diff --git a/custom_components/nestquest/__init__.py b/custom_components/nestquest/__init__.py index e3107b5..f34c276 100644 --- a/custom_components/nestquest/__init__.py +++ b/custom_components/nestquest/__init__.py @@ -50,7 +50,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - runtime_data = hass.data.setdefault(DOMAIN, {}).pop(entry.entry_id, None) + domain_data = hass.data.setdefault(DOMAIN, {}) + runtime_data = domain_data.pop(entry.entry_id, None) + if not domain_data: + hass.data.pop(DOMAIN, None) if runtime_data is None: runtime_data = getattr(entry, "runtime_data", None) if runtime_data is not None: diff --git a/pyproject.toml b/pyproject.toml index d7bdfe5..6d77648 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,9 +9,11 @@ dependencies = [] [dependency-groups] dev = [ "pytest>=8.0.0", + "pytest-asyncio>=0.24", "voluptuous>=0.14", ] [tool.pytest.ini_options] testpaths = ["tests"] pythonpath = ["."] +asyncio_mode = "auto" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..e702c10 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,308 @@ +"""Shared pytest fixtures for the NestQuest test suite. + +pytest-homeassistant-custom-component could not be adopted: it pins +``pytest==9.0.3`` and ``voluptuous==0.13.1``, which conflicts with the dev +group's ``voluptuous>=0.14`` (used directly by the options flow's schema and +its tests), and its back-releases raise the floor to Python >=3.12 while this +project supports >=3.11. The documented fallback is therefore used: +``pytest-asyncio`` plus a standard-shaped ``hass`` fixture below. + +conftest.py is the single place where ``homeassistant`` modules are provided: +this harness is explicitly MOCK-ONLY. It unconditionally installs a coherent +set of MagicMock stand-ins via ``sys.modules`` and wires the parent-package +attributes. All of that happens here, before any test module imports the +integration, so no test module needs its own ``sys.modules`` setup. It also +provides the ``hass`` fixture plus shared helpers used by the behavior tests +(config-entry factory, listener registry). + +Run this suite in its own venv *without* homeassistant installed (``uv run +pytest -q``); it is never meant to run against a real HA install. +""" + +from __future__ import annotations + +import asyncio +import importlib.util +import inspect +import sys +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +# --------------------------------------------------------------------------- +# Homeassistant module provisioning (explicitly mock-only). +# --------------------------------------------------------------------------- +_HA_MODULES = ( + "homeassistant", + "homeassistant.core", + "homeassistant.config_entries", + "homeassistant.data_entry_flow", +) + +def _find_spec(name: str): + """find_spec that returns None when the module (or its parent) is absent.""" + try: + return importlib.util.find_spec(name) + except (ModuleNotFoundError, ValueError): + return None + + +def _ensure_mock_only(): + """Fail fast if real homeassistant is importable: this harness is mock-only.""" + for _mod in _HA_MODULES: + # Anything already in sys.modules at load time is real HA; our mocks + # are only installed after this check. + if _mod in sys.modules or _find_spec(_mod) is not None: + raise pytest.UsageError( + "The NestQuest test harness is mock-only: real homeassistant is " + f"importable ({_mod!r} found), and mixing real HA modules with the " + "shaped mocks is not supported. Run the suite in its own venv " + "without homeassistant installed, e.g. `uv run pytest -q`." + ) + + +_ensure_mock_only() + +for _mod in _HA_MODULES: + sys.modules.setdefault(_mod, MagicMock()) + +# Wire parent package attributes so "from homeassistant import +# config_entries" resolves to the mocked submodule, mirroring real HA's +# package layout. +for _parent, _child in ( + ("homeassistant", "config_entries"), + ("homeassistant", "data_entry_flow"), + ("homeassistant", "core"), +): + setattr(sys.modules[_parent], _child, sys.modules[f"{_parent}.{_child}"]) + + +def _ha_mock(name: str): + """Return the shaped mock module object for a homeassistant module.""" + return sys.modules[name] + + +def options_flow_base(): + """Return the mocked OptionsFlowWithConfigEntry class.""" + return getattr(sys.modules["homeassistant.config_entries"], "OptionsFlowWithConfigEntry") + + +class _ConfigFlowBase: + """Stand-in for homeassistant.config_entries.ConfigFlow. + + A superset covering both the config-flow and options-flow tests: it must + satisfy every consumer of the mocked surface. + """ + + VERSION = 1 + + def __init_subclass__(cls, domain=None, **kwargs): + super().__init_subclass__(**kwargs) + cls._domain = domain + + def __init__(self): + self.context = {} + + def async_show_form(self, *, step_id, **kwargs): + return {"type": "form", "step_id": step_id, **kwargs} + + def async_abort(self, *, reason, **kwargs): + return {"type": "abort", "reason": reason} + + def async_create_entry(self, *, title, data, **kwargs): + """Return the flow result only; the flow manager persists the entry.""" + return {"type": "create_entry", "title": title, "data": data} + + async def async_set_unique_id(self, unique_id, raise_on_progress=True): + self.context["unique_id"] = unique_id + return None + + def _async_current_entries(self): + return self.hass.config_entries.async_entries(self._domain) + + def _async_in_progress(self): + return [] + + @staticmethod + def async_get_options_flow(config_entry): + raise NotImplementedError + + +class _OptionsFlowWithConfigEntryBase: + """Stand-in mirroring HA 2024.6 config_entries.OptionsFlowWithConfigEntry.""" + + def __init__(self, config_entry=None): + self.config_entry = config_entry + self.hass = None + self.options = dict(config_entry.options) if config_entry is not None else {} + + def async_show_form(self, *, step_id, **kwargs): + return {"type": "form", "step_id": step_id, **kwargs} + + def async_create_entry(self, *, title, data, **kwargs): + return {"type": "create_entry", "title": title, "data": data} + + +def _callback_decorator(fn): + """Mirror homeassistant.core.callback: flags the fn and returns it unchanged.""" + fn.__ha_callback__ = True + return fn + + +# The concrete 2024.6-shaped surface the integration is written against +# (mirrors the shapes the tests previously installed per module). +_config_entries_mock = _ha_mock("homeassistant.config_entries") +_core_mock = _ha_mock("homeassistant.core") +_data_entry_flow_mock = _ha_mock("homeassistant.data_entry_flow") + +_config_entries_mock.ConfigFlow = _ConfigFlowBase +_config_entries_mock.OptionsFlowWithConfigEntry = _OptionsFlowWithConfigEntryBase +_core_mock.callback = _callback_decorator +_data_entry_flow_mock.RESULT_TYPE_FORM = "form" +_data_entry_flow_mock.RESULT_TYPE_CREATE_ENTRY = "create_entry" +_data_entry_flow_mock.RESULT_TYPE_ABORT = "abort" +_data_entry_flow_mock.FlowResult = dict + + +# --------------------------------------------------------------------------- +# Shared helpers for the behavior tests. +# --------------------------------------------------------------------------- +class ListenerRegistry: + """Models HA's update-listener bookkeeping: appends to a list per entry, with per-listener removers.""" + + def __init__(self, hass=None): + self._hass = hass + self._listeners: dict[str, list[object]] = {} + self.reloaded: list[str] = [] + + def add(self, entry_id: str, listener) -> object: + def _remove(): + self._listeners.get(entry_id, []).remove(listener) + + self._listeners.setdefault(entry_id, []).append(listener) + return _remove + + async def dispatch_options_update(self, entry) -> None: + """Dispatch on the running loop; sync listeners run inline, async ones awaited.""" + for listener in list(self._listeners.get(entry.entry_id, [])): + result = listener(self._hass, entry) + if inspect.isawaitable(result): + await result + + async_dispatch_options_update = dispatch_options_update + + @property + def size(self) -> int: + return sum(len(listeners) for listeners in self._listeners.values()) + + +def make_config_entry( + entry_id: str = "test_entry", + options: dict | None = None, + data: dict | None = None, +): + """Create a stub config entry shaped like HA's ConfigEntry for tests.""" + entry = SimpleNamespace( + entry_id=entry_id, + options=dict(options or {}), + data=dict(data or {}), + ) + entry.add_update_listener = None + return entry + + +def wire_entry_to_registry(entry, registry: ListenerRegistry): + """Attach an update-listener registration function to the entry.""" + entry.add_update_listener = lambda listener: registry.add(entry.entry_id, listener) + return entry + + +def make_hass() -> tuple: + """Create a hass stub plus its listener registry, mirroring HA's shape.""" + hass = SimpleNamespace( + data={}, + config=MagicMock(), + config_entries=SimpleNamespace(async_reload=None), + ) + registry = ListenerRegistry(hass) + + async def _async_reload(entry_id: str) -> None: + registry.reloaded.append(entry_id) + + hass.config_entries.async_reload = MagicMock(side_effect=_async_reload) + hass.config_entries._entries = [] + hass.config_entries.async_add = ( + lambda entry: hass.config_entries._entries.append(entry) + ) + + def _async_entries(domain: str): + return [e for e in hass.config_entries._entries if e.domain == domain] + + hass.config_entries.async_entries = _async_entries + return hass, registry + + +@pytest.fixture +async def hass(): + """Provide a standard-shaped hass fixture bound to the running test loop. + + Mirrors the surface of Home Assistant's ``hass`` fixture: a ``config`` + namespace, a ``config_entries`` manager wired to a listener registry, a + ``data`` dict for integration state, and a ``loop`` bound to the + pytest-asyncio loop the test itself runs on (HA's running-loop contract), + so code scheduling on ``hass.loop`` runs on the actual test loop. + """ + _hass, registry = make_hass() + _hass.registry = registry + _hass.loop = asyncio.get_running_loop() + yield _hass + + +@pytest.fixture +def make_entry(): + """Provide the config-entry factory as a fixture.""" + + def _factory(entry_id: str = "test_entry", options: dict | None = None, data: dict | None = None): + return make_config_entry(entry_id=entry_id, options=options, data=data) + + return _factory + + +@pytest.fixture +def listener_registry(): + """Provide a fresh listener registry for manual wiring.""" + + def _factory(hass=None): + return ListenerRegistry(hass) + + return _factory + + +@pytest.fixture +def make_flow(): + """Provide a factory for a stubbed NestQuestConfigFlow bound to hass.""" + + def _factory(hass=None, existing_entries=None, in_progress=None): + from custom_components.nestquest import config_flow + + flow = config_flow.NestQuestConfigFlow() + flow.hass = hass if hass is not None else make_hass()[0] + for entry in existing_entries or []: + if getattr(entry, "domain", None) == flow._domain: + flow.hass.config_entries.async_add(entry) + if in_progress: + flow._async_in_progress = lambda: in_progress + return flow + + return _factory + + +@pytest.fixture +def make_config_flow_entry(): + """Provide an entry stub carrying a domain, for current-entries checks.""" + + def _factory(domain: str, entry_id: str = "existing"): + return SimpleNamespace(domain=domain, entry_id=entry_id) + + return _factory \ No newline at end of file diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 46eaefb..b744bf4 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -4,58 +4,6 @@ from __future__ import annotations import ast import json from pathlib import Path -import sys -from types import SimpleNamespace -from unittest.mock import MagicMock - -# Mock homeassistant modules if not installed in environment -for _mod in ( - "homeassistant", - "homeassistant.core", - "homeassistant.config_entries", - "homeassistant.data_entry_flow", -): - sys.modules.setdefault(_mod, MagicMock()) - -# Wire parent package attributes to the actual module mocks so that -# "from homeassistant import config_entries" resolves to the mocked module. -sys.modules["homeassistant"].config_entries = sys.modules["homeassistant.config_entries"] -sys.modules["homeassistant"].data_entry_flow = sys.modules["homeassistant.data_entry_flow"] - - -class _ConfigFlowBase: - """Minimal stand-in for homeassistant ConfigFlow.""" - - VERSION = 1 - - def __init_subclass__(cls, domain=None, **kwargs): - super().__init_subclass__(**kwargs) - cls._domain = domain - - def __init__(self): - self.context = {} - - def async_show_form(self, *, step_id, **kwargs): - return {"type": "form", "step_id": step_id} - - def async_abort(self, *, reason, **kwargs): - return {"type": "abort", "reason": reason} - - def async_create_entry(self, *, title, data, **kwargs): - return {"type": "create_entry", "title": title, "data": data} - - async def async_set_unique_id(self, unique_id, raise_on_progress=True): - self.context["unique_id"] = unique_id - return None - - def _async_current_entries(self): - return self.hass.config_entries.async_entries(self._domain) - - def _async_in_progress(self): - return [] - - -sys.modules["homeassistant.config_entries"].ConfigFlow = _ConfigFlowBase import pytest @@ -67,79 +15,63 @@ STRINGS_PATH = PKG_DIR / "strings.json" TRANSLATIONS_EN_PATH = PKG_DIR / "translations" / "en.json" -def _run(coro): - import asyncio - - return asyncio.new_event_loop().run_until_complete(coro) - - -def _make_flow(existing_entries: list | None = None, in_progress: list | None = None): - """Create a config flow instance with a stubbed hass.""" - flow = config_flow.NestQuestConfigFlow() - flow.hass = SimpleNamespace( - config_entries=SimpleNamespace( - async_entries=lambda domain: [ - entry - for entry in (existing_entries or []) - if entry.domain == domain - ] - ) - ) - if in_progress: - flow._async_in_progress = lambda: in_progress - return flow - - -def _make_entry(domain: str): - """Create a stub config entry with the given domain.""" - return SimpleNamespace(domain=domain) - - -def test_flow_shows_form_initially() -> None: +async def test_flow_shows_form_initially(hass, make_flow) -> None: """The initial user step shows a form.""" - flow = _make_flow() - result = _run(flow.async_step_user(None)) + flow = make_flow(hass) + result = await flow.async_step_user(None) assert result["type"] == "form" assert result["step_id"] == "user" -def test_flow_creates_entry_on_submit() -> None: - """Submitting the user step creates a config entry.""" - flow = _make_flow() - result = _run(flow.async_step_user({})) +async def test_flow_creates_entry_on_submit(hass, make_flow) -> None: + """Submitting the user step returns a create-entry result with the payload. + + Real HA's ConfigFlow.async_create_entry only returns the flow result; the + flow manager persists the entry afterward. The manager surface therefore + stays empty here — persistence is the manager's job, not the flow's. + """ + flow = make_flow(hass) + assert hass.config_entries.async_entries(DOMAIN) == [] + result = await flow.async_step_user({}) assert result["type"] == "create_entry" assert result["title"] == "NestQuest" assert result["data"] == {} + assert hass.config_entries.async_entries(DOMAIN) == [] -def test_second_flow_aborts_single_instance() -> None: +async def test_second_flow_aborts_single_instance( + hass, make_flow, make_config_flow_entry +) -> None: """A second flow aborts with single_instance_allowed.""" - flow = _make_flow([_make_entry(DOMAIN)]) - result = _run(flow.async_step_user(None)) + existing = make_config_flow_entry(DOMAIN) + flow = make_flow(hass, existing_entries=[existing]) + assert hass.config_entries.async_entries(DOMAIN) == [existing] + result = await flow.async_step_user(None) assert result["type"] == "abort" assert result["reason"] == "single_instance_allowed" + assert hass.config_entries.async_entries(DOMAIN) == [existing] -def test_flow_aborts_when_in_progress() -> None: +async def test_flow_aborts_when_in_progress(hass, make_flow) -> None: """A concurrent flow in progress aborts with single_instance_allowed.""" - flow = _make_flow(in_progress=[{"flow_id": "other"}]) - result = _run(flow.async_step_user(None)) + flow = make_flow(hass, in_progress=[{"flow_id": "other"}]) + result = await flow.async_step_user(None) assert result["type"] == "abort" assert result["reason"] == "single_instance_allowed" -def test_flow_sets_stable_unique_id() -> None: +async def test_flow_sets_stable_unique_id(hass, make_flow) -> None: """The flow assigns a stable unique id equal to the domain.""" - flow = _make_flow() + flow = make_flow(hass) assert flow.context.get("unique_id") is None - _run(flow.async_step_user(None)) + await flow.async_step_user(None) assert flow.context["unique_id"] == DOMAIN -def test_flow_ignores_other_domain_entries() -> None: +async def test_flow_ignores_other_domain_entries(hass, make_flow, make_config_flow_entry) -> None: """Entries of other domains do not trigger the abort.""" - flow = _make_flow([_make_entry("other_domain")]) - result = _run(flow.async_step_user(None)) + flow = make_flow(hass, existing_entries=[make_config_flow_entry("other_domain")]) + result = await flow.async_step_user(None) assert result["type"] == "form" assert result["step_id"] == "user" diff --git a/tests/test_const.py b/tests/test_const.py index b4fa982..aea1bd9 100644 --- a/tests/test_const.py +++ b/tests/test_const.py @@ -5,12 +5,6 @@ import ast from datetime import datetime import logging from pathlib import Path -import sys -from unittest.mock import MagicMock - -# Mock homeassistant modules if not installed in environment -for _mod in ("homeassistant", "homeassistant.core", "homeassistant.config_entries"): - sys.modules.setdefault(_mod, MagicMock()) from custom_components.nestquest import const diff --git a/tests/test_harness.py b/tests/test_harness.py new file mode 100644 index 0000000..b947990 --- /dev/null +++ b/tests/test_harness.py @@ -0,0 +1,86 @@ +"""Contract test for the mock-only homeassistant harness in conftest.py.""" + +from __future__ import annotations + +import asyncio +import importlib.metadata +import inspect +import sys +from unittest.mock import MagicMock + +import pytest + +import conftest + +from conftest import _HA_MODULES, _ensure_mock_only + + +def test_mock_only_harness_contract(monkeypatch): + """The harness is mock-only: shaped mocks everywhere, fail-fast on real HA.""" + # Every required homeassistant module in sys.modules is a mock stand-in + # with the shaped surface, and parent-package attributes point at them. + for name in _HA_MODULES: + assert isinstance(sys.modules[name], MagicMock), f"{name} is not a mock" + + config_entries = sys.modules["homeassistant.config_entries"] + assert config_entries.ConfigFlow is conftest._ConfigFlowBase + assert config_entries.OptionsFlowWithConfigEntry is ( + conftest._OptionsFlowWithConfigEntryBase + ) + assert sys.modules["homeassistant"].config_entries is config_entries + assert sys.modules["homeassistant.core"].callback is conftest._callback_decorator + assert sys.modules["homeassistant.data_entry_flow"].RESULT_TYPE_FORM == "form" + + # No homeassistant distribution is installed in this suite's own venv. + with pytest.raises(importlib.metadata.PackageNotFoundError): + importlib.metadata.version("homeassistant") + + # Fail-fast paths: a findable real HA module, or one preloaded in + # sys.modules, both abort collection with a UsageError. + real_spec = MagicMock(origin="/fake/site-packages/homeassistant/__init__.py") + monkeypatch.setattr(conftest, "_find_spec", lambda name: real_spec) + with pytest.raises(pytest.UsageError, match="mock-only"): + _ensure_mock_only() + + monkeypatch.setattr(conftest, "_find_spec", lambda name: None) + monkeypatch.setitem(sys.modules, "homeassistant", MagicMock(name="real-module")) + with pytest.raises(pytest.UsageError, match="mock-only"): + _ensure_mock_only() + + +async def test_hass_and_registry_run_on_the_running_test_loop(hass, make_entry): + """hass.loop is the test's own loop and the registry spins no private loop.""" + # hass.loop is the very loop the async test runs on (HA running-loop + # contract): code scheduling on hass.loop lands on the test loop itself. + assert hass.loop is asyncio.get_running_loop() + ran = [] + + async def _scheduled(): + ran.append(True) + + await hass.loop.create_task(_scheduled()) + assert ran == [True] + + # ListenerRegistry never spins its own event loop (no resource leak) and + # dispatch is a plain coroutine: sync listeners run inline, async awaited. + registry = hass.registry + assert not hasattr(registry, "_loop") + assert inspect.iscoroutinefunction(registry.dispatch_options_update) + + calls = [] + + def _sync_listener(h, entry): + calls.append(("sync", entry.entry_id, h is hass)) + + entry = make_entry(entry_id="disp") + registry.add("disp", _sync_listener) + await registry.dispatch_options_update(entry) + assert calls == [("sync", "disp", True)] + + async def _async_listener(h, entry): + calls.append(("async", entry.entry_id)) + + registry.add("disp", _async_listener) + calls.clear() + await registry.async_dispatch_options_update(entry) + assert calls == [("sync", "disp", True), ("async", "disp")] \ No newline at end of file diff --git a/tests/test_lifecycle.py b/tests/test_lifecycle.py index 5908cae..b12ed36 100644 --- a/tests/test_lifecycle.py +++ b/tests/test_lifecycle.py @@ -2,249 +2,198 @@ from __future__ import annotations import asyncio -import inspect -import sys -from types import SimpleNamespace -from unittest.mock import MagicMock -# Mock homeassistant modules if not installed in environment -for _mod in ( - "homeassistant", - "homeassistant.core", - "homeassistant.config_entries", -): - sys.modules.setdefault(_mod, MagicMock()) - -# Wire parent package attributes to the actual module mocks so that -# "from homeassistant import config_entries" resolves to the mocked module. -sys.modules["homeassistant"].config_entries = sys.modules["homeassistant.config_entries"] - -import pytest +from conftest import make_config_entry, make_hass, wire_entry_to_registry from custom_components.nestquest import DOMAIN, async_setup_entry, async_unload_entry from custom_components.nestquest.const import DOMAIN as DOMAIN_CONST -def _run(coro): - return asyncio.new_event_loop().run_until_complete(coro) +def _wire(entry, registry): + return wire_entry_to_registry(entry, registry) -class _ListenerRegistry: - """Models HA's update-listener bookkeeping: appends to a list per entry, with per-listener removers.""" - - def __init__(self, hass=None): - self._hass = hass - self._listeners: dict[str, list[object]] = {} - self.reloaded: list[str] = [] - self._loop = asyncio.new_event_loop() - - def add(self, entry_id: str, listener) -> object: - def _remove(): - self._listeners.get(entry_id, []).remove(listener) - - self._listeners.setdefault(entry_id, []).append(listener) - return _remove - - def dispatch_options_update(self, entry) -> None: - for listener in list(self._listeners.get(entry.entry_id, [])): - result = listener(self._hass, entry) - if inspect.isawaitable(result): - self._loop.run_until_complete(result) - - @property - def size(self) -> int: - return sum(len(listeners) for listeners in self._listeners.values()) - - -def _make_hass(): - hass = SimpleNamespace( - data={}, - config_entries=SimpleNamespace( - async_reload=None, - ), - ) - registry = _ListenerRegistry(hass) - async def _async_reload(entry_id: str) -> None: - registry.reloaded.append(entry_id) - - hass.config_entries.async_reload = MagicMock(side_effect=_async_reload) - return hass, registry - - -def _make_entry(entry_id: str = "test_entry", options: dict | None = None): - entry = SimpleNamespace(entry_id=entry_id, options=dict(options or {})) - entry.add_update_listener = None - return entry - - -def _wire_entry(entry, registry: _ListenerRegistry): - entry.add_update_listener = lambda listener: registry.add(entry.entry_id, listener) - return entry - - -def test_setup_stores_runtime_data_in_hass_data() -> None: - hass, _ = _make_hass() - entry = _wire_entry(_make_entry(), _ListenerRegistry()) - assert _run(async_setup_entry(hass, entry)) is True +async def test_setup_stores_runtime_data_in_hass_data(hass, make_entry) -> None: + entry = _wire(make_entry(), hass.registry) + assert await async_setup_entry(hass, entry) is True assert entry.entry_id in hass.data[DOMAIN] assert hass.data[DOMAIN][entry.entry_id] is entry.runtime_data -def test_setup_runtime_data_has_entry_id_and_options() -> None: - hass, _ = _make_hass() - entry = _wire_entry(_make_entry(options={"horizon_days": 7}), _ListenerRegistry()) - _run(async_setup_entry(hass, entry)) +async def test_setup_runtime_data_has_entry_id_and_options(hass, make_entry) -> None: + entry = _wire(make_entry(options={"horizon_days": 7}), hass.registry) + await async_setup_entry(hass, entry) runtime = entry.runtime_data assert runtime.entry_id == entry.entry_id assert runtime.options == {"horizon_days": 7} assert callable(runtime.remove_update_listener) -def test_unload_returns_true_and_removes_hass_data() -> None: - hass, _ = _make_hass() - registry = _ListenerRegistry() - entry = _wire_entry(_make_entry(), registry) - _run(async_setup_entry(hass, entry)) - assert _run(async_unload_entry(hass, entry)) is True - assert entry.entry_id not in hass.data[DOMAIN] - - -def test_unload_is_idempotent() -> None: - hass, _ = _make_hass() - registry = _ListenerRegistry() - entry = _wire_entry(_make_entry(), registry) - _run(async_setup_entry(hass, entry)) - assert _run(async_unload_entry(hass, entry)) is True - assert _run(async_unload_entry(hass, entry)) is True - assert entry.entry_id not in hass.data[DOMAIN] - - -def test_unload_without_setup_returns_true() -> None: - hass, _ = _make_hass() - entry = _make_entry() - assert _run(async_unload_entry(hass, entry)) is True - assert DOMAIN not in hass.data or entry.entry_id not in hass.data[DOMAIN] - - -def test_unload_clears_entry_runtime_data() -> None: - hass, _ = _make_hass() - registry = _ListenerRegistry() - entry = _wire_entry(_make_entry(), registry) - _run(async_setup_entry(hass, entry)) - _run(async_unload_entry(hass, entry)) - assert entry.runtime_data is None - - -def test_unload_removes_update_listener() -> None: - hass, _ = _make_hass() - registry = _ListenerRegistry() - entry = _wire_entry(_make_entry(), registry) - _run(async_setup_entry(hass, entry)) +async def test_unload_returns_true_and_removes_hass_data(hass, make_entry) -> None: + registry = hass.registry + entry = _wire(make_entry(), registry) + assert await async_setup_entry(hass, entry) is True assert registry.size == 1 - _run(async_unload_entry(hass, entry)) + assert await async_unload_entry(hass, entry) is True + assert hass.data == {} + assert DOMAIN not in hass.data + assert entry.runtime_data is None assert registry.size == 0 -def test_options_update_triggers_reload_once() -> None: - hass, registry = _make_hass() - entry = _wire_entry(_make_entry(), registry) - _run(async_setup_entry(hass, entry)) +async def test_unload_of_one_entry_keeps_domain_while_other_remains( + hass, make_entry +) -> None: + """Unloading one of two entries keeps DOMAIN alive with the other entry.""" + registry = hass.registry + entry_a = _wire(make_entry(entry_id="entry_a"), registry) + entry_b = _wire(make_entry(entry_id="entry_b"), registry) + assert await async_setup_entry(hass, entry_a) is True + assert await async_setup_entry(hass, entry_b) is True + assert set(hass.data[DOMAIN]) == {"entry_a", "entry_b"} + + assert await async_unload_entry(hass, entry_a) is True + assert DOMAIN in hass.data + assert set(hass.data[DOMAIN]) == {"entry_b"} + assert hass.data[DOMAIN]["entry_b"] is entry_b.runtime_data + assert entry_a.runtime_data is None + assert entry_b.runtime_data is not None + assert registry.size == 1 + + assert await async_unload_entry(hass, entry_b) is True + assert DOMAIN not in hass.data + assert hass.data == {} + assert entry_b.runtime_data is None + assert registry.size == 0 + + +async def test_unload_is_idempotent(hass, make_entry) -> None: + entry = _wire(make_entry(), hass.registry) + assert await async_setup_entry(hass, entry) is True + assert await async_unload_entry(hass, entry) is True + assert await async_unload_entry(hass, entry) is True + assert DOMAIN not in hass.data + assert hass.data == {} + + +async def test_unload_without_setup_returns_true(hass, make_entry) -> None: + entry = _wire(make_entry(), hass.registry) + assert await async_unload_entry(hass, entry) is True + assert DOMAIN not in hass.data + assert hass.data == {} + + +async def test_unload_clears_entry_runtime_data(hass, make_entry) -> None: + entry = _wire(make_entry(), hass.registry) + await async_setup_entry(hass, entry) + await async_unload_entry(hass, entry) + assert entry.runtime_data is None + + +async def test_unload_removes_update_listener(hass, make_entry) -> None: + registry = hass.registry + entry = _wire(make_entry(), registry) + await async_setup_entry(hass, entry) + assert registry.size == 1 + await async_unload_entry(hass, entry) + assert registry.size == 0 + + +async def test_options_update_triggers_reload_once(hass, make_entry) -> None: + registry = hass.registry + entry = _wire(make_entry(), registry) + await async_setup_entry(hass, entry) entry.options = {"horizon_days": 30} - registry.dispatch_options_update(entry) + await registry.async_dispatch_options_update(entry) assert hass.config_entries.async_reload.call_count == 1 assert registry.reloaded == [entry.entry_id] -def test_no_reload_after_unload() -> None: - hass, registry = _make_hass() - entry = _wire_entry(_make_entry(), registry) - _run(async_setup_entry(hass, entry)) - _run(async_unload_entry(hass, entry)) - registry.dispatch_options_update(entry) +async def test_no_reload_after_unload(hass, make_entry) -> None: + registry = hass.registry + entry = _wire(make_entry(), registry) + await async_setup_entry(hass, entry) + await async_unload_entry(hass, entry) + await registry.async_dispatch_options_update(entry) assert hass.config_entries.async_reload.call_count == 0 -def test_three_reload_cycles_keep_single_listener() -> None: - hass, registry = _make_hass() - entry = _wire_entry(_make_entry(), registry) +async def test_three_reload_cycles_keep_single_listener(hass, make_entry) -> None: + registry = hass.registry + entry = _wire(make_entry(), registry) for _ in range(3): - assert _run(async_setup_entry(hass, entry)) is True + assert await async_setup_entry(hass, entry) is True assert registry.size == 1 - assert _run(async_unload_entry(hass, entry)) is True + assert await async_unload_entry(hass, entry) is True assert registry.size == 0 assert hass.config_entries.async_reload.call_count == 0 -def test_three_setup_unload_cycles_leave_no_listeners() -> None: +async def test_three_setup_unload_cycles_leave_no_listeners(hass, make_entry) -> None: """Listeners accumulate per entry (HA semantics); unload must remove them all.""" - hass, registry = _make_hass() - entry = _wire_entry(_make_entry(), registry) + registry = hass.registry + entry = _wire(make_entry(), registry) for _ in range(3): - _run(async_setup_entry(hass, entry)) - _run(async_unload_entry(hass, entry)) + await async_setup_entry(hass, entry) + await async_unload_entry(hass, entry) assert registry._listeners.get(entry.entry_id, []) == [] assert registry.size == 0 -def test_options_update_after_each_setup_invokes_exactly_one_listener() -> None: +async def test_options_update_after_each_setup_invokes_exactly_one_listener( + hass, make_entry +) -> None: """After each setup, an options update dispatches to exactly one listener.""" - hass, registry = _make_hass() - entry = _wire_entry(_make_entry(), registry) + registry = hass.registry + entry = _wire(make_entry(), registry) for _ in range(3): - _run(async_setup_entry(hass, entry)) - registry.dispatch_options_update(entry) + await async_setup_entry(hass, entry) + await registry.async_dispatch_options_update(entry) assert hass.config_entries.async_reload.call_count == 1 assert registry.reloaded == [entry.entry_id] - _run(async_unload_entry(hass, entry)) + await async_unload_entry(hass, entry) hass.config_entries.async_reload.reset_mock() registry.reloaded.clear() -def test_reload_setup_cycles_dispatch_reload_each_time() -> None: - hass, registry = _make_hass() - entry = _wire_entry(_make_entry(), registry) +async def test_reload_setup_cycles_dispatch_reload_each_time(hass, make_entry) -> None: + registry = hass.registry + entry = _wire(make_entry(), registry) for _ in range(3): - _run(async_setup_entry(hass, entry)) - registry.dispatch_options_update(entry) - _run(async_unload_entry(hass, entry)) + await async_setup_entry(hass, entry) + await registry.async_dispatch_options_update(entry) + await async_unload_entry(hass, entry) assert hass.config_entries.async_reload.call_count == 3 assert registry.size == 0 -def test_no_orphaned_tasks_after_unload() -> None: - async def _scenario(): - hass, registry = _make_hass() - entry = _wire_entry(_make_entry(), registry) - await async_setup_entry(hass, entry) - await async_unload_entry(hass, entry) - pending = [task for task in asyncio.all_tasks() if task is not asyncio.current_task()] - assert pending == [] - - _run(_scenario()) +async def test_no_orphaned_tasks_after_unload(hass, make_entry) -> None: + entry = _wire(make_entry(), hass.registry) + await async_setup_entry(hass, entry) + await async_unload_entry(hass, entry) + pending = [task for task in asyncio.all_tasks() if task is not asyncio.current_task()] + assert pending == [] -def test_setup_entry_uses_domain_constant() -> None: - hass, _ = _make_hass() - entry = _wire_entry(_make_entry(), _ListenerRegistry()) - _run(async_setup_entry(hass, entry)) +async def test_setup_entry_uses_domain_constant(hass, make_entry) -> None: + entry = _wire(make_entry(), hass.registry) + await async_setup_entry(hass, entry) assert set(hass.data) == {DOMAIN_CONST} -def test_setup_awaitable_listener_removal_supported() -> None: +async def test_setup_awaitable_listener_removal_supported() -> None: """A coroutine-returning remover is awaited during unload.""" + removed = [] - async def _scenario(): - removed = [] + async def _remover(): + removed.append(True) - async def _remover(): - removed.append(True) - - entry = SimpleNamespace(entry_id="awaitable", options={}) - entry.add_update_listener = lambda listener: _remover - hass = SimpleNamespace(data={}, config_entries=SimpleNamespace(async_reload=None)) - await async_setup_entry(hass, entry) - result = await async_unload_entry(hass, entry) - assert result is True - assert removed == [True] - - _run(_scenario()) \ No newline at end of file + entry = make_config_entry(entry_id="awaitable") + entry.add_update_listener = lambda listener: _remover + hass, _registry = make_hass() + await async_setup_entry(hass, entry) + result = await async_unload_entry(hass, entry) + assert result is True + assert removed == [True] + assert hass.data == {} + assert entry.runtime_data is None \ No newline at end of file diff --git a/tests/test_manifest.py b/tests/test_manifest.py index e442a23..ebdde54 100644 --- a/tests/test_manifest.py +++ b/tests/test_manifest.py @@ -3,12 +3,6 @@ from __future__ import annotations import json from pathlib import Path -import sys -from unittest.mock import MagicMock - -# Mock homeassistant modules if not installed in environment -for _mod in ("homeassistant", "homeassistant.core", "homeassistant.config_entries"): - sys.modules.setdefault(_mod, MagicMock()) import pytest diff --git a/tests/test_options_flow.py b/tests/test_options_flow.py index 71d73c4..9b8dad8 100644 --- a/tests/test_options_flow.py +++ b/tests/test_options_flow.py @@ -3,129 +3,14 @@ from __future__ import annotations import ast import asyncio -import importlib -import inspect import json from pathlib import Path -import sys from types import SimpleNamespace -from typing import Any -from unittest.mock import MagicMock - -# Mock homeassistant modules if not installed in environment. -# The mock mirrors the REAL Home Assistant 2024.6 contract: -# config_entries.OptionsFlowWithConfigEntry accepts a config_entry kwarg and -# exposes .options / .config_entry; homeassistant.core.callback exists as a -# decorator; data_entry_flow.RESULT_TYPE_* constants and FlowResult exist. -for _mod in ( - "homeassistant", - "homeassistant.core", - "homeassistant.config_entries", - "homeassistant.data_entry_flow", -): - sys.modules.setdefault(_mod, MagicMock()) - -# Wire parent package attributes to the actual module mocks so that -# "from homeassistant import config_entries" resolves to the mocked module. -sys.modules["homeassistant"].config_entries = sys.modules["homeassistant.config_entries"] -sys.modules["homeassistant"].data_entry_flow = sys.modules["homeassistant.data_entry_flow"] - - -class _OptionsFlowWithConfigEntryBase: - """Stand-in mirroring HA 2024.6 config_entries.OptionsFlowWithConfigEntry.""" - - def __init__(self, config_entry=None): - self.config_entry = config_entry - self.hass = None - self.options = dict(config_entry.options) if config_entry is not None else {} - - def async_show_form(self, *, step_id, **kwargs): - return {"type": "form", "step_id": step_id, **kwargs} - - def async_create_entry(self, *, title, data, **kwargs): - return {"type": "create_entry", "title": title, "data": data} - - -class _ConfigFlowBase: - """Stand-in for homeassistant ConfigFlow; a superset of test_config_flow's base. - - test_config_flow.py may be imported after this file, so the concrete - NestQuestConfigFlow can end up bound to this base. It must therefore also - satisfy that file's tests. - """ - - VERSION = 1 - - def __init_subclass__(cls, domain=None, **kwargs): - super().__init_subclass__(**kwargs) - cls._domain = domain - - def __init__(self): - self.context = {} - - def async_show_form(self, *, step_id, **kwargs): - return {"type": "form", "step_id": step_id, **kwargs} - - def async_abort(self, *, reason, **kwargs): - return {"type": "abort", "reason": reason} - - def async_create_entry(self, *, title, data, **kwargs): - return {"type": "create_entry", "title": title, "data": data} - - async def async_set_unique_id(self, unique_id, raise_on_progress=True): - self.context["unique_id"] = unique_id - return None - - def _async_current_entries(self): - return self.hass.config_entries.async_entries(self._domain) - - def _async_in_progress(self): - return [] - - @staticmethod - def async_get_options_flow(config_entry): - raise NotImplementedError - - -def _callback_decorator(fn): - """Mirror homeassistant.core.callback: flags the fn and returns it unchanged.""" - fn.__ha_callback__ = True - return fn - - -_config_entries_mock = sys.modules["homeassistant.config_entries"] -_core_mock = sys.modules["homeassistant.core"] -_data_entry_flow_mock = sys.modules["homeassistant.data_entry_flow"] - -# The concrete 2024.6-shaped surface the integration is written against: -# plain OptionsFlow has no config_entry constructor (NQ-001); only -# OptionsFlowWithConfigEntry does. -_config_entries_mock.OptionsFlowWithConfigEntry = _OptionsFlowWithConfigEntryBase -_config_entries_mock.ConfigFlow = _ConfigFlowBase -_core_mock.callback = _callback_decorator - -# data_entry_flow constants/annotations referenced by the production modules. -_data_entry_flow_mock.RESULT_TYPE_FORM = "form" -_data_entry_flow_mock.RESULT_TYPE_CREATE_ENTRY = "create_entry" -_data_entry_flow_mock.RESULT_TYPE_ABORT = "abort" -_data_entry_flow_mock.FlowResult = dict - -# test_config_flow.py may run first and import the integration modules against a -# bare MagicMock surface. Force a clean re-import now that the 2024.6-shaped -# mocks are installed, so the concrete classes bind to them. -for _name in [ - name - for name in sys.modules - if name.startswith("custom_components.nestquest") -]: - del sys.modules[_name] - -importlib.import_module("custom_components.nestquest") -importlib.import_module("custom_components.nestquest.config_flow") -importlib.import_module("custom_components.nestquest.options_flow") import pytest +from conftest import make_config_entry, make_hass, options_flow_base as _options_flow_base + from custom_components.nestquest import config_flow from custom_components.nestquest import options_flow from custom_components.nestquest.const import ( @@ -153,53 +38,12 @@ def _run(coro): return asyncio.new_event_loop().run_until_complete(coro) -class _ListenerRegistry: - """Models HA's update-listener bookkeeping with reload tracking.""" - - def __init__(self, hass=None): - self._hass = hass - self._listeners: dict[str, list[object]] = {} - self.reloaded: list[str] = [] - self._loop = asyncio.new_event_loop() - - def add(self, entry_id: str, listener) -> object: - def _remove(): - self._listeners.get(entry_id, []).remove(listener) - - self._listeners.setdefault(entry_id, []).append(listener) - return _remove - - def dispatch_options_update(self, entry) -> None: - for listener in list(self._listeners.get(entry.entry_id, [])): - result = listener(self._hass, entry) - if inspect.isawaitable(result): - self._loop.run_until_complete(result) - - @property - def size(self) -> int: - return sum(len(listeners) for listeners in self._listeners.values()) - - def _make_hass(): - hass = SimpleNamespace( - data={}, - config_entries=SimpleNamespace(async_reload=None), - ) - registry = _ListenerRegistry(hass) - - async def _async_reload(entry_id: str) -> None: - registry.reloaded.append(entry_id) - - hass.config_entries.async_reload = MagicMock(side_effect=_async_reload) - return hass, registry + return make_hass() def _make_entry(entry_id: str = "test_entry", options: dict | None = None, data: dict | None = None): - entry = SimpleNamespace( - entry_id=entry_id, options=dict(options or {}), data=dict(data or {}) - ) - entry.add_update_listener = None - return entry + return make_config_entry(entry_id=entry_id, options=options, data=data) def _make_flow(entry) -> options_flow.NestQuestOptionsFlow: @@ -237,7 +81,7 @@ def test_options_flow_constructs_through_2024_6_surface() -> None: """The handler constructs against the mocked 2024.6 base and inherits its contract.""" entry = _make_entry(options={CONF_HORIZON_DAYS: 21}) flow = options_flow.NestQuestOptionsFlow(config_entry=entry) - assert isinstance(flow, sys.modules["homeassistant.config_entries"].OptionsFlowWithConfigEntry) + assert isinstance(flow, _options_flow_base()) assert flow.config_entry is entry assert flow.options == {CONF_HORIZON_DAYS: 21} @@ -427,7 +271,7 @@ def test_options_flow_create_entry_data_lands_in_entry_options_and_reloads_once( assert result["type"] == "create_entry" entry.options = dict(result["data"]) - registry.dispatch_options_update(entry) + _run(registry.dispatch_options_update(entry)) assert hass.config_entries.async_reload.call_count == 1 assert registry.reloaded == [entry.entry_id] assert entry.options == VALID_INPUT @@ -513,7 +357,7 @@ def test_options_flow_no_plain_dict_schema_fallback() -> None: def test_options_flow_subclasses_options_flow_with_config_entry() -> None: """NestQuestOptionsFlow subclasses config_entries.OptionsFlowWithConfigEntry.""" - base = sys.modules["homeassistant.config_entries"].OptionsFlowWithConfigEntry + base = _options_flow_base() assert issubclass(options_flow.NestQuestOptionsFlow, base) assert options_flow.NestQuestOptionsFlow.__bases__ == (base,) diff --git a/tests/test_store.py b/tests/test_store.py index 992fbbe..95866b4 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -4,17 +4,8 @@ from __future__ import annotations import ast import asyncio from pathlib import Path -import sys from unittest.mock import AsyncMock, MagicMock -# Mock homeassistant modules if not installed in environment -for _mod in ( - "homeassistant", - "homeassistant.core", - "homeassistant.config_entries", -): - sys.modules.setdefault(_mod, MagicMock()) - from custom_components.nestquest import store from custom_components.nestquest.const import SQLITE_DB_FILENAME diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..185f269 --- /dev/null +++ b/uv.lock @@ -0,0 +1,116 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "nestquest" +version = "0.1.0" +source = { virtual = "." } + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "voluptuous" }, +] + +[package.metadata] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8.0.0" }, + { name = "pytest-asyncio", specifier = ">=0.24" }, + { name = "voluptuous", specifier = ">=0.14" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "voluptuous" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/f4/0738e6849858deae22218be3bbb8207ba83a96e9d0ec7e8e8cd67b30e5ca/voluptuous-0.16.0.tar.gz", hash = "sha256:006535e22fed944aec17bef6e8725472476194743c87bd233e912eb463f8ff05", size = 54238, upload-time = "2025-12-18T23:18:46.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/00/0e0da784245c93cf346150ab67634177bf277f93b7a162bb56c928c39c04/voluptuous-0.16.0-py3-none-any.whl", hash = "sha256:ee342095263e1b5afbd4d418cb5adc92810eebfd07696bb033a261210df33db4", size = 31931, upload-time = "2025-12-18T23:18:44.694Z" }, +]