diff --git a/.github/workflows/action-container.yml b/.github/workflows/action-container.yml index 23f325d9819..ec820a09ccf 100644 --- a/.github/workflows/action-container.yml +++ b/.github/workflows/action-container.yml @@ -30,7 +30,7 @@ jobs: permissions: packages: write steps: - - uses: actions/checkout@v4.1.7 + - uses: actions/checkout@v4.2.0 - name: Login to GitHub Container Registry uses: docker/login-action@v3.3.0 @@ -52,7 +52,7 @@ jobs: type=sha - name: Build and push - uses: docker/build-push-action@v6.7.0 + uses: docker/build-push-action@v6.9.0 with: context: . file: action/Dockerfile diff --git a/.github/workflows/generate-hacs-data.yml b/.github/workflows/generate-hacs-data.yml index a3d7b0fc351..13736cd47b9 100644 --- a/.github/workflows/generate-hacs-data.yml +++ b/.github/workflows/generate-hacs-data.yml @@ -56,7 +56,7 @@ jobs: category: ${{ fromJSON( needs.generate-matrix.outputs.categories )}} steps: - name: Checkout the repository - uses: actions/checkout@v4.1.7 + uses: actions/checkout@v4.2.0 - name: Set up Python uses: actions/setup-python@v5.2.0 @@ -157,10 +157,6 @@ jobs: if (!changedPctTarget) { if (!parsed.new_count || parsed.new_count <= 0) { changedPctTarget = 0; - } else if (parsed.new_count > 1500) { - changedPctTarget = 3; - } else if (parsed.new_count > 1000) { - changedPctTarget = 4; } else if (parsed.new_count > 750) { changedPctTarget = 5; } else if (parsed.new_count > 500) { @@ -226,7 +222,7 @@ jobs: category: ${{ fromJSON(needs.summarize.outputs.changedCategories) }} steps: - name: Checkout the repository - uses: actions/checkout@v4.1.7 + uses: actions/checkout@v4.2.0 - name: Set up Python uses: actions/setup-python@v5.2.0 diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index 7ea67c0afb2..75af3419f51 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -31,7 +31,7 @@ jobs: - ruff-format steps: - name: Checkout the repository - uses: actions/checkout@v4.1.7 + uses: actions/checkout@v4.2.0 - name: Set up Python uses: actions/setup-python@v5.2.0 @@ -56,7 +56,7 @@ jobs: name: With JQ steps: - name: Checkout the repository - uses: actions/checkout@v4.1.7 + uses: actions/checkout@v4.2.0 - name: Run validation run: jq -r -e -c . tests/fixtures/*.json \ No newline at end of file diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 06d90aad74e..8823872adcc 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -20,7 +20,7 @@ jobs: contents: write steps: - name: 📥 Checkout the repository - uses: actions/checkout@v4.1.7 + uses: actions/checkout@v4.2.0 - name: 🛠️ Set up Python uses: actions/setup-python@v5.2.0 diff --git a/.github/workflows/pull_requests_labels.yml b/.github/workflows/pull_requests_labels.yml index 028ec56d492..a94661155ca 100644 --- a/.github/workflows/pull_requests_labels.yml +++ b/.github/workflows/pull_requests_labels.yml @@ -18,7 +18,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4.1.7 + uses: actions/checkout@v4.2.0 - name: Check the labels uses: ludeeus/action-require-labels@1.1.0 diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 2fa542475d2..8d8ca7260c0 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -18,7 +18,7 @@ jobs: runs-on: ubuntu-latest steps: - name: 📥 Checkout the repository - uses: actions/checkout@v4.1.7 + uses: actions/checkout@v4.2.0 - name: 🛠️ Set up Python 3.12 uses: actions/setup-python@v5.2.0 @@ -58,7 +58,7 @@ jobs: - "3.12" steps: - name: 📥 Checkout the repository - uses: actions/checkout@v4.1.7 + uses: actions/checkout@v4.2.0 - name: 🛠️ Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5.2.0 diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 672de34875d..b0c0b47faf8 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -42,7 +42,7 @@ jobs: name: With hassfest steps: - name: Checkout the repository - uses: actions/checkout@v4.1.7 + uses: actions/checkout@v4.2.0 # Test files conflict with running hassfest - name: Remove tests @@ -77,7 +77,7 @@ jobs: category: "plugin" steps: - name: Checkout the repository - uses: actions/checkout@v4.1.7 + uses: actions/checkout@v4.2.0 - name: Build Container run: | @@ -106,7 +106,7 @@ jobs: category: "plugin" steps: - name: Checkout the repository - uses: actions/checkout@v4.1.7 + uses: actions/checkout@v4.2.0 - name: Set up Python uses: actions/setup-python@v5.2.0 diff --git a/custom_components/hacs/translations/en.json b/custom_components/hacs/translations/en.json index 6385bcdc391..a4bed760a6c 100644 --- a/custom_components/hacs/translations/en.json +++ b/custom_components/hacs/translations/en.json @@ -30,7 +30,7 @@ } }, "progress": { - "wait_for_device": "1. Open {url} \n2. Paste the following key to authorize HACS: \n```\n{code}\n```\n" + "wait_for_device": "1. Open {url} \n2. Paste the following key to authorize HACS: \n```\n{code}\n```" } }, "options": { diff --git a/requirements_base.txt b/requirements_base.txt index 5144d61827b..ee85494433e 100644 --- a/requirements_base.txt +++ b/requirements_base.txt @@ -4,4 +4,4 @@ aiohttp_cors==0.7.0 async-timeout>=4.0.2 asynctest==0.13.0 colorlog==6.8.2 -setuptools==74.1.2 +setuptools==75.1.0 diff --git a/requirements_generate_data.txt b/requirements_generate_data.txt index 0bdade5da24..4e13e527db1 100644 --- a/requirements_generate_data.txt +++ b/requirements_generate_data.txt @@ -1,3 +1,3 @@ --requirement requirements_base.txt -awscli==1.34.16 +awscli==1.34.29 homeassistant==2024.3.3 diff --git a/requirements_lint.txt b/requirements_lint.txt index 01890858713..0b06b913b20 100644 --- a/requirements_lint.txt +++ b/requirements_lint.txt @@ -4,5 +4,5 @@ isort==5.13.2 pre-commit==3.8.0 pre-commit-hooks==4.6.0 pyupgrade==3.17.0 -ruff==0.6.4 -vulture==2.11 +ruff==0.6.8 +vulture==2.13 diff --git a/requirements_test.txt b/requirements_test.txt index c5b004a50e3..99786cddda1 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -6,4 +6,4 @@ pytest-asyncio==0.24.0 pytest-cov==5.0.0 pytest-snapshot==0.9.0 pytest-socket==0.7.0 -RestrictedPython==7.2 +RestrictedPython==7.3 diff --git a/tests/common.py b/tests/common.py index 16e67add8d9..0e68c347603 100644 --- a/tests/common.py +++ b/tests/common.py @@ -1,41 +1,25 @@ # pylint: disable=missing-docstring,invalid-name from __future__ import annotations -import asyncio -from collections.abc import AsyncGenerator, Iterable, Mapping, Sequence -from contextlib import asynccontextmanager, contextmanager, suppress +from collections.abc import Iterable +from contextlib import contextmanager from contextvars import ContextVar -import functools as ft from inspect import currentframe import json as json_func import os from types import NoneType -from typing import Any, TypedDict, TypeVar -from unittest.mock import AsyncMock, Mock, patch +from typing import Any, TypedDict +from unittest.mock import AsyncMock, patch from aiohttp import ClientError, ClientSession, ClientWebSocketResponse from aiohttp.typedefs import StrOrURL -from homeassistant import auth, bootstrap, config_entries, core as ha, loader -from homeassistant.auth import auth_store, models as auth_models -from homeassistant.const import EVENT_HOMEASSISTANT_CLOSE, EVENT_HOMEASSISTANT_STOP -from homeassistant.core import CoreState, HomeAssistant, callback -from homeassistant.helpers import ( - area_registry as ar, - category_registry as cr, - device_registry as dr, - entity, - entity_registry as er, - floor_registry as fr, - issue_registry as ir, - label_registry as lr, - restore_state as rs, - storage, - translation, -) +from awesomeversion import AwesomeVersion +from homeassistant import config_entries, core as ha +from homeassistant.auth import models as auth_models +from homeassistant.const import EVENT_HOMEASSISTANT_STOP, __version__ as HA_VERSION +from homeassistant.helpers import storage from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.json import ExtendedJSONEncoder -import homeassistant.util.dt as dt_util -from homeassistant.util.unit_system import METRIC_SYSTEM import homeassistant.util.uuid as uuid_util import pytest from yarl import URL @@ -47,8 +31,8 @@ from custom_components.hacs.utils.logger import LOGGER TOKEN = "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" -INSTANCES = [] -REQUEST_CONTEXT: ContextVar[pytest.FixtureRequest] = ContextVar("request_context", default=None) +REQUEST_CONTEXT: ContextVar[pytest.FixtureRequest] = ContextVar( + "request_context", default=None) IGNORED_BASE_FILES = { "/config/automations.yaml", @@ -132,7 +116,8 @@ class CategoryTestData(TypedDict): def category_test_data_parametrized( *, xfail_categories: list[HacsCategory] | None = None, - categories: Iterable[HacsCategory] = [entry["category"] for entry in _CATEGORY_TEST_DATA], + categories: Iterable[HacsCategory] = [entry["category"] + for entry in _CATEGORY_TEST_DATA], **kwargs, ): return ( @@ -173,7 +158,8 @@ def _sort_list(entry): return [_sort_list(item) for item in entry] return sorted( entry, - key=lambda obj: (getattr(obj, "id", None) or getattr(obj, "name", None) or 0) + key=lambda obj: (getattr(obj, "id", None) + or getattr(obj, "name", None) or 0) if isinstance(obj, dict) else obj, ) @@ -197,7 +183,8 @@ def _sort_list(entry): to_remove, ) elif isinstance(value, (list, set)): - returndata[key] = [recursive_remove_key(item, to_remove) for item in _sort_list(value)] + returndata[key] = [recursive_remove_key( + item, to_remove) for item in _sort_list(value)] else: returndata[key] = type(value) return returndata @@ -246,335 +233,6 @@ async def update_repository(*args, **kwargs): return repository -def get_test_config_dir(*add_path): - """Return a path to a test config dir.""" - return os.path.join(os.path.dirname(__file__), "testing_config", *add_path) - - -_T = TypeVar("_T", bound=Mapping[str, Any] | Sequence[Any]) - - -class StoreWithoutWriteLoad(storage.Store[_T]): - """Fake store that does not write or load. Used for testing.""" - - async def async_save(self, *args: Any, **kwargs: Any) -> None: - """Save the data. - - This function is mocked out in tests. - """ - - @callback - def async_save_delay(self, *args: Any, **kwargs: Any) -> None: - """Save data with an optional delay. - - This function is mocked out in tests. - """ - - -# pylint: disable=protected-access -@asynccontextmanager -async def async_test_home_assistant_min_version( - event_loop: asyncio.AbstractEventLoop | None = None, - load_registries: bool = True, - config_dir: str | None = None, -) -> AsyncGenerator[HomeAssistant]: - """Return a Home Assistant object pointing at test config dir. - - This should be copied from the minimum supported version, - currently Home Assistant Core 2024.4.1. - """ - hass = HomeAssistant(config_dir or get_test_config_dir()) - store = auth_store.AuthStore(hass) - hass.auth = auth.AuthManager(hass, store, {}, {}) - ensure_auth_manager_loaded(hass.auth) - INSTANCES.append(hass) - - orig_async_add_job = hass.async_add_job - orig_async_add_executor_job = hass.async_add_executor_job - orig_async_create_task = hass.async_create_task - orig_tz = dt_util.DEFAULT_TIME_ZONE - - def async_add_job(target, *args, eager_start: bool = False): - """Add job.""" - check_target = target - while isinstance(check_target, ft.partial): - check_target = check_target.func - - if isinstance(check_target, Mock) and not isinstance(target, AsyncMock): - fut = asyncio.Future() - fut.set_result(target(*args)) - return fut - - return orig_async_add_job(target, *args, eager_start=eager_start) - - def async_add_executor_job(target, *args): - """Add executor job.""" - check_target = target - while isinstance(check_target, ft.partial): - check_target = check_target.func - - if isinstance(check_target, Mock): - fut = asyncio.Future() - fut.set_result(target(*args)) - return fut - - return orig_async_add_executor_job(target, *args) - - def async_create_task(coroutine, name=None, eager_start=False): - """Create task.""" - if isinstance(coroutine, Mock) and not isinstance(coroutine, AsyncMock): - fut = asyncio.Future() - fut.set_result(None) - return fut - - return orig_async_create_task(coroutine, name, eager_start) - - hass.async_add_job = async_add_job - hass.async_add_executor_job = async_add_executor_job - hass.async_create_task = async_create_task - - hass.data[loader.DATA_CUSTOM_COMPONENTS] = {} - - hass.config.location_name = "test home" - hass.config.latitude = 32.87336 - hass.config.longitude = -117.22743 - hass.config.elevation = 0 - hass.config.set_time_zone("US/Pacific") - hass.config.units = METRIC_SYSTEM - hass.config.media_dirs = {"local": get_test_config_dir("media")} - hass.config.skip_pip = True - hass.config.skip_pip_packages = [] - - hass.config_entries = config_entries.ConfigEntries( - hass, - {"_": ("Not empty or else some bad checks for hass config in discovery.py breaks")}, - ) - hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_STOP, - hass.config_entries._async_shutdown, - run_immediately=True, - ) - - # Load the registries - entity.async_setup(hass) - loader.async_setup(hass) - - # setup translation cache instead of calling translation.async_setup(hass) - hass.data[translation.TRANSLATION_FLATTEN_CACHE] = translation._TranslationCache(hass) - if load_registries: - with ( - patch.object(StoreWithoutWriteLoad, "async_load", return_value=None), - patch( - "homeassistant.helpers.area_registry.AreaRegistryStore", - StoreWithoutWriteLoad, - ), - patch( - "homeassistant.helpers.device_registry.DeviceRegistryStore", - StoreWithoutWriteLoad, - ), - patch( - "homeassistant.helpers.entity_registry.EntityRegistryStore", - StoreWithoutWriteLoad, - ), - patch( - "homeassistant.helpers.storage.Store", # Floor & label registry are different - StoreWithoutWriteLoad, - ), - patch( - "homeassistant.helpers.issue_registry.IssueRegistryStore", - StoreWithoutWriteLoad, - ), - patch( - "homeassistant.helpers.restore_state.RestoreStateData.async_setup_dump", - return_value=None, - ), - patch( - "homeassistant.helpers.restore_state.start.async_at_start", - ), - ): - await ar.async_load(hass) - await cr.async_load(hass) - await dr.async_load(hass) - await er.async_load(hass) - await fr.async_load(hass) - await ir.async_load(hass) - await lr.async_load(hass) - await rs.async_load(hass) - hass.data[bootstrap.DATA_REGISTRIES_LOADED] = None - - hass.set_state(CoreState.running) - - @callback - def clear_instance(event): - """Clear global instance.""" - INSTANCES.remove(hass) - - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_CLOSE, clear_instance) - - yield hass - - # Restore timezone, it is set when creating the hass object - dt_util.DEFAULT_TIME_ZONE = orig_tz - - -@asynccontextmanager -async def async_test_home_assistant_dev( - event_loop: asyncio.AbstractEventLoop | None = None, - load_registries: bool = True, - config_dir: str | None = None, -) -> AsyncGenerator[HomeAssistant]: - """Return a Home Assistant object pointing at test config dir. - - This should be copied from latest Home Assistant version, - currently Home Assistant Core 2024.9.0dev0 (2024-08-14). - https://github.com/home-assistant/core/blob/dev/tests/common.py - """ - hass = HomeAssistant(config_dir or get_test_config_dir()) - store = auth_store.AuthStore(hass) - hass.auth = auth.AuthManager(hass, store, {}, {}) - ensure_auth_manager_loaded(hass.auth) - INSTANCES.append(hass) - - orig_async_add_job = hass.async_add_job - orig_async_add_executor_job = hass.async_add_executor_job - orig_async_create_task_internal = hass.async_create_task_internal - orig_tz = dt_util.get_default_time_zone() - - def async_add_job(target, *args, eager_start: bool = False): - """Add job.""" - check_target = target - while isinstance(check_target, ft.partial): - check_target = check_target.func - - if isinstance(check_target, Mock) and not isinstance(target, AsyncMock): - fut = asyncio.Future() - fut.set_result(target(*args)) - return fut - - return orig_async_add_job(target, *args, eager_start=eager_start) - - def async_add_executor_job(target, *args): - """Add executor job.""" - check_target = target - while isinstance(check_target, ft.partial): - check_target = check_target.func - - if isinstance(check_target, Mock): - fut = asyncio.Future() - fut.set_result(target(*args)) - return fut - - return orig_async_add_executor_job(target, *args) - - def async_create_task_internal(coroutine, name=None, eager_start=True): - """Create task.""" - if isinstance(coroutine, Mock) and not isinstance(coroutine, AsyncMock): - fut = asyncio.Future() - fut.set_result(None) - return fut - - return orig_async_create_task_internal(coroutine, name, eager_start) - - hass.async_add_job = async_add_job - hass.async_add_executor_job = async_add_executor_job - hass.async_create_task_internal = async_create_task_internal - - hass.data[loader.DATA_CUSTOM_COMPONENTS] = {} - - hass.config.location_name = "test home" - hass.config.latitude = 32.87336 - hass.config.longitude = -117.22743 - hass.config.elevation = 0 - await hass.config.async_set_time_zone("US/Pacific") - hass.config.units = METRIC_SYSTEM - hass.config.media_dirs = {"local": get_test_config_dir("media")} - hass.config.skip_pip = True - hass.config.skip_pip_packages = [] - - hass.config_entries = config_entries.ConfigEntries( - hass, - { - "_": ( - "Not empty or else some bad checks for hass config in discovery.py" - " breaks" - ) - }, - ) - hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_STOP, - hass.config_entries._async_shutdown, - ) - - # Load the registries - entity.async_setup(hass) - loader.async_setup(hass) - - # setup translation cache instead of calling translation.async_setup(hass) - hass.data[translation.TRANSLATION_FLATTEN_CACHE] = translation._TranslationCache( - hass - ) - if load_registries: - with ( - patch.object(StoreWithoutWriteLoad, - "async_load", return_value=None), - patch( - "homeassistant.helpers.area_registry.AreaRegistryStore", - StoreWithoutWriteLoad, - ), - patch( - "homeassistant.helpers.device_registry.DeviceRegistryStore", - StoreWithoutWriteLoad, - ), - patch( - "homeassistant.helpers.entity_registry.EntityRegistryStore", - StoreWithoutWriteLoad, - ), - patch( - "homeassistant.helpers.storage.Store", # Floor & label registry are different - StoreWithoutWriteLoad, - ), - patch( - "homeassistant.helpers.issue_registry.IssueRegistryStore", - StoreWithoutWriteLoad, - ), - patch( - "homeassistant.helpers.restore_state.RestoreStateData.async_setup_dump", - return_value=None, - ), - patch( - "homeassistant.helpers.restore_state.start.async_at_start", - ), - ): - await ar.async_load(hass) - await cr.async_load(hass) - await dr.async_load(hass) - await er.async_load(hass) - await fr.async_load(hass) - await ir.async_load(hass) - await lr.async_load(hass) - await rs.async_load(hass) - hass.data[bootstrap.DATA_REGISTRIES_LOADED] = None - - hass.set_state(CoreState.running) - - @callback - def clear_instance(event): - """Clear global instance.""" - # Give aiohttp one loop iteration to close - hass.loop.call_soon(INSTANCES.remove, hass) - - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_CLOSE, clear_instance) - - try: - yield hass - finally: - # Restore timezone, it is set when creating the hass object - dt_util.set_default_time_zone(orig_tz) - # Remove loop shutdown indicator to not interfere with additional hass objects - with suppress(AttributeError): - delattr(hass.loop, "_shutdown_run_callback_threadsafe") - - @ha.callback def ensure_auth_manager_loaded(auth_mgr): """Ensure an auth manager is considered loaded.""" @@ -615,7 +273,8 @@ async def mock_async_load(store): def mock_write_data(store, path, data_to_write): """Mock version of write data.""" # To ensure that the data can be serialized - data[store.key] = json_func.loads(json_func.dumps(data_to_write, cls=store._encoder)) + data[store.key] = json_func.loads( + json_func.dumps(data_to_write, cls=store._encoder)) async def mock_remove(store): """Remove data.""" @@ -697,7 +356,8 @@ async def _async_close_websession(event: ha.Event) -> None: clientsession.detach() - self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_close_websession) + self.hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, _async_close_websession) self.client = await clientsession.ws_connect( "ws://localhost:8123/api/websocket", @@ -727,7 +387,7 @@ async def send_and_receive_json(self, type: str, payload: dict[str, Any]) -> dic class MockedResponse: def __init__(self, **kwargs) -> None: self.kwargs = kwargs - self.exception = kwargs.get("exception", None) + self.exception = kwargs.get("exception") self.keep = kwargs.get("keep", False) @property @@ -894,16 +554,20 @@ def create_config_entry( data: dict[str, Any] = None, options: dict[str, Any] = None, ) -> MockConfigEntry: - return MockConfigEntry( - version=1, - minor_version=0, - domain=DOMAIN, - title="", - data={"token": TOKEN, **(data or {})}, - source="user", - options={**(options or {})}, - unique_id="12345", - ) + config_entry_data = { + "version": 1, + "minor_version": 0, + "domain": DOMAIN, + "title": "", + "data": {"token": TOKEN, **(data or {})}, + "source": "user", + "options": {**(options or {})}, + "unique_id": "12345", + } + # legacy workaround for tests + if AwesomeVersion(HA_VERSION).dev: + config_entry_data["discovery_keys"] = {} + return MockConfigEntry(**config_entry_data) async def setup_integration(hass: ha.HomeAssistant, config_entry: MockConfigEntry) -> None: diff --git a/tests/conftest.py b/tests/conftest.py index d12ca0bffb2..92f1d24175e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -12,7 +12,6 @@ from typing import Any from unittest.mock import MagicMock, patch -from _pytest.assertion.util import _compare_eq_iterable from awesomeversion import AwesomeVersion import freezegun from homeassistant import loader @@ -52,8 +51,6 @@ ProxyClientSession, ResponseMocker, WSClient, - async_test_home_assistant_dev, - async_test_home_assistant_min_version, client_session_proxy, create_config_entry, dummy_repository_base, @@ -63,6 +60,12 @@ safe_json_dumps, setup_integration as common_setup_integration, ) +from tests.homeassistantfixtures.dev import ( + async_test_home_assistant as async_test_home_assistant_dev, +) +from tests.homeassistantfixtures.min import ( + async_test_home_assistant as async_test_home_assistant_min_version, +) # Set default logger logging.basicConfig(level=logging.INFO) diff --git a/tests/homeassistantfixtures/__init__.py b/tests/homeassistantfixtures/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/homeassistantfixtures/common.py b/tests/homeassistantfixtures/common.py new file mode 100644 index 00000000000..2976ea9c617 --- /dev/null +++ b/tests/homeassistantfixtures/common.py @@ -0,0 +1,39 @@ +from collections.abc import Mapping, Sequence +from pathlib import Path +from typing import Any, TypeVar + +from homeassistant import core as ha +from homeassistant.helpers import storage + +INSTANCES = [] +_T = TypeVar("_T", bound=Mapping[str, Any] | Sequence[Any]) + + +def get_test_config_dir(*add_path): + """Return a path to a test config dir.""" + return Path(Path(__file__).resolve().parent, "testing_config", *add_path) + + +@ha.callback +def ensure_auth_manager_loaded(auth_mgr): + """Ensure an auth manager is considered loaded.""" + store = auth_mgr._store + if store._users is None: + store._set_defaults() + + +class StoreWithoutWriteLoad(storage.Store[_T]): + """Fake store that does not write or load. Used for testing.""" + + async def async_save(self, *args: Any, **kwargs: Any) -> None: + """Save the data. + + This function is mocked out in tests. + """ + + @ha.callback + def async_save_delay(self, *args: Any, **kwargs: Any) -> None: + """Save data with an optional delay. + + This function is mocked out in tests. + """ diff --git a/tests/homeassistantfixtures/dev.py b/tests/homeassistantfixtures/dev.py new file mode 100644 index 00000000000..4bd00aa1a49 --- /dev/null +++ b/tests/homeassistantfixtures/dev.py @@ -0,0 +1,189 @@ +"""Return a Home Assistant object pointing at test config dir. + +This should be copied from latest Home Assistant version, +currently Home Assistant Core 2024.10.0dev0 (2024-09-26). +https://github.com/home-assistant/core/blob/dev/tests/common.py +""" + +from __future__ import annotations + +import asyncio +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager, suppress +import functools as ft +from unittest.mock import AsyncMock, Mock, patch + +from homeassistant import auth, bootstrap, config_entries, loader +from homeassistant.auth import auth_store +from homeassistant.const import EVENT_HOMEASSISTANT_CLOSE, EVENT_HOMEASSISTANT_STOP +from homeassistant.core import CoreState, HomeAssistant, callback +from homeassistant.helpers import ( + area_registry as ar, + category_registry as cr, + device_registry as dr, + entity, + entity_registry as er, + floor_registry as fr, + issue_registry as ir, + label_registry as lr, + restore_state as rs, + translation, +) +from homeassistant.util.async_ import _SHUTDOWN_RUN_CALLBACK_THREADSAFE +import homeassistant.util.dt as dt_util +from homeassistant.util.unit_system import METRIC_SYSTEM + +from .common import ( + INSTANCES, + StoreWithoutWriteLoad, + ensure_auth_manager_loaded, + get_test_config_dir, +) + + +@asynccontextmanager +async def async_test_home_assistant( + event_loop: asyncio.AbstractEventLoop | None = None, + load_registries: bool = True, + config_dir: str | None = None, + initial_state: CoreState = CoreState.running, +) -> AsyncGenerator[HomeAssistant]: + """Return a Home Assistant object pointing at test config dir.""" + hass = HomeAssistant(config_dir or get_test_config_dir()) + store = auth_store.AuthStore(hass) + hass.auth = auth.AuthManager(hass, store, {}, {}) + ensure_auth_manager_loaded(hass.auth) + INSTANCES.append(hass) + + orig_async_add_job = hass.async_add_job + orig_async_add_executor_job = hass.async_add_executor_job + orig_async_create_task_internal = hass.async_create_task_internal + orig_tz = dt_util.get_default_time_zone() + + def async_add_job(target, *args, eager_start: bool = False): + """Add job.""" + check_target = target + while isinstance(check_target, ft.partial): + check_target = check_target.func + + if isinstance(check_target, Mock) and not isinstance(target, AsyncMock): + fut = asyncio.Future() + fut.set_result(target(*args)) + return fut + + return orig_async_add_job(target, *args, eager_start=eager_start) + + def async_add_executor_job(target, *args): + """Add executor job.""" + check_target = target + while isinstance(check_target, ft.partial): + check_target = check_target.func + + if isinstance(check_target, Mock): + fut = asyncio.Future() + fut.set_result(target(*args)) + return fut + + return orig_async_add_executor_job(target, *args) + + def async_create_task_internal(coroutine, name=None, eager_start=True): + """Create task.""" + if isinstance(coroutine, Mock) and not isinstance(coroutine, AsyncMock): + fut = asyncio.Future() + fut.set_result(None) + return fut + + return orig_async_create_task_internal(coroutine, name, eager_start) + + hass.async_add_job = async_add_job + hass.async_add_executor_job = async_add_executor_job + hass.async_create_task_internal = async_create_task_internal + + hass.data[loader.DATA_CUSTOM_COMPONENTS] = {} + + hass.config.location_name = "test home" + hass.config.latitude = 32.87336 + hass.config.longitude = -117.22743 + hass.config.elevation = 0 + await hass.config.async_set_time_zone("US/Pacific") + hass.config.units = METRIC_SYSTEM + hass.config.media_dirs = {"local": get_test_config_dir("media")} + hass.config.skip_pip = True + hass.config.skip_pip_packages = [] + + hass.config_entries = config_entries.ConfigEntries( + hass, + {"_": ("Not empty or else some bad checks for hass config in discovery.py" " breaks")}, + ) + hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, + hass.config_entries._async_shutdown, + ) + + # Load the registries + entity.async_setup(hass) + loader.async_setup(hass) + + # setup translation cache instead of calling translation.async_setup(hass) + hass.data[translation.TRANSLATION_FLATTEN_CACHE] = translation._TranslationCache( + hass) + if load_registries: + with ( + patch.object(StoreWithoutWriteLoad, + "async_load", return_value=None), + patch( + "homeassistant.helpers.area_registry.AreaRegistryStore", + StoreWithoutWriteLoad, + ), + patch( + "homeassistant.helpers.device_registry.DeviceRegistryStore", + StoreWithoutWriteLoad, + ), + patch( + "homeassistant.helpers.entity_registry.EntityRegistryStore", + StoreWithoutWriteLoad, + ), + patch( + "homeassistant.helpers.storage.Store", # Floor & label registry are different + StoreWithoutWriteLoad, + ), + patch( + "homeassistant.helpers.issue_registry.IssueRegistryStore", + StoreWithoutWriteLoad, + ), + patch( + "homeassistant.helpers.restore_state.RestoreStateData.async_setup_dump", + return_value=None, + ), + patch( + "homeassistant.helpers.restore_state.start.async_at_start", + ), + ): + await ar.async_load(hass) + await cr.async_load(hass) + await dr.async_load(hass) + await er.async_load(hass) + await fr.async_load(hass) + await ir.async_load(hass) + await lr.async_load(hass) + await rs.async_load(hass) + hass.data[bootstrap.DATA_REGISTRIES_LOADED] = None + + hass.set_state(initial_state) + + @callback + def clear_instance(event): + """Clear global instance.""" + # Give aiohttp one loop iteration to close + hass.loop.call_soon(INSTANCES.remove, hass) + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_CLOSE, clear_instance) + + try: + yield hass + finally: + # Restore timezone, it is set when creating the hass object + dt_util.set_default_time_zone(orig_tz) + # Remove loop shutdown indicator to not interfere with additional hass objects + with suppress(AttributeError): + delattr(hass.loop, _SHUTDOWN_RUN_CALLBACK_THREADSAFE) diff --git a/tests/homeassistantfixtures/min.py b/tests/homeassistantfixtures/min.py new file mode 100644 index 00000000000..a393d323727 --- /dev/null +++ b/tests/homeassistantfixtures/min.py @@ -0,0 +1,185 @@ +"""Return a Home Assistant object pointing at test config dir. + +This should be copied from the minimum supported version, +currently Home Assistant Core 2024.4.1. +""" + +from __future__ import annotations + +import asyncio +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager +import functools as ft +from unittest.mock import AsyncMock, Mock, patch + +from homeassistant import auth, bootstrap, config_entries, loader +from homeassistant.auth import auth_store +from homeassistant.const import EVENT_HOMEASSISTANT_CLOSE, EVENT_HOMEASSISTANT_STOP +from homeassistant.core import CoreState, HomeAssistant, callback +from homeassistant.helpers import ( + area_registry as ar, + category_registry as cr, + device_registry as dr, + entity, + entity_registry as er, + floor_registry as fr, + issue_registry as ir, + label_registry as lr, + restore_state as rs, + translation, +) +import homeassistant.util.dt as dt_util +from homeassistant.util.unit_system import METRIC_SYSTEM + +from .common import ( + INSTANCES, + StoreWithoutWriteLoad, + ensure_auth_manager_loaded, + get_test_config_dir, +) + + +# pylint: disable=protected-access +@asynccontextmanager +async def async_test_home_assistant( + event_loop: asyncio.AbstractEventLoop | None = None, + load_registries: bool = True, + config_dir: str | None = None, +) -> AsyncGenerator[HomeAssistant]: + """Return a Home Assistant object pointing at test config dir. + + This should be copied from the minimum supported version, + currently Home Assistant Core 2024.4.1. + """ + hass = HomeAssistant(config_dir or get_test_config_dir()) + store = auth_store.AuthStore(hass) + hass.auth = auth.AuthManager(hass, store, {}, {}) + ensure_auth_manager_loaded(hass.auth) + INSTANCES.append(hass) + + orig_async_add_job = hass.async_add_job + orig_async_add_executor_job = hass.async_add_executor_job + orig_async_create_task = hass.async_create_task + orig_tz = dt_util.DEFAULT_TIME_ZONE + + def async_add_job(target, *args, eager_start: bool = False): + """Add job.""" + check_target = target + while isinstance(check_target, ft.partial): + check_target = check_target.func + + if isinstance(check_target, Mock) and not isinstance(target, AsyncMock): + fut = asyncio.Future() + fut.set_result(target(*args)) + return fut + + return orig_async_add_job(target, *args, eager_start=eager_start) + + def async_add_executor_job(target, *args): + """Add executor job.""" + check_target = target + while isinstance(check_target, ft.partial): + check_target = check_target.func + + if isinstance(check_target, Mock): + fut = asyncio.Future() + fut.set_result(target(*args)) + return fut + + return orig_async_add_executor_job(target, *args) + + def async_create_task(coroutine, name=None, eager_start=False): + """Create task.""" + if isinstance(coroutine, Mock) and not isinstance(coroutine, AsyncMock): + fut = asyncio.Future() + fut.set_result(None) + return fut + + return orig_async_create_task(coroutine, name, eager_start) + + hass.async_add_job = async_add_job + hass.async_add_executor_job = async_add_executor_job + hass.async_create_task = async_create_task + + hass.data[loader.DATA_CUSTOM_COMPONENTS] = {} + + hass.config.location_name = "test home" + hass.config.latitude = 32.87336 + hass.config.longitude = -117.22743 + hass.config.elevation = 0 + hass.config.set_time_zone("US/Pacific") + hass.config.units = METRIC_SYSTEM + hass.config.media_dirs = {"local": get_test_config_dir("media")} + hass.config.skip_pip = True + hass.config.skip_pip_packages = [] + + hass.config_entries = config_entries.ConfigEntries( + hass, + {"_": ("Not empty or else some bad checks for hass config in discovery.py breaks")}, + ) + hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, + hass.config_entries._async_shutdown, + run_immediately=True, + ) + + # Load the registries + entity.async_setup(hass) + loader.async_setup(hass) + + # setup translation cache instead of calling translation.async_setup(hass) + hass.data[translation.TRANSLATION_FLATTEN_CACHE] = translation._TranslationCache(hass) + if load_registries: + with ( + patch.object(StoreWithoutWriteLoad, "async_load", return_value=None), + patch( + "homeassistant.helpers.area_registry.AreaRegistryStore", + StoreWithoutWriteLoad, + ), + patch( + "homeassistant.helpers.device_registry.DeviceRegistryStore", + StoreWithoutWriteLoad, + ), + patch( + "homeassistant.helpers.entity_registry.EntityRegistryStore", + StoreWithoutWriteLoad, + ), + patch( + "homeassistant.helpers.storage.Store", # Floor & label registry are different + StoreWithoutWriteLoad, + ), + patch( + "homeassistant.helpers.issue_registry.IssueRegistryStore", + StoreWithoutWriteLoad, + ), + patch( + "homeassistant.helpers.restore_state.RestoreStateData.async_setup_dump", + return_value=None, + ), + patch( + "homeassistant.helpers.restore_state.start.async_at_start", + ), + ): + await ar.async_load(hass) + await cr.async_load(hass) + await dr.async_load(hass) + await er.async_load(hass) + await fr.async_load(hass) + await ir.async_load(hass) + await lr.async_load(hass) + await rs.async_load(hass) + hass.data[bootstrap.DATA_REGISTRIES_LOADED] = None + + hass.set_state(CoreState.running) + + @callback + def clear_instance(event): + """Clear global instance.""" + INSTANCES.remove(hass) + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_CLOSE, clear_instance) + + yield hass + + # Restore timezone, it is set when creating the hass object + dt_util.DEFAULT_TIME_ZONE = orig_tz diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py index ab0afadac32..21108c1bb14 100644 --- a/tests/test_diagnostics.py +++ b/tests/test_diagnostics.py @@ -12,7 +12,8 @@ ) from tests.conftest import SnapshotFixture -REMOVE_KEYS = ("entry_id", "last_updated", "local", "minor_version", "created_at", "modified_at") +REMOVE_KEYS = ("entry_id", "last_updated", "local", "minor_version", + "created_at", "modified_at", "discovery_keys") async def test_diagnostics(hacs: HacsBase, snapshots: SnapshotFixture): @@ -24,7 +25,8 @@ async def test_diagnostics(hacs: HacsBase, snapshots: SnapshotFixture): assert TOKEN not in str(diagnostics) snapshots.assert_match( - safe_json_dumps(recursive_remove_key(diagnostics, REMOVE_KEYS)), "diagnostics/base.json" + safe_json_dumps(recursive_remove_key( + diagnostics, REMOVE_KEYS)), "diagnostics/base.json" )