diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index c90e54e..4823457 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,5 +1,19 @@ # NestQuest Release Notes +## Unreleased + +- **Storage layer extraction (task d0b691d8):** the SQLite storage layer + (schema, migrations, db, store, const, recurrence, DAOs) is bundled + under `custom_components/nestquest/core/`, with the integration's + original module names preserved as re-export shims. +- **Logger namespace change (breaking for logging configuration):** the + migrations runner now logs under + `custom_components.nestquest.core.migrations` instead of + `custom_components.nestquest.migrations`. Users who tuned the old + logger name (e.g. in `logger.yaml` or `configuration.yaml`) must + update the entry to keep that level, as the old name no longer + receives migration log output. + ## Version 0.5.0 — 2026-09-18 ### Scope diff --git a/custom_components/nestquest/const.py b/custom_components/nestquest/const.py index 4edc3ee..5b97150 100644 --- a/custom_components/nestquest/const.py +++ b/custom_components/nestquest/const.py @@ -1,6 +1,6 @@ -"""Re-export of :mod:`core.const` for the integration package. +"""Re-export of :mod:`custom_components.nestquest.core.const` for the integration package. -The canonical constants live in :mod:`core.const` (the storage layer +The canonical constants live in :mod:`custom_components.nestquest.core.const` (the storage layer extracted in task d0b691d8); this module re-exports them so existing ``from .const import ...`` references inside the integration keep working without touching every import line. diff --git a/custom_components/nestquest/core/__init__.py b/custom_components/nestquest/core/__init__.py index 1903490..89e132f 100644 --- a/custom_components/nestquest/core/__init__.py +++ b/custom_components/nestquest/core/__init__.py @@ -5,7 +5,7 @@ SQLite storage layer: schema DDL, the versioned migration runner, the async connection wrapper, the path resolver, the domain constants and recurrence model, and the typed DAOs. Nothing in this package imports ``homeassistant``; the integration wires it into HA at its own layer -(constructing :class:`core.db.NestQuestDatabase` with an executor -adapter backed by ``hass.async_add_executor_job``). +(constructing :class:`custom_components.nestquest.core.db.NestQuestDatabase` +with an executor adapter backed by ``hass.async_add_executor_job``). """ from __future__ import annotations diff --git a/custom_components/nestquest/dao_children.py b/custom_components/nestquest/dao_children.py index 6e37e39..6ee1e2e 100644 --- a/custom_components/nestquest/dao_children.py +++ b/custom_components/nestquest/dao_children.py @@ -1,6 +1,7 @@ -"""Re-export of :mod:`core.dao_children` for the integration package. +"""Re-export of :mod:`custom_components.nestquest.core.dao_children` for the integration package. -The children/admin DAO lives in :mod:`core.dao_children` (extracted in +The children/admin DAO lives in +:mod:`custom_components.nestquest.core.dao_children` (extracted in task d0b691d8); this module re-exports it so existing ``from .dao_children import ...`` references inside the integration keep working. diff --git a/custom_components/nestquest/dao_instances.py b/custom_components/nestquest/dao_instances.py index 65f67d5..1ac0c9e 100644 --- a/custom_components/nestquest/dao_instances.py +++ b/custom_components/nestquest/dao_instances.py @@ -1,6 +1,7 @@ -"""Re-export of :mod:`core.dao_instances` for the integration package. +"""Re-export of :mod:`custom_components.nestquest.core.dao_instances` for the integration package. -The instances/completion-events DAO lives in :mod:`core.dao_instances` +The instances/completion-events DAO lives in +:mod:`custom_components.nestquest.core.dao_instances` (extracted in task d0b691d8); this module re-exports it so existing ``from .dao_instances import ...`` references inside the integration keep working. diff --git a/custom_components/nestquest/dao_meta.py b/custom_components/nestquest/dao_meta.py index a4275c5..bef9c16 100644 --- a/custom_components/nestquest/dao_meta.py +++ b/custom_components/nestquest/dao_meta.py @@ -1,6 +1,7 @@ -"""Re-export of :mod:`core.dao_meta` for the integration package. +"""Re-export of :mod:`custom_components.nestquest.core.dao_meta` for the integration package. -The meta-state DAO lives in :mod:`core.dao_meta` (extracted in task +The meta-state DAO lives in +:mod:`custom_components.nestquest.core.dao_meta` (extracted in task d0b691d8); this module re-exports it so existing ``from .dao_meta import ...`` references inside the integration keep working. diff --git a/custom_components/nestquest/dao_presence.py b/custom_components/nestquest/dao_presence.py index 8dab0bd..422ae17 100644 --- a/custom_components/nestquest/dao_presence.py +++ b/custom_components/nestquest/dao_presence.py @@ -1,7 +1,8 @@ -"""Re-export of :mod:`core.dao_presence` for the integration package. +"""Re-export of :mod:`custom_components.nestquest.core.dao_presence` for the integration package. -The presence DAOs live in :mod:`core.dao_presence` (extracted in task -d0b691d8); this module re-exports them so existing +The presence DAOs live in +:mod:`custom_components.nestquest.core.dao_presence` (extracted in +task d0b691d8); this module re-exports them so existing ``from .dao_presence import ...`` references inside the integration keep working. """ diff --git a/custom_components/nestquest/dao_rules.py b/custom_components/nestquest/dao_rules.py index 85ec4d9..31c4ab9 100644 --- a/custom_components/nestquest/dao_rules.py +++ b/custom_components/nestquest/dao_rules.py @@ -1,9 +1,10 @@ -"""Re-export of :mod:`core.dao_rules` for the integration package. +"""Re-export of :mod:`custom_components.nestquest.core.dao_rules` for the integration package. The schedule-rules/quest-definitions DAO lives in -:mod:`core.dao_rules` (extracted in task d0b691d8); this module -re-exports it so existing ``from .dao_rules import ...`` references -inside the integration keep working. +:mod:`custom_components.nestquest.core.dao_rules` (extracted in task +d0b691d8); this module re-exports it so existing +``from .dao_rules import ...`` references inside the integration keep +working. """ from __future__ import annotations diff --git a/custom_components/nestquest/db.py b/custom_components/nestquest/db.py index 78dfaec..b13a2e0 100644 --- a/custom_components/nestquest/db.py +++ b/custom_components/nestquest/db.py @@ -1,6 +1,6 @@ -"""Re-export of :mod:`core.db` for the integration package. +"""Re-export of :mod:`custom_components.nestquest.core.db` for the integration package. -The async SQLite wrapper lives in :mod:`core.db` (extracted in task +The async SQLite wrapper lives in :mod:`custom_components.nestquest.core.db` (extracted in task d0b691d8) and is Home-Assistant-free: :class:`NestQuestDatabase` takes an executor callable rather than a ``hass`` object. This module re-exports it and provides :func:`make_database`, a thin adapter that diff --git a/custom_components/nestquest/migrations.py b/custom_components/nestquest/migrations.py index f9fb72a..d51d3c7 100644 --- a/custom_components/nestquest/migrations.py +++ b/custom_components/nestquest/migrations.py @@ -1,6 +1,7 @@ -"""Re-export of :mod:`core.migrations` for the integration package. +"""Re-export of :mod:`custom_components.nestquest.core.migrations` for the integration package. -The versioned migration runner lives in :mod:`core.migrations` +The versioned migration runner lives in +:mod:`custom_components.nestquest.core.migrations` (extracted in task d0b691d8); this module re-exports it so existing ``from .migrations import ...`` references inside the integration keep working. diff --git a/custom_components/nestquest/recurrence.py b/custom_components/nestquest/recurrence.py index a3da9fb..7e72994 100644 --- a/custom_components/nestquest/recurrence.py +++ b/custom_components/nestquest/recurrence.py @@ -1,11 +1,10 @@ -"""Re-export of :mod:`core.recurrence` for the integration package. +"""Re-export of :mod:`custom_components.nestquest.core.recurrence` for the integration package. -The recurrence model lives in :mod:`core.recurrence` (extracted in task -d0b691d8); this module re-exports it so existing +The recurrence model lives in :mod:`custom_components.nestquest.core.recurrence` +(extracted in task d0b691d8); this module re-exports it so existing ``from .recurrence import ...`` references inside the integration keep working. """ from __future__ import annotations from .core.recurrence import * # noqa: F401,F403 -from .core.recurrence import _month_end, _nth_weekday_of_month # noqa: F401 diff --git a/custom_components/nestquest/schema.py b/custom_components/nestquest/schema.py index b19b4f8..6ed278a 100644 --- a/custom_components/nestquest/schema.py +++ b/custom_components/nestquest/schema.py @@ -1,6 +1,7 @@ -"""Re-export of :mod:`core.schema` for the integration package. +"""Re-export of :mod:`custom_components.nestquest.core.schema` for the integration package. -The declarative schema DDL lives in :mod:`core.schema` (extracted in +The declarative schema DDL lives in +:mod:`custom_components.nestquest.core.schema` (extracted in task d0b691d8); this module re-exports it so existing ``from .schema import ...`` references inside the integration keep working. diff --git a/custom_components/nestquest/store.py b/custom_components/nestquest/store.py index 1c2e69f..e87a7d7 100644 --- a/custom_components/nestquest/store.py +++ b/custom_components/nestquest/store.py @@ -1,7 +1,9 @@ -"""Re-export of :mod:`core.store` for the integration package. +"""Re-export of :mod:`custom_components.nestquest.core.store` for the integration package. -The path resolver lives in :mod:`core.store` (extracted in task -d0b691d8) and is Home-Assistant-free: :func:`core.store.async_get_db_path` +The path resolver lives in +:mod:`custom_components.nestquest.core.store` (extracted in task +d0b691d8) and is Home-Assistant-free: +:func:`custom_components.nestquest.core.store.async_get_db_path` takes the full database path. This module re-exports it and provides the original ``async_get_db_path(hass)`` signature so the integration resolves the path from ``hass.config.path`` exactly as before. @@ -23,7 +25,8 @@ async def async_get_db_path(hass: HomeAssistant) -> Path: """Return the absolute path to the NestQuest SQLite database file. Resolves the database file from ``hass.config.path`` and delegates to - :func:`core.store.async_get_db_path`, which ensures the parent - directory exists off the event loop via :func:`asyncio.to_thread`. + :func:`custom_components.nestquest.core.store.async_get_db_path`, + which ensures the parent directory exists off the event loop via + :func:`asyncio.to_thread`. """ return await _core_async_get_db_path(hass.config.path(SQLITE_DB_FILENAME)) diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 246e59b..a6ecc2d 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -140,7 +140,7 @@ def test_flow_strings_match_translations( def test_no_hardcoded_domain_or_db_filename_in_new_modules() -> None: """Ensure no module other than const.py hardcodes the domain or db filename.""" pkg_dir = Path(config_flow.__file__).parent - py_files = [f for f in pkg_dir.glob("*.py") if f.name not in ("const.py",)] + py_files = [f for f in pkg_dir.rglob("*.py") if f.name not in ("const.py",)] assert len(py_files) > 0, "No python modules found in package to check" diff --git a/tests/test_const.py b/tests/test_const.py index 361ca83..4d8592a 100644 --- a/tests/test_const.py +++ b/tests/test_const.py @@ -106,7 +106,7 @@ def test_window_clock_ranges_cover_every_window() -> None: def test_no_hardcoded_domain_or_db_filename_in_modules() -> None: """Ensure no module other than const.py hardcodes DOMAIN string or SQLITE_DB_FILENAME.""" pkg_dir = Path(const.__file__).parent - py_files = [f for f in pkg_dir.glob("*.py") if f.name != "const.py"] + py_files = [f for f in pkg_dir.rglob("*.py") if f.name != "const.py"] assert len(py_files) > 0, "No python modules found in package to check" diff --git a/tests/test_core_isolation.py b/tests/test_core_isolation.py new file mode 100644 index 0000000..3ff816d --- /dev/null +++ b/tests/test_core_isolation.py @@ -0,0 +1,119 @@ +"""Invariant: the bundled core package is self-contained. + +Every module under ``custom_components/nestquest/core/`` must be free of +Home Assistant and must not climb out of the core package: no +``homeassistant`` import, no relative import with level > 1, no absolute +import of ``custom_components.nestquest.``, +and no bare ``import core`` / ``from core``. This guards the +storage-layer extraction (task d0b691d8) from regressing into a +HA-coupled or integration-coupled bundle. +""" +from __future__ import annotations + +import ast +from pathlib import Path + +CORE_PKG = Path( + __import__( + "custom_components.nestquest.core", fromlist=["__file__"] + ).__file__ +).parent + + +def _module_chain(base: str, name: str) -> str: + """Combined dotted path for ``from import ``.""" + return f"{base}.{name}" if base else name + + +def _is_homeassistant(module: str) -> bool: + return module == "homeassistant" or module.startswith("homeassistant.") + + +def _climbs_out_of_core(module: str) -> bool: + """An absolute path that reaches the integration package but not core.""" + if module == "custom_components.nestquest": + return True + if module.startswith("custom_components.nestquest."): + rest = module[len("custom_components.nestquest."):] + head = rest.split(".", 1)[0] + return head != "core" + return False + + +def _is_bare_core(module: str) -> bool: + """A bare ``core`` / ``core.*`` absolute reference (not the bundle).""" + return module == "core" or module.startswith("core.") + + +def _scan(path: Path) -> list[str]: + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + offenders: list[str] = [] + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + mod = alias.name + if _is_homeassistant(mod): + offenders.append( + f"{path.name}:{node.lineno} homeassistant import: import {mod}" + ) + if _is_bare_core(mod): + offenders.append( + f"{path.name}:{node.lineno} bare core import: import {mod}" + ) + if _climbs_out_of_core(mod): + offenders.append( + f"{path.name}:{node.lineno} climbs out of core: import {mod}" + ) + elif isinstance(node, ast.ImportFrom): + if node.level and node.level > 1: + offenders.append( + f"{path.name}:{node.lineno} relative import climbs out " + f"of core (level {node.level})" + ) + continue + if node.level: + # relative level 1 stays inside core; nothing to check. + continue + base = node.module or "" + if _is_homeassistant(base): + offenders.append( + f"{path.name}:{node.lineno} homeassistant import: from {base}" + ) + if _is_bare_core(base): + offenders.append( + f"{path.name}:{node.lineno} bare core import: from {base}" + ) + if _climbs_out_of_core(base): + offenders.append( + f"{path.name}:{node.lineno} climbs out of core: from {base}" + ) + for alias in node.names: + combined = _module_chain(base, alias.name) + if _is_homeassistant(combined): + offenders.append( + f"{path.name}:{node.lineno} homeassistant import: " + f"from {base} import {alias.name}" + ) + if _is_bare_core(combined): + offenders.append( + f"{path.name}:{node.lineno} bare core import: " + f"from {base} import {alias.name}" + ) + if _climbs_out_of_core(combined): + offenders.append( + f"{path.name}:{node.lineno} climbs out of core: " + f"from {base} import {alias.name}" + ) + return offenders + + +def test_core_package_is_self_contained() -> None: + """No core module imports homeassistant or climbs out of the core package.""" + py_files = sorted(CORE_PKG.rglob("*.py")) + assert py_files, "core package not found under custom_components/nestquest/core" + all_offenders: list[str] = [] + for py_file in py_files: + all_offenders.extend(_scan(py_file)) + assert all_offenders == [], ( + "core package isolation violated:\n" + "\n".join(all_offenders) + ) diff --git a/tests/test_dao_matrix.py b/tests/test_dao_matrix.py index 30f96d2..945471c 100644 --- a/tests/test_dao_matrix.py +++ b/tests/test_dao_matrix.py @@ -441,7 +441,7 @@ def test_completion_events_absolutely_no_mutation_path() -> None: ) offenders = [ py.name - for py in sorted(package.glob("*.py")) + for py in sorted(package.rglob("*.py")) if py.name != "__pycache__" and mutation.search(py.read_text()) ] assert offenders == [] \ No newline at end of file diff --git a/tests/test_recurrence_model.py b/tests/test_recurrence_model.py index 8e12098..095ac03 100644 --- a/tests/test_recurrence_model.py +++ b/tests/test_recurrence_model.py @@ -276,7 +276,7 @@ def test_recurrence_module_imports_only_stdlib() -> None: from pathlib import Path source = Path( - __import__("custom_components.nestquest.recurrence", + __import__("custom_components.nestquest.core.recurrence", fromlist=["__file__"]).__file__ ).read_text() offenders = _scan_forbidden_imports(source) diff --git a/tests/test_recurrence_monthly_day.py b/tests/test_recurrence_monthly_day.py index aec763c..8830523 100644 --- a/tests/test_recurrence_monthly_day.py +++ b/tests/test_recurrence_monthly_day.py @@ -9,10 +9,10 @@ from custom_components.nestquest.recurrence import ( RuleType, RuleValidationError, ScheduleRule, - _month_end, occurs_on, occurrences_between, ) +from custom_components.nestquest.core.recurrence import _month_end def _d(date: str) -> datetime.date: diff --git a/tests/test_recurrence_monthly_weekday.py b/tests/test_recurrence_monthly_weekday.py index 1d08be7..2e040e9 100644 --- a/tests/test_recurrence_monthly_weekday.py +++ b/tests/test_recurrence_monthly_weekday.py @@ -9,10 +9,10 @@ from custom_components.nestquest.recurrence import ( RuleType, RuleValidationError, ScheduleRule, - _nth_weekday_of_month, occurs_on, occurrences_between, ) +from custom_components.nestquest.core.recurrence import _nth_weekday_of_month def _d(date: str) -> datetime.date: