Generate HomeAssistantError message from English translations (#113305)

* Fetch exception message from translation cache

* Improve tests

* Return translation key without path, cleanup

* Fetch translations when string variant is requested

* Move import

* revert changes ConfigValidationError

* mypy

* Remove _str__ method instead

* Type _message for mqtt template exception classes

* Revert changes made to test_config.py

* Undo changes TemplateError

* Follow up comments and test coverage
This commit is contained in:
Jan Bouwhuis 2024-03-16 22:56:48 +01:00 committed by GitHub
parent 2bc4a5067d
commit 554aefed42
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 136 additions and 13 deletions

View file

@ -647,8 +647,6 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
elif mode_type == "fan": elif mode_type == "fan":
translation_key = "not_valid_fan_mode" translation_key = "not_valid_fan_mode"
raise ServiceValidationError( raise ServiceValidationError(
f"The {mode_type}_mode {mode} is not a valid {mode_type}_mode:"
f" {modes_str}",
translation_domain=DOMAIN, translation_domain=DOMAIN,
translation_key=translation_key, translation_key=translation_key,
translation_placeholders={ translation_placeholders={

View file

@ -116,6 +116,8 @@ class MqttOriginInfo(TypedDict, total=False):
class MqttCommandTemplateException(ServiceValidationError): class MqttCommandTemplateException(ServiceValidationError):
"""Handle MqttCommandTemplate exceptions.""" """Handle MqttCommandTemplate exceptions."""
_message: str
def __init__( def __init__(
self, self,
*args: object, *args: object,
@ -227,6 +229,8 @@ class MqttCommandTemplate:
class MqttValueTemplateException(TemplateError): class MqttValueTemplateException(TemplateError):
"""Handle MqttValueTemplate exceptions.""" """Handle MqttValueTemplate exceptions."""
_message: str
def __init__( def __init__(
self, self,
*args: object, *args: object,

View file

@ -13,6 +13,9 @@ if TYPE_CHECKING:
class HomeAssistantError(Exception): class HomeAssistantError(Exception):
"""General Home Assistant exception occurred.""" """General Home Assistant exception occurred."""
_message: str | None = None
generate_message: bool = False
def __init__( def __init__(
self, self,
*args: object, *args: object,
@ -21,11 +24,42 @@ class HomeAssistantError(Exception):
translation_placeholders: dict[str, str] | None = None, translation_placeholders: dict[str, str] | None = None,
) -> None: ) -> None:
"""Initialize exception.""" """Initialize exception."""
if not args and translation_key and translation_domain:
self.generate_message = True
args = (translation_key,)
super().__init__(*args) super().__init__(*args)
self.translation_domain = translation_domain self.translation_domain = translation_domain
self.translation_key = translation_key self.translation_key = translation_key
self.translation_placeholders = translation_placeholders self.translation_placeholders = translation_placeholders
def __str__(self) -> str:
"""Return exception message.
If no message was passed to `__init__`, the exception message is generated from
the translation_key. The message will be in English, regardless of the configured
language.
"""
if self._message:
return self._message
if not self.generate_message:
self._message = super().__str__()
return self._message
if TYPE_CHECKING:
assert self.translation_key is not None
assert self.translation_domain is not None
# pylint: disable-next=import-outside-toplevel
from .helpers.translation import async_get_exception_message
self._message = async_get_exception_message(
self.translation_domain, self.translation_key, self.translation_placeholders
)
return self._message
class ConfigValidationError(HomeAssistantError, ExceptionGroup[Exception]): class ConfigValidationError(HomeAssistantError, ExceptionGroup[Exception]):
"""A validation exception occurred when validating the configuration.""" """A validation exception occurred when validating the configuration."""
@ -47,10 +81,6 @@ class ConfigValidationError(HomeAssistantError, ExceptionGroup[Exception]):
) )
self._message = message self._message = message
def __str__(self) -> str:
"""Return exception message string."""
return self._message
class ServiceValidationError(HomeAssistantError): class ServiceValidationError(HomeAssistantError):
"""A validation exception occurred when calling a service.""" """A validation exception occurred when calling a service."""

View file

@ -4,6 +4,7 @@ from __future__ import annotations
import asyncio import asyncio
from collections.abc import Iterable, Mapping from collections.abc import Iterable, Mapping
from contextlib import suppress
import logging import logging
import string import string
from typing import Any from typing import Any
@ -13,7 +14,7 @@ from homeassistant.const import (
STATE_UNAVAILABLE, STATE_UNAVAILABLE,
STATE_UNKNOWN, STATE_UNKNOWN,
) )
from homeassistant.core import Event, HomeAssistant, callback from homeassistant.core import Event, HomeAssistant, async_get_hass, callback
from homeassistant.loader import ( from homeassistant.loader import (
Integration, Integration,
async_get_config_flows, async_get_config_flows,
@ -528,6 +529,35 @@ def async_translations_loaded(hass: HomeAssistant, components: set[str]) -> bool
) )
@callback
def async_get_exception_message(
translation_domain: str,
translation_key: str,
translation_placeholders: dict[str, str] | None = None,
) -> str:
"""Return a translated exception message.
Defaults to English, requires translations to already be cached.
"""
language = "en"
hass = async_get_hass()
localize_key = (
f"component.{translation_domain}.exceptions.{translation_key}.message"
)
translations = async_get_cached_translations(hass, language, "exceptions")
if localize_key in translations:
if message := translations[localize_key]:
message = message.rstrip(".")
if not translation_placeholders:
return message
with suppress(KeyError):
message = message.format(**translation_placeholders)
return message
# We return the translation key when was not found in the cache
return translation_key
@callback @callback
def async_translate_state( def async_translate_state(
hass: HomeAssistant, hass: HomeAssistant,

View file

@ -300,7 +300,7 @@ async def test_preset_mode_validation(
with pytest.raises( with pytest.raises(
ServiceValidationError, ServiceValidationError,
match="The preset_mode invalid is not a valid preset_mode: home, away", match="Preset mode invalid is not valid. Valid preset modes are: home, away",
) as exc: ) as exc:
await hass.services.async_call( await hass.services.async_call(
DOMAIN, DOMAIN,
@ -313,13 +313,13 @@ async def test_preset_mode_validation(
) )
assert ( assert (
str(exc.value) str(exc.value)
== "The preset_mode invalid is not a valid preset_mode: home, away" == "Preset mode invalid is not valid. Valid preset modes are: home, away"
) )
assert exc.value.translation_key == "not_valid_preset_mode" assert exc.value.translation_key == "not_valid_preset_mode"
with pytest.raises( with pytest.raises(
ServiceValidationError, ServiceValidationError,
match="The swing_mode invalid is not a valid swing_mode: auto, off", match="Swing mode invalid is not valid. Valid swing modes are: auto, off",
) as exc: ) as exc:
await hass.services.async_call( await hass.services.async_call(
DOMAIN, DOMAIN,
@ -331,13 +331,14 @@ async def test_preset_mode_validation(
blocking=True, blocking=True,
) )
assert ( assert (
str(exc.value) == "The swing_mode invalid is not a valid swing_mode: auto, off" str(exc.value)
== "Swing mode invalid is not valid. Valid swing modes are: auto, off"
) )
assert exc.value.translation_key == "not_valid_swing_mode" assert exc.value.translation_key == "not_valid_swing_mode"
with pytest.raises( with pytest.raises(
ServiceValidationError, ServiceValidationError,
match="The fan_mode invalid is not a valid fan_mode: auto, off", match="Fan mode invalid is not valid. Valid fan modes are: auto, off",
) as exc: ) as exc:
await hass.services.async_call( await hass.services.async_call(
DOMAIN, DOMAIN,
@ -348,7 +349,10 @@ async def test_preset_mode_validation(
}, },
blocking=True, blocking=True,
) )
assert str(exc.value) == "The fan_mode invalid is not a valid fan_mode: auto, off" assert (
str(exc.value)
== "Fan mode invalid is not valid. Valid fan modes are: auto, off"
)
assert exc.value.translation_key == "not_valid_fan_mode" assert exc.value.translation_key == "not_valid_fan_mode"

View file

@ -2,12 +2,17 @@
from __future__ import annotations from __future__ import annotations
from typing import Any
from unittest.mock import patch
import pytest import pytest
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ( from homeassistant.exceptions import (
ConditionErrorContainer, ConditionErrorContainer,
ConditionErrorIndex, ConditionErrorIndex,
ConditionErrorMessage, ConditionErrorMessage,
HomeAssistantError,
TemplateError, TemplateError,
) )
@ -62,3 +67,55 @@ def test_template_message(arg: str | Exception, expected: str) -> None:
"""Ensure we can create TemplateError.""" """Ensure we can create TemplateError."""
template_error = TemplateError(arg) template_error = TemplateError(arg)
assert str(template_error) == expected assert str(template_error) == expected
@pytest.mark.parametrize(
("exception_args", "exception_kwargs", "args_base_class", "message"),
[
((), {}, (), ""),
(("bla",), {}, ("bla",), "bla"),
((None,), {}, (None,), "None"),
((type_error_bla := TypeError("bla"),), {}, (type_error_bla,), "bla"),
(
(),
{"translation_domain": "test", "translation_key": "test"},
("test",),
"test",
),
(
(),
{"translation_domain": "test", "translation_key": "bla"},
("bla",),
"{bla} from cache",
),
(
(),
{
"translation_domain": "test",
"translation_key": "bla",
"translation_placeholders": {"bla": "Bla"},
},
("bla",),
"Bla from cache",
),
],
)
async def test_home_assistant_error(
hass: HomeAssistant,
exception_args: tuple[Any,],
exception_kwargs: dict[str, Any],
args_base_class: tuple[Any],
message: str,
) -> None:
"""Test edge cases with HomeAssistantError."""
with patch(
"homeassistant.helpers.translation.async_get_cached_translations",
return_value={"component.test.exceptions.bla.message": "{bla} from cache"},
):
with pytest.raises(HomeAssistantError) as exc:
raise HomeAssistantError(*exception_args, **exception_kwargs)
assert exc.value.args == args_base_class
assert str(exc.value) == message
# Get string of exception again from the cache
assert str(exc.value) == message