Extract the storage layer into core/ (bundled in the integration)

Extract the storage layer into core/ (bundled in the integration)
This commit was merged in pull request #142.
This commit is contained in:
2026-09-21 14:48:01 -04:00
parent 87757aaaf9
commit 1503774e85
50 changed files with 5410 additions and 5031 deletions
+18
View File
@@ -829,6 +829,24 @@ def make_hass() -> tuple:
return hass, registry
def executor_for(hass):
"""Return a dynamic executor callable backed by ``hass.async_add_executor_job``.
The wrapper resolves ``hass.async_add_executor_job`` on every call, so a
test that swaps it after constructing the database (to gate or fail
specific jobs) is observed without rebuilding the wrapper — mirroring
the pre-extraction db.py, which read the attribute dynamically off
``hass``. Use this for cancellation/gating tests that reassign
``hass.async_add_executor_job``; plain call sites can pass
``hass.async_add_executor_job`` directly.
"""
async def _executor(fn, *args, **kwargs):
return await hass.async_add_executor_job(fn, *args, **kwargs)
return _executor
@pytest.fixture
async def hass():
"""Provide a standard-shaped hass fixture bound to the running test loop.
+1 -1
View File
@@ -31,7 +31,7 @@ def _make_hass_mock():
def _with_db(tmp_path, name):
def _run_test(body):
async def _main():
database = NestQuestDatabase(_make_hass_mock())
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
await database.open(tmp_path / name)
try:
await apply_migrations(database)
+4 -4
View File
@@ -33,7 +33,7 @@ def _make_hass_mock():
def _with_db(tmp_path, name):
def _run_test(body):
async def _main():
database = NestQuestDatabase(_make_hass_mock())
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
await database.open(tmp_path / name)
try:
await apply_migrations(database)
@@ -360,7 +360,7 @@ def test_create_child_concurrent_same_name_still_warns(tmp_path) -> None:
gate_open = asyncio.Event()
a_started = asyncio.Event()
hass = _gated_hass(armed, gate_open, a_started)
database = NestQuestDatabase(hass)
database = NestQuestDatabase(hass.async_add_executor_job)
await database.open(tmp_path / "create-race.db")
try:
await apply_migrations(database)
@@ -633,7 +633,7 @@ def test_edit_child_concurrent_opposite_edits_return_own_values(
gate_open = asyncio.Event()
a_started = asyncio.Event()
hass = _gated_hass(armed, gate_open, a_started)
database = NestQuestDatabase(hass)
database = NestQuestDatabase(hass.async_add_executor_job)
await database.open(tmp_path / "edit-race.db")
try:
await apply_migrations(database)
@@ -685,7 +685,7 @@ def test_set_child_active_concurrent_opposite_transitions_return_own(
gate_open = asyncio.Event()
a_started = asyncio.Event()
hass = _gated_hass(armed, gate_open, a_started)
database = NestQuestDatabase(hass)
database = NestQuestDatabase(hass.async_add_executor_job)
await database.open(tmp_path / "active-race.db")
try:
await apply_migrations(database)
+3 -3
View File
@@ -61,7 +61,7 @@ D1 = _future_day(1)
async def _prepare(path) -> tuple:
database = NestQuestDatabase(_make_hass_mock())
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
await database.open(path)
await apply_migrations(database)
children = ChildrenDao(database)
@@ -630,7 +630,7 @@ def test_complete_instance_concurrent_second_call_is_noop(tmp_path) -> None:
gate_open = asyncio.Event()
a_started = asyncio.Event()
hass = _gated_hass(armed, gate_open, a_started)
database = NestQuestDatabase(hass)
database = NestQuestDatabase(hass.async_add_executor_job)
await database.open(tmp_path / "complete-race.db")
try:
await apply_migrations(database)
@@ -1060,7 +1060,7 @@ def test_uncomplete_instance_concurrent_second_call_is_noop(tmp_path) -> None:
gate_open = asyncio.Event()
a_started = asyncio.Event()
hass = _gated_hass(armed, gate_open, a_started)
database = NestQuestDatabase(hass)
database = NestQuestDatabase(hass.async_add_executor_job)
await database.open(tmp_path / "uncomplete-race.db")
try:
await apply_migrations(database)
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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"
+119
View File
@@ -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.<something other than core>``,
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 <base> import <name>``."""
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)
)
+9 -9
View File
@@ -30,7 +30,7 @@ def _make_hass_mock():
def _open_db(path) -> NestQuestDatabase:
database = NestQuestDatabase(_make_hass_mock())
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
_run(database.open(path))
_run(apply_migrations(database))
return database
@@ -319,7 +319,7 @@ def test_reorder_concurrent_calls_serialize(tmp_path) -> None:
RuntimeError.
"""
async def _main() -> None:
database = NestQuestDatabase(_make_hass_mock())
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
await database.open(tmp_path / "reorder-concurrent.db")
try:
await apply_migrations(database)
@@ -377,7 +377,7 @@ def test_admin_add_concurrent_calls_are_idempotent(tmp_path) -> None:
'see no row' and one of them raise IntegrityError.
"""
async def _main() -> None:
database = NestQuestDatabase(_make_hass_mock())
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
await database.open(tmp_path / "admin-race.db")
try:
await apply_migrations(database)
@@ -487,17 +487,17 @@ def test_children_and_admin_sql_lives_only_in_dao_module() -> None:
scan_roots = [package, repo_root / "tests"]
allowed = {
"custom_components/nestquest/dao_children.py", # the DAO itself
"custom_components/nestquest/dao_rules.py", # validates child
"custom_components/nestquest/core/dao_children.py", # the DAO itself
"custom_components/nestquest/core/dao_rules.py", # validates child
# activeness on assignment (FK to children, done-condition
# 'validates that the assigned child is active')
"custom_components/nestquest/dao_presence.py", # validates the
"custom_components/nestquest/core/dao_presence.py", # validates the
# schedule/override child exists before writing (same FK shape
# as dao_rules: one existence SELECT per write)
"custom_components/nestquest/dao_instances.py", # validates the
"custom_components/nestquest/core/dao_instances.py", # validates the
# instance/event child exists before writing (same FK shape)
"custom_components/nestquest/schema.py", # declares the DDL
"custom_components/nestquest/migrations.py", # applies the DDL
"custom_components/nestquest/core/schema.py", # declares the DDL
"custom_components/nestquest/core/migrations.py", # applies the DDL
"tests/test_schema.py", # tests the DDL
"tests/test_migrations.py", # tests migration application
"tests/test_dao_children.py", # this file, scanned separately
+7 -7
View File
@@ -71,7 +71,7 @@ D20 = _future_day(20)
async def _prepare(path) -> tuple:
database = NestQuestDatabase(_make_hass_mock())
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
await database.open(path)
await apply_migrations(database)
children = ChildrenDao(database)
@@ -352,7 +352,7 @@ def test_upsert_no_past_check_uses_execution_date_not_call_date(
monkeypatch = pytest.MonkeyPatch()
monkeypatch.setattr(
"custom_components.nestquest.dao_instances.datetime",
"custom_components.nestquest.core.dao_instances.datetime",
_ShiftingModule(),
)
try:
@@ -362,7 +362,7 @@ def test_upsert_no_past_check_uses_execution_date_not_call_date(
# the lock, the IN-LOCK re-check sees tomorrow and must
# reject the insert — the stale call-date pre-check alone
# would have let it through.
import custom_components.nestquest.dao_instances as mod
import custom_components.nestquest.core.dao_instances as mod
async def _lock_holder():
# Hold the CONNECTION lock across a transaction until
@@ -402,7 +402,7 @@ def test_upsert_no_past_check_uses_execution_date_not_call_date(
return None
async def _main():
database = NestQuestDatabase(_make_hass_mock())
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
await database.open(tmp_path / "upsert-rollover.db")
try:
await apply_migrations(database)
@@ -1030,8 +1030,8 @@ def test_no_mutation_sql_for_completion_events_anywhere() -> None:
# INSERT statements are stripped before scanning so only genuine
# UPDATE/DELETE/non-DAO-INSERT paths hit the pattern.
insert_exempt = {
"custom_components/nestquest/dao_instances.py",
"custom_components/nestquest/migrations.py", # the rebuild
"custom_components/nestquest/core/dao_instances.py",
"custom_components/nestquest/core/migrations.py", # the rebuild
# migrations re-create the table via INSERT..SELECT (append-
# order preserved); UPDATE/DELETE stays forbidden everywhere
"tests/test_schema.py",
@@ -1141,7 +1141,7 @@ def test_completion_events_dao_instance_has_no_mutation_capability(
def test_append_concurrent_events_serialize(tmp_path) -> None:
"""Concurrent appends for the same instance all land, in order."""
async def _main():
database = NestQuestDatabase(_make_hass_mock())
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
await database.open(tmp_path / "append-race.db")
try:
await apply_migrations(database)
+2 -2
View File
@@ -118,7 +118,7 @@ class World:
async def _world(path) -> World:
database = NestQuestDatabase(_make_hass_mock())
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
await database.open(path)
await apply_migrations(database)
world = World(database)
@@ -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 == []
+6 -6
View File
@@ -33,7 +33,7 @@ NOW = "2026-09-14T12:00:00+00:00"
async def _prepare(path) -> tuple:
database = NestQuestDatabase(_make_hass_mock())
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
await database.open(path)
await apply_migrations(database)
schedules = PresenceSchedulesDao(database)
@@ -196,7 +196,7 @@ def test_upsert_concurrent_same_child_serializes(tmp_path) -> None:
return fn(*args)
hass.async_add_executor_job = _gated_executor
database = NestQuestDatabase(hass)
database = NestQuestDatabase(hass.async_add_executor_job)
await database.open(tmp_path / "upsert-race.db")
try:
await apply_migrations(database)
@@ -553,9 +553,9 @@ def test_presence_sql_lives_only_in_dao_module() -> None:
scan_roots = [package, repo_root / "tests"]
allowed = {
"custom_components/nestquest/dao_presence.py", # this DAO
"custom_components/nestquest/schema.py", # declares the DDL
"custom_components/nestquest/migrations.py", # applies the DDL
"custom_components/nestquest/core/dao_presence.py", # this DAO
"custom_components/nestquest/core/schema.py", # declares the DDL
"custom_components/nestquest/core/migrations.py", # applies the DDL
"tests/test_schema.py", # tests the DDL
"tests/test_migrations.py", # tests migration application
"tests/test_dao_presence.py", # this file, scanned separately
@@ -766,7 +766,7 @@ def test_override_create_racing_conflicts_serialize(tmp_path) -> None:
return fn(*args)
hass.async_add_executor_job = _gated_executor
database = NestQuestDatabase(hass)
database = NestQuestDatabase(hass.async_add_executor_job)
await database.open(tmp_path / "override-race.db")
try:
await apply_migrations(database)
+6 -6
View File
@@ -42,7 +42,7 @@ NOW = "2026-09-14T12:00:00+00:00"
async def _prepare(path) -> tuple:
database = NestQuestDatabase(_make_hass_mock())
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
await database.open(path)
await apply_migrations(database)
rules = ScheduleRulesDao(database)
@@ -325,7 +325,7 @@ def test_rule_delete_concurrent_with_definition_create_is_safe(
create's validation pauses mid-flight before the delete starts.
"""
async def _main(order: str):
database = NestQuestDatabase(_make_hass_mock())
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
await database.open(tmp_path / f"rule-delete-race-{order}.db")
try:
await apply_migrations(database)
@@ -1218,12 +1218,12 @@ def test_rules_and_definitions_sql_lives_only_in_dao_module() -> None:
scan_roots = [package, repo_root / "tests"]
allowed = {
"custom_components/nestquest/dao_rules.py", # this DAO
"custom_components/nestquest/dao_instances.py", # validates the
"custom_components/nestquest/core/dao_rules.py", # this DAO
"custom_components/nestquest/core/dao_instances.py", # validates the
# definition exists + fetches its assignee before generating an
# instance (the instance must snapshot the definition's child)
"custom_components/nestquest/schema.py", # declares the DDL
"custom_components/nestquest/migrations.py", # applies the DDL
"custom_components/nestquest/core/schema.py", # declares the DDL
"custom_components/nestquest/core/migrations.py", # applies the DDL
"tests/test_schema.py", # tests the DDL
"tests/test_migrations.py", # tests migration application
"tests/test_dao_rules.py", # this file, scanned separately
+28 -26
View File
@@ -11,9 +11,9 @@ from unittest.mock import AsyncMock, MagicMock
import pytest
from conftest import make_hass
from conftest import executor_for, make_hass
from custom_components.nestquest import db as db_module
import custom_components.nestquest.core.db as db_module
from custom_components.nestquest.db import NestQuestDatabase
@@ -29,7 +29,7 @@ def _make_hass_mock():
def _open_db(path) -> tuple[NestQuestDatabase, MagicMock]:
hass = _make_hass_mock()
database = NestQuestDatabase(hass)
database = NestQuestDatabase(hass.async_add_executor_job)
_run(database.open(path))
return database, hass
@@ -55,7 +55,7 @@ def test_open_enables_foreign_key_enforcement(tmp_path) -> None:
def test_open_delegates_connection_to_executor(tmp_path) -> None:
hass = _make_hass_mock()
database = NestQuestDatabase(hass)
database = NestQuestDatabase(hass.async_add_executor_job)
_run(database.open(tmp_path / "exec.db"))
first_call = hass.async_add_executor_job.call_args_list[0]
assert callable(first_call.args[0])
@@ -65,7 +65,7 @@ def test_open_delegates_connection_to_executor(tmp_path) -> None:
def test_open_twice_returns_same_wrapper_without_second_connect(tmp_path) -> None:
path = tmp_path / "once.db"
hass = _make_hass_mock()
database = NestQuestDatabase(hass)
database = NestQuestDatabase(hass.async_add_executor_job)
first = _run(database.open(path))
second = _run(database.open(path))
assert first is database
@@ -111,7 +111,7 @@ def test_every_method_routes_sqlite3_through_executor(tmp_path) -> None:
async def test_every_wrapper_call_awaits_executor_job(tmp_path) -> None:
"""The executor surface is awaited per wrapper call on a live loop."""
hass = _make_hass_mock()
database = NestQuestDatabase(hass)
database = NestQuestDatabase(hass.async_add_executor_job)
await database.open(tmp_path / "loop.db")
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY)")
@@ -129,7 +129,7 @@ async def test_every_wrapper_call_awaits_executor_job(tmp_path) -> None:
async def test_public_methods_return_only_plain_data(tmp_path) -> None:
"""execute/execute_many return plain dataclasses, never sqlite3 objects."""
hass = _make_hass_mock()
database = NestQuestDatabase(hass)
database = NestQuestDatabase(hass.async_add_executor_job)
await database.open(tmp_path / "plain.db")
result = await database.execute(
"CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)"
@@ -156,7 +156,7 @@ async def test_public_methods_return_only_plain_data(tmp_path) -> None:
async def test_no_sqlite3_call_on_event_loop_with_real_executor(tmp_path) -> None:
"""With the conftest hass (real run_in_executor), queries still succeed."""
hass, _registry = make_hass()
database = NestQuestDatabase(hass)
database = NestQuestDatabase(hass.async_add_executor_job)
await database.open(tmp_path / "real.db")
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY)")
assert await database.fetch_one("SELECT 1") == (1,)
@@ -210,7 +210,7 @@ async def test_real_executor_never_touches_loop_thread(tmp_path, monkeypatch) ->
monkeypatch.setattr(db_module.sqlite3, "connect", _traced_connect)
hass, _registry = make_hass()
database = NestQuestDatabase(hass)
database = NestQuestDatabase(hass.async_add_executor_job)
await database.open(tmp_path / "threads.db")
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY)")
await database.execute("INSERT INTO t (id) VALUES (?)", (1,))
@@ -250,7 +250,9 @@ def test_db_sqlite3_calls_only_inside_executor_delegated_functions() -> None:
"""Every sqlite3.* call in db.py sits inside an executor-delegating function.
A function is executor-delegating when it (or, for nested closures, any of
its enclosing functions) passes work to ``hass.async_add_executor_job``.
its enclosing functions) passes work to the wrapper's ``self._executor``
callable (the Home-Assistant-free contract that mirrors
``hass.async_add_executor_job``).
"""
source = Path(db_module.__file__).read_text(encoding="utf-8")
tree = ast.parse(source, filename=str(db_module.__file__))
@@ -262,7 +264,7 @@ def test_db_sqlite3_calls_only_inside_executor_delegated_functions() -> None:
and any(
isinstance(sub, ast.Call)
and isinstance(sub.func, ast.Attribute)
and sub.func.attr == "async_add_executor_job"
and sub.func.attr == "_executor"
for sub in ast.walk(node)
)
}
@@ -375,7 +377,7 @@ async def test_commit_failure_rolls_back_and_next_transaction_works(
must run cleanly.
"""
hass, _registry = make_hass()
database = NestQuestDatabase(hass)
database = NestQuestDatabase(hass.async_add_executor_job)
await database.open(tmp_path / "deferred.db")
await database.execute("CREATE TABLE parent (id INTEGER PRIMARY KEY)")
await database.execute(
@@ -416,7 +418,7 @@ async def test_cancel_during_open_closes_abandoned_connection(tmp_path) -> None:
propagates, and a follow-up open() must succeed cleanly.
"""
hass, _registry = make_hass()
database = NestQuestDatabase(hass)
database = NestQuestDatabase(executor_for(hass))
open_started = asyncio.Event()
release_open = asyncio.Event()
@@ -481,7 +483,7 @@ async def test_cancel_during_begin_settles_then_rolls_back(tmp_path) -> None:
clear its state, and stay usable for the next transaction.
"""
hass, _registry = make_hass()
database = NestQuestDatabase(hass)
database = NestQuestDatabase(executor_for(hass))
await database.open(tmp_path / "cancel-begin.db")
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)")
await database.execute("INSERT INTO t (name) VALUES (?)", ("before",))
@@ -543,7 +545,7 @@ async def test_cancel_during_commit_settles_then_propagates_cancellation(
transaction, and the wrapper must stay usable.
"""
hass, _registry = make_hass()
database = NestQuestDatabase(hass)
database = NestQuestDatabase(executor_for(hass))
await database.open(tmp_path / "cancel-commit.db")
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)")
@@ -603,7 +605,7 @@ async def test_cancel_during_in_transaction_execute_settles_then_rolls_back(
not persist.
"""
hass, _registry = make_hass()
database = NestQuestDatabase(hass)
database = NestQuestDatabase(executor_for(hass))
await database.open(tmp_path / "cancel-execute.db")
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)")
await database.execute("INSERT INTO t (name) VALUES (?)", ("before",))
@@ -666,7 +668,7 @@ async def test_commit_and_rollback_failures_are_both_preserved(tmp_path) -> None
commit failure behind the rollback failure.
"""
hass, _registry = make_hass()
database = NestQuestDatabase(hass)
database = NestQuestDatabase(executor_for(hass))
await database.open(tmp_path / "both-fail.db")
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY)")
@@ -698,14 +700,14 @@ async def test_commit_and_rollback_failures_are_both_preserved(tmp_path) -> None
async def test_transaction_persists_across_connections(tmp_path) -> None:
"""A committed transaction is visible to a brand-new connection."""
hass, _registry = make_hass()
database = NestQuestDatabase(hass)
database = NestQuestDatabase(hass.async_add_executor_job)
await database.open(tmp_path / "persist.db")
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)")
async with database.transaction():
await database.execute("INSERT INTO t (name) VALUES (?)", ("kept",))
await database.close()
other = NestQuestDatabase(hass)
other = NestQuestDatabase(hass.async_add_executor_job)
await other.open(tmp_path / "persist.db")
assert await other.fetch_all("SELECT name FROM t") == [("kept",)]
await other.close()
@@ -726,7 +728,7 @@ async def test_concurrent_writers_do_not_interleave_commits(tmp_path) -> None:
while a transaction is open queue behind it on the wrapper lock.
"""
hass, _registry = make_hass()
database = NestQuestDatabase(hass)
database = NestQuestDatabase(hass.async_add_executor_job)
await database.open(tmp_path / "serial.db")
await database.execute("CREATE TABLE t (kind TEXT, seq INTEGER)")
tx_rounds, writers, writer_rounds = 6, 5, 8
@@ -766,7 +768,7 @@ async def test_concurrent_writers_do_not_interleave_commits(tmp_path) -> None:
async def test_transaction_span_excludes_concurrent_transaction(tmp_path) -> None:
"""A second transaction caller is rejected with a clear error."""
hass, _registry = make_hass()
database = NestQuestDatabase(hass)
database = NestQuestDatabase(hass.async_add_executor_job)
await database.open(tmp_path / "reject.db")
await database.execute("CREATE TABLE t (id INTEGER)")
@@ -791,7 +793,7 @@ async def test_transaction_span_excludes_concurrent_transaction(tmp_path) -> Non
async def test_transaction_blocks_other_statements_until_commit(tmp_path) -> None:
"""A statement issued during an open transaction queues behind it."""
hass, _registry = make_hass()
database = NestQuestDatabase(hass)
database = NestQuestDatabase(hass.async_add_executor_job)
await database.open(tmp_path / "block.db")
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)")
@@ -830,7 +832,7 @@ async def test_concurrent_transaction_is_rejected_with_clear_error(
) -> None:
"""A second concurrent transaction() gets a clear RuntimeError."""
hass, _registry = make_hass()
database = NestQuestDatabase(hass)
database = NestQuestDatabase(hass.async_add_executor_job)
await database.open(tmp_path / "concurrent-tx.db")
await database.execute("CREATE TABLE t (id INTEGER)")
@@ -853,7 +855,7 @@ async def test_concurrent_transaction_is_rejected_with_clear_error(
async def test_close_waits_for_in_flight_transaction(tmp_path) -> None:
"""close() serializes: an open transaction finishes before shutdown."""
hass, _registry = make_hass()
database = NestQuestDatabase(hass)
database = NestQuestDatabase(hass.async_add_executor_job)
await database.open(tmp_path / "closerace.db")
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)")
@@ -875,7 +877,7 @@ async def test_close_waits_for_in_flight_transaction(tmp_path) -> None:
await asyncio.gather(txn_task, close_task)
assert database.connected is False
witness = NestQuestDatabase(hass)
witness = NestQuestDatabase(hass.async_add_executor_job)
await witness.open(tmp_path / "closerace.db")
assert await witness.fetch_all("SELECT name FROM t") == [("kept",)]
await witness.close()
@@ -886,7 +888,7 @@ async def test_close_during_transaction_from_same_task_is_rejected(
) -> None:
"""close() inside its own transaction body raises instead of deadlocking."""
hass, _registry = make_hass()
database = NestQuestDatabase(hass)
database = NestQuestDatabase(hass.async_add_executor_job)
await database.open(tmp_path / "selfclose.db")
await database.execute("CREATE TABLE t (id INTEGER)")
with pytest.raises(RuntimeError, match="transaction"):
+2 -2
View File
@@ -472,7 +472,7 @@ async def test_setup_backfills_instances_over_horizon(hass, make_entry) -> None:
# Seed the database BEFORE setup: a restart reopens an existing file that
# already holds definitions. The startup backfill must pick them up.
db_path = await async_get_db_path(hass)
pre = NestQuestDatabase(hass)
pre = NestQuestDatabase(hass.async_add_executor_job)
await pre.open(db_path)
await apply_migrations(pre)
child_id = await _seed_daily_child_and_definition(pre, _local_today(hass))
@@ -499,7 +499,7 @@ async def test_setup_backfills_configured_horizon_days(hass, make_entry) -> None
from custom_components.nestquest.store import async_get_db_path
db_path = await async_get_db_path(hass)
pre = NestQuestDatabase(hass)
pre = NestQuestDatabase(hass.async_add_executor_job)
await pre.open(db_path)
await apply_migrations(pre)
child_id = await _seed_daily_child_and_definition(pre, _local_today(hass))
+2 -2
View File
@@ -51,7 +51,7 @@ NOW = "2026-09-14T12:00:00+00:00"
async def _prepare(path):
database = NestQuestDatabase(_make_hass_mock())
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
await database.open(path)
await apply_migrations(database)
return database
@@ -1039,7 +1039,7 @@ def test_regenerate_for_child_uses_single_anchor_across_midnight(
clock["reads"] += 1
return day0 if clock["reads"] == 1 else day1
import custom_components.nestquest.dao_instances as dao_instances_module
import custom_components.nestquest.core.dao_instances as dao_instances_module
monkeypatch.setattr(dao_instances_module, "_today", _fake_today)
+4 -4
View File
@@ -37,7 +37,7 @@ def _make_hass_mock():
def _open_db(path) -> NestQuestDatabase:
database = NestQuestDatabase(_make_hass_mock())
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
_run(database.open(path))
return database
@@ -707,7 +707,7 @@ def test_noop_run_logs_no_migration(caplog, tmp_path) -> None:
try:
_migrate(database)
caplog.clear()
with caplog.at_level(logging.INFO, logger="custom_components.nestquest.migrations"):
with caplog.at_level(logging.INFO, logger="custom_components.nestquest.core.migrations"):
_migrate(database)
migrated = [
r for r in caplog.records if "migrated" in r.getMessage().lower()
@@ -722,7 +722,7 @@ def test_noop_run_is_logged_at_debug_level(caplog, tmp_path) -> None:
try:
_migrate(database)
caplog.clear()
with caplog.at_level(logging.DEBUG, logger="custom_components.nestquest.migrations"):
with caplog.at_level(logging.DEBUG, logger="custom_components.nestquest.core.migrations"):
_migrate(database)
assert any(
f"already at version {len(MIGRATIONS)}" in r.getMessage()
@@ -983,7 +983,7 @@ def test_concurrent_runners_serialize_and_apply_once(tmp_path) -> None:
results: dict[str, int] = {}
async def _main(tmp_name: str) -> None:
database = NestQuestDatabase(_make_hass_mock())
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
await database.open(tmp_name)
try:
# Start A, yield so it acquires the lock and enters its
+1 -1
View File
@@ -113,7 +113,7 @@ def _open_allowlist_db(tmp_path, name):
hass = MagicMock()
hass.async_add_executor_job = AsyncMock(side_effect=(lambda fn, *a: fn(*a)))
database = NestQuestDatabase(hass)
database = NestQuestDatabase(hass.async_add_executor_job)
_run(database.open(tmp_path / name))
_run(apply_migrations(database))
return database
+2 -2
View File
@@ -50,7 +50,7 @@ NOW = "2026-09-14T12:00:00+00:00"
async def _prepare(path) -> tuple:
database = NestQuestDatabase(_make_hass_mock())
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
await database.open(path)
await apply_migrations(database)
children = ChildrenDao(database)
@@ -1396,7 +1396,7 @@ def test_set_quest_definition_active_concurrent_opposite_transitions_return_own(
gate_open = asyncio.Event()
a_started = asyncio.Event()
hass = _gated_hass(armed, gate_open, a_started)
database = NestQuestDatabase(hass)
database = NestQuestDatabase(hass.async_add_executor_job)
await database.open(tmp_path / "active-race.db")
try:
await apply_migrations(database)
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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:
+1 -1
View File
@@ -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:
+1 -1
View File
@@ -37,7 +37,7 @@ def _make_hass_mock():
def _open_db(path) -> NestQuestDatabase:
database = NestQuestDatabase(_make_hass_mock())
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
_run(database.open(path))
return database
+2 -2
View File
@@ -36,7 +36,7 @@ def _make_hass_mock():
def _open_db(path) -> NestQuestDatabase:
database = NestQuestDatabase(_make_hass_mock())
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
_run(database.open(path))
return database
@@ -44,7 +44,7 @@ def _open_db(path) -> NestQuestDatabase:
async def _avalid_database(path) -> None:
"""Create a valid, migrated NestQuest database file (async, on the
running loop, for use inside pytest-asyncio tests)."""
database = NestQuestDatabase(_make_hass_mock())
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
await database.open(path)
try:
await apply_migrations(database)
+30 -7
View File
@@ -23,7 +23,11 @@ def _make_hass(config_dir: Path, execute: bool = True):
return hass
def test_returns_absolute_path_from_config_path() -> None:
def test_returns_absolute_path_from_config_path(monkeypatch) -> None:
# Path resolution only: the parent-dir mkdir is a side effect of
# core.store and is exercised elsewhere; suppress it here so the
# absolute-path assertion can run against /config (non-writable).
monkeypatch.setattr("custom_components.nestquest.core.store._ensure_dir", lambda path: None)
hass = _make_hass(Path("/config"), execute=False)
db_path = _run(store.async_get_db_path(hass))
hass.config.path.assert_called_once_with(SQLITE_DB_FILENAME)
@@ -75,18 +79,36 @@ def test_existing_directory_does_not_fail(tmp_path) -> None:
assert not db_path.exists()
def test_mkdir_runs_via_executor(tmp_path) -> None:
def test_mkdir_runs_off_event_loop(tmp_path, monkeypatch) -> None:
"""The parent-directory mkdir runs off the event loop via asyncio.to_thread.
core.store is Home-Assistant-free, so it schedules the blocking
``mkdir`` through :func:`asyncio.to_thread` rather than
``hass.async_add_executor_job``; the integration shim delegates to it
unchanged. The intent is preserved: the mkdir still runs off the
event loop, with ``_ensure_dir`` on the parent directory, and the
directory exists afterwards.
"""
nested = tmp_path / "deep" / "nested"
hass = _make_hass(nested)
to_thread_calls: list = []
real_to_thread = asyncio.to_thread
def _trace_to_thread(fn, *args, **kwargs):
to_thread_calls.append((fn, args, kwargs))
return real_to_thread(fn, *args, **kwargs)
monkeypatch.setattr(asyncio, "to_thread", _trace_to_thread)
db_path = _run(store.async_get_db_path(hass))
assert nested.is_dir()
hass.async_add_executor_job.assert_called_once()
args, kwargs = hass.async_add_executor_job.call_args
fn, fn_args = args[0], args[1:]
assert len(to_thread_calls) == 1, (
f"expected one asyncio.to_thread call, got {len(to_thread_calls)}"
)
fn, fn_args, fn_kwargs = to_thread_calls[0]
assert callable(fn)
assert fn is store._ensure_dir
assert fn_args == (nested,)
assert kwargs == {}
assert fn_kwargs == {}
assert db_path.parent == nested
assert db_path.parent.is_dir()
assert not db_path.exists()
@@ -180,7 +202,8 @@ def test_no_file_creation_calls_in_store() -> None:
assert "open(" not in source
def test_container_style_path_resolves() -> None:
def test_container_style_path_resolves(monkeypatch) -> None:
monkeypatch.setattr("custom_components.nestquest.core.store._ensure_dir", lambda path: None)
hass = _make_hass(Path("/config"), execute=False)
assert _run(store.async_get_db_path(hass)) == Path("/config") / SQLITE_DB_FILENAME
+2 -2
View File
@@ -38,7 +38,7 @@ def _make_hass_mock():
async def _prepare(path):
database = NestQuestDatabase(_make_hass_mock())
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
await database.open(path)
await apply_migrations(database)
return database
@@ -313,7 +313,7 @@ def test_definition_business_regeneration_uses_pinned_today_not_host(
business operations must regenerate against the pinned date and keep
that first local day, proving the threaded ``today`` wins.
"""
import custom_components.nestquest.dao_instances as dao_instances_module
import custom_components.nestquest.core.dao_instances as dao_instances_module
pinned = datetime.date(2026, 1, 14)
host_today = datetime.date(2026, 1, 15)