From 8ab4ce53fcd56a7833ac99ca12945b1914f965e6 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 24 Apr 2026 10:07:19 +0200 Subject: [PATCH 1/8] Migrate async_conditions_from_config to ConditionChecker --- homeassistant/helpers/condition.py | 51 +++++++++++++++++++++----- tests/helpers/test_condition.py | 57 ++++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 9 deletions(-) diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index 112de4e4d8bdab..4d1b97a7dc00a2 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -1646,40 +1646,73 @@ async def async_conditions_from_config( condition_configs: list[ConfigType], logger: logging.Logger, name: str, -) -> Callable[[TemplateVarsType], bool]: +) -> ConditionsChecker: """AND all conditions.""" checks = [ await async_from_config(hass, condition_config) for condition_config in condition_configs ] + return ConditionsChecker(hass, checks, logger, name) - def check_conditions(variables: TemplateVarsType = None) -> bool: + +class ConditionsChecker(CompoundConditionChecker): + """Condition checker that ANDs multiple conditions. + + Used by automations and template entities. Unlike AndConditionChecker, + this logs warnings on errors instead of raising, and uses "condition" + as the trace path prefix. + """ + + def __init__( + self, + hass: HomeAssistant, + checks: list[ConditionChecker], + logger: logging.Logger, + name: str, + ) -> None: + """Initialize condition checker.""" + super().__init__(hass, checks) + self._logger = logger + self._name = name + + # The function previously returned by async_conditions_from_config + # did not accept hass as first argument, so we need to override the + # __call__ provided for backwawrds comaptibitility + def __call__( # type: ignore[override] + self, + variables: TemplateVarsType | None = None, + **kwargs: Any, + ) -> bool: + """Check all conditions.""" + # The implicit AND can't be disabled, so the call will never return None + return self.async_check(variables=variables) # type: ignore[return-value] + + @callback + def _async_check(self, **kwargs: Unpack[ConditionCheckParams]) -> bool: """AND all conditions.""" errors: list[ConditionErrorIndex] = [] - for index, check in enumerate(checks): + for index, check in enumerate(self._checks): try: with trace_path(["condition", str(index)]): - if check(hass, variables) is False: + if check(self._hass, **kwargs) is False: return False except ConditionError as ex: errors.append( ConditionErrorIndex( - "condition", index=index, total=len(checks), error=ex + "condition", index=index, total=len(self._checks), error=ex ) ) if errors: - logger.warning( + self._logger.warning( "Error evaluating condition in '%s':\n%s", - name, + self._name, ConditionErrorContainer("condition", errors=errors), ) return False return True - return check_conditions - @callback def async_extract_entities(config: ConfigType | Template) -> set[str]: diff --git a/tests/helpers/test_condition.py b/tests/helpers/test_condition.py index e5e64ba7d36cba..849eb757d351f7 100644 --- a/tests/helpers/test_condition.py +++ b/tests/helpers/test_condition.py @@ -4,6 +4,7 @@ from contextlib import AbstractContextManager, nullcontext as does_not_raise from datetime import timedelta import io +import logging from typing import Any from unittest.mock import AsyncMock, Mock, patch @@ -4488,3 +4489,59 @@ async def test_nested_compound_condition_forwards_async_unload( test._checks[0]._checks[0].async_unload.assert_called_once() test._checks[1].async_unload.assert_called_once() + + +async def test_conditions_from_config_forwards_async_unload( + hass: HomeAssistant, +) -> None: + """Test that async_conditions_from_config forwards async_unload to children.""" + await _setup_mock_integration(hass) + configs = [ + await condition.async_validate_condition_config(hass, {"condition": "test"}), + await condition.async_validate_condition_config(hass, {"condition": "test"}), + ] + test = await condition.async_conditions_from_config( + hass, configs, logging.getLogger(__name__), "test" + ) + + assert hasattr(test, "_checks") + assert len(test._checks) == 2 + + test.async_unload() + + for child in test._checks: + child.async_unload.assert_called_once() + + +@pytest.mark.parametrize( + "inner_type", + ["and", "or", "not"], +) +async def test_conditions_from_config_nested_forwards_async_unload( + hass: HomeAssistant, inner_type: str +) -> None: + """Test that async_conditions_from_config forwards async_unload recursively.""" + await _setup_mock_integration(hass) + configs = [ + await condition.async_validate_condition_config( + hass, + { + "condition": inner_type, + "conditions": [{"condition": "test"}], + }, + ), + await condition.async_validate_condition_config(hass, {"condition": "test"}), + ] + test = await condition.async_conditions_from_config( + hass, configs, logging.getLogger(__name__), "test" + ) + + assert len(test._checks) == 2 + inner_checker = test._checks[0] + assert hasattr(inner_checker, "_checks") + assert len(inner_checker._checks) == 1 + + test.async_unload() + + test._checks[0]._checks[0].async_unload.assert_called_once() + test._checks[1].async_unload.assert_called_once() From f44f676a47cfdb20e7d151e2fce13afa50725bb7 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 24 Apr 2026 10:30:49 +0200 Subject: [PATCH 2/8] Fix spelling --- homeassistant/helpers/condition.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index 4d1b97a7dc00a2..c8a8e091c92f1e 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -1677,7 +1677,7 @@ def __init__( # The function previously returned by async_conditions_from_config # did not accept hass as first argument, so we need to override the - # __call__ provided for backwawrds comaptibitility + # __call__ provided for backwards compatibility def __call__( # type: ignore[override] self, variables: TemplateVarsType | None = None, From bcc3fac9c1bd41af90dc54dce733e893f3c70cd6 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 24 Apr 2026 11:00:16 +0200 Subject: [PATCH 3/8] Use modern API in compound checkers --- homeassistant/helpers/condition.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index c8a8e091c92f1e..9ee9822d62b19d 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -1015,7 +1015,7 @@ def _async_check(self, **kwargs: Unpack[ConditionCheckParams]) -> bool: for index, check in enumerate(self._checks): try: with trace_path(["conditions", str(index)]): - if check(self._hass, **kwargs) is False: + if check.async_check(**kwargs) is False: return False except ConditionError as ex: errors.append( @@ -1049,7 +1049,7 @@ def _async_check(self, **kwargs: Unpack[ConditionCheckParams]) -> bool: for index, check in enumerate(self._checks): try: with trace_path(["conditions", str(index)]): - if check(self._hass, **kwargs) is True: + if check.async_check(**kwargs) is True: return True except ConditionError as ex: errors.append( @@ -1083,7 +1083,7 @@ def _async_check(self, **kwargs: Unpack[ConditionCheckParams]) -> bool: for index, check in enumerate(self._checks): try: with trace_path(["conditions", str(index)]): - if check(self._hass, **kwargs): + if check.async_check(**kwargs): return False except ConditionError as ex: errors.append( @@ -1694,7 +1694,7 @@ def _async_check(self, **kwargs: Unpack[ConditionCheckParams]) -> bool: for index, check in enumerate(self._checks): try: with trace_path(["condition", str(index)]): - if check(self._hass, **kwargs) is False: + if check.async_check(**kwargs) is False: return False except ConditionError as ex: errors.append( From 45e2bb61b3e3bcd0b774fd80e380eb0eff216dfd Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 24 Apr 2026 11:06:02 +0200 Subject: [PATCH 4/8] Fix tracing --- homeassistant/helpers/condition.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index 9ee9822d62b19d..1cbabf4890a1a6 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -1684,8 +1684,17 @@ def __call__( # type: ignore[override] **kwargs: Any, ) -> bool: """Check all conditions.""" - # The implicit AND can't be disabled, so the call will never return None - return self.async_check(variables=variables) # type: ignore[return-value] + return self.async_check(variables=variables) + + def async_check( + self, *, variables: TemplateVarsType = None, **kwargs: Never + ) -> bool: + """Check the condition. + + Overrides the base class to skip the outer trace_condition wrapper. + The individual child conditions handle their own tracing. + """ + return self._async_check(variables=variables) @callback def _async_check(self, **kwargs: Unpack[ConditionCheckParams]) -> bool: From 03a662bb0c9a8ec4cac72dacb50d7a45dd3a6bdb Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 24 Apr 2026 13:00:27 +0200 Subject: [PATCH 5/8] Make ConditionsChecker a direct descendant of ConditionChecker --- homeassistant/helpers/condition.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index 1cbabf4890a1a6..5f0aa0d941384d 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -1655,7 +1655,7 @@ async def async_conditions_from_config( return ConditionsChecker(hass, checks, logger, name) -class ConditionsChecker(CompoundConditionChecker): +class ConditionsChecker(ConditionChecker): """Condition checker that ANDs multiple conditions. Used by automations and template entities. Unlike AndConditionChecker, @@ -1671,7 +1671,8 @@ def __init__( name: str, ) -> None: """Initialize condition checker.""" - super().__init__(hass, checks) + super().__init__(hass) + self._checks = checks self._logger = logger self._name = name @@ -1686,6 +1687,12 @@ def __call__( # type: ignore[override] """Check all conditions.""" return self.async_check(variables=variables) + def async_unload(self) -> None: + """Clean up child conditions.""" + for check in self._checks: + check.async_unload() + super().async_unload() + def async_check( self, *, variables: TemplateVarsType = None, **kwargs: Never ) -> bool: From af6eb96914f8369284502b88c7b8583c60d67e76 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 24 Apr 2026 13:38:38 +0200 Subject: [PATCH 6/8] Make ConditionsChecker not inherit ConditionChecker --- homeassistant/helpers/condition.py | 34 +++++++++++++----------------- 1 file changed, 15 insertions(+), 19 deletions(-) diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index 5f0aa0d941384d..27f8c1f94c6ce6 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -1652,10 +1652,10 @@ async def async_conditions_from_config( await async_from_config(hass, condition_config) for condition_config in condition_configs ] - return ConditionsChecker(hass, checks, logger, name) + return ConditionsChecker(checks, logger, name) -class ConditionsChecker(ConditionChecker): +class ConditionsChecker: """Condition checker that ANDs multiple conditions. Used by automations and template entities. Unlike AndConditionChecker, @@ -1665,21 +1665,17 @@ class ConditionsChecker(ConditionChecker): def __init__( self, - hass: HomeAssistant, checks: list[ConditionChecker], logger: logging.Logger, name: str, ) -> None: """Initialize condition checker.""" - super().__init__(hass) self._checks = checks self._logger = logger self._name = name + self._unloaded = False - # The function previously returned by async_conditions_from_config - # did not accept hass as first argument, so we need to override the - # __call__ provided for backwards compatibility - def __call__( # type: ignore[override] + def __call__( self, variables: TemplateVarsType | None = None, **kwargs: Any, @@ -1687,30 +1683,30 @@ def __call__( # type: ignore[override] """Check all conditions.""" return self.async_check(variables=variables) + def __del__(self) -> None: + """Clean up when the checker is deleted.""" + if self._unloaded: + return + try: + self.async_unload() + except Exception: + _LOGGER.exception("Error while unloading condition checker") + def async_unload(self) -> None: """Clean up child conditions.""" + self._unloaded = True for check in self._checks: check.async_unload() - super().async_unload() def async_check( self, *, variables: TemplateVarsType = None, **kwargs: Never ) -> bool: - """Check the condition. - - Overrides the base class to skip the outer trace_condition wrapper. - The individual child conditions handle their own tracing. - """ - return self._async_check(variables=variables) - - @callback - def _async_check(self, **kwargs: Unpack[ConditionCheckParams]) -> bool: """AND all conditions.""" errors: list[ConditionErrorIndex] = [] for index, check in enumerate(self._checks): try: with trace_path(["condition", str(index)]): - if check.async_check(**kwargs) is False: + if check.async_check(variables=variables, **kwargs) is False: return False except ConditionError as ex: errors.append( From 0a4d47b19aa86b1ef535f3e11c6356044387b675 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 24 Apr 2026 13:59:50 +0200 Subject: [PATCH 7/8] Address review comment --- homeassistant/helpers/condition.py | 20 ++++++++------------ tests/helpers/test_condition.py | 14 +++++++------- 2 files changed, 15 insertions(+), 19 deletions(-) diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index 27f8c1f94c6ce6..af6e72562de8aa 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -1665,21 +1665,17 @@ class ConditionsChecker: def __init__( self, - checks: list[ConditionChecker], + conditions: list[ConditionChecker], logger: logging.Logger, name: str, ) -> None: """Initialize condition checker.""" - self._checks = checks + self._conditions = conditions self._logger = logger self._name = name self._unloaded = False - def __call__( - self, - variables: TemplateVarsType | None = None, - **kwargs: Any, - ) -> bool: + def __call__(self, variables: TemplateVarsType = None) -> bool: """Check all conditions.""" return self.async_check(variables=variables) @@ -1695,23 +1691,23 @@ def __del__(self) -> None: def async_unload(self) -> None: """Clean up child conditions.""" self._unloaded = True - for check in self._checks: - check.async_unload() + for condition in self._conditions: + condition.async_unload() def async_check( self, *, variables: TemplateVarsType = None, **kwargs: Never ) -> bool: """AND all conditions.""" errors: list[ConditionErrorIndex] = [] - for index, check in enumerate(self._checks): + for index, condition in enumerate(self._conditions): try: with trace_path(["condition", str(index)]): - if check.async_check(variables=variables, **kwargs) is False: + if condition.async_check(variables=variables, **kwargs) is False: return False except ConditionError as ex: errors.append( ConditionErrorIndex( - "condition", index=index, total=len(self._checks), error=ex + "condition", index=index, total=len(self._conditions), error=ex ) ) diff --git a/tests/helpers/test_condition.py b/tests/helpers/test_condition.py index 849eb757d351f7..1380b9a69b2669 100644 --- a/tests/helpers/test_condition.py +++ b/tests/helpers/test_condition.py @@ -4504,12 +4504,12 @@ async def test_conditions_from_config_forwards_async_unload( hass, configs, logging.getLogger(__name__), "test" ) - assert hasattr(test, "_checks") - assert len(test._checks) == 2 + assert hasattr(test, "_conditions") + assert len(test._conditions) == 2 test.async_unload() - for child in test._checks: + for child in test._conditions: child.async_unload.assert_called_once() @@ -4536,12 +4536,12 @@ async def test_conditions_from_config_nested_forwards_async_unload( hass, configs, logging.getLogger(__name__), "test" ) - assert len(test._checks) == 2 - inner_checker = test._checks[0] + assert len(test._conditions) == 2 + inner_checker = test._conditions[0] assert hasattr(inner_checker, "_checks") assert len(inner_checker._checks) == 1 test.async_unload() - test._checks[0]._checks[0].async_unload.assert_called_once() - test._checks[1].async_unload.assert_called_once() + test._conditions[0]._checks[0].async_unload.assert_called_once() + test._conditions[1].async_unload.assert_called_once() From 4cf536f96a52b2cd39939e14bbc3263645e9ac45 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 24 Apr 2026 14:01:04 +0200 Subject: [PATCH 8/8] Revert unrelated changes --- homeassistant/helpers/condition.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index af6e72562de8aa..4a2e230f25618b 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -1015,7 +1015,7 @@ def _async_check(self, **kwargs: Unpack[ConditionCheckParams]) -> bool: for index, check in enumerate(self._checks): try: with trace_path(["conditions", str(index)]): - if check.async_check(**kwargs) is False: + if check(self._hass, **kwargs) is False: return False except ConditionError as ex: errors.append( @@ -1049,7 +1049,7 @@ def _async_check(self, **kwargs: Unpack[ConditionCheckParams]) -> bool: for index, check in enumerate(self._checks): try: with trace_path(["conditions", str(index)]): - if check.async_check(**kwargs) is True: + if check(self._hass, **kwargs) is True: return True except ConditionError as ex: errors.append( @@ -1083,7 +1083,7 @@ def _async_check(self, **kwargs: Unpack[ConditionCheckParams]) -> bool: for index, check in enumerate(self._checks): try: with trace_path(["conditions", str(index)]): - if check.async_check(**kwargs): + if check(self._hass, **kwargs): return False except ConditionError as ex: errors.append(