From f96a21cc76fdefb28dff6f924f850ccdbc4b45d9 Mon Sep 17 00:00:00 2001 From: Matthew Flamm Date: Thu, 1 Dec 2022 05:10:34 +0000 Subject: [PATCH] Bump version --- README.md | 2 +- ha_version | 2 +- .../common.py | 54 ++++++++++- .../components/recorder/common.py | 22 ++++- .../const.py | 4 +- .../plugins.py | 91 +++++++++++++++---- requirements_dev.txt | 7 +- requirements_test.txt | 9 +- version | 2 +- 9 files changed, 158 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index b1ab828..bcfd3c2 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # pytest-homeassistant-custom-component -![HA core version](https://img.shields.io/static/v1?label=HA+core+version&message=2022.11.4&labelColor=blue) +![HA core version](https://img.shields.io/static/v1?label=HA+core+version&message=2022.12.0b0&labelColor=blue) [![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/#https://github.com/MatthewFlamm/pytest-homeassistant-custom-component) diff --git a/ha_version b/ha_version index 5063097..a1ac9d0 100644 --- a/ha_version +++ b/ha_version @@ -1 +1 @@ -2022.11.4 \ No newline at end of file +2022.12.0b0 \ No newline at end of file diff --git a/pytest_homeassistant_custom_component/common.py b/pytest_homeassistant_custom_component/common.py index 83c4f0e..2468a22 100644 --- a/pytest_homeassistant_custom_component/common.py +++ b/pytest_homeassistant_custom_component/common.py @@ -27,7 +27,7 @@ from aiohttp.test_utils import unused_port as get_test_instance_port # noqa: F401 import voluptuous as vol -from homeassistant import auth, config_entries, core as ha, loader +from homeassistant import auth, bootstrap, config_entries, core as ha, loader from homeassistant.auth import ( auth_store, models as auth_models, @@ -165,7 +165,7 @@ def stop_hass(): # pylint: disable=protected-access -async def async_test_home_assistant(loop, load_registries=True): +async def async_test_home_assistant(event_loop, load_registries=True): """Return a Home Assistant object pointing at test config dir.""" hass = ha.HomeAssistant() store = auth_store.AuthStore(hass) @@ -294,6 +294,7 @@ async def _await_count_and_log_pending( 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, @@ -311,6 +312,7 @@ async def _await_count_and_log_pending( issue_registry.async_load(hass), ) await hass.async_block_till_done() + hass.data[bootstrap.DATA_REGISTRIES_LOADED] = None hass.state = ha.CoreState.running @@ -384,17 +386,59 @@ def async_fire_mqtt_message(hass, topic, payload, qos=0, retain=False): fire_mqtt_message = threadsafe_callback_factory(async_fire_mqtt_message) +@ha.callback +def async_fire_time_changed_exact( + hass: HomeAssistant, datetime_: datetime | None = None, fire_all: bool = False +) -> None: + """Fire a time changed event at an exact microsecond. + + Consider that it is not possible to actually achieve an exact + microsecond in production as the event loop is not precise enough. + If your code relies on this level of precision, consider a different + approach, as this is only for testing. + """ + if datetime_ is None: + utc_datetime = date_util.utcnow() + else: + utc_datetime = date_util.as_utc(datetime_) + + _async_fire_time_changed(hass, utc_datetime, fire_all) + + @ha.callback def async_fire_time_changed( - hass: HomeAssistant, datetime_: datetime = None, fire_all: bool = False + hass: HomeAssistant, datetime_: datetime | None = None, fire_all: bool = False ) -> None: - """Fire a time changed event.""" + """Fire a time changed event. + + This function will add up to 0.5 seconds to the time to ensure that + it accounts for the accidental synchronization avoidance code in repeating + listeners. + + As asyncio is cooperative, we can't guarantee that the event loop will + run an event at the exact time we want. If you need to fire time changed + for an exact microsecond, use async_fire_time_changed_exact. + """ if datetime_ is None: utc_datetime = date_util.utcnow() else: utc_datetime = date_util.as_utc(datetime_) - timestamp = date_util.utc_to_timestamp(utc_datetime) + if utc_datetime.microsecond < 500000: + # Allow up to 500000 microseconds to be added to the time + # to handle update_coordinator's and + # async_track_time_interval's + # staggering to avoid thundering herd. + utc_datetime = utc_datetime.replace(microsecond=500000) + + _async_fire_time_changed(hass, utc_datetime, fire_all) + + +@ha.callback +def _async_fire_time_changed( + hass: HomeAssistant, utc_datetime: datetime | None, fire_all: bool +) -> None: + timestamp = date_util.utc_to_timestamp(utc_datetime) for task in list(hass.loop._scheduled): if not isinstance(task, asyncio.TimerHandle): continue diff --git a/pytest_homeassistant_custom_component/components/recorder/common.py b/pytest_homeassistant_custom_component/components/recorder/common.py index afe0549..0e4aab4 100644 --- a/pytest_homeassistant_custom_component/components/recorder/common.py +++ b/pytest_homeassistant_custom_component/components/recorder/common.py @@ -9,7 +9,7 @@ from dataclasses import dataclass from datetime import datetime import time -from typing import Any, cast +from typing import Any, Literal, cast from sqlalchemy import create_engine from sqlalchemy.orm.session import Session @@ -57,7 +57,7 @@ def do_adhoc_statistics(hass: HomeAssistant, **kwargs: Any) -> None: """Trigger an adhoc statistics run.""" if not (start := kwargs.get("start")): start = statistics.get_start_time() - get_instance(hass).queue_task(StatisticsTask(start)) + get_instance(hass).queue_task(StatisticsTask(start, False)) def wait_recording_done(hass: HomeAssistant) -> None: @@ -141,3 +141,21 @@ def run_information_with_session( session.expunge(res) return cast(RecorderRuns, res) return res + + +def statistics_during_period( + hass: HomeAssistant, + start_time: datetime, + end_time: datetime | None = None, + statistic_ids: list[str] | None = None, + period: Literal["5minute", "day", "hour", "week", "month"] = "hour", + units: dict[str, str] | None = None, + types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]] + | None = None, +) -> dict[str, list[dict[str, Any]]]: + """Call statistics_during_period with defaults for simpler ...""" + if types is None: + types = {"last_reset", "max", "mean", "min", "state", "sum"} + return statistics.statistics_during_period( + hass, start_time, end_time, statistic_ids, period, units, types + ) diff --git a/pytest_homeassistant_custom_component/const.py b/pytest_homeassistant_custom_component/const.py index d792e9d..1051614 100644 --- a/pytest_homeassistant_custom_component/const.py +++ b/pytest_homeassistant_custom_component/const.py @@ -10,8 +10,8 @@ APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2022 -MINOR_VERSION: Final = 11 -PATCH_VERSION: Final = "4" +MINOR_VERSION: Final = 12 +PATCH_VERSION: Final = "0b0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 9, 0) diff --git a/pytest_homeassistant_custom_component/plugins.py b/pytest_homeassistant_custom_component/plugins.py index 79a0b05..5dfdbee 100644 --- a/pytest_homeassistant_custom_component/plugins.py +++ b/pytest_homeassistant_custom_component/plugins.py @@ -9,6 +9,8 @@ from collections.abc import AsyncGenerator, Callable, Generator from contextlib import asynccontextmanager import functools +import gc +import itertools from json import JSONDecoder, loads import logging import sqlite3 @@ -16,6 +18,7 @@ import threading from typing import Any from unittest.mock import AsyncMock, MagicMock, Mock, patch +import warnings from aiohttp import client from aiohttp.pytest_plugin import AiohttpClient @@ -191,12 +194,14 @@ async def guard_func(*args, **kwargs): @pytest.fixture(autouse=True) -def verify_cleanup(): +def verify_cleanup(event_loop: asyncio.AbstractEventLoop): """Verify that the test has cleaned up resources correctly.""" threads_before = frozenset(threading.enumerate()) - + tasks_before = asyncio.all_tasks(event_loop) yield + event_loop.run_until_complete(event_loop.shutdown_default_executor()) + if len(INSTANCES) >= 2: count = len(INSTANCES) for inst in INSTANCES: @@ -207,6 +212,26 @@ def verify_cleanup(): for thread in threads: assert isinstance(thread, threading._DummyThread) + # Warn and clean-up lingering tasks and timers + # before moving on to the next test. + tasks = asyncio.all_tasks(event_loop) - tasks_before + for task in tasks: + warnings.warn(f"Linger task after test {task}") + task.cancel() + if tasks: + event_loop.run_until_complete(asyncio.wait(tasks)) + + for handle in event_loop._scheduled: # pylint: disable=protected-access + if not handle.cancelled(): + warnings.warn(f"Lingering timer after test {handle}") + handle.cancel() + + # Make sure garbage collect run in same test as allocation + # this is to mimic the behavior of pytest-aiohttp, and is + # required to avoid warnings from spilling over into next + # test case. + gc.collect() + @pytest.fixture(autouse=True) def bcrypt_cost(): @@ -281,7 +306,7 @@ def aiohttp_client_cls(): @pytest.fixture def aiohttp_client( - loop: asyncio.AbstractEventLoop, + event_loop: asyncio.AbstractEventLoop, ) -> Generator[AiohttpClient, None, None]: """Override the default aiohttp_client since 3.x does not support aiohttp_client_cls. @@ -292,6 +317,7 @@ def aiohttp_client( aiohttp_client(server, **kwargs) aiohttp_client(raw_server, **kwargs) """ + loop = event_loop clients = [] async def go( @@ -338,9 +364,10 @@ def hass_fixture_setup(): @pytest.fixture -def hass(hass_fixture_setup, loop, load_registries, hass_storage, request): +def hass(hass_fixture_setup, event_loop, load_registries, hass_storage, request): """Fixture to provide a test instance of Home Assistant.""" + loop = event_loop hass_fixture_setup.append(True) orig_tz = dt_util.DEFAULT_TIME_ZONE @@ -385,7 +412,7 @@ def exc_handle(loop, context): @pytest.fixture -async def stop_hass(): +async def stop_hass(event_loop): """Make sure all hass are stopped.""" orig_hass = ha.HomeAssistant @@ -406,6 +433,7 @@ def mock_hass(): with patch.object(hass_inst.loop, "stop"): await hass_inst.async_block_till_done() await hass_inst.async_stop(force=True) + await event_loop.shutdown_default_executor() @pytest.fixture @@ -864,6 +892,16 @@ def enable_statistics(): return False +@pytest.fixture +def enable_statistics_table_validation(): + """Fixture to control enabling of recorder's statistics table validation. + + To enable statistics table validation, tests can be marked with: + @pytest.mark.parametrize("enable_statistics_table_validation", [True]) + """ + return False + + @pytest.fixture def enable_nightly_purge(): """Fixture to control enabling of recorder's nightly purge job. @@ -906,6 +944,7 @@ def hass_recorder( recorder_db_url, enable_nightly_purge, enable_statistics, + enable_statistics_table_validation, hass_storage, ): """Home Assistant fixture with in-memory recorder.""" @@ -914,6 +953,11 @@ def hass_recorder( hass = get_test_home_assistant() nightly = recorder.Recorder.async_nightly_tasks if enable_nightly_purge else None stats = recorder.Recorder.async_periodic_statistics if enable_statistics else None + stats_validate = ( + recorder.statistics.validate_db_schema + if enable_statistics_table_validation + else itertools.repeat(set()) + ) with patch( "homeassistant.components.recorder.Recorder.async_nightly_tasks", side_effect=nightly, @@ -922,6 +966,10 @@ def hass_recorder( "homeassistant.components.recorder.Recorder.async_periodic_statistics", side_effect=stats, autospec=True, + ), patch( + "homeassistant.components.recorder.migration.statistics_validate_db_schema", + side_effect=stats_validate, + autospec=True, ): def setup_recorder(config=None): @@ -966,12 +1014,18 @@ async def async_setup_recorder_instance( hass_fixture_setup, enable_nightly_purge, enable_statistics, + enable_statistics_table_validation, ) -> AsyncGenerator[SetupRecorderInstanceT, None]: """Yield callable to setup recorder instance.""" assert not hass_fixture_setup nightly = recorder.Recorder.async_nightly_tasks if enable_nightly_purge else None stats = recorder.Recorder.async_periodic_statistics if enable_statistics else None + stats_validate = ( + recorder.statistics.validate_db_schema + if enable_statistics_table_validation + else itertools.repeat(set()) + ) with patch( "homeassistant.components.recorder.Recorder.async_nightly_tasks", side_effect=nightly, @@ -980,6 +1034,10 @@ async def async_setup_recorder_instance( "homeassistant.components.recorder.Recorder.async_periodic_statistics", side_effect=stats, autospec=True, + ), patch( + "homeassistant.components.recorder.migration.statistics_validate_db_schema", + side_effect=stats_validate, + autospec=True, ): async def async_setup_recorder( @@ -1045,18 +1103,19 @@ async def mock_enable_bluetooth( def mock_bluetooth_adapters(): """Fixture to mock bluetooth adapters.""" with patch( - "homeassistant.components.bluetooth.util.platform.system", return_value="Linux" - ), patch( - "bluetooth_adapters.BlueZDBusObjects", return_value=MagicMock(load=AsyncMock()) - ), patch( - "bluetooth_adapters.get_bluetooth_adapter_details", - return_value={ + "bluetooth_adapters.systems.platform.system", return_value="Linux" + ), patch("bluetooth_adapters.systems.linux.LinuxAdapters.refresh"), patch( + "bluetooth_adapters.systems.linux.LinuxAdapters.adapters", + { "hci0": { - "org.bluez.Adapter1": { - "Address": "00:00:00:00:00:01", - "Name": "BlueZ 4.63", - "Modalias": "usbid:1234", - } + "address": "00:00:00:00:00:01", + "hw_version": "usb:v1D6Bp0246d053F", + "passive_scan": False, + "sw_version": "homeassistant", + "manufacturer": "ACME", + "product": "Bluetooth Adapter 5.0", + "product_id": "aa01", + "vendor_id": "cc01", }, }, ): diff --git a/requirements_dev.txt b/requirements_dev.txt index abbf6b2..373882d 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -1,9 +1,9 @@ # This file is originally from homeassistant/core and modified by pytest-homeassistant-custom-component. -astroid==2.12.12 +astroid==2.12.13 codecov==2.1.12 -mypy==0.982 +mypy==0.991 pre-commit==2.20.0 -pylint==2.15.5 +pylint==2.15.7 types-atomicwrites==1.4.1 types-croniter==1.0.0 types-backports==0.1.3 @@ -13,6 +13,7 @@ types-decorator==0.1.7 types-enum34==0.1.8 types-ipaddress==0.1.5 types-pkg-resources==0.1.3 +types-python-dateutil==2.8.19.2 types-python-slugify==0.1.2 types-pytz==2021.1.2 types-PyYAML==5.4.6 diff --git a/requirements_test.txt b/requirements_test.txt index a3f454f..9442579 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -12,7 +12,8 @@ coverage==6.4.4 freezegun==1.2.2 mock-open==1.4.0 pipdeptree==2.3.1 -pytest-aiohttp==0.3.0 +pytest-asyncio==0.20.2 +pytest-aiohttp==1.0.4 pytest-cov==3.0.0 pytest-freezegun==0.4.2 pytest-socket==0.5.1 @@ -20,13 +21,13 @@ pytest-test-groups==1.0.3 pytest-sugar==0.9.5 pytest-timeout==2.1.0 pytest-xdist==2.5.0 -pytest==7.1.3 +pytest==7.2.0 requests_mock==1.10.0 -respx==0.19.2 +respx==0.20.1 stdlib-list==0.7.0 tomli==2.0.1;python_version<"3.11" tqdm==4.64.0 -homeassistant==2022.11.4 +homeassistant==2022.12.0b0 sqlalchemy==1.4.44 paho-mqtt==1.6.1 diff --git a/version b/version index 330a1e9..d23da87 100644 --- a/version +++ b/version @@ -1 +1 @@ -0.12.21 +0.12.22