diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 3a48b3e3cc2..7e07f35be45 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -32,7 +32,12 @@ from homeassistant.core import ( callback, split_entity_id, ) -from homeassistant.exceptions import ConditionError, HomeAssistantError +from homeassistant.exceptions import ( + ConditionError, + ConditionErrorContainer, + ConditionErrorIndex, + HomeAssistantError, +) from homeassistant.helpers import condition, extract_domain_configs, template import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import ToggleEntity @@ -616,16 +621,22 @@ async def _async_process_if(hass, config, p_config): def if_action(variables=None): """AND all conditions.""" errors = [] - for check in checks: + for index, check in enumerate(checks): try: if not check(hass, variables): return False except ConditionError as ex: - errors.append(f"Error in 'condition' evaluation: {ex}") + errors.append( + ConditionErrorIndex( + "condition", index=index, total=len(checks), error=ex + ) + ) if errors: - for error in errors: - LOGGER.warning("%s", error) + LOGGER.warning( + "Error evaluating condition:\n%s", + ConditionErrorContainer("condition", errors=errors), + ) return False return True diff --git a/homeassistant/components/homeassistant/triggers/numeric_state.py b/homeassistant/components/homeassistant/triggers/numeric_state.py index 16b3fb97475..59f16c41a36 100644 --- a/homeassistant/components/homeassistant/triggers/numeric_state.py +++ b/homeassistant/components/homeassistant/triggers/numeric_state.py @@ -112,7 +112,7 @@ async def async_attach_trigger( armed_entities.add(entity_id) except exceptions.ConditionError as ex: _LOGGER.warning( - "Error initializing 'numeric_state' trigger for '%s': %s", + "Error initializing '%s' trigger: %s", automation_info["name"], ex, ) diff --git a/homeassistant/exceptions.py b/homeassistant/exceptions.py index 852795ebb4a..0ac231fd314 100644 --- a/homeassistant/exceptions.py +++ b/homeassistant/exceptions.py @@ -1,5 +1,7 @@ """The exceptions used by Home Assistant.""" -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING, Generator, Optional, Sequence + +import attr if TYPE_CHECKING: from .core import Context # noqa: F401 pylint: disable=unused-import @@ -25,9 +27,74 @@ class TemplateError(HomeAssistantError): super().__init__(f"{exception.__class__.__name__}: {exception}") +@attr.s class ConditionError(HomeAssistantError): """Error during condition evaluation.""" + # The type of the failed condition, such as 'and' or 'numeric_state' + type: str = attr.ib() + + @staticmethod + def _indent(indent: int, message: str) -> str: + """Return indentation.""" + return " " * indent + message + + def output(self, indent: int) -> Generator: + """Yield an indented representation.""" + raise NotImplementedError() + + def __str__(self) -> str: + """Return string representation.""" + return "\n".join(list(self.output(indent=0))) + + +@attr.s +class ConditionErrorMessage(ConditionError): + """Condition error message.""" + + # A message describing this error + message: str = attr.ib() + + def output(self, indent: int) -> Generator: + """Yield an indented representation.""" + yield self._indent(indent, f"In '{self.type}' condition: {self.message}") + + +@attr.s +class ConditionErrorIndex(ConditionError): + """Condition error with index.""" + + # The zero-based index of the failed condition, for conditions with multiple parts + index: int = attr.ib() + # The total number of parts in this condition, including non-failed parts + total: int = attr.ib() + # The error that this error wraps + error: ConditionError = attr.ib() + + def output(self, indent: int) -> Generator: + """Yield an indented representation.""" + if self.total > 1: + yield self._indent( + indent, f"In '{self.type}' (item {self.index+1} of {self.total}):" + ) + else: + yield self._indent(indent, f"In '{self.type}':") + + yield from self.error.output(indent + 1) + + +@attr.s +class ConditionErrorContainer(ConditionError): + """Condition error with index.""" + + # List of ConditionErrors that this error wraps + errors: Sequence[ConditionError] = attr.ib() + + def output(self, indent: int) -> Generator: + """Yield an indented representation.""" + for item in self.errors: + yield from item.output(indent) + class PlatformNotReady(HomeAssistantError): """Error to indicate that platform is not ready.""" diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index b66ee6c7976..c20755a1780 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -36,7 +36,14 @@ from homeassistant.const import ( WEEKDAYS, ) from homeassistant.core import HomeAssistant, State, callback -from homeassistant.exceptions import ConditionError, HomeAssistantError, TemplateError +from homeassistant.exceptions import ( + ConditionError, + ConditionErrorContainer, + ConditionErrorIndex, + ConditionErrorMessage, + HomeAssistantError, + TemplateError, +) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.sun import get_astral_event_date from homeassistant.helpers.template import Template @@ -109,18 +116,18 @@ async def async_and_from_config( ) -> bool: """Test and condition.""" errors = [] - for check in checks: + for index, check in enumerate(checks): try: if not check(hass, variables): return False except ConditionError as ex: - errors.append(str(ex)) - except Exception as ex: # pylint: disable=broad-except - errors.append(str(ex)) + errors.append( + ConditionErrorIndex("and", index=index, total=len(checks), error=ex) + ) # Raise the errors if no check was false if errors: - raise ConditionError("Error in 'and' condition: " + ", ".join(errors)) + raise ConditionErrorContainer("and", errors=errors) return True @@ -142,18 +149,18 @@ async def async_or_from_config( ) -> bool: """Test or condition.""" errors = [] - for check in checks: + for index, check in enumerate(checks): try: if check(hass, variables): return True except ConditionError as ex: - errors.append(str(ex)) - except Exception as ex: # pylint: disable=broad-except - errors.append(str(ex)) + errors.append( + ConditionErrorIndex("or", index=index, total=len(checks), error=ex) + ) # Raise the errors if no check was true if errors: - raise ConditionError("Error in 'or' condition: " + ", ".join(errors)) + raise ConditionErrorContainer("or", errors=errors) return False @@ -175,18 +182,18 @@ async def async_not_from_config( ) -> bool: """Test not condition.""" errors = [] - for check in checks: + for index, check in enumerate(checks): try: if check(hass, variables): return False except ConditionError as ex: - errors.append(str(ex)) - except Exception as ex: # pylint: disable=broad-except - errors.append(str(ex)) + errors.append( + ConditionErrorIndex("not", index=index, total=len(checks), error=ex) + ) # Raise the errors if no check was true if errors: - raise ConditionError("Error in 'not' condition: " + ", ".join(errors)) + raise ConditionErrorContainer("not", errors=errors) return True @@ -225,20 +232,21 @@ def async_numeric_state( ) -> bool: """Test a numeric state condition.""" if entity is None: - raise ConditionError("No entity specified") + raise ConditionErrorMessage("numeric_state", "no entity specified") if isinstance(entity, str): entity_id = entity entity = hass.states.get(entity) if entity is None: - raise ConditionError(f"Unknown entity {entity_id}") + raise ConditionErrorMessage("numeric_state", f"unknown entity {entity_id}") else: entity_id = entity.entity_id if attribute is not None and attribute not in entity.attributes: - raise ConditionError( - f"Attribute '{attribute}' (of entity {entity_id}) does not exist" + raise ConditionErrorMessage( + "numeric_state", + f"attribute '{attribute}' (of entity {entity_id}) does not exist", ) value: Any = None @@ -253,16 +261,21 @@ def async_numeric_state( try: value = value_template.async_render(variables) except TemplateError as ex: - raise ConditionError(f"Template error: {ex}") from ex + raise ConditionErrorMessage( + "numeric_state", f"template error: {ex}" + ) from ex if value in (STATE_UNAVAILABLE, STATE_UNKNOWN): - raise ConditionError("State is not available") + raise ConditionErrorMessage( + "numeric_state", f"state of {entity_id} is unavailable" + ) try: fvalue = float(value) - except ValueError as ex: - raise ConditionError( - f"Entity {entity_id} state '{value}' cannot be processed as a number" + except (ValueError, TypeError) as ex: + raise ConditionErrorMessage( + "numeric_state", + f"entity {entity_id} state '{value}' cannot be processed as a number", ) from ex if below is not None: @@ -272,9 +285,17 @@ def async_numeric_state( STATE_UNAVAILABLE, STATE_UNKNOWN, ): - raise ConditionError(f"The below entity {below} is not available") - if fvalue >= float(below_entity.state): - return False + raise ConditionErrorMessage( + "numeric_state", f"the 'below' entity {below} is unavailable" + ) + try: + if fvalue >= float(below_entity.state): + return False + except (ValueError, TypeError) as ex: + raise ConditionErrorMessage( + "numeric_state", + f"the 'below' entity {below} state '{below_entity.state}' cannot be processed as a number", + ) from ex elif fvalue >= below: return False @@ -285,9 +306,17 @@ def async_numeric_state( STATE_UNAVAILABLE, STATE_UNKNOWN, ): - raise ConditionError(f"The above entity {above} is not available") - if fvalue <= float(above_entity.state): - return False + raise ConditionErrorMessage( + "numeric_state", f"the 'above' entity {above} is unavailable" + ) + try: + if fvalue <= float(above_entity.state): + return False + except (ValueError, TypeError) as ex: + raise ConditionErrorMessage( + "numeric_state", + f"the 'above' entity {above} state '{above_entity.state}' cannot be processed as a number", + ) from ex elif fvalue <= above: return False @@ -335,20 +364,20 @@ def state( Async friendly. """ if entity is None: - raise ConditionError("No entity specified") + raise ConditionErrorMessage("state", "no entity specified") if isinstance(entity, str): entity_id = entity entity = hass.states.get(entity) if entity is None: - raise ConditionError(f"Unknown entity {entity_id}") + raise ConditionErrorMessage("state", f"unknown entity {entity_id}") else: entity_id = entity.entity_id if attribute is not None and attribute not in entity.attributes: - raise ConditionError( - f"Attribute '{attribute}' (of entity {entity_id}) does not exist" + raise ConditionErrorMessage( + "state", f"attribute '{attribute}' (of entity {entity_id}) does not exist" ) assert isinstance(entity, State) @@ -370,7 +399,9 @@ def state( ): state_entity = hass.states.get(req_state_value) if not state_entity: - continue + raise ConditionErrorMessage( + "state", f"the 'state' entity {req_state_value} is unavailable" + ) state_value = state_entity.state is_state = value == state_value if is_state: @@ -495,7 +526,7 @@ def async_template( try: value: str = value_template.async_render(variables, parse_result=False) except TemplateError as ex: - raise ConditionError(f"Error in 'template' condition: {ex}") from ex + raise ConditionErrorMessage("template", str(ex)) from ex return value.lower() == "true" @@ -538,9 +569,7 @@ def time( elif isinstance(after, str): after_entity = hass.states.get(after) if not after_entity: - raise ConditionError( - f"Error in 'time' condition: The 'after' entity {after} is not available" - ) + raise ConditionErrorMessage("time", f"unknown 'after' entity {after}") after = dt_util.dt.time( after_entity.attributes.get("hour", 23), after_entity.attributes.get("minute", 59), @@ -552,9 +581,7 @@ def time( elif isinstance(before, str): before_entity = hass.states.get(before) if not before_entity: - raise ConditionError( - f"Error in 'time' condition: The 'before' entity {before} is not available" - ) + raise ConditionErrorMessage("time", f"unknown 'before' entity {before}") before = dt_util.dt.time( before_entity.attributes.get("hour", 23), before_entity.attributes.get("minute", 59), @@ -609,24 +636,24 @@ def zone( Async friendly. """ if zone_ent is None: - raise ConditionError("No zone specified") + raise ConditionErrorMessage("zone", "no zone specified") if isinstance(zone_ent, str): zone_ent_id = zone_ent zone_ent = hass.states.get(zone_ent) if zone_ent is None: - raise ConditionError(f"Unknown zone {zone_ent_id}") + raise ConditionErrorMessage("zone", f"unknown zone {zone_ent_id}") if entity is None: - raise ConditionError("No entity specified") + raise ConditionErrorMessage("zone", "no entity specified") if isinstance(entity, str): entity_id = entity entity = hass.states.get(entity) if entity is None: - raise ConditionError(f"Unknown entity {entity_id}") + raise ConditionErrorMessage("zone", f"unknown entity {entity_id}") else: entity_id = entity.entity_id @@ -634,10 +661,14 @@ def zone( longitude = entity.attributes.get(ATTR_LONGITUDE) if latitude is None: - raise ConditionError(f"Entity {entity_id} has no 'latitude' attribute") + raise ConditionErrorMessage( + "zone", f"entity {entity_id} has no 'latitude' attribute" + ) if longitude is None: - raise ConditionError(f"Entity {entity_id} has no 'longitude' attribute") + raise ConditionErrorMessage( + "zone", f"entity {entity_id} has no 'longitude' attribute" + ) return zone_cmp.in_zone( zone_ent, latitude, longitude, entity.attributes.get(ATTR_GPS_ACCURACY, 0) @@ -664,15 +695,20 @@ def zone_from_config( try: if zone(hass, zone_entity_id, entity_id): entity_ok = True - except ConditionError as ex: - errors.append(str(ex)) + except ConditionErrorMessage as ex: + errors.append( + ConditionErrorMessage( + "zone", + f"error matching {entity_id} with {zone_entity_id}: {ex.message}", + ) + ) if not entity_ok: all_ok = False # Raise the errors only if no definitive result was found if errors and not all_ok: - raise ConditionError("Error in 'zone' condition: " + ", ".join(errors)) + raise ConditionErrorContainer("zone", errors=errors) return all_ok diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index 2e8348bcaf8..e4eb0d4a901 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -516,7 +516,7 @@ class _ScriptRun: try: check = cond(self._hass, self._variables) except exceptions.ConditionError as ex: - _LOGGER.warning("Error in 'condition' evaluation: %s", ex) + _LOGGER.warning("Error in 'condition' evaluation:\n%s", ex) check = False self._log("Test condition %s: %s", self._script.last_action, check) @@ -575,7 +575,7 @@ class _ScriptRun: ): break except exceptions.ConditionError as ex: - _LOGGER.warning("Error in 'while' evaluation: %s", ex) + _LOGGER.warning("Error in 'while' evaluation:\n%s", ex) break await async_run_sequence(iteration) @@ -593,7 +593,7 @@ class _ScriptRun: ): break except exceptions.ConditionError as ex: - _LOGGER.warning("Error in 'until' evaluation: %s", ex) + _LOGGER.warning("Error in 'until' evaluation:\n%s", ex) break if saved_repeat_vars: @@ -614,7 +614,7 @@ class _ScriptRun: await self._async_run_script(script) return except exceptions.ConditionError as ex: - _LOGGER.warning("Error in 'choose' evaluation: %s", ex) + _LOGGER.warning("Error in 'choose' evaluation:\n%s", ex) if choose_data["default"]: await self._async_run_script(choose_data["default"]) diff --git a/tests/helpers/test_condition.py b/tests/helpers/test_condition.py index 63ef9ba56d8..2c35a3c8b15 100644 --- a/tests/helpers/test_condition.py +++ b/tests/helpers/test_condition.py @@ -386,8 +386,12 @@ async def test_if_numeric_state_raises_on_unavailable(hass, caplog): async def test_state_raises(hass): """Test that state raises ConditionError on errors.""" + # No entity + with pytest.raises(ConditionError, match="no entity"): + condition.state(hass, entity=None, req_state="missing") + # Unknown entity_id - with pytest.raises(ConditionError, match="Unknown entity"): + with pytest.raises(ConditionError, match="unknown entity"): test = await condition.async_from_config( hass, { @@ -400,7 +404,7 @@ async def test_state_raises(hass): test(hass) # Unknown attribute - with pytest.raises(ConditionError, match=r"Attribute .* does not exist"): + with pytest.raises(ConditionError, match=r"attribute .* does not exist"): test = await condition.async_from_config( hass, { @@ -414,6 +418,20 @@ async def test_state_raises(hass): hass.states.async_set("sensor.door", "open") test(hass) + # Unknown state entity + with pytest.raises(ConditionError, match="input_text.missing"): + test = await condition.async_from_config( + hass, + { + "condition": "state", + "entity_id": "sensor.door", + "state": "input_text.missing", + }, + ) + + hass.states.async_set("sensor.door", "open") + test(hass) + async def test_state_multiple_entities(hass): """Test with multiple entities in condition.""" @@ -564,7 +582,6 @@ async def test_state_using_input_entities(hass): "state": [ "input_text.hello", "input_select.hello", - "input_number.not_exist", "salut", ], }, @@ -616,7 +633,7 @@ async def test_state_using_input_entities(hass): async def test_numeric_state_raises(hass): """Test that numeric_state raises ConditionError on errors.""" # Unknown entity_id - with pytest.raises(ConditionError, match="Unknown entity"): + with pytest.raises(ConditionError, match="unknown entity"): test = await condition.async_from_config( hass, { @@ -629,7 +646,7 @@ async def test_numeric_state_raises(hass): test(hass) # Unknown attribute - with pytest.raises(ConditionError, match=r"Attribute .* does not exist"): + with pytest.raises(ConditionError, match=r"attribute .* does not exist"): test = await condition.async_from_config( hass, { @@ -659,7 +676,7 @@ async def test_numeric_state_raises(hass): test(hass) # Unavailable state - with pytest.raises(ConditionError, match="State is not available"): + with pytest.raises(ConditionError, match="state of .* is unavailable"): test = await condition.async_from_config( hass, { @@ -687,7 +704,7 @@ async def test_numeric_state_raises(hass): test(hass) # Below entity missing - with pytest.raises(ConditionError, match="below entity"): + with pytest.raises(ConditionError, match="'below' entity"): test = await condition.async_from_config( hass, { @@ -700,8 +717,16 @@ async def test_numeric_state_raises(hass): hass.states.async_set("sensor.temperature", 50) test(hass) + # Below entity not a number + with pytest.raises( + ConditionError, + match="'below'.*input_number.missing.*cannot be processed as a number", + ): + hass.states.async_set("input_number.missing", "number") + test(hass) + # Above entity missing - with pytest.raises(ConditionError, match="above entity"): + with pytest.raises(ConditionError, match="'above' entity"): test = await condition.async_from_config( hass, { @@ -714,6 +739,14 @@ async def test_numeric_state_raises(hass): hass.states.async_set("sensor.temperature", 50) test(hass) + # Above entity not a number + with pytest.raises( + ConditionError, + match="'above'.*input_number.missing.*cannot be processed as a number", + ): + hass.states.async_set("input_number.missing", "number") + test(hass) + async def test_numeric_state_multiple_entities(hass): """Test with multiple entities in condition.""" @@ -849,7 +882,10 @@ async def test_zone_raises(hass): }, ) - with pytest.raises(ConditionError, match="Unknown zone"): + with pytest.raises(ConditionError, match="no zone"): + condition.zone(hass, zone_ent=None, entity="sensor.any") + + with pytest.raises(ConditionError, match="unknown zone"): test(hass) hass.states.async_set( @@ -858,7 +894,10 @@ async def test_zone_raises(hass): {"name": "home", "latitude": 2.1, "longitude": 1.1, "radius": 10}, ) - with pytest.raises(ConditionError, match="Unknown entity"): + with pytest.raises(ConditionError, match="no entity"): + condition.zone(hass, zone_ent="zone.home", entity=None) + + with pytest.raises(ConditionError, match="unknown entity"): test(hass) hass.states.async_set( diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py new file mode 100644 index 00000000000..959f0846cae --- /dev/null +++ b/tests/test_exceptions.py @@ -0,0 +1,46 @@ +"""Test to verify that Home Assistant exceptions work.""" +from homeassistant.exceptions import ( + ConditionErrorContainer, + ConditionErrorIndex, + ConditionErrorMessage, +) + + +def test_conditionerror_format(): + """Test ConditionError stringifiers.""" + error1 = ConditionErrorMessage("test", "A test error") + assert str(error1) == "In 'test' condition: A test error" + + error2 = ConditionErrorMessage("test", "Another error") + assert str(error2) == "In 'test' condition: Another error" + + error_pos1 = ConditionErrorIndex("box", index=0, total=2, error=error1) + assert ( + str(error_pos1) + == """In 'box' (item 1 of 2): + In 'test' condition: A test error""" + ) + + error_pos2 = ConditionErrorIndex("box", index=1, total=2, error=error2) + assert ( + str(error_pos2) + == """In 'box' (item 2 of 2): + In 'test' condition: Another error""" + ) + + error_container1 = ConditionErrorContainer("box", errors=[error_pos1, error_pos2]) + print(error_container1) + assert ( + str(error_container1) + == """In 'box' (item 1 of 2): + In 'test' condition: A test error +In 'box' (item 2 of 2): + In 'test' condition: Another error""" + ) + + error_pos3 = ConditionErrorIndex("box", index=0, total=1, error=error1) + assert ( + str(error_pos3) + == """In 'box': + In 'test' condition: A test error""" + )