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:
+6
-4
@@ -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
@@ -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
@@ -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
@@ -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"]
|
||||||
|
|||||||
@@ -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
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user