Skip to content

Commit

Permalink
Add translation support
Browse files Browse the repository at this point in the history
  • Loading branch information
jbouwh committed Oct 23, 2023
1 parent ef18e7b commit c267160
Show file tree
Hide file tree
Showing 5 changed files with 182 additions and 20 deletions.
43 changes: 42 additions & 1 deletion homeassistant/components/websocket_api/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
json_dumps,
)
from homeassistant.helpers.service import async_get_all_descriptions
from homeassistant.helpers.translation import async_get_translations
from homeassistant.helpers.typing import EventType
from homeassistant.loader import (
Integration,
Expand All @@ -58,6 +59,8 @@

ALL_SERVICE_DESCRIPTIONS_JSON_CACHE = "websocket_api_all_service_descriptions_json"

DATA_EXCEPTION_TRANSLATIONS = "websocket_api_exceptions_translations"


@callback
def async_register_commands(
Expand Down Expand Up @@ -240,7 +243,8 @@ async def handle_call_service(
except ServiceValidationError as err:
connection.logger.error(err)
connection.logger.debug("", exc_info=err)
connection.send_error(msg["id"], const.ERR_HOME_ASSISTANT_ERROR, str(err))
message = await async_build_error_message(hass, err)
connection.send_error(msg["id"], const.ERR_HOME_ASSISTANT_ERROR, message)
except HomeAssistantError as err:
connection.logger.exception(err)
connection.send_error(msg["id"], const.ERR_HOME_ASSISTANT_ERROR, str(err))
Expand All @@ -249,6 +253,43 @@ async def handle_call_service(
connection.send_error(msg["id"], const.ERR_UNKNOWN_ERROR, str(err))


async def async_get_exception_translations(
hass: HomeAssistant, domain: str
) -> dict[str, Any]:
"""Get translations for exceptions for a domain."""
return await async_get_translations(
hass, hass.config.language, "exceptions", {domain}
)


async def async_build_error_message(
hass: HomeAssistant, err: ServiceValidationError
) -> str:
"""Build translated error message from exception."""
if (translation_key := err.translation_key) is None or (
domain := err.domain
) is None:
return str(err)

exception_translations: dict[str, dict[str, Any]] = hass.data.setdefault(
DATA_EXCEPTION_TRANSLATIONS, {}
)
if (domain_exception_translations := exception_translations.get(domain)) is None:
domain_exception_translations = await async_get_exception_translations(
hass, domain
)
exception_translations[domain] = domain_exception_translations
exception_message: str | None
if (
exception_message := domain_exception_translations.get(
f"component.{domain}.exceptions.{translation_key}.message",
)
) is None:
return str(err)
place_holders = err.translation_placeholders or {}
return exception_message.format(**place_holders, message=str(err)) # type: ignore[no-any-return]


@callback
def _async_get_allowed_states(
hass: HomeAssistant, connection: ActiveConnection
Expand Down
13 changes: 13 additions & 0 deletions homeassistant/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,19 @@ class HomeAssistantError(Exception):
class ServiceValidationError(HomeAssistantError):
"""A validation exception occurred when calling a service."""

def __init__(
self,
*args: object,
domain: str | None = None,
translation_key: str | None = None,
translation_placeholders: dict[str, str] | None = None,
) -> None:
"""Initialize exception."""
super().__init__(*args)
self.domain = domain
self.translation_key = translation_key
self.translation_placeholders = translation_placeholders


class InvalidEntityFormatError(HomeAssistantError):
"""When an invalid formatted entity is encountered."""
Expand Down
4 changes: 4 additions & 0 deletions homeassistant/helpers/entity_platform.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ def __init__(
self.config_entry: config_entries.ConfigEntry | None = None
self.entities: dict[str, Entity] = {}
self.component_translations: dict[str, Any] = {}
self.exception_translations: dict[str, Any] = {}
self.platform_translations: dict[str, Any] = {}
self.object_id_component_translations: dict[str, Any] = {}
self.object_id_platform_translations: dict[str, Any] = {}
Expand Down Expand Up @@ -328,6 +329,9 @@ async def get_translations(
self.component_translations = await get_translations(
hass.config.language, "entity_component", self.domain
)
self.exception_translations = await get_translations(
hass.config.language, "exceptions", self.domain
)
self.platform_translations = await get_translations(
hass.config.language, "entity", self.platform_name
)
Expand Down
4 changes: 4 additions & 0 deletions script/hassfest/translations.py
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,10 @@ def gen_strings_schema(config: Config, integration: Integration) -> vol.Schema:
),
slug_validator=cv.slug,
),
vol.Optional("exceptions"): cv.schema_with_slug_keys(
{vol.Optional("message"): translation_value_validator},
slug_validator=cv.slug,
),
vol.Optional("services"): cv.schema_with_slug_keys(
{
vol.Required("name"): translation_value_validator,
Expand Down
138 changes: 119 additions & 19 deletions tests/components/websocket_api/test_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -455,10 +455,12 @@ def ha_error_call(_):
hass.services.async_register("domain_test", "ha_error", ha_error_call)

@callback
def service_error_call(_):
def service_validation_error_call(_):
raise ServiceValidationError("error_message")

hass.services.async_register("domain_test", "service_error", service_error_call)
hass.services.async_register(
"domain_test", "service_validation_error", service_validation_error_call
)

async def unknown_error_call(_):
raise ValueError("value_error")
Expand Down Expand Up @@ -486,33 +488,131 @@ async def unknown_error_call(_):
"id": 6,
"type": "call_service",
"domain": "domain_test",
"service": "service_error",
}
)

msg = await websocket_client.receive_json()
assert msg["id"] == 6
assert msg["type"] == const.TYPE_RESULT
assert msg["success"] is False
assert msg["error"]["code"] == "home_assistant_error"
assert msg["error"]["message"] == "error_message"

await websocket_client.send_json(
{
"id": 7,
"type": "call_service",
"domain": "domain_test",
"service": "unknown_error",
}
)

msg = await websocket_client.receive_json()
assert msg["id"] == 7
assert msg["id"] == 6
assert msg["type"] == const.TYPE_RESULT
assert msg["success"] is False
assert msg["error"]["code"] == "unknown_error"
assert msg["error"]["message"] == "value_error"

error_strings = {}
with patch(
"homeassistant.components.websocket_api.commands.async_get_translations",
return_value=error_strings,
) as mock_get_translations:
await websocket_client.send_json(
{
"id": 7,
"type": "call_service",
"domain": "domain_test",
"service": "service_validation_error",
}
)

msg = await websocket_client.receive_json()
assert msg["id"] == 7
assert msg["type"] == const.TYPE_RESULT
assert msg["success"] is False
assert msg["error"]["code"] == "home_assistant_error"
assert msg["error"]["message"] == "error_message"
assert len(mock_get_translations.mock_calls) == 0


@pytest.mark.parametrize(
(
"domain",
"translation_key",
"translation_placeholders",
"translations",
"message",
),
[
(
"test",
"custom_error",
{"option": "beer"},
{
"component.test.exceptions.custom_error.message": "Translated error {option}: {message}"
},
"Translated error beer: error_message",
),
(
"test",
"custom_error",
None,
{
"component.test.exceptions.custom_error.message": "Translated error: {message}"
},
"Translated error: error_message",
),
("test", "custom_error_key_not_exists", None, {}, "error_message"),
],
)
async def test_exception_translations(
hass: HomeAssistant,
websocket_client: MockHAClientWebSocket,
domain: str | None,
translation_key: str | None,
translations: dict[str, str],
translation_placeholders: dict[str, str] | None,
message: str,
) -> None:
"""Test handling exceptions with translations."""

@callback
def service_error_call(_):
raise ServiceValidationError(
"error_message",
domain=domain,
translation_key=translation_key,
translation_placeholders=translation_placeholders,
)

hass.services.async_register(domain, "service_validation_error", service_error_call)

with patch(
"homeassistant.components.websocket_api.commands.async_get_translations",
return_value=translations,
) as mock_get_translations:
await websocket_client.send_json(
{
"id": 7,
"type": "call_service",
"domain": domain,
"service": "service_validation_error",
}
)

msg = await websocket_client.receive_json()
assert msg["id"] == 7
assert msg["type"] == const.TYPE_RESULT
assert msg["success"] is False
assert msg["error"]["code"] == "home_assistant_error"
assert msg["error"]["message"] == message
assert len(mock_get_translations.mock_calls) == 1

# retry using cache
await websocket_client.send_json(
{
"id": 8,
"type": "call_service",
"domain": domain,
"service": "service_validation_error",
}
)

msg = await websocket_client.receive_json()
assert msg["id"] == 8
assert msg["type"] == const.TYPE_RESULT
assert msg["success"] is False
assert msg["error"]["code"] == "home_assistant_error"
assert msg["error"]["message"] == message
assert len(mock_get_translations.mock_calls) == 1


async def test_subscribe_unsubscribe_events(
hass: HomeAssistant, websocket_client
Expand Down

0 comments on commit c267160

Please sign in to comment.