The presence business operations (schedule upsert, override
create/delete, each followed by regenerate_for_child) lived only in the
HA-coupled service handlers, so the API admin plane could not reuse
them. Extract them into core/presence_management.py — model-validated
PresenceSchedule/PresenceOverride construction, the typed DAO calls
and the regeneration side effect in ONE Home-Assistant-free
implementation — and reduce the three HA service handlers to thin
wrappers over it.
Expose the admin plane's presence routes on the existing admin router
(PUT /children/{child_id}/presence-schedule, POST
/presence-overrides, DELETE /presence-overrides/{override_id}): the
body models carry only the shape, validation stays in the presence
model, and the error mapping stays narrow (unknown child/override 404,
rejected schedule/override 422). Tests drive all three routes as an
admin, prove the schedule upserts in place, an override is created and
then deleted, and the child's resolved presence (PresenceEngine)
changes accordingly.
179 lines
7.6 KiB
Python
179 lines
7.6 KiB
Python
"""Transitional coupling: import ``core`` without importing Home Assistant.
|
|
|
|
The NestQuest domain ``core`` package (Feature 15) is bundled INSIDE the
|
|
Home Assistant integration at ``custom_components/nestquest/core/``.
|
|
Importing it the obvious way — ``import custom_components.nestquest.core``
|
|
— would execute ``custom_components/nestquest/__init__.py``, which
|
|
imports ``homeassistant``. Home Assistant is NOT installed in the API
|
|
service's runtime, so that import raises ``ModuleNotFoundError`` and the
|
|
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 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::
|
|
|
|
from api.nestquest_core import core_db, core_migrations
|
|
# core_db.NestQuestDatabase, core_migrations.apply_migrations, ...
|
|
|
|
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
|
|
|
|
#: The repository root (this file is ``<root>/api/nestquest_core.py``).
|
|
_REPO_ROOT: Path = Path(__file__).resolve().parent.parent
|
|
|
|
#: The bundled ``core`` package directory, inside the HA integration.
|
|
_CORE_DIR: Path = _REPO_ROOT / "custom_components" / "nestquest" / "core"
|
|
|
|
#: 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 _load_core() -> ModuleType:
|
|
"""Load the bundled ``core`` package by file location and return it.
|
|
|
|
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.<submodule>`` against that directory
|
|
without mutating ``sys.path``. Idempotent: a second call returns
|
|
the already-registered module.
|
|
"""
|
|
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
|
|
|
|
|
|
#: 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 the core package directly.
|
|
core_db = importlib.import_module(f"{_CORE_MODULE_NAME}.db")
|
|
core_migrations = importlib.import_module(f"{_CORE_MODULE_NAME}.migrations")
|
|
|
|
#: The panel snapshot builder and its payload shaper
|
|
#: (``core.snapshot.build_snapshot`` / ``instance_payload``) — the pure
|
|
#: assembly the panel routes call.
|
|
core_snapshot = importlib.import_module(f"{_CORE_MODULE_NAME}.snapshot")
|
|
|
|
#: The completion service (``core.completion.complete_instance`` /
|
|
#: ``uncomplete_instance``) the panel's write routes call.
|
|
core_completion = importlib.import_module(f"{_CORE_MODULE_NAME}.completion")
|
|
|
|
#: The transition-event builders (``core.events.build_quest_completed_event``
|
|
#: et al.) the panel's write routes build their SSE events with.
|
|
core_events = importlib.import_module(f"{_CORE_MODULE_NAME}.events")
|
|
|
|
#: The explicit settings object (``core.settings.NestQuestSettings``)
|
|
#: the snapshot builder's ``settings`` parameter takes.
|
|
core_settings = importlib.import_module(f"{_CORE_MODULE_NAME}.settings")
|
|
|
|
#: The children business layer (``core.children.create_child`` et al.)
|
|
#: the admin plane's children routes call.
|
|
core_children = importlib.import_module(f"{_CORE_MODULE_NAME}.children")
|
|
|
|
#: The quest-definition business layer (``core.quest_definitions`` —
|
|
#: ``create_quest_definition``, ``edit_quest_definition``,
|
|
#: ``set_quest_definition_active``) the admin plane's quest-definition
|
|
#: routes call.
|
|
core_quest_definitions = importlib.import_module(
|
|
f"{_CORE_MODULE_NAME}.quest_definitions"
|
|
)
|
|
|
|
#: The recurrence model (``core.recurrence.ScheduleRule`` and
|
|
#: ``RuleValidationError``) the quest-definition routes build request
|
|
#: rules with — the model validates at construction, the routes never
|
|
#: re-implement rule validation.
|
|
core_recurrence = importlib.import_module(f"{_CORE_MODULE_NAME}.recurrence")
|
|
|
|
#: The presence model (``core.presence.PresenceEngine``,
|
|
#: ``PresenceSchedule``, ``PresenceOverride``) the admin plane's
|
|
#: presence routes serialize and the tests resolve presence with.
|
|
core_presence = importlib.import_module(f"{_CORE_MODULE_NAME}.presence")
|
|
|
|
#: The presence-write business layer
|
|
#: (``core.presence_management.set_presence_schedule`` et al.) the
|
|
#: admin plane's presence routes call — the ONE implementation the HA
|
|
#: services share.
|
|
core_presence_management = importlib.import_module(
|
|
f"{_CORE_MODULE_NAME}.presence_management"
|
|
)
|
|
|
|
#: The typed presence DAOs (``core.dao_presence``) the admin-plane tests
|
|
#: read stored schedules and overrides back through.
|
|
core_dao_presence = importlib.import_module(
|
|
f"{_CORE_MODULE_NAME}.dao_presence"
|
|
)
|
|
|
|
__all__ = [
|
|
"core",
|
|
"core_children",
|
|
"core_completion",
|
|
"core_dao_presence",
|
|
"core_db",
|
|
"core_events",
|
|
"core_migrations",
|
|
"core_presence",
|
|
"core_presence_management",
|
|
"core_quest_definitions",
|
|
"core_recurrence",
|
|
"core_snapshot",
|
|
"core_settings",
|
|
]
|