Address review findings on PR #147 (P2+P3)

P2-1 (containment): api/nestquest_core.py no longer mutates sys.path.
Load core by file location with importlib.util.spec_from_file_location,
registering it under the distinct top-level name 'nestquest_core' with
submodule_search_locations pointing at the bundled core directory. No
generic top-level names (db, const, schema, ...) are published that
could shadow a future dependency, and the integration's HA-coupled
__init__ is never executed. Use importlib.import_module for submodules;
drop the redundant _BOOTSTRAPPED flag. Document that two core copies
(nestquest_core.* and custom_components.nestquest.core.*) coexist in
the pytest process so later tasks must not compare class/exception
identity across them.

P2-2 (fail fast): ApiConfig.from_env now RAISES ConfigError when
NESTQUEST_DB_PATH is unset or empty — no silent fallback to a
predictable world-writable temp path that loses data at reboot. Remove
the module-level 'app = create_app()' (it read env at import time and
would break fail-fast); document serving with 'uvicorn --factory
api.app:create_app'.

P3 cleanups:
- /health drops db_path from the body (no filesystem path leak on an
  unauthenticated route) and runs a cheap SELECT 1 to reflect database
  reachability.
- Remove dead code: resolve_db_path in api/config.py; unused LOGGER in
  api/app.py.
- tests/test_api_health.py: add ApiConfig.from_env tests (set value,
  empty raises, missing raises); add startup-failure tests (unwritable
  dir, non-SQLite file); assert shutdown closes the connection
  (connected is False); make _AppRunner.__aexit__ use try/finally so a
  client error still shuts down the lifespan; reuse api.database._executor
  instead of a duplicated local copy; in the no-HA subprocess test
  assert core.__file__ points at the bundled directory and stop passing
  the unused ':memory:'.
- pyproject.toml: comment that fastapi/uvicorn are dev-group only so the
  monorepo test venv can exercise the app; the API deployable's runtime
  dependencies must be declared when it is packaged (Feature 17).
