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":
translation_key = "not_valid_fan_mode"
raise ServiceValidationError(
f"The {mode_type}_mode {mode} is not a valid {mode_type}_mode:"
f" {modes_str}",
translation_domain=DOMAIN,
translation_key=translation_key,
translation_placeholders={

View File

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

View File

@ -13,6 +13,9 @@ if TYPE_CHECKING:
class HomeAssistantError(Exception):
"""General Home Assistant exception occurred."""
_message: str | None = None
generate_message: bool = False
def __init__(
self,
*args: object,
@ -21,11 +24,42 @@ class HomeAssistantError(Exception):
translation_placeholders: dict[str, str] | None = None,
) -> None:
"""Initialize exception."""
if not args and translation_key and translation_domain:
self.generate_message = True
args = (translation_key,)
super().__init__(*args)
self.translation_domain = translation_domain
self.translation_key = translation_key
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]):
"""A validation exception occurred when validating the configuration."""
@ -47,10 +81,6 @@ class ConfigValidationError(HomeAssistantError, ExceptionGroup[Exception]):
)
self._message = message
def __str__(self) -> str:
"""Return exception message string."""
return self._message
class ServiceValidationError(HomeAssistantError):
"""A validation exception occurred when calling a service."""

View File

@ -4,6 +4,7 @@ from __future__ import annotations
import asyncio
from collections.abc import Iterable, Mapping
from contextlib import suppress
import logging
import string
from typing import Any
@ -13,7 +14,7 @@ from homeassistant.const import (
STATE_UNAVAILABLE,
STATE_UNKNOWN,
)
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.core import Event, HomeAssistant, async_get_hass, callback
from homeassistant.loader import (
Integration,
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
def async_translate_state(
hass: HomeAssistant,

View File

@ -300,7 +300,7 @@ async def test_preset_mode_validation(
with pytest.raises(
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:
await hass.services.async_call(
DOMAIN,
@ -313,13 +313,13 @@ async def test_preset_mode_validation(
)
assert (
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"
with pytest.raises(
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:
await hass.services.async_call(
DOMAIN,
@ -331,13 +331,14 @@ async def test_preset_mode_validation(
blocking=True,
)
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"
with pytest.raises(
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:
await hass.services.async_call(
DOMAIN,
@ -348,7 +349,10 @@ async def test_preset_mode_validation(
},
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"

View File

@ -2,12 +2,17 @@
from __future__ import annotations
from typing import Any
from unittest.mock import patch
import pytest
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import (
ConditionErrorContainer,
ConditionErrorIndex,
ConditionErrorMessage,
HomeAssistantError,
TemplateError,
)
@ -62,3 +67,55 @@ def test_template_message(arg: str | Exception, expected: str) -> None:
"""Ensure we can create TemplateError."""
template_error = TemplateError(arg)
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