221 files
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
import asyncio
|
||||
from dataclasses import dataclass, field
|
||||
import importlib
|
||||
from pathlib import Path
|
||||
@@ -39,10 +40,10 @@ from homeassistant.setup import ATTR_COMPONENT
|
||||
|
||||
from .const import DOMAIN, LOGGER
|
||||
from .core_compat import load_service_descriptions
|
||||
from .service_icons import async_inject_service_icons, async_remove_service_icons
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import asyncio
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Callable, Iterable
|
||||
from types import ModuleType
|
||||
|
||||
|
||||
@@ -310,8 +311,9 @@ class AbstractSpookEntityComponentService(AbstractSpookServiceBase, Generic[_Ent
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
# Most of these track something it put into Home Assistant, to undo on unload.
|
||||
@dataclass
|
||||
class SpookServiceManager:
|
||||
class SpookServiceManager: # pylint: disable=too-many-instance-attributes
|
||||
"""Class to manage Spook services."""
|
||||
|
||||
hass: HomeAssistant
|
||||
@@ -325,6 +327,8 @@ class SpookServiceManager:
|
||||
_selector_translation_overrides: dict[tuple[str, str, str], str | None] = field(
|
||||
default_factory=dict
|
||||
)
|
||||
# The icons Spook gave its actions on other domains, as (domain, action).
|
||||
_injected_service_icons: set[tuple[str, str]] = field(default_factory=set)
|
||||
# Services for a domain that was not loaded yet, by that domain.
|
||||
_waiting_for_domain: dict[str, list[AbstractSpookService]] = field(
|
||||
default_factory=dict
|
||||
@@ -332,6 +336,17 @@ class SpookServiceManager:
|
||||
# Everything to undo on unload: the listeners, and any translation
|
||||
# injection still on its way.
|
||||
_on_unload: list[Callable[[], None]] = field(default_factory=list)
|
||||
# The languages Spook's strings went into. Not just the server's: every
|
||||
# person picks their own in their profile, and the frontend asks for that
|
||||
# one. #1820.
|
||||
_languages: set[str] = field(default_factory=set)
|
||||
# A language whose injection is on its way, so a second request for it
|
||||
# waits for that one instead of reading the cache halfway.
|
||||
_language_tasks: dict[str, asyncio.Task[None]] = field(default_factory=dict)
|
||||
_starting_languages: set[str] = field(default_factory=set)
|
||||
# Whether Home Assistant's translation loading is still being followed.
|
||||
# Turned off the moment unloading starts, for a load already under way.
|
||||
follows_translation_loads: bool = False
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Post initialization."""
|
||||
@@ -382,8 +397,137 @@ class SpookServiceManager:
|
||||
for module in modules:
|
||||
self._async_setup_service_module(module)
|
||||
|
||||
self._async_follow_translation_loads()
|
||||
await self.async_inject_service_translations()
|
||||
|
||||
@callback
|
||||
def _async_follow_translation_loads(self) -> None:
|
||||
"""Inject into every language Home Assistant loads, as it loads it.
|
||||
|
||||
Home Assistant loads a language the moment somebody asks for it, and
|
||||
says nothing when it does. Spook's strings for a domain that is not
|
||||
its own have to be written into that language by hand, so it follows
|
||||
the loading on this one cache: once a language is in for the first
|
||||
time, Spook's strings go in too, before the caller reads them.
|
||||
|
||||
The cache keeps its attributes in slots, so its method cannot be
|
||||
swapped on the instance. Its class can, for a subclass that adds no
|
||||
slots of its own, and only this Home Assistant's cache is touched.
|
||||
"""
|
||||
translations_cache = _async_get_translations_cache(self.hass)
|
||||
original_class = type(translations_cache)
|
||||
if not callable(getattr(original_class, "async_load", None)):
|
||||
LOGGER.warning(
|
||||
"Unable to follow Home Assistant's translation loading, "
|
||||
"Spook's actions are only translated in the server's language"
|
||||
)
|
||||
return
|
||||
|
||||
manager = self
|
||||
|
||||
class _FollowedTranslationCache(original_class): # type: ignore[misc,valid-type]
|
||||
"""Home Assistant's translation cache, with Spook along."""
|
||||
|
||||
__slots__ = ()
|
||||
|
||||
async def async_load(self, language: str, components: set[str]) -> None:
|
||||
"""Load as ever, then add Spook's strings to a new language."""
|
||||
await super().async_load(language, components)
|
||||
# Unloading can have happened while that waited on the lock.
|
||||
if manager.follows_translation_loads:
|
||||
await manager.async_follow_language(language)
|
||||
|
||||
try:
|
||||
translations_cache.__class__ = _FollowedTranslationCache
|
||||
except TypeError:
|
||||
LOGGER.warning(
|
||||
"Unable to follow Home Assistant's translation loading, "
|
||||
"Spook's actions are only translated in the server's language"
|
||||
)
|
||||
return
|
||||
|
||||
self.follows_translation_loads = True
|
||||
|
||||
@callback
|
||||
def _stop_following() -> None:
|
||||
self.follows_translation_loads = False
|
||||
# Only when nothing swapped it again since.
|
||||
if translations_cache.__class__ is _FollowedTranslationCache:
|
||||
translations_cache.__class__ = original_class
|
||||
|
||||
self._on_unload.append(_stop_following)
|
||||
|
||||
async def async_follow_language(self, language: str) -> None:
|
||||
"""Translate Spook's actions into a language loaded for the first time.
|
||||
|
||||
One injection per language, which every request for that language
|
||||
waits for. Only one that went through marks the language as done; a
|
||||
failed or cancelled one is tried again by the next request.
|
||||
"""
|
||||
if language in self._languages or language in self._starting_languages:
|
||||
return
|
||||
|
||||
task = self._language_tasks.get(language)
|
||||
if task is None or task.done():
|
||||
# A finished one is still listed until its callback has run; one
|
||||
# that failed is not a reason to stop trying.
|
||||
task = self._async_start_language_task(language)
|
||||
elif task is asyncio.current_task():
|
||||
# The injection itself loading this language again.
|
||||
return
|
||||
|
||||
try:
|
||||
# Shielded: one caller being cancelled must not cancel the
|
||||
# injection the others are waiting for.
|
||||
await asyncio.shield(task)
|
||||
except asyncio.CancelledError:
|
||||
# Only swallowed when unloading cancelled Spook's part; the caller
|
||||
# being cancelled itself goes on as it should.
|
||||
current = asyncio.current_task()
|
||||
if current is not None and current.cancelling():
|
||||
raise
|
||||
# pylint: disable-next=broad-exception-caught
|
||||
except Exception: # noqa: BLE001, S110
|
||||
# Logged once, where the task finishes. The caller only asked for
|
||||
# translations, and Spook's part going wrong must not take theirs
|
||||
# with it.
|
||||
pass
|
||||
|
||||
@callback
|
||||
def _async_start_language_task(self, language: str) -> asyncio.Task[None]:
|
||||
"""Start injecting into a language, in a task unloading cancels."""
|
||||
# Home Assistant starts a task eagerly: the injection runs, and loads
|
||||
# this language again, before the task is handed back to be listed.
|
||||
self._starting_languages.add(language)
|
||||
try:
|
||||
task = self.hass.async_create_task(
|
||||
self.async_inject_service_translations([language]),
|
||||
f"Inject Spook service translations in {language}",
|
||||
)
|
||||
finally:
|
||||
self._starting_languages.discard(language)
|
||||
self._language_tasks[language] = task
|
||||
self._on_unload.append(task.cancel)
|
||||
|
||||
@callback
|
||||
def _finished(_task: asyncio.Task[None]) -> None:
|
||||
self._language_tasks.pop(language, None)
|
||||
if task.cancel in self._on_unload:
|
||||
self._on_unload.remove(task.cancel)
|
||||
if task.cancelled():
|
||||
return
|
||||
if (err := task.exception()) is not None:
|
||||
LOGGER.error(
|
||||
"Spook could not translate its actions into %s: %s",
|
||||
language,
|
||||
err,
|
||||
)
|
||||
return
|
||||
self._languages.add(language)
|
||||
|
||||
task.add_done_callback(_finished)
|
||||
return task
|
||||
|
||||
@callback
|
||||
def _async_setup_service_module(self, module: ModuleType) -> None:
|
||||
"""Set up a single service module, isolating failures.
|
||||
@@ -461,12 +605,15 @@ class SpookServiceManager:
|
||||
for service in waiting:
|
||||
self._async_setup_service(service, type(service).__module__)
|
||||
|
||||
# The descriptions went in with the registration, the translations did
|
||||
# not: those are injected for every registered service in one go.
|
||||
# The descriptions went in with the registration, the translations and
|
||||
# icons did not: those are injected for every registered service in one
|
||||
# go.
|
||||
self._async_reinject_service_translations()
|
||||
|
||||
@callback
|
||||
def _async_reinject_service_translations(self) -> None:
|
||||
def _async_reinject_service_translations(
|
||||
self, languages: Iterable[str] | None = None
|
||||
) -> None:
|
||||
"""Inject the translations again, in a task unloading cancels.
|
||||
|
||||
Injecting waits on loading translations before it writes anything.
|
||||
@@ -475,7 +622,7 @@ class SpookServiceManager:
|
||||
back.
|
||||
"""
|
||||
task = self.hass.async_create_task(
|
||||
self.async_inject_service_translations(),
|
||||
self.async_inject_service_translations(languages),
|
||||
"Inject Spook service translations",
|
||||
)
|
||||
self._on_unload.append(task.cancel)
|
||||
@@ -593,9 +740,9 @@ class SpookServiceManager:
|
||||
self,
|
||||
service: AbstractSpookService,
|
||||
cached_spook_translations: dict[str, str],
|
||||
language: str,
|
||||
) -> None:
|
||||
"""Inject service translation strings into Home Assistant's cache."""
|
||||
language = self.hass.config.language
|
||||
component_cache = self._translation_component_cache(
|
||||
language,
|
||||
service.domain,
|
||||
@@ -640,6 +787,7 @@ class SpookServiceManager:
|
||||
self,
|
||||
service: AbstractSpookService,
|
||||
cached_spook_translations: dict[str, str],
|
||||
language: str,
|
||||
) -> None:
|
||||
"""Inject the option labels of a Spook service's selectors.
|
||||
|
||||
@@ -651,7 +799,6 @@ class SpookServiceManager:
|
||||
if not (keys := self._selector_translation_keys(service)):
|
||||
return
|
||||
|
||||
language = self.hass.config.language
|
||||
component_cache = self._translation_component_cache(
|
||||
language,
|
||||
service.domain,
|
||||
@@ -681,8 +828,13 @@ class SpookServiceManager:
|
||||
)
|
||||
component_cache[target_key] = value
|
||||
|
||||
async def async_inject_service_translations(self) -> None:
|
||||
"""Inject Spook service strings into Home Assistant translations."""
|
||||
async def async_inject_service_translations(
|
||||
self, languages: Iterable[str] | None = None
|
||||
) -> None:
|
||||
"""Inject Spook service strings and icons into Home Assistant.
|
||||
|
||||
Into the languages given, or every language Spook has written into.
|
||||
"""
|
||||
services = [
|
||||
service
|
||||
for service in self._services
|
||||
@@ -692,42 +844,53 @@ class SpookServiceManager:
|
||||
if not services:
|
||||
return
|
||||
|
||||
await async_get_translations(
|
||||
self.hass,
|
||||
self.hass.config.language,
|
||||
SERVICE_TRANSLATION_CATEGORY,
|
||||
{DOMAIN, *(service.domain for service in services)},
|
||||
)
|
||||
cached_spook_translations = async_get_cached_translations(
|
||||
self.hass,
|
||||
self.hass.config.language,
|
||||
SERVICE_TRANSLATION_CATEGORY,
|
||||
DOMAIN,
|
||||
)
|
||||
|
||||
for service in services:
|
||||
self._inject_service_translation_strings(
|
||||
service,
|
||||
cached_spook_translations,
|
||||
)
|
||||
if languages is None:
|
||||
# The server's own is always one of them. So is a language still
|
||||
# on its first injection: that one took its list of actions when it
|
||||
# started, and an action registered since would be left out.
|
||||
self._languages.add(self.hass.config.language)
|
||||
languages = self._languages | set(self._language_tasks)
|
||||
|
||||
domains = {DOMAIN, *(service.domain for service in services)}
|
||||
await async_get_translations(
|
||||
for language in list(languages):
|
||||
await self._async_inject_translations_for(language, services, domains)
|
||||
|
||||
await async_inject_service_icons(
|
||||
self.hass,
|
||||
self.hass.config.language,
|
||||
SELECTOR_TRANSLATION_CATEGORY,
|
||||
domains,
|
||||
(
|
||||
(service.domain, service.service, self._service_schema_key(service))
|
||||
for service in services
|
||||
),
|
||||
self._injected_service_icons,
|
||||
)
|
||||
|
||||
async def _async_inject_translations_for(
|
||||
self,
|
||||
language: str,
|
||||
services: list[AbstractSpookService],
|
||||
domains: set[str],
|
||||
) -> None:
|
||||
"""Inject the strings and selector labels of these services in a language."""
|
||||
await async_get_translations(
|
||||
self.hass, language, SERVICE_TRANSLATION_CATEGORY, domains
|
||||
)
|
||||
cached_spook_translations = async_get_cached_translations(
|
||||
self.hass, language, SERVICE_TRANSLATION_CATEGORY, DOMAIN
|
||||
)
|
||||
for service in services:
|
||||
self._inject_service_translation_strings(
|
||||
service, cached_spook_translations, language
|
||||
)
|
||||
|
||||
await async_get_translations(
|
||||
self.hass, language, SELECTOR_TRANSLATION_CATEGORY, domains
|
||||
)
|
||||
cached_spook_selector_translations = async_get_cached_translations(
|
||||
self.hass,
|
||||
self.hass.config.language,
|
||||
SELECTOR_TRANSLATION_CATEGORY,
|
||||
DOMAIN,
|
||||
self.hass, language, SELECTOR_TRANSLATION_CATEGORY, DOMAIN
|
||||
)
|
||||
for service in services:
|
||||
self._inject_selector_translation_strings(
|
||||
service,
|
||||
cached_spook_selector_translations,
|
||||
service, cached_spook_selector_translations, language
|
||||
)
|
||||
|
||||
@callback
|
||||
@@ -735,7 +898,10 @@ class SpookServiceManager:
|
||||
"""Re-inject service translations when the language changes."""
|
||||
if "language" not in event.data:
|
||||
return
|
||||
self._async_reinject_service_translations()
|
||||
|
||||
# The language it was is kept: somebody can still have it as theirs.
|
||||
self._languages.add(self.hass.config.language)
|
||||
self._async_reinject_service_translations([self.hass.config.language])
|
||||
|
||||
@callback
|
||||
def async_clear_service_translation_overrides(self) -> None:
|
||||
@@ -804,3 +970,4 @@ class SpookServiceManager:
|
||||
self.hass.data.pop(SERVICE_DESCRIPTION_CACHE, None)
|
||||
|
||||
self.async_clear_service_translation_overrides()
|
||||
async_remove_service_icons(self.hass, self._injected_service_icons)
|
||||
|
||||
Reference in New Issue
Block a user