From 124088eb389a12a15c7ebf1e48fec784a451fabb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 8 May 2024 17:35:24 -0500 Subject: [PATCH 1/2] Only load translations for an integration once per test session During a production run we cache the translations, but for tests we load them on every test. A new pytest fixture called translations_once with session scope allows translations to be shared between runs. It can be disabled for specific tests by using the disable_translations_once fixture --- homeassistant/helpers/translation.py | 32 +++++++++++++++++++-------- tests/conftest.py | 25 +++++++++++++++++++++ tests/helpers/test_entity_platform.py | 2 ++ tests/helpers/test_translation.py | 5 +++++ 4 files changed, 55 insertions(+), 9 deletions(-) diff --git a/homeassistant/helpers/translation.py b/homeassistant/helpers/translation.py index 182747ec4150b2..81f7a6f8e74296 100644 --- a/homeassistant/helpers/translation.py +++ b/homeassistant/helpers/translation.py @@ -5,6 +5,7 @@ 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 @@ async def async_load( 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 @@ def get_cached( 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 @@ def get_cached( 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 @@ async def _async_load(self, language: str, components: set[str]) -> None: 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 @@ async def _async_load(self, language: str, components: set[str]) -> None: ) loaded_english_components.update(components) - self.loaded[language].update(components) + loaded[language].update(components) def _validate_placeholders( self, @@ -304,7 +318,7 @@ def _build_category_cache( ) -> 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 6a16082a87f7c8..7d807525c98613 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1159,6 +1159,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 646b0ec0abf245..fda667344310f4 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 @@ def create_entity(number: int) -> MockEntity: 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 b841e1ab5acff4..abb754cd4357fb 100644 --- a/tests/helpers/test_translation.py +++ b/tests/helpers/test_translation.py @@ -16,6 +16,11 @@ 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.""" From e5a1739d657f9986b21510fd6bf57e87833ddbf7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 May 2024 10:27:15 +0900 Subject: [PATCH 2/2] Fix flakey advantage_air test --- .../advantage_air/test_binary_sensor.py | 17 ++++++++++++++++- tests/components/advantage_air/test_sensor.py | 12 ++++++++++-- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/tests/components/advantage_air/test_binary_sensor.py b/tests/components/advantage_air/test_binary_sensor.py index 2eb95c18b7d8f1..13bbadb38f987e 100644 --- a/tests/components/advantage_air/test_binary_sensor.py +++ b/tests/components/advantage_air/test_binary_sensor.py @@ -3,6 +3,7 @@ from datetime import timedelta from unittest.mock import AsyncMock +from homeassistant.components.advantage_air import ADVANTAGE_AIR_SYNC_INTERVAL from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant @@ -74,11 +75,18 @@ async def test_binary_sensor_async_setup_entry( async_fire_time_changed( hass, - dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), + dt_util.utcnow() + timedelta(seconds=ADVANTAGE_AIR_SYNC_INTERVAL + 1), ) await hass.async_block_till_done(wait_background_tasks=True) assert len(mock_get.mock_calls) == 1 + async_fire_time_changed( + hass, + dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), + ) + await hass.async_block_till_done(wait_background_tasks=True) + assert len(mock_get.mock_calls) == 2 + state = hass.states.get(entity_id) assert state assert state.state == STATE_ON @@ -96,6 +104,13 @@ async def test_binary_sensor_async_setup_entry( entity_registry.async_update_entity(entity_id=entity_id, disabled_by=None) await hass.async_block_till_done() + async_fire_time_changed( + hass, + dt_util.utcnow() + timedelta(seconds=ADVANTAGE_AIR_SYNC_INTERVAL + 1), + ) + await hass.async_block_till_done(wait_background_tasks=True) + assert len(mock_get.mock_calls) == 1 + async_fire_time_changed( hass, dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), diff --git a/tests/components/advantage_air/test_sensor.py b/tests/components/advantage_air/test_sensor.py index ced1ff3a9e7276..06243921a645c5 100644 --- a/tests/components/advantage_air/test_sensor.py +++ b/tests/components/advantage_air/test_sensor.py @@ -3,6 +3,7 @@ from datetime import timedelta from unittest.mock import AsyncMock +from homeassistant.components.advantage_air import ADVANTAGE_AIR_SYNC_INTERVAL from homeassistant.components.advantage_air.const import DOMAIN as ADVANTAGE_AIR_DOMAIN from homeassistant.components.advantage_air.sensor import ( ADVANTAGE_AIR_SERVICE_SET_TIME_TO, @@ -123,17 +124,24 @@ async def test_sensor_platform_disabled_entity( assert not hass.states.get(entity_id) - mock_get.reset_mock() entity_registry.async_update_entity(entity_id=entity_id, disabled_by=None) await hass.async_block_till_done(wait_background_tasks=True) + mock_get.reset_mock() async_fire_time_changed( hass, - dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), + dt_util.utcnow() + timedelta(seconds=ADVANTAGE_AIR_SYNC_INTERVAL + 1), ) await hass.async_block_till_done(wait_background_tasks=True) assert len(mock_get.mock_calls) == 1 + async_fire_time_changed( + hass, + dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), + ) + await hass.async_block_till_done(wait_background_tasks=True) + assert len(mock_get.mock_calls) == 2 + state = hass.states.get(entity_id) assert state assert int(state.state) == 25