From 48552ac62e1f11f38d5b95845e4ff285000f8894 Mon Sep 17 00:00:00 2001 From: Joshua Date: Mon, 21 Sep 2026 16:11:55 -0400 Subject: [PATCH] Address review findings on PR #147 (P2+P3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- api/__init__.py | 10 ++- api/app.py | 47 ++++++----- api/config.py | 47 ++++++----- api/nestquest_core.py | 113 +++++++++++++++---------- pyproject.toml | 6 ++ tests/test_api_health.py | 176 ++++++++++++++++++++++++++++++++------- 6 files changed, 278 insertions(+), 121 deletions(-) diff --git a/api/__init__.py b/api/__init__.py index 6ccbb0f..0e62779 100644 --- a/api/__init__.py +++ b/api/__init__.py @@ -9,9 +9,11 @@ This package imports the NestQuest domain ``core`` WITHOUT executing ``custom_components/nestquest/__init__.py`` — that parent package's ``__init__.py`` imports ``homeassistant``, which is absent from the API's runtime. The coupling is encapsulated in -:mod:`api.nestquest_core` (a ``sys.path`` bootstrap that makes ``core`` -importable as a top-level package). 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. +:mod:`api.nestquest_core`, which loads ``core`` by file location with +:mod:`importlib.util` and registers it under the distinct top-level name +``nestquest_core`` (no ``sys.path`` mutation, no generic top-level +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 diff --git a/api/app.py b/api/app.py index 5dace38..bb35c1c 100644 --- a/api/app.py +++ b/api/app.py @@ -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 exposes the two endpoints every later task depends on: -- ``GET /health`` — returns ``200`` with a JSON status body carrying - ``status: "ok"`` and the resolved database path, proving the app - imported core, opened the database, and applied migrations. +- ``GET /health`` — returns ``200`` with a JSON status body once the + app has 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); ``/openapi.json`` is the raw schema. No extra wiring is needed; 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 startup failure (e.g. an unwritable path) surfaces as a startup error 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 -import logging from contextlib import asynccontextmanager from collections.abc import AsyncIterator @@ -26,8 +36,6 @@ from fastapi import FastAPI from api.config import ApiConfig from api.database import DatabaseState, make_database -LOGGER = logging.getLogger(__name__) - def _build_lifespan(db_path: str): """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. ``config`` defaults to one built from the process environment, so - ``uvicorn api.app:app`` picks up ``NESTQUEST_DB_PATH``. Tests pass an - explicit config (or set the env var) so each test gets a hermetic - temp SQLite file. + ``uvicorn --factory api.app:create_app`` picks up + ``NESTQUEST_DB_PATH`` (and fails fast on startup if it is missing). + 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() @@ -72,21 +81,19 @@ def create_app(config: ApiConfig | None = None) -> FastAPI: @app.get("/health", summary="Service health") 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 - opened against — so a probe can confirm the app wired core and - reached the configured SQLite file. Returns 200 once the - lifespan has opened and migrated the database. + Returns ``200`` with ``{"status": "ok"}`` once the lifespan has + opened and migrated the database. A cheap ``SELECT 1`` against + the live connection confirms the database is reachable WITHOUT + 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 - return {"status": "ok", "db_path": state.db_path} + await state.database.fetch_one("SELECT 1") + return {"status": "ok"} return app -#: The module-level app instance, for ``uvicorn api.app:app``. -app: FastAPI = create_app() - - -__all__ = ["app", "create_app"] +__all__ = ["create_app"] diff --git a/api/config.py b/api/config.py index 2ae8843..6237abc 100644 --- a/api/config.py +++ b/api/config.py @@ -1,33 +1,35 @@ """Configuration for the NestQuest API service. The API reads its single configuration knob — the SQLite database path — -from the ``NESTQUEST_DB_PATH`` environment variable, with a documented -default inside the OS temp directory. This mirrors the integration's +from the ``NESTQUEST_DB_PATH`` environment variable. Unlike a dev +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 ``core.settings``): the API never reads HA config; callers pass the 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 import os -import tempfile from dataclasses import dataclass -from pathlib import Path #: Environment variable holding the SQLite file path. The API opens #: 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" -#: 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" - -def _default_db_path() -> str: - return str(Path(tempfile.gettempdir()) / _DEFAULT_DB_NAME) +class ConfigError(RuntimeError): + """Raised when required API configuration is missing or invalid.""" @dataclass(frozen=True) @@ -47,15 +49,20 @@ class ApiConfig: ``env`` defaults to ``os.environ``; tests pass an explicit dict 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 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: - """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"] +__all__ = ["ApiConfig", "ConfigError", "DB_PATH_ENV"] diff --git a/api/nestquest_core.py b/api/nestquest_core.py index f88428c..863cf34 100644 --- a/api/nestquest_core.py +++ b/api/nestquest_core.py @@ -10,11 +10,18 @@ API cannot start. ``core`` itself is a clean, Home-Assistant-free package: its ``__init__.py`` is a bare docstring and every internal import is -relative (``from .db import ...``). It can therefore be imported as a -top-level package if the integration directory is placed on -``sys.path``. This module does exactly that, in ONE documented place, -so the coupling between the API and the bundled core is explicit and -testable rather than scattered across route handlers. +relative (``from .db import ...``). It can therefore be imported by +file location as a standalone package. + +This module loads ``core`` with :func:`importlib.util.spec_from_file_location` +— 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:: @@ -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 integration bundle; once that lands, this bootstrap helper goes away and 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 +import importlib +import importlib.util import sys from pathlib import Path from types import ModuleType @@ -35,53 +55,56 @@ from types import ModuleType #: The repository root (this file is ``/api/nestquest_core.py``). _REPO_ROOT: Path = Path(__file__).resolve().parent.parent -#: The integration directory that contains the bundled ``core`` package. -#: Adding this to ``sys.path`` makes ``import core`` resolve to -#: ``custom_components/nestquest/core/`` WITHOUT executing the -#: integration's ``__init__.py`` (which would import ``homeassistant``). -_INTEGRATION_DIR: Path = _REPO_ROOT / "custom_components" / "nestquest" +#: The bundled ``core`` package directory, inside the HA integration. +_CORE_DIR: Path = _REPO_ROOT / "custom_components" / "nestquest" / "core" -_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: - """Make the bundled ``core`` package importable as a top-level package. +def _load_core() -> ModuleType: + """Load the bundled ``core`` package by file location and return it. - Inserts the integration directory at the front of ``sys.path`` (once, - idempotently). After this call, ``import core`` resolves to the - bundled ``custom_components/nestquest/core`` package, and its - relative internal imports (``from .db import ...``) resolve against - that same directory. The integration's HA-coupled ``__init__.py`` is - never executed: ``core`` is imported directly as a top-level package, - so ``custom_components.nestquest`` is never touched. + Registers the package under :data:`_CORE_MODULE_NAME` in + ``sys.modules`` with ``submodule_search_locations`` pointing at the + bundled directory, so its relative imports (``from .db import ...``) + resolve to ``nestquest_core.`` against that directory + without mutating ``sys.path``. Idempotent: a second call returns + the already-registered module. """ - global _BOOTSTRAPPED - if _BOOTSTRAPPED: - return - path = str(_INTEGRATION_DIR) - if path not in sys.path: - sys.path.insert(0, path) - _BOOTSTRAPPED = True + existing = sys.modules.get(_CORE_MODULE_NAME) + if existing is not None: + return existing + spec = importlib.util.spec_from_file_location( + _CORE_MODULE_NAME, + _CORE_INIT, + 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: - """Import ``core`` (bootstrapping first) and return the module object.""" - bootstrap_core() - import core # type: ignore[import-not-found] # noqa: F401 - - 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() +#: The bundled core package, loaded by file location and registered as +#: ``nestquest_core`` in ``sys.modules``. Held here so callers import the +#: submodules through it (``nestquest_core.db``, ``nestquest_core.migrations``) +#: the same relative way the integration does. +core: ModuleType = _load_core() #: Re-exported for convenience; route handlers import these names from -#: here rather than reaching into ``core`` directly. -core_db = __import__("core.db", fromlist=["db"]) # type: ignore[import-not-found] -core_migrations = __import__( # type: ignore[import-not-found] - "core.migrations", fromlist=["migrations"] -) +#: here rather than reaching into the core package directly. +core_db = importlib.import_module(f"{_CORE_MODULE_NAME}.db") +core_migrations = importlib.import_module(f"{_CORE_MODULE_NAME}.migrations") -__all__ = ["bootstrap_core", "core", "core_db", "core_migrations"] +__all__ = ["core", "core_db", "core_migrations"] diff --git a/pyproject.toml b/pyproject.toml index 65e7919..754ff79 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,6 +7,12 @@ requires-python = ">=3.11" dependencies = [] [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 = [ "fastapi>=0.115", "httpx>=0.28", diff --git a/tests/test_api_health.py b/tests/test_api_health.py index 2db7c85..f9f120e 100644 --- a/tests/test_api_health.py +++ b/tests/test_api_health.py @@ -3,14 +3,19 @@ These tests start the NestQuest API app with httpx against the ASGI app and assert the skeleton contract of Feature 16's first task: -- ``GET /health`` returns 200 with a JSON status body (``status: ok`` - plus the resolved ``db_path``); +- ``GET /health`` returns 200 with a JSON status body (``status: ok``) + and does NOT leak the filesystem path; - ``GET /docs`` (Swagger UI) is served, and ``GET /openapi.json`` returns the OpenAPI document; - the app wires core (opens the configured SQLite file and applies - migrations on startup via the lifespan); -- the test is hermetic — each test points ``NESTQUEST_DB_PATH`` at a - fresh temp file. + migrations on startup via the lifespan) and closes the connection on + shutdown (``connected is False`` afterwards); +- ``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 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 -import asyncio import subprocess import sys import tempfile @@ -33,7 +37,8 @@ import httpx import pytest 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. _REPO_ROOT = Path(__file__).resolve().parent.parent @@ -57,6 +62,10 @@ class _AppRunner: session in it drives the same startup/shutdown path uvicorn would, so ``open_and_migrate`` runs before the first request and ``close`` 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: @@ -72,8 +81,10 @@ class _AppRunner: return await self._client.__aenter__() async def __aexit__(self, *exc) -> None: - await self._client.__aexit__(*exc) - await self._lifespan.__aexit__(*exc) + try: + await self._client.__aexit__(*exc) + finally: + await self._lifespan.__aexit__(*exc) @pytest.fixture @@ -82,19 +93,43 @@ async def app_client(temp_db_path: str) -> httpx.AsyncClient: 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( - app_client: httpx.AsyncClient, temp_db_path: str + app_client: httpx.AsyncClient, ) -> 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") assert response.status_code == 200 body = response.json() - assert body["status"] == "ok" - assert body["db_path"] == temp_db_path + assert body == {"status": "ok"} + assert "db_path" not in body async def test_openapi_document_is_served( - app_client: httpx.AsyncClient + app_client: httpx.AsyncClient, ) -> None: """GET /openapi.json returns the OpenAPI document.""" 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() +# --- core wiring & shutdown -------------------------------------------- + + async def test_app_wires_core_and_applies_migrations( - temp_db_path: str + temp_db_path: str, ) -> None: """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): assert Path(temp_db_path).exists() # 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 - async def _executor(fn, *args, **kwargs): - return await asyncio.to_thread(fn, *args, **kwargs) - database = core_db.NestQuestDatabase(_executor) await database.open(temp_db_path) try: @@ -138,16 +174,71 @@ async def test_app_wires_core_and_applies_migrations( 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 #: starts with a clean ``sys.modules`` and no ``homeassistant`` installed #: (the suite's conftest stubs are not present in a subprocess). It #: builds the app — which triggers the core bootstrap — and asserts #: 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( """\ import sys + import tempfile + from pathlib import Path + sys.path.insert(0, sys.argv[1]) + REPO_ROOT = Path(sys.argv[1]) # homeassistant must not be present before we touch the API. assert "homeassistant" not in sys.modules, ( @@ -157,25 +248,46 @@ _NO_HA_SCRIPT = textwrap.dedent( from api.app import create_app from api.config import ApiConfig - - app = create_app(ApiConfig(db_path=":memory:")) + from api.nestquest_core import core # The app build imports core via api.nestquest_core. core is a - # Home-Assistant-free package; if it (or its bootstrap) ever drags - # HA in, this fails. + # Home-Assistant-free package; if it (or its loader) ever drags HA + # in, this fails. assert "homeassistant" not in sys.modules, ( "homeassistant imported while building the NestQuest API app — " "the core wiring is no longer Home-Assistant-free" ) - # The bootstrap must import core as a TOP-LEVEL package without - # executing custom_components.nestquest.__init__, so the parent - # integration package must never be registered. + # The loader registers core under the distinct name "nestquest_core" + # WITHOUT executing custom_components.nestquest.__init__, so the + # 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, ( - "custom_components registered in sys.modules — the core bootstrap " - "executed the integration's HA-coupled __init__ instead of importing " - "core as a top-level package" + "custom_components registered in sys.modules — the core loader " + "executed the integration's HA-coupled __init__ instead of loading " + "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") """ @@ -190,7 +302,7 @@ def test_app_imports_core_without_importing_homeassistant() -> None: before any test runs, so an in-process check could never distinguish "core never touched HA" from "conftest already stubbed 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``. """ result = subprocess.run(