221 files

This commit is contained in:
Home Assistant Version Control
2026-10-10 15:24:58 +00:00
parent ae12b88c7d
commit bfafb4c28a
221 changed files with 73725 additions and 3277 deletions
+207 -40
View File
@@ -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)