From 06130219b4c50aec24e697a073279cd8a860229b Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Wed, 6 Aug 2025 18:20:30 +0200 Subject: [PATCH] Use relative condition keys (#150021) --- .../components/device_automation/condition.py | 2 +- homeassistant/components/sun/condition.py | 2 +- homeassistant/components/zone/condition.py | 2 +- homeassistant/helpers/condition.py | 72 +++++++++++-------- script/hassfest/conditions.py | 2 +- script/hassfest/icons.py | 2 +- script/hassfest/translations.py | 2 +- .../components/websocket_api/test_commands.py | 9 +-- tests/helpers/test_condition.py | 24 +++---- 9 files changed, 65 insertions(+), 52 deletions(-) diff --git a/homeassistant/components/device_automation/condition.py b/homeassistant/components/device_automation/condition.py index 426cc45a895..63be9641aeb 100644 --- a/homeassistant/components/device_automation/condition.py +++ b/homeassistant/components/device_automation/condition.py @@ -80,7 +80,7 @@ class DeviceCondition(Condition): CONDITIONS: dict[str, type[Condition]] = { - "device": DeviceCondition, + "_device": DeviceCondition, } diff --git a/homeassistant/components/sun/condition.py b/homeassistant/components/sun/condition.py index 15f3ea90c73..415d0a04e7c 100644 --- a/homeassistant/components/sun/condition.py +++ b/homeassistant/components/sun/condition.py @@ -153,7 +153,7 @@ class SunCondition(Condition): CONDITIONS: dict[str, type[Condition]] = { - "sun": SunCondition, + "_": SunCondition, } diff --git a/homeassistant/components/zone/condition.py b/homeassistant/components/zone/condition.py index b0fe30b26fd..cc2429ed3a4 100644 --- a/homeassistant/components/zone/condition.py +++ b/homeassistant/components/zone/condition.py @@ -147,7 +147,7 @@ class ZoneCondition(Condition): CONDITIONS: dict[str, type[Condition]] = { - "zone": ZoneCondition, + "_": ZoneCondition, } diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index 5aa39e73166..d9f16217c2e 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -58,9 +58,9 @@ from homeassistant.util import dt as dt_util from homeassistant.util.async_ import run_callback_threadsafe from homeassistant.util.hass_dict import HassKey from homeassistant.util.yaml import load_yaml_dict -from homeassistant.util.yaml.loader import JSON_TYPE from . import config_validation as cv, entity_registry as er +from .automation import get_absolute_description_key, get_relative_description_key from .integration_platform import async_process_integration_platforms from .template import Template, render_complex from .trace import ( @@ -132,7 +132,7 @@ def starts_with_dot(key: str) -> str: _CONDITIONS_SCHEMA = vol.Schema( { vol.Remove(vol.All(str, starts_with_dot)): object, - cv.slug: vol.Any(None, _CONDITION_SCHEMA), + cv.underscore_slug: vol.Any(None, _CONDITION_SCHEMA), } ) @@ -171,6 +171,9 @@ async def _register_condition_platform( if hasattr(platform, "async_get_conditions"): for condition_key in await platform.async_get_conditions(hass): + condition_key = get_absolute_description_key( + integration_domain, condition_key + ) hass.data[CONDITIONS][condition_key] = integration_domain new_conditions.add(condition_key) else: @@ -288,22 +291,21 @@ def trace_condition_function(condition: ConditionCheckerType) -> ConditionChecke async def _async_get_condition_platform( - hass: HomeAssistant, config: ConfigType -) -> ConditionProtocol | None: - condition_key: str = config[CONF_CONDITION] - platform_and_sub_type = condition_key.partition(".") + hass: HomeAssistant, condition_key: str +) -> tuple[str, ConditionProtocol | None]: + platform_and_sub_type = condition_key.split(".") platform: str | None = platform_and_sub_type[0] platform = _PLATFORM_ALIASES.get(platform, platform) if platform is None: - return None + return "", None try: integration = await async_get_integration(hass, platform) except IntegrationNotFound: raise HomeAssistantError( - f'Invalid condition "{condition_key}" specified {config}' + f'Invalid condition "{condition_key}" specified' ) from None try: - return await integration.async_get_platform("condition") + return platform, await integration.async_get_platform("condition") except ImportError: raise HomeAssistantError( f"Integration '{platform}' does not provide condition support" @@ -339,17 +341,20 @@ async def async_from_config( return disabled_condition - condition: str = config[CONF_CONDITION] + condition_key: str = config[CONF_CONDITION] factory: Any = None - platform = await _async_get_condition_platform(hass, config) + platform_domain, platform = await _async_get_condition_platform(hass, condition_key) if platform is not None: condition_descriptors = await platform.async_get_conditions(hass) - condition_instance = condition_descriptors[condition](hass, config) + relative_condition_key = get_relative_description_key( + platform_domain, condition_key + ) + condition_instance = condition_descriptors[relative_condition_key](hass, config) return await condition_instance.async_get_checker() for fmt in (ASYNC_FROM_CONFIG_FORMAT, FROM_CONFIG_FORMAT): - factory = getattr(sys.modules[__name__], fmt.format(condition), None) + factory = getattr(sys.modules[__name__], fmt.format(condition_key), None) if factory: break @@ -960,8 +965,9 @@ async def async_validate_condition_config( hass: HomeAssistant, config: ConfigType ) -> ConfigType: """Validate config.""" - condition: str = config[CONF_CONDITION] - if condition in ("and", "not", "or"): + condition_key: str = config[CONF_CONDITION] + + if condition_key in ("and", "not", "or"): conditions = [] for sub_cond in config["conditions"]: sub_cond = await async_validate_condition_config(hass, sub_cond) @@ -969,16 +975,23 @@ async def async_validate_condition_config( config["conditions"] = conditions return config - platform = await _async_get_condition_platform(hass, config) + platform_domain, platform = await _async_get_condition_platform(hass, condition_key) + if platform is not None: condition_descriptors = await platform.async_get_conditions(hass) - if not (condition_class := condition_descriptors.get(condition)): - raise vol.Invalid(f"Invalid condition '{condition}' specified") + relative_condition_key = get_relative_description_key( + platform_domain, condition_key + ) + if not (condition_class := condition_descriptors.get(relative_condition_key)): + raise vol.Invalid(f"Invalid condition '{condition_key}' specified") return await condition_class.async_validate_config(hass, config) - if platform is None and condition in ("numeric_state", "state"): + + if platform is None and condition_key in ("numeric_state", "state"): validator = cast( Callable[[HomeAssistant, ConfigType], ConfigType], - getattr(sys.modules[__name__], VALIDATE_CONFIG_FORMAT.format(condition)), + getattr( + sys.modules[__name__], VALIDATE_CONFIG_FORMAT.format(condition_key) + ), ) return validator(hass, config) @@ -1088,11 +1101,11 @@ def async_extract_devices(config: ConfigType | Template) -> set[str]: return referenced -def _load_conditions_file(hass: HomeAssistant, integration: Integration) -> JSON_TYPE: +def _load_conditions_file(integration: Integration) -> dict[str, Any]: """Load conditions file for an integration.""" try: return cast( - JSON_TYPE, + dict[str, Any], _CONDITIONS_SCHEMA( load_yaml_dict(str(integration.file_path / "conditions.yaml")) ), @@ -1112,11 +1125,14 @@ def _load_conditions_file(hass: HomeAssistant, integration: Integration) -> JSON def _load_conditions_files( - hass: HomeAssistant, integrations: Iterable[Integration] -) -> dict[str, JSON_TYPE]: + integrations: Iterable[Integration], +) -> dict[str, dict[str, Any]]: """Load condition files for multiple integrations.""" return { - integration.domain: _load_conditions_file(hass, integration) + integration.domain: { + get_absolute_description_key(integration.domain, key): value + for key, value in _load_conditions_file(integration).items() + } for integration in integrations } @@ -1137,7 +1153,7 @@ async def async_get_all_descriptions( return descriptions_cache # Files we loaded for missing descriptions - new_conditions_descriptions: dict[str, JSON_TYPE] = {} + new_conditions_descriptions: dict[str, dict[str, Any]] = {} # We try to avoid making a copy in the event the cache is good, # but now we must make a copy in case new conditions get added # while we are loading the missing ones so we do not @@ -1166,7 +1182,7 @@ async def async_get_all_descriptions( if integrations: new_conditions_descriptions = await hass.async_add_executor_job( - _load_conditions_files, hass, integrations + _load_conditions_files, integrations ) # Make a copy of the old cache and add missing descriptions to it @@ -1175,7 +1191,7 @@ async def async_get_all_descriptions( domain = conditions[missing_condition] if ( - yaml_description := new_conditions_descriptions.get(domain, {}).get( # type: ignore[union-attr] + yaml_description := new_conditions_descriptions.get(domain, {}).get( missing_condition ) ) is None: diff --git a/script/hassfest/conditions.py b/script/hassfest/conditions.py index 2a1d363a5fc..b9e9e7b82a4 100644 --- a/script/hassfest/conditions.py +++ b/script/hassfest/conditions.py @@ -47,7 +47,7 @@ CONDITION_SCHEMA = vol.Any( CONDITIONS_SCHEMA = vol.Schema( { vol.Remove(vol.All(str, condition.starts_with_dot)): object, - cv.slug: CONDITION_SCHEMA, + cv.underscore_slug: CONDITION_SCHEMA, } ) diff --git a/script/hassfest/icons.py b/script/hassfest/icons.py index ba6ac5e88c8..6d2187e3fe6 100644 --- a/script/hassfest/icons.py +++ b/script/hassfest/icons.py @@ -126,7 +126,7 @@ CONDITION_ICONS_SCHEMA = cv.schema_with_slug_keys( vol.Optional("condition"): icon_value_validator, } ), - slug_validator=translation_key_validator, + slug_validator=cv.underscore_slug, ) diff --git a/script/hassfest/translations.py b/script/hassfest/translations.py index 76af88f8dec..d09fb27f71a 100644 --- a/script/hassfest/translations.py +++ b/script/hassfest/translations.py @@ -434,7 +434,7 @@ def gen_strings_schema(config: Config, integration: Integration) -> vol.Schema: slug_validator=translation_key_validator, ), }, - slug_validator=translation_key_validator, + slug_validator=cv.underscore_slug, ), vol.Optional("triggers"): cv.schema_with_slug_keys( { diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index 263cd4a4ed8..846b3657bb2 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -721,10 +721,10 @@ async def test_subscribe_conditions( ) -> None: """Test condition_platforms/subscribe command.""" sun_condition_descriptions = """ - sun: {} + _: {} """ device_automation_condition_descriptions = """ - device: {} + _device: {} """ def _load_yaml(fname, secrets=None): @@ -2738,10 +2738,7 @@ async def test_validate_config_works( "entity_id": "hello.world", "state": "paulus", }, - ( - "Invalid condition \"non_existing\" specified {'condition': " - "'non_existing', 'entity_id': 'hello.world', 'state': 'paulus'}" - ), + 'Invalid condition "non_existing" specified', ), # Raises HomeAssistantError ( diff --git a/tests/helpers/test_condition.py b/tests/helpers/test_condition.py index 94e71696270..b037d6a450e 100644 --- a/tests/helpers/test_condition.py +++ b/tests/helpers/test_condition.py @@ -2073,7 +2073,7 @@ async def test_platform_async_get_conditions(hass: HomeAssistant) -> None: config = {CONF_DEVICE_ID: "test", CONF_DOMAIN: "test", CONF_CONDITION: "device"} with patch( "homeassistant.components.device_automation.condition.async_get_conditions", - AsyncMock(return_value={"device": AsyncMock()}), + AsyncMock(return_value={"_device": AsyncMock()}), ) as device_automation_async_get_conditions_mock: await condition.async_validate_condition_config(hass, config) device_automation_async_get_conditions_mock.assert_awaited() @@ -2113,8 +2113,8 @@ async def test_platform_multiple_conditions(hass: HomeAssistant) -> None: hass: HomeAssistant, ) -> dict[str, type[condition.Condition]]: return { - "test": MockCondition1, - "test.cond_2": MockCondition2, + "_": MockCondition1, + "cond_2": MockCondition2, } mock_integration(hass, MockModule("test")) @@ -2337,7 +2337,7 @@ async def test_or_condition_with_disabled_condition(hass: HomeAssistant) -> None "sun_condition_descriptions", [ """ - sun: + _: fields: after: example: sunrise @@ -2371,7 +2371,7 @@ async def test_or_condition_with_disabled_condition(hass: HomeAssistant) -> None .offset_selector: &offset_selector selector: time: null - sun: + _: fields: after: *sunrise_sunset_selector after_offset: *offset_selector @@ -2385,7 +2385,7 @@ async def test_async_get_all_descriptions( ) -> None: """Test async_get_all_descriptions.""" device_automation_condition_descriptions = """ - device: {} + _device: {} """ assert await async_setup_component(hass, DOMAIN_SUN, {}) @@ -2415,7 +2415,7 @@ async def test_async_get_all_descriptions( # Test we only load conditions.yaml for integrations with conditions, # system_health has no conditions - assert proxy_load_conditions_files.mock_calls[0][1][1] == unordered( + assert proxy_load_conditions_files.mock_calls[0][1][0] == unordered( [ await async_get_integration(hass, DOMAIN_SUN), ] @@ -2423,7 +2423,7 @@ async def test_async_get_all_descriptions( # system_health does not have conditions and should not be in descriptions assert descriptions == { - DOMAIN_SUN: { + "sun": { "fields": { "after": { "example": "sunrise", @@ -2459,7 +2459,7 @@ async def test_async_get_all_descriptions( "device": { "fields": {}, }, - DOMAIN_SUN: { + "sun": { "fields": { "after": { "example": "sunrise", @@ -2525,7 +2525,7 @@ async def test_async_get_all_descriptions_with_bad_description( ) -> None: """Test async_get_all_descriptions.""" sun_service_descriptions = """ - sun: + _: fields: not_a_dict """ @@ -2545,11 +2545,11 @@ async def test_async_get_all_descriptions_with_bad_description( ): descriptions = await condition.async_get_all_descriptions(hass) - assert descriptions == {DOMAIN_SUN: None} + assert descriptions == {"sun": None} assert ( "Unable to parse conditions.yaml for the sun integration: " - "expected a dictionary for dictionary value @ data['sun']['fields']" + "expected a dictionary for dictionary value @ data['_']['fields']" ) in caplog.text