diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index 112de4e4d8bda..4a2e230f25618 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -1646,40 +1646,81 @@ 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(checks, logger, name) - def check_conditions(variables: TemplateVarsType = None) -> bool: + +class ConditionsChecker: + """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, + conditions: list[ConditionChecker], + logger: logging.Logger, + name: str, + ) -> None: + """Initialize condition checker.""" + self._conditions = conditions + self._logger = logger + self._name = name + self._unloaded = False + + def __call__(self, variables: TemplateVarsType = None) -> bool: + """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 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(checks): + for index, condition in enumerate(self._conditions): try: with trace_path(["condition", str(index)]): - if check(hass, variables) 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(checks), error=ex + "condition", index=index, total=len(self._conditions), 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 e5e64ba7d36cb..1380b9a69b266 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, "_conditions") + assert len(test._conditions) == 2 + + test.async_unload() + + for child in test._conditions: + 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._conditions) == 2 + inner_checker = test._conditions[0] + assert hasattr(inner_checker, "_checks") + assert len(inner_checker._checks) == 1 + + test.async_unload() + + test._conditions[0]._checks[0].async_unload.assert_called_once() + test._conditions[1].async_unload.assert_called_once()