Compare commits

..

9 Commits

44 changed files with 180 additions and 1272 deletions
Generated
+2 -2
View File
@@ -1413,8 +1413,8 @@ CLAUDE.md @home-assistant/core
/tests/components/pushover/ @engrbm87
/homeassistant/components/pvoutput/ @frenck
/tests/components/pvoutput/ @frenck
/homeassistant/components/pvpc_hourly_pricing/ @azogue
/tests/components/pvpc_hourly_pricing/ @azogue
/homeassistant/components/pvpc_hourly_pricing/ @azogue @chiro79
/tests/components/pvpc_hourly_pricing/ @azogue @chiro79
/homeassistant/components/pyload/ @tr4nt0r
/tests/components/pyload/ @tr4nt0r
/homeassistant/components/qbittorrent/ @geoffreylagaisse @finder39
@@ -173,7 +173,6 @@ class BlinkConfigFlow(ConfigFlow, domain=DOMAIN):
errors["base"] = "unknown"
config_entry = self._get_reconfigure_entry()
# pylint: disable-next=home-assistant-config-flow-field-not-translated
return self.async_show_form(
step_id="reconfigure",
data_schema=vol.Schema(
@@ -1,11 +1,18 @@
"""Provide functionality to keep track of devices."""
import asyncio
from typing import Any
from homeassistant.const import ATTR_GPS_ACCURACY, STATE_HOME # noqa: F401
from homeassistant.core import HomeAssistant
from homeassistant.helpers import discovery
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.typing import ConfigType
from .config_entry import ( # noqa: F401
DATA_COMPONENT,
BaseScannerEntity,
BaseTrackerEntity,
ScannerEntity,
ScannerEntityDescription,
TrackerEntity,
@@ -33,6 +40,8 @@ from .const import ( # noqa: F401
DEFAULT_TRACK_NEW,
DOMAIN,
ENTITY_ID_FORMAT,
LOGGER,
PLATFORM_TYPE_LEGACY,
SCAN_INTERVAL,
SourceType,
)
@@ -44,7 +53,9 @@ from .legacy import ( # noqa: F401
SOURCE_TYPES,
AsyncSeeCallback,
DeviceScanner,
DeviceTracker,
SeeCallback,
async_create_platform_type,
async_setup_integration as async_setup_legacy_integration,
see,
)
@@ -57,5 +68,43 @@ def is_on(hass: HomeAssistant, entity_id: str) -> bool:
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the device tracker."""
async_setup_legacy_integration(hass, config)
component = hass.data[DATA_COMPONENT] = EntityComponent[BaseTrackerEntity](
LOGGER, DOMAIN, hass, SCAN_INTERVAL
)
component.config = {}
component.register_shutdown()
# The tracker is loaded in the async_setup_legacy_integration task so
# we create a future to avoid waiting on it here so that only
# async_platform_discovered will have to wait in the rare event
# a custom component still uses the legacy device tracker discovery.
tracker_future: asyncio.Future[DeviceTracker] = hass.loop.create_future()
async def async_platform_discovered(
p_type: str, info: dict[str, Any] | None
) -> None:
"""Load a platform."""
platform = await async_create_platform_type(hass, config, p_type, {})
if platform is None:
return
if platform.type != PLATFORM_TYPE_LEGACY:
await component.async_setup_platform(p_type, {}, info)
return
tracker = await tracker_future
await platform.async_setup_legacy(hass, tracker, info)
discovery.async_listen_platform(hass, DOMAIN, async_platform_discovered)
#
# Legacy and platforms load in a non-awaited tracked task
# to ensure device tracker setup can continue and config
# entry integrations are not waiting for legacy device
# tracker platforms to be set up.
#
hass.async_create_task(
async_setup_legacy_integration(hass, config, tracker_future),
eager_start=True,
)
return True
@@ -37,11 +37,7 @@ from homeassistant.const import (
)
from homeassistant.core import Event, HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import (
config_validation as cv,
discovery,
entity_registry as er,
)
from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.event import (
async_track_time_interval,
async_track_utc_time_change,
@@ -204,40 +200,7 @@ def see(
hass.services.call(DOMAIN, SERVICE_SEE, data)
@callback
def async_setup_integration(hass: HomeAssistant, config: ConfigType) -> None:
"""Set up the legacy integration."""
# The tracker is loaded in the _async_setup_integration task so
# we create a future to avoid waiting on it here so that only
# async_platform_discovered will have to wait in the rare event
# a custom component still uses the legacy device tracker discovery.
tracker_future: asyncio.Future[DeviceTracker] = hass.loop.create_future()
async def async_platform_discovered(
p_type: str, info: dict[str, Any] | None
) -> None:
"""Load a platform."""
platform = await async_create_platform_type(hass, config, p_type, {})
if platform is None or platform.type != PLATFORM_TYPE_LEGACY:
return
tracker = await tracker_future
await platform.async_setup_legacy(hass, tracker, info)
discovery.async_listen_platform(hass, DOMAIN, async_platform_discovered)
#
# Legacy and platforms load in a non-awaited tracked task
# to ensure device tracker setup can continue and config
# entry integrations are not waiting for legacy device
# tracker platforms to be set up.
#
hass.async_create_task(
_async_setup_integration(hass, config, tracker_future), eager_start=True
)
async def _async_setup_integration(
async def async_setup_integration(
hass: HomeAssistant,
config: ConfigType,
tracker_future: asyncio.Future[DeviceTracker],
@@ -31,7 +31,6 @@ class DownloaderConfigFlow(ConfigFlow, domain=DOMAIN):
else:
return self.async_create_entry(title=DEFAULT_NAME, data=user_input)
# pylint: disable-next=home-assistant-config-flow-field-not-translated
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
@@ -52,5 +52,4 @@ class EGPSConfigFlow(ConfigFlow, domain=DOMAIN):
else:
return self.async_abort(reason="no_device")
# pylint: disable-next=home-assistant-config-flow-field-not-translated
return self.async_show_form(step_id="user", data_schema=vol.Schema(data_schema))
@@ -277,7 +277,6 @@ class FluxLedConfigFlow(ConfigFlow, domain=DOMAIN):
# Check if there is at least one device
if not devices_name:
return self.async_abort(reason="no_devices_found")
# pylint: disable-next=home-assistant-config-flow-field-not-translated
return self.async_show_form(
step_id="pick_device",
data_schema=vol.Schema({vol.Required(CONF_DEVICE): vol.In(devices_name)}),
@@ -125,7 +125,6 @@ class Gogogate2FlowHandler(ConfigFlow, domain=DOMAIN):
CONF_DEVICE: DEVICE_NAMES[self._device_type],
CONF_IP_ADDRESS: self._ip_address,
}
# pylint: disable-next=home-assistant-config-flow-field-not-translated
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
@@ -140,7 +140,6 @@ class HueFlowHandler(ConfigFlow, domain=DOMAIN):
if not self.discovered_bridges:
return await self.async_step_manual()
# pylint: disable-next=home-assistant-config-flow-field-not-translated
return self.async_show_form(
step_id="init",
data_schema=vol.Schema(
@@ -12,6 +12,6 @@
"documentation": "https://www.home-assistant.io/integrations/indevolt",
"integration_type": "device",
"iot_class": "local_polling",
"quality_scale": "silver",
"quality_scale": "platinum",
"requirements": ["indevolt-api==1.8.1"]
}
@@ -75,7 +75,6 @@ class IslamicPrayerFlowHandler(ConfigFlow, domain=DOMAIN):
CONF_LATITUDE: self.hass.config.latitude,
CONF_LONGITUDE: self.hass.config.longitude,
}
# pylint: disable-next=home-assistant-config-flow-field-not-translated
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
@@ -100,7 +100,6 @@ class LaCrosseViewConfigFlow(ConfigFlow, domain=DOMAIN):
if not user_input:
_LOGGER.debug("Showing initial location selection")
# pylint: disable-next=home-assistant-config-flow-field-not-translated
return self.async_show_form(
step_id="location",
data_schema=vol.Schema(
@@ -75,7 +75,6 @@ class LookinFlowHandler(ConfigFlow, domain=DOMAIN):
data={CONF_HOST: host},
)
# pylint: disable-next=home-assistant-config-flow-field-not-translated
return self.async_show_form(
step_id="user",
data_schema=vol.Schema({vol.Required(CONF_HOST): str}),
@@ -77,7 +77,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: LunatoneConfigEntry) ->
await coordinator_info.async_config_entry_first_refresh()
if info_api.data is None or info_api.serial_number is None:
# pylint: disable-next=home-assistant-exception-translation-key-missing
raise ConfigEntryError(
translation_domain=DOMAIN, translation_key="missing_device_info"
)
+2 -1
View File
@@ -207,7 +207,6 @@ class LunatoneLight(
await self.coordinator.async_refresh()
# pylint: disable-next=home-assistant-missing-has-entity-name
class LunatoneLineBroadcastLight(
CoordinatorEntity[LunatoneInfoDataUpdateCoordinator], LightEntity
):
@@ -217,6 +216,8 @@ class LunatoneLineBroadcastLight(
_attr_assumed_state = True
_attr_color_mode = ColorMode.BRIGHTNESS
_attr_has_entity_name = True
_attr_name = None
_attr_supported_color_modes = {ColorMode.BRIGHTNESS}
def __init__(
@@ -35,5 +35,10 @@
"description": "Enter the URL of your Lunatone device.\nHome Assistant will use this address to connect to the device API."
}
}
},
"exceptions": {
"missing_device_info": {
"message": "Unable to read device information. Please verify the device's network connection."
}
}
}
@@ -98,7 +98,6 @@ class MelnorConfigFlow(ConfigFlow, domain=DOMAIN):
if not addresses:
return self.async_abort(reason="no_devices_found")
# pylint: disable-next=home-assistant-config-flow-field-not-translated
return self.async_show_form(
step_id="pick_device",
data_schema=vol.Schema({vol.Required(CONF_ADDRESS): vol.In(addresses)}),
@@ -11,7 +11,7 @@ from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
)
from homeassistant.const import CONF_NAME
from homeassistant.const import CONF_COUNTRY, CONF_LANGUAGE, CONF_NAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
@@ -22,10 +22,6 @@ _LOGGER = logging.getLogger(__name__)
ATTRIBUTION = "Information provided by MeteoAlarm"
# pylint: disable-next=home-assistant-duplicate-const
CONF_COUNTRY = "country"
# pylint: disable-next=home-assistant-duplicate-const
CONF_LANGUAGE = "language"
CONF_PROVINCE = "province"
DEFAULT_NAME = "meteoalarm"
@@ -24,7 +24,6 @@ class MeteoclimaticFlowHandler(ConfigFlow, domain=DOMAIN):
if user_input is None:
user_input = {}
# pylint: disable-next=home-assistant-config-flow-field-not-translated
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
@@ -117,7 +117,6 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
},
)
# pylint: disable-next=home-assistant-config-flow-field-not-translated
return self.async_show_form(
step_id="confirm",
data_schema=vol.Schema(
@@ -23,7 +23,7 @@ from music_assistant_models.errors import (
from music_assistant_models.player import Player
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
from homeassistant.const import CONF_URL, EVENT_HOMEASSISTANT_STOP, Platform
from homeassistant.const import CONF_TOKEN, CONF_URL, EVENT_HOMEASSISTANT_STOP, Platform
from homeassistant.core import Event, HomeAssistant
from homeassistant.exceptions import (
ConfigEntryAuthFailed,
@@ -38,7 +38,7 @@ from homeassistant.helpers.issue_registry import (
async_delete_issue,
)
from .const import ATTR_CONF_EXPOSE_PLAYER_TO_HA, CONF_TOKEN, DOMAIN, LOGGER
from .const import ATTR_CONF_EXPOSE_PLAYER_TO_HA, DOMAIN, LOGGER
from .helpers import get_music_assistant_client
from .services import register_actions
@@ -21,7 +21,7 @@ from homeassistant.config_entries import (
ConfigFlow,
ConfigFlowResult,
)
from homeassistant.const import CONF_URL
from homeassistant.const import CONF_TOKEN, CONF_URL
from homeassistant.core import HomeAssistant
from homeassistant.helpers import aiohttp_client
from homeassistant.helpers.config_entry_oauth2_flow import (
@@ -31,13 +31,7 @@ from homeassistant.helpers.config_entry_oauth2_flow import (
from homeassistant.helpers.service_info.hassio import HassioServiceInfo
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import (
AUTH_SCHEMA_VERSION,
CONF_TOKEN,
DOMAIN,
HASSIO_DISCOVERY_SCHEMA_VERSION,
LOGGER,
)
from .const import AUTH_SCHEMA_VERSION, DOMAIN, HASSIO_DISCOVERY_SCHEMA_VERSION, LOGGER
DEFAULT_TITLE = "Music Assistant"
DEFAULT_URL = "http://mass.local:8095"
@@ -12,9 +12,6 @@ AUTH_SCHEMA_VERSION = 28
# Schema version where hassio discovery support was added
HASSIO_DISCOVERY_SCHEMA_VERSION = 28
# pylint: disable-next=home-assistant-duplicate-const
CONF_TOKEN = "token"
ATTR_IS_GROUP = "is_group"
ATTR_GROUP_MEMBERS = "group_members"
ATTR_GROUP_PARENTS = "group_parents"
@@ -206,7 +206,6 @@ class PlaatoOptionsFlowHandler(OptionsFlow):
if user_input is not None:
return self.async_create_entry(title="", data=user_input)
# pylint: disable-next=home-assistant-options-flow-field-not-translated
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
@@ -3,7 +3,7 @@
from collections.abc import Mapping
from typing import Any
from aiopvpc import DEFAULT_POWER_KW, PVPCData
from esios_api import DEFAULT_POWER_KW, PVPCData
import voluptuous as vol
from homeassistant.config_entries import (
@@ -63,9 +63,10 @@ class TariffSelectorConfigFlow(ConfigFlow, domain=DOMAIN):
if user_input is not None:
await self.async_set_unique_id(user_input[ATTR_TARIFF])
self._abort_if_unique_id_configured()
calc_name = f"{DEFAULT_NAME} - {user_input[ATTR_TARIFF]}"
if not user_input[CONF_USE_API_TOKEN]:
return self.async_create_entry(
title=DEFAULT_NAME,
title=calc_name,
data={
ATTR_TARIFF: user_input[ATTR_TARIFF],
ATTR_POWER: user_input[ATTR_POWER],
@@ -74,7 +75,7 @@ class TariffSelectorConfigFlow(ConfigFlow, domain=DOMAIN):
},
)
self._name = DEFAULT_NAME
self._name = calc_name
self._tariff = user_input[ATTR_TARIFF]
self._power = user_input[ATTR_POWER]
self._power_p3 = user_input[ATTR_POWER_P3]
@@ -150,7 +151,7 @@ class TariffSelectorConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle re-authentication with ESIOS Token."""
self._api_token = entry_data.get(CONF_API_TOKEN)
self._use_api_token = self._api_token is not None
self._name = DEFAULT_NAME
self._name = f"{DEFAULT_NAME} - {entry_data[ATTR_TARIFF]}"
self._tariff = entry_data[ATTR_TARIFF]
self._power = entry_data[ATTR_POWER]
self._power_p3 = entry_data[ATTR_POWER_P3]
@@ -1,6 +1,6 @@
"""Constant values for pvpc_hourly_pricing."""
from aiopvpc.const import TARIFFS
from esios_api.const import TARIFFS
import voluptuous as vol
DOMAIN = "pvpc_hourly_pricing"
@@ -3,7 +3,7 @@
from datetime import timedelta
import logging
from aiopvpc import BadApiTokenAuthError, EsiosApiData, PVPCData
from esios_api import BadApiTokenAuthError, EsiosApiData, PVPCData
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_TOKEN
@@ -1,6 +1,6 @@
"""Helper functions to relate sensors keys and unique ids."""
from aiopvpc.const import (
from esios_api.const import (
ALL_SENSORS,
KEY_INJECTION,
KEY_MAG,
@@ -1,11 +1,11 @@
{
"domain": "pvpc_hourly_pricing",
"name": "Spain electricity hourly pricing (PVPC)",
"codeowners": ["@azogue"],
"codeowners": ["@azogue", "@chiro79"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/pvpc_hourly_pricing",
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["aiopvpc"],
"requirements": ["aiopvpc==4.3.1"]
"loggers": ["esios_api"],
"requirements": ["esios_api==4.4.0"]
}
@@ -5,7 +5,7 @@ from datetime import datetime
import logging
from typing import Any
from aiopvpc.const import KEY_INJECTION, KEY_MAG, KEY_OMIE, KEY_PVPC
from esios_api.const import KEY_INJECTION, KEY_MAG, KEY_OMIE, KEY_PVPC
from homeassistant.components.sensor import (
SensorEntity,
@@ -64,7 +64,6 @@ class SimpleFinConfigFlow(ConfigFlow, domain=DOMAIN):
data={CONF_ACCESS_URL: user_input[CONF_ACCESS_URL]},
)
# pylint: disable-next=home-assistant-config-flow-field-not-translated
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
@@ -66,7 +66,6 @@ class SmhiFlowHandler(ConfigFlow, domain=DOMAIN):
CONF_LATITUDE: self.hass.config.latitude,
CONF_LONGITUDE: self.hass.config.longitude,
}
# pylint: disable-next=home-assistant-config-flow-field-not-translated
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
@@ -110,7 +110,6 @@ class SnoozConfigFlow(ConfigFlow, domain=DOMAIN):
if not self._discovered_devices:
return self.async_abort(reason="no_devices_found")
# pylint: disable-next=home-assistant-config-flow-field-not-translated
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
+1 -1
View File
@@ -191,11 +191,11 @@ class SonosAudioInputFormatSensorEntity(SonosPollingEntity, SensorEntity):
"""Provide a stub for required ABC method."""
# pylint: disable-next=home-assistant-missing-has-entity-name
class SonosFavoritesEntity(SensorEntity):
"""Representation of a Sonos favorites info entity."""
_attr_entity_registry_enabled_default = False
_attr_has_entity_name = True
_attr_name = "Sonos favorites"
_attr_translation_key = "favorites"
_attr_native_unit_of_measurement = "items"
@@ -38,12 +38,12 @@ async def async_setup_entry(
)
# pylint: disable-next=home-assistant-missing-has-entity-name
class OmadaClientScannerEntity(
CoordinatorEntity[OmadaClientsCoordinator], ScannerEntity
):
"""Entity for a client connected to the Omada network."""
_attr_has_entity_name = True
_client_details: OmadaWirelessClient | None = None
def __init__(
@@ -75,7 +75,6 @@ class TuyaConfigFlow(ConfigFlow, domain=DOMAIN):
) -> ConfigFlowResult:
"""Step scan."""
if user_input is None:
# pylint: disable-next=home-assistant-config-flow-field-not-translated
return self.async_show_form(
step_id="scan",
data_schema=vol.Schema(
@@ -100,7 +99,6 @@ class TuyaConfigFlow(ConfigFlow, domain=DOMAIN):
if not ret:
# Try to get a new QR code on failure
await self.__async_get_qr_code(self.__user_code)
# pylint: disable-next=home-assistant-config-flow-field-not-translated
return self.async_show_form(
step_id="scan",
errors={"base": "login_error"},
@@ -1,439 +0,0 @@
"""Checker for missing config/options/subentry flow form translations.
When a flow calls ``async_show_form`` with a ``data_schema``, every
field in the schema needs a corresponding translation entry in
``strings.json``.
The expected translation paths are::
config.step.{step_id}.data.{field_name}
config.step.{step_id}.sections.{section_key}.data.{field_name}
options.step.{step_id}.data.{field_name}
options.step.{step_id}.sections.{section_key}.data.{field_name}
config_subentries.{subentry_type}.step.{step_id}.data.{field_name}
- ``W7420``: Missing config flow field translation
- ``W7421``: Missing options flow field translation
- ``W7422``: Missing subentry flow field translation
"""
import astroid
from astroid import nodes
from pylint.checkers import BaseChecker
from pylint.lint import PyLinter
from pylint_home_assistant.helpers.module_info import get_module_platform
from pylint_home_assistant.helpers.translations import load_translations
_InferenceError = astroid.exceptions.InferenceError
def _extract_step_id(call: nodes.Call) -> str | None:
"""Extract step_id from an async_show_form call.
Falls back to the enclosing method name if step_id is omitted
(e.g., ``async_step_user`` -> ``"user"``).
"""
for kw in call.keywords:
if kw.arg == "step_id":
if isinstance(kw.value, nodes.Const):
return str(kw.value.value)
try:
for inferred in kw.value.infer():
if isinstance(inferred, nodes.Const) and isinstance(
inferred.value, str
):
return str(inferred.value)
except _InferenceError:
pass
return None
# No step_id keyword: infer from enclosing async_step_* method
current = call.parent
while current is not None:
if isinstance(current, nodes.FunctionDef):
if current.name.startswith("async_step_"):
return str(current.name).removeprefix("async_step_")
break
current = current.parent
return None
# Result types for schema extraction
class _Field:
"""A regular form field."""
__slots__ = ("name",)
def __init__(self, name: str) -> None:
self.name = name
class _Section:
"""A section containing nested fields."""
__slots__ = ("fields", "key")
def __init__(self, key: str, fields: list[str]) -> None:
self.key = key
self.fields = fields
def _extract_schema_items(call: nodes.Call) -> list[_Field | _Section]:
"""Extract fields and sections from the data_schema keyword argument."""
schema_node = None
for kw in call.keywords:
if kw.arg == "data_schema":
schema_node = kw.value
break
if schema_node is None:
return []
return _extract_items_from_node(schema_node)
def _extract_items_from_node(
node: nodes.NodeNG,
) -> list[_Field | _Section]:
"""Recursively extract fields and sections from a schema node."""
items: list[_Field | _Section] = []
# vol.Schema({...}) or similar call wrapping a dict
if isinstance(node, nodes.Call) and node.args:
return _extract_items_from_node(node.args[0])
# Direct dict literal
if isinstance(node, nodes.Dict):
for key, value in node.items:
if isinstance(key, nodes.DictUnpack):
try:
for inferred in value.infer():
if isinstance(inferred, nodes.Dict):
items.extend(_extract_items_from_node(inferred))
except _InferenceError:
pass
continue
name = _resolve_field_name(key)
if name is None:
continue
# Check if the value is a section(...) call
section_fields = _extract_section_fields(value)
if section_fields is not None:
items.append(_Section(name, section_fields))
else:
items.append(_Field(name))
return items
# Variable reference: try to infer to a Call or Dict
try:
for inferred in node.infer():
if isinstance(inferred, (nodes.Call, nodes.Dict)):
return _extract_items_from_node(inferred)
except _InferenceError:
pass
return items
def _extract_section_fields(node: nodes.NodeNG) -> list[str] | None:
"""Extract field names from a section(...) call.
Returns a list of field names if the node is a section() call,
or None if it's not a section.
"""
if not isinstance(node, nodes.Call):
return None
# Match section(...) or data_entry_flow.section(...)
if isinstance(node.func, nodes.Name):
if node.func.name != "section":
return None
elif isinstance(node.func, nodes.Attribute):
if node.func.attrname != "section":
return None
else:
return None
if not node.args:
return None
# section(vol.Schema({...}), ...) - first arg is the schema
inner_items = _extract_items_from_node(node.args[0])
return [item.name for item in inner_items if isinstance(item, _Field)]
def _resolve_field_name(node: nodes.NodeNG) -> str | None:
"""Resolve a schema key to a field name string."""
if isinstance(node, nodes.Call) and isinstance(node.func, nodes.Attribute):
if node.func.attrname in ("Required", "Optional") and node.args:
return _resolve_field_name(node.args[0])
if isinstance(node, nodes.Const) and isinstance(node.value, str):
return node.value
try:
for inferred in node.infer():
if isinstance(inferred, nodes.Const) and isinstance(inferred.value, str):
return inferred.value
except _InferenceError:
pass
return None
def _find_enclosing_class(node: nodes.Call) -> nodes.ClassDef | None:
"""Find the enclosing class definition for a call node."""
current = node.parent
while current is not None:
if isinstance(current, nodes.ClassDef):
return current
current = current.parent
return None
def _get_flow_type(class_node: nodes.ClassDef) -> str | None:
"""Determine flow type from a class definition."""
for base in class_node.bases:
match base:
case nodes.Name(name=name):
if "SubentryFlow" in name:
return "subentry"
if "OptionsFlow" in name:
return "options"
if "ConfigFlow" in name or "FlowHandler" in name:
return "config"
case nodes.Attribute(attrname=name):
if "SubentryFlow" in name:
return "subentry"
if "OptionsFlow" in name:
return "options"
if "ConfigFlow" in name or "FlowHandler" in name:
return "config"
try:
for ancestor in class_node.ancestors():
if "SubentryFlow" in ancestor.name:
return "subentry"
if "OptionsFlow" in ancestor.name:
return "options"
if "ConfigFlow" in ancestor.name:
return "config"
except _InferenceError:
pass
return None
def _resolve_subentry_types(module: nodes.Module, handler_class_name: str) -> list[str]:
"""Resolve subentry type names for a handler class."""
subentry_types: list[str] = []
for node in module.body:
if not isinstance(node, nodes.ClassDef):
continue
if _get_flow_type(node) != "config":
continue
for method in node.mymethods():
if method.name != "async_get_supported_subentry_types":
continue
for child in method.body:
if not isinstance(child, nodes.Return):
continue
if not isinstance(child.value, nodes.Dict):
continue
for key, value in child.value.items:
if isinstance(key, nodes.DictUnpack):
continue
key_str = _resolve_field_name(key)
if key_str and isinstance(value, nodes.Name):
if value.name == handler_class_name:
subentry_types.append(key_str)
return subentry_types
class ConfigFlowTranslationsChecker(BaseChecker):
"""Checker for missing flow form translations."""
name = "home_assistant_config_flow_translations"
priority = -1
msgs = {
"W7420": (
"Form field '%s' in step '%s' is missing a translation in "
"strings.json (expected at %s)",
"home-assistant-config-flow-field-not-translated",
"Used when a config flow form field does not have a "
"corresponding translation in strings.json.",
),
"W7421": (
"Form field '%s' in step '%s' is missing a translation in "
"strings.json (expected at %s)",
"home-assistant-options-flow-field-not-translated",
"Used when an options flow form field does not have a "
"corresponding translation in strings.json.",
),
"W7422": (
"Form field '%s' in step '%s' is missing a translation in "
"strings.json (expected at %s)",
"home-assistant-subentry-flow-field-not-translated",
"Used when a subentry flow form field does not have a "
"corresponding translation in strings.json.",
),
}
options = ()
_translations: dict | None
_is_config_flow: bool
_module_node: nodes.Module | None
def visit_module(self, node: nodes.Module) -> None:
"""Load translations for config_flow modules."""
platform = get_module_platform(node.name)
self._is_config_flow = platform == "config_flow"
self._translations = None
self._module_node = None
if self._is_config_flow:
self._translations = load_translations(node)
self._module_node = node
def visit_call(self, node: nodes.Call) -> None:
"""Check async_show_form calls for translated fields."""
if not self._is_config_flow or self._translations is None:
return
if not isinstance(node.func, nodes.Attribute):
return
if node.func.attrname != "async_show_form":
return
step_id = _extract_step_id(node)
if step_id is None:
return
schema_items = _extract_schema_items(node)
if not schema_items:
return
class_node = _find_enclosing_class(node)
if class_node is None:
return
flow_type = _get_flow_type(class_node) or "config"
if flow_type == "config":
self._check_flow(
node,
step_id,
schema_items,
"config",
"home-assistant-config-flow-field-not-translated",
)
elif flow_type == "options":
self._check_flow(
node,
step_id,
schema_items,
"options",
"home-assistant-options-flow-field-not-translated",
)
elif flow_type == "subentry":
self._check_subentry_flow(node, step_id, schema_items, class_node)
def _check_flow(
self,
node: nodes.Call,
step_id: str,
schema_items: list[_Field | _Section],
flow_key: str,
msg_id: str,
) -> None:
"""Check fields against translations for config/options flows."""
assert self._translations is not None
step_trans = (
self._translations.get(flow_key, {}).get("step", {}).get(step_id, {})
)
data_trans = step_trans.get("data", {})
sections_trans = step_trans.get("sections", {})
for item in schema_items:
if isinstance(item, _Field):
if item.name not in data_trans:
path = f"{flow_key}.step.{step_id}.data.{item.name}"
self.add_message(
msg_id,
node=node,
args=(item.name, step_id, path),
)
elif isinstance(item, _Section):
section_data = sections_trans.get(item.key, {}).get("data", {})
for field in item.fields:
if field not in section_data:
path = f"{flow_key}.step.{step_id}.sections.{item.key}.data.{field}"
self.add_message(
msg_id,
node=node,
args=(field, step_id, path),
)
def _check_subentry_flow(
self,
node: nodes.Call,
step_id: str,
schema_items: list[_Field | _Section],
class_node: nodes.ClassDef,
) -> None:
"""Check subentry flow fields against translations."""
if self._module_node is None:
return
subentry_types = _resolve_subentry_types(self._module_node, class_node.name)
if not subentry_types:
return
assert self._translations is not None
config_subentries = self._translations.get("config_subentries", {})
sub0 = subentry_types[0]
for item in schema_items:
if isinstance(item, _Section):
for field in item.fields:
if not any(
field
in config_subentries.get(st, {})
.get("step", {})
.get(step_id, {})
.get("sections", {})
.get(item.key, {})
.get("data", {})
for st in subentry_types
):
path = f"config_subentries.{sub0}.step.{step_id}.sections.{item.key}.data.{field}"
self.add_message(
"home-assistant-subentry-flow-field-not-translated",
node=node,
args=(field, step_id, path),
)
elif isinstance(item, _Field):
if not any(
item.name
in config_subentries.get(st, {})
.get("step", {})
.get(step_id, {})
.get("data", {})
for st in subentry_types
):
path = f"config_subentries.{sub0}.step.{step_id}.data.{item.name}"
self.add_message(
"home-assistant-subentry-flow-field-not-translated",
node=node,
args=(item.name, step_id, path),
)
def register(linter: PyLinter) -> None:
"""Register the checker."""
linter.register_checker(ConfigFlowTranslationsChecker(linter))
+3 -3
View File
@@ -374,9 +374,6 @@ aiopurpleair==2025.08.1
# homeassistant.components.hunterdouglas_powerview
aiopvapi==3.3.0
# homeassistant.components.pvpc_hourly_pricing
aiopvpc==4.3.1
# homeassistant.components.lidarr
# homeassistant.components.radarr
# homeassistant.components.sonarr
@@ -951,6 +948,9 @@ epson-projector==0.6.0
# homeassistant.components.eq3btsmart
eq3btsmart==2.3.0
# homeassistant.components.pvpc_hourly_pricing
esios_api==4.4.0
# homeassistant.components.esphome
esphome-dashboard-api==1.3.0
View File
-1
View File
@@ -203,7 +203,6 @@ FORBIDDEN_PACKAGE_EXCEPTIONS: dict[str, dict[str, set[str]]] = {
"opengarage": {"open-garage": {"async-timeout"}},
"overkiz": {"pyoverkiz": {"backoff"}},
"prosegur": {"pyprosegur": {"backoff"}},
"pvpc_hourly_pricing": {"aiopvpc": {"async-timeout"}},
"radio_browser": {"radios": {"backoff"}},
"remote_rpi_gpio": {
# https://github.com/waveform80/colorzero/issues/9
+93 -1
View File
@@ -8,7 +8,12 @@ from unittest.mock import call, patch
import pytest
from homeassistant.components import device_tracker, zone
from homeassistant.components.device_tracker import SourceType, const, legacy
from homeassistant.components.device_tracker import (
SourceType,
TrackerEntity,
const,
legacy,
)
from homeassistant.const import (
ATTR_ENTITY_PICTURE,
ATTR_FRIENDLY_NAME,
@@ -19,11 +24,15 @@ from homeassistant.const import (
CONF_PLATFORM,
STATE_HOME,
STATE_NOT_HOME,
STATE_UNKNOWN,
)
from homeassistant.core import HomeAssistant, State, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import discovery
from homeassistant.helpers.discovery import DiscoveryInfoType
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.json import JSONEncoder
from homeassistant.helpers.typing import ConfigType
from homeassistant.setup import async_setup_component
from homeassistant.util import dt as dt_util
@@ -31,9 +40,13 @@ from . import common
from .common import MockScanner, mock_legacy_device_tracker_setup
from tests.common import (
MockModule,
MockPlatform,
RegistryEntryWithDefaults,
assert_setup_component,
async_fire_time_changed,
mock_integration,
mock_platform,
mock_registry,
mock_restore_cache,
patch_yaml_files,
@@ -729,3 +742,82 @@ def test_see_schema_allowing_ios_calls() -> None:
"hostname": "beer",
}
)
async def test_modern_platform_setup(hass: HomeAssistant) -> None:
"""Test modern platform setup."""
test_domain = "test"
entity1 = TrackerEntity()
entity1.entity_id = "device_tracker.test1"
entity1._attr_source_type = SourceType.ROUTER
entity2 = TrackerEntity()
entity2.entity_id = "device_tracker.test2"
entity2._attr_location_name = "home"
entity2._attr_location_accuracy = 1
entity2._attr_latitude = 10.0
entity2._attr_longitude = 5.0
entity2._attr_source_type = SourceType.GPS
entity3 = TrackerEntity()
entity3.entity_id = "device_tracker.test3"
entity3._attr_location_name = "not_home"
entity3._attr_source_type = SourceType.ROUTER
async def async_setup_platform(
hass: HomeAssistant,
config: ConfigType,
async_add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> bool:
async_add_entities([entity1, entity2, entity3])
return True
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
hass.async_create_task(
discovery.async_load_platform(
hass, "device_tracker", test_domain, {}, config
)
)
return True
mock_integration(
hass,
MockModule(test_domain, async_setup=async_setup),
)
mock_platform(
hass,
f"{test_domain}.device_tracker",
MockPlatform(async_setup_platform=async_setup_platform),
)
await async_setup_component(hass, "homeassistant", {})
await async_setup_component(hass, "device_tracker", {})
await async_setup_component(hass, test_domain, {})
await hass.async_block_till_done()
state = hass.states.get(entity1.entity_id)
assert state
assert state.state == STATE_UNKNOWN
assert state.attributes == {"in_zones": [], "source_type": SourceType.ROUTER}
state = hass.states.get(entity2.entity_id)
assert state
assert state.state == STATE_HOME
assert state.attributes == {
"in_zones": [],
"source_type": SourceType.GPS,
"latitude": 10.0,
"longitude": 5.0,
"gps_accuracy": 1,
}
state = hass.states.get(entity3.entity_id)
assert state
assert state.state == STATE_NOT_HOME
assert state.attributes == {
"in_zones": [],
"source_type": SourceType.ROUTER,
}
@@ -18,7 +18,7 @@
'domain': 'light',
'entity_category': None,
'entity_id': 'light.dali_line_0',
'has_entity_name': False,
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
@@ -79,7 +79,7 @@
'domain': 'light',
'entity_category': None,
'entity_id': 'light.dali_line_1',
'has_entity_name': False,
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
@@ -22,7 +22,6 @@ from homeassistant.components.music_assistant.config_flow import (
)
from homeassistant.components.music_assistant.const import (
AUTH_SCHEMA_VERSION,
CONF_TOKEN,
DEFAULT_NAME,
DOMAIN,
)
@@ -34,6 +33,7 @@ from homeassistant.config_entries import (
SOURCE_ZEROCONF,
ConfigEntryState,
)
from homeassistant.const import CONF_TOKEN
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers.service_info.hassio import HassioServiceInfo
-731
View File
@@ -1,731 +0,0 @@
"""Tests for the config flow translations checker."""
import json
from pathlib import Path
import astroid
from pylint.testutils import UnittestLinter
from pylint.utils.ast_walker import ASTWalker
from pylint_home_assistant.checkers.flow_translations import (
ConfigFlowTranslationsChecker,
)
from pylint_home_assistant.helpers.translations import clear_translations_cache
import pytest
from . import assert_no_messages
@pytest.fixture(name="flow_translations_checker")
def flow_translations_checker_fixture(
linter: UnittestLinter,
) -> ConfigFlowTranslationsChecker:
"""Fixture to provide a config flow translations checker."""
clear_translations_cache()
return ConfigFlowTranslationsChecker(linter)
def _make_integration(tmp_path: Path, strings: dict | None = None) -> Path:
"""Create a fake integration with optional strings.json."""
integration_dir = tmp_path / "homeassistant" / "components" / "test_int"
integration_dir.mkdir(parents=True)
if strings is not None:
(integration_dir / "strings.json").write_text(json.dumps(strings))
return integration_dir
# --- Config flow tests ---
def test_config_flow_translated_ok(
linter: UnittestLinter,
flow_translations_checker: ConfigFlowTranslationsChecker,
tmp_path: Path,
) -> None:
"""No warning when all config flow fields have translations."""
integration_dir = _make_integration(
tmp_path,
{"config": {"step": {"user": {"data": {"host": "Host", "port": "Port"}}}}},
)
root_node = astroid.parse(
"""
class MyConfigFlow(ConfigFlow, domain=DOMAIN):
async def async_step_user(self, user_input=None):
return self.async_show_form(
step_id="user",
data_schema=vol.Schema({
vol.Required("host"): str,
vol.Optional("port"): int,
}),
)
""",
"homeassistant.components.test_int.config_flow",
)
root_node.file = str(integration_dir / "config_flow.py")
walker = ASTWalker(linter)
walker.add_checker(flow_translations_checker)
with assert_no_messages(linter):
walker.walk(root_node)
def test_config_flow_missing_field_flagged(
linter: UnittestLinter,
flow_translations_checker: ConfigFlowTranslationsChecker,
tmp_path: Path,
) -> None:
"""Warning when a config flow field is missing a translation."""
integration_dir = _make_integration(
tmp_path,
{"config": {"step": {"user": {"data": {"host": "Host"}}}}},
)
root_node = astroid.parse(
"""
class MyConfigFlow(ConfigFlow, domain=DOMAIN):
async def async_step_user(self, user_input=None):
return self.async_show_form(
step_id="user",
data_schema=vol.Schema({
vol.Required("host"): str,
vol.Required("missing_field"): str,
}),
)
""",
"homeassistant.components.test_int.config_flow",
)
root_node.file = str(integration_dir / "config_flow.py")
walker = ASTWalker(linter)
walker.add_checker(flow_translations_checker)
walker.walk(root_node)
messages = linter.release_messages()
assert len(messages) == 1
assert messages[0].msg_id == "home-assistant-config-flow-field-not-translated"
assert "missing_field" in messages[0].args[0]
# --- Options flow tests ---
def test_options_flow_translated_ok(
linter: UnittestLinter,
flow_translations_checker: ConfigFlowTranslationsChecker,
tmp_path: Path,
) -> None:
"""No warning when options flow fields have translations."""
integration_dir = _make_integration(
tmp_path,
{"options": {"step": {"init": {"data": {"interval": "Update interval"}}}}},
)
root_node = astroid.parse(
"""
class MyOptionsFlow(OptionsFlow):
async def async_step_init(self, user_input=None):
return self.async_show_form(
step_id="init",
data_schema=vol.Schema({
vol.Required("interval"): int,
}),
)
""",
"homeassistant.components.test_int.config_flow",
)
root_node.file = str(integration_dir / "config_flow.py")
walker = ASTWalker(linter)
walker.add_checker(flow_translations_checker)
with assert_no_messages(linter):
walker.walk(root_node)
def test_options_flow_missing_field_flagged(
linter: UnittestLinter,
flow_translations_checker: ConfigFlowTranslationsChecker,
tmp_path: Path,
) -> None:
"""Warning when an options flow field is missing a translation."""
integration_dir = _make_integration(
tmp_path,
{"options": {"step": {"init": {"data": {"interval": "Update interval"}}}}},
)
root_node = astroid.parse(
"""
class MyOptionsFlow(OptionsFlow):
async def async_step_init(self, user_input=None):
return self.async_show_form(
step_id="init",
data_schema=vol.Schema({
vol.Required("interval"): int,
vol.Required("missing"): str,
}),
)
""",
"homeassistant.components.test_int.config_flow",
)
root_node.file = str(integration_dir / "config_flow.py")
walker = ASTWalker(linter)
walker.add_checker(flow_translations_checker)
walker.walk(root_node)
messages = linter.release_messages()
assert len(messages) == 1
assert messages[0].msg_id == "home-assistant-options-flow-field-not-translated"
# --- Subentry flow tests ---
def test_subentry_flow_translated_ok(
linter: UnittestLinter,
flow_translations_checker: ConfigFlowTranslationsChecker,
tmp_path: Path,
) -> None:
"""No warning when subentry flow fields have translations."""
integration_dir = _make_integration(
tmp_path,
{
"config_subentries": {
"my_sub": {
"step": {"user": {"data": {"name": "Name"}}},
}
}
},
)
root_node = astroid.parse(
"""
class MyConfigFlow(ConfigFlow, domain=DOMAIN):
@classmethod
def async_get_supported_subentry_types(cls, config_entry):
return {"my_sub": MySubentryFlow}
class MySubentryFlow(ConfigSubentryFlow):
async def async_step_user(self, user_input=None):
return self.async_show_form(
step_id="user",
data_schema=vol.Schema({
vol.Required("name"): str,
}),
)
""",
"homeassistant.components.test_int.config_flow",
)
root_node.file = str(integration_dir / "config_flow.py")
walker = ASTWalker(linter)
walker.add_checker(flow_translations_checker)
with assert_no_messages(linter):
walker.walk(root_node)
def test_subentry_flow_missing_field_flagged(
linter: UnittestLinter,
flow_translations_checker: ConfigFlowTranslationsChecker,
tmp_path: Path,
) -> None:
"""Warning when a subentry flow field is missing a translation."""
integration_dir = _make_integration(
tmp_path,
{
"config_subentries": {
"my_sub": {
"step": {"user": {"data": {"name": "Name"}}},
}
}
},
)
root_node = astroid.parse(
"""
class MyConfigFlow(ConfigFlow, domain=DOMAIN):
@classmethod
def async_get_supported_subentry_types(cls, config_entry):
return {"my_sub": MySubentryFlow}
class MySubentryFlow(ConfigSubentryFlow):
async def async_step_user(self, user_input=None):
return self.async_show_form(
step_id="user",
data_schema=vol.Schema({
vol.Required("name"): str,
vol.Required("missing"): str,
}),
)
""",
"homeassistant.components.test_int.config_flow",
)
root_node.file = str(integration_dir / "config_flow.py")
walker = ASTWalker(linter)
walker.add_checker(flow_translations_checker)
walker.walk(root_node)
messages = linter.release_messages()
assert len(messages) == 1
assert messages[0].msg_id == "home-assistant-subentry-flow-field-not-translated"
def test_subentry_shared_handler_any_type_ok(
linter: UnittestLinter,
flow_translations_checker: ConfigFlowTranslationsChecker,
tmp_path: Path,
) -> None:
"""No warning when field exists in any of the mapped subentry types."""
integration_dir = _make_integration(
tmp_path,
{
"config_subentries": {
"type_a": {
"step": {"user": {"data": {"name": "Name"}}},
},
"type_b": {
"step": {"user": {"data": {}}},
},
}
},
)
root_node = astroid.parse(
"""
class MyConfigFlow(ConfigFlow, domain=DOMAIN):
@classmethod
def async_get_supported_subentry_types(cls, config_entry):
return {"type_a": SharedFlow, "type_b": SharedFlow}
class SharedFlow(ConfigSubentryFlow):
async def async_step_user(self, user_input=None):
return self.async_show_form(
step_id="user",
data_schema=vol.Schema({
vol.Required("name"): str,
}),
)
""",
"homeassistant.components.test_int.config_flow",
)
root_node.file = str(integration_dir / "config_flow.py")
walker = ASTWalker(linter)
walker.add_checker(flow_translations_checker)
with assert_no_messages(linter):
walker.walk(root_node)
# --- Inference tests ---
def test_implicit_step_id_from_method(
linter: UnittestLinter,
flow_translations_checker: ConfigFlowTranslationsChecker,
tmp_path: Path,
) -> None:
"""Test that step_id is inferred from async_step_* method name."""
integration_dir = _make_integration(
tmp_path,
{"config": {"step": {"user": {"data": {"host": "Host"}}}}},
)
root_node = astroid.parse(
"""
class MyConfigFlow(ConfigFlow, domain=DOMAIN):
async def async_step_user(self, user_input=None):
return self.async_show_form(
data_schema=vol.Schema({vol.Required("host"): str}),
)
""",
"homeassistant.components.test_int.config_flow",
)
root_node.file = str(integration_dir / "config_flow.py")
walker = ASTWalker(linter)
walker.add_checker(flow_translations_checker)
with assert_no_messages(linter):
walker.walk(root_node)
def test_implicit_step_id_missing_field_flagged(
linter: UnittestLinter,
flow_translations_checker: ConfigFlowTranslationsChecker,
tmp_path: Path,
) -> None:
"""Test that missing field is flagged when step_id is inferred."""
integration_dir = _make_integration(
tmp_path,
{"config": {"step": {"user": {"data": {"host": "Host"}}}}},
)
root_node = astroid.parse(
"""
class MyConfigFlow(ConfigFlow, domain=DOMAIN):
async def async_step_user(self, user_input=None):
return self.async_show_form(
data_schema=vol.Schema({
vol.Required("host"): str,
vol.Required("missing"): str,
}),
)
""",
"homeassistant.components.test_int.config_flow",
)
root_node.file = str(integration_dir / "config_flow.py")
walker = ASTWalker(linter)
walker.add_checker(flow_translations_checker)
walker.walk(root_node)
messages = linter.release_messages()
assert len(messages) == 1
assert messages[0].msg_id == "home-assistant-config-flow-field-not-translated"
assert "missing" in messages[0].args[0]
def test_section_fields_translated_ok(
linter: UnittestLinter,
flow_translations_checker: ConfigFlowTranslationsChecker,
tmp_path: Path,
) -> None:
"""Test that section fields are checked against sections translations."""
integration_dir = _make_integration(
tmp_path,
{
"config": {
"step": {
"user": {
"data": {"host": "Host"},
"sections": {
"advanced": {
"data": {"ssl": "Use SSL", "verify": "Verify"},
}
},
}
}
}
},
)
root_node = astroid.parse(
"""
class MyConfigFlow(ConfigFlow, domain=DOMAIN):
async def async_step_user(self, user_input=None):
return self.async_show_form(
step_id="user",
data_schema=vol.Schema({
vol.Required("host"): str,
vol.Required("advanced"): section(
vol.Schema({
vol.Required("ssl"): bool,
vol.Required("verify"): bool,
}),
{"collapsed": True},
),
}),
)
""",
"homeassistant.components.test_int.config_flow",
)
root_node.file = str(integration_dir / "config_flow.py")
walker = ASTWalker(linter)
walker.add_checker(flow_translations_checker)
with assert_no_messages(linter):
walker.walk(root_node)
def test_section_missing_field_flagged(
linter: UnittestLinter,
flow_translations_checker: ConfigFlowTranslationsChecker,
tmp_path: Path,
) -> None:
"""Test that missing section fields are flagged."""
integration_dir = _make_integration(
tmp_path,
{
"config": {
"step": {
"user": {
"data": {"host": "Host"},
"sections": {
"advanced": {
"data": {"ssl": "Use SSL"},
}
},
}
}
}
},
)
root_node = astroid.parse(
"""
class MyConfigFlow(ConfigFlow, domain=DOMAIN):
async def async_step_user(self, user_input=None):
return self.async_show_form(
step_id="user",
data_schema=vol.Schema({
vol.Required("host"): str,
vol.Required("advanced"): section(
vol.Schema({
vol.Required("ssl"): bool,
vol.Required("missing_field"): bool,
}),
{"collapsed": True},
),
}),
)
""",
"homeassistant.components.test_int.config_flow",
)
root_node.file = str(integration_dir / "config_flow.py")
walker = ASTWalker(linter)
walker.add_checker(flow_translations_checker)
walker.walk(root_node)
messages = linter.release_messages()
assert len(messages) == 1
assert "missing_field" in messages[0].args[0]
def test_section_attribute_form_ok(
linter: UnittestLinter,
flow_translations_checker: ConfigFlowTranslationsChecker,
tmp_path: Path,
) -> None:
"""Test that data_entry_flow.section(...) is recognized as a section."""
integration_dir = _make_integration(
tmp_path,
{
"config": {
"step": {
"user": {
"data": {"host": "Host"},
"sections": {
"advanced": {
"data": {"ssl": "Use SSL"},
}
},
}
}
}
},
)
root_node = astroid.parse(
"""
class MyConfigFlow(ConfigFlow, domain=DOMAIN):
async def async_step_user(self, user_input=None):
return self.async_show_form(
step_id="user",
data_schema=vol.Schema({
vol.Required("host"): str,
vol.Required("advanced"): data_entry_flow.section(
vol.Schema({vol.Required("ssl"): bool}),
{"collapsed": True},
),
}),
)
""",
"homeassistant.components.test_int.config_flow",
)
root_node.file = str(integration_dir / "config_flow.py")
walker = ASTWalker(linter)
walker.add_checker(flow_translations_checker)
with assert_no_messages(linter):
walker.walk(root_node)
def test_constant_field_names_resolved(
linter: UnittestLinter,
flow_translations_checker: ConfigFlowTranslationsChecker,
tmp_path: Path,
) -> None:
"""Test that constant field names are resolved via inference."""
integration_dir = _make_integration(
tmp_path,
{"config": {"step": {"user": {"data": {"host": "Host"}}}}},
)
root_node = astroid.parse(
"""
CONF_HOST = "host"
class MyConfigFlow(ConfigFlow, domain=DOMAIN):
async def async_step_user(self, user_input=None):
return self.async_show_form(
step_id="user",
data_schema=vol.Schema({vol.Required(CONF_HOST): str}),
)
""",
"homeassistant.components.test_int.config_flow",
)
root_node.file = str(integration_dir / "config_flow.py")
walker = ASTWalker(linter)
walker.add_checker(flow_translations_checker)
with assert_no_messages(linter):
walker.walk(root_node)
def test_dict_unpacking_in_schema(
linter: UnittestLinter,
flow_translations_checker: ConfigFlowTranslationsChecker,
tmp_path: Path,
) -> None:
"""Test that **dict unpacking in schema dicts is resolved."""
integration_dir = _make_integration(
tmp_path,
{"config": {"step": {"user": {"data": {"host": "Host", "port": "Port"}}}}},
)
root_node = astroid.parse(
"""
BASE = {vol.Required("host"): str}
class MyConfigFlow(ConfigFlow, domain=DOMAIN):
async def async_step_user(self, user_input=None):
return self.async_show_form(
step_id="user",
data_schema=vol.Schema({**BASE, vol.Optional("port"): int}),
)
""",
"homeassistant.components.test_int.config_flow",
)
root_node.file = str(integration_dir / "config_flow.py")
walker = ASTWalker(linter)
walker.add_checker(flow_translations_checker)
with assert_no_messages(linter):
walker.walk(root_node)
def test_schema_variable_resolved(
linter: UnittestLinter,
flow_translations_checker: ConfigFlowTranslationsChecker,
tmp_path: Path,
) -> None:
"""Test that schema passed via a variable is resolved."""
integration_dir = _make_integration(
tmp_path,
{"config": {"step": {"user": {"data": {"host": "Host"}}}}},
)
root_node = astroid.parse(
"""
USER_SCHEMA = vol.Schema({vol.Required("host"): str})
class MyConfigFlow(ConfigFlow, domain=DOMAIN):
async def async_step_user(self, user_input=None):
return self.async_show_form(step_id="user", data_schema=USER_SCHEMA)
""",
"homeassistant.components.test_int.config_flow",
)
root_node.file = str(integration_dir / "config_flow.py")
walker = ASTWalker(linter)
walker.add_checker(flow_translations_checker)
with assert_no_messages(linter):
walker.walk(root_node)
# --- Edge cases ---
def test_no_strings_json_no_warning(
linter: UnittestLinter,
flow_translations_checker: ConfigFlowTranslationsChecker,
tmp_path: Path,
) -> None:
"""No warning when strings.json doesn't exist."""
integration_dir = _make_integration(tmp_path)
root_node = astroid.parse(
"""
class MyConfigFlow(ConfigFlow, domain=DOMAIN):
async def async_step_user(self, user_input=None):
return self.async_show_form(
step_id="user",
data_schema=vol.Schema({vol.Required("host"): str}),
)
""",
"homeassistant.components.test_int.config_flow",
)
root_node.file = str(integration_dir / "config_flow.py")
walker = ASTWalker(linter)
walker.add_checker(flow_translations_checker)
with assert_no_messages(linter):
walker.walk(root_node)
def test_not_config_flow_module_ignored(
linter: UnittestLinter,
flow_translations_checker: ConfigFlowTranslationsChecker,
tmp_path: Path,
) -> None:
"""Non-config_flow modules are ignored."""
integration_dir = _make_integration(
tmp_path, {"config": {"step": {"user": {"data": {}}}}}
)
root_node = astroid.parse(
"""
class MyConfigFlow(ConfigFlow, domain=DOMAIN):
async def async_step_user(self, user_input=None):
return self.async_show_form(
step_id="user",
data_schema=vol.Schema({vol.Required("host"): str}),
)
""",
"homeassistant.components.test_int.sensor",
)
root_node.file = str(integration_dir / "sensor.py")
walker = ASTWalker(linter)
walker.add_checker(flow_translations_checker)
with assert_no_messages(linter):
walker.walk(root_node)
def test_no_data_schema_no_warning(
linter: UnittestLinter,
flow_translations_checker: ConfigFlowTranslationsChecker,
tmp_path: Path,
) -> None:
"""No warning when async_show_form has no data_schema."""
integration_dir = _make_integration(
tmp_path, {"config": {"step": {"link": {"title": "Link"}}}}
)
root_node = astroid.parse(
"""
class MyConfigFlow(ConfigFlow, domain=DOMAIN):
async def async_step_link(self, user_input=None):
return self.async_show_form(step_id="link")
""",
"homeassistant.components.test_int.config_flow",
)
root_node.file = str(integration_dir / "config_flow.py")
walker = ASTWalker(linter)
walker.add_checker(flow_translations_checker)
with assert_no_messages(linter):
walker.walk(root_node)