Only load translations for an integration once per test session (#117118)

This commit is contained in:
J. Nick Koston 2024-05-11 12:00:02 +09:00 committed by GitHub
parent 9e107a02db
commit d7aa24fa50
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 55 additions and 9 deletions

View file

@ -5,6 +5,7 @@ from __future__ import annotations
import asyncio import asyncio
from collections.abc import Iterable, Mapping from collections.abc import Iterable, Mapping
from contextlib import suppress from contextlib import suppress
from dataclasses import dataclass
import logging import logging
import pathlib import pathlib
import string import string
@ -140,22 +141,34 @@ async def _async_get_component_strings(
return translations_by_language 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: class _TranslationCache:
"""Cache for flattened translations.""" """Cache for flattened translations."""
__slots__ = ("hass", "loaded", "cache", "lock") __slots__ = ("hass", "cache_data", "lock")
def __init__(self, hass: HomeAssistant) -> None: def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the cache.""" """Initialize the cache."""
self.hass = hass self.hass = hass
self.loaded: dict[str, set[str]] = {} self.cache_data = _TranslationsCacheData({}, {})
self.cache: dict[str, dict[str, dict[str, dict[str, str]]]] = {}
self.lock = asyncio.Lock() self.lock = asyncio.Lock()
@callback @callback
def async_is_loaded(self, language: str, components: set[str]) -> bool: def async_is_loaded(self, language: str, components: set[str]) -> bool:
"""Return if the given components are loaded for the language.""" """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( async def async_load(
self, self,
@ -163,7 +176,7 @@ class _TranslationCache:
components: set[str], components: set[str],
) -> None: ) -> None:
"""Load resources into the cache.""" """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: if components_to_load := components - loaded:
# Translations are never unloaded so if there are no components to load # Translations are never unloaded so if there are no components to load
# we can skip the lock which reduces contention when multiple different # we can skip the lock which reduces contention when multiple different
@ -193,7 +206,7 @@ class _TranslationCache:
components: set[str], components: set[str],
) -> dict[str, str]: ) -> dict[str, str]:
"""Read resources from the cache.""" """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 # If only one component was requested, return it directly
# to avoid merging the dictionaries and keeping additional # to avoid merging the dictionaries and keeping additional
# copies of the same data in memory. # copies of the same data in memory.
@ -207,6 +220,7 @@ class _TranslationCache:
async def _async_load(self, language: str, components: set[str]) -> None: async def _async_load(self, language: str, components: set[str]) -> None:
"""Populate the cache for a given set of components.""" """Populate the cache for a given set of components."""
loaded = self.cache_data.loaded
_LOGGER.debug( _LOGGER.debug(
"Cache miss for %s: %s", "Cache miss for %s: %s",
language, language,
@ -240,7 +254,7 @@ class _TranslationCache:
language, components, translation_by_language_strings[language] 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 # Since we just loaded english anyway we can avoid loading
# again if they switch back to english. # again if they switch back to english.
if loaded_english_components.isdisjoint(components): if loaded_english_components.isdisjoint(components):
@ -249,7 +263,7 @@ class _TranslationCache:
) )
loaded_english_components.update(components) loaded_english_components.update(components)
self.loaded[language].update(components) loaded[language].update(components)
def _validate_placeholders( def _validate_placeholders(
self, self,
@ -304,7 +318,7 @@ class _TranslationCache:
) -> None: ) -> None:
"""Extract resources into the cache.""" """Extract resources into the cache."""
resource: dict[str, Any] | str resource: dict[str, Any] | str
cached = self.cache.setdefault(language, {}) cached = self.cache_data.cache.setdefault(language, {})
categories = { categories = {
category category
for component in translation_strings.values() for component in translation_strings.values()

View file

@ -1165,6 +1165,31 @@ def mock_get_source_ip() -> Generator[patch, None, None]:
patcher.stop() 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 @pytest.fixture
def mock_zeroconf() -> Generator[None, None, None]: def mock_zeroconf() -> Generator[None, None, None]:
"""Mock zeroconf.""" """Mock zeroconf."""

View file

@ -213,6 +213,7 @@ async def test_update_state_adds_entities_with_update_before_add_false(
assert not ent.update.called assert not ent.update.called
@pytest.mark.usefixtures("disable_translations_once")
async def test_set_scan_interval_via_platform(hass: HomeAssistant) -> None: async def test_set_scan_interval_via_platform(hass: HomeAssistant) -> None:
"""Test the setting of the scan interval via platform.""" """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)) 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: async def test_platform_warn_slow_setup(hass: HomeAssistant) -> None:
"""Warn we log when platform setup takes a long time.""" """Warn we log when platform setup takes a long time."""
platform = MockPlatform() platform = MockPlatform()

View file

@ -16,6 +16,11 @@ from homeassistant.loader import async_get_integration
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
@pytest.fixture(autouse=True)
def _disable_translations_once(disable_translations_once):
"""Override loading translations once."""
@pytest.fixture @pytest.fixture
def mock_config_flows(): def mock_config_flows():
"""Mock the config flows.""" """Mock the config flows."""