From 6868d14ef0544316f9aecf816c98f4f50fbb06ce Mon Sep 17 00:00:00 2001 From: Matthew Flamm Date: Thu, 27 Jun 2024 05:05:02 +0000 Subject: [PATCH] Bump version --- README.md | 2 +- ha_version | 2 +- requirements_dev.txt | 2 +- requirements_test.txt | 10 +- .../common.py | 233 +++++++------- .../components/recorder/common.py | 2 +- .../components/recorder/db_schema_0.py | 3 +- .../const.py | 4 +- .../ignore_uncaught_exceptions.py | 12 + .../patch_time.py | 1 + .../plugins.py | 287 +++++++----------- .../syrupy.py | 2 + .../test_util/aiohttp.py | 2 +- version | 2 +- 14 files changed, 259 insertions(+), 305 deletions(-) diff --git a/README.md b/README.md index a82f91d..a56c637 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=2024.6.4&labelColor=blue) +![HA core version](https://img.shields.io/static/v1?label=HA+core+version&message=2024.7.0b2&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 ca26d9a..548910a 100644 --- a/ha_version +++ b/ha_version @@ -1 +1 @@ -2024.6.4 \ No newline at end of file +2024.7.0b2 \ No newline at end of file diff --git a/requirements_dev.txt b/requirements_dev.txt index 42df61b..0b17c87 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -1,6 +1,6 @@ # This file is originally from homeassistant/core and modified by pytest-homeassistant-custom-component. astroid==3.2.2 -mypy-dev==1.11.0a3 +mypy-dev==1.11.0a9 pre-commit==3.7.1 pylint==3.2.2 types-aiofiles==23.2.0.20240403 diff --git a/requirements_test.txt b/requirements_test.txt index da838c9..6ca8056 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -8,10 +8,10 @@ -c homeassistant/package_constraints.txt -r requirements_test_pre_commit.txt -coverage==7.5.0 +coverage==7.5.3 freezegun==1.5.0 mock-open==1.4.0 -pydantic==1.10.15 +pydantic==1.10.17 pylint-per-file-ignores==1.3.2 pipdeptree==2.19.0 pytest-asyncio==0.23.6 @@ -30,9 +30,9 @@ requests-mock==1.12.1 respx==0.21.1 syrupy==4.6.1 tqdm==4.66.4 -uv==0.1.43 -homeassistant==2024.6.4 -SQLAlchemy==2.0.30 +uv==0.2.13 +homeassistant==2024.7.0b2 +SQLAlchemy==2.0.31 paho-mqtt==1.6.1 diff --git a/src/pytest_homeassistant_custom_component/common.py b/src/pytest_homeassistant_custom_component/common.py index e5eedf4..7cddfa5 100644 --- a/src/pytest_homeassistant_custom_component/common.py +++ b/src/pytest_homeassistant_custom_component/common.py @@ -7,7 +7,7 @@ from __future__ import annotations import asyncio -from collections.abc import AsyncGenerator, Generator, Mapping, Sequence +from collections.abc import Callable, Coroutine, Mapping, Sequence from contextlib import asynccontextmanager, contextmanager from datetime import UTC, datetime, timedelta from enum import Enum @@ -22,12 +22,13 @@ import time import traceback from types import FrameType, ModuleType -from typing import Any, NoReturn +from typing import Any, Literal, NoReturn from unittest.mock import AsyncMock, Mock, patch from aiohttp.test_utils import unused_port as get_test_instance_port # noqa: F401 import pytest from syrupy import SnapshotAssertion +from typing_extensions import AsyncGenerator, Generator import voluptuous as vol from homeassistant import auth, bootstrap, config_entries, loader @@ -43,7 +44,7 @@ _async_get_device_automation_capabilities as async_get_device_automation_capabilities, ) from homeassistant.config import async_process_component_config -from homeassistant.config_entries import ConfigEntry, ConfigFlow, _DataT +from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.const import ( DEVICE_DEFAULT_NAME, EVENT_HOMEASSISTANT_CLOSE, @@ -74,8 +75,6 @@ intent, issue_registry as ir, label_registry as lr, - recorder as recorder_helper, - restore_state, restore_state as rs, storage, translation, @@ -88,7 +87,6 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.json import JSONEncoder, _orjson_default_encoder, json_dumps from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.setup import setup_component from homeassistant.util.async_ import run_callback_threadsafe import homeassistant.util.dt as dt_util from homeassistant.util.json import ( @@ -100,8 +98,8 @@ json_loads_object, ) from homeassistant.util.signal_type import SignalType +import homeassistant.util.ulid as ulid_util from homeassistant.util.unit_system import METRIC_SYSTEM -import homeassistant.util.uuid as uuid_util import homeassistant.util.yaml.loader as yaml_loader from .testing_config.custom_components.test_constant_deprecation import ( @@ -166,7 +164,7 @@ def get_test_config_dir(*add_path): @contextmanager -def get_test_home_assistant() -> Generator[HomeAssistant, None, None]: +def get_test_home_assistant() -> Generator[HomeAssistant]: """Return a Home Assistant object pointing at test config directory.""" loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) @@ -179,7 +177,7 @@ def run_loop() -> None: """Run event loop.""" loop._thread_ident = threading.get_ident() - hass._loop_thread_id = loop._thread_ident + hass.loop_thread_id = loop._thread_ident loop.run_forever() loop_stop_event.set() @@ -200,9 +198,11 @@ def stop_hass() -> None: threading.Thread(name="LoopThread", target=run_loop, daemon=False).start() - yield hass - loop.run_until_complete(context_manager.__aexit__(None, None, None)) - loop.close() + try: + yield hass + finally: + loop.run_until_complete(context_manager.__aexit__(None, None, None)) + loop.close() class StoreWithoutWriteLoad[_T: (Mapping[str, Any] | Sequence[Any])](storage.Store[_T]): @@ -227,7 +227,7 @@ async def async_test_home_assistant( event_loop: asyncio.AbstractEventLoop | None = None, load_registries: bool = True, config_dir: str | None = None, -) -> AsyncGenerator[HomeAssistant, None]: +) -> 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) @@ -364,10 +364,11 @@ def clear_instance(event): hass.bus.async_listen_once(EVENT_HOMEASSISTANT_CLOSE, clear_instance) - yield hass - - # Restore timezone, it is set when creating the hass object - dt_util.set_default_time_zone(orig_tz) + try: + yield hass + finally: + # Restore timezone, it is set when creating the hass object + dt_util.set_default_time_zone(orig_tz) def async_mock_service( @@ -418,10 +419,10 @@ def async_mock_intent(hass, intent_typ): class MockIntentHandler(intent.IntentHandler): intent_type = intent_typ - async def async_handle(self, intent): + async def async_handle(self, intent_obj): """Handle the intent.""" - intents.append(intent) - return intent.create_response() + intents.append(intent_obj) + return intent_obj.create_response() intent.async_register(hass, MockIntentHandler()) @@ -561,7 +562,7 @@ def get_fixture_path(filename: str, integration: str | None = None) -> pathlib.P @lru_cache def load_fixture(filename: str, integration: str | None = None) -> str: """Load a fixture.""" - return get_fixture_path(filename, integration).read_text() + return get_fixture_path(filename, integration).read_text(encoding="utf8") def load_json_value_fixture( @@ -606,7 +607,7 @@ def mock_state_change_event( def mock_component(hass: HomeAssistant, component: str) -> None: """Mock a component is setup.""" if component in hass.config.components: - AssertionError(f"Integration {component} is already setup") + raise AssertionError(f"Integration {component} is already setup") hass.config.components.add(component) @@ -696,19 +697,19 @@ def mock_device_registry( class MockGroup(auth_models.Group): """Mock a group in Home Assistant.""" - def __init__(self, id=None, name="Mock Group", policy=system_policies.ADMIN_POLICY): + def __init__(self, id: str | None = None, name: str | None = "Mock Group") -> None: """Mock a group.""" - kwargs = {"name": name, "policy": policy} + kwargs = {"name": name, "policy": system_policies.ADMIN_POLICY} if id is not None: kwargs["id"] = id super().__init__(**kwargs) - def add_to_hass(self, hass): + def add_to_hass(self, hass: HomeAssistant) -> MockGroup: """Test helper to add entry to hass.""" return self.add_to_auth_manager(hass.auth) - def add_to_auth_manager(self, auth_mgr): + def add_to_auth_manager(self, auth_mgr: auth.AuthManager) -> MockGroup: """Test helper to add entry to hass.""" ensure_auth_manager_loaded(auth_mgr) auth_mgr._store._groups[self.id] = self @@ -720,13 +721,13 @@ class MockUser(auth_models.User): def __init__( self, - id=None, - is_owner=False, - is_active=True, - name="Mock User", - system_generated=False, - groups=None, - ): + id: str | None = None, + is_owner: bool = False, + is_active: bool = True, + name: str | None = "Mock User", + system_generated: bool = False, + groups: list[auth_models.Group] | None = None, + ) -> None: """Initialize mock user.""" kwargs = { "is_owner": is_owner, @@ -740,17 +741,17 @@ def __init__( kwargs["id"] = id super().__init__(**kwargs) - def add_to_hass(self, hass): + def add_to_hass(self, hass: HomeAssistant) -> MockUser: """Test helper to add entry to hass.""" return self.add_to_auth_manager(hass.auth) - def add_to_auth_manager(self, auth_mgr): + def add_to_auth_manager(self, auth_mgr: auth.AuthManager) -> MockUser: """Test helper to add entry to hass.""" ensure_auth_manager_loaded(auth_mgr) auth_mgr._store._users[self.id] = self return self - def mock_policy(self, policy): + def mock_policy(self, policy: auth_permissions.PolicyType) -> None: """Mock a policy for a user.""" self.permissions = auth_permissions.PolicyPermissions(policy, self.perm_lookup) @@ -774,7 +775,7 @@ async def register_auth_provider( @callback -def ensure_auth_manager_loaded(auth_mgr): +def ensure_auth_manager_loaded(auth_mgr: auth.AuthManager) -> None: """Ensure an auth manager is considered loaded.""" store = auth_mgr._store if store._users is None: @@ -786,21 +787,38 @@ class MockModule: def __init__( self, - domain=None, - dependencies=None, - setup=None, - requirements=None, - config_schema=None, - platform_schema=None, - platform_schema_base=None, - async_setup=None, - async_setup_entry=None, - async_unload_entry=None, - async_migrate_entry=None, - async_remove_entry=None, - partial_manifest=None, - async_remove_config_entry_device=None, - ): + domain: str | None = None, + *, + dependencies: list[str] | None = None, + setup: Callable[[HomeAssistant, ConfigType], bool] | None = None, + requirements: list[str] | None = None, + config_schema: vol.Schema | None = None, + platform_schema: vol.Schema | None = None, + platform_schema_base: vol.Schema | None = None, + async_setup: Callable[[HomeAssistant, ConfigType], Coroutine[Any, Any, bool]] + | None = None, + async_setup_entry: Callable[ + [HomeAssistant, ConfigEntry], Coroutine[Any, Any, bool] + ] + | None = None, + async_unload_entry: Callable[ + [HomeAssistant, ConfigEntry], Coroutine[Any, Any, bool] + ] + | None = None, + async_migrate_entry: Callable[ + [HomeAssistant, ConfigEntry], Coroutine[Any, Any, bool] + ] + | None = None, + async_remove_entry: Callable[ + [HomeAssistant, ConfigEntry], Coroutine[Any, Any, None] + ] + | None = None, + partial_manifest: dict[str, Any] | None = None, + async_remove_config_entry_device: Callable[ + [HomeAssistant, ConfigEntry, dr.DeviceEntry], Coroutine[Any, Any, bool] + ] + | None = None, + ) -> None: """Initialize the mock module.""" self.__name__ = f"homeassistant.components.{domain}" self.__file__ = f"homeassistant/components/{domain}" @@ -821,6 +839,7 @@ def __init__( if setup: # We run this in executor, wrap it in function + # pylint: disable-next=unnecessary-lambda self.setup = lambda *args: setup(*args) if async_setup is not None: @@ -860,13 +879,25 @@ class MockPlatform: def __init__( self, - setup_platform=None, - dependencies=None, - platform_schema=None, - async_setup_platform=None, - async_setup_entry=None, - scan_interval=None, - ): + *, + setup_platform: Callable[ + [HomeAssistant, ConfigType, AddEntitiesCallback, DiscoveryInfoType | None], + None, + ] + | None = None, + dependencies: list[str] | None = None, + platform_schema: vol.Schema | None = None, + async_setup_platform: Callable[ + [HomeAssistant, ConfigType, AddEntitiesCallback, DiscoveryInfoType | None], + Coroutine[Any, Any, None], + ] + | None = None, + async_setup_entry: Callable[ + [HomeAssistant, ConfigEntry, AddEntitiesCallback], Coroutine[Any, Any, None] + ] + | None = None, + scan_interval: timedelta | None = None, + ) -> None: """Initialize the platform.""" self.DEPENDENCIES = dependencies or [] @@ -878,6 +909,7 @@ def __init__( if setup_platform is not None: # We run this in executor, wrap it in function + # pylint: disable-next=unnecessary-lambda self.setup_platform = lambda *args: setup_platform(*args) if async_setup_platform is not None: @@ -902,7 +934,7 @@ def __init__( platform=None, scan_interval=timedelta(seconds=15), entity_namespace=None, - ): + ) -> None: """Initialize a mock entity platform.""" if logger is None: logger = logging.getLogger("homeassistant.helpers.entity_platform") @@ -931,41 +963,41 @@ def _async_on_stop(_: Event) -> None: class MockToggleEntity(entity.ToggleEntity): """Provide a mock toggle device.""" - def __init__(self, name, state, unique_id=None): + def __init__(self, name: str | None, state: Literal["on", "off"] | None) -> None: """Initialize the mock entity.""" self._name = name or DEVICE_DEFAULT_NAME self._state = state - self.calls = [] + self.calls: list[tuple[str, dict[str, Any]]] = [] @property - def name(self): + def name(self) -> str: """Return the name of the entity if any.""" self.calls.append(("name", {})) return self._name @property - def state(self): + def state(self) -> Literal["on", "off"] | None: """Return the state of the entity if any.""" self.calls.append(("state", {})) return self._state @property - def is_on(self): + def is_on(self) -> bool: """Return true if entity is on.""" self.calls.append(("is_on", {})) return self._state == STATE_ON - def turn_on(self, **kwargs): + def turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" self.calls.append(("turn_on", kwargs)) self._state = STATE_ON - def turn_off(self, **kwargs): + def turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" self.calls.append(("turn_off", kwargs)) self._state = STATE_OFF - def last_call(self, method=None): + def last_call(self, method: str | None = None) -> tuple[str, dict[str, Any]]: """Return the last call.""" if not self.calls: return None @@ -977,11 +1009,9 @@ def last_call(self, method=None): return None -class MockConfigEntry(config_entries.ConfigEntry[_DataT]): +class MockConfigEntry(config_entries.ConfigEntry): """Helper for creating config entries that adds some defaults.""" - runtime_data: _DataT - def __init__( self, *, @@ -1005,7 +1035,7 @@ def __init__( "data": data or {}, "disabled_by": disabled_by, "domain": domain, - "entry_id": entry_id or uuid_util.random_uuid_hex(), + "entry_id": entry_id or ulid_util.ulid_now(), "minor_version": minor_version, "options": options or {}, "pref_disable_new_entities": pref_disable_new_entities, @@ -1136,33 +1166,10 @@ async def mock_psc(hass, config_input, integration, component=None): ), f"setup_component failed, expected {count} got {res_len}: {res}" -def init_recorder_component(hass, add_config=None, db_url="sqlite://"): - """Initialize the recorder.""" - # Local import to avoid processing recorder and SQLite modules when running a - # testcase which does not use the recorder. - from homeassistant.components import recorder - - config = dict(add_config) if add_config else {} - if recorder.CONF_DB_URL not in config: - config[recorder.CONF_DB_URL] = db_url - if recorder.CONF_COMMIT_INTERVAL not in config: - config[recorder.CONF_COMMIT_INTERVAL] = 0 - - with patch("homeassistant.components.recorder.ALLOW_IN_MEMORY_DB", True): - if recorder.DOMAIN not in hass.data: - recorder_helper.async_initialize_recorder(hass) - assert setup_component(hass, recorder.DOMAIN, {recorder.DOMAIN: config}) - assert recorder.DOMAIN in hass.config.components - _LOGGER.info( - "Test recorder successfully started, database location: %s", - config[recorder.CONF_DB_URL], - ) - - def mock_restore_cache(hass: HomeAssistant, states: Sequence[State]) -> None: """Mock the DATA_RESTORE_CACHE.""" - key = restore_state.DATA_RESTORE_STATE - data = restore_state.RestoreStateData(hass) + key = rs.DATA_RESTORE_STATE + data = rs.RestoreStateData(hass) now = dt_util.utcnow() last_states = {} @@ -1174,14 +1181,14 @@ def mock_restore_cache(hass: HomeAssistant, states: Sequence[State]) -> None: json.dumps(restored_state["attributes"], cls=JSONEncoder) ), } - last_states[state.entity_id] = restore_state.StoredState.from_dict( + last_states[state.entity_id] = rs.StoredState.from_dict( {"state": restored_state, "last_seen": now} ) data.last_states = last_states _LOGGER.debug("Restore cache: %s", data.last_states) assert len(data.last_states) == len(states), f"Duplicate entity_id? {states}" - restore_state.async_get.cache_clear() + rs.async_get.cache_clear() hass.data[key] = data @@ -1189,8 +1196,8 @@ def mock_restore_cache_with_extra_data( hass: HomeAssistant, states: Sequence[tuple[State, Mapping[str, Any]]] ) -> None: """Mock the DATA_RESTORE_CACHE.""" - key = restore_state.DATA_RESTORE_STATE - data = restore_state.RestoreStateData(hass) + key = rs.DATA_RESTORE_STATE + data = rs.RestoreStateData(hass) now = dt_util.utcnow() last_states = {} @@ -1202,22 +1209,22 @@ def mock_restore_cache_with_extra_data( json.dumps(restored_state["attributes"], cls=JSONEncoder) ), } - last_states[state.entity_id] = restore_state.StoredState.from_dict( + last_states[state.entity_id] = rs.StoredState.from_dict( {"state": restored_state, "extra_data": extra_data, "last_seen": now} ) data.last_states = last_states _LOGGER.debug("Restore cache: %s", data.last_states) assert len(data.last_states) == len(states), f"Duplicate entity_id? {states}" - restore_state.async_get.cache_clear() + rs.async_get.cache_clear() hass.data[key] = data async def async_mock_restore_state_shutdown_restart( hass: HomeAssistant, -) -> restore_state.RestoreStateData: +) -> rs.RestoreStateData: """Mock shutting down and saving restore state and restoring.""" - data = restore_state.async_get(hass) + data = rs.async_get(hass) await data.async_dump_states() await async_mock_load_restore_state_from_storage(hass) return data @@ -1230,7 +1237,7 @@ async def async_mock_load_restore_state_from_storage( hass_storage must already be mocked. """ - await restore_state.async_get(hass).async_load() + await rs.async_get(hass).async_load() class MockEntity(entity.Entity): @@ -1331,9 +1338,7 @@ def _handle(self, attr: str) -> Any: @contextmanager -def mock_storage( - data: dict[str, Any] | None = None, -) -> Generator[dict[str, Any], None, None]: +def mock_storage(data: dict[str, Any] | None = None) -> Generator[dict[str, Any]]: """Mock storage. Data is a dict {'key': {'version': version, 'data': data}} @@ -1441,7 +1446,10 @@ def mock_config_flow(domain: str, config_flow: type[ConfigFlow]) -> None: def mock_integration( - hass: HomeAssistant, module: MockModule, built_in: bool = True + hass: HomeAssistant, + module: MockModule, + built_in: bool = True, + top_level_files: set[str] | None = None, ) -> loader.Integration: """Mock an integration.""" integration = loader.Integration( @@ -1451,7 +1459,7 @@ def mock_integration( else f"{loader.PACKAGE_CUSTOM_COMPONENTS}.{module.DOMAIN}", pathlib.Path(""), module.mock_manifest(), - set(), + top_level_files, ) def mock_import_platform(platform_name: str) -> NoReturn: @@ -1580,6 +1588,7 @@ def async_get_persistent_notifications( def async_mock_cloud_connection_status(hass: HomeAssistant, connected: bool) -> None: """Mock a signal the cloud disconnected.""" + # pylint: disable-next=import-outside-toplevel from homeassistant.components.cloud import ( SIGNAL_CLOUD_CONNECTION_STATE, CloudConnectionState, @@ -1688,7 +1697,7 @@ def import_and_test_deprecated_alias( def help_test_all(module: ModuleType) -> None: """Test module.__all__ is correctly set.""" assert set(module.__all__) == { - itm for itm in module.__dir__() if not itm.startswith("_") + itm for itm in dir(module) if not itm.startswith("_") } diff --git a/src/pytest_homeassistant_custom_component/components/recorder/common.py b/src/pytest_homeassistant_custom_component/components/recorder/common.py index 79e9f37..e33dc1d 100644 --- a/src/pytest_homeassistant_custom_component/components/recorder/common.py +++ b/src/pytest_homeassistant_custom_component/components/recorder/common.py @@ -142,7 +142,7 @@ async def async_recorder_block_till_done(hass: HomeAssistant) -> None: def corrupt_db_file(test_db_file): """Corrupt an sqlite3 database file.""" - with open(test_db_file, "w+") as fhandle: + with open(test_db_file, "w+", encoding="utf8") as fhandle: fhandle.seek(200) fhandle.write("I am a corrupt db" * 100) diff --git a/src/pytest_homeassistant_custom_component/components/recorder/db_schema_0.py b/src/pytest_homeassistant_custom_component/components/recorder/db_schema_0.py index 008e2df..ce1b8bf 100644 --- a/src/pytest_homeassistant_custom_component/components/recorder/db_schema_0.py +++ b/src/pytest_homeassistant_custom_component/components/recorder/db_schema_0.py @@ -23,6 +23,7 @@ distinct, ) from sqlalchemy.orm import declarative_base +from sqlalchemy.orm.session import Session from homeassistant.core import Event, EventOrigin, State, split_entity_id from homeassistant.helpers.json import JSONEncoder @@ -145,8 +146,6 @@ def entity_ids(self, point_in_time=None): Specify point_in_time if you want to know which existed at that point in time inside the run. """ - from sqlalchemy.orm.session import Session - session = Session.object_session(self) assert session is not None, "RecorderRuns need to be persisted" diff --git a/src/pytest_homeassistant_custom_component/const.py b/src/pytest_homeassistant_custom_component/const.py index 572fb37..2336f66 100644 --- a/src/pytest_homeassistant_custom_component/const.py +++ b/src/pytest_homeassistant_custom_component/const.py @@ -5,7 +5,7 @@ """ from typing import TYPE_CHECKING, Final MAJOR_VERSION: Final = 2024 -MINOR_VERSION: Final = 6 -PATCH_VERSION: Final = "4" +MINOR_VERSION: Final = 7 +PATCH_VERSION: Final = "0b2" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" diff --git a/src/pytest_homeassistant_custom_component/ignore_uncaught_exceptions.py b/src/pytest_homeassistant_custom_component/ignore_uncaught_exceptions.py index d8f8a31..88afd52 100644 --- a/src/pytest_homeassistant_custom_component/ignore_uncaught_exceptions.py +++ b/src/pytest_homeassistant_custom_component/ignore_uncaught_exceptions.py @@ -17,6 +17,18 @@ ".helpers.test_event", "test_track_point_in_time_repr", ), + ( + # This test explicitly throws an uncaught exception + # and should not be removed. + ".test_config_entries", + "test_config_entry_unloaded_during_platform_setups", + ), + ( + # This test explicitly throws an uncaught exception + # and should not be removed. + ".test_config_entries", + "test_config_entry_unloaded_during_platform_setup", + ), ( "test_homeassistant_bridge", "test_homeassistant_bridge_fan_setup", diff --git a/src/pytest_homeassistant_custom_component/patch_time.py b/src/pytest_homeassistant_custom_component/patch_time.py index 4412836..b4abc0c 100644 --- a/src/pytest_homeassistant_custom_component/patch_time.py +++ b/src/pytest_homeassistant_custom_component/patch_time.py @@ -29,4 +29,5 @@ def _monotonic() -> float: event_helper.time_tracker_utcnow = _utcnow # type: ignore[assignment] util.utcnow = _utcnow # type: ignore[assignment] +# Replace bound methods which are not found by freezegun runner.monotonic = _monotonic # type: ignore[assignment] diff --git a/src/pytest_homeassistant_custom_component/plugins.py b/src/pytest_homeassistant_custom_component/plugins.py index 7cb7725..8fd34cb 100644 --- a/src/pytest_homeassistant_custom_component/plugins.py +++ b/src/pytest_homeassistant_custom_component/plugins.py @@ -7,8 +7,9 @@ from __future__ import annotations import asyncio -from collections.abc import AsyncGenerator, Callable, Coroutine, Generator +from collections.abc import Callable, Coroutine from contextlib import asynccontextmanager, contextmanager +import datetime import functools import gc import itertools @@ -19,7 +20,7 @@ import ssl import threading from typing import TYPE_CHECKING, Any, cast -from unittest.mock import AsyncMock, MagicMock, Mock, patch +from unittest.mock import AsyncMock, MagicMock, Mock, _patch, patch from aiohttp import client from aiohttp.test_utils import ( @@ -30,12 +31,16 @@ ) from aiohttp.typedefs import JSONDecoder from aiohttp.web import Application +import bcrypt import freezegun import multidict import pytest import pytest_socket import requests_mock from syrupy.assertion import SnapshotAssertion +from typing_extensions import AsyncGenerator, Generator + +from homeassistant import block_async_io # Setup patching if dt_util time functions before any other Home Assistant imports from . import patch_time # noqa: F401, isort:skip @@ -43,7 +48,7 @@ from homeassistant import core as ha, loader, runner from homeassistant.auth.const import GROUP_ID_ADMIN, GROUP_ID_READ_ONLY from homeassistant.auth.models import Credentials -from homeassistant.auth.providers import homeassistant, legacy_api_password +from homeassistant.auth.providers import homeassistant from homeassistant.components.device_tracker.legacy import Device from homeassistant.components.websocket_api.auth import ( TYPE_AUTH, @@ -54,7 +59,13 @@ from homeassistant.config import YAML_CONFIG_FILE from homeassistant.config_entries import ConfigEntries, ConfigEntry, ConfigEntryState from homeassistant.const import HASSIO_USER_NAME -from homeassistant.core import CoreState, HassJob, HomeAssistant +from homeassistant.core import ( + CoreState, + HassJob, + HomeAssistant, + ServiceCall, + ServiceResponse, +) from homeassistant.helpers import ( area_registry as ar, category_registry as cr, @@ -67,9 +78,10 @@ recorder as recorder_helper, ) from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.translation import _TranslationsCacheData from homeassistant.helpers.typing import ConfigType from homeassistant.setup import BASE_PLATFORMS, async_setup_component -from homeassistant.util import location +from homeassistant.util import dt as dt_util, location from homeassistant.util.async_ import create_eager_task from homeassistant.util.json import json_loads @@ -99,8 +111,6 @@ MockUser, async_fire_mqtt_message, async_test_home_assistant, - get_test_home_assistant, - init_recorder_component, mock_storage, patch_yaml_files, extract_stack_to_frame, @@ -296,7 +306,7 @@ def wait_for_stop_scripts_after_shutdown() -> bool: @pytest.fixture(autouse=True) def skip_stop_scripts( wait_for_stop_scripts_after_shutdown: bool, -) -> Generator[None, None, None]: +) -> Generator[None]: """Add ability to bypass _schedule_stop_scripts_after_shutdown.""" if wait_for_stop_scripts_after_shutdown: yield @@ -309,7 +319,7 @@ def skip_stop_scripts( @contextmanager -def long_repr_strings() -> Generator[None, None, None]: +def long_repr_strings() -> Generator[None]: """Increase reprlib maxstring and maxother to 300.""" arepr = reprlib.aRepr original_maxstring = arepr.maxstring @@ -334,7 +344,7 @@ def verify_cleanup( event_loop: asyncio.AbstractEventLoop, expected_lingering_tasks: bool, expected_lingering_timers: bool, -) -> Generator[None, None, None]: +) -> Generator[None]: """Verify that the test has cleaned up resources correctly.""" threads_before = frozenset(threading.enumerate()) tasks_before = asyncio.all_tasks(event_loop) @@ -380,19 +390,24 @@ def verify_cleanup( "waitpid-" ) + try: + # Verify the default time zone has been restored + assert dt_util.DEFAULT_TIME_ZONE is datetime.UTC + finally: + # Restore the default time zone to not break subsequent tests + dt_util.DEFAULT_TIME_ZONE = datetime.UTC + @pytest.fixture(autouse=True) -def reset_hass_threading_local_object() -> Generator[None, None, None]: +def reset_hass_threading_local_object() -> Generator[None]: """Reset the _Hass threading.local object for every test case.""" yield ha._hass.__dict__.clear() @pytest.fixture(scope="session", autouse=True) -def bcrypt_cost() -> Generator[None, None, None]: +def bcrypt_cost() -> Generator[None]: """Run with reduced rounds during tests, to speed up uses.""" - import bcrypt - gensalt_orig = bcrypt.gensalt def gensalt_mock(rounds=12, prefix=b"2b"): @@ -404,7 +419,7 @@ def gensalt_mock(rounds=12, prefix=b"2b"): @pytest.fixture -def hass_storage() -> Generator[dict[str, Any], None, None]: +def hass_storage() -> Generator[dict[str, Any]]: """Fixture to mock storage.""" with mock_storage() as stored_data: yield stored_data @@ -462,7 +477,7 @@ def aiohttp_client_cls() -> type[CoalescingClient]: @pytest.fixture def aiohttp_client( event_loop: asyncio.AbstractEventLoop, -) -> Generator[ClientSessionGenerator, None, None]: +) -> Generator[ClientSessionGenerator]: """Override the default aiohttp_client since 3.x does not support aiohttp_client_cls. Remove this when upgrading to 4.x as aiohttp_client_cls @@ -527,7 +542,7 @@ async def hass( hass_storage: dict[str, Any], request: pytest.FixtureRequest, mock_recorder_before_hass: None, -) -> AsyncGenerator[HomeAssistant, None]: +) -> AsyncGenerator[HomeAssistant]: """Create a test instance of Home Assistant.""" loop = asyncio.get_running_loop() @@ -586,7 +601,7 @@ def exc_handle(loop, context): @pytest.fixture -async def stop_hass() -> AsyncGenerator[None, None]: +async def stop_hass() -> AsyncGenerator[None]: """Make sure all hass are stopped.""" orig_hass = ha.HomeAssistant @@ -612,21 +627,21 @@ def mock_hass(*args): @pytest.fixture(name="requests_mock") -def requests_mock_fixture() -> Generator[requests_mock.Mocker, None, None]: +def requests_mock_fixture() -> Generator[requests_mock.Mocker]: """Fixture to provide a requests mocker.""" with requests_mock.mock() as m: yield m @pytest.fixture -def aioclient_mock() -> Generator[AiohttpClientMocker, None, None]: +def aioclient_mock() -> Generator[AiohttpClientMocker]: """Fixture to mock aioclient calls.""" with mock_aiohttp_client() as mock_session: yield mock_session @pytest.fixture -def mock_device_tracker_conf() -> Generator[list[Device], None, None]: +def mock_device_tracker_conf() -> Generator[list[Device]]: """Prevent device tracker from reading/writing data.""" devices: list[Device] = [] @@ -738,7 +753,7 @@ async def hass_supervisor_user( @pytest.fixture async def hass_supervisor_access_token( hass: HomeAssistant, - hass_supervisor_user, + hass_supervisor_user: MockUser, local_auth: homeassistant.HassAuthProvider, ) -> str: """Return a Home Assistant Supervisor access token.""" @@ -746,20 +761,6 @@ async def hass_supervisor_access_token( return hass.auth.async_create_access_token(refresh_token) -@pytest.fixture -def legacy_auth( - hass: HomeAssistant, -) -> legacy_api_password.LegacyApiPasswordAuthProvider: - """Load legacy API password provider.""" - prv = legacy_api_password.LegacyApiPasswordAuthProvider( - hass, - hass.auth._store, - {"type": "legacy_api_password", "api_password": "test-password"}, - ) - hass.auth._providers[(prv.type, prv.id)] = prv - return prv - - @pytest.fixture async def local_auth(hass: HomeAssistant) -> homeassistant.HassAuthProvider: """Load local auth provider.""" @@ -805,7 +806,7 @@ async def client() -> TestClient: @pytest.fixture -def current_request() -> Generator[MagicMock, None, None]: +def current_request() -> Generator[MagicMock]: """Mock current request.""" with patch("homeassistant.components.http.current_request") as mock_request_context: mocked_request = make_mocked_request( @@ -831,7 +832,7 @@ def current_request_with_host(current_request: MagicMock) -> None: @pytest.fixture def hass_ws_client( aiohttp_client: ClientSessionGenerator, - hass_access_token: str | None, + hass_access_token: str, hass: HomeAssistant, socket_enabled: None, ) -> WebSocketGenerator: @@ -855,7 +856,7 @@ async def create_client( auth_ok = await websocket.receive_json() assert auth_ok["type"] == TYPE_AUTH_OK - def _get_next_id() -> Generator[int, None, None]: + def _get_next_id() -> Generator[int]: i = 0 while True: yield (i := i + 1) @@ -895,7 +896,7 @@ def fail_on_log_exception( return def log_exception(format_err, *args): - raise + raise # pylint: disable=misplaced-bare-raise monkeypatch.setattr("homeassistant.util.logging.log_exception", log_exception) @@ -907,7 +908,7 @@ def mqtt_config_entry_data() -> dict[str, Any] | None: @pytest.fixture -def mqtt_client_mock(hass: HomeAssistant) -> Generator[MqttMockPahoClient, None, None]: +def mqtt_client_mock(hass: HomeAssistant) -> Generator[MqttMockPahoClient]: """Fixture to mock MQTT client.""" mid: int = 0 @@ -924,7 +925,9 @@ def __init__(self, mid: int) -> None: self.mid = mid self.rc = 0 - with patch("paho.mqtt.client.Client") as mock_client: + with patch( + "homeassistant.components.mqtt.async_client.AsyncMQTTClient" + ) as mock_client: # The below use a call_soon for the on_publish/on_subscribe/on_unsubscribe # callbacks to simulate the behavior of the real MQTT client which will # not be synchronous. @@ -967,6 +970,7 @@ def _connect(*args, **kwargs): mock_client.subscribe.side_effect = _subscribe mock_client.unsubscribe.side_effect = _unsubscribe mock_client.publish.side_effect = _async_fire_mqtt_message + mock_client.loop_read.return_value = 0 yield mock_client @@ -977,7 +981,7 @@ async def mqtt_mock( mqtt_client_mock: MqttMockPahoClient, mqtt_config_entry_data: dict[str, Any] | None, mqtt_mock_entry: MqttMockHAClientGenerator, -) -> AsyncGenerator[MqttMockHAClient, None]: +) -> AsyncGenerator[MqttMockHAClient]: """Fixture to mock MQTT component.""" return await mqtt_mock_entry() @@ -987,7 +991,7 @@ async def _mqtt_mock_entry( hass: HomeAssistant, mqtt_client_mock: MqttMockPahoClient, mqtt_config_entry_data: dict[str, Any] | None, -) -> AsyncGenerator[MqttMockHAClientGenerator, None]: +) -> AsyncGenerator[MqttMockHAClientGenerator]: """Fixture to mock a delayed setup of the MQTT config entry.""" # Local import to avoid processing MQTT modules when running a testcase # which does not use MQTT. @@ -1061,9 +1065,7 @@ def hass_config() -> ConfigType: @pytest.fixture -def mock_hass_config( - hass: HomeAssistant, hass_config: ConfigType -) -> Generator[None, None, None]: +def mock_hass_config(hass: HomeAssistant, hass_config: ConfigType) -> Generator[None]: """Fixture to mock the content of main configuration. Patches homeassistant.config.load_yaml_config_file and hass.config_entries @@ -1102,7 +1104,7 @@ def hass_config_yaml_files(hass_config_yaml: str) -> dict[str, str]: @pytest.fixture def mock_hass_config_yaml( hass: HomeAssistant, hass_config_yaml_files: dict[str, str] -) -> Generator[None, None, None]: +) -> Generator[None]: """Fixture to mock the content of the yaml configuration files. Patches yaml configuration files using the `hass_config_yaml` @@ -1117,7 +1119,7 @@ async def mqtt_mock_entry( hass: HomeAssistant, mqtt_client_mock: MqttMockPahoClient, mqtt_config_entry_data: dict[str, Any] | None, -) -> AsyncGenerator[MqttMockHAClientGenerator, None]: +) -> AsyncGenerator[MqttMockHAClientGenerator]: """Set up an MQTT config entry.""" async def _async_setup_config_entry( @@ -1139,7 +1141,7 @@ async def _setup_mqtt_entry() -> MqttMockHAClient: @pytest.fixture(autouse=True, scope="session") -def mock_network() -> Generator[None, None, None]: +def mock_network() -> Generator[None]: """Mock network.""" with patch( "homeassistant.components.network.util.ifaddr.get_adapters", @@ -1155,7 +1157,7 @@ def mock_network() -> Generator[None, None, None]: @pytest.fixture(autouse=True, scope="session") -def mock_get_source_ip() -> Generator[patch, None, None]: +def mock_get_source_ip() -> Generator[_patch]: """Mock network util's async_get_source_ip.""" patcher = patch( "homeassistant.components.network.util.async_get_source_ip", @@ -1169,10 +1171,8 @@ def mock_get_source_ip() -> Generator[patch, None, None]: @pytest.fixture(autouse=True, scope="session") -def translations_once() -> Generator[patch, None, None]: +def translations_once() -> Generator[_patch]: """Only load translations once per session.""" - from homeassistant.helpers.translation import _TranslationsCacheData - cache = _TranslationsCacheData({}, {}) patcher = patch( "homeassistant.helpers.translation._TranslationsCacheData", @@ -1186,7 +1186,9 @@ def translations_once() -> Generator[patch, None, None]: @pytest.fixture -def disable_translations_once(translations_once): +def disable_translations_once( + translations_once: _patch, +) -> Generator[None]: """Override loading translations once.""" translations_once.stop() yield @@ -1194,7 +1196,7 @@ def disable_translations_once(translations_once): @pytest.fixture -def mock_zeroconf() -> Generator[None, None, None]: +def mock_zeroconf() -> Generator[MagicMock]: """Mock zeroconf.""" from zeroconf import DNSCache # pylint: disable=import-outside-toplevel @@ -1210,7 +1212,7 @@ def mock_zeroconf() -> Generator[None, None, None]: @pytest.fixture -def mock_async_zeroconf(mock_zeroconf: None) -> Generator[None, None, None]: +def mock_async_zeroconf(mock_zeroconf: MagicMock) -> Generator[MagicMock]: """Mock AsyncZeroconf.""" from zeroconf import DNSCache, Zeroconf # pylint: disable=import-outside-toplevel from zeroconf.asyncio import ( # pylint: disable=import-outside-toplevel @@ -1315,7 +1317,7 @@ def recorder_config() -> dict[str, Any] | None: def recorder_db_url( pytestconfig: pytest.Config, hass_fixture_setup: list[bool], -) -> Generator[str, None, None]: +) -> Generator[str]: """Prepare a default database for tests and return a connection URL.""" assert not hass_fixture_setup @@ -1358,120 +1360,6 @@ def recorder_db_url( sqlalchemy_utils.drop_database(db_url) -@pytest.fixture -def hass_recorder( - recorder_db_url: str, - enable_nightly_purge: bool, - enable_statistics: bool, - enable_schema_validation: bool, - enable_migrate_context_ids: bool, - enable_migrate_event_type_ids: bool, - enable_migrate_entity_ids: bool, - hass_storage, -) -> Generator[Callable[..., HomeAssistant], None, None]: - """Home Assistant fixture with in-memory recorder.""" - # pylint: disable-next=import-outside-toplevel - from homeassistant.components import recorder - - # pylint: disable-next=import-outside-toplevel - from homeassistant.components.recorder import migration - - with get_test_home_assistant() as hass: - nightly = ( - recorder.Recorder.async_nightly_tasks if enable_nightly_purge else None - ) - stats = ( - recorder.Recorder.async_periodic_statistics if enable_statistics else None - ) - compile_missing = ( - recorder.Recorder._schedule_compile_missing_statistics - if enable_statistics - else None - ) - schema_validate = ( - migration._find_schema_errors - if enable_schema_validation - else itertools.repeat(set()) - ) - migrate_states_context_ids = ( - recorder.Recorder._migrate_states_context_ids - if enable_migrate_context_ids - else None - ) - migrate_events_context_ids = ( - recorder.Recorder._migrate_events_context_ids - if enable_migrate_context_ids - else None - ) - migrate_event_type_ids = ( - recorder.Recorder._migrate_event_type_ids - if enable_migrate_event_type_ids - else None - ) - migrate_entity_ids = ( - recorder.Recorder._migrate_entity_ids if enable_migrate_entity_ids else None - ) - with ( - patch( - "homeassistant.components.recorder.Recorder.async_nightly_tasks", - side_effect=nightly, - autospec=True, - ), - patch( - "homeassistant.components.recorder.Recorder.async_periodic_statistics", - side_effect=stats, - autospec=True, - ), - patch( - "homeassistant.components.recorder.migration._find_schema_errors", - side_effect=schema_validate, - autospec=True, - ), - patch( - "homeassistant.components.recorder.Recorder._migrate_events_context_ids", - side_effect=migrate_events_context_ids, - autospec=True, - ), - patch( - "homeassistant.components.recorder.Recorder._migrate_states_context_ids", - side_effect=migrate_states_context_ids, - autospec=True, - ), - patch( - "homeassistant.components.recorder.Recorder._migrate_event_type_ids", - side_effect=migrate_event_type_ids, - autospec=True, - ), - patch( - "homeassistant.components.recorder.Recorder._migrate_entity_ids", - side_effect=migrate_entity_ids, - autospec=True, - ), - patch( - "homeassistant.components.recorder.Recorder._schedule_compile_missing_statistics", - side_effect=compile_missing, - autospec=True, - ), - ): - - def setup_recorder( - *, config: dict[str, Any] | None = None, timezone: str | None = None - ) -> HomeAssistant: - """Set up with params.""" - if timezone is not None: - asyncio.run_coroutine_threadsafe( - hass.config.async_set_time_zone(timezone), hass.loop - ).result() - init_recorder_component(hass, config, recorder_db_url) - hass.start() - hass.block_till_done() - hass.data[recorder.DATA_INSTANCE].block_till_done() - return hass - - yield setup_recorder - hass.stop() - - async def _async_init_recorder_component( hass: HomeAssistant, add_config: dict[str, Any] | None = None, @@ -1509,7 +1397,7 @@ async def async_setup_recorder_instance( enable_migrate_context_ids: bool, enable_migrate_event_type_ids: bool, enable_migrate_entity_ids: bool, -) -> AsyncGenerator[RecorderInstanceGenerator, None]: +) -> AsyncGenerator[RecorderInstanceGenerator]: """Yield callable to setup recorder instance.""" # pylint: disable-next=import-outside-toplevel from homeassistant.components import recorder @@ -1632,7 +1520,7 @@ async def mock_enable_bluetooth( hass: HomeAssistant, mock_bleak_scanner_start: MagicMock, mock_bluetooth_adapters: None, -) -> AsyncGenerator[None, None]: +) -> AsyncGenerator[None]: """Fixture to mock starting the bleak scanner.""" entry = MockConfigEntry(domain="bluetooth", unique_id="00:00:00:00:00:01") entry.add_to_hass(hass) @@ -1644,7 +1532,7 @@ async def mock_enable_bluetooth( @pytest.fixture(scope="session") -def mock_bluetooth_adapters() -> Generator[None, None, None]: +def mock_bluetooth_adapters() -> Generator[None]: """Fixture to mock bluetooth adapters.""" with ( patch("bluetooth_auto_recovery.recover_adapter"), @@ -1670,7 +1558,7 @@ def mock_bluetooth_adapters() -> Generator[None, None, None]: @pytest.fixture -def mock_bleak_scanner_start() -> Generator[MagicMock, None, None]: +def mock_bleak_scanner_start() -> Generator[MagicMock]: """Fixture to mock starting the bleak scanner.""" # Late imports to avoid loading bleak unless we need it @@ -1681,10 +1569,11 @@ def mock_bleak_scanner_start() -> Generator[MagicMock, None, None]: # We need to drop the stop method from the object since we patched # out start and this fixture will expire before the stop method is called # when EVENT_HOMEASSISTANT_STOP is fired. + # pylint: disable-next=c-extension-no-member bluetooth_scanner.OriginalBleakScanner.stop = AsyncMock() # type: ignore[assignment] with ( patch.object( - bluetooth_scanner.OriginalBleakScanner, + bluetooth_scanner.OriginalBleakScanner, # pylint: disable=c-extension-no-member "start", ) as mock_bleak_scanner_start, patch.object(bluetooth_scanner, "HaScanner"), @@ -1693,7 +1582,7 @@ def mock_bleak_scanner_start() -> Generator[MagicMock, None, None]: @pytest.fixture -def mock_integration_frame() -> Generator[Mock, None, None]: +def mock_integration_frame() -> Generator[Mock]: """Mock as if we're calling code from inside an integration.""" correct_frame = Mock( filename="/home/paulus/homeassistant/components/hue/light.py", @@ -1776,7 +1665,49 @@ def label_registry(hass: HomeAssistant) -> lr.LabelRegistry: return lr.async_get(hass) +@pytest.fixture +def service_calls(hass: HomeAssistant) -> Generator[None, None, list[ServiceCall]]: + """Track all service calls.""" + calls = [] + + _original_async_call = hass.services.async_call + + async def _async_call( + self, + domain: str, + service: str, + service_data: dict[str, Any] | None = None, + **kwargs: Any, + ) -> ServiceResponse: + calls.append(ServiceCall(domain, service, service_data)) + try: + return await _original_async_call( + domain, + service, + service_data, + **kwargs, + ) + except ha.ServiceNotFound: + _LOGGER.debug("Ignoring unknown service call to %s.%s", domain, service) + return None + + with patch("homeassistant.core.ServiceRegistry.async_call", _async_call): + yield calls + + @pytest.fixture def snapshot(snapshot: SnapshotAssertion) -> SnapshotAssertion: """Return snapshot assertion fixture with the Home Assistant extension.""" return snapshot.use_extension(HomeAssistantSnapshotExtension) + + +@pytest.fixture +def disable_block_async_io() -> Generator[Any, Any, None]: + """Fixture to disable the loop protection from block_async_io.""" + yield + calls = block_async_io._BLOCKED_CALLS.calls + for blocking_call in calls: + setattr( + blocking_call.object, blocking_call.function, blocking_call.original_func + ) + calls.clear() diff --git a/src/pytest_homeassistant_custom_component/syrupy.py b/src/pytest_homeassistant_custom_component/syrupy.py index e9f8cba..e527db1 100644 --- a/src/pytest_homeassistant_custom_component/syrupy.py +++ b/src/pytest_homeassistant_custom_component/syrupy.py @@ -163,6 +163,8 @@ def _serializable_device_registry_entry( ) if serialized["via_device_id"] is not None: serialized["via_device_id"] = ANY + if serialized["primary_config_entry"] is not None: + serialized["primary_config_entry"] = ANY return serialized @classmethod diff --git a/src/pytest_homeassistant_custom_component/test_util/aiohttp.py b/src/pytest_homeassistant_custom_component/test_util/aiohttp.py index 5c661e3..34a3d43 100644 --- a/src/pytest_homeassistant_custom_component/test_util/aiohttp.py +++ b/src/pytest_homeassistant_custom_component/test_util/aiohttp.py @@ -58,7 +58,7 @@ def request( content=None, json=None, params=None, - headers={}, + headers=None, exc=None, cookies=None, side_effect=None, diff --git a/version b/version index d0bc6c4..b81a630 100644 --- a/version +++ b/version @@ -1 +1 @@ -0.13.136 +0.13.137