Refine printing of ConditionError (#46838)

* Refine printing of ConditionError

* Improve coverage

* name -> type
This commit is contained in:
Anders Melchiorsen 2021-02-21 14:54:36 +01:00 committed by GitHub
parent e2fd255a96
commit d33a1a5ff8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 272 additions and 73 deletions

View File

@ -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

View File

@ -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,
)

View File

@ -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."""

View File

@ -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

View File

@ -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"])

View File

@ -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(

46
tests/test_exceptions.py Normal file
View File

@ -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"""
)