diff --git a/homeassistant/helpers/translation.py b/homeassistant/helpers/translation.py index 182747ec415..81f7a6f8e74 100644 --- a/homeassistant/helpers/translation.py +++ b/homeassistant/helpers/translation.py @@ -5,6 +5,7 @@ from __future__ import annotations import asyncio from collections.abc import Iterable, Mapping from contextlib import suppress +from dataclasses import dataclass import logging import pathlib import string @@ -140,22 +141,34 @@ async def _async_get_component_strings( return translations_by_language +@dataclass(slots=True) +class _TranslationsCacheData: + """Data for the translation cache. + + This class contains data that is designed to be shared + between multiple instances of the translation cache so + we only have to load the data once. + """ + + loaded: dict[str, set[str]] + cache: dict[str, dict[str, dict[str, dict[str, str]]]] + + class _TranslationCache: """Cache for flattened translations.""" - __slots__ = ("hass", "loaded", "cache", "lock") + __slots__ = ("hass", "cache_data", "lock") def __init__(self, hass: HomeAssistant) -> None: """Initialize the cache.""" self.hass = hass - self.loaded: dict[str, set[str]] = {} - self.cache: dict[str, dict[str, dict[str, dict[str, str]]]] = {} + self.cache_data = _TranslationsCacheData({}, {}) self.lock = asyncio.Lock() @callback def async_is_loaded(self, language: str, components: set[str]) -> bool: """Return if the given components are loaded for the language.""" - return components.issubset(self.loaded.get(language, set())) + return components.issubset(self.cache_data.loaded.get(language, set())) async def async_load( self, @@ -163,7 +176,7 @@ class _TranslationCache: components: set[str], ) -> None: """Load resources into the cache.""" - loaded = self.loaded.setdefault(language, set()) + loaded = self.cache_data.loaded.setdefault(language, set()) if components_to_load := components - loaded: # Translations are never unloaded so if there are no components to load # we can skip the lock which reduces contention when multiple different @@ -193,7 +206,7 @@ class _TranslationCache: components: set[str], ) -> dict[str, str]: """Read resources from the cache.""" - category_cache = self.cache.get(language, {}).get(category, {}) + category_cache = self.cache_data.cache.get(language, {}).get(category, {}) # If only one component was requested, return it directly # to avoid merging the dictionaries and keeping additional # copies of the same data in memory. @@ -207,6 +220,7 @@ class _TranslationCache: async def _async_load(self, language: str, components: set[str]) -> None: """Populate the cache for a given set of components.""" + loaded = self.cache_data.loaded _LOGGER.debug( "Cache miss for %s: %s", language, @@ -240,7 +254,7 @@ class _TranslationCache: language, components, translation_by_language_strings[language] ) - loaded_english_components = self.loaded.setdefault(LOCALE_EN, set()) + loaded_english_components = loaded.setdefault(LOCALE_EN, set()) # Since we just loaded english anyway we can avoid loading # again if they switch back to english. if loaded_english_components.isdisjoint(components): @@ -249,7 +263,7 @@ class _TranslationCache: ) loaded_english_components.update(components) - self.loaded[language].update(components) + loaded[language].update(components) def _validate_placeholders( self, @@ -304,7 +318,7 @@ class _TranslationCache: ) -> None: """Extract resources into the cache.""" resource: dict[str, Any] | str - cached = self.cache.setdefault(language, {}) + cached = self.cache_data.cache.setdefault(language, {}) categories = { category for component in translation_strings.values() diff --git a/tests/conftest.py b/tests/conftest.py index a034ec7ad8f..b90e6fb342f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1165,6 +1165,31 @@ def mock_get_source_ip() -> Generator[patch, None, None]: patcher.stop() +@pytest.fixture(autouse=True, scope="session") +def translations_once() -> Generator[patch, None, None]: + """Only load translations once per session.""" + from homeassistant.helpers.translation import _TranslationsCacheData + + cache = _TranslationsCacheData({}, {}) + patcher = patch( + "homeassistant.helpers.translation._TranslationsCacheData", + return_value=cache, + ) + patcher.start() + try: + yield patcher + finally: + patcher.stop() + + +@pytest.fixture +def disable_translations_once(translations_once): + """Override loading translations once.""" + translations_once.stop() + yield + translations_once.start() + + @pytest.fixture def mock_zeroconf() -> Generator[None, None, None]: """Mock zeroconf.""" diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index 646b0ec0abf..fda66734431 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -213,6 +213,7 @@ async def test_update_state_adds_entities_with_update_before_add_false( assert not ent.update.called +@pytest.mark.usefixtures("disable_translations_once") async def test_set_scan_interval_via_platform(hass: HomeAssistant) -> None: """Test the setting of the scan interval via platform.""" @@ -260,6 +261,7 @@ async def test_adding_entities_with_generator_and_thread_callback( await component.async_add_entities(create_entity(i) for i in range(2)) +@pytest.mark.usefixtures("disable_translations_once") async def test_platform_warn_slow_setup(hass: HomeAssistant) -> None: """Warn we log when platform setup takes a long time.""" platform = MockPlatform() diff --git a/tests/helpers/test_translation.py b/tests/helpers/test_translation.py index b841e1ab5ac..abb754cd435 100644 --- a/tests/helpers/test_translation.py +++ b/tests/helpers/test_translation.py @@ -16,6 +16,11 @@ from homeassistant.loader import async_get_integration from homeassistant.setup import async_setup_component +@pytest.fixture(autouse=True) +def _disable_translations_once(disable_translations_once): + """Override loading translations once.""" + + @pytest.fixture def mock_config_flows(): """Mock the config flows."""