This commit is contained in:
2026-09-21 16:11:55 -04:00
parent 70dce8a536
commit 48552ac62e
6 changed files with 278 additions and 121 deletions
+6 -4
View File
@@ -9,9 +9,11 @@ This package imports the NestQuest domain ``core`` WITHOUT executing
``custom_components/nestquest/__init__.py`` — that parent package's ``custom_components/nestquest/__init__.py`` — that parent package's
``__init__.py`` imports ``homeassistant``, which is absent from the ``__init__.py`` imports ``homeassistant``, which is absent from the
API's runtime. The coupling is encapsulated in API's runtime. The coupling is encapsulated in
:mod:`api.nestquest_core` (a ``sys.path`` bootstrap that makes ``core`` :mod:`api.nestquest_core`, which loads ``core`` by file location with
importable as a top-level package). It is a TRANSITIONAL coupling that :mod:`importlib.util` and registers it under the distinct top-level name
Feature 18 will revisit: once the integration becomes a thin API client ``nestquest_core`` (no ``sys.path`` mutation, no generic top-level
and ``core`` moves out of the integration bundle, this helper goes away. names published). It is a TRANSITIONAL coupling that Feature 18 will
revisit: once the integration becomes a thin API client and ``core``
moves out of the integration bundle, this helper goes away.
""" """
from __future__ import annotations from __future__ import annotations
+27 -20
View File
@@ -4,9 +4,10 @@ This module builds the ASGI app for the NestQuest API service (Feature
16). The skeleton here wires the bundled ``core`` (Feature 15) and 16). The skeleton here wires the bundled ``core`` (Feature 15) and
exposes the two endpoints every later task depends on: exposes the two endpoints every later task depends on:
- ``GET /health`` — returns ``200`` with a JSON status body carrying - ``GET /health`` — returns ``200`` with a JSON status body once the
``status: "ok"`` and the resolved database path, proving the app app has imported core, opened the database, and applied migrations;
imported core, opened the database, and applied migrations. a cheap ``SELECT 1`` confirms the connection is live without leaking
the filesystem path on an unauthenticated route.
- ``GET /docs`` — FastAPI's auto-served OpenAPI document (Swagger UI); - ``GET /docs`` — FastAPI's auto-served OpenAPI document (Swagger UI);
``/openapi.json`` is the raw schema. No extra wiring is needed; ``/openapi.json`` is the raw schema. No extra wiring is needed;
FastAPI serves both by default. FastAPI serves both by default.
@@ -14,10 +15,19 @@ exposes the two endpoints every later task depends on:
The database is opened and migrated in a FastAPI lifespan context so a The database is opened and migrated in a FastAPI lifespan context so a
startup failure (e.g. an unwritable path) surfaces as a startup error startup failure (e.g. an unwritable path) surfaces as a startup error
rather than the first request. The connection is closed on shutdown. rather than the first request. The connection is closed on shutdown.
This module exports ONLY the :func:`create_app` factory — there is no
module-level app instance, because building one at import time would
read ``NESTQUEST_DB_PATH`` at import (and fail-fast on a missing value)
before any worker is ready to report it. Serve with::
uvicorn --factory api.app:create_app
so the factory runs on each worker startup and reads the environment
then.
""" """
from __future__ import annotations from __future__ import annotations
import logging
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
@@ -26,8 +36,6 @@ from fastapi import FastAPI
from api.config import ApiConfig from api.config import ApiConfig
from api.database import DatabaseState, make_database from api.database import DatabaseState, make_database
LOGGER = logging.getLogger(__name__)
def _build_lifespan(db_path: str): def _build_lifespan(db_path: str):
"""Return a lifespan that opens+migrates then closes one database. """Return a lifespan that opens+migrates then closes one database.
@@ -53,9 +61,10 @@ def create_app(config: ApiConfig | None = None) -> FastAPI:
"""Build the NestQuest API FastAPI app. """Build the NestQuest API FastAPI app.
``config`` defaults to one built from the process environment, so ``config`` defaults to one built from the process environment, so
``uvicorn api.app:app`` picks up ``NESTQUEST_DB_PATH``. Tests pass an ``uvicorn --factory api.app:create_app`` picks up
explicit config (or set the env var) so each test gets a hermetic ``NESTQUEST_DB_PATH`` (and fails fast on startup if it is missing).
temp SQLite file. Tests pass an explicit config so each test gets a hermetic temp
SQLite file.
""" """
cfg = config if config is not None else ApiConfig.from_env() cfg = config if config is not None else ApiConfig.from_env()
@@ -72,21 +81,19 @@ def create_app(config: ApiConfig | None = None) -> FastAPI:
@app.get("/health", summary="Service health") @app.get("/health", summary="Service health")
async def health() -> dict[str, str]: async def health() -> dict[str, str]:
"""Return service health and the resolved database path. """Return service health.
The body carries ``status: "ok"`` and the ``db_path`` the app Returns ``200`` with ``{"status": "ok"}`` once the lifespan has
opened against — so a probe can confirm the app wired core and opened and migrated the database. A cheap ``SELECT 1`` against
reached the configured SQLite file. Returns 200 once the the live connection confirms the database is reachable WITHOUT
lifespan has opened and migrated the database. leaking the filesystem path on an unauthenticated route; if the
connection is dead the query raises and the probe fails.
""" """
state: DatabaseState = app.state.db state: DatabaseState = app.state.db
return {"status": "ok", "db_path": state.db_path} await state.database.fetch_one("SELECT 1")
return {"status": "ok"}
return app return app
#: The module-level app instance, for ``uvicorn api.app:app``. __all__ = ["create_app"]
app: FastAPI = create_app()
__all__ = ["app", "create_app"]
+27 -20
View File
@@ -1,33 +1,35 @@
"""Configuration for the NestQuest API service. """Configuration for the NestQuest API service.
The API reads its single configuration knob — the SQLite database path — The API reads its single configuration knob — the SQLite database path —
from the ``NESTQUEST_DB_PATH`` environment variable, with a documented from the ``NESTQUEST_DB_PATH`` environment variable. Unlike a dev
default inside the OS temp directory. This mirrors the integration's convenience default, a missing or empty value is a configuration ERROR,
not a silent fallback to a predictable world-writable temp path: the API
owns the database (it is the writer of record per Feature 16's
contract), and silently dropping data into ``$TMPDIR/nestquest.db``
would lose it at reboot. ``ApiConfig.from_env`` therefore RAISES when
the variable is unset or empty. This mirrors the integration's
"explicit settings object" discipline (Feature 15's "explicit settings object" discipline (Feature 15's
``core.settings``): the API never reads HA config; callers pass the ``core.settings``): the API never reads HA config; callers pass the
resolved path into the database wiring. resolved path into the database wiring.
Serving: the app is built with the factory ``api.app:create_app`` — run
it with ``uvicorn --factory api.app:create_app`` so the factory reads
the environment on each worker startup rather than at import time.
""" """
from __future__ import annotations from __future__ import annotations
import os import os
import tempfile
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path
#: Environment variable holding the SQLite file path. The API opens #: Environment variable holding the SQLite file path. The API opens
#: this file on startup (creating it if absent) and applies migrations. #: this file on startup (creating it if absent) and applies migrations.
#: REQUIRED — unset or empty raises :class:`ConfigError` from
#: :meth:`ApiConfig.from_env`.
DB_PATH_ENV = "NESTQUEST_DB_PATH" DB_PATH_ENV = "NESTQUEST_DB_PATH"
#: The default database location when the env var is unset. Lives in
#: the OS temp directory so a dev run without configuration does not
#: litter the repo, and so tests that forget to set the env var still
#: get a hermetic-ish path. Production deployments ALWAYS set
#: ``NESTQUEST_DB_PATH`` to a persistent volume path.
_DEFAULT_DB_NAME = "nestquest.db"
class ConfigError(RuntimeError):
def _default_db_path() -> str: """Raised when required API configuration is missing or invalid."""
return str(Path(tempfile.gettempdir()) / _DEFAULT_DB_NAME)
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -47,15 +49,20 @@ class ApiConfig:
``env`` defaults to ``os.environ``; tests pass an explicit dict ``env`` defaults to ``os.environ``; tests pass an explicit dict
so they are hermetic (no monkeypatch of the global environ). so they are hermetic (no monkeypatch of the global environ).
Raises :class:`ConfigError` when ``NESTQUEST_DB_PATH`` is unset
or empty/whitespace-only — there is no silent default, because a
default in the temp directory would silently lose data at reboot.
""" """
source = env if env is not None else os.environ source = env if env is not None else os.environ
raw = source.get(DB_PATH_ENV, "").strip() raw = source.get(DB_PATH_ENV, "").strip()
return cls(db_path=raw if raw else _default_db_path()) if not raw:
raise ConfigError(
f"{DB_PATH_ENV} must be set to a writable SQLite file path "
f"(it is unset or empty — the API will not fall back to a "
f"temp directory and silently lose data)"
)
return cls(db_path=raw)
def resolve_db_path(env: dict[str, str] | None = None) -> str: __all__ = ["ApiConfig", "ConfigError", "DB_PATH_ENV"]
"""Return the resolved SQLite path (env override or documented default)."""
return ApiConfig.from_env(env).db_path
__all__ = ["ApiConfig", "DB_PATH_ENV", "resolve_db_path"]
+68 -45
View File
@@ -10,11 +10,18 @@ API cannot start.
``core`` itself is a clean, Home-Assistant-free package: its ``core`` itself is a clean, Home-Assistant-free package: its
``__init__.py`` is a bare docstring and every internal import is ``__init__.py`` is a bare docstring and every internal import is
relative (``from .db import ...``). It can therefore be imported as a relative (``from .db import ...``). It can therefore be imported by
top-level package if the integration directory is placed on file location as a standalone package.
``sys.path``. This module does exactly that, in ONE documented place,
so the coupling between the API and the bundled core is explicit and This module loads ``core`` with :func:`importlib.util.spec_from_file_location`
testable rather than scattered across route handlers. — registering it under the distinct top-level name ``nestquest_core``
with ``submodule_search_locations`` set to the bundled ``core``
directory — so its relative imports resolve against that directory
without touching ``sys.path``. No generic top-level name (``db``,
``const``, ``schema``, ...) is published, so nothing here can silently
shadow a future dependency, and the integration's HA-coupled
``__init__.py`` is never executed: ``custom_components.nestquest`` is
never imported.
Usage:: Usage::
@@ -25,9 +32,22 @@ This is a TRANSITIONAL coupling. Feature 18 (task 430f7f98) converts
the integration into a thin API client and moves ``core`` out of the the integration into a thin API client and moves ``core`` out of the
integration bundle; once that lands, this bootstrap helper goes away and integration bundle; once that lands, this bootstrap helper goes away and
the API imports ``core`` as an ordinary installed dependency. the API imports ``core`` as an ordinary installed dependency.
NOTE on the pytest process: the suite's ``conftest.py`` stubs the
parent integration package and imports ``custom_components.nestquest.core.*``
BEFORE any API test runs, so two copies of the core package coexist in
the pytest process — the one registered here as ``nestquest_core.*``
(loaded by file location) and the conftest-stubbed
``custom_components.nestquest.core.*``. Later tasks must NOT compare
class or exception identity across these two copies: a
``nestquest_core.db.NestQuestDatabase`` is a DIFFERENT class object from
``custom_components.nestquest.core.db.NestQuestDatabase`` even though
they share source. Operate on a single copy within any one code path.
""" """
from __future__ import annotations from __future__ import annotations
import importlib
import importlib.util
import sys import sys
from pathlib import Path from pathlib import Path
from types import ModuleType from types import ModuleType
@@ -35,53 +55,56 @@ from types import ModuleType
#: The repository root (this file is ``<root>/api/nestquest_core.py``). #: The repository root (this file is ``<root>/api/nestquest_core.py``).
_REPO_ROOT: Path = Path(__file__).resolve().parent.parent _REPO_ROOT: Path = Path(__file__).resolve().parent.parent
#: The integration directory that contains the bundled ``core`` package. #: The bundled ``core`` package directory, inside the HA integration.
#: Adding this to ``sys.path`` makes ``import core`` resolve to _CORE_DIR: Path = _REPO_ROOT / "custom_components" / "nestquest" / "core"
#: ``custom_components/nestquest/core/`` WITHOUT executing the
#: integration's ``__init__.py`` (which would import ``homeassistant``).
_INTEGRATION_DIR: Path = _REPO_ROOT / "custom_components" / "nestquest"
_BOOTSTRAPPED = False #: The ``core`` package's ``__init__.py`` — a bare docstring, so loading
#: it by file location executes no imports of its own.
_CORE_INIT: Path = _CORE_DIR / "__init__.py"
#: The distinct top-level name under which the bundled ``core`` package
#: is registered in ``sys.modules``. A distinctive name (rather than the
#: generic ``core``) avoids publishing shadow-able top-level names and
#: keeps the bundled copy separate from the conftest-stubbed
#: ``custom_components.nestquest.core`` present in the pytest process.
_CORE_MODULE_NAME = "nestquest_core"
def bootstrap_core() -> None: def _load_core() -> ModuleType:
"""Make the bundled ``core`` package importable as a top-level package. """Load the bundled ``core`` package by file location and return it.
Inserts the integration directory at the front of ``sys.path`` (once, Registers the package under :data:`_CORE_MODULE_NAME` in
idempotently). After this call, ``import core`` resolves to the ``sys.modules`` with ``submodule_search_locations`` pointing at the
bundled ``custom_components/nestquest/core`` package, and its bundled directory, so its relative imports (``from .db import ...``)
relative internal imports (``from .db import ...``) resolve against resolve to ``nestquest_core.<submodule>`` against that directory
that same directory. The integration's HA-coupled ``__init__.py`` is without mutating ``sys.path``. Idempotent: a second call returns
never executed: ``core`` is imported directly as a top-level package, the already-registered module.
so ``custom_components.nestquest`` is never touched.
""" """
global _BOOTSTRAPPED existing = sys.modules.get(_CORE_MODULE_NAME)
if _BOOTSTRAPPED: if existing is not None:
return return existing
path = str(_INTEGRATION_DIR) spec = importlib.util.spec_from_file_location(
if path not in sys.path: _CORE_MODULE_NAME,
sys.path.insert(0, path) _CORE_INIT,
_BOOTSTRAPPED = True submodule_search_locations=[str(_CORE_DIR)],
)
if spec is None or spec.loader is None:
raise ImportError(f"could not load core from {_CORE_INIT}")
module = importlib.util.module_from_spec(spec)
sys.modules[_CORE_MODULE_NAME] = module
spec.loader.exec_module(module)
return module
def _ensure_core() -> ModuleType: #: The bundled core package, loaded by file location and registered as
"""Import ``core`` (bootstrapping first) and return the module object.""" #: ``nestquest_core`` in ``sys.modules``. Held here so callers import the
bootstrap_core() #: submodules through it (``nestquest_core.db``, ``nestquest_core.migrations``)
import core # type: ignore[import-not-found] # noqa: F401 #: the same relative way the integration does.
core: ModuleType = _load_core()
return core
#: The bundled core package, imported via the sys.path bootstrap. Hold a
#: reference here so callers import the submodules through it
#: (``core.db``, ``core.migrations``) the same way the integration does.
core: ModuleType = _ensure_core()
#: Re-exported for convenience; route handlers import these names from #: Re-exported for convenience; route handlers import these names from
#: here rather than reaching into ``core`` directly. #: here rather than reaching into the core package directly.
core_db = __import__("core.db", fromlist=["db"]) # type: ignore[import-not-found] core_db = importlib.import_module(f"{_CORE_MODULE_NAME}.db")
core_migrations = __import__( # type: ignore[import-not-found] core_migrations = importlib.import_module(f"{_CORE_MODULE_NAME}.migrations")
"core.migrations", fromlist=["migrations"]
)
__all__ = ["bootstrap_core", "core", "core_db", "core_migrations"] __all__ = ["core", "core_db", "core_migrations"]
+6
View File
@@ -7,6 +7,12 @@ requires-python = ">=3.11"
dependencies = [] dependencies = []
[dependency-groups] [dependency-groups]
# fastapi and uvicorn live in the DEV group only so the monorepo's test
# venv can exercise the API app (tests under tests/test_api_health.py).
# They are NOT runtime dependencies of the NestQuest HA integration.
# When the API deployable is packaged (Feature 17), its runtime
# dependencies — fastapi, uvicorn, and any others — MUST be declared in
# that deployable's own dependency manifest, not here.
dev = [ dev = [
"fastapi>=0.115", "fastapi>=0.115",
"httpx>=0.28", "httpx>=0.28",
+144 -32
View File
@@ -3,14 +3,19 @@
These tests start the NestQuest API app with httpx against the ASGI These tests start the NestQuest API app with httpx against the ASGI
app and assert the skeleton contract of Feature 16's first task: app and assert the skeleton contract of Feature 16's first task:
- ``GET /health`` returns 200 with a JSON status body (``status: ok`` - ``GET /health`` returns 200 with a JSON status body (``status: ok``)
plus the resolved ``db_path``); and does NOT leak the filesystem path;
- ``GET /docs`` (Swagger UI) is served, and ``GET /openapi.json`` - ``GET /docs`` (Swagger UI) is served, and ``GET /openapi.json``
returns the OpenAPI document; returns the OpenAPI document;
- the app wires core (opens the configured SQLite file and applies - the app wires core (opens the configured SQLite file and applies
migrations on startup via the lifespan); migrations on startup via the lifespan) and closes the connection on
- the test is hermetic — each test points ``NESTQUEST_DB_PATH`` at a shutdown (``connected is False`` afterwards);
fresh temp file. - ``ApiConfig.from_env`` raises when ``NESTQUEST_DB_PATH`` is missing or
empty, and returns the value when set;
- a startup failure (unwritable path / non-SQLite file) surfaces from
the lifespan rather than the first request;
- each test is hermetic — it points ``NESTQUEST_DB_PATH`` at a fresh
temp file.
A SEPARATE subprocess test asserts the stronger invariant the task A SEPARATE subprocess test asserts the stronger invariant the task
calls out: the app imports the bundled ``core`` WITHOUT importing calls out: the app imports the bundled ``core`` WITHOUT importing
@@ -22,7 +27,6 @@ before any test imports the integration; a subprocess with a clean
""" """
from __future__ import annotations from __future__ import annotations
import asyncio
import subprocess import subprocess
import sys import sys
import tempfile import tempfile
@@ -33,7 +37,8 @@ import httpx
import pytest import pytest
from api.app import create_app from api.app import create_app
from api.config import ApiConfig from api.config import ApiConfig, ConfigError
from api.database import DatabaseState, _executor, make_database
#: The repo root, so the no-HA subprocess can import the API package. #: The repo root, so the no-HA subprocess can import the API package.
_REPO_ROOT = Path(__file__).resolve().parent.parent _REPO_ROOT = Path(__file__).resolve().parent.parent
@@ -57,6 +62,10 @@ class _AppRunner:
session in it drives the same startup/shutdown path uvicorn would, session in it drives the same startup/shutdown path uvicorn would,
so ``open_and_migrate`` runs before the first request and ``close`` so ``open_and_migrate`` runs before the first request and ``close``
runs after the last. runs after the last.
``__aexit__`` uses try/finally so a client-side error still shuts
the lifespan down — otherwise a failure mid-test would leak the
database connection open.
""" """
def __init__(self, db_path: str) -> None: def __init__(self, db_path: str) -> None:
@@ -72,8 +81,10 @@ class _AppRunner:
return await self._client.__aenter__() return await self._client.__aenter__()
async def __aexit__(self, *exc) -> None: async def __aexit__(self, *exc) -> None:
await self._client.__aexit__(*exc) try:
await self._lifespan.__aexit__(*exc) await self._client.__aexit__(*exc)
finally:
await self._lifespan.__aexit__(*exc)
@pytest.fixture @pytest.fixture
@@ -82,19 +93,43 @@ async def app_client(temp_db_path: str) -> httpx.AsyncClient:
yield client yield client
# --- ApiConfig.from_env -------------------------------------------------
def test_from_env_returns_set_value() -> None:
"""A set, non-empty NESTQUEST_DB_PATH is returned verbatim."""
cfg = ApiConfig.from_env({"NESTQUEST_DB_PATH": "/data/nq.db"})
assert cfg.db_path == "/data/nq.db"
def test_from_env_raises_on_empty_value() -> None:
"""An empty/whitespace-only value raises ConfigError (no silent default)."""
with pytest.raises(ConfigError):
ApiConfig.from_env({"NESTQUEST_DB_PATH": " "})
def test_from_env_raises_on_missing_value() -> None:
"""A missing NESTQUEST_DB_PATH raises ConfigError (no silent default)."""
with pytest.raises(ConfigError):
ApiConfig.from_env({})
# --- health / openapi / docs -------------------------------------------
async def test_health_returns_200_with_status_body( async def test_health_returns_200_with_status_body(
app_client: httpx.AsyncClient, temp_db_path: str app_client: httpx.AsyncClient,
) -> None: ) -> None:
"""GET /health returns 200 with status ok and the resolved db path.""" """GET /health returns 200 with status ok and no filesystem path."""
response = await app_client.get("/health") response = await app_client.get("/health")
assert response.status_code == 200 assert response.status_code == 200
body = response.json() body = response.json()
assert body["status"] == "ok" assert body == {"status": "ok"}
assert body["db_path"] == temp_db_path assert "db_path" not in body
async def test_openapi_document_is_served( async def test_openapi_document_is_served(
app_client: httpx.AsyncClient app_client: httpx.AsyncClient,
) -> None: ) -> None:
"""GET /openapi.json returns the OpenAPI document.""" """GET /openapi.json returns the OpenAPI document."""
response = await app_client.get("/openapi.json") response = await app_client.get("/openapi.json")
@@ -111,8 +146,11 @@ async def test_docs_swagger_ui_is_served(app_client: httpx.AsyncClient) -> None:
assert "swagger" in response.text.lower() assert "swagger" in response.text.lower()
# --- core wiring & shutdown --------------------------------------------
async def test_app_wires_core_and_applies_migrations( async def test_app_wires_core_and_applies_migrations(
temp_db_path: str temp_db_path: str,
) -> None: ) -> None:
"""The lifespan opens the SQLite file and applies migrations. """The lifespan opens the SQLite file and applies migrations.
@@ -123,12 +161,10 @@ async def test_app_wires_core_and_applies_migrations(
async with _AppRunner(temp_db_path): async with _AppRunner(temp_db_path):
assert Path(temp_db_path).exists() assert Path(temp_db_path).exists()
# Re-open the file with a fresh core database to read the version # Re-open the file with a fresh core database to read the version
# stamp independent of the app's connection. # stamp independent of the app's connection. Reuse the API's own
# executor so the test exercises the same wiring the app does.
from api.nestquest_core import core_db, core_migrations from api.nestquest_core import core_db, core_migrations
async def _executor(fn, *args, **kwargs):
return await asyncio.to_thread(fn, *args, **kwargs)
database = core_db.NestQuestDatabase(_executor) database = core_db.NestQuestDatabase(_executor)
await database.open(temp_db_path) await database.open(temp_db_path)
try: try:
@@ -138,16 +174,71 @@ async def test_app_wires_core_and_applies_migrations(
assert version >= 1 assert version >= 1
async def test_shutdown_closes_the_connection(temp_db_path: str) -> None:
"""The lifespan closes the database connection on shutdown.
After the runner exits, ``DatabaseState.database.connected`` is
False — proving ``close()`` ran (the lifespan's finally block) even
on a clean exit.
"""
runner = _AppRunner(temp_db_path)
async with runner as _client:
state: DatabaseState = runner.app.state.db
assert state.database.connected
assert not state.database.connected
# --- startup failure ----------------------------------------------------
async def test_startup_fails_on_unwritable_path(tmp_path: Path) -> None:
"""A database path whose parent directory does not exist fails fast.
``sqlite3.connect`` raises ``OperationalError`` for a path under a
non-existent directory; the lifespan surfaces that as a startup
error rather than the first request. The connection is closed
through the lifespan's finally block (``close`` is a no-op on a
never-opened wrapper).
"""
bad_path = str(tmp_path / "does_not_exist" / "nestquest.db")
runner = _AppRunner(bad_path)
with pytest.raises(Exception):
async with runner:
pass
async def test_startup_fails_on_non_sqlite_file(tmp_path: Path) -> None:
"""A path pointing at a non-SQLite file fails fast on startup.
Opening an existing file that is not a SQLite database makes the
``PRAGMA journal_mode=WAL`` run during :meth:`open` raise
``DatabaseError: file is not a database``; the lifespan surfaces it.
"""
bad_file = tmp_path / "not_a_db.bin"
bad_file.write_bytes(b"this is not a sqlite database")
runner = _AppRunner(str(bad_file))
with pytest.raises(Exception):
async with runner:
pass
# --- no-HA subprocess ---------------------------------------------------
#: The no-HA subprocess script. Runs with ``sys.executable -c`` so it #: The no-HA subprocess script. Runs with ``sys.executable -c`` so it
#: starts with a clean ``sys.modules`` and no ``homeassistant`` installed #: starts with a clean ``sys.modules`` and no ``homeassistant`` installed
#: (the suite's conftest stubs are not present in a subprocess). It #: (the suite's conftest stubs are not present in a subprocess). It
#: builds the app — which triggers the core bootstrap — and asserts #: builds the app — which triggers the core bootstrap — and asserts
#: neither ``homeassistant`` nor ``custom_components`` entered #: neither ``homeassistant`` nor ``custom_components`` entered
#: ``sys.modules``. #: ``sys.modules``, and that the loaded core package's ``__file__``
#: points at the bundled directory.
_NO_HA_SCRIPT = textwrap.dedent( _NO_HA_SCRIPT = textwrap.dedent(
"""\ """\
import sys import sys
import tempfile
from pathlib import Path
sys.path.insert(0, sys.argv[1]) sys.path.insert(0, sys.argv[1])
REPO_ROOT = Path(sys.argv[1])
# homeassistant must not be present before we touch the API. # homeassistant must not be present before we touch the API.
assert "homeassistant" not in sys.modules, ( assert "homeassistant" not in sys.modules, (
@@ -157,25 +248,46 @@ _NO_HA_SCRIPT = textwrap.dedent(
from api.app import create_app from api.app import create_app
from api.config import ApiConfig from api.config import ApiConfig
from api.nestquest_core import core
app = create_app(ApiConfig(db_path=":memory:"))
# The app build imports core via api.nestquest_core. core is a # The app build imports core via api.nestquest_core. core is a
# Home-Assistant-free package; if it (or its bootstrap) ever drags # Home-Assistant-free package; if it (or its loader) ever drags HA
# HA in, this fails. # in, this fails.
assert "homeassistant" not in sys.modules, ( assert "homeassistant" not in sys.modules, (
"homeassistant imported while building the NestQuest API app — " "homeassistant imported while building the NestQuest API app — "
"the core wiring is no longer Home-Assistant-free" "the core wiring is no longer Home-Assistant-free"
) )
# The bootstrap must import core as a TOP-LEVEL package without # The loader registers core under the distinct name "nestquest_core"
# executing custom_components.nestquest.__init__, so the parent # WITHOUT executing custom_components.nestquest.__init__, so the
# integration package must never be registered. # parent integration package must never be registered, and no
# sys.path mutation places the integration directory on sys.path.
assert "custom_components" not in sys.modules, ( assert "custom_components" not in sys.modules, (
"custom_components registered in sys.modules — the core bootstrap " "custom_components registered in sys.modules — the core loader "
"executed the integration's HA-coupled __init__ instead of importing " "executed the integration's HA-coupled __init__ instead of loading "
"core as a top-level package" "core by file location"
) )
assert "core" in sys.modules, "core was not imported" assert "nestquest_core" in sys.modules, "core was not imported"
# core.__file__ must point at the bundled directory inside the
# integration, proving the file-location loader resolved against the
# bundled package rather than some other "core" on sys.path.
expected_core_dir = (
REPO_ROOT / "custom_components" / "nestquest" / "core"
)
assert Path(core.__file__).resolve() == (
expected_core_dir / "__init__.py"
).resolve(), (
f"core.__file__={core.__file__} does not point at the bundled "
f"directory {expected_core_dir}"
)
# Build the app (the lifespan is NOT run here, so the path is never
# opened — it just needs to be a non-empty string to satisfy the
# fail-fast config check). Use a temp path so nothing is created.
app = create_app(ApiConfig(db_path=str(
Path(tempfile.gettempdir()) / "nq-noha-buildonly.db"
)))
assert app is not None
print("API NO-HA IMPORT OK") print("API NO-HA IMPORT OK")
""" """
@@ -190,7 +302,7 @@ def test_app_imports_core_without_importing_homeassistant() -> None:
before any test runs, so an in-process check could never before any test runs, so an in-process check could never
distinguish "core never touched HA" from "conftest already stubbed distinguish "core never touched HA" from "conftest already stubbed
HA for us". The subprocess is a genuine no-HA process; if the HA for us". The subprocess is a genuine no-HA process; if the
core bootstrap (or core itself) ever grows a ``homeassistant`` core loader (or core itself) ever grows a ``homeassistant``
import, the subprocess exits non-zero with ``ModuleNotFoundError``. import, the subprocess exits non-zero with ``ModuleNotFoundError``.
""" """
result = subprocess.run( result = subprocess.run(