Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77fdd2531c | ||
|
|
08cb31ce4c |
@@ -1,19 +0,0 @@
|
||||
"""NestQuest API service.
|
||||
|
||||
A standalone FastAPI deployable that owns the NestQuest database and
|
||||
exposes it over HTTP to the Home Assistant integration (panel plane) and
|
||||
the admin PWA (admin plane). See Feature 16 (task cd03735b) in the
|
||||
project tracker for the full contract.
|
||||
|
||||
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`, 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
|
||||
-128
@@ -1,128 +0,0 @@
|
||||
"""The NestQuest FastAPI application.
|
||||
|
||||
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 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 /api/v1/panel/snapshot`` — the panel plane's today snapshot
|
||||
(service-token protected; see :mod:`api.routes_panel`, whose router
|
||||
carries the shared :func:`api.dependencies.require_panel_token`
|
||||
check).
|
||||
- ``GET /api/v1/admin/ping`` — the admin plane's auth probe, protected
|
||||
by the Authentik OIDC JWT check (see :mod:`api.routes_admin`, whose
|
||||
router carries the shared :func:`api.auth.require_admin_jwt`
|
||||
dependency; the JWKS document it verifies against is cached on
|
||||
``app.state.jwks_cache``).
|
||||
- ``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.
|
||||
|
||||
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
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from api.auth import JwksCache
|
||||
from api.config import ApiConfig
|
||||
from api.database import DatabaseState, make_database
|
||||
from api.events import TransitionPublisher
|
||||
from api.routes_admin import router as admin_router
|
||||
from api.routes_panel import router as panel_router
|
||||
|
||||
|
||||
def _build_lifespan(db_path: str):
|
||||
"""Return a lifespan that opens+migrates then closes one database.
|
||||
|
||||
Captured as a closure so the app factory can hand the configured
|
||||
path in without threading it through FastAPI's lifespan signature.
|
||||
The lifespan also installs the app's ONE in-process transition
|
||||
publisher on ``app.state.publisher`` (see :mod:`api.events`) so
|
||||
every route and SSE subscriber shares that single instance.
|
||||
"""
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
state = make_database(db_path)
|
||||
app.state.db = state
|
||||
app.state.publisher = TransitionPublisher()
|
||||
try:
|
||||
await state.open_and_migrate()
|
||||
yield
|
||||
finally:
|
||||
await state.close()
|
||||
|
||||
return lifespan
|
||||
|
||||
|
||||
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 --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()
|
||||
|
||||
app = FastAPI(
|
||||
title="NestQuest API",
|
||||
description=(
|
||||
"Standalone HTTP service that owns the NestQuest database and "
|
||||
"exposes it to the Home Assistant integration (panel plane) and "
|
||||
"the admin PWA (admin plane)."
|
||||
),
|
||||
version="0.1.0",
|
||||
lifespan=_build_lifespan(cfg.db_path),
|
||||
)
|
||||
|
||||
# The resolved config rides on app.state so the reusable route
|
||||
# dependencies (the panel service-token check, the admin JWT check)
|
||||
# read the configured values from the app rather than module state.
|
||||
app.state.config = cfg
|
||||
# The admin plane's ONE per-app JWKS cache: fetched from the
|
||||
# configured provider on first use, then reused until its TTL
|
||||
# lapses (see api.auth.JwksCache). Tests replace it with a
|
||||
# stub-backed cache so no request ever leaves the process.
|
||||
app.state.jwks_cache = JwksCache(cfg.oidc_jwks_url)
|
||||
app.include_router(panel_router)
|
||||
app.include_router(admin_router)
|
||||
|
||||
@app.get("/health", summary="Service health")
|
||||
async def health() -> dict[str, str]:
|
||||
"""Return service health.
|
||||
|
||||
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
|
||||
await state.database.fetch_one("SELECT 1")
|
||||
return {"status": "ok"}
|
||||
|
||||
return app
|
||||
|
||||
|
||||
__all__ = ["create_app"]
|
||||
-406
@@ -1,406 +0,0 @@
|
||||
"""Admin-plane authentication for the NestQuest API service.
|
||||
|
||||
The admin plane (``/api/v1/admin/*``, D-012) is authenticated by
|
||||
Authentik-issued OIDC access tokens: the admin PWA presents its JWT as
|
||||
a Bearer credential and :func:`require_admin_jwt` — the ONE
|
||||
implementation of that check — verifies it before any admin route runs.
|
||||
Verification enforces, without exception:
|
||||
|
||||
- the signature, against the RSA signing key published in the
|
||||
provider's JWKS document (fetched from the configured
|
||||
``NESTQUEST_OIDC_JWKS_URL`` and cached per app — never per request);
|
||||
- the issuer (``iss`` must equal the configured
|
||||
``NESTQUEST_OIDC_ISSUER``);
|
||||
- the audience (``aud`` must carry the configured
|
||||
``NESTQUEST_OIDC_AUDIENCE``);
|
||||
- expiry (``exp`` is required and enforced, and ``nbf`` is enforced
|
||||
when the token carries it).
|
||||
|
||||
Every rejection raises the SAME :class:`~fastapi.HTTPException` with
|
||||
the same detail — nothing in the response distinguishes which check
|
||||
failed. The check FAILS CLOSED: a missing app config, a malformed or
|
||||
missing ``Authorization`` header, a non-RS256 algorithm, an unknown
|
||||
``kid``, and any PyJWT verification error all yield the one 401.
|
||||
|
||||
The token's header (``alg``, ``kid``) is parsed and checked BEFORE the
|
||||
JWKS cache is consulted, so structurally invalid tokens can never
|
||||
amplify load on the provider. An unknown ``kid`` forces ONE
|
||||
TTL-bypassing refetch (throttled, see :class:`JwksCache`) so a rotated
|
||||
key is picked up without waiting out the TTL, and a JWKS entry that
|
||||
matches the ``kid`` but carries corrupt key material maps to the same
|
||||
401. A JWKS FETCH failure is deliberately NOT mapped to 401: the
|
||||
provider being unreachable is a server-side fault (an HTTP 500
|
||||
surfaces) and never poisons the cache, so the plane stays closed while
|
||||
the real cause is visible in the server log instead of disguised as a
|
||||
bad credential; a short backoff window after a failed fetch fails fast
|
||||
(re-raising the recorded fault) rather than fetching once per request.
|
||||
|
||||
This module validates the TOKEN only — it is authentication, not
|
||||
authorization. Membership in the ``nestquest-admins`` group is the
|
||||
NEXT task, which extends :func:`require_admin_jwt` (the verified
|
||||
claims are already its return value, so no rewrite is needed).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import binascii
|
||||
import json
|
||||
import time
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Annotated
|
||||
|
||||
import httpx
|
||||
import jwt
|
||||
from fastapi import Header, HTTPException, Request
|
||||
|
||||
#: The authorization scheme the admin plane expects. Matched
|
||||
#: case-insensitively, as RFC 9110 requires for scheme names.
|
||||
_BEARER_SCHEME = "bearer"
|
||||
|
||||
#: The ONLY JWT signing algorithm accepted. Pinned to one asymmetric
|
||||
#: algorithm (Authentik's RS256 default) so neither the header's ``alg``
|
||||
#: nor any JWKS key can talk the verifier into a weaker or symmetric
|
||||
#: scheme; PyJWT re-enforces it at decode time.
|
||||
_ALLOWED_ALGORITHM = "RS256"
|
||||
|
||||
#: The 401 detail shared by every rejection — deliberately uniform so
|
||||
#: the response never reveals which part of the check failed.
|
||||
_REJECTION_DETAIL = "Missing or invalid admin credentials"
|
||||
|
||||
#: Challenge header returned with a 401, per the Bearer auth scheme.
|
||||
_WWW_AUTHENTICATE = {"WWW-Authenticate": "Bearer"}
|
||||
|
||||
#: Seconds a fetched JWKS document stays trusted before the next
|
||||
#: fetch. Five minutes bounds how long a rotated-away key keeps
|
||||
#: verifying while staying far above a per-request fetch.
|
||||
_JWKS_TTL_SECONDS = 300.0
|
||||
|
||||
#: Seconds a FAILED JWKS fetch blocks further fetch attempts. Within
|
||||
#: this window after a failure the cache fails fast — it re-raises the
|
||||
#: recorded fault instead of touching the provider — so an outage is
|
||||
#: never amplified into one fetch per request.
|
||||
_JWKS_BACKOFF_SECONDS = 5.0
|
||||
|
||||
#: Seconds that must pass before ANOTHER forced (TTL-bypassing) refetch
|
||||
#: may run. An unmatched ``kid`` forces one refetch to pick up rotated
|
||||
#: keys; this interval bounds it, so a flood of unknown-``kid`` tokens
|
||||
#: cannot turn each request into a provider fetch either.
|
||||
_JWKS_KID_REFETCH_SECONDS = 5.0
|
||||
|
||||
#: Seconds before the real JWKS fetch gives up on the provider.
|
||||
_JWKS_TIMEOUT_SECONDS = 5.0
|
||||
|
||||
#: An async callable that fetches the JWKS document from a URL. The
|
||||
#: real one (:func:`_fetch_jwks`) uses httpx; tests inject a stub so
|
||||
#: the suite stays hermetic (no network access).
|
||||
JwksFetcher = Callable[[str], Awaitable[dict[str, object]]]
|
||||
|
||||
|
||||
async def _fetch_jwks(url: str) -> dict[str, object]:
|
||||
"""Fetch the provider's JWKS document over HTTP (the real fetch).
|
||||
|
||||
Uses httpx with a short timeout; a non-2xx response or a body that
|
||||
is not a ``{"keys": [...]}`` object raises — the cache never stores
|
||||
a failed or malformed fetch.
|
||||
"""
|
||||
async with httpx.AsyncClient(timeout=_JWKS_TIMEOUT_SECONDS) as client:
|
||||
response = await client.get(url)
|
||||
response.raise_for_status()
|
||||
document = response.json()
|
||||
if not isinstance(document, dict) or not isinstance(
|
||||
document.get("keys"), list
|
||||
):
|
||||
raise ValueError("JWKS document must be an object with a keys list")
|
||||
return document
|
||||
|
||||
|
||||
class JwksCache:
|
||||
"""One app's cache of the OIDC provider's JWKS document.
|
||||
|
||||
The document is fetched through ``fetcher`` (the real one uses
|
||||
httpx against the configured JWKS URL) and held for ``ttl_seconds``
|
||||
— the dependency NEVER fetches per request. A single fetch is
|
||||
shared between concurrent requests via an :class:`asyncio.Lock`
|
||||
(double-checked around it), and a failed fetch is never cached, so
|
||||
the next request retries once the backoff window lapses rather than
|
||||
being locked out until the TTL does.
|
||||
|
||||
Two bounds keep the provider out of an amplification loop:
|
||||
|
||||
- ``backoff_seconds``: after a FAILED fetch, :meth:`get` fails fast
|
||||
for this long — re-raising the recorded fault WITHOUT touching
|
||||
the fetcher or even the lock — instead of retrying per request.
|
||||
- ``kid_refetch_seconds``: forced (TTL-bypassing) refetches — the
|
||||
unmatched-``kid`` path — may run at most this often; a forced
|
||||
refetch that is bounded out (or one inside the backoff window)
|
||||
is served from the cached document as-is.
|
||||
|
||||
A successful fetch always clears the backoff; the document itself
|
||||
is still trusted only for ``ttl_seconds`` — nothing is cached
|
||||
indefinitely.
|
||||
|
||||
One instance lives on ``app.state.jwks_cache`` (installed by
|
||||
:func:`api.app.create_app`); tests replace it with one built around
|
||||
a stub fetcher, keeping the suite hermetic.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
url: str,
|
||||
*,
|
||||
ttl_seconds: float = _JWKS_TTL_SECONDS,
|
||||
backoff_seconds: float = _JWKS_BACKOFF_SECONDS,
|
||||
kid_refetch_seconds: float = _JWKS_KID_REFETCH_SECONDS,
|
||||
fetcher: JwksFetcher | None = None,
|
||||
) -> None:
|
||||
self._url = url
|
||||
self._ttl_seconds = ttl_seconds
|
||||
self._backoff_seconds = backoff_seconds
|
||||
self._kid_refetch_seconds = kid_refetch_seconds
|
||||
self._fetcher: JwksFetcher = (
|
||||
fetcher if fetcher is not None else _fetch_jwks
|
||||
)
|
||||
self._document: dict[str, object] | None = None
|
||||
self._fetched_at: float | None = None
|
||||
self._failed_until: float | None = None
|
||||
self._last_error: BaseException | None = None
|
||||
self._forced_at: float | None = None
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def get(self, *, force: bool = False) -> dict[str, object]:
|
||||
"""Return the JWKS document, refetching it if the TTL lapsed.
|
||||
|
||||
With ``force=True`` the TTL is bypassed so an unmatched ``kid``
|
||||
can pick up a rotated key without waiting it out; the forced
|
||||
refetch is itself bounded (see the class docstring): inside the
|
||||
backoff window or the ``kid_refetch_seconds`` interval the
|
||||
cached document is served as-is instead. A failed fetch is
|
||||
re-raised to the caller (a provider outage is a server fault,
|
||||
never a 401) and starts the backoff window, during which every
|
||||
caller — including forced ones with nothing cached — fails fast
|
||||
on the recorded fault.
|
||||
"""
|
||||
if not force:
|
||||
fresh = self._fresh_document()
|
||||
if fresh is not None:
|
||||
return fresh
|
||||
error = self._backoff_error()
|
||||
if error is not None:
|
||||
# Fail fast OUTSIDE the lock: a request inside the backoff
|
||||
# window must not queue behind an in-flight fetch.
|
||||
if force:
|
||||
document = self._document
|
||||
if document is not None:
|
||||
return document
|
||||
raise error
|
||||
async with self._lock:
|
||||
if not force:
|
||||
fresh = self._fresh_document()
|
||||
if fresh is not None:
|
||||
return fresh
|
||||
error = self._backoff_error()
|
||||
if error is not None:
|
||||
if force:
|
||||
document = self._document
|
||||
if document is not None:
|
||||
return document
|
||||
raise error
|
||||
if force and self._refetch_throttled():
|
||||
document = self._document
|
||||
if document is not None:
|
||||
return document
|
||||
return await self._fetch_locked(force=force)
|
||||
|
||||
def _fresh_document(self) -> dict[str, object] | None:
|
||||
"""The cached document if one exists and is inside its TTL."""
|
||||
document = self._document
|
||||
fetched_at = self._fetched_at
|
||||
if (
|
||||
document is not None
|
||||
and fetched_at is not None
|
||||
and (time.monotonic() - fetched_at) < self._ttl_seconds
|
||||
):
|
||||
return document
|
||||
return None
|
||||
|
||||
def _backoff_error(self) -> BaseException | None:
|
||||
"""The recorded fetch fault while the backoff window is open."""
|
||||
failed_until = self._failed_until
|
||||
last_error = self._last_error
|
||||
if (
|
||||
failed_until is not None
|
||||
and last_error is not None
|
||||
and time.monotonic() < failed_until
|
||||
):
|
||||
return last_error
|
||||
return None
|
||||
|
||||
def _refetch_throttled(self) -> bool:
|
||||
"""Whether a forced refetch ran too recently to run another."""
|
||||
forced_at = self._forced_at
|
||||
return forced_at is not None and (
|
||||
time.monotonic() - forced_at
|
||||
) < self._kid_refetch_seconds
|
||||
|
||||
async def _fetch_locked(self, *, force: bool) -> dict[str, object]:
|
||||
"""Run ONE fetch attempt (caller holds the lock) and record it.
|
||||
|
||||
A failed fetch starts the ``backoff_seconds`` window and is
|
||||
re-raised; a successful one stores the document, restarts the
|
||||
TTL and clears the backoff. A forced attempt stamps
|
||||
``_forced_at`` so the ``kid_refetch_seconds`` interval bounds
|
||||
how often forced refetches may run back-to-back.
|
||||
"""
|
||||
if force:
|
||||
self._forced_at = time.monotonic()
|
||||
try:
|
||||
document = await self._fetcher(self._url)
|
||||
except Exception as error:
|
||||
self._failed_until = time.monotonic() + self._backoff_seconds
|
||||
self._last_error = error
|
||||
raise
|
||||
self._document = document
|
||||
self._fetched_at = time.monotonic()
|
||||
self._failed_until = None
|
||||
self._last_error = None
|
||||
return document
|
||||
|
||||
|
||||
def _reject() -> HTTPException:
|
||||
"""Build the single 401 the dependency raises for every failure."""
|
||||
return HTTPException(
|
||||
status_code=401,
|
||||
detail=_REJECTION_DETAIL,
|
||||
headers=_WWW_AUTHENTICATE,
|
||||
)
|
||||
|
||||
|
||||
def _matching_key(jwks: dict[str, object], kid: str) -> object | None:
|
||||
"""Return the RSA signing key named ``kid`` from the JWKS.
|
||||
|
||||
Only ``kty: RSA`` entries are considered (the pinned RS256 scheme
|
||||
is asymmetric RSA); anything else — a missing ``keys`` list, no
|
||||
entry with that ``kid`` — returns ``None`` rather than falling back
|
||||
to "some" key. The key is built through PyJWT's JWKS decoder, and
|
||||
an entry that MATCHES the ``kid`` but carries corrupt key material
|
||||
(a malformed ``n``/``e`` raises ``ValueError`` — ``binascii.Error``
|
||||
is its subclass — or ``TypeError`` instead of a ``PyJWTError``) is
|
||||
mapped to the one 401 like every other rejection: the caller must
|
||||
never see a half-built key.
|
||||
"""
|
||||
keys = jwks.get("keys")
|
||||
if not isinstance(keys, list):
|
||||
raise _reject()
|
||||
for entry in keys:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
if entry.get("kid") != kid or entry.get("kty") != "RSA":
|
||||
continue
|
||||
try:
|
||||
return jwt.algorithms.RSAAlgorithm.from_jwk(json.dumps(entry))
|
||||
except (
|
||||
jwt.PyJWTError,
|
||||
binascii.Error,
|
||||
ValueError,
|
||||
TypeError,
|
||||
KeyError,
|
||||
):
|
||||
raise _reject() from None
|
||||
return None
|
||||
|
||||
|
||||
async def verify_admin_token(
|
||||
request: Request, token: str
|
||||
) -> dict[str, object]:
|
||||
"""Verify ONE admin bearer token and return its verified claims.
|
||||
|
||||
Reads the OIDC settings off ``app.state.config`` and the JWKS cache
|
||||
off ``app.state.jwks_cache`` (both installed by
|
||||
:func:`api.app.create_app`) so the check carries no module state.
|
||||
A missing config or cache, an EMPTY configured issuer/audience/JWKS
|
||||
URL, a non-RS256 ``alg`` header, an absent ``kid``, an unknown
|
||||
``kid``, and any PyJWT verification failure (bad signature,
|
||||
expired, wrong issuer, wrong audience, not-yet-valid, missing
|
||||
``exp``) raise the SAME 401 — the failure mode is never leaked.
|
||||
``exp`` is REQUIRED (a token that never expires is never accepted)
|
||||
and PyJWT enforces ``nbf`` when the token carries it.
|
||||
|
||||
The token's HEADER (``alg``, ``kid``) is parsed and enforced BEFORE
|
||||
the JWKS cache is consulted, so a structurally invalid token costs
|
||||
no fetch, no lock, no provider load. When the cached document has
|
||||
no entry for the ``kid``, ONE forced (TTL-bypassing, throttled)
|
||||
refetch is made and the match retried once — a rotated key is
|
||||
picked up without waiting out the TTL — before the 401.
|
||||
|
||||
A JWKS fetch failure propagates (see the module docstring): the
|
||||
provider being down is a server fault, not a bad credential.
|
||||
"""
|
||||
config = getattr(request.app.state, "config", None)
|
||||
if (
|
||||
config is None
|
||||
or not config.oidc_issuer
|
||||
or not config.oidc_audience
|
||||
or not config.oidc_jwks_url
|
||||
):
|
||||
raise _reject()
|
||||
cache = getattr(request.app.state, "jwks_cache", None)
|
||||
if cache is None:
|
||||
raise _reject()
|
||||
try:
|
||||
header = jwt.get_unverified_header(token)
|
||||
except jwt.PyJWTError:
|
||||
raise _reject() from None
|
||||
if header.get("alg") != _ALLOWED_ALGORITHM:
|
||||
raise _reject()
|
||||
kid = header.get("kid")
|
||||
if not isinstance(kid, str) or not kid:
|
||||
raise _reject()
|
||||
jwks = await cache.get()
|
||||
try:
|
||||
key = _matching_key(jwks, kid)
|
||||
if key is None:
|
||||
# The kid is not in the cached document: force ONE
|
||||
# TTL-bypassing refetch (bounded by the cache) and retry
|
||||
# the match once before rejecting.
|
||||
jwks = await cache.get(force=True)
|
||||
key = _matching_key(jwks, kid)
|
||||
if key is None:
|
||||
raise _reject()
|
||||
claims = jwt.decode(
|
||||
token,
|
||||
key=key,
|
||||
algorithms=[_ALLOWED_ALGORITHM],
|
||||
issuer=config.oidc_issuer,
|
||||
audience=config.oidc_audience,
|
||||
options={"require": ["exp"]},
|
||||
)
|
||||
except jwt.PyJWTError:
|
||||
raise _reject() from None
|
||||
return claims
|
||||
|
||||
|
||||
async def require_admin_jwt(
|
||||
request: Request,
|
||||
authorization: Annotated[str | None, Header()] = None,
|
||||
) -> dict[str, object]:
|
||||
"""Reject the request with 401 unless it carries a valid admin JWT.
|
||||
|
||||
Extracts the Bearer credential — a missing or malformed
|
||||
``Authorization`` header (wrong scheme, empty credential) raises
|
||||
the one 401 — and delegates the verification to
|
||||
:func:`verify_admin_token`, returning the verified claims for the
|
||||
route to use (the probe route surfaces the token's ``sub``).
|
||||
|
||||
The nestquest-admins GROUP check is the NEXT task: it extends this
|
||||
dependency (e.g. by inspecting the returned claims) so every admin
|
||||
route inherits it — the 401 contract stays exactly this uniform.
|
||||
"""
|
||||
if authorization is None:
|
||||
raise _reject()
|
||||
scheme, _, credential = authorization.partition(" ")
|
||||
if scheme.lower() != _BEARER_SCHEME or not credential.strip():
|
||||
raise _reject()
|
||||
return await verify_admin_token(request, credential.strip())
|
||||
|
||||
|
||||
__all__ = ["JwksCache", "JwksFetcher", "require_admin_jwt", "verify_admin_token"]
|
||||
-174
@@ -1,174 +0,0 @@
|
||||
"""Configuration for the NestQuest API service.
|
||||
|
||||
The API reads its configuration knobs from environment variables:
|
||||
|
||||
- ``NESTQUEST_DB_PATH`` — the SQLite database file. The API opens this
|
||||
file on startup (creating it if absent) and applies migrations.
|
||||
- ``NESTQUEST_PANEL_TOKEN`` — the static panel-plane service token
|
||||
(D-012): the credential ``/api/v1/panel/*`` routes require via
|
||||
:func:`api.dependencies.require_panel_token`. It is stored outside
|
||||
the repo and handed to the service through the environment.
|
||||
- ``NESTQUEST_OIDC_ISSUER`` — the Authentik OIDC issuer URL whose
|
||||
``iss`` claim admin tokens must carry (D-012 admin plane).
|
||||
- ``NESTQUEST_OIDC_AUDIENCE`` — the OIDC client id the admin tokens
|
||||
must carry as their ``aud`` claim (the API service's client id).
|
||||
- ``NESTQUEST_OIDC_JWKS_URL`` — the Authentik endpoint serving the
|
||||
signing keys (the JWKS document) the admin tokens' signatures are
|
||||
verified against. Cached per app (see :class:`api.auth.JwksCache`).
|
||||
|
||||
Unlike a dev convenience default, a missing or empty value is a
|
||||
configuration ERROR, not a silent fallback: a default database path
|
||||
would silently drop data at reboot, a default (or empty) service token
|
||||
would either authenticate everyone or lock the panel plane out for
|
||||
good, and a default (or empty) OIDC setting would silently leave the
|
||||
admin plane trusting an issuer that was never configured. All five
|
||||
variables are therefore REQUIRED — ``ApiConfig.from_env`` RAISES when
|
||||
any of them 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 values into
|
||||
the 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
|
||||
from dataclasses import dataclass
|
||||
|
||||
#: 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"
|
||||
|
||||
#: Environment variable holding the static panel-plane service token
|
||||
#: (D-012), checked by :func:`api.dependencies.require_panel_token`.
|
||||
#: REQUIRED — unset or empty raises :class:`ConfigError` from
|
||||
#: :meth:`ApiConfig.from_env`; there is no silent default, because an
|
||||
#: empty configured token would either authenticate every caller or
|
||||
#: lock the panel plane out permanently.
|
||||
PANEL_TOKEN_ENV = "NESTQUEST_PANEL_TOKEN"
|
||||
|
||||
#: Environment variable holding the Authentik OIDC issuer URL (D-012
|
||||
#: admin plane): every admin JWT's ``iss`` claim must equal it. Checked
|
||||
#: by :func:`api.auth.require_admin_jwt`. REQUIRED — unset or empty
|
||||
#: raises :class:`ConfigError` from :meth:`ApiConfig.from_env`; a default
|
||||
#: issuer would silently make the admin plane trust tokens from an
|
||||
#: issuer nobody configured.
|
||||
OIDC_ISSUER_ENV = "NESTQUEST_OIDC_ISSUER"
|
||||
|
||||
#: Environment variable holding the OIDC audience (the API service's
|
||||
#: client id at Authentik): every admin JWT's ``aud`` claim must carry
|
||||
#: it. Checked by :func:`api.auth.require_admin_jwt`. REQUIRED — unset
|
||||
#: or empty raises :class:`ConfigError` from :meth:`ApiConfig.from_env`.
|
||||
OIDC_AUDIENCE_ENV = "NESTQUEST_OIDC_AUDIENCE"
|
||||
|
||||
#: Environment variable holding the Authentik JWKS URL: the endpoint
|
||||
#: serving the signing keys admin JWT signatures are verified against.
|
||||
#: Fetched and cached by :class:`api.auth.JwksCache`. REQUIRED — unset
|
||||
#: or empty raises :class:`ConfigError` from :meth:`ApiConfig.from_env`;
|
||||
#: without the provider's keys no token can be verified, so a silent
|
||||
#: default would only move the failure to request time.
|
||||
OIDC_JWKS_URL_ENV = "NESTQUEST_OIDC_JWKS_URL"
|
||||
|
||||
|
||||
class ConfigError(RuntimeError):
|
||||
"""Raised when required API configuration is missing or invalid."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ApiConfig:
|
||||
"""Resolved API configuration.
|
||||
|
||||
Every field is required (no defaults): ``from_env`` rejects an
|
||||
unset or empty value for each, and a caller building the dataclass
|
||||
directly must pass explicit values — the token check and the JWT
|
||||
checks themselves fail closed on empty configured values, but
|
||||
expecting a caller to discover that at request time is a wiring
|
||||
mistake, so every field is mandatory. Kept as a dataclass so later
|
||||
tasks (SSE, settings persistence) can extend it without touching
|
||||
the call sites that read ``db_path``.
|
||||
"""
|
||||
|
||||
db_path: str
|
||||
panel_token: str
|
||||
#: The Authentik OIDC issuer URL admin tokens' ``iss`` must equal.
|
||||
oidc_issuer: str
|
||||
#: The OIDC audience (the API service's client id) admin tokens'
|
||||
#: ``aud`` must carry.
|
||||
oidc_audience: str
|
||||
#: The Authentik JWKS URL the signing keys are fetched from.
|
||||
oidc_jwks_url: str
|
||||
|
||||
@classmethod
|
||||
def from_env(cls, env: dict[str, str] | None = None) -> "ApiConfig":
|
||||
"""Build configuration from the process environment.
|
||||
|
||||
``env`` defaults to ``os.environ``; tests pass an explicit dict
|
||||
so they are hermetic (no monkeypatch of the global environ).
|
||||
|
||||
Raises :class:`ConfigError` when ANY of the five variables is
|
||||
unset or empty/whitespace-only — there is no silent default:
|
||||
|
||||
- ``NESTQUEST_DB_PATH``: a default in the temp directory would
|
||||
silently lose data at reboot;
|
||||
- ``NESTQUEST_PANEL_TOKEN``: a default token is never invented —
|
||||
the panel plane is closed until a real one is configured;
|
||||
- the three OIDC variables: an unconfigured issuer/audience/JWKS
|
||||
URL must not silently leave the admin plane open (a default
|
||||
JWKS URL or issuer would point at a provider nobody set up).
|
||||
"""
|
||||
source = env if env is not None else os.environ
|
||||
|
||||
def require(env_var: str, reason: str) -> str:
|
||||
"""Return the stripped env value or raise ConfigError."""
|
||||
value = source.get(env_var, "").strip()
|
||||
if not value:
|
||||
raise ConfigError(f"{env_var} {reason}")
|
||||
return value
|
||||
|
||||
return cls(
|
||||
db_path=require(
|
||||
DB_PATH_ENV,
|
||||
"must be set to a writable SQLite file path (it is unset "
|
||||
"or empty — the API will not fall back to a temp directory "
|
||||
"and silently lose data)",
|
||||
),
|
||||
panel_token=require(
|
||||
PANEL_TOKEN_ENV,
|
||||
"must be set to the panel service token (it is unset or "
|
||||
"empty — the API will not start without one, and no "
|
||||
"default token is invented)",
|
||||
),
|
||||
oidc_issuer=require(
|
||||
OIDC_ISSUER_ENV,
|
||||
"must be set to the Authentik OIDC issuer URL (it is "
|
||||
"unset or empty — the admin plane will not trust tokens "
|
||||
"from an issuer nobody configured)",
|
||||
),
|
||||
oidc_audience=require(
|
||||
OIDC_AUDIENCE_ENV,
|
||||
"must be set to the OIDC audience the admin tokens carry "
|
||||
"(it is unset or empty — the admin plane will not accept "
|
||||
"tokens without a configured audience)",
|
||||
),
|
||||
oidc_jwks_url=require(
|
||||
OIDC_JWKS_URL_ENV,
|
||||
"must be set to the Authentik JWKS URL (it is unset or "
|
||||
"empty — admin token signatures cannot be verified "
|
||||
"without the provider's signing keys)",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ApiConfig",
|
||||
"ConfigError",
|
||||
"DB_PATH_ENV",
|
||||
"OIDC_AUDIENCE_ENV",
|
||||
"OIDC_ISSUER_ENV",
|
||||
"OIDC_JWKS_URL_ENV",
|
||||
"PANEL_TOKEN_ENV",
|
||||
]
|
||||
@@ -1,82 +0,0 @@
|
||||
"""Database wiring for the NestQuest API service.
|
||||
|
||||
The API owns the database (it is the writer of record per Feature 16's
|
||||
contract). This module owns one application-lifetime
|
||||
:class:`core.db.NestQuestDatabase`: it opens the SQLite file at the
|
||||
configured path on startup, applies migrations (creating the schema on
|
||||
a fresh file), and closes the connection on shutdown.
|
||||
|
||||
The executor callable passed into ``NestQuestDatabase`` mirrors the
|
||||
contract of ``hass.async_add_executor_job``: schedule a synchronous
|
||||
callable on a worker and await its result. HA's own version lives on
|
||||
the event loop; the API is not HA, so it uses
|
||||
:func:`asyncio.to_thread` for the same effect — sqlite3 calls run on a
|
||||
worker thread and only plain data returns to the event loop.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from api.nestquest_core import core_db, core_migrations
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
#: The async SQLite wrapper. Re-exported so route handlers and later
|
||||
#: tasks reach it through the API package, not the bundled core.
|
||||
NestQuestDatabase = core_db.NestQuestDatabase
|
||||
|
||||
#: The migration runner. Re-exported for the same reason.
|
||||
apply_migrations = core_migrations.apply_migrations
|
||||
|
||||
|
||||
async def _executor(fn: Any, *args: Any, **kwargs: Any) -> Any:
|
||||
"""Run a synchronous callable on a worker thread and await its result.
|
||||
|
||||
Mirrors the contract of ``hass.async_add_executor_job`` so the
|
||||
bundled ``NestQuestDatabase`` (written against that contract) works
|
||||
unchanged in the API process: sqlite3 calls never block the event
|
||||
loop, and only plain Python data (row tuples,
|
||||
:class:`core.db.ExecutionResult`) crosses back.
|
||||
"""
|
||||
return await asyncio.to_thread(fn, *args, **kwargs)
|
||||
|
||||
|
||||
class DatabaseState:
|
||||
"""Holds the live database wrapper and its resolved path.
|
||||
|
||||
A small container so the lifespan and the route handlers share one
|
||||
authoritative reference; FastAPI's lifespan stores one of these on
|
||||
``app.state.db`` and the health route reads it back.
|
||||
"""
|
||||
|
||||
__slots__ = ("database", "db_path")
|
||||
|
||||
def __init__(self, database: NestQuestDatabase, db_path: str) -> None:
|
||||
self.database = database
|
||||
self.db_path = db_path
|
||||
|
||||
async def open_and_migrate(self) -> None:
|
||||
"""Open the connection and apply migrations to the latest version."""
|
||||
LOGGER.info("Opening NestQuest database at %s", self.db_path)
|
||||
await self.database.open(self.db_path)
|
||||
version = await apply_migrations(self.database)
|
||||
LOGGER.info("NestQuest schema at version %d", version)
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close the connection; safe to call even if open failed."""
|
||||
await self.database.close()
|
||||
|
||||
|
||||
def make_database(db_path: str) -> DatabaseState:
|
||||
"""Construct a :class:`DatabaseState` for the given path (not yet open)."""
|
||||
return DatabaseState(NestQuestDatabase(_executor), db_path)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DatabaseState",
|
||||
"NestQuestDatabase",
|
||||
"apply_migrations",
|
||||
"make_database",
|
||||
]
|
||||
@@ -1,81 +0,0 @@
|
||||
"""Reusable FastAPI dependencies for the NestQuest API routes.
|
||||
|
||||
The panel plane (``/api/v1/panel/*``) is authenticated by the static
|
||||
service token configured through ``NESTQUEST_PANEL_TOKEN`` (D-012): the
|
||||
HA integration presents it as a Bearer credential on every panel
|
||||
request. :func:`require_panel_token` is the ONE implementation of that
|
||||
check — every panel route (this task's snapshot route and each later
|
||||
panel route) declares it as a dependency rather than re-implementing
|
||||
token handling, so the credential, the failure behaviour, and the
|
||||
401 contract stay in exactly one place.
|
||||
|
||||
The dependency FAILS CLOSED: a missing ``Authorization`` header, a
|
||||
malformed one (wrong scheme or empty credential), a token that does not
|
||||
match the configured value, and an empty configured token all yield
|
||||
HTTP 401. The comparison is constant-time (:func:`secrets.compare_digest`)
|
||||
so the wall-clock difference between "wrong length" and "wrong value"
|
||||
cannot be used to probe the token byte by byte, and every rejection
|
||||
carries the same detail — nothing in the response distinguishes which
|
||||
part of the check failed.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Header, HTTPException, Request
|
||||
|
||||
#: The authorization scheme the panel plane expects. Matched
|
||||
#: case-insensitively, as RFC 9110 requires for scheme names.
|
||||
_BEARER_SCHEME = "bearer"
|
||||
|
||||
#: The 401 detail shared by every rejection — deliberately uniform so
|
||||
#: the response never reveals which part of the check failed.
|
||||
_REJECTION_DETAIL = "Missing or invalid panel service token"
|
||||
|
||||
#: Challenge header returned with a 401, per the Bearer auth scheme.
|
||||
_WWW_AUTHENTICATE = {"WWW-Authenticate": "Bearer"}
|
||||
|
||||
|
||||
def _reject() -> HTTPException:
|
||||
"""Build the single 401 the dependency raises for every failure."""
|
||||
return HTTPException(
|
||||
status_code=401,
|
||||
detail=_REJECTION_DETAIL,
|
||||
headers=_WWW_AUTHENTICATE,
|
||||
)
|
||||
|
||||
|
||||
async def require_panel_token(
|
||||
request: Request,
|
||||
authorization: Annotated[str | None, Header()] = None,
|
||||
) -> None:
|
||||
"""Reject the request with 401 unless it carries the panel token.
|
||||
|
||||
Reads the configured token off ``app.state.config`` (set by
|
||||
:func:`api.app.create_app`) so the dependency stays reusable across
|
||||
routes and carries no module state of its own. A missing or
|
||||
malformed ``Authorization`` header, a non-matching credential, and
|
||||
an EMPTY configured token (a wiring mistake) all raise the same
|
||||
``HTTPException(401)`` — the check is closed even when the service
|
||||
is misconfigured.
|
||||
|
||||
Returns ``None`` on success; FastAPI discards a ``None`` return,
|
||||
the route handler simply proceeds.
|
||||
"""
|
||||
config = getattr(request.app.state, "config", None)
|
||||
expected = config.panel_token if config is not None else ""
|
||||
if not expected or authorization is None:
|
||||
raise _reject()
|
||||
scheme, _, credential = authorization.partition(" ")
|
||||
if scheme.lower() != _BEARER_SCHEME or not credential.strip():
|
||||
raise _reject()
|
||||
# compare_digest on bytes so a non-ASCII token raises nothing and a
|
||||
# timing side channel on the str path is not available.
|
||||
if not secrets.compare_digest(
|
||||
credential.strip().encode("utf-8"), expected.encode("utf-8")
|
||||
):
|
||||
raise _reject()
|
||||
|
||||
|
||||
__all__ = ["require_panel_token"]
|
||||
@@ -1,78 +0,0 @@
|
||||
"""The ONE in-process transition-event publisher for the API service.
|
||||
|
||||
Route handlers call :meth:`TransitionPublisher.publish` synchronously
|
||||
(no ``await``, callable from any async handler); SSE subscribers stream
|
||||
those transitions through an async generator. The scope of "ONE" is
|
||||
deliberate: a single :class:`TransitionPublisher` instance is created
|
||||
in the app lifespan and stored on ``app.state.publisher`` — per-app,
|
||||
so tests build their own hermetic app without module-level state, and
|
||||
both the panel complete route and the SSE endpoint resolve the SAME
|
||||
instance off ``request.app.state`` (exactly one publisher exists per
|
||||
running app; nothing else may create one).
|
||||
|
||||
Design (small and dependency-free, asyncio only):
|
||||
|
||||
- Each subscriber gets its own ``asyncio.Queue(maxsize=64)``.
|
||||
- ``publish`` is synchronous and non-blocking: it puts the event on
|
||||
every live queue with ``put_nowait``. A slow subscriber that lets
|
||||
its queue fill gets the event DROPPED (oldest overflow) — never a
|
||||
blocked publisher, never unbounded growth.
|
||||
- A disconnected subscriber's queue is removed in the generator's
|
||||
``finally`` block, so teardown is clean and a closed consumer cannot
|
||||
leak its queue.
|
||||
|
||||
Fan-out is fire-and-forget by design: the SSE plane mirrors transition
|
||||
events for the panel UI; a dropped event costs the panel one UI
|
||||
refresh, and the documented recovery path is the next snapshot poll.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
#: Per-subscriber queue capacity. Bounded so a stalled consumer can
|
||||
#: never make the publisher or another subscriber wait; beyond this
|
||||
#: the slow subscriber drops the event (see module docstring).
|
||||
_QUEUE_MAXSIZE = 64
|
||||
|
||||
__all__ = ["TransitionPublisher"]
|
||||
|
||||
|
||||
class TransitionPublisher:
|
||||
"""A single in-process fan-out point for transition events."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Create the publisher with no subscribers."""
|
||||
self._subscribers: set[asyncio.Queue] = set()
|
||||
|
||||
def publish(self, event_type: str, payload: dict) -> None:
|
||||
"""Fan one transition event out to every current subscriber.
|
||||
|
||||
Synchronous and non-blocking: each subscriber's bounded queue
|
||||
is filled with ``put_nowait``, and a FULL queue silently drops
|
||||
the event for that (slow) subscriber rather than blocking the
|
||||
route handler or growing without bound.
|
||||
"""
|
||||
for queue in tuple(self._subscribers):
|
||||
try:
|
||||
queue.put_nowait((event_type, payload))
|
||||
except asyncio.QueueFull:
|
||||
# The subscriber is not keeping up; drop the event for
|
||||
# it and move on (the panel recovers by polling).
|
||||
pass
|
||||
|
||||
async def subscribe(self) -> AsyncIterator[tuple[str, dict]]:
|
||||
"""Yield ``(event_type, payload)`` tuples until the consumer breaks.
|
||||
|
||||
Registers ONE bounded queue for the duration of the iteration
|
||||
and removes it in the ``finally`` block, so a client disconnect
|
||||
(or any break out of the loop) tears the subscription down
|
||||
cleanly with no leaked queue.
|
||||
"""
|
||||
queue: asyncio.Queue = asyncio.Queue(maxsize=_QUEUE_MAXSIZE)
|
||||
self._subscribers.add(queue)
|
||||
try:
|
||||
while True:
|
||||
yield await queue.get()
|
||||
finally:
|
||||
self._subscribers.discard(queue)
|
||||
@@ -1,135 +0,0 @@
|
||||
"""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")
|
||||
|
||||
__all__ = [
|
||||
"core",
|
||||
"core_completion",
|
||||
"core_db",
|
||||
"core_events",
|
||||
"core_migrations",
|
||||
"core_snapshot",
|
||||
"core_settings",
|
||||
]
|
||||
@@ -1,63 +0,0 @@
|
||||
"""Admin-plane routes for the NestQuest API service.
|
||||
|
||||
The admin plane (``/api/v1/admin/*``, D-012) serves the admin PWA.
|
||||
Every route on :data:`router` requires a valid Authentik OIDC JWT
|
||||
through the ONE reusable dependency :func:`api.auth.require_admin_jwt`
|
||||
(declared once at the router level, so each later admin route added to
|
||||
this router inherits the check).
|
||||
|
||||
This task is the AUTH probe only: the ping route is a thin adapter
|
||||
that performs NO business logic and NO auth of its own — it reuses the
|
||||
router's already-verified claims (FastAPI caches a dependency's result
|
||||
per request, so the check runs exactly once) and returns the status
|
||||
plus the token's subject. Authorization — membership in the
|
||||
``nestquest-admins`` group — is the NEXT task, layered onto
|
||||
:func:`api.auth.require_admin_jwt` so every admin route inherits it.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
|
||||
from api.auth import require_admin_jwt
|
||||
|
||||
#: All admin-plane routes share this router; the JWT check is attached
|
||||
#: HERE so every admin route (current and later) requires it.
|
||||
router = APIRouter(
|
||||
prefix="/api/v1/admin",
|
||||
dependencies=[Depends(require_admin_jwt)],
|
||||
)
|
||||
|
||||
|
||||
class AdminPingResponse(BaseModel):
|
||||
"""The probe body: the status plus the verified token's subject."""
|
||||
|
||||
status: str
|
||||
#: The JWT's ``sub`` claim — the subject the admin PWA authenticated
|
||||
#: as. ``None`` only for a verified token that carries no ``sub``.
|
||||
sub: str | None
|
||||
|
||||
|
||||
@router.get("/ping", summary="Admin plane probe")
|
||||
async def admin_ping(
|
||||
claims: Annotated[dict[str, object], Depends(require_admin_jwt)],
|
||||
) -> AdminPingResponse:
|
||||
"""Return 200 for a verified admin JWT (the plane's smoke probe).
|
||||
|
||||
Requires a valid Authentik JWT (checked by the router's shared
|
||||
:func:`~api.auth.require_admin_jwt` dependency — the ONE token
|
||||
check; this handler performs NO auth of its own). ``claims`` is
|
||||
the SAME verified-claims dict that dependency produced (FastAPI's
|
||||
per-request dependency cache), so the token is verified exactly
|
||||
once per request.
|
||||
"""
|
||||
sub = claims.get("sub")
|
||||
return AdminPingResponse(
|
||||
status="ok",
|
||||
sub=sub if isinstance(sub, str) else None,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["router"]
|
||||
@@ -1,373 +0,0 @@
|
||||
"""Panel-plane routes for the NestQuest API service.
|
||||
|
||||
The panel plane (``/api/v1/panel/*``, D-012) serves the wall panel's
|
||||
reads. Every route on :data:`router` requires the panel service token
|
||||
through the ONE reusable dependency :func:`api.dependencies.require_panel_token`
|
||||
(declared once at the router level, so each later panel route added to
|
||||
this router inherits the check).
|
||||
|
||||
The snapshot route is a thin adapter over the bundled core: it performs
|
||||
NO business logic. It reads the live database off ``app.state.db``,
|
||||
resolves ONE timezone-aware ``now`` (the API host's local time — the
|
||||
API is the household's "local" host now that the domain no longer lives
|
||||
inside Home Assistant), calls the pure
|
||||
:func:`nestquest_core.snapshot.build_snapshot`, shapes each child's
|
||||
instances through :func:`nestquest_core.snapshot.instance_payload` with
|
||||
``include_missed=False`` (D-009: missed instances are OMITTED from the
|
||||
panel payload), and returns the documented JSON shape:
|
||||
|
||||
.. code-block:: json
|
||||
|
||||
{
|
||||
"today_iso": "2026-09-22",
|
||||
"cycle_day": 2,
|
||||
"children": [
|
||||
{
|
||||
"child_id": 1,
|
||||
"child_name": "Ada",
|
||||
"present": false,
|
||||
"next_present": "2026-09-24",
|
||||
"due_today": 1,
|
||||
"completed_today": 0,
|
||||
"remaining_today": 1,
|
||||
"completion_pct": 0,
|
||||
"instances": [
|
||||
{"id": 7, "definition_id": 3, "child_id": 1,
|
||||
"title": "Pack bag", "icon": null, "window": "morning",
|
||||
"due_time": "10:00", "state": "open", "overdue": true,
|
||||
"completed_at": null, "on_time": null}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
``children`` follows the household's sort order (the core builder lists
|
||||
active children ``ORDER BY sort_order, id``); ``state`` is the
|
||||
documented panel spelling ``open`` | ``completed`` (§1 of
|
||||
design/ENTITIES-AND-SERVICES.md); ``cycle_day`` is today's 1-based day
|
||||
in the first scheduled child's custody cycle (0 when no child has one).
|
||||
The same :class:`PanelSnapshotResponse` Pydantic model is the route's
|
||||
``response_model``, so the OpenAPI document carries the shape too.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import importlib
|
||||
import json
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel, field_validator
|
||||
|
||||
from api.database import DatabaseState
|
||||
from api.dependencies import require_panel_token
|
||||
from api.nestquest_core import (
|
||||
core_completion,
|
||||
core_events,
|
||||
core_settings,
|
||||
core_snapshot,
|
||||
)
|
||||
from api import transitions as api_transitions
|
||||
|
||||
#: The instance DAO, imported on the app's core copy for the
|
||||
#: child-mismatch guard on the complete route.
|
||||
_dao_instances = importlib.import_module("nestquest_core.dao_instances")
|
||||
|
||||
#: All panel-plane routes share this router; the service-token check is
|
||||
#: attached HERE so every panel route (current and later) requires it.
|
||||
router = APIRouter(
|
||||
prefix="/api/v1/panel",
|
||||
dependencies=[Depends(require_panel_token)],
|
||||
)
|
||||
|
||||
|
||||
class PanelInstancePayload(BaseModel):
|
||||
"""One instance in the documented panel payload shape (§1).
|
||||
|
||||
Mirrors :func:`nestquest_core.snapshot.instance_payload`:
|
||||
``state`` is ``open`` | ``completed`` (missed instances never reach
|
||||
the panel payload), ``on_time`` passes the completion event's flag
|
||||
through verbatim (``None`` for an open instance), and
|
||||
``completed_at`` is the event's UTC timestamp when completed.
|
||||
"""
|
||||
|
||||
id: int
|
||||
definition_id: int
|
||||
child_id: int
|
||||
title: str
|
||||
icon: str | None
|
||||
window: str
|
||||
due_time: str | None
|
||||
state: str
|
||||
overdue: bool
|
||||
completed_at: str | None
|
||||
on_time: bool | None
|
||||
|
||||
|
||||
class PanelChildPayload(BaseModel):
|
||||
"""One child's day: presence, rollup counts, and today's instances."""
|
||||
|
||||
child_id: int
|
||||
child_name: str
|
||||
present: bool
|
||||
#: ISO date of the child's next present day while they are away
|
||||
#: (the away plate renders ``Returns <weekday>, <Mon D>`` from it);
|
||||
#: ``None`` when the child is present today.
|
||||
next_present: str | None
|
||||
due_today: int
|
||||
completed_today: int
|
||||
remaining_today: int
|
||||
completion_pct: int
|
||||
instances: list[PanelInstancePayload]
|
||||
|
||||
|
||||
class PanelSnapshotResponse(BaseModel):
|
||||
"""The whole household snapshot the panel renders from.
|
||||
|
||||
``today_iso`` anchors the payload's day; ``cycle_day`` is today's
|
||||
1-based day in the first scheduled child's custody cycle (0 when no
|
||||
child has a schedule); ``children`` is in the household's sort
|
||||
order.
|
||||
"""
|
||||
|
||||
today_iso: str
|
||||
cycle_day: int
|
||||
children: list[PanelChildPayload]
|
||||
|
||||
|
||||
class PanelCompleteRequest(BaseModel):
|
||||
"""The body of ``POST /api/v1/panel/instances/{id}/complete``.
|
||||
|
||||
Matches the panel's ``nestquest.complete_quest`` payload (§2 of
|
||||
design/ENTITIES-AND-SERVICES.md): ``actor_child_id`` is the tapped
|
||||
profile. A positive integer is required — the core completion
|
||||
layer rejects non-int (and bool) ids anyway, but rejecting them
|
||||
here with 422 keeps the contract visible in the OpenAPI document
|
||||
and fails before any database work.
|
||||
"""
|
||||
|
||||
actor_child_id: int
|
||||
|
||||
model_config = {
|
||||
"json_schema_extra": {
|
||||
"description": (
|
||||
"The tapped child profile the completion is "
|
||||
"attributed to (decision 9)."
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@field_validator("actor_child_id")
|
||||
@classmethod
|
||||
def _positive(cls, value: int) -> int:
|
||||
"""Reject zero and negative ids with a validation error."""
|
||||
if value < 1:
|
||||
raise ValueError("actor_child_id must be a positive integer")
|
||||
return value
|
||||
|
||||
|
||||
def _local_now() -> datetime.datetime:
|
||||
"""Return the API host's local time as ONE timezone-aware clock read.
|
||||
|
||||
``now().astimezone()`` attaches the host's local timezone to a
|
||||
single clock read, so the calendar date and wall-clock time the
|
||||
builder derives can never disagree. Kept as a module function (not
|
||||
inlined) so tests pin the instant the same way the coordinator's
|
||||
``_local_now`` is pinned.
|
||||
"""
|
||||
return datetime.datetime.now().astimezone()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/snapshot",
|
||||
summary="Today's household snapshot",
|
||||
response_model=PanelSnapshotResponse,
|
||||
)
|
||||
async def panel_snapshot(request: Request) -> PanelSnapshotResponse:
|
||||
"""Return today's full household snapshot for the panel.
|
||||
|
||||
Requires the panel service token (checked by the router's shared
|
||||
:func:`~api.dependencies.require_panel_token` dependency). Builds
|
||||
the snapshot through the pure core builder against the live
|
||||
database and shapes each child's instances with
|
||||
``instance_payload(include_missed=False)`` — missed instances are
|
||||
OMITTED from the panel payload (D-009) — then returns the shape
|
||||
documented in this module's docstring and
|
||||
:class:`PanelSnapshotResponse`.
|
||||
"""
|
||||
state: DatabaseState = request.app.state.db
|
||||
snapshot = await core_snapshot.build_snapshot(
|
||||
state.database, core_settings.NestQuestSettings(), _local_now()
|
||||
)
|
||||
return PanelSnapshotResponse(
|
||||
today_iso=snapshot.today_iso,
|
||||
cycle_day=snapshot.cycle_day,
|
||||
children=[
|
||||
PanelChildPayload(
|
||||
child_id=child.child_id,
|
||||
child_name=child.child_name,
|
||||
present=child.present,
|
||||
next_present=child.next_present,
|
||||
due_today=child.due_today,
|
||||
completed_today=child.completed_today,
|
||||
remaining_today=child.remaining_today,
|
||||
completion_pct=child.completion_pct,
|
||||
instances=[
|
||||
PanelInstancePayload.model_validate(instance)
|
||||
for instance in core_snapshot.instance_payload(
|
||||
child.instances, include_missed=False
|
||||
)
|
||||
],
|
||||
)
|
||||
for child in snapshot.children
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/instances/{instance_id}/complete",
|
||||
summary="Complete one quest instance",
|
||||
status_code=200,
|
||||
)
|
||||
async def panel_complete_instance(
|
||||
instance_id: int, body: PanelCompleteRequest, request: Request
|
||||
) -> dict[str, str]:
|
||||
"""Complete one quest instance as the tapped child profile.
|
||||
|
||||
Requires the panel service token (checked by the router's shared
|
||||
:func:`~api.dependencies.require_panel_token` dependency — the ONE
|
||||
token check; this handler performs NO auth of its own).
|
||||
|
||||
Thin adapter only: resolves the live database off ``app.state.db``,
|
||||
reads ONE timezone-aware clock (:func:`_local_now`), and delegates
|
||||
to :func:`nestquest_core.completion.complete_instance` with
|
||||
``actor_source="panel"`` and the tapped ``actor_child_id`` — the
|
||||
append/idempotence logic, the lock, and the actor-shape validation
|
||||
all live in the core layer, never here.
|
||||
|
||||
Response and idempotence: ``complete_instance`` returns a
|
||||
:class:`~nestquest_core.completion.CompletionResult` whose
|
||||
``appended`` flag reports whether THIS call wrote an event; a
|
||||
repeat call for an already-completed instance is a no-op inside
|
||||
the core layer (no duplicate event) and this route still answers
|
||||
``{"status": "done"}`` either way, so a retried panel tap succeeds.
|
||||
|
||||
Error mapping (deliberately narrow): the ONLY ``ValueError`` mapped
|
||||
to 404 is the unknown-``instance_id`` one, identified by the
|
||||
``instance_id:`` prefix ``complete_instance`` names the failing
|
||||
field with. Every other ``ValueError`` (an invalid actor shape,
|
||||
which the body model and the panel actor contract already make
|
||||
unreachable) re-raises to the framework's 500 — nothing else is
|
||||
turned into a 404 or a 500 of our own making.
|
||||
|
||||
Child mismatch: ``complete_instance`` verifies the tapped
|
||||
``actor_child_id`` exists but does NOT check it is the child the
|
||||
instance belongs to. The route performs that check here (one
|
||||
``QuestInstancesDao.get_by_id`` read) and answers 404 when the
|
||||
instance's ``child_id`` differs — completing another child's quest
|
||||
from the wrong profile must not succeed, and 404 (not 403) keeps
|
||||
the instance's existence from leaking to a wrong-profile tap.
|
||||
"""
|
||||
state: DatabaseState = request.app.state.db
|
||||
database = state.database
|
||||
|
||||
# Child-mismatch guard (see docstring): the instance must belong to
|
||||
# the tapped profile before the completion layer is entered.
|
||||
instance = await _dao_instances.QuestInstancesDao(database).get_by_id(
|
||||
instance_id
|
||||
)
|
||||
if instance is not None and instance.child_id != body.actor_child_id:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Quest instance not found for this child",
|
||||
)
|
||||
|
||||
now = _local_now()
|
||||
try:
|
||||
result = await core_completion.complete_instance(
|
||||
database,
|
||||
instance_id,
|
||||
actor_source="panel",
|
||||
actor_child_id=body.actor_child_id,
|
||||
now=now,
|
||||
)
|
||||
except ValueError as error:
|
||||
message = str(error)
|
||||
if message.startswith("instance_id:"):
|
||||
# complete_instance names the field in its ValueError:
|
||||
# "instance_id: quest instance N does not exist".
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Quest instance not found",
|
||||
) from error
|
||||
raise
|
||||
# The derived state is "done" in both the appended and no-op cases;
|
||||
# ``result.appended`` distinguishes them for callers that care —
|
||||
# the panel route returns the same success either way.
|
||||
if result.appended:
|
||||
# An ACTUAL transition: publish the SSE transition event. The
|
||||
# payload is built by the core builder (never hand-built here)
|
||||
# and fan-out is fire-and-forget (see api/events.py).
|
||||
event_type, payload = await core_events.build_quest_completed_event(
|
||||
database, instance_id, was_on_time=result.was_on_time
|
||||
)
|
||||
request.app.state.publisher.publish(event_type, payload)
|
||||
# THEN evaluate the day-complete rule off the completed event's
|
||||
# payload (the core builder decides when the child's whole day
|
||||
# is cleared — a zero-quest day and a non-today instance never
|
||||
# fire it). ``today`` comes from the route's single clock read
|
||||
# so the calendar date can never disagree with the completion's.
|
||||
await api_transitions.publish_child_day_complete(
|
||||
database,
|
||||
payload,
|
||||
now.date(),
|
||||
request.app.state.publisher,
|
||||
)
|
||||
return {"status": str(result)}
|
||||
|
||||
|
||||
async def _sse_stream(request: Request) -> AsyncIterator[str]:
|
||||
"""Yield the SSE frames for every published transition event.
|
||||
|
||||
One subscriber of the app's single publisher (resolved off
|
||||
``app.state.publisher``); each event becomes one
|
||||
``event: <type>\\ndata: <json>\\n\\n`` frame. Breaking out of the
|
||||
publisher's subscription (on client disconnect) tears the
|
||||
subscription down cleanly — no queue is leaked.
|
||||
"""
|
||||
publisher = request.app.state.publisher
|
||||
async for event_type, payload in publisher.subscribe():
|
||||
yield (
|
||||
f"event: {event_type}\n"
|
||||
f"data: {json.dumps(payload)}\n\n"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/events",
|
||||
summary="Stream panel transition events (SSE)",
|
||||
)
|
||||
async def panel_events(request: Request) -> StreamingResponse:
|
||||
"""Stream the app's transition events to the panel (Server-Sent
|
||||
Events).
|
||||
|
||||
Requires the panel service token (checked by the router's shared
|
||||
:func:`~api.dependencies.require_panel_token` dependency — the ONE
|
||||
token check; this handler performs NO auth of its own).
|
||||
|
||||
Returns a ``text/event-stream`` response whose body is produced by
|
||||
:func:`_sse_stream` off the app's single in-process publisher
|
||||
(``app.state.publisher``, installed in the lifespan). The stream
|
||||
ends when the client disconnects, at which point the subscription
|
||||
is torn down (its queue removed) with nothing leaked.
|
||||
"""
|
||||
return StreamingResponse(
|
||||
_sse_stream(request),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache"},
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["router"]
|
||||
@@ -1,83 +0,0 @@
|
||||
"""Reusable SSE transition publishers for the API service (task 44c93cfd).
|
||||
|
||||
Each helper wraps the ONE publisher on ``app.state.publisher`` with a
|
||||
single documented transition: it builds the payload through the core
|
||||
event builders (``nestquest_core.events``) — never hand-built here —
|
||||
and fans it out with :meth:`~api.events.TransitionPublisher.publish`.
|
||||
|
||||
The uncomplete route (follow-up task da0226b3) and the API-side missed
|
||||
sweep do not exist yet; these helpers are the functions they will call,
|
||||
and the SSE tests exercise them directly. Fan-out stays fire-and-forget
|
||||
(see api/events.py): a slow subscriber drops the event and recovers by
|
||||
polling the snapshot.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
|
||||
from api.nestquest_core import core_events
|
||||
|
||||
|
||||
async def publish_quest_uncompleted(
|
||||
database: object, instance_id: int, publisher: object
|
||||
) -> None:
|
||||
"""Publish ONE ``nestquest_quest_uncompleted`` event for an actual
|
||||
reversal of ``instance_id``.
|
||||
|
||||
The payload is built by the core builder
|
||||
(:func:`nestquest_core.events.build_quest_uncompleted_events`);
|
||||
this helper only picks the single event out of the builder's list
|
||||
and publishes it.
|
||||
"""
|
||||
events = await core_events.build_quest_uncompleted_events(
|
||||
database, instance_id
|
||||
)
|
||||
for event_type, payload in events:
|
||||
publisher.publish(event_type, payload)
|
||||
|
||||
|
||||
async def publish_quest_missed(
|
||||
database: object, instance_id: int, publisher: object
|
||||
) -> None:
|
||||
"""Publish ONE ``nestquest_quest_missed`` event for ``instance_id``.
|
||||
|
||||
The payload is built by the core builder
|
||||
(:func:`nestquest_core.events.build_quest_missed_event`), which
|
||||
mirrors the documented sweep payload exactly (§3 of
|
||||
design/ENTITIES-AND-SERVICES.md).
|
||||
"""
|
||||
event_type, payload = await core_events.build_quest_missed_event(
|
||||
database, instance_id
|
||||
)
|
||||
publisher.publish(event_type, payload)
|
||||
|
||||
|
||||
async def publish_child_day_complete(
|
||||
database: object,
|
||||
completed_payload: dict,
|
||||
today: datetime.date,
|
||||
publisher: object,
|
||||
) -> bool:
|
||||
"""Publish ``nestquest_child_day_complete`` when a completion
|
||||
cleared the child's whole day; return whether one was published.
|
||||
|
||||
The day-complete rule lives entirely in the core builder
|
||||
(:func:`nestquest_core.events.build_child_day_complete_event`):
|
||||
it returns ``None`` when the day did not clear (a zero-quest day
|
||||
or a non-today instance) and this helper publishes nothing then.
|
||||
"""
|
||||
event = await core_events.build_child_day_complete_event(
|
||||
database, completed_payload, today=today
|
||||
)
|
||||
if event is None:
|
||||
return False
|
||||
event_type, payload = event
|
||||
publisher.publish(event_type, payload)
|
||||
return True
|
||||
|
||||
|
||||
__all__ = [
|
||||
"publish_child_day_complete",
|
||||
"publish_quest_missed",
|
||||
"publish_quest_uncompleted",
|
||||
]
|
||||
@@ -18,13 +18,16 @@ from homeassistant.helpers.event import async_track_time_change
|
||||
|
||||
from .const import (
|
||||
CONF_ADMIN_USER_IDS,
|
||||
CONF_DAY_ROLLOVER_TIME,
|
||||
CONF_HORIZON_DAYS,
|
||||
CONF_UPDATE_INTERVAL,
|
||||
DEFAULT_DAY_ROLLOVER_TIME,
|
||||
DEFAULT_HORIZON_DAYS,
|
||||
DEFAULT_UPDATE_INTERVAL,
|
||||
DOMAIN,
|
||||
LOGGER,
|
||||
PLATFORMS,
|
||||
)
|
||||
from .core.settings import NestQuestSettings
|
||||
from .frontend import async_register_frontend
|
||||
from .coordinator import NestQuestCoordinator
|
||||
from .db import NestQuestDatabase, make_database
|
||||
@@ -237,7 +240,7 @@ class NestQuestRuntimeData:
|
||||
"""Runtime data stored on a NestQuest config entry."""
|
||||
|
||||
entry_id: str
|
||||
settings: NestQuestSettings
|
||||
options: dict[str, Any]
|
||||
database: NestQuestDatabase
|
||||
remove_update_listener: Callable[[], Any]
|
||||
remove_time_change_listener: Callable[[], Any]
|
||||
@@ -266,10 +269,38 @@ async def _async_owner_user_ids(hass: HomeAssistant) -> list[str]:
|
||||
return owners
|
||||
|
||||
|
||||
def _day_rollover_hour_minute(options: dict[str, Any]) -> tuple[int, int]:
|
||||
"""Return the (hour, minute) the daily rollover fires at from ``options``.
|
||||
|
||||
The options flow validates ``day_rollover_time`` as a strict ``HH:MM``
|
||||
string, so a stored value never needs re-validation here; a missing key
|
||||
falls back to :data:`~.const.DEFAULT_DAY_ROLLOVER_TIME`.
|
||||
"""
|
||||
rollover = options.get(CONF_DAY_ROLLOVER_TIME, DEFAULT_DAY_ROLLOVER_TIME)
|
||||
hour, _, minute = rollover.partition(":")
|
||||
return int(hour), int(minute)
|
||||
|
||||
|
||||
def _configured_horizon_days(options: dict[str, Any]) -> int:
|
||||
"""Return the configured generation horizon, validated, else the default.
|
||||
|
||||
The options flow already validates ``horizon_days`` as an integer >= 1,
|
||||
but a stored ``entry.options`` dict could still carry a partial or
|
||||
hand-edited value. Re-checking here — and falling back to
|
||||
:data:`~.const.DEFAULT_HORIZON_DAYS` on a missing, non-int or sub-1 value
|
||||
— keeps a malformed option from shrinking or exploding the materialized
|
||||
horizon.
|
||||
"""
|
||||
value = options.get(CONF_HORIZON_DAYS, DEFAULT_HORIZON_DAYS)
|
||||
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
|
||||
return DEFAULT_HORIZON_DAYS
|
||||
return value
|
||||
|
||||
|
||||
async def _run_horizon_materialization(
|
||||
hass: HomeAssistant,
|
||||
database: NestQuestDatabase,
|
||||
settings: NestQuestSettings,
|
||||
horizon_days: int = DEFAULT_HORIZON_DAYS,
|
||||
) -> None:
|
||||
"""Materialize the rolling horizon ``[today, today + horizon_days]``.
|
||||
|
||||
@@ -280,22 +311,23 @@ async def _run_horizon_materialization(
|
||||
|
||||
This is the ONE generation path: the startup backfill, the daily rollover
|
||||
listener and the ``regenerate`` service all funnel through it, so there is
|
||||
no duplicated materialization logic. ``settings`` (REQUIRED — there is no
|
||||
silent default; every caller already passes the entry's validated
|
||||
:class:`NestQuestSettings`) carries the configured ``horizon_days``. The
|
||||
window itself comes from :meth:`settings.horizon_window` so the helper is
|
||||
real and shared (not recomputed inline here).
|
||||
no duplicated materialization logic. ``horizon_days`` sizes the window
|
||||
and defaults to :data:`~.const.DEFAULT_HORIZON_DAYS`; callers thread the
|
||||
entry's validated option value through.
|
||||
"""
|
||||
time_zone = ZoneInfo(hass.config.time_zone)
|
||||
today = datetime.datetime.now(time_zone).date()
|
||||
start, end = settings.horizon_window(today)
|
||||
await _materialize_run(database, start.isoformat(), end.isoformat(), today=today)
|
||||
start_date = today.isoformat()
|
||||
end_date = (
|
||||
today + datetime.timedelta(days=horizon_days)
|
||||
).isoformat()
|
||||
await _materialize_run(database, start_date, end_date, today=today)
|
||||
|
||||
|
||||
def _register_day_rollover_listener(
|
||||
hass: HomeAssistant,
|
||||
database: NestQuestDatabase,
|
||||
settings: NestQuestSettings,
|
||||
options: dict[str, Any],
|
||||
) -> Callable[[], Any]:
|
||||
"""Register the daily rollover listener and return its remover.
|
||||
|
||||
@@ -303,15 +335,16 @@ def _register_day_rollover_listener(
|
||||
first re-materializes the rolling horizon
|
||||
``[today, today + horizon_days]`` through the shared
|
||||
:func:`_run_horizon_materialization` path (the entry's configured,
|
||||
validated ``horizon_days`` carried on ``settings``), then runs the
|
||||
Feature 11 missed sweep — announcing yesterday's still-open quests
|
||||
as missed AFTER today's instances exist, watermark-guarded so a
|
||||
re-fire of the listener the same night announces nothing twice.
|
||||
validated ``horizon_days``), then runs the Feature 11 missed sweep
|
||||
— announcing yesterday's still-open quests as missed AFTER today's
|
||||
instances exist, watermark-guarded so a re-fire of the listener the
|
||||
same night announces nothing twice.
|
||||
"""
|
||||
hour, minute = settings.day_rollover_hour_minute
|
||||
hour, minute = _day_rollover_hour_minute(options)
|
||||
horizon_days = _configured_horizon_days(options)
|
||||
|
||||
async def _run_materialization(_now: datetime.datetime) -> None:
|
||||
await _run_horizon_materialization(hass, database, settings)
|
||||
await _run_horizon_materialization(hass, database, horizon_days)
|
||||
await run_missed_sweep(hass, database)
|
||||
|
||||
return async_track_time_change(
|
||||
@@ -326,9 +359,7 @@ def _find_live_runtime_data(hass: HomeAssistant) -> NestQuestRuntimeData | None:
|
||||
their own; they resolve whichever config entry's runtime data is
|
||||
currently live at call time, so none of them can hold a stale handle
|
||||
to a closed connection after another entry unloads. The record also
|
||||
carries that entry's validated ``settings`` (e.g. the configured
|
||||
horizon) so service handlers read horizon days off it rather than
|
||||
re-reading HA config.
|
||||
carries that entry's options (e.g. the configured horizon).
|
||||
"""
|
||||
for runtime_data in hass.data.get(DOMAIN, {}).values():
|
||||
database = getattr(runtime_data, "database", None)
|
||||
@@ -348,8 +379,9 @@ def _make_regenerate_handler(hass: HomeAssistant):
|
||||
"live database; ignoring"
|
||||
)
|
||||
return
|
||||
horizon_days = _configured_horizon_days(runtime_data.options)
|
||||
await _run_horizon_materialization(
|
||||
hass, runtime_data.database, runtime_data.settings
|
||||
hass, runtime_data.database, horizon_days
|
||||
)
|
||||
|
||||
return _regenerate
|
||||
@@ -463,24 +495,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
context_user_id=context_user_id,
|
||||
owner_ids=owner_ids,
|
||||
)
|
||||
# Build the explicit settings object from entry.options ONCE: the
|
||||
# horizon materialization and the daily-rollover listener both
|
||||
# consume it, so core never reads HA config itself. A malformed
|
||||
# stored option falls back PER FIELD (via from_options_resilient)
|
||||
# so a single bad sibling option cannot reset the user's
|
||||
# horizon_days or day_rollover_time — the two fields core
|
||||
# consumes — to their defaults.
|
||||
settings = NestQuestSettings.from_options_resilient(entry.options)
|
||||
remove_update_listener = entry.add_update_listener(_async_update_listener)
|
||||
remove_time_change_listener = _register_day_rollover_listener(
|
||||
hass, database, settings
|
||||
hass, database, dict(entry.options)
|
||||
)
|
||||
# Backfill any days the daily listener missed while the integration
|
||||
# was off (or freshly installed): run the one idempotent walk over
|
||||
# [today, today + horizon_days] once at setup, using the entry's
|
||||
# configured horizon. This runs through the SAME path as the daily
|
||||
# listener and regenerate service.
|
||||
await _run_horizon_materialization(hass, database, settings)
|
||||
await _run_horizon_materialization(
|
||||
hass, database, _configured_horizon_days(dict(entry.options))
|
||||
)
|
||||
# The missed sweep runs at startup too (Feature 11): if HA was
|
||||
# down at the rollover time, the previous night's still-open
|
||||
# quests are announced as missed now instead of waiting for
|
||||
@@ -496,7 +522,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
hass,
|
||||
entry_id=entry.entry_id,
|
||||
database=database,
|
||||
settings=settings,
|
||||
update_interval_seconds=entry.options.get(
|
||||
CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL
|
||||
),
|
||||
@@ -504,7 +529,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
runtime_data = NestQuestRuntimeData(
|
||||
entry_id=entry.entry_id,
|
||||
settings=settings,
|
||||
options=dict(entry.options),
|
||||
database=database,
|
||||
remove_update_listener=remove_update_listener,
|
||||
remove_time_change_listener=remove_time_change_listener,
|
||||
|
||||
@@ -11,15 +11,6 @@ refresh updates the whole board coherently.
|
||||
host clock and never UTC, matching the materialization and completion
|
||||
layers.
|
||||
|
||||
The snapshot assembly itself lives in the Home-Assistant-free
|
||||
:mod:`custom_components.nestquest.core.snapshot` module
|
||||
(:func:`~core.snapshot.build_snapshot`); this coordinator is now a thin
|
||||
HA wrapper that resolves the local timezone and reads the settings handed
|
||||
into its constructor, then delegates to that builder, passing the SAME
|
||||
HA-local ``now`` it resolved in one clock read. The API service's panel
|
||||
snapshot route shares the same builder, so both consumers render ONE
|
||||
snapshot shape.
|
||||
|
||||
Presence is resolved through the SAME engine the materializer uses
|
||||
(:class:`~.presence.PresenceEngine`): schedules and overrides are read
|
||||
with the public DAO methods and the engine's anchor-date arithmetic
|
||||
@@ -36,11 +27,13 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
from dataclasses import dataclass
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
|
||||
from .completion import derive_state
|
||||
from .const import (
|
||||
CONF_UPDATE_INTERVAL,
|
||||
DEFAULT_UPDATE_INTERVAL,
|
||||
@@ -48,21 +41,66 @@ from .const import (
|
||||
LOGGER,
|
||||
MIN_UPDATE_INTERVAL,
|
||||
)
|
||||
from .core.db import NestQuestDatabase
|
||||
from .core.settings import NestQuestSettings
|
||||
from .core.snapshot import (
|
||||
ChildDaySnapshot,
|
||||
NestQuestSnapshot,
|
||||
QuestInstanceView,
|
||||
build_snapshot,
|
||||
from .dao_children import ChildrenDao
|
||||
from .dao_instances import (
|
||||
CompletionEventsDao,
|
||||
QuestInstancesDao,
|
||||
)
|
||||
from .dao_rules import QuestDefinitionsDao
|
||||
from .dao_presence import PresenceOverridesDao, PresenceSchedulesDao
|
||||
from .db import NestQuestDatabase
|
||||
from .presence import (
|
||||
MAX_PREVIEW_SCAN_DAYS,
|
||||
PresenceEngine,
|
||||
PresenceOverride,
|
||||
PresenceSchedule,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ChildDaySnapshot",
|
||||
"NestQuestCoordinator",
|
||||
"NestQuestSnapshot",
|
||||
"QuestInstanceView",
|
||||
]
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class QuestInstanceView:
|
||||
"""One instance plus everything the entities render from it."""
|
||||
|
||||
instance_id: int
|
||||
definition_id: int
|
||||
child_id: int
|
||||
title: str
|
||||
icon: str | None
|
||||
window: str
|
||||
due_date: str
|
||||
due_time: str | None
|
||||
state: str # open | done | missed
|
||||
overdue: bool
|
||||
completed_at: str | None
|
||||
was_on_time: bool | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ChildDaySnapshot:
|
||||
"""One active child's day: instances, presence, and rollup counts."""
|
||||
|
||||
child_id: int
|
||||
child_name: str
|
||||
instances: tuple[QuestInstanceView, ...]
|
||||
present: bool
|
||||
due_today: int
|
||||
completed_today: int
|
||||
remaining_today: int
|
||||
completion_pct: int
|
||||
#: For a child away today: the ISO date of their next present day
|
||||
#: (the panel's away plate renders ``Returns <weekday>, <Mon D>``
|
||||
#: from it). ``None`` when the child is present today or no
|
||||
#: present day exists within the presence engine's scan cap.
|
||||
next_present: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NestQuestSnapshot:
|
||||
"""The coordinator's whole-entity data payload."""
|
||||
|
||||
today_iso: str
|
||||
children: tuple[ChildDaySnapshot, ...]
|
||||
cycle_day: int
|
||||
|
||||
|
||||
class NestQuestCoordinator(DataUpdateCoordinator):
|
||||
@@ -83,7 +121,6 @@ class NestQuestCoordinator(DataUpdateCoordinator):
|
||||
*,
|
||||
entry_id: str,
|
||||
database: NestQuestDatabase,
|
||||
settings: NestQuestSettings,
|
||||
update_interval_seconds: int | None = None,
|
||||
) -> None:
|
||||
interval = update_interval_seconds
|
||||
@@ -101,7 +138,6 @@ class NestQuestCoordinator(DataUpdateCoordinator):
|
||||
)
|
||||
self.entry_id = entry_id
|
||||
self.database = database
|
||||
self.settings = settings
|
||||
self._refresh_lock = asyncio.Lock()
|
||||
|
||||
async def async_refresh(self) -> None:
|
||||
@@ -117,6 +153,140 @@ class NestQuestCoordinator(DataUpdateCoordinator):
|
||||
|
||||
async def _async_update_data(self) -> NestQuestSnapshot:
|
||||
now = self._local_now()
|
||||
return await build_snapshot(
|
||||
self.database, self.settings, now
|
||||
today = now.date()
|
||||
today_iso = today.isoformat()
|
||||
|
||||
children = await ChildrenDao(self.database).list_active()
|
||||
|
||||
schedules: dict[int, PresenceSchedule] = {}
|
||||
overrides: dict[int, list[PresenceOverride]] = {}
|
||||
for child in children:
|
||||
schedule = await PresenceSchedulesDao(self.database).get_by_child(
|
||||
child.id
|
||||
)
|
||||
if schedule is not None:
|
||||
schedules[child.id] = PresenceSchedule.decode(
|
||||
child.id, schedule.anchor_date, schedule.pattern
|
||||
)
|
||||
window_end = today + datetime.timedelta(
|
||||
days=MAX_PREVIEW_SCAN_DAYS
|
||||
)
|
||||
window_end_iso = window_end.isoformat()
|
||||
ranged = await PresenceOverridesDao(
|
||||
self.database
|
||||
).list_by_child_and_range(child.id, today_iso, window_end_iso)
|
||||
if ranged:
|
||||
# The engine validates MODEL objects, not DAO records;
|
||||
# each record converts losslessly (the schema guarantees
|
||||
# end >= start and a real is_present).
|
||||
overrides[child.id] = [
|
||||
PresenceOverride(
|
||||
record.child_id,
|
||||
record.start_date,
|
||||
record.end_date,
|
||||
record.is_present,
|
||||
record.note,
|
||||
)
|
||||
for record in ranged
|
||||
]
|
||||
engine = PresenceEngine(schedules, overrides)
|
||||
|
||||
instances_dao = QuestInstancesDao(self.database)
|
||||
events_dao = CompletionEventsDao(self.database)
|
||||
definitions_dao = QuestDefinitionsDao(self.database)
|
||||
|
||||
snapshots: list[ChildDaySnapshot] = []
|
||||
for child in children:
|
||||
instances = await instances_dao.list_by_date_range(
|
||||
child.id, today_iso, today_iso
|
||||
)
|
||||
views: list[QuestInstanceView] = []
|
||||
for instance in instances:
|
||||
latest = await events_dao.get_latest_for_instance(instance.id)
|
||||
definition = await definitions_dao.get(instance.definition_id)
|
||||
state = derive_state(instance, latest, today)
|
||||
overdue = False
|
||||
if state == "open" and instance.due_date == today_iso:
|
||||
if instance.due_time is not None:
|
||||
due_moment = datetime.datetime.strptime(
|
||||
instance.due_time, "%H:%M"
|
||||
).time()
|
||||
overdue = now.time() > due_moment
|
||||
views.append(
|
||||
QuestInstanceView(
|
||||
instance_id=instance.id,
|
||||
definition_id=instance.definition_id,
|
||||
child_id=instance.child_id,
|
||||
title=definition.title if definition else "Quest",
|
||||
icon=definition.icon if definition else None,
|
||||
window=instance.window,
|
||||
due_date=instance.due_date,
|
||||
due_time=instance.due_time,
|
||||
state=state,
|
||||
overdue=overdue,
|
||||
completed_at=(
|
||||
latest.occurred_at
|
||||
if latest is not None
|
||||
and latest.event_type == "completed"
|
||||
else None
|
||||
),
|
||||
was_on_time=(
|
||||
latest.was_on_time
|
||||
if latest is not None
|
||||
and latest.event_type == "completed"
|
||||
else None
|
||||
),
|
||||
)
|
||||
)
|
||||
due = len(views)
|
||||
completed = sum(1 for view in views if view.state == "done")
|
||||
remaining = due - completed
|
||||
child_present = engine.is_present(child.id, today)
|
||||
next_present: str | None = None
|
||||
if not child_present:
|
||||
try:
|
||||
next_present = engine.next_present_dates(
|
||||
child.id, today, 1
|
||||
)[0].isoformat()
|
||||
except ValueError:
|
||||
next_present = None
|
||||
snapshots.append(
|
||||
ChildDaySnapshot(
|
||||
child_id=child.id,
|
||||
child_name=child.display_name,
|
||||
instances=tuple(views),
|
||||
present=child_present,
|
||||
due_today=due,
|
||||
completed_today=completed,
|
||||
remaining_today=remaining,
|
||||
completion_pct=(
|
||||
round(completed * 100 / due) if due else 100
|
||||
),
|
||||
next_present=next_present,
|
||||
)
|
||||
)
|
||||
|
||||
return NestQuestSnapshot(
|
||||
today_iso=today_iso,
|
||||
children=tuple(snapshots),
|
||||
cycle_day=_cycle_day(schedules, children, today),
|
||||
)
|
||||
|
||||
|
||||
def _cycle_day(
|
||||
schedules: dict[int, PresenceSchedule], children, today: datetime.date
|
||||
) -> int:
|
||||
"""Return today's 1-based day in the first scheduled child's cycle.
|
||||
|
||||
Anchor-date arithmetic ONLY (D-004): the day offset is computed from
|
||||
the schedule's own anchor, never ISO week numbers or parity, so a
|
||||
53-week year cannot silently invert the cycle.
|
||||
"""
|
||||
for child in children:
|
||||
schedule = schedules.get(child.id)
|
||||
if schedule is None:
|
||||
continue
|
||||
days = (today - schedule.anchor_date).days
|
||||
cycle_length = schedule.cycle_length_weeks * 7
|
||||
return (days % cycle_length) + 1
|
||||
return 0
|
||||
|
||||
@@ -2,19 +2,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
DOMAIN = "nestquest"
|
||||
LOGGER_NAME = "custom_components.nestquest"
|
||||
LOGGER = logging.getLogger(LOGGER_NAME)
|
||||
|
||||
#: Strict 24-hour ``HH:MM`` pattern shared by the options flow and the
|
||||
#: settings object's time validation, so a stored option and a freshly
|
||||
#: validated form submit parse the same way. Defined here (HA-free) so
|
||||
#: both the integration's options flow and the core settings module
|
||||
#: import ONE pattern instead of re-copying it.
|
||||
TIME_PATTERN = re.compile(r"(?:[01]\d|2[0-3]):[0-5]\d")
|
||||
|
||||
# Platforms set up per config entry (Feature 10): the per-child day
|
||||
# sensors and binary sensors.
|
||||
PLATFORMS: list[str] = ["sensor", "binary_sensor"]
|
||||
|
||||
@@ -14,9 +14,7 @@ fires nothing), and ``nestquest_child_day_complete`` fires when a
|
||||
completion clears the child's whole day (quests were owed and none
|
||||
remain — a zero-quest day can never fire it, matching the all-done
|
||||
binary sensor's rule). ``nestquest_quest_missed`` is the documented
|
||||
contract Feature 11's nightly sweep fires; :func:`build_quest_missed_event`
|
||||
builds it with exactly the payload the sweep hand-builds (§3), so the
|
||||
API plane can publish the same shape without re-deriving it.
|
||||
contract Feature 11's nightly sweep fires; nothing here builds it.
|
||||
|
||||
The completed-event and day-complete-event builders are split so the
|
||||
shim can fire ``nestquest_quest_completed`` BEFORE it evaluates the
|
||||
@@ -52,7 +50,6 @@ from .completion import derive_state
|
||||
from .const import (
|
||||
EVENT_CHILD_DAY_COMPLETE,
|
||||
EVENT_QUEST_COMPLETED,
|
||||
EVENT_QUEST_MISSED,
|
||||
EVENT_QUEST_UNCOMPLETED,
|
||||
)
|
||||
from .dao_children import ChildrenDao
|
||||
@@ -191,20 +188,3 @@ async def build_quest_uncompleted_events(
|
||||
"""
|
||||
payload = await _instance_payload(database, instance_id, _now_stamp())
|
||||
return [(EVENT_QUEST_UNCOMPLETED, payload)]
|
||||
|
||||
|
||||
async def build_quest_missed_event(
|
||||
database: NestQuestDatabase, instance_id: int
|
||||
) -> tuple[str, dict]:
|
||||
"""Build the ``nestquest_quest_missed`` event for one instance.
|
||||
|
||||
The payload mirrors the nightly sweep's hand-built payload exactly
|
||||
(the shared :func:`_instance_payload` shape: ``child_id``,
|
||||
``child_name``, ``instance_id``, ``quest_title``, ``window``,
|
||||
``due_date``, ``due_time``, ``occurred_at``), so the API plane's
|
||||
missed path publishes the same event shape the integration's sweep
|
||||
fires. Like the sweep, this builder never mutates the instance or
|
||||
writes a completion event — it reads only.
|
||||
"""
|
||||
payload = await _instance_payload(database, instance_id, _now_stamp())
|
||||
return (EVENT_QUEST_MISSED, payload)
|
||||
|
||||
@@ -1,379 +0,0 @@
|
||||
"""Explicit, Home-Assistant-free settings object for NestQuest core.
|
||||
|
||||
The integration reads its configuration off a Home Assistant config
|
||||
entry (``entry.options``); the core package must not. This module
|
||||
bridges that split: it defines a frozen, validation-carrying
|
||||
:class:`NestQuestSettings` dataclass that the integration constructs
|
||||
from ``entry.options`` (via :meth:`NestQuestSettings.from_options`, or
|
||||
:meth:`NestQuestSettings.from_options_resilient` for the per-field
|
||||
fallback used at setup) and then passes into the core paths that
|
||||
previously read HA config themselves — the rolling horizon
|
||||
(``horizon_days``) and the daily rollover time (``day_rollover_time``),
|
||||
plus the Feature 11 notification settings (notify target, the three
|
||||
summary/reminder/report times, and the four independent enable toggles).
|
||||
|
||||
Nothing in this module imports ``homeassistant`` or touches a config
|
||||
entry; ``from_options`` accepts any plain mapping keyed by the
|
||||
:data:`~.const.CONF_*` names. The validation semantics match the
|
||||
options flow's: ``horizon_days`` must be a real integer >= 1 (bool
|
||||
rejected), and ``day_rollover_time`` plus the three notification times
|
||||
must be strict ``HH:MM`` 24-hour strings. Construction therefore can
|
||||
never yield a settings object whose horizon could shrink or explode the
|
||||
materialized window, or whose rollover fires at an unparsable time.
|
||||
|
||||
The strict resolvers (:func:`_resolve_horizon_days`, :func:`_validate_hhmm`,
|
||||
:func:`_resolve_bool`, :func:`_resolve_optional_str`) reject ``None``; the
|
||||
``None``-as-absent mapping lives ONLY in :meth:`from_options` /
|
||||
:meth:`from_options_resilient` (via :func:`_option_value`), so a hand-built
|
||||
:class:`NestQuestSettings` cannot smuggle a ``None`` past
|
||||
:meth:`__post_init__`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from types import MappingProxyType
|
||||
from typing import Any, Mapping
|
||||
|
||||
from .const import (
|
||||
CONF_AFTERNOON_REMINDER_ENABLED,
|
||||
CONF_AFTERNOON_REMINDER_TIME,
|
||||
CONF_CELEBRATION_ENABLED,
|
||||
CONF_DAY_ROLLOVER_TIME,
|
||||
CONF_END_OF_DAY_REPORT_ENABLED,
|
||||
CONF_END_OF_DAY_REPORT_TIME,
|
||||
CONF_HORIZON_DAYS,
|
||||
CONF_MORNING_SUMMARY_ENABLED,
|
||||
CONF_MORNING_SUMMARY_TIME,
|
||||
CONF_NOTIFY_TARGET,
|
||||
DEFAULT_AFTERNOON_REMINDER_TIME,
|
||||
DEFAULT_AUTOMATION_ENABLED,
|
||||
DEFAULT_DAY_ROLLOVER_TIME,
|
||||
DEFAULT_END_OF_DAY_REPORT_TIME,
|
||||
DEFAULT_HORIZON_DAYS,
|
||||
DEFAULT_MORNING_SUMMARY_TIME,
|
||||
LOGGER,
|
||||
TIME_PATTERN,
|
||||
)
|
||||
|
||||
|
||||
def _option_value(
|
||||
opts: Mapping[str, Any], key: str, default: Any
|
||||
) -> Any:
|
||||
"""Return ``opts[key]`` when present and non-None, else ``default``.
|
||||
|
||||
This is the SOLE place the ``None``-as-absent mapping lives: a stored
|
||||
``None`` is treated the same as an absent key — the historical
|
||||
``horizon_days`` path mapped a missing-or-None value to the default,
|
||||
and this keeps that forgiving semantics uniform across every field.
|
||||
The per-field resolvers (:func:`_resolve_horizon_days`,
|
||||
:func:`_validate_hhmm`, :func:`_resolve_bool`,
|
||||
:func:`_resolve_optional_str`) are STRICT — they reject ``None`` —
|
||||
so a partially-populated ``entry.options`` dict (or one migrated
|
||||
across versions) never raises just because a key carries ``None``,
|
||||
and a hand-built :class:`NestQuestSettings` cannot smuggle a
|
||||
``None`` past :meth:`NestQuestSettings.__post_init__`.
|
||||
"""
|
||||
if key not in opts:
|
||||
return default
|
||||
value = opts[key]
|
||||
return default if value is None else value
|
||||
|
||||
|
||||
def _validate_hhmm(value: Any, *, field: str) -> str:
|
||||
"""Validate a strict ``HH:MM`` string and return it; raise naming ``field``.
|
||||
|
||||
Uses the shared :data:`~.const.TIME_PATTERN` (the same compiled
|
||||
regex the options flow applies), so a stored option and a freshly
|
||||
validated form submit parse the same way. ``None`` (and any
|
||||
non-string) is rejected; the ``None``-as-absent mapping lives ONLY
|
||||
in :meth:`NestQuestSettings.from_options` (via :func:`_option_value`).
|
||||
"""
|
||||
if not isinstance(value, str) or TIME_PATTERN.fullmatch(value) is None:
|
||||
raise ValueError(
|
||||
f"{field} must be a strict HH:MM string, got {value!r}"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _resolve_horizon_days(value: Any) -> int:
|
||||
"""Validate a horizon value and return the resolved day count.
|
||||
|
||||
A bool is rejected (SQLite binds ``True``/``False`` onto an int), a
|
||||
non-int is rejected, and a sub-1 value is rejected — so a malformed
|
||||
option can never shrink or explode the materialized horizon.
|
||||
``None`` is rejected here too; the ``None``-as-absent mapping lives
|
||||
ONLY in :meth:`NestQuestSettings.from_options` (via
|
||||
:func:`_option_value`), so a hand-built
|
||||
:class:`NestQuestSettings(horizon_days=None)` raises in
|
||||
:meth:`NestQuestSettings.__post_init__` rather than silently
|
||||
constructing an object whose :meth:`NestQuestSettings.horizon_window`
|
||||
would later raise ``TypeError`` on ``timedelta(days=None)``.
|
||||
"""
|
||||
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
|
||||
raise ValueError(
|
||||
f"horizon_days must be a positive integer, got {value!r}"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _resolve_bool(value: Any) -> bool:
|
||||
"""Return a real bool; a non-bool (including ``None``) is rejected.
|
||||
|
||||
voluptuous ``bool`` would coerce strings, so the options flow uses a
|
||||
strict-bool validator and this mirrors it. The ``None``-as-absent
|
||||
mapping lives ONLY in :meth:`NestQuestSettings.from_options` (via
|
||||
:func:`_option_value`).
|
||||
"""
|
||||
if not isinstance(value, bool):
|
||||
raise ValueError(f"expected a boolean, got {value!r}")
|
||||
return value
|
||||
|
||||
|
||||
def _resolve_optional_str(value: Any) -> str:
|
||||
"""Return a stripped notify target string; a non-str is rejected.
|
||||
|
||||
A padded or whitespace-only value is STRIPPED to its trim — the
|
||||
shipped default is the empty string, meaning "no notifications are
|
||||
configured yet". This is intentionally MORE LENIENT than the options
|
||||
flow, which REJECTS a target that differs from its own trim (so a
|
||||
padded value never persists): the integration tolerates a stored
|
||||
padded value (e.g. one migrated from an older schema) by normalizing
|
||||
it rather than crashing setup. The ``None``-as-absent mapping lives
|
||||
ONLY in :meth:`NestQuestSettings.from_options` (via
|
||||
:func:`_option_value`). :meth:`NestQuestSettings.__post_init__`
|
||||
applies this SAME normalizer so a hand-built
|
||||
``NestQuestSettings(notify_target=' x ')`` is stripped to ``'x'``
|
||||
exactly like the options-flow path (no padded value can slip through
|
||||
direct construction either).
|
||||
"""
|
||||
if not isinstance(value, str):
|
||||
raise ValueError(f"notify_target must be a string, got {value!r}")
|
||||
return value.strip()
|
||||
|
||||
|
||||
#: The single field table the two builders share. Each row is
|
||||
#: ``(field_name, CONF_* key, default, validator)``. ``from_options``
|
||||
#: applies every validator (raising on the first invalid value) while
|
||||
#: ``from_options_resilient`` wraps each in its own try/except so a single
|
||||
#: bad sibling falls back to its default alone. Driving both builders
|
||||
#: from this table avoids three-way drift between ``__post_init__``'s
|
||||
#: inline checks and the two option builders' field lists: the only
|
||||
#: places that enumerate the fields are this table and
|
||||
#: ``__post_init__`` (which validates a hand-built instance without the
|
||||
#: None->default mapping, so it stays inline).
|
||||
_FIELDS: tuple[tuple[str, str, Any, Callable[[Any], Any]], ...] = (
|
||||
("horizon_days", CONF_HORIZON_DAYS, DEFAULT_HORIZON_DAYS, _resolve_horizon_days),
|
||||
(
|
||||
"day_rollover_time",
|
||||
CONF_DAY_ROLLOVER_TIME,
|
||||
DEFAULT_DAY_ROLLOVER_TIME,
|
||||
lambda v: _validate_hhmm(v, field="day_rollover_time"),
|
||||
),
|
||||
("notify_target", CONF_NOTIFY_TARGET, "", _resolve_optional_str),
|
||||
(
|
||||
"morning_summary_time",
|
||||
CONF_MORNING_SUMMARY_TIME,
|
||||
DEFAULT_MORNING_SUMMARY_TIME,
|
||||
lambda v: _validate_hhmm(v, field="morning_summary_time"),
|
||||
),
|
||||
(
|
||||
"afternoon_reminder_time",
|
||||
CONF_AFTERNOON_REMINDER_TIME,
|
||||
DEFAULT_AFTERNOON_REMINDER_TIME,
|
||||
lambda v: _validate_hhmm(v, field="afternoon_reminder_time"),
|
||||
),
|
||||
(
|
||||
"end_of_day_report_time",
|
||||
CONF_END_OF_DAY_REPORT_TIME,
|
||||
DEFAULT_END_OF_DAY_REPORT_TIME,
|
||||
lambda v: _validate_hhmm(v, field="end_of_day_report_time"),
|
||||
),
|
||||
(
|
||||
"morning_summary_enabled",
|
||||
CONF_MORNING_SUMMARY_ENABLED,
|
||||
DEFAULT_AUTOMATION_ENABLED,
|
||||
_resolve_bool,
|
||||
),
|
||||
(
|
||||
"afternoon_reminder_enabled",
|
||||
CONF_AFTERNOON_REMINDER_ENABLED,
|
||||
DEFAULT_AUTOMATION_ENABLED,
|
||||
_resolve_bool,
|
||||
),
|
||||
(
|
||||
"end_of_day_report_enabled",
|
||||
CONF_END_OF_DAY_REPORT_ENABLED,
|
||||
DEFAULT_AUTOMATION_ENABLED,
|
||||
_resolve_bool,
|
||||
),
|
||||
(
|
||||
"celebration_enabled",
|
||||
CONF_CELEBRATION_ENABLED,
|
||||
DEFAULT_AUTOMATION_ENABLED,
|
||||
_resolve_bool,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NestQuestSettings:
|
||||
"""Explicit, HA-free configuration carried into core paths.
|
||||
|
||||
The integration builds this once from ``entry.options`` (via
|
||||
:meth:`from_options`, or :meth:`from_options_resilient` for the
|
||||
per-field fallback used at setup) and threads it into the horizon
|
||||
materialization and the daily-rollover scheduling. Core never
|
||||
reads HA config itself; callers pass the resolved horizon and
|
||||
rollover time in.
|
||||
|
||||
All fields are validated at construction; the dataclass is frozen so
|
||||
a stale settings object cannot be mutated in place after the
|
||||
integration hands it off.
|
||||
"""
|
||||
|
||||
horizon_days: int = DEFAULT_HORIZON_DAYS
|
||||
day_rollover_time: str = DEFAULT_DAY_ROLLOVER_TIME
|
||||
notify_target: str = ""
|
||||
morning_summary_time: str = DEFAULT_MORNING_SUMMARY_TIME
|
||||
afternoon_reminder_time: str = DEFAULT_AFTERNOON_REMINDER_TIME
|
||||
end_of_day_report_time: str = DEFAULT_END_OF_DAY_REPORT_TIME
|
||||
morning_summary_enabled: bool = DEFAULT_AUTOMATION_ENABLED
|
||||
afternoon_reminder_enabled: bool = DEFAULT_AUTOMATION_ENABLED
|
||||
end_of_day_report_enabled: bool = DEFAULT_AUTOMATION_ENABLED
|
||||
celebration_enabled: bool = DEFAULT_AUTOMATION_ENABLED
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
# Validate STRICTLY at construction so a hand-built instance
|
||||
# cannot carry an invalid value: the same rules from_options
|
||||
# applies, but WITHOUT the None->default mapping (that lives
|
||||
# only in from_options via _option_value). A None horizon, time,
|
||||
# or toggle raises here rather than later blowing up
|
||||
# horizon_window or the rollover listener.
|
||||
_resolve_horizon_days(self.horizon_days)
|
||||
_validate_hhmm(self.day_rollover_time, field="day_rollover_time")
|
||||
# ``notify_target`` is normalised the SAME way as from_options
|
||||
# (via _resolve_optional_str): a non-str is rejected and a
|
||||
# padded/whitespace-only value is STRIPPED to its trim. This
|
||||
# keeps a hand-built ``NestQuestSettings(notify_target=' x ')``
|
||||
# behaving like the options flow (which strips on persist) so a
|
||||
# padded value cannot slip through direct construction either.
|
||||
# The dataclass is frozen, so the stripped value is written via
|
||||
# object.__setattr__.
|
||||
stripped_notify_target = _resolve_optional_str(self.notify_target)
|
||||
object.__setattr__(self, "notify_target", stripped_notify_target)
|
||||
_validate_hhmm(self.morning_summary_time, field="morning_summary_time")
|
||||
_validate_hhmm(
|
||||
self.afternoon_reminder_time, field="afternoon_reminder_time"
|
||||
)
|
||||
_validate_hhmm(
|
||||
self.end_of_day_report_time, field="end_of_day_report_time"
|
||||
)
|
||||
for name in (
|
||||
"morning_summary_enabled",
|
||||
"afternoon_reminder_enabled",
|
||||
"end_of_day_report_enabled",
|
||||
"celebration_enabled",
|
||||
):
|
||||
value = getattr(self, name)
|
||||
if not isinstance(value, bool):
|
||||
raise ValueError(
|
||||
f"{name} must be a boolean, got {value!r}"
|
||||
)
|
||||
|
||||
@property
|
||||
def day_rollover_hour_minute(self) -> tuple[int, int]:
|
||||
"""The (hour, minute) the daily rollover fires at."""
|
||||
# The string is validated at construction, so a plain partition
|
||||
# is safe and avoids re-running the regex on every access.
|
||||
hour_str, _, minute_str = self.day_rollover_time.partition(":")
|
||||
return int(hour_str), int(minute_str)
|
||||
|
||||
def horizon_window(
|
||||
self, today: datetime.date
|
||||
) -> tuple[datetime.date, datetime.date]:
|
||||
"""Return the rolling horizon window ``[today, today + horizon_days]``.
|
||||
|
||||
``today`` is REQUIRED (there is no host-clock default): the
|
||||
integration passes the HA-local ``hass.config.time_zone`` date so
|
||||
the horizon tracks the household's own day, and a silent
|
||||
host-clock fallback would be a wrong-day footgun (a test or a
|
||||
caller that forgets to pass ``today`` would otherwise materialize
|
||||
against the machine's calendar, not the household's). The
|
||||
returned window is closed/inclusive — the same shape
|
||||
:func:`~.materialize.materialize` walks.
|
||||
"""
|
||||
end = today + datetime.timedelta(days=self.horizon_days)
|
||||
return today, end
|
||||
|
||||
@classmethod
|
||||
def from_options(
|
||||
cls, options: Mapping[str, Any] | None = None
|
||||
) -> "NestQuestSettings":
|
||||
"""Build settings from a CONF_*-keyed mapping (e.g. ``entry.options``).
|
||||
|
||||
Reads the same keys the options flow persists, applying the
|
||||
matching defaults when a key is absent OR ``None`` (via
|
||||
:func:`_option_value`, the SOLE place the ``None``-as-absent
|
||||
mapping lives) and validating STRICTLY: ``horizon_days`` is a
|
||||
positive int (bool rejected), the four times are strict
|
||||
``HH:MM``, the four toggles are real booleans, and
|
||||
``notify_target`` is a string (stripped). A malformed value
|
||||
RAISES — the integration uses :meth:`from_options_resilient` at
|
||||
setup to fall back per field instead of crashing. A ``None``
|
||||
mapping yields the all-defaults settings — useful for tests and
|
||||
for a fresh install whose options dict is empty.
|
||||
|
||||
``options`` is consumed read-only via
|
||||
:class:`types.MappingProxyType`, so this method never mutates
|
||||
the caller's mapping.
|
||||
"""
|
||||
opts: Mapping[str, Any] = (
|
||||
MappingProxyType({}) if options is None else MappingProxyType(options)
|
||||
)
|
||||
return cls(
|
||||
**{
|
||||
field: validator(_option_value(opts, key, default))
|
||||
for field, key, default, validator in _FIELDS
|
||||
}
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_options_resilient(
|
||||
cls, options: Mapping[str, Any] | None = None
|
||||
) -> "NestQuestSettings":
|
||||
"""Build settings, falling back PER FIELD on a malformed value.
|
||||
|
||||
Unlike :meth:`from_options` (which raises on the first invalid
|
||||
field), this resolves EACH field in its own try/except: a
|
||||
malformed value logs a :data:`~.const.LOGGER` warning naming the
|
||||
field and falls back to that field's default. The integration
|
||||
uses this at setup so a single bad sibling option (e.g. a
|
||||
migrated ``morning_summary_time`` of ``'25:00'``) cannot
|
||||
silently reset the user's ``horizon_days`` or
|
||||
``day_rollover_time`` — the two fields core actually consumes.
|
||||
|
||||
An absent or ``None`` key maps to the default via
|
||||
:func:`_option_value` WITHOUT a warning (that is the normal
|
||||
missing-key path); only a present, non-``None``, invalid value
|
||||
triggers a warning and a per-field fallback.
|
||||
"""
|
||||
opts: Mapping[str, Any] = (
|
||||
MappingProxyType({}) if options is None else MappingProxyType(options)
|
||||
)
|
||||
resolved: dict[str, Any] = {}
|
||||
for field, key, default, validator in _FIELDS:
|
||||
raw = _option_value(opts, key, default)
|
||||
try:
|
||||
resolved[field] = validator(raw)
|
||||
except ValueError as err:
|
||||
LOGGER.warning(
|
||||
"NestQuest option %s is invalid (%s); falling back "
|
||||
"to the default %r",
|
||||
field,
|
||||
err,
|
||||
default,
|
||||
)
|
||||
resolved[field] = default
|
||||
return cls(**resolved)
|
||||
@@ -1,306 +0,0 @@
|
||||
"""Pure, Home-Assistant-free panel snapshot builder.
|
||||
|
||||
The household snapshot the NestQuest panel renders — active children in
|
||||
sort order, each child's today-instances in the documented payload shape
|
||||
(missed instances OMITTED from the entity-facing attribute payload, but
|
||||
retained on the snapshot for the count rollups), per-child counts and
|
||||
presence, and the custody cycle day — used to be assembled inline inside
|
||||
:mod:`custom_components.nestquest.coordinator`'s
|
||||
``_async_update_data``. That assembly is HA-coupled only because its
|
||||
host is a :class:`~homeassistant.helpers.update_coordinator.DataUpdateCoordinator`;
|
||||
the assembly itself touches no HA surface.
|
||||
|
||||
This module holds the pure pieces — the snapshot dataclasses
|
||||
(:class:`QuestInstanceView`, :class:`ChildDaySnapshot`,
|
||||
:class:`NestQuestSnapshot`), the cycle-day helper (:func:`_cycle_day`),
|
||||
the panel payload shaper (:func:`instance_payload`), and the async
|
||||
:func:`build_snapshot` builder — so the HA coordinator and the API
|
||||
service's panel snapshot route share ONE implementation of both the
|
||||
snapshot assembly and the entity-facing attribute payload shape.
|
||||
Nothing here imports :mod:`homeassistant`; the builder takes the
|
||||
database, the settings object, and the HA-local ``now`` (a single
|
||||
clock read the caller already resolved, from which it derives
|
||||
``today``) and returns the snapshot, mirroring exactly what the
|
||||
coordinator used to do inline.
|
||||
|
||||
``settings`` is accepted because the API route hands in the entry's
|
||||
:class:`~.settings.NestQuestSettings`; it is CURRENTLY UNUSED — the
|
||||
assembly reads nothing from it — and is retained so the builder's
|
||||
contract stays stable if a future panel feature derives scan caps or
|
||||
rollup behaviour from configuration. No speculative read is wired in.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from .completion import derive_state
|
||||
from .dao_children import ChildrenDao
|
||||
from .dao_instances import CompletionEventsDao, QuestInstancesDao
|
||||
from .dao_presence import PresenceOverridesDao, PresenceSchedulesDao
|
||||
from .dao_rules import QuestDefinitionsDao
|
||||
from .db import NestQuestDatabase
|
||||
from .presence import (
|
||||
MAX_PREVIEW_SCAN_DAYS,
|
||||
PresenceEngine,
|
||||
PresenceOverride,
|
||||
PresenceSchedule,
|
||||
)
|
||||
from .settings import NestQuestSettings
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class QuestInstanceView:
|
||||
"""One instance plus everything the entities render from it."""
|
||||
|
||||
instance_id: int
|
||||
definition_id: int
|
||||
child_id: int
|
||||
title: str
|
||||
icon: str | None
|
||||
window: str
|
||||
due_date: str
|
||||
due_time: str | None
|
||||
state: str # open | done | missed
|
||||
overdue: bool
|
||||
completed_at: str | None
|
||||
was_on_time: bool | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ChildDaySnapshot:
|
||||
"""One active child's day: instances, presence, and rollup counts."""
|
||||
|
||||
child_id: int
|
||||
child_name: str
|
||||
instances: tuple[QuestInstanceView, ...]
|
||||
present: bool
|
||||
due_today: int
|
||||
completed_today: int
|
||||
remaining_today: int
|
||||
completion_pct: int
|
||||
#: For a child away today: the ISO date of their next present day
|
||||
#: (the panel's away plate renders ``Returns <weekday>, <Mon D>``
|
||||
#: from it). ``None`` when the child is present today or no
|
||||
#: present day exists within the presence engine's scan cap.
|
||||
next_present: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NestQuestSnapshot:
|
||||
"""The coordinator's whole-entity data payload."""
|
||||
|
||||
today_iso: str
|
||||
children: tuple[ChildDaySnapshot, ...]
|
||||
cycle_day: int
|
||||
|
||||
|
||||
def _cycle_day(
|
||||
schedules: dict[int, PresenceSchedule], children, today: datetime.date
|
||||
) -> int:
|
||||
"""Return today's 1-based day in the first scheduled child's cycle.
|
||||
|
||||
Anchor-date arithmetic ONLY (D-004): the day offset is computed from
|
||||
the schedule's own anchor, never ISO week numbers or parity, so a
|
||||
53-week year cannot silently invert the cycle.
|
||||
"""
|
||||
for child in children:
|
||||
schedule = schedules.get(child.id)
|
||||
if schedule is None:
|
||||
continue
|
||||
days = (today - schedule.anchor_date).days
|
||||
cycle_length = schedule.cycle_length_weeks * 7
|
||||
return (days % cycle_length) + 1
|
||||
return 0
|
||||
|
||||
|
||||
def instance_payload(
|
||||
instances: Iterable[QuestInstanceView], *, include_missed: bool
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Shape instance dicts per ENTITIES-AND-SERVICES.md §1.
|
||||
|
||||
The panel payload ``state`` is the documented ``open`` | ``completed``
|
||||
spelling, so the builder's derived ``done`` maps to ``completed`` here;
|
||||
``open`` passes through. ``missed`` instances are OMITTED from the
|
||||
entity-facing attribute payload (decision 5) unless ``include_missed``
|
||||
is set — the admin payload (D-009) keeps them with their ``missed``
|
||||
state. ``on_time`` passes the builder's ``was_on_time`` through
|
||||
verbatim (``True`` / ``False`` for a completed instance, ``None`` for
|
||||
an open one). This is the SINGLE shared implementation both the HA
|
||||
sensor layer and the API panel route read; it touches no Home
|
||||
Assistant surface.
|
||||
"""
|
||||
payload: list[dict[str, Any]] = []
|
||||
for view in instances:
|
||||
if view.state == "missed" and not include_missed:
|
||||
continue
|
||||
payload.append(
|
||||
{
|
||||
"id": view.instance_id,
|
||||
"definition_id": view.definition_id,
|
||||
"child_id": view.child_id,
|
||||
"title": view.title,
|
||||
"icon": view.icon,
|
||||
"window": view.window,
|
||||
"due_time": view.due_time,
|
||||
"state": (
|
||||
"completed" if view.state == "done" else view.state
|
||||
),
|
||||
"overdue": view.overdue,
|
||||
"completed_at": view.completed_at,
|
||||
"on_time": view.was_on_time,
|
||||
}
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
async def build_snapshot(
|
||||
database: NestQuestDatabase,
|
||||
settings: NestQuestSettings,
|
||||
now: datetime.datetime,
|
||||
) -> NestQuestSnapshot:
|
||||
"""Build the household panel snapshot for ``today`` at the ``now`` instant.
|
||||
|
||||
Mirrors the assembly that lived in the coordinator's
|
||||
``_async_update_data``: active children in sort order, each child's
|
||||
today-instances with derived state (open / done / missed via
|
||||
:func:`~.completion.derive_state`) and the overdue flag, per-child
|
||||
presence (resolved through :class:`~.presence.PresenceEngine`), the
|
||||
per-child rollup counts, and the custody cycle day of the first
|
||||
scheduled active child. Missed instances are retained on the
|
||||
snapshot (the rollup counts and the panel's missed plate need them)
|
||||
but the entity-facing attribute payload omits them at the
|
||||
:func:`instance_payload` layer — this builder produces the shared
|
||||
shape both consumers read.
|
||||
|
||||
``now`` is the HA-local timezone-aware datetime the caller already
|
||||
resolved (a SINGLE clock read); the overdue check compares
|
||||
``now.time()`` against the instance's ``due_time`` for today's open
|
||||
instances. The builder performs NO internal clock read — it never
|
||||
calls :func:`datetime.datetime.now` — so the instant is pinned in
|
||||
tests. ``today`` is derived INSIDE the builder as ``now.date()``,
|
||||
so the calendar date that anchors the due-date and cycle-day
|
||||
arithmetic is always the SAME clock read as the wall-clock time;
|
||||
the two can never disagree. ``settings`` is accepted but currently
|
||||
unused (see the module docstring).
|
||||
"""
|
||||
today = now.date()
|
||||
today_iso = today.isoformat()
|
||||
|
||||
children = await ChildrenDao(database).list_active()
|
||||
|
||||
schedules: dict[int, PresenceSchedule] = {}
|
||||
overrides: dict[int, list[PresenceOverride]] = {}
|
||||
for child in children:
|
||||
schedule = await PresenceSchedulesDao(database).get_by_child(
|
||||
child.id
|
||||
)
|
||||
if schedule is not None:
|
||||
schedules[child.id] = PresenceSchedule.decode(
|
||||
child.id, schedule.anchor_date, schedule.pattern
|
||||
)
|
||||
window_end = today + datetime.timedelta(
|
||||
days=MAX_PREVIEW_SCAN_DAYS
|
||||
)
|
||||
window_end_iso = window_end.isoformat()
|
||||
ranged = await PresenceOverridesDao(
|
||||
database
|
||||
).list_by_child_and_range(child.id, today_iso, window_end_iso)
|
||||
if ranged:
|
||||
# The engine validates MODEL objects, not DAO records;
|
||||
# each record converts losslessly (the schema guarantees
|
||||
# end >= start and a real is_present).
|
||||
overrides[child.id] = [
|
||||
PresenceOverride(
|
||||
record.child_id,
|
||||
record.start_date,
|
||||
record.end_date,
|
||||
record.is_present,
|
||||
record.note,
|
||||
)
|
||||
for record in ranged
|
||||
]
|
||||
engine = PresenceEngine(schedules, overrides)
|
||||
|
||||
instances_dao = QuestInstancesDao(database)
|
||||
events_dao = CompletionEventsDao(database)
|
||||
definitions_dao = QuestDefinitionsDao(database)
|
||||
|
||||
snapshots: list[ChildDaySnapshot] = []
|
||||
for child in children:
|
||||
instances = await instances_dao.list_by_date_range(
|
||||
child.id, today_iso, today_iso
|
||||
)
|
||||
views: list[QuestInstanceView] = []
|
||||
for instance in instances:
|
||||
latest = await events_dao.get_latest_for_instance(instance.id)
|
||||
definition = await definitions_dao.get(instance.definition_id)
|
||||
state = derive_state(instance, latest, today)
|
||||
overdue = False
|
||||
if state == "open" and instance.due_date == today_iso:
|
||||
if instance.due_time is not None:
|
||||
due_moment = datetime.datetime.strptime(
|
||||
instance.due_time, "%H:%M"
|
||||
).time()
|
||||
overdue = now.time() > due_moment
|
||||
views.append(
|
||||
QuestInstanceView(
|
||||
instance_id=instance.id,
|
||||
definition_id=instance.definition_id,
|
||||
child_id=instance.child_id,
|
||||
title=definition.title if definition else "Quest",
|
||||
icon=definition.icon if definition else None,
|
||||
window=instance.window,
|
||||
due_date=instance.due_date,
|
||||
due_time=instance.due_time,
|
||||
state=state,
|
||||
overdue=overdue,
|
||||
completed_at=(
|
||||
latest.occurred_at
|
||||
if latest is not None
|
||||
and latest.event_type == "completed"
|
||||
else None
|
||||
),
|
||||
was_on_time=(
|
||||
latest.was_on_time
|
||||
if latest is not None
|
||||
and latest.event_type == "completed"
|
||||
else None
|
||||
),
|
||||
)
|
||||
)
|
||||
due = len(views)
|
||||
completed = sum(1 for view in views if view.state == "done")
|
||||
remaining = due - completed
|
||||
child_present = engine.is_present(child.id, today)
|
||||
next_present: str | None = None
|
||||
if not child_present:
|
||||
try:
|
||||
next_present = engine.next_present_dates(
|
||||
child.id, today, 1
|
||||
)[0].isoformat()
|
||||
except ValueError:
|
||||
next_present = None
|
||||
snapshots.append(
|
||||
ChildDaySnapshot(
|
||||
child_id=child.id,
|
||||
child_name=child.display_name,
|
||||
instances=tuple(views),
|
||||
present=child_present,
|
||||
due_today=due,
|
||||
completed_today=completed,
|
||||
remaining_today=remaining,
|
||||
completion_pct=(
|
||||
round(completed * 100 / due) if due else 100
|
||||
),
|
||||
next_present=next_present,
|
||||
)
|
||||
)
|
||||
|
||||
return NestQuestSnapshot(
|
||||
today_iso=today_iso,
|
||||
children=tuple(snapshots),
|
||||
cycle_day=_cycle_day(schedules, children, today),
|
||||
)
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Options flow for NestQuest post-setup settings."""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
@@ -32,17 +33,18 @@ from .const import (
|
||||
DEFAULT_UPDATE_INTERVAL,
|
||||
DOMAIN,
|
||||
MIN_UPDATE_INTERVAL,
|
||||
TIME_PATTERN,
|
||||
)
|
||||
from .admin_allowlist import list_admin_ids, set_admin_ids
|
||||
|
||||
MIN_HORIZON_DAYS = 1
|
||||
MIN_PANEL_IDLE_TIMEOUT = 30
|
||||
|
||||
_TIME_PATTERN = re.compile(r"(?:[01]\d|2[0-3]):[0-5]\d")
|
||||
|
||||
|
||||
def _parse_time(value: str) -> bool:
|
||||
"""Return True when value is a valid 24-hour HH:MM string."""
|
||||
return TIME_PATTERN.fullmatch(value) is not None
|
||||
return _TIME_PATTERN.fullmatch(value) is not None
|
||||
|
||||
|
||||
def _async_validate(user_input: dict[str, Any]) -> dict[str, str]:
|
||||
|
||||
@@ -39,7 +39,6 @@ from .coordinator import (
|
||||
NestQuestCoordinator,
|
||||
QuestInstanceView,
|
||||
)
|
||||
from .core.snapshot import instance_payload
|
||||
|
||||
#: Home Assistant's state machine refuses states longer than 255
|
||||
#: characters (its hard recorder/state limit). These sensors' states
|
||||
@@ -64,6 +63,40 @@ def _state_within_limit(state: Any) -> Any:
|
||||
return state
|
||||
|
||||
|
||||
def _instance_payload(
|
||||
instances: Iterable[QuestInstanceView], *, include_missed: bool
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Shape instance dicts per ENTITIES-AND-SERVICES.md §1.
|
||||
|
||||
``state`` maps the coordinator's derived ``done`` to the panel's
|
||||
``completed`` spelling; ``missed`` instances are omitted unless
|
||||
``include_missed`` (the admin payload, D-009, keeps them with
|
||||
their ``missed`` state).
|
||||
"""
|
||||
payload: list[dict[str, Any]] = []
|
||||
for view in instances:
|
||||
if view.state == "missed" and not include_missed:
|
||||
continue
|
||||
payload.append(
|
||||
{
|
||||
"id": view.instance_id,
|
||||
"definition_id": view.definition_id,
|
||||
"child_id": view.child_id,
|
||||
"title": view.title,
|
||||
"icon": view.icon,
|
||||
"window": view.window,
|
||||
"due_time": view.due_time,
|
||||
"state": (
|
||||
"completed" if view.state == "done" else view.state
|
||||
),
|
||||
"overdue": view.overdue,
|
||||
"completed_at": view.completed_at,
|
||||
"on_time": view.was_on_time,
|
||||
}
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
#: State reported by the next-quest sensor when the child owes nothing
|
||||
#: else today. A documented sentinel, never an empty string (an empty
|
||||
#: state reads as "sensor went unavailable" in HA).
|
||||
@@ -182,10 +215,10 @@ class NestQuestQuestsDueTodaySensor(_NestQuestChildDaySensor):
|
||||
if child is None:
|
||||
return None
|
||||
return {
|
||||
"instances": instance_payload(
|
||||
"instances": _instance_payload(
|
||||
child.instances, include_missed=False
|
||||
),
|
||||
"admin_instances": instance_payload(
|
||||
"admin_instances": _instance_payload(
|
||||
child.instances, include_missed=True
|
||||
),
|
||||
"child_id": child.child_id,
|
||||
|
||||
@@ -24,6 +24,8 @@ from . import children as children_layer
|
||||
from . import completion as completion_layer
|
||||
from . import quest_definitions as quest_definitions_layer
|
||||
from .const import (
|
||||
CONF_HORIZON_DAYS,
|
||||
DEFAULT_HORIZON_DAYS,
|
||||
DOMAIN,
|
||||
DOMAIN_SERVICES,
|
||||
QUEST_WINDOWS,
|
||||
@@ -256,7 +258,12 @@ async def _refresh_entities(runtime: Any) -> None:
|
||||
def _generation_kwargs(hass: HomeAssistant, runtime: Any) -> dict[str, Any]:
|
||||
time_zone = ZoneInfo(hass.config.time_zone)
|
||||
today = datetime.datetime.now(time_zone).date()
|
||||
return {"today": today, "horizon_days": runtime.settings.horizon_days}
|
||||
value = runtime.options.get(CONF_HORIZON_DAYS, DEFAULT_HORIZON_DAYS)
|
||||
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
|
||||
horizon_days = DEFAULT_HORIZON_DAYS
|
||||
else:
|
||||
horizon_days = value
|
||||
return {"today": today, "horizon_days": horizon_days}
|
||||
|
||||
|
||||
def _actor_kwargs(call: Any) -> dict[str, Any]:
|
||||
|
||||
@@ -7,21 +7,11 @@ 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",
|
||||
"jinja2>=3",
|
||||
"pyjwt[crypto]>=2.9",
|
||||
"pytest>=8.0.0",
|
||||
"pytest-asyncio>=0.24",
|
||||
"pyyaml>=6",
|
||||
"uvicorn>=0.30",
|
||||
"voluptuous>=0.14",
|
||||
]
|
||||
|
||||
|
||||
+1
-38
@@ -22,7 +22,6 @@ pytest -q``); it is never meant to run against a real HA install.
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
import importlib.util
|
||||
import inspect
|
||||
import sys
|
||||
@@ -917,40 +916,4 @@ def make_config_flow_entry():
|
||||
def _factory(domain: str, entry_id: str = "existing"):
|
||||
return SimpleNamespace(domain=domain, entry_id=entry_id)
|
||||
|
||||
return _factory
|
||||
|
||||
|
||||
def freeze_nestquest_clock(
|
||||
monkeypatch, instant: datetime.datetime
|
||||
) -> tuple[datetime.datetime, list]:
|
||||
"""Freeze the integration's ``datetime`` clock to a fixed aware instant.
|
||||
|
||||
Only ``custom_components.nestquest``'s ``datetime`` binding is patched,
|
||||
so the generation path's ``datetime.datetime.now`` reads this instant
|
||||
(localized to whatever time zone it asks for) while every other module
|
||||
and the test harness keep the real clock. Returns ``(frozen, requested)``
|
||||
where ``frozen`` is the instant normalized to UTC and ``requested`` is
|
||||
the list of ``tzinfo`` objects the code under test passed to ``now`` —
|
||||
so a caller can prove the generation path actually requested the
|
||||
configured HA time zone rather than silently reading UTC.
|
||||
"""
|
||||
import custom_components.nestquest as nestquest
|
||||
|
||||
frozen = instant.astimezone(datetime.timezone.utc)
|
||||
requested: list = []
|
||||
|
||||
class _FrozenDatetime(datetime.datetime):
|
||||
@classmethod
|
||||
def now(cls, tz=None):
|
||||
requested.append(tz)
|
||||
if tz is None:
|
||||
return frozen.replace(tzinfo=None)
|
||||
return frozen.astimezone(tz)
|
||||
|
||||
fake_module = SimpleNamespace(
|
||||
datetime=_FrozenDatetime,
|
||||
timedelta=datetime.timedelta,
|
||||
date=datetime.date,
|
||||
)
|
||||
monkeypatch.setattr(nestquest, "datetime", fake_module)
|
||||
return frozen, requested
|
||||
return _factory
|
||||
@@ -1,791 +0,0 @@
|
||||
"""Admin-plane JWT auth tests: Authentik-style OIDC verification (task e70e6bfb).
|
||||
|
||||
These tests exercise the NestQuest API's admin plane against the ASGI
|
||||
app with httpx, using a locally generated RSA key pair and a stubbed
|
||||
JWKS fetcher — NO network access:
|
||||
|
||||
- ``GET /api/v1/admin/ping`` requires a valid Authentik OIDC JWT: a
|
||||
request with NO ``Authorization`` header and a MALFORMED header
|
||||
return 401; a garbage, expired, wrong-issuer, wrong-audience,
|
||||
not-yet-valid (future ``nbf``), unknown-``kid``, or bad-signature
|
||||
token returns 401; a token without ``exp`` is rejected; a VALID
|
||||
token returns 200 with the verified subject.
|
||||
- Every 401 carries the SAME detail — nothing in the response
|
||||
distinguishes which check failed.
|
||||
- The token's HEADER is validated BEFORE the JWKS cache is touched: a
|
||||
garbage or non-RS256 token triggers NO fetch at all.
|
||||
- A JWKS entry that matches the ``kid`` but carries corrupt key
|
||||
material maps to the same uniform 401.
|
||||
- The JWKS document is fetched ONCE through the app's
|
||||
:class:`api.auth.JwksCache` and then reused across requests (the
|
||||
stub fetcher counts calls); a zero TTL refetches.
|
||||
- A JWKS OUTAGE fails fast: after one failed fetch the cache re-raises
|
||||
the fault for the whole backoff window without further fetches, even
|
||||
under a burst of concurrent requests; an unknown ``kid`` forces one
|
||||
TTL-bypassing refetch, throttled to at most one per interval.
|
||||
|
||||
``ApiConfig.from_env`` requires the three OIDC variables
|
||||
(``NESTQUEST_OIDC_ISSUER``, ``NESTQUEST_OIDC_AUDIENCE``,
|
||||
``NESTQUEST_OIDC_JWKS_URL``) with the same fail-fast discipline as
|
||||
``NESTQUEST_DB_PATH`` / ``NESTQUEST_PANEL_TOKEN``: tests build the app
|
||||
with explicit ApiConfig values so they stay hermetic.
|
||||
|
||||
The signature/expiry/audience proof is real, not stubbed: tokens are
|
||||
signed with a locally generated RSA private key, the app verifies
|
||||
against the JWKS built from its public key, and the wrong-signature
|
||||
case signs with a SECOND locally generated key pair.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import jwt
|
||||
import pytest
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
|
||||
from api.app import create_app
|
||||
from api.auth import JwksCache
|
||||
from api.config import ApiConfig, ConfigError
|
||||
|
||||
#: The configured OIDC settings the test apps run with (values a real
|
||||
#: deployment would get from the NESTQUEST_OIDC_* environment vars).
|
||||
ISSUER = "https://authentik.example.com/application/o/nestquest/"
|
||||
AUDIENCE = "nestquest-api"
|
||||
JWKS_URL = "https://authentik.example.com/application/o/nestquest/jwks/"
|
||||
|
||||
#: The ``kid`` the test tokens carry, matching the JWKS entry.
|
||||
KID = "admin-test-key-1"
|
||||
|
||||
#: The subject a valid token authenticates as.
|
||||
SUBJECT = "admin-user-1"
|
||||
|
||||
#: The uniform 401 detail every rejection must carry.
|
||||
REJECTION_DETAIL = "Missing or invalid admin credentials"
|
||||
|
||||
|
||||
def _generate_key() -> rsa.RSAPrivateKey:
|
||||
"""Generate one local RSA key pair (no network, no fixtures)."""
|
||||
return rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
|
||||
|
||||
#: The key pair whose PUBLIC half the stubbed JWKS publishes — tokens
|
||||
#: signed with it verify; the second pair below proves they don't when
|
||||
#: signed otherwise.
|
||||
ADMIN_KEY = _generate_key()
|
||||
OTHER_KEY = _generate_key()
|
||||
|
||||
|
||||
def _b64url_uint(value: int) -> str:
|
||||
"""Base64url-encode a non-negative integer (no padding), per JWK."""
|
||||
raw = value.to_bytes((value.bit_length() + 7) // 8, "big")
|
||||
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
|
||||
|
||||
|
||||
def _jwks_for(private_key: rsa.RSAPrivateKey, kid: str) -> dict[str, object]:
|
||||
"""Build a JWKS document publishing ``private_key``'s public half."""
|
||||
numbers = private_key.public_key().public_numbers()
|
||||
return {
|
||||
"keys": [
|
||||
{
|
||||
"kty": "RSA",
|
||||
"use": "sig",
|
||||
"alg": "RS256",
|
||||
"kid": kid,
|
||||
"n": _b64url_uint(numbers.n),
|
||||
"e": _b64url_uint(numbers.e),
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
class _StubbedFetch:
|
||||
"""A JWKS fetcher stub: returns a fixed document, counts calls."""
|
||||
|
||||
def __init__(self, document: dict[str, object]) -> None:
|
||||
self.document = document
|
||||
self.calls = 0
|
||||
|
||||
async def __call__(self, url: str) -> dict[str, object]:
|
||||
self.calls += 1
|
||||
return self.document
|
||||
|
||||
|
||||
class _FailingFetch:
|
||||
"""A JWKS fetcher stub that always fails, counting the attempts."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.calls = 0
|
||||
|
||||
async def __call__(self, url: str) -> dict[str, object]:
|
||||
self.calls += 1
|
||||
raise httpx.ConnectError("provider unreachable")
|
||||
|
||||
|
||||
class _FlakyFetch:
|
||||
"""A fetcher that succeeds ONCE, then fails on every later call."""
|
||||
|
||||
def __init__(self, document: dict[str, object]) -> None:
|
||||
self.document = document
|
||||
self.calls = 0
|
||||
|
||||
async def __call__(self, url: str) -> dict[str, object]:
|
||||
self.calls += 1
|
||||
if self.calls == 1:
|
||||
return self.document
|
||||
raise httpx.ConnectError("provider unreachable")
|
||||
|
||||
|
||||
class _FailThenServe:
|
||||
"""A fetcher that fails the FIRST call, then serves the document."""
|
||||
|
||||
def __init__(self, document: dict[str, object]) -> None:
|
||||
self.document = document
|
||||
self.calls = 0
|
||||
|
||||
async def __call__(self, url: str) -> dict[str, object]:
|
||||
self.calls += 1
|
||||
if self.calls == 1:
|
||||
raise httpx.ConnectError("provider unreachable")
|
||||
return self.document
|
||||
|
||||
|
||||
def _make_token(
|
||||
signing_key: rsa.RSAPrivateKey = ADMIN_KEY,
|
||||
*,
|
||||
kid: str | None = KID,
|
||||
iss: str = ISSUER,
|
||||
aud: object = AUDIENCE,
|
||||
sub: str = SUBJECT,
|
||||
expires_in: int | None = 300,
|
||||
nbf_in: int | None = -1,
|
||||
) -> str:
|
||||
"""Mint one RS256 JWT the way Authentik would (its claims, our key)."""
|
||||
now = int(time.time())
|
||||
claims: dict[str, object] = {"iss": iss, "aud": aud, "sub": sub, "iat": now}
|
||||
if nbf_in is not None:
|
||||
claims["nbf"] = now + nbf_in
|
||||
if expires_in is not None:
|
||||
claims["exp"] = now + expires_in
|
||||
headers: dict[str, str] = {}
|
||||
if kid is not None:
|
||||
headers["kid"] = kid
|
||||
return jwt.encode(
|
||||
claims, signing_key, algorithm="RS256", headers=headers
|
||||
)
|
||||
|
||||
|
||||
def _token_with_header(
|
||||
header: dict[str, object], payload: dict[str, object]
|
||||
) -> str:
|
||||
"""Build a JWT with an EXACT header (bypassing jwt.encode's rules).
|
||||
|
||||
Used to prove header checks (``alg``, ``kid``) happen before the
|
||||
JWKS cache is consulted; the signature segment is never reached.
|
||||
"""
|
||||
def part(obj: dict[str, object]) -> str:
|
||||
raw = json.dumps(obj).encode("utf-8")
|
||||
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
|
||||
|
||||
return f"{part(header)}.{part(payload)}.c2lnbmF0dXJl"
|
||||
|
||||
|
||||
def _verified_payload() -> dict[str, object]:
|
||||
"""Claims that would verify — the header checks fire before them."""
|
||||
now = int(time.time())
|
||||
return {
|
||||
"iss": ISSUER,
|
||||
"aud": AUDIENCE,
|
||||
"sub": SUBJECT,
|
||||
"exp": now + 300,
|
||||
"nbf": now - 1,
|
||||
}
|
||||
|
||||
|
||||
class _AdminRunner:
|
||||
"""Run the app's lifespan around an httpx AsyncClient.
|
||||
|
||||
Same pattern as test_api_health.py's ``_AppRunner``: httpx's ASGI
|
||||
transport does not run the lifespan, so it is driven explicitly and
|
||||
shut down in ``__aexit__``'s finally block even on failure.
|
||||
|
||||
The app's JWKS cache is replaced (before the lifespan runs) with
|
||||
one built around a counting stub fetcher, so verification goes
|
||||
through the REAL cache logic but never the network.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
db_path: str,
|
||||
jwks_document: dict[str, object],
|
||||
*,
|
||||
ttl_seconds: float | None = None,
|
||||
backoff_seconds: float | None = None,
|
||||
kid_refetch_seconds: float | None = None,
|
||||
) -> None:
|
||||
self.app = create_app(
|
||||
ApiConfig(
|
||||
db_path=db_path,
|
||||
panel_token="admin-test-panel-token",
|
||||
oidc_issuer=ISSUER,
|
||||
oidc_audience=AUDIENCE,
|
||||
oidc_jwks_url=JWKS_URL,
|
||||
)
|
||||
)
|
||||
self.fetch = _StubbedFetch(jwks_document)
|
||||
cache_kwargs: dict[str, float] = {}
|
||||
if ttl_seconds is not None:
|
||||
cache_kwargs["ttl_seconds"] = ttl_seconds
|
||||
if backoff_seconds is not None:
|
||||
cache_kwargs["backoff_seconds"] = backoff_seconds
|
||||
if kid_refetch_seconds is not None:
|
||||
cache_kwargs["kid_refetch_seconds"] = kid_refetch_seconds
|
||||
self.app.state.jwks_cache = JwksCache(
|
||||
JWKS_URL, fetcher=self.fetch, **cache_kwargs
|
||||
)
|
||||
|
||||
async def __aenter__(self) -> httpx.AsyncClient:
|
||||
self._lifespan = self.app.router.lifespan_context(self.app)
|
||||
await self._lifespan.__aenter__()
|
||||
self._client = httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=self.app),
|
||||
base_url="http://testserver",
|
||||
)
|
||||
return await self._client.__aenter__()
|
||||
|
||||
async def __aexit__(self, *exc) -> None:
|
||||
try:
|
||||
await self._client.__aexit__(*exc)
|
||||
finally:
|
||||
await self._lifespan.__aexit__(*exc)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_db_path(tmp_path: Path) -> str:
|
||||
"""A fresh per-test SQLite path under pytest's tmp_path."""
|
||||
return str(tmp_path / "nestquest.db")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def admin_client(
|
||||
temp_db_path: str,
|
||||
) -> httpx.AsyncClient:
|
||||
"""An app client whose provider publishes the admin test key."""
|
||||
async with _AdminRunner(temp_db_path, _jwks_for(ADMIN_KEY, KID)) as client:
|
||||
yield client
|
||||
|
||||
|
||||
# --- the happy path -------------------------------------------------------
|
||||
|
||||
|
||||
async def test_valid_token_returns_200_with_subject(
|
||||
admin_client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
"""A valid token returns 200 with the status and verified subject."""
|
||||
response = await admin_client.get(
|
||||
"/api/v1/admin/ping",
|
||||
headers={"Authorization": f"Bearer {_make_token()}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "ok", "sub": SUBJECT}
|
||||
|
||||
|
||||
async def test_bearer_scheme_is_case_insensitive(
|
||||
admin_client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
"""``bearer`` (lowercase) is accepted, as RFC 9110 requires."""
|
||||
response = await admin_client.get(
|
||||
"/api/v1/admin/ping",
|
||||
headers={"Authorization": f"bearer {_make_token()}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
# --- 401s: missing / malformed credentials --------------------------------
|
||||
|
||||
|
||||
async def test_missing_token_is_unauthorized(
|
||||
admin_client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
"""A request with no Authorization header returns 401."""
|
||||
response = await admin_client.get("/api/v1/admin/ping")
|
||||
assert response.status_code == 401
|
||||
assert response.json()["detail"] == REJECTION_DETAIL
|
||||
assert "www-authenticate" in {
|
||||
key.lower() for key in response.headers
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"header",
|
||||
[
|
||||
"Basic cGFuZWw6dG9rZW4=", # wrong scheme
|
||||
"Bearer", # scheme, no credential
|
||||
"Bearer ", # blank credential
|
||||
"BearerNotAToken", # no scheme separator at all
|
||||
],
|
||||
)
|
||||
async def test_malformed_header_is_unauthorized(
|
||||
admin_client: httpx.AsyncClient, header: str
|
||||
) -> None:
|
||||
"""A malformed Authorization header returns 401."""
|
||||
response = await admin_client.get(
|
||||
"/api/v1/admin/ping", headers={"Authorization": header}
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
async def test_garbage_token_is_unauthorized(
|
||||
admin_client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
"""A Bearer credential that is not a JWT at all returns 401."""
|
||||
response = await admin_client.get(
|
||||
"/api/v1/admin/ping",
|
||||
headers={"Authorization": "Bearer not-a-jwt"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
# --- 401s: token content the verifier must reject ---------------------------
|
||||
|
||||
|
||||
async def test_expired_token_is_unauthorized(
|
||||
admin_client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
"""A token whose exp has passed returns 401."""
|
||||
token = _make_token(expires_in=-60)
|
||||
response = await admin_client.get(
|
||||
"/api/v1/admin/ping",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
async def test_wrong_issuer_is_unauthorized(
|
||||
admin_client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
"""A token issued by another issuer returns 401."""
|
||||
token = _make_token(iss="https://evil.example.com/application/o/nestquest/")
|
||||
response = await admin_client.get(
|
||||
"/api/v1/admin/ping",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
async def test_wrong_audience_is_unauthorized(
|
||||
admin_client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
"""A token minted for another audience returns 401."""
|
||||
token = _make_token(aud="some-other-client")
|
||||
response = await admin_client.get(
|
||||
"/api/v1/admin/ping",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
async def test_future_nbf_is_unauthorized(
|
||||
admin_client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
"""A token that is not yet valid (future nbf) returns 401."""
|
||||
token = _make_token(nbf_in=3600)
|
||||
response = await admin_client.get(
|
||||
"/api/v1/admin/ping",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
async def test_token_without_exp_is_unauthorized(
|
||||
admin_client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
"""A token with no exp claim returns 401 (expiry is REQUIRED)."""
|
||||
token = _make_token(expires_in=None)
|
||||
response = await admin_client.get(
|
||||
"/api/v1/admin/ping",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
async def test_unknown_kid_is_unauthorized(
|
||||
admin_client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
"""A token naming a kid the JWKS does not publish returns 401."""
|
||||
token = _make_token(kid="no-such-key")
|
||||
response = await admin_client.get(
|
||||
"/api/v1/admin/ping",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
async def test_bad_signature_is_unauthorized(
|
||||
admin_client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
"""A token signed by a key the provider never published returns 401.
|
||||
|
||||
Signed with the SECOND local key pair (header kid still points at
|
||||
the published key), so the signature check itself must fail.
|
||||
"""
|
||||
token = _make_token(signing_key=OTHER_KEY)
|
||||
response = await admin_client.get(
|
||||
"/api/v1/admin/ping",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
async def test_corrupt_jwks_entry_matching_kid_is_unauthorized(
|
||||
temp_db_path: str,
|
||||
) -> None:
|
||||
"""A JWKS entry matching the kid but with corrupt key data → 401.
|
||||
|
||||
``RSAAlgorithm.from_jwk`` raises ValueError/TypeError (NOT a
|
||||
PyJWTError) on such an entry; it must map to the SAME uniform 401,
|
||||
not surface as a server error.
|
||||
"""
|
||||
corrupt_jwks: dict[str, object] = {
|
||||
"keys": [
|
||||
{"kty": "RSA", "kid": KID, "n": "AQ", "e": "AQAB"},
|
||||
]
|
||||
}
|
||||
runner = _AdminRunner(temp_db_path, corrupt_jwks)
|
||||
async with runner as client:
|
||||
response = await client.get(
|
||||
"/api/v1/admin/ping",
|
||||
headers={"Authorization": f"Bearer {_make_token()}"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
assert response.json()["detail"] == REJECTION_DETAIL
|
||||
|
||||
|
||||
# --- the header is checked BEFORE the JWKS cache -----------------------------
|
||||
|
||||
|
||||
async def test_garbage_token_triggers_no_jwks_fetch(
|
||||
temp_db_path: str,
|
||||
) -> None:
|
||||
"""A token that is not a JWT costs NO fetch, lock or provider load."""
|
||||
runner = _AdminRunner(temp_db_path, _jwks_for(ADMIN_KEY, KID))
|
||||
async with runner as client:
|
||||
response = await client.get(
|
||||
"/api/v1/admin/ping",
|
||||
headers={"Authorization": "Bearer not-a-jwt"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
assert response.json()["detail"] == REJECTION_DETAIL
|
||||
assert runner.fetch.calls == 0
|
||||
|
||||
|
||||
async def test_non_rs256_token_triggers_no_jwks_fetch(
|
||||
temp_db_path: str,
|
||||
) -> None:
|
||||
"""A structurally valid JWT with a non-RS256 alg is 401 pre-cache.
|
||||
|
||||
The header carries a kid pointing at the published key, so ONLY the
|
||||
early alg check can reject it — the fetcher must never be called.
|
||||
"""
|
||||
runner = _AdminRunner(temp_db_path, _jwks_for(ADMIN_KEY, KID))
|
||||
token = _token_with_header(
|
||||
{"alg": "HS256", "typ": "JWT", "kid": KID}, _verified_payload()
|
||||
)
|
||||
async with runner as client:
|
||||
response = await client.get(
|
||||
"/api/v1/admin/ping",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
assert response.json()["detail"] == REJECTION_DETAIL
|
||||
assert runner.fetch.calls == 0
|
||||
|
||||
|
||||
# --- the uniform 401 contract -----------------------------------------------
|
||||
|
||||
|
||||
async def test_every_rejection_carries_the_same_detail(
|
||||
admin_client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
"""No failure mode is distinguishable in the response body.
|
||||
|
||||
Collects the (status, detail) pair for every rejection — missing
|
||||
and malformed headers, garbage, expired, wrong issuer, wrong
|
||||
audience, future nbf, missing exp, unknown kid, bad signature —
|
||||
and asserts they are ALL the same one 401.
|
||||
"""
|
||||
cases: list[dict[str, str]] = [
|
||||
{},
|
||||
{"Authorization": "Basic cGFuZWw6dG9rZW4="},
|
||||
{"Authorization": "Bearer"},
|
||||
{"Authorization": "Bearer not-a-jwt"},
|
||||
{"Authorization": f"Bearer {_make_token(expires_in=-60)}"},
|
||||
{"Authorization": f"Bearer {_make_token(iss='https://evil.example.com/')}"},
|
||||
{"Authorization": f"Bearer {_make_token(aud='other-client')}"},
|
||||
{"Authorization": f"Bearer {_make_token(nbf_in=3600)}"},
|
||||
{"Authorization": f"Bearer {_make_token(expires_in=None)}"},
|
||||
{"Authorization": f"Bearer {_make_token(kid='no-such-key')}"},
|
||||
{"Authorization": f"Bearer {_make_token(signing_key=OTHER_KEY)}"},
|
||||
]
|
||||
observed = set()
|
||||
for headers in cases:
|
||||
response = await admin_client.get("/api/v1/admin/ping", headers=headers)
|
||||
observed.add((response.status_code, response.json()["detail"]))
|
||||
assert observed == {(401, REJECTION_DETAIL)}
|
||||
|
||||
|
||||
# --- the JWKS cache ----------------------------------------------------------
|
||||
|
||||
|
||||
async def test_jwks_is_fetched_once_across_requests(
|
||||
temp_db_path: str,
|
||||
) -> None:
|
||||
"""Multiple requests reuse ONE cached JWKS document.
|
||||
|
||||
Three separately authenticated requests trigger exactly ONE stub
|
||||
fetch — the cache is per app and never per request.
|
||||
"""
|
||||
runner = _AdminRunner(temp_db_path, _jwks_for(ADMIN_KEY, KID))
|
||||
token = _make_token()
|
||||
async with runner as client:
|
||||
for _ in range(3):
|
||||
response = await client.get(
|
||||
"/api/v1/admin/ping",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert runner.fetch.calls == 1
|
||||
|
||||
|
||||
async def test_expired_ttl_refetches_the_jwks(temp_db_path: str) -> None:
|
||||
"""A lapsed TTL triggers a fresh fetch instead of serving stale keys."""
|
||||
runner = _AdminRunner(
|
||||
temp_db_path, _jwks_for(ADMIN_KEY, KID), ttl_seconds=0.0
|
||||
)
|
||||
token = _make_token()
|
||||
async with runner as client:
|
||||
for _ in range(2):
|
||||
response = await client.get(
|
||||
"/api/v1/admin/ping",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert runner.fetch.calls == 2
|
||||
|
||||
|
||||
# --- the JWKS cache under an outage: fail fast, don't amplify ---------------
|
||||
|
||||
|
||||
async def test_jwks_outage_fails_fast_during_backoff() -> None:
|
||||
"""After one failed fetch, later requests fail fast for the window."""
|
||||
fetch = _FailingFetch()
|
||||
cache = JwksCache(JWKS_URL, fetcher=fetch)
|
||||
for _ in range(4):
|
||||
with pytest.raises(httpx.ConnectError):
|
||||
await cache.get()
|
||||
assert fetch.calls == 1
|
||||
|
||||
|
||||
async def test_jwks_outage_burst_calls_the_fetcher_once() -> None:
|
||||
"""A burst of concurrent requests costs ONE fetch, not one each.
|
||||
|
||||
The first caller fetches and fails; every other caller — queued on
|
||||
the lock or arriving inside the backoff window — fails fast on the
|
||||
recorded fault without touching the fetcher.
|
||||
"""
|
||||
fetch = _FailingFetch()
|
||||
cache = JwksCache(JWKS_URL, fetcher=fetch)
|
||||
results = await asyncio.gather(
|
||||
*(cache.get() for _ in range(6)), return_exceptions=True
|
||||
)
|
||||
assert fetch.calls == 1
|
||||
assert all(
|
||||
isinstance(result, httpx.ConnectError) for result in results
|
||||
)
|
||||
|
||||
|
||||
async def test_jwks_backoff_lapses_and_recovers() -> None:
|
||||
"""A backoff window is not a lockout: retries resume after it.
|
||||
|
||||
With a zero-length window the very next request retries (and the
|
||||
successful document is then served from the cache), proving a
|
||||
failed state is never cached for good.
|
||||
"""
|
||||
document = _jwks_for(ADMIN_KEY, KID)
|
||||
fetch = _FailThenServe(document)
|
||||
cache = JwksCache(JWKS_URL, fetcher=fetch, backoff_seconds=0.0)
|
||||
with pytest.raises(httpx.ConnectError):
|
||||
await cache.get()
|
||||
assert await cache.get() == document
|
||||
assert await cache.get() == document
|
||||
assert fetch.calls == 2
|
||||
|
||||
|
||||
async def test_jwks_outage_is_a_server_fault_not_a_401(
|
||||
temp_db_path: str,
|
||||
) -> None:
|
||||
"""A provider outage propagates as a fault, never as a 401."""
|
||||
runner = _AdminRunner(temp_db_path, _jwks_for(ADMIN_KEY, KID))
|
||||
runner.app.state.jwks_cache = JwksCache(JWKS_URL, fetcher=_FailingFetch())
|
||||
async with runner as client:
|
||||
with pytest.raises(httpx.ConnectError):
|
||||
await client.get(
|
||||
"/api/v1/admin/ping",
|
||||
headers={"Authorization": f"Bearer {_make_token()}"},
|
||||
)
|
||||
|
||||
|
||||
# --- the unknown-kid refetch: one forced, bounded attempt --------------------
|
||||
|
||||
|
||||
async def test_unknown_kid_refetch_picks_up_a_rotated_key(
|
||||
temp_db_path: str,
|
||||
) -> None:
|
||||
"""An unmatched kid forces ONE refetch, and the retry can verify.
|
||||
|
||||
The JWKS first publishes a DIFFERENT kid: the token is 401 after
|
||||
exactly two fetches (normal + forced). When the rotated key lands
|
||||
in the provider's document, the NEXT token forces another refetch
|
||||
that picks it up — the same token verifies WITHOUT waiting out the
|
||||
TTL.
|
||||
"""
|
||||
runner = _AdminRunner(
|
||||
temp_db_path, _jwks_for(ADMIN_KEY, "other-key"), kid_refetch_seconds=0.0
|
||||
)
|
||||
token = _make_token()
|
||||
async with runner as client:
|
||||
response = await client.get(
|
||||
"/api/v1/admin/ping",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
assert response.json()["detail"] == REJECTION_DETAIL
|
||||
assert runner.fetch.calls == 2
|
||||
|
||||
# The rotation lands at the provider (same stub fetcher).
|
||||
runner.fetch.document = _jwks_for(ADMIN_KEY, KID)
|
||||
response = await client.get(
|
||||
"/api/v1/admin/ping",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert runner.fetch.calls == 3
|
||||
|
||||
|
||||
async def test_unknown_kid_refetch_is_throttled(temp_db_path: str) -> None:
|
||||
"""Back-to-back unknown-kid tokens force at most one refetch.
|
||||
|
||||
The interval bounds forced refetches: the second unknown-kid token
|
||||
inside it is served from the cached document (still unmatched →
|
||||
401) with no further fetch.
|
||||
"""
|
||||
runner = _AdminRunner(temp_db_path, _jwks_for(ADMIN_KEY, "other-key"))
|
||||
token = _make_token()
|
||||
async with runner as client:
|
||||
for _ in range(2):
|
||||
response = await client.get(
|
||||
"/api/v1/admin/ping",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
assert runner.fetch.calls == 2
|
||||
|
||||
|
||||
async def test_unknown_kid_refetch_resumes_after_the_interval(
|
||||
temp_db_path: str,
|
||||
) -> None:
|
||||
"""A lapsed interval allows the next forced refetch again.
|
||||
|
||||
With a zero-length interval every request may force its refetch:
|
||||
the first costs one normal fetch plus one forced (two), and the
|
||||
second — its cached document still fresh — costs just the forced
|
||||
one (three in total), proving the throttle is a bounded interval
|
||||
rather than a permanent lockout.
|
||||
"""
|
||||
runner = _AdminRunner(
|
||||
temp_db_path, _jwks_for(ADMIN_KEY, "other-key"), kid_refetch_seconds=0.0
|
||||
)
|
||||
token = _make_token()
|
||||
async with runner as client:
|
||||
for _ in range(2):
|
||||
response = await client.get(
|
||||
"/api/v1/admin/ping",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
assert runner.fetch.calls == 3
|
||||
|
||||
|
||||
async def test_failed_forced_refetch_propagates_then_fails_fast(
|
||||
temp_db_path: str,
|
||||
) -> None:
|
||||
"""A failed forced refetch is a fault, and its backoff bounds the rest.
|
||||
|
||||
First request: cache fills (1 fetch), the kid is unmatched, the
|
||||
forced refetch FAILS (2 fetches) and propagates as the server
|
||||
fault. Second request inside the backoff window: the cached
|
||||
document is served, the match fails, and the token is 401 — with
|
||||
NO third fetch.
|
||||
"""
|
||||
runner = _AdminRunner(temp_db_path, _jwks_for(ADMIN_KEY, KID))
|
||||
runner.app.state.jwks_cache = JwksCache(
|
||||
JWKS_URL, fetcher=_FlakyFetch(_jwks_for(ADMIN_KEY, "other-key"))
|
||||
)
|
||||
token = _make_token()
|
||||
async with runner as client:
|
||||
with pytest.raises(httpx.ConnectError):
|
||||
await client.get(
|
||||
"/api/v1/admin/ping",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
response = await client.get(
|
||||
"/api/v1/admin/ping",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
assert response.json()["detail"] == REJECTION_DETAIL
|
||||
|
||||
|
||||
# --- ApiConfig.from_env: the OIDC settings -----------------------------------
|
||||
|
||||
|
||||
def _full_env() -> dict[str, str]:
|
||||
"""A from_env dict with every required variable set to a valid value."""
|
||||
return {
|
||||
"NESTQUEST_DB_PATH": "/data/nq.db",
|
||||
"NESTQUEST_PANEL_TOKEN": "panel-token",
|
||||
"NESTQUEST_OIDC_ISSUER": ISSUER,
|
||||
"NESTQUEST_OIDC_AUDIENCE": AUDIENCE,
|
||||
"NESTQUEST_OIDC_JWKS_URL": JWKS_URL,
|
||||
}
|
||||
|
||||
|
||||
def test_from_env_reads_oidc_settings() -> None:
|
||||
"""Set, non-empty OIDC env values are returned verbatim."""
|
||||
cfg = ApiConfig.from_env(_full_env())
|
||||
assert cfg.oidc_issuer == ISSUER
|
||||
assert cfg.oidc_audience == AUDIENCE
|
||||
assert cfg.oidc_jwks_url == JWKS_URL
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"env_var",
|
||||
[
|
||||
"NESTQUEST_OIDC_ISSUER",
|
||||
"NESTQUEST_OIDC_AUDIENCE",
|
||||
"NESTQUEST_OIDC_JWKS_URL",
|
||||
],
|
||||
)
|
||||
def test_from_env_requires_oidc_setting(env_var: str) -> None:
|
||||
"""A missing OR empty OIDC variable raises ConfigError (fail fast)."""
|
||||
env = _full_env()
|
||||
del env[env_var]
|
||||
with pytest.raises(ConfigError):
|
||||
ApiConfig.from_env(env)
|
||||
env[env_var] = " "
|
||||
with pytest.raises(ConfigError):
|
||||
ApiConfig.from_env(env)
|
||||
@@ -1,355 +0,0 @@
|
||||
"""FastAPI skeleton tests: health, OpenAPI, core wiring (task 042dfc5c).
|
||||
|
||||
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``)
|
||||
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) 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
|
||||
``homeassistant``. That cannot run inside the pytest process because
|
||||
``tests/conftest.py`` installs mock ``homeassistant`` modules globally
|
||||
before any test imports the integration; a subprocess with a clean
|
||||
``sys.modules`` is the only honest proof (the same approach
|
||||
``tests/test_core_no_ha_smoke.py`` takes for the core bundle).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from api.app import create_app
|
||||
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
|
||||
|
||||
#: The marker the no-HA subprocess prints on success.
|
||||
_SUCCESS_MARKER = "API NO-HA IMPORT OK"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_db_path(tmp_path: Path) -> str:
|
||||
"""A fresh per-test SQLite path under pytest's tmp_path."""
|
||||
return str(tmp_path / "nestquest.db")
|
||||
|
||||
|
||||
class _AppRunner:
|
||||
"""Run the app's lifespan around an httpx AsyncClient.
|
||||
|
||||
httpx's :class:`ASGITransport` does NOT run the ASGI lifespan on its
|
||||
own, so the database would never open. Starlette exposes the app's
|
||||
lifespan as ``app.router.lifespan_context``; wrapping the client
|
||||
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:
|
||||
# The token and OIDC values are irrelevant to these tests (the
|
||||
# health route is unauthenticated); they just satisfy the
|
||||
# required fields — the panel token's own tests live in
|
||||
# test_api_panel.py and the OIDC settings' in
|
||||
# test_api_admin_auth.py.
|
||||
self.app = create_app(
|
||||
ApiConfig(
|
||||
db_path=db_path,
|
||||
panel_token="health-runner-token",
|
||||
oidc_issuer="https://authentik.example.com/application/o/nestquest/",
|
||||
oidc_audience="nestquest-api",
|
||||
oidc_jwks_url=(
|
||||
"https://authentik.example.com/application/o/nestquest/jwks/"
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
async def __aenter__(self) -> httpx.AsyncClient:
|
||||
self._lifespan = self.app.router.lifespan_context(self.app)
|
||||
await self._lifespan.__aenter__()
|
||||
self._client = httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=self.app),
|
||||
base_url="http://testserver",
|
||||
)
|
||||
return await self._client.__aenter__()
|
||||
|
||||
async def __aexit__(self, *exc) -> None:
|
||||
try:
|
||||
await self._client.__aexit__(*exc)
|
||||
finally:
|
||||
await self._lifespan.__aexit__(*exc)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def app_client(temp_db_path: str) -> httpx.AsyncClient:
|
||||
async with _AppRunner(temp_db_path) as client:
|
||||
yield client
|
||||
|
||||
|
||||
# --- ApiConfig.from_env -------------------------------------------------
|
||||
|
||||
|
||||
def test_from_env_returns_set_value() -> None:
|
||||
"""Set, non-empty env values are returned verbatim."""
|
||||
cfg = ApiConfig.from_env(
|
||||
{
|
||||
"NESTQUEST_DB_PATH": "/data/nq.db",
|
||||
"NESTQUEST_PANEL_TOKEN": "panel-token",
|
||||
"NESTQUEST_OIDC_ISSUER": "https://idp.example.com/issuer/",
|
||||
"NESTQUEST_OIDC_AUDIENCE": "nestquest-api",
|
||||
"NESTQUEST_OIDC_JWKS_URL": "https://idp.example.com/jwks/",
|
||||
}
|
||||
)
|
||||
assert cfg.db_path == "/data/nq.db"
|
||||
assert cfg.panel_token == "panel-token"
|
||||
|
||||
|
||||
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,
|
||||
) -> None:
|
||||
"""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 "db_path" not in body
|
||||
|
||||
|
||||
async def test_openapi_document_is_served(
|
||||
app_client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
"""GET /openapi.json returns the OpenAPI document."""
|
||||
response = await app_client.get("/openapi.json")
|
||||
assert response.status_code == 200
|
||||
schema = response.json()
|
||||
assert schema["openapi"].startswith("3.")
|
||||
assert "/health" in schema["paths"]
|
||||
|
||||
|
||||
async def test_docs_swagger_ui_is_served(app_client: httpx.AsyncClient) -> None:
|
||||
"""GET /docs serves the Swagger UI HTML (OpenAPI at /docs)."""
|
||||
response = await app_client.get("/docs")
|
||||
assert response.status_code == 200
|
||||
assert "swagger" in response.text.lower()
|
||||
|
||||
|
||||
# --- core wiring & shutdown --------------------------------------------
|
||||
|
||||
|
||||
async def test_app_wires_core_and_applies_migrations(
|
||||
temp_db_path: str,
|
||||
) -> None:
|
||||
"""The lifespan opens the SQLite file and applies migrations.
|
||||
|
||||
After startup, the file exists and carries the NestQuest schema
|
||||
version stamp — proving the app imported core, opened the database
|
||||
at the configured path, and ran the migration runner.
|
||||
"""
|
||||
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. 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
|
||||
|
||||
database = core_db.NestQuestDatabase(_executor)
|
||||
await database.open(temp_db_path)
|
||||
try:
|
||||
version = await core_migrations.read_schema_version(database)
|
||||
finally:
|
||||
await database.close()
|
||||
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``, 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, (
|
||||
"homeassistant already in sys.modules before app import — the "
|
||||
"subprocess is not a clean no-HA process"
|
||||
)
|
||||
|
||||
from api.app import create_app
|
||||
from api.config import ApiConfig
|
||||
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 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 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 loader "
|
||||
"executed the integration's HA-coupled __init__ instead of loading "
|
||||
"core by file location"
|
||||
)
|
||||
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;
|
||||
# the token and OIDC values are irrelevant to this import-only check.
|
||||
app = create_app(ApiConfig(
|
||||
db_path=str(Path(tempfile.gettempdir()) / "nq-noha-buildonly.db"),
|
||||
panel_token="noha-import-token",
|
||||
oidc_issuer="https://authentik.example.com/application/o/nestquest/",
|
||||
oidc_audience="nestquest-api",
|
||||
oidc_jwks_url=(
|
||||
"https://authentik.example.com/application/o/nestquest/jwks/"
|
||||
),
|
||||
))
|
||||
assert app is not None
|
||||
|
||||
print("API NO-HA IMPORT OK")
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def test_app_imports_core_without_importing_homeassistant() -> None:
|
||||
"""The app imports core without importing homeassistant (subprocess).
|
||||
|
||||
Runs in a subprocess because ``tests/conftest.py`` installs mock
|
||||
``homeassistant`` modules into the pytest process's ``sys.modules``
|
||||
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 loader (or core itself) ever grows a ``homeassistant``
|
||||
import, the subprocess exits non-zero with ``ModuleNotFoundError``.
|
||||
"""
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", _NO_HA_SCRIPT, str(_REPO_ROOT)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise AssertionError(
|
||||
f"no-HA app-import subprocess exited {result.returncode}.\n"
|
||||
f"--- stdout ---\n{result.stdout}\n"
|
||||
f"--- stderr ---\n{result.stderr}"
|
||||
)
|
||||
assert _SUCCESS_MARKER in result.stdout, (
|
||||
f"success marker {_SUCCESS_MARKER!r} not printed.\n"
|
||||
f"--- stdout ---\n{result.stdout}\n"
|
||||
f"--- stderr ---\n{result.stderr}"
|
||||
)
|
||||
@@ -1,709 +0,0 @@
|
||||
"""Panel-plane route tests: service token + snapshot (task ae7e90ae).
|
||||
|
||||
These tests exercise the NestQuest API's panel plane against the ASGI
|
||||
app with httpx:
|
||||
|
||||
- ``GET /api/v1/panel/snapshot`` requires the panel service token
|
||||
(D-012): a request with NO ``Authorization`` header, a MALFORMED
|
||||
header, and a WRONG token all return 401; the correct Bearer token
|
||||
returns 200.
|
||||
- The 200 body is the documented snapshot shape: ``today_iso``,
|
||||
``cycle_day``, and ``children`` in the household's sort order, each
|
||||
carrying ``child_id``/``child_name``/``present``/``next_present``,
|
||||
the per-day rollup counts, and its instances in the documented panel
|
||||
payload shape (ENTITIES-AND-SERVICES.md §1) with missed instances
|
||||
OMITTED (D-009).
|
||||
- ``ApiConfig.from_env`` requires ``NESTQUEST_PANEL_TOKEN`` with the
|
||||
same fail-fast discipline as ``NESTQUEST_DB_PATH``.
|
||||
|
||||
The household is seeded through the SAME core copy the app uses — the
|
||||
``nestquest_core.*`` package registered by ``api/nestquest_core.py`` —
|
||||
NOT the conftest-stubbed ``custom_components.nestquest.core`` copy (see
|
||||
the two-copy caveat in api/nestquest_core.py's docstring: never compare
|
||||
class or exception identity across the two copies). The seed opens and
|
||||
migrates its own connection on the app's SQLite path and closes BEFORE
|
||||
the app's lifespan opens the file, so the two connections never
|
||||
overlap.
|
||||
|
||||
The route's single clock read (:func:`api.routes_panel._local_now`) is
|
||||
pinned with monkeypatch the same way the coordinator's ``_local_now``
|
||||
is pinned in test_core_snapshot.py, so the seeded rows and the
|
||||
snapshot's ``today`` can never straddle midnight.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import importlib
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from api import routes_panel
|
||||
from api.app import create_app
|
||||
from api.config import ApiConfig, ConfigError
|
||||
from api.database import _executor
|
||||
from api.nestquest_core import (
|
||||
core_db,
|
||||
core_migrations,
|
||||
core_snapshot,
|
||||
)
|
||||
|
||||
#: The panel service token the test apps are configured with.
|
||||
PANEL_TOKEN = "panel-service-token-test"
|
||||
|
||||
# The bundled core submodules, through the SAME copy the app uses
|
||||
# (nestquest_core.*, NOT the conftest-stubbed custom_components copy).
|
||||
_children = importlib.import_module("nestquest_core.children")
|
||||
_quest_definitions = importlib.import_module(
|
||||
"nestquest_core.quest_definitions"
|
||||
)
|
||||
_materialize = importlib.import_module("nestquest_core.materialize")
|
||||
_recurrence = importlib.import_module("nestquest_core.recurrence")
|
||||
_dao_presence = importlib.import_module("nestquest_core.dao_presence")
|
||||
_dao_instances = importlib.import_module("nestquest_core.dao_instances")
|
||||
_completion = importlib.import_module("nestquest_core.completion")
|
||||
|
||||
|
||||
async def _events_for_instance(database: object, instance_id: int) -> list:
|
||||
"""The instance's completion events, on the app's core copy.
|
||||
|
||||
Uses the SAME ``nestquest_core.*`` copy the app uses (the two-copy
|
||||
caveat: never mix copies) against the app's live connection.
|
||||
"""
|
||||
dao = _dao_instances.CompletionEventsDao(database)
|
||||
return await dao.list_by_instance(instance_id)
|
||||
|
||||
|
||||
async def _derived_state(
|
||||
database: object, instance: object, today: datetime.date
|
||||
) -> str:
|
||||
"""The instance's derived state on the app's core copy.
|
||||
|
||||
``derive_state(instance, latest, today)`` from the app's core copy;
|
||||
the latest event is fetched through the same copy's DAO against the
|
||||
app's live connection.
|
||||
"""
|
||||
dao = _dao_instances.CompletionEventsDao(database)
|
||||
latest = await dao.get_latest_for_instance(instance.id)
|
||||
return _completion.derive_state(instance, latest, today)
|
||||
|
||||
|
||||
async def _seed_household(
|
||||
db_path: str,
|
||||
today: datetime.date,
|
||||
pinned_now: datetime.datetime,
|
||||
) -> SimpleNamespace:
|
||||
"""Seed a two-child household on ``db_path`` and return its records.
|
||||
|
||||
Mirrors the test_core_snapshot.py household: an AWAY child (2-week
|
||||
custody cycle anchored yesterday, empty week 0, an override returns
|
||||
her today+2) with an open quest due 10:00 — overdue at the pinned
|
||||
12:00 — and a present child whose 17:00 quest is completed at
|
||||
12:00 (on time). A third quest's instance is materialized due
|
||||
YESTERDAY (via ``materialize`` with ``today`` pinned to yesterday,
|
||||
since the no-past guard refuses past-dated writes): a past-due OPEN
|
||||
instance, i.e. the "missed" view the panel payload must omit.
|
||||
|
||||
Opens, migrates, seeds, and CLOSES its own connection so the app's
|
||||
lifespan connection never overlaps it.
|
||||
"""
|
||||
database = core_db.NestQuestDatabase(_executor)
|
||||
await database.open(db_path)
|
||||
try:
|
||||
await core_migrations.apply_migrations(database)
|
||||
today_iso = today.isoformat()
|
||||
yesterday = today - datetime.timedelta(days=1)
|
||||
yesterday_iso = yesterday.isoformat()
|
||||
|
||||
ada = await _children.create_child(database, "Ada", sort_order=0)
|
||||
bo = await _children.create_child(database, "Bo", sort_order=1)
|
||||
|
||||
# Every rule starts YESTERDAY so the yesterday materialization
|
||||
# below (which pins ``today`` to yesterday) fires them.
|
||||
await _quest_definitions.create_quest_definition(
|
||||
database,
|
||||
"Pack bag",
|
||||
_recurrence.ScheduleRule.from_dict(
|
||||
{"rule_type": "daily", "start_date": yesterday_iso}
|
||||
),
|
||||
[ada.id],
|
||||
[("morning", "10:00")],
|
||||
)
|
||||
await _quest_definitions.create_quest_definition(
|
||||
database,
|
||||
"Brush teeth",
|
||||
_recurrence.ScheduleRule.from_dict(
|
||||
{"rule_type": "daily", "start_date": yesterday_iso}
|
||||
),
|
||||
[bo.id],
|
||||
[("morning", "17:00")],
|
||||
)
|
||||
# Today's instances first, while Ada has no presence schedule
|
||||
# (she is present every day, so her instance IS generated).
|
||||
await _materialize.materialize(
|
||||
database,
|
||||
today_iso,
|
||||
(today + datetime.timedelta(days=3)).isoformat(),
|
||||
today=today,
|
||||
)
|
||||
# The stale quest is created AFTER the today walk, so its only
|
||||
# instance is the yesterday one written below.
|
||||
await _quest_definitions.create_quest_definition(
|
||||
database,
|
||||
"Stale chore",
|
||||
_recurrence.ScheduleRule.from_dict(
|
||||
{"rule_type": "daily", "start_date": yesterday_iso}
|
||||
),
|
||||
[ada.id],
|
||||
[("morning", "09:00")],
|
||||
)
|
||||
# Materialize yesterday with ``today`` pinned to yesterday: the
|
||||
# no-past guard accepts the rows, and they land past-due (no
|
||||
# completion events) — the missed view.
|
||||
await _materialize.materialize(
|
||||
database, yesterday_iso, yesterday_iso, today=yesterday
|
||||
)
|
||||
instances = _dao_instances.QuestInstancesDao(database)
|
||||
|
||||
ada_today = await instances.list_by_child_and_date(ada.id, today_iso)
|
||||
assert len(ada_today) == 1
|
||||
pack_bag_instance = ada_today[0]
|
||||
bo_today = await instances.list_by_child_and_date(bo.id, today_iso)
|
||||
assert len(bo_today) == 1
|
||||
brush_teeth_instance = bo_today[0]
|
||||
ada_yesterday = await instances.list_by_child_and_date(
|
||||
ada.id, yesterday_iso
|
||||
)
|
||||
stale_instance = next(
|
||||
record
|
||||
for record in ada_yesterday
|
||||
if record.due_time == "09:00"
|
||||
)
|
||||
|
||||
# Ada's custody schedule, added AFTER materialization (the
|
||||
# same order test_core_snapshot.py seeds): 2-week cycle
|
||||
# anchored yesterday, empty week 0 covers today => absent
|
||||
# today; an override returns her on today+2.
|
||||
await _dao_presence.PresenceSchedulesDao(database).upsert_by_child(
|
||||
ada.id,
|
||||
cycle_length_weeks=2,
|
||||
anchor_date=yesterday_iso,
|
||||
pattern="|0,1,2,3,4,5,6",
|
||||
)
|
||||
returns_date = today + datetime.timedelta(days=2)
|
||||
await _dao_presence.PresenceOverridesDao(database).create(
|
||||
ada.id,
|
||||
returns_date.isoformat(),
|
||||
returns_date.isoformat(),
|
||||
is_present=True,
|
||||
note="visit",
|
||||
)
|
||||
|
||||
# Complete Bo's today instance at the pinned 12:00 (17:00 due
|
||||
# => on time).
|
||||
await _completion.complete_instance(
|
||||
database,
|
||||
brush_teeth_instance.id,
|
||||
actor_source="panel",
|
||||
actor_child_id=bo.id,
|
||||
now=pinned_now,
|
||||
today=today,
|
||||
)
|
||||
return SimpleNamespace(
|
||||
ada=ada,
|
||||
bo=bo,
|
||||
pack_bag_instance=pack_bag_instance,
|
||||
brush_teeth_instance=brush_teeth_instance,
|
||||
stale_instance=stale_instance,
|
||||
returns_date=returns_date,
|
||||
)
|
||||
finally:
|
||||
await database.close()
|
||||
|
||||
|
||||
class _PanelRunner:
|
||||
"""Run the app's lifespan around an httpx AsyncClient.
|
||||
|
||||
Same pattern as test_api_health.py's ``_AppRunner``: httpx's ASGI
|
||||
transport does not run the lifespan, so it is driven explicitly and
|
||||
shut down in ``__aexit__``'s finally block even on failure.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, db_path: str, panel_token: str = PANEL_TOKEN
|
||||
) -> None:
|
||||
self.app = create_app(
|
||||
ApiConfig(
|
||||
db_path=db_path,
|
||||
panel_token=panel_token,
|
||||
oidc_issuer="https://authentik.example.com/application/o/nestquest/",
|
||||
oidc_audience="nestquest-api",
|
||||
oidc_jwks_url=(
|
||||
"https://authentik.example.com/application/o/nestquest/jwks/"
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
async def __aenter__(self) -> tuple["_PanelRunner", httpx.AsyncClient]:
|
||||
self._lifespan = self.app.router.lifespan_context(self.app)
|
||||
await self._lifespan.__aenter__()
|
||||
self._client = httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=self.app),
|
||||
base_url="http://testserver",
|
||||
)
|
||||
await self._client.__aenter__()
|
||||
return self, self._client
|
||||
|
||||
async def __aexit__(self, *exc) -> None:
|
||||
try:
|
||||
await self._client.__aexit__(*exc)
|
||||
finally:
|
||||
await self._lifespan.__aexit__(*exc)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_db_path(tmp_path: Path) -> str:
|
||||
"""A fresh per-test SQLite path under pytest's tmp_path."""
|
||||
return str(tmp_path / "nestquest.db")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def panel_client(temp_db_path: str) -> httpx.AsyncClient:
|
||||
"""An app client with an empty (migrated) household database."""
|
||||
async with _PanelRunner(temp_db_path) as runner_and_client:
|
||||
_, client = runner_and_client
|
||||
yield client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def seeded_panel(temp_db_path: str, monkeypatch) -> SimpleNamespace:
|
||||
"""A seeded household served by the app, with the clock pinned.
|
||||
|
||||
One host clock read anchors ``today``; the pinned instant is
|
||||
12:00 UTC that day (past Ada's 10:00 due, before Bo's 17:00). The
|
||||
route's ``_local_now`` is patched to return the pinned instant.
|
||||
|
||||
Yields the runner too so tests reach the app's live database (the
|
||||
SAME ``nestquest_core`` connection) for assertions.
|
||||
"""
|
||||
today = datetime.datetime.now().astimezone().date()
|
||||
time_zone = ZoneInfo("UTC")
|
||||
pinned_now = datetime.datetime(
|
||||
today.year, today.month, today.day, 12, 0, 0, tzinfo=time_zone
|
||||
)
|
||||
seed = await _seed_household(temp_db_path, today, pinned_now)
|
||||
monkeypatch.setattr(routes_panel, "_local_now", lambda: pinned_now)
|
||||
async with _PanelRunner(temp_db_path) as runner_and_client:
|
||||
runner, client = runner_and_client
|
||||
yield SimpleNamespace(
|
||||
client=client,
|
||||
database=runner.app.state.db.database,
|
||||
seed=seed,
|
||||
today=today,
|
||||
pinned_now=pinned_now,
|
||||
pinned_utc_iso=pinned_now.astimezone(
|
||||
datetime.timezone.utc
|
||||
).isoformat(),
|
||||
)
|
||||
|
||||
|
||||
# --- ApiConfig.from_env: the panel token --------------------------------
|
||||
|
||||
|
||||
def test_from_env_reads_panel_token() -> None:
|
||||
"""A set, non-empty NESTQUEST_PANEL_TOKEN is returned verbatim."""
|
||||
cfg = ApiConfig.from_env(
|
||||
{
|
||||
"NESTQUEST_DB_PATH": "/data/nq.db",
|
||||
"NESTQUEST_PANEL_TOKEN": "panel-token",
|
||||
"NESTQUEST_OIDC_ISSUER": "https://idp.example.com/issuer/",
|
||||
"NESTQUEST_OIDC_AUDIENCE": "nestquest-api",
|
||||
"NESTQUEST_OIDC_JWKS_URL": "https://idp.example.com/jwks/",
|
||||
}
|
||||
)
|
||||
assert cfg.panel_token == "panel-token"
|
||||
|
||||
|
||||
def test_from_env_requires_panel_token_when_missing() -> None:
|
||||
"""A missing panel token raises ConfigError (no silent default)."""
|
||||
with pytest.raises(ConfigError):
|
||||
ApiConfig.from_env(
|
||||
{
|
||||
"NESTQUEST_DB_PATH": "/data/nq.db",
|
||||
"NESTQUEST_OIDC_ISSUER": "https://idp.example.com/issuer/",
|
||||
"NESTQUEST_OIDC_AUDIENCE": "nestquest-api",
|
||||
"NESTQUEST_OIDC_JWKS_URL": "https://idp.example.com/jwks/",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_from_env_requires_panel_token_when_empty() -> None:
|
||||
"""An empty/whitespace panel token raises ConfigError."""
|
||||
with pytest.raises(ConfigError):
|
||||
ApiConfig.from_env(
|
||||
{
|
||||
"NESTQUEST_DB_PATH": "/data/nq.db",
|
||||
"NESTQUEST_PANEL_TOKEN": " ",
|
||||
"NESTQUEST_OIDC_ISSUER": "https://idp.example.com/issuer/",
|
||||
"NESTQUEST_OIDC_AUDIENCE": "nestquest-api",
|
||||
"NESTQUEST_OIDC_JWKS_URL": "https://idp.example.com/jwks/",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# --- service-token auth --------------------------------------------------
|
||||
|
||||
|
||||
async def test_missing_token_is_unauthorized(
|
||||
panel_client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
"""A request with no Authorization header returns 401."""
|
||||
response = await panel_client.get("/api/v1/panel/snapshot")
|
||||
assert response.status_code == 401
|
||||
assert "www-authenticate" in {
|
||||
key.lower() for key in response.headers
|
||||
}
|
||||
|
||||
|
||||
async def test_wrong_token_is_unauthorized(
|
||||
panel_client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
"""A wrong Bearer token returns 401."""
|
||||
response = await panel_client.get(
|
||||
"/api/v1/panel/snapshot",
|
||||
headers={"Authorization": "Bearer not-the-panel-token"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
async def test_malformed_authorization_header_is_unauthorized(
|
||||
panel_client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
"""A malformed Authorization header returns 401.
|
||||
|
||||
Wrong scheme, empty credential, and an empty header all fail the
|
||||
same way — the response never reveals which part failed.
|
||||
"""
|
||||
for header in (
|
||||
"Basic dXNlcjpwYXNz",
|
||||
"Bearer",
|
||||
"Bearer ",
|
||||
"Token not-the-panel-token",
|
||||
"",
|
||||
):
|
||||
response = await panel_client.get(
|
||||
"/api/v1/panel/snapshot",
|
||||
headers={"Authorization": header},
|
||||
)
|
||||
assert response.status_code == 401, f"header {header!r} passed"
|
||||
|
||||
|
||||
# --- snapshot payload ----------------------------------------------------
|
||||
|
||||
|
||||
async def test_valid_token_returns_full_snapshot(
|
||||
seeded_panel: SimpleNamespace,
|
||||
) -> None:
|
||||
"""The correct token returns 200 with the documented snapshot shape.
|
||||
|
||||
Children in sort order (Ada sort_order 0, Bo 1); per-child counts,
|
||||
presence, next_present, and cycle day all asserted as explicit
|
||||
literals; each child's instances are in the documented panel
|
||||
payload shape (done -> completed spelling, on_time passthrough).
|
||||
"""
|
||||
seed = seeded_panel.seed
|
||||
response = await seeded_panel.client.get(
|
||||
"/api/v1/panel/snapshot",
|
||||
headers={"Authorization": f"Bearer {PANEL_TOKEN}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
|
||||
assert body["today_iso"] == seeded_panel.today.isoformat()
|
||||
# Ada's 2-week cycle anchored yesterday => today is cycle day 2.
|
||||
assert body["cycle_day"] == 2
|
||||
|
||||
children = body["children"]
|
||||
assert [child["child_id"] for child in children] == [
|
||||
seed.ada.id,
|
||||
seed.bo.id,
|
||||
], "children appear in sort_order"
|
||||
|
||||
ada, bo = children
|
||||
|
||||
# Ada: away today, returns on the override date; her 10:00 quest
|
||||
# is open and overdue at the pinned 12:00.
|
||||
assert ada["child_name"] == "Ada"
|
||||
assert ada["present"] is False
|
||||
assert ada["next_present"] == seed.returns_date.isoformat()
|
||||
assert ada["due_today"] == 1
|
||||
assert ada["completed_today"] == 0
|
||||
assert ada["remaining_today"] == 1
|
||||
assert ada["completion_pct"] == 0
|
||||
assert ada["instances"] == [
|
||||
{
|
||||
"id": seed.pack_bag_instance.id,
|
||||
"definition_id": seed.pack_bag_instance.definition_id,
|
||||
"child_id": seed.ada.id,
|
||||
"title": "Pack bag",
|
||||
"icon": None,
|
||||
"window": "morning",
|
||||
"due_time": "10:00",
|
||||
"state": "open",
|
||||
"overdue": True,
|
||||
"completed_at": None,
|
||||
"on_time": None,
|
||||
}
|
||||
]
|
||||
|
||||
# Bo: present, completed his 17:00 quest at the pinned 12:00
|
||||
# (on time), so the panel spelling is "completed".
|
||||
assert bo["child_name"] == "Bo"
|
||||
assert bo["present"] is True
|
||||
assert bo["next_present"] is None
|
||||
assert bo["due_today"] == 1
|
||||
assert bo["completed_today"] == 1
|
||||
assert bo["remaining_today"] == 0
|
||||
assert bo["completion_pct"] == 100
|
||||
assert bo["instances"] == [
|
||||
{
|
||||
"id": seed.brush_teeth_instance.id,
|
||||
"definition_id": seed.brush_teeth_instance.definition_id,
|
||||
"child_id": seed.bo.id,
|
||||
"title": "Brush teeth",
|
||||
"icon": None,
|
||||
"window": "morning",
|
||||
"due_time": "17:00",
|
||||
"state": "completed",
|
||||
"overdue": False,
|
||||
"completed_at": seeded_panel.pinned_utc_iso,
|
||||
"on_time": True,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
async def test_missed_instances_are_omitted_from_panel_payload(
|
||||
seeded_panel: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A missed instance never reaches the panel payload (D-009).
|
||||
|
||||
The household carries a past-due OPEN instance due yesterday (the
|
||||
state the panel reads as "missed"); it must not appear anywhere in
|
||||
the response, and Ada's payload carries exactly her ONE today
|
||||
instance — the past-due rows (the stale chore AND her pack bag due
|
||||
yesterday) are both omitted rather than rendered.
|
||||
"""
|
||||
seed = seeded_panel.seed
|
||||
response = await seeded_panel.client.get(
|
||||
"/api/v1/panel/snapshot",
|
||||
headers={"Authorization": f"Bearer {PANEL_TOKEN}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
|
||||
payload_instances = [
|
||||
instance
|
||||
for child in body["children"]
|
||||
for instance in child["instances"]
|
||||
]
|
||||
assert "missed" not in [
|
||||
instance["state"] for instance in payload_instances
|
||||
]
|
||||
past_due_ids = {seed.stale_instance.id}
|
||||
assert past_due_ids.isdisjoint(
|
||||
instance["id"] for instance in payload_instances
|
||||
), "the past-due open instance leaked into the panel payload"
|
||||
|
||||
ada = next(
|
||||
child
|
||||
for child in body["children"]
|
||||
if child["child_id"] == seed.ada.id
|
||||
)
|
||||
assert [instance["id"] for instance in ada["instances"]] == [
|
||||
seed.pack_bag_instance.id
|
||||
]
|
||||
|
||||
|
||||
def test_panel_shaper_drops_missed_views() -> None:
|
||||
"""The route's shaper omits a missed view (D-009), on the app's copy.
|
||||
|
||||
``instance_payload(include_missed=False)`` is what the snapshot
|
||||
route shapes every child's instances with; exercised here on the
|
||||
SAME core copy the app uses (the two-copy caveat makes this a
|
||||
distinct object from the conftest-stubbed copy's identical source).
|
||||
"""
|
||||
missed_view = core_snapshot.QuestInstanceView(
|
||||
instance_id=999,
|
||||
definition_id=1,
|
||||
child_id=1,
|
||||
title="Stale chore",
|
||||
icon=None,
|
||||
window="morning",
|
||||
due_date="2026-09-21",
|
||||
due_time="09:00",
|
||||
state="missed",
|
||||
overdue=False,
|
||||
completed_at=None,
|
||||
was_on_time=None,
|
||||
)
|
||||
assert core_snapshot.instance_payload(
|
||||
[missed_view], include_missed=False
|
||||
) == []
|
||||
|
||||
|
||||
# --- route documentation -------------------------------------------------
|
||||
|
||||
|
||||
async def test_snapshot_route_is_documented(
|
||||
panel_client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
"""The snapshot route and its response shape are in the OpenAPI doc."""
|
||||
response = await panel_client.get("/openapi.json")
|
||||
assert response.status_code == 200
|
||||
schema = response.json()
|
||||
assert "/api/v1/panel/snapshot" in schema["paths"]
|
||||
assert "get" in schema["paths"]["/api/v1/panel/snapshot"]
|
||||
documented = {
|
||||
model["title"]
|
||||
for model in schema["components"]["schemas"].values()
|
||||
}
|
||||
assert "PanelSnapshotResponse" in documented
|
||||
assert "PanelChildPayload" in documented
|
||||
assert "PanelInstancePayload" in documented
|
||||
|
||||
|
||||
# --- complete route -------------------------------------------------------
|
||||
|
||||
|
||||
async def test_complete_writes_panel_completion(
|
||||
seeded_panel: SimpleNamespace,
|
||||
) -> None:
|
||||
"""The first POST completes the instance as the tapped profile.
|
||||
|
||||
Returns success; the completion event recorded is
|
||||
``actor_source='panel'`` with the tapped ``actor_child_id`` and NO
|
||||
``actor_user_id`` (the panel actor contract), and the instance's
|
||||
derived state becomes ``done``.
|
||||
"""
|
||||
seed = seeded_panel.seed
|
||||
response = await seeded_panel.client.post(
|
||||
f"/api/v1/panel/instances/{seed.pack_bag_instance.id}/complete",
|
||||
headers={"Authorization": f"Bearer {PANEL_TOKEN}"},
|
||||
json={"actor_child_id": seed.ada.id},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "done"}
|
||||
|
||||
# Assert the recorded event and derived state through the SAME core
|
||||
# copy the app uses (two-copy caveat: never mix copies), against the
|
||||
# app's live connection.
|
||||
events = await _events_for_instance(
|
||||
seeded_panel.database, seed.pack_bag_instance.id
|
||||
)
|
||||
assert len(events) == 1
|
||||
event = events[0]
|
||||
assert event.event_type == "completed"
|
||||
assert event.actor_source == "panel"
|
||||
assert event.actor_child_id == seed.ada.id
|
||||
assert event.actor_user_id is None
|
||||
|
||||
instance = await _dao_instances.QuestInstancesDao(
|
||||
seeded_panel.database
|
||||
).get_by_id(seed.pack_bag_instance.id)
|
||||
assert await _derived_state(
|
||||
seeded_panel.database, instance, seeded_panel.today
|
||||
) == "done"
|
||||
|
||||
|
||||
async def test_complete_is_idempotent(
|
||||
seeded_panel: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A second call is a no-op that still returns success.
|
||||
|
||||
The core completion layer's ``appended`` flag decides: the repeat
|
||||
call appends nothing, so exactly ONE completion event row exists
|
||||
after two calls.
|
||||
"""
|
||||
seed = seeded_panel.seed
|
||||
headers = {"Authorization": f"Bearer {PANEL_TOKEN}"}
|
||||
url = f"/api/v1/panel/instances/{seed.pack_bag_instance.id}/complete"
|
||||
body = {"actor_child_id": seed.ada.id}
|
||||
first = await seeded_panel.client.post(url, headers=headers, json=body)
|
||||
assert first.status_code == 200
|
||||
second = await seeded_panel.client.post(url, headers=headers, json=body)
|
||||
assert second.status_code == 200
|
||||
assert second.json() == {"status": "done"}
|
||||
|
||||
events = await _events_for_instance(
|
||||
seeded_panel.database, seed.pack_bag_instance.id
|
||||
)
|
||||
assert len(events) == 1, (
|
||||
"the idempotent call must not append a second event"
|
||||
)
|
||||
|
||||
|
||||
async def test_complete_unknown_instance_is_not_found(
|
||||
panel_client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
"""Completing a non-existent instance returns 404."""
|
||||
response = await panel_client.post(
|
||||
"/api/v1/panel/instances/999999/complete",
|
||||
headers={"Authorization": f"Bearer {PANEL_TOKEN}"},
|
||||
json={"actor_child_id": 1},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
async def test_complete_without_token_is_unauthorized(
|
||||
panel_client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
"""A complete request with no Authorization header returns 401."""
|
||||
response = await panel_client.post(
|
||||
"/api/v1/panel/instances/1/complete",
|
||||
json={"actor_child_id": 1},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
async def test_complete_wrong_child_is_not_found(
|
||||
seeded_panel: SimpleNamespace,
|
||||
) -> None:
|
||||
"""Tapping the wrong child's profile answers 404, not success.
|
||||
|
||||
``complete_instance`` does not itself check that the tapped
|
||||
``actor_child_id`` is the child the instance belongs to; the route
|
||||
guards it so a wrong-profile tap can never complete another
|
||||
child's quest.
|
||||
"""
|
||||
seed = seeded_panel.seed
|
||||
response = await seeded_panel.client.post(
|
||||
f"/api/v1/panel/instances/{seed.pack_bag_instance.id}/complete",
|
||||
headers={"Authorization": f"Bearer {PANEL_TOKEN}"},
|
||||
json={"actor_child_id": seed.bo.id},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
# And nothing was completed.
|
||||
events = await _events_for_instance(
|
||||
seeded_panel.database, seed.pack_bag_instance.id
|
||||
)
|
||||
assert events == []
|
||||
|
||||
|
||||
async def test_complete_route_is_documented(
|
||||
panel_client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
"""The complete route and its request shape are in the OpenAPI doc."""
|
||||
response = await panel_client.get("/openapi.json")
|
||||
assert response.status_code == 200
|
||||
schema = response.json()
|
||||
path = "/api/v1/panel/instances/{instance_id}/complete"
|
||||
assert path in schema["paths"]
|
||||
assert "post" in schema["paths"][path]
|
||||
documented = {
|
||||
model["title"]
|
||||
for model in schema["components"]["schemas"].values()
|
||||
}
|
||||
assert "PanelCompleteRequest" in documented
|
||||
@@ -1,466 +0,0 @@
|
||||
"""SSE endpoint tests: the panel event stream (task 4d7bae00).
|
||||
|
||||
These tests exercise ``GET /api/v1/panel/events`` — the panel plane's
|
||||
Server-Sent Events stream — against a REAL server:
|
||||
|
||||
- The stream requires the panel service token (D-012): no/wrong token
|
||||
returns 401 (the router-level shared dependency, no second check).
|
||||
- The response is ``text/event-stream`` and each transition arrives as
|
||||
an ``event: <type>`` / ``data: <json>`` frame.
|
||||
- Completing an instance through the panel complete route publishes
|
||||
the ``nestquest_quest_completed`` transition carrying the documented
|
||||
payload fields (design/ENTITIES-AND-SERVICES.md §3).
|
||||
- A no-op re-complete publishes nothing (no second completed event).
|
||||
|
||||
Streaming approach (documented): httpx's ASGITransport BUFFERS the
|
||||
whole response body before returning, so an endless SSE stream hangs
|
||||
it — exactly the awkward case this task anticipated. These tests
|
||||
therefore run the app with uvicorn on an ephemeral port (port 0, so
|
||||
the OS picks a free one) in a daemon thread, and httpx streams from
|
||||
``http://127.0.0.1:<port>``. Every read is bounded by a timeout so a
|
||||
wedged stream fails an assertion instead of hanging the suite; the
|
||||
server is shut down in the fixture's finally block.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
import importlib
|
||||
import json
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import uvicorn
|
||||
|
||||
from api import routes_panel
|
||||
from api.app import create_app
|
||||
from api.config import ApiConfig
|
||||
from tests.test_api_panel import PANEL_TOKEN, _seed_household
|
||||
|
||||
#: The documented quest-completed event name (core const, §3).
|
||||
_core_const = importlib.import_module("nestquest_core.const")
|
||||
EVENT_QUEST_COMPLETED = _core_const.EVENT_QUEST_COMPLETED
|
||||
EVENT_QUEST_UNCOMPLETED = _core_const.EVENT_QUEST_UNCOMPLETED
|
||||
EVENT_QUEST_MISSED = _core_const.EVENT_QUEST_MISSED
|
||||
EVENT_CHILD_DAY_COMPLETE = _core_const.EVENT_CHILD_DAY_COMPLETE
|
||||
|
||||
#: Upper bound for any single stream read; bounds the whole test so a
|
||||
#: wedged stream fails the assertion instead of hanging the suite.
|
||||
READ_TIMEOUT = 5.0
|
||||
|
||||
|
||||
def _free_port() -> int:
|
||||
"""Ask the OS for one free TCP port (bound then released)."""
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
return sock.getsockname()[1]
|
||||
|
||||
|
||||
class _Server:
|
||||
"""Run the app with uvicorn on an ephemeral port, in a thread.
|
||||
|
||||
``create_app`` runs INSIDE the server thread (uvicorn's factory
|
||||
mode), so the lifespan — including ``app.state.publisher`` — is
|
||||
set up per test on the server's own event loop. Daemon thread +
|
||||
explicit ``should_exit`` shutdown in the fixture's finally block.
|
||||
"""
|
||||
|
||||
def __init__(self, db_path: str) -> None:
|
||||
self.port = _free_port()
|
||||
self._server_loop: asyncio.AbstractEventLoop | None = None
|
||||
self._server = uvicorn.Server(
|
||||
uvicorn.Config(
|
||||
# A closure factory that captures the app's event loop
|
||||
# as it is created INSIDE the server thread: the
|
||||
# lifespan's database and publisher belong to that
|
||||
# thread's loop, so tests driving an API-side
|
||||
# transition helper directly schedule it there.
|
||||
self._make_app_factory(db_path),
|
||||
host="127.0.0.1",
|
||||
port=self.port,
|
||||
log_level="warning",
|
||||
)
|
||||
)
|
||||
self._thread = threading.Thread(
|
||||
target=self._server.run, daemon=True
|
||||
)
|
||||
|
||||
def _make_app_factory(self, db_path: str):
|
||||
"""The uvicorn app factory, closing over ``db_path``.
|
||||
|
||||
Records the running loop the first time uvicorn calls it (in
|
||||
the server thread) so :meth:`run_on_loop` can target it.
|
||||
"""
|
||||
|
||||
def factory():
|
||||
app = create_app(
|
||||
ApiConfig(
|
||||
db_path=db_path,
|
||||
panel_token=PANEL_TOKEN,
|
||||
oidc_issuer=(
|
||||
"https://authentik.example.com/application/o/nestquest/"
|
||||
),
|
||||
oidc_audience="nestquest-api",
|
||||
oidc_jwks_url=(
|
||||
"https://authentik.example.com/application/o/nestquest/jwks/"
|
||||
),
|
||||
)
|
||||
)
|
||||
self._server_loop = asyncio.get_running_loop()
|
||||
self.app = app
|
||||
return app
|
||||
|
||||
return factory
|
||||
|
||||
def __enter__(self) -> "_Server":
|
||||
self._thread.start()
|
||||
deadline = time.monotonic() + READ_TIMEOUT
|
||||
while not self._server.started:
|
||||
if time.monotonic() > deadline or not self._thread.is_alive():
|
||||
raise RuntimeError("uvicorn did not start")
|
||||
time.sleep(0.01)
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc) -> None:
|
||||
self._server.should_exit = True
|
||||
self._thread.join(timeout=READ_TIMEOUT)
|
||||
|
||||
def url(self, path: str) -> str:
|
||||
"""The full URL for ``path`` on this server."""
|
||||
return f"http://127.0.0.1:{self.port}{path}"
|
||||
|
||||
def run_on_loop(self, coro) -> None:
|
||||
"""Run ``coro`` on the SERVER's event loop and wait for it.
|
||||
|
||||
The app (its database and publisher) lives on the uvicorn
|
||||
thread's loop, so a test that drives an API-side transition
|
||||
helper directly must schedule it there: the helper awaits core
|
||||
DAO calls and calls ``publisher.publish`` synchronously, and
|
||||
both must run on the loop that owns them. Bounded by
|
||||
READ_TIMEOUT so a wedged schedule fails instead of hanging.
|
||||
"""
|
||||
return asyncio.run_coroutine_threadsafe(
|
||||
coro, self._server_loop
|
||||
).result(timeout=READ_TIMEOUT)
|
||||
|
||||
|
||||
class _EventStream:
|
||||
"""A subscribed SSE reader over a live httpx connection.
|
||||
|
||||
Parses frames (``event:``/``data:`` pairs) into
|
||||
``(event_type, payload)`` tuples; ``next_event`` awaits the next
|
||||
parsed frame with a timeout.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.queue: asyncio.Queue = asyncio.Queue()
|
||||
self._task: asyncio.Task | None = None
|
||||
self._client: httpx.AsyncClient | None = None
|
||||
self._stream_cm = None
|
||||
self._response: httpx.Response | None = None
|
||||
|
||||
async def __aenter__(self) -> "_EventStream":
|
||||
self._client = httpx.AsyncClient(timeout=READ_TIMEOUT)
|
||||
self._stream_cm = self._client.stream(
|
||||
"GET",
|
||||
self.url,
|
||||
headers={"Authorization": f"Bearer {PANEL_TOKEN}"},
|
||||
)
|
||||
self._response = await self._stream_cm.__aenter__()
|
||||
assert self._response.status_code == 200
|
||||
assert self._response.headers["content-type"].startswith(
|
||||
"text/event-stream"
|
||||
)
|
||||
self._task = asyncio.create_task(self._pump())
|
||||
return self
|
||||
|
||||
def _set_url(self, url: str) -> "_EventStream":
|
||||
"""Record the stream URL (called before ``__aenter__``)."""
|
||||
self.url = url
|
||||
return self
|
||||
|
||||
async def _pump(self) -> None:
|
||||
"""Read the stream and push parsed frames onto the queue."""
|
||||
event_type: str | None = None
|
||||
data_lines: list[str] = []
|
||||
try:
|
||||
async for chunk in self._response.aiter_text():
|
||||
for line in chunk.splitlines():
|
||||
if line.startswith("event:"):
|
||||
event_type = line[len("event:"):].strip()
|
||||
elif line.startswith("data:"):
|
||||
data_lines.append(line[len("data:"):].strip())
|
||||
elif not line and event_type is not None:
|
||||
payload = json.loads("\n".join(data_lines))
|
||||
await self.queue.put((event_type, payload))
|
||||
event_type = None
|
||||
data_lines = []
|
||||
except Exception as error: # pragma: no cover - teardown path
|
||||
await self.queue.put(error)
|
||||
|
||||
async def next_event(self) -> tuple[str, dict]:
|
||||
"""The next parsed ``(event_type, payload)``, with a timeout."""
|
||||
item = await asyncio.wait_for(
|
||||
self.queue.get(), timeout=READ_TIMEOUT
|
||||
)
|
||||
if isinstance(item, Exception):
|
||||
raise item
|
||||
return item
|
||||
|
||||
async def __aexit__(self, *exc) -> None:
|
||||
if self._task is not None:
|
||||
self._task.cancel()
|
||||
try:
|
||||
await self._task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
await self._stream_cm.__aexit__(*exc)
|
||||
await self._client.__aexit__(*exc)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_db_path(tmp_path: Path) -> str:
|
||||
"""A fresh per-test SQLite path under pytest's tmp_path."""
|
||||
return str(tmp_path / "nestquest.db")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def seeded_panel(temp_db_path: str) -> SimpleNamespace:
|
||||
"""A seeded household served by a live uvicorn app, clock pinned.
|
||||
|
||||
Same pinning discipline as the panel tests (12:00 UTC, past Ada's
|
||||
10:00 due, before Bo's 17:00): the route's ``_local_now`` is
|
||||
patched so the completion's ``was_on_time`` is deterministic. The
|
||||
app is created inside uvicorn's factory, so the patch must happen
|
||||
BEFORE the server starts.
|
||||
"""
|
||||
today = datetime.datetime.now().astimezone().date()
|
||||
pinned_now = datetime.datetime(
|
||||
today.year, today.month, today.day, 12, 0, 0, tzinfo=ZoneInfo("UTC")
|
||||
)
|
||||
seed = await _seed_household(temp_db_path, today, pinned_now)
|
||||
# The app is created inside uvicorn's factory (in the server
|
||||
# thread), so the patch must be applied BEFORE the server starts.
|
||||
original = routes_panel._local_now
|
||||
routes_panel._local_now = lambda: pinned_now
|
||||
try:
|
||||
with _Server(temp_db_path) as server:
|
||||
client = httpx.AsyncClient(base_url=server.url(""))
|
||||
async with client:
|
||||
yield SimpleNamespace(
|
||||
client=client,
|
||||
seed=seed,
|
||||
server=server,
|
||||
url=server.url,
|
||||
)
|
||||
finally:
|
||||
routes_panel._local_now = original
|
||||
|
||||
|
||||
def _stream(url: str) -> _EventStream:
|
||||
"""A subscribed SSE reader for ``url``."""
|
||||
return _EventStream()._set_url(url)
|
||||
|
||||
|
||||
# --- auth and content type ----------------------------------------------
|
||||
|
||||
|
||||
async def test_events_requires_panel_token(seeded_panel) -> None:
|
||||
"""The SSE route inherits the router's token check (no second one)."""
|
||||
response = await seeded_panel.client.get("/api/v1/panel/events")
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
# --- the quest-completed transition --------------------------------------
|
||||
|
||||
|
||||
async def test_complete_publishes_quest_completed_event(
|
||||
seeded_panel,
|
||||
) -> None:
|
||||
"""A panel completion emits the documented completed transition.
|
||||
|
||||
The stream is subscribed FIRST, the instance is completed through
|
||||
the panel route, and the emitted frame's type and every documented
|
||||
payload field (child_id, child_name, instance_id, quest_title,
|
||||
window, due_date, due_time, occurred_at, was_on_time) are asserted.
|
||||
"""
|
||||
seed = seeded_panel.seed
|
||||
pack_bag = seed.pack_bag_instance
|
||||
stream = _stream(seeded_panel.url("/api/v1/panel/events"))
|
||||
async with stream:
|
||||
response = await seeded_panel.client.post(
|
||||
f"/api/v1/panel/instances/{pack_bag.id}/complete",
|
||||
headers={"Authorization": f"Bearer {PANEL_TOKEN}"},
|
||||
json={"actor_child_id": seed.ada.id},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
event_type, payload = await stream.next_event()
|
||||
assert event_type == EVENT_QUEST_COMPLETED
|
||||
assert event_type == "nestquest_quest_completed"
|
||||
assert payload["child_id"] == seed.ada.id
|
||||
assert payload["child_name"] == "Ada"
|
||||
assert payload["instance_id"] == pack_bag.id
|
||||
assert payload["quest_title"] == "Pack bag"
|
||||
assert payload["window"] == "morning"
|
||||
assert payload["due_date"] == pack_bag.due_date
|
||||
assert payload["due_time"] == "10:00"
|
||||
assert payload["occurred_at"] # strict UTC ISO-8601 stamp
|
||||
assert payload["was_on_time"] is False # 10:00 due, 12:00 now
|
||||
|
||||
|
||||
async def test_no_op_recomplete_publishes_nothing(seeded_panel) -> None:
|
||||
"""A second complete call emits NO second completed event.
|
||||
|
||||
After the first (real) transition, a re-complete is a no-op in the
|
||||
core layer; the stream must stay silent.
|
||||
"""
|
||||
seed = seeded_panel.seed
|
||||
url = f"/api/v1/panel/instances/{seed.pack_bag_instance.id}/complete"
|
||||
body = {"actor_child_id": seed.ada.id}
|
||||
headers = {"Authorization": f"Bearer {PANEL_TOKEN}"}
|
||||
stream = _stream(seeded_panel.url("/api/v1/panel/events"))
|
||||
async with stream:
|
||||
first = await seeded_panel.client.post(url, headers=headers, json=body)
|
||||
assert first.status_code == 200
|
||||
event_type, _ = await stream.next_event()
|
||||
assert event_type == EVENT_QUEST_COMPLETED
|
||||
# Ada's only quest today: the completion also clears her whole
|
||||
# day, so the day-complete event follows (task 44c93cfd).
|
||||
event_type, _ = await stream.next_event()
|
||||
assert event_type == EVENT_CHILD_DAY_COMPLETE
|
||||
|
||||
second = await seeded_panel.client.post(
|
||||
url, headers=headers, json=body
|
||||
)
|
||||
assert second.status_code == 200
|
||||
with pytest.raises(asyncio.TimeoutError):
|
||||
# Nothing arrives within the window: the no-op re-complete
|
||||
# must publish nothing.
|
||||
await asyncio.wait_for(stream.queue.get(), timeout=0.3)
|
||||
|
||||
|
||||
# --- the remaining documented transitions (task 44c93cfd) -----------------
|
||||
|
||||
|
||||
async def test_publish_quest_uncompleted_streams_event(seeded_panel) -> None:
|
||||
"""The reusable uncompleted publisher streams the documented
|
||||
uncompleted transition with its documented payload fields.
|
||||
|
||||
The admin uncomplete route does not exist yet (deferred to task
|
||||
da0226b3); the test drives the API-side helper the route will call.
|
||||
The helper runs on the SERVER's event loop (``run_coroutine_threadsafe``
|
||||
on the uvicorn thread's loop) against the app's own live database,
|
||||
exactly the way the later route handler will call it.
|
||||
"""
|
||||
from api import transitions
|
||||
|
||||
seed = seeded_panel.seed
|
||||
pack_bag = seed.pack_bag_instance
|
||||
app = seeded_panel.server.app
|
||||
database = app.state.db.database
|
||||
stream = _stream(seeded_panel.url("/api/v1/panel/events"))
|
||||
async with stream:
|
||||
seeded_panel.server.run_on_loop(
|
||||
transitions.publish_quest_uncompleted(
|
||||
database, pack_bag.id, app.state.publisher
|
||||
)
|
||||
)
|
||||
event_type, payload = await stream.next_event()
|
||||
assert event_type == EVENT_QUEST_UNCOMPLETED
|
||||
assert event_type == "nestquest_quest_uncompleted"
|
||||
assert payload["child_id"] == seed.ada.id
|
||||
assert payload["child_name"] == "Ada"
|
||||
assert payload["instance_id"] == pack_bag.id
|
||||
assert payload["quest_title"] == "Pack bag"
|
||||
assert payload["window"] == "morning"
|
||||
assert payload["due_date"] == pack_bag.due_date
|
||||
assert payload["due_time"] == "10:00"
|
||||
assert payload["occurred_at"]
|
||||
assert "was_on_time" not in payload
|
||||
|
||||
|
||||
async def test_last_quest_of_day_publishes_day_complete(seeded_panel) -> None:
|
||||
"""Completing the child's LAST open quest of the day emits the
|
||||
completed event AND the child-day-complete event carrying
|
||||
``quests_due`` and ``quests_completed``.
|
||||
|
||||
Ada has exactly one instance today (Pack bag), so completing it
|
||||
clears her whole day; a no-op re-complete afterwards publishes
|
||||
nothing.
|
||||
"""
|
||||
seed = seeded_panel.seed
|
||||
pack_bag = seed.pack_bag_instance
|
||||
stream = _stream(seeded_panel.url("/api/v1/panel/events"))
|
||||
async with stream:
|
||||
response = await seeded_panel.client.post(
|
||||
f"/api/v1/panel/instances/{pack_bag.id}/complete",
|
||||
headers={"Authorization": f"Bearer {PANEL_TOKEN}"},
|
||||
json={"actor_child_id": seed.ada.id},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
event_type, payload = await stream.next_event()
|
||||
assert event_type == EVENT_QUEST_COMPLETED
|
||||
completed_occurred_at = payload["occurred_at"]
|
||||
event_type, payload = await stream.next_event()
|
||||
assert event_type == EVENT_CHILD_DAY_COMPLETE
|
||||
assert event_type == "nestquest_child_day_complete"
|
||||
assert payload["child_id"] == seed.ada.id
|
||||
assert payload["child_name"] == "Ada"
|
||||
assert payload["quests_due"] == 1
|
||||
assert payload["quests_completed"] == 1
|
||||
# One transition, one shared timestamp (the core builder
|
||||
# reuses the completed event's stamp).
|
||||
assert payload["occurred_at"] == completed_occurred_at
|
||||
|
||||
# No-op re-complete: nothing further on the stream.
|
||||
second = await seeded_panel.client.post(
|
||||
f"/api/v1/panel/instances/{pack_bag.id}/complete",
|
||||
headers={"Authorization": f"Bearer {PANEL_TOKEN}"},
|
||||
json={"actor_child_id": seed.ada.id},
|
||||
)
|
||||
assert second.status_code == 200
|
||||
with pytest.raises(asyncio.TimeoutError):
|
||||
await asyncio.wait_for(stream.queue.get(), timeout=0.3)
|
||||
|
||||
|
||||
async def test_publish_quest_missed_streams_event(seeded_panel) -> None:
|
||||
"""The reusable missed publisher streams the documented missed
|
||||
transition with its documented payload fields (the sweep payload).
|
||||
|
||||
The API-side missed sweep does not exist yet at this queue
|
||||
position; the test drives the API-side helper the sweep will call
|
||||
against the seeded past-due open instance, on the server's own
|
||||
event loop and database.
|
||||
"""
|
||||
from api import transitions
|
||||
|
||||
seed = seeded_panel.seed
|
||||
stale = seed.stale_instance
|
||||
app = seeded_panel.server.app
|
||||
database = app.state.db.database
|
||||
stream = _stream(seeded_panel.url("/api/v1/panel/events"))
|
||||
async with stream:
|
||||
seeded_panel.server.run_on_loop(
|
||||
transitions.publish_quest_missed(
|
||||
database, stale.id, app.state.publisher
|
||||
)
|
||||
)
|
||||
event_type, payload = await stream.next_event()
|
||||
assert event_type == EVENT_QUEST_MISSED
|
||||
assert event_type == "nestquest_quest_missed"
|
||||
assert payload["child_id"] == seed.ada.id
|
||||
assert payload["child_name"] == "Ada"
|
||||
assert payload["instance_id"] == stale.id
|
||||
assert payload["quest_title"] == "Stale chore"
|
||||
assert payload["window"] == "morning"
|
||||
assert payload["due_date"] == stale.due_date
|
||||
assert payload["due_time"] == "09:00"
|
||||
assert payload["occurred_at"]
|
||||
@@ -1,230 +0,0 @@
|
||||
"""No-HA end-to-end smoke test (task 628473ad).
|
||||
|
||||
The bundled core package (``custom_components.nestquest.core``) is
|
||||
self-contained and Home-Assistant-free, but the parent integration
|
||||
package ``custom_components.nestquest`` is NOT: its ``__init__.py``
|
||||
imports ``homeassistant``. A genuine "core runs with no Home
|
||||
Assistant installed" proof therefore cannot live inside the suite
|
||||
process — the suite's ``conftest.py`` installs mock ``homeassistant``
|
||||
modules globally before any test imports the integration, so an
|
||||
in-process exercise can never distinguish "core never touched HA"
|
||||
from "conftest already stubbed HA for us".
|
||||
|
||||
This module runs the smoke test in a SUBPROCESS (``sys.executable -c
|
||||
<script>``) that starts with a clean ``sys.modules`` and no
|
||||
``homeassistant`` installed. The subprocess script:
|
||||
|
||||
- asserts ``homeassistant`` is NOT in ``sys.modules`` before importing
|
||||
core;
|
||||
- installs a STUB parent package ``custom_components.nestquest`` (a
|
||||
bare :class:`types.ModuleType` whose ``__path__`` points at the real
|
||||
``custom_components/nestquest`` directory) into ``sys.modules``
|
||||
WITHOUT executing its ``__init__.py`` — so importing
|
||||
``custom_components.nestquest.core.<x>`` resolves the real core
|
||||
subpackage without dragging the integration's HA imports along;
|
||||
- installs a ``custom_components`` namespace entry too, so the
|
||||
subpackage resolves;
|
||||
- imports the core modules (db, migrations, children,
|
||||
quest_definitions, materialize, completion, recurrence);
|
||||
- asserts ``homeassistant`` is STILL not in ``sys.modules`` after
|
||||
those imports;
|
||||
- opens a FRESH SQLite file, applies migrations, creates two children
|
||||
and one quest definition (a daily recurrence rule with a start date
|
||||
and one ``morning`` window), runs :func:`materialize` over a 28-day
|
||||
window, completes one instance via :func:`complete_instance`
|
||||
(``actor_source='panel'``), and derives the instance state via
|
||||
:func:`instance_state`, asserting it is ``done``;
|
||||
- prints a clear success marker.
|
||||
|
||||
The pytest test asserts the subprocess exit code is 0 and the marker
|
||||
is printed, and fails with the subprocess stderr otherwise. This
|
||||
guards the storage/engines/business extraction (tasks d0b691d8 /
|
||||
510c1f78 / 4dd8f870) from regressing into a HA-coupled bundle: the
|
||||
moment a core module grows a ``homeassistant`` import (direct or via
|
||||
the parent package's ``__init__``), this test fails in a process
|
||||
where HA is genuinely absent.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
#: The marker the subprocess prints on success. Kept short and unique
|
||||
#: so the parent test can assert it survived intact in stdout.
|
||||
SUCCESS_MARKER = "NO-HA SMOKE TEST OK"
|
||||
|
||||
#: The repo root, so the subprocess can locate the real
|
||||
#: ``custom_components/nestquest`` directory regardless of its own
|
||||
#: working directory.
|
||||
_REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
#: The smoke-test script, run with ``sys.executable -c``. It is a
|
||||
#: real no-HA process: the suite's conftest stubs do not exist here,
|
||||
#: and ``homeassistant`` is not installed in this venv, so any core
|
||||
#: import of HA (direct or via the parent package ``__init__``) raises
|
||||
#: ``ModuleNotFoundError`` and the subprocess exits non-zero.
|
||||
_SCRIPT = textwrap.dedent(
|
||||
"""\
|
||||
import asyncio
|
||||
import datetime
|
||||
import sys
|
||||
import tempfile
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(sys.argv[1])
|
||||
CC_PATH = REPO_ROOT / "custom_components"
|
||||
NQ_PATH = CC_PATH / "nestquest"
|
||||
|
||||
# 1. homeassistant must not be imported before we touch core.
|
||||
assert "homeassistant" not in sys.modules, (
|
||||
"homeassistant already in sys.modules before core import — "
|
||||
"the subprocess is not a clean no-HA process"
|
||||
)
|
||||
|
||||
# 2. Stub the parent packages WITHOUT running their __init__.py.
|
||||
# custom_components is a namespace package; nestquest's __init__
|
||||
# imports homeassistant, so we install a bare ModuleType whose
|
||||
# __path__ points at the real directory instead of executing it.
|
||||
cc = types.ModuleType("custom_components")
|
||||
cc.__path__ = [str(CC_PATH)]
|
||||
sys.modules["custom_components"] = cc
|
||||
|
||||
nq = types.ModuleType("custom_components.nestquest")
|
||||
nq.__path__ = [str(NQ_PATH)]
|
||||
nq.__package__ = "custom_components.nestquest"
|
||||
sys.modules["custom_components.nestquest"] = nq
|
||||
cc.nestquest = nq
|
||||
|
||||
# 3. Import the core modules directly from the bundle.
|
||||
from custom_components.nestquest.core.db import NestQuestDatabase
|
||||
from custom_components.nestquest.core.migrations import apply_migrations
|
||||
from custom_components.nestquest.core.children import create_child
|
||||
from custom_components.nestquest.core.quest_definitions import (
|
||||
create_quest_definition,
|
||||
)
|
||||
from custom_components.nestquest.core.materialize import materialize
|
||||
from custom_components.nestquest.core.completion import (
|
||||
complete_instance,
|
||||
instance_state,
|
||||
)
|
||||
from custom_components.nestquest.core.recurrence import (
|
||||
RuleType,
|
||||
ScheduleRule,
|
||||
)
|
||||
from custom_components.nestquest.core.dao_instances import (
|
||||
QuestInstancesDao,
|
||||
)
|
||||
|
||||
# 4. core must not have pulled in homeassistant.
|
||||
assert "homeassistant" not in sys.modules, (
|
||||
"homeassistant imported by core — the core bundle is no longer "
|
||||
"Home-Assistant-free"
|
||||
)
|
||||
|
||||
|
||||
async def _executor(fn, *args, **kwargs):
|
||||
return await asyncio.to_thread(fn, *args, **kwargs)
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
db_path = Path(tempfile.mkdtemp(prefix="nq-noha-smoke-")) / "nestquest.db"
|
||||
database = NestQuestDatabase(_executor)
|
||||
await database.open(str(db_path))
|
||||
try:
|
||||
await apply_migrations(database)
|
||||
|
||||
# Two children.
|
||||
child_a = await create_child(database, "Ada")
|
||||
child_b = await create_child(database, "Bee")
|
||||
|
||||
# One quest definition: daily recurrence, one morning window.
|
||||
today = datetime.date.today()
|
||||
start_iso = today.isoformat()
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.DAILY, interval=1, start_date=start_iso
|
||||
)
|
||||
created = await create_quest_definition(
|
||||
database,
|
||||
"Brush teeth",
|
||||
rule,
|
||||
[child_a.id, child_b.id],
|
||||
["morning"],
|
||||
)
|
||||
assert created.definition.id is not None
|
||||
|
||||
# Materialize over a 28-day window (inclusive of both ends).
|
||||
end_iso = (today + datetime.timedelta(days=27)).isoformat()
|
||||
count = await materialize(database, start_iso, end_iso, today=today)
|
||||
assert count > 0, (
|
||||
f"materialize produced no instances over the 28-day "
|
||||
f"window (count={count})"
|
||||
)
|
||||
|
||||
# Complete one instance for child_a on the start date.
|
||||
instances = QuestInstancesDao(database)
|
||||
rows = await instances.list_by_child_and_date(
|
||||
child_a.id, start_iso
|
||||
)
|
||||
assert rows, (
|
||||
"no materialized instance for child_a on the start date"
|
||||
)
|
||||
instance = rows[0]
|
||||
|
||||
result = await complete_instance(
|
||||
database,
|
||||
instance.id,
|
||||
actor_source="panel",
|
||||
actor_child_id=child_a.id,
|
||||
today=today,
|
||||
)
|
||||
assert result == "done", (
|
||||
f"complete_instance returned {result!r}, expected 'done'"
|
||||
)
|
||||
|
||||
state = await instance_state(database, instance.id, today=today)
|
||||
assert state == "done", (
|
||||
f"instance_state returned {state!r}, expected 'done'"
|
||||
)
|
||||
finally:
|
||||
await database.close()
|
||||
|
||||
print("NO-HA SMOKE TEST OK")
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def test_core_runs_end_to_end_without_homeassistant() -> None:
|
||||
"""Run the no-HA smoke test in a subprocess and assert its marker.
|
||||
|
||||
The subprocess is a genuine no-HA process: the suite's conftest
|
||||
stubs are not present there, and ``homeassistant`` is not installed
|
||||
in this venv. If any core module ever grows a ``homeassistant``
|
||||
import (directly or via the parent package ``__init__``), the
|
||||
subprocess exits non-zero with a ``ModuleNotFoundError`` and this
|
||||
test fails with the subprocess's stderr.
|
||||
"""
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", _SCRIPT, str(_REPO_ROOT)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise AssertionError(
|
||||
f"no-HA smoke subprocess exited {result.returncode}.\n"
|
||||
f"--- stdout ---\n{result.stdout}\n"
|
||||
f"--- stderr ---\n{result.stderr}"
|
||||
)
|
||||
assert SUCCESS_MARKER in result.stdout, (
|
||||
f"success marker {SUCCESS_MARKER!r} not printed by subprocess.\n"
|
||||
f"--- stdout ---\n{result.stdout}\n"
|
||||
f"--- stderr ---\n{result.stderr}"
|
||||
)
|
||||
@@ -1,912 +0,0 @@
|
||||
"""Tests for core/settings.py: the explicit, HA-free settings object.
|
||||
|
||||
Covers the contracts from task 4dd8f870 plus the review fixes:
|
||||
|
||||
- ``NestQuestSettings.from_options`` applies defaults when keys are
|
||||
absent and rejects invalid horizon/rollover values; ``from_options``
|
||||
is strict (raises), while ``from_options_resilient`` falls back PER
|
||||
FIELD so a malformed sibling cannot reset horizon_days/rollover.
|
||||
- An AST scan asserts no module under
|
||||
``custom_components/nestquest/core/`` imports ``homeassistant``,
|
||||
names ``hass``/``config_entry``, reads a ``.config``/``.options``/
|
||||
``.data`` attribute, or dynamically reads them via ``getattr``/
|
||||
``hasattr``. (Docstring mentions are allowed.) A negative-control
|
||||
test feeds the scanner each bypass shape and asserts it flags them.
|
||||
- A settings object constructed with NON-default ``horizon_days`` and
|
||||
``day_rollover_time`` drives a materialization whose resulting window
|
||||
matches the non-default horizon — via BOTH the core ``materialize``
|
||||
path and the INTEGRATION path (``_run_horizon_materialization`` +
|
||||
``_register_day_rollover_listener``).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import datetime
|
||||
from dataclasses import FrozenInstanceError
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
import custom_components.nestquest.core as _core_mod
|
||||
from conftest import freeze_nestquest_clock
|
||||
|
||||
from custom_components.nestquest.const import (
|
||||
CONF_AFTERNOON_REMINDER_ENABLED,
|
||||
CONF_AFTERNOON_REMINDER_TIME,
|
||||
CONF_CELEBRATION_ENABLED,
|
||||
CONF_DAY_ROLLOVER_TIME,
|
||||
CONF_END_OF_DAY_REPORT_ENABLED,
|
||||
CONF_END_OF_DAY_REPORT_TIME,
|
||||
CONF_HORIZON_DAYS,
|
||||
CONF_MORNING_SUMMARY_ENABLED,
|
||||
CONF_MORNING_SUMMARY_TIME,
|
||||
CONF_NOTIFY_TARGET,
|
||||
DEFAULT_AFTERNOON_REMINDER_TIME,
|
||||
DEFAULT_AUTOMATION_ENABLED,
|
||||
DEFAULT_DAY_ROLLOVER_TIME,
|
||||
DEFAULT_END_OF_DAY_REPORT_TIME,
|
||||
DEFAULT_HORIZON_DAYS,
|
||||
DEFAULT_MORNING_SUMMARY_TIME,
|
||||
)
|
||||
from custom_components.nestquest.core.settings import NestQuestSettings
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# from_options: defaults + validation.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_from_options_none_yields_all_defaults() -> None:
|
||||
"""A None mapping yields a settings object with every default applied."""
|
||||
s = NestQuestSettings.from_options(None)
|
||||
assert s.horizon_days == DEFAULT_HORIZON_DAYS
|
||||
assert s.day_rollover_time == DEFAULT_DAY_ROLLOVER_TIME
|
||||
assert s.notify_target == ""
|
||||
assert s.morning_summary_time == DEFAULT_MORNING_SUMMARY_TIME
|
||||
assert s.afternoon_reminder_time == DEFAULT_AFTERNOON_REMINDER_TIME
|
||||
assert s.end_of_day_report_time == DEFAULT_END_OF_DAY_REPORT_TIME
|
||||
assert s.morning_summary_enabled is DEFAULT_AUTOMATION_ENABLED
|
||||
assert s.afternoon_reminder_enabled is DEFAULT_AUTOMATION_ENABLED
|
||||
assert s.end_of_day_report_enabled is DEFAULT_AUTOMATION_ENABLED
|
||||
assert s.celebration_enabled is DEFAULT_AUTOMATION_ENABLED
|
||||
|
||||
|
||||
def test_from_options_empty_mapping_yields_all_defaults() -> None:
|
||||
"""An empty mapping also yields every default (the fresh-install case)."""
|
||||
s = NestQuestSettings.from_options({})
|
||||
assert s.horizon_days == DEFAULT_HORIZON_DAYS
|
||||
assert s.day_rollover_time == DEFAULT_DAY_ROLLOVER_TIME
|
||||
|
||||
|
||||
def test_from_options_reads_keys() -> None:
|
||||
"""Each CONF_* key is read into the matching field."""
|
||||
opts = {
|
||||
CONF_HORIZON_DAYS: 21,
|
||||
CONF_DAY_ROLLOVER_TIME: "03:30",
|
||||
CONF_NOTIFY_TARGET: "notify.mobile_app_x",
|
||||
CONF_MORNING_SUMMARY_TIME: "07:15",
|
||||
CONF_AFTERNOON_REMINDER_TIME: "14:45",
|
||||
CONF_END_OF_DAY_REPORT_TIME: "21:30",
|
||||
CONF_MORNING_SUMMARY_ENABLED: False,
|
||||
CONF_AFTERNOON_REMINDER_ENABLED: False,
|
||||
CONF_END_OF_DAY_REPORT_ENABLED: False,
|
||||
CONF_CELEBRATION_ENABLED: False,
|
||||
}
|
||||
s = NestQuestSettings.from_options(opts)
|
||||
assert s.horizon_days == 21
|
||||
assert s.day_rollover_time == "03:30"
|
||||
assert s.notify_target == "notify.mobile_app_x"
|
||||
assert s.morning_summary_time == "07:15"
|
||||
assert s.afternoon_reminder_time == "14:45"
|
||||
assert s.end_of_day_report_time == "21:30"
|
||||
assert s.morning_summary_enabled is False
|
||||
assert s.afternoon_reminder_enabled is False
|
||||
assert s.end_of_day_report_enabled is False
|
||||
assert s.celebration_enabled is False
|
||||
|
||||
|
||||
def test_from_options_does_not_mutate_caller_mapping() -> None:
|
||||
"""from_options consumes the mapping read-only."""
|
||||
opts: dict[str, Any] = {CONF_HORIZON_DAYS: 9}
|
||||
snapshot = dict(opts)
|
||||
_ = NestQuestSettings.from_options(opts)
|
||||
assert opts == snapshot
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value",
|
||||
[True, False, "5", 0, -3, 1.5],
|
||||
)
|
||||
def test_from_options_rejects_invalid_horizon(value: Any) -> None:
|
||||
"""A malformed horizon_days is rejected.
|
||||
|
||||
A key that is ABSENT (or present-but-None) falls back to the
|
||||
default — see :func:`test_from_options_horizon_absent_falls_back_to_default`
|
||||
and the None case below; any other invalid value raises, matching
|
||||
the options flow's strict validation.
|
||||
"""
|
||||
with pytest.raises(ValueError, match="horizon_days"):
|
||||
NestQuestSettings.from_options({CONF_HORIZON_DAYS: value})
|
||||
|
||||
|
||||
def test_from_options_horizon_absent_falls_back_to_default() -> None:
|
||||
"""An absent (or None) horizon_days key falls back to the default."""
|
||||
assert NestQuestSettings.from_options({}).horizon_days == DEFAULT_HORIZON_DAYS
|
||||
assert (
|
||||
NestQuestSettings.from_options(
|
||||
{CONF_HORIZON_DAYS: None}
|
||||
).horizon_days
|
||||
== DEFAULT_HORIZON_DAYS
|
||||
)
|
||||
|
||||
|
||||
def test_from_options_horizon_validates_and_defaults() -> None:
|
||||
"""from_options returns the configured horizon, defaulting when absent/None.
|
||||
|
||||
A valid horizon is read; an absent or None key falls back to the
|
||||
default; and a malformed value RAISES (the integration's
|
||||
``from_options_resilient`` handles the per-field fall-back at setup,
|
||||
not this strict path).
|
||||
"""
|
||||
assert NestQuestSettings.from_options({CONF_HORIZON_DAYS: 5}).horizon_days == 5
|
||||
assert (
|
||||
NestQuestSettings.from_options({}).horizon_days == DEFAULT_HORIZON_DAYS
|
||||
)
|
||||
assert (
|
||||
NestQuestSettings.from_options(
|
||||
{CONF_HORIZON_DAYS: None}
|
||||
).horizon_days
|
||||
== DEFAULT_HORIZON_DAYS
|
||||
)
|
||||
for bad in (True, "5", 0):
|
||||
with pytest.raises(ValueError):
|
||||
NestQuestSettings.from_options({CONF_HORIZON_DAYS: bad})
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value",
|
||||
["25:00", "00:60", "24:00", "9:5", "abc", "07:00:00", 700, ""],
|
||||
)
|
||||
def test_from_options_rejects_invalid_rollover(value: Any) -> None:
|
||||
"""A malformed day_rollover_time is rejected.
|
||||
|
||||
A key that is ABSENT (or present-but-None) falls back to the
|
||||
default — a present-but-empty-string or non-HH:MM value raises,
|
||||
matching the options flow's strict validation.
|
||||
"""
|
||||
with pytest.raises(ValueError):
|
||||
NestQuestSettings.from_options({CONF_DAY_ROLLOVER_TIME: value})
|
||||
|
||||
|
||||
def test_from_options_rollover_absent_falls_back_to_default() -> None:
|
||||
"""An absent (or None) day_rollover_time key falls back to the default."""
|
||||
assert (
|
||||
NestQuestSettings.from_options({}).day_rollover_time
|
||||
== DEFAULT_DAY_ROLLOVER_TIME
|
||||
)
|
||||
assert (
|
||||
NestQuestSettings.from_options(
|
||||
{CONF_DAY_ROLLOVER_TIME: None}
|
||||
).day_rollover_time
|
||||
== DEFAULT_DAY_ROLLOVER_TIME
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"key",
|
||||
[
|
||||
CONF_MORNING_SUMMARY_TIME,
|
||||
CONF_AFTERNOON_REMINDER_TIME,
|
||||
CONF_END_OF_DAY_REPORT_TIME,
|
||||
],
|
||||
)
|
||||
def test_from_options_rejects_invalid_notification_time(key: str) -> None:
|
||||
"""The three notification times are validated as strict HH:MM."""
|
||||
with pytest.raises(ValueError):
|
||||
NestQuestSettings.from_options({key: "25:99"})
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"key",
|
||||
[
|
||||
CONF_MORNING_SUMMARY_ENABLED,
|
||||
CONF_AFTERNOON_REMINDER_ENABLED,
|
||||
CONF_END_OF_DAY_REPORT_ENABLED,
|
||||
CONF_CELEBRATION_ENABLED,
|
||||
],
|
||||
)
|
||||
def test_from_options_rejects_non_bool_toggle(key: str) -> None:
|
||||
"""Each toggle must be a real bool (no string coercion)."""
|
||||
with pytest.raises(ValueError):
|
||||
NestQuestSettings.from_options({key: "yes"})
|
||||
|
||||
|
||||
def test_from_options_rejects_non_string_notify_target() -> None:
|
||||
"""notify_target must be a string (or absent)."""
|
||||
with pytest.raises(ValueError):
|
||||
NestQuestSettings.from_options({CONF_NOTIFY_TARGET: 123})
|
||||
|
||||
|
||||
def test_from_options_strips_notify_target() -> None:
|
||||
"""A padded notify target is stripped; empty stays empty."""
|
||||
s = NestQuestSettings.from_options({CONF_NOTIFY_TARGET: " notify.x "})
|
||||
assert s.notify_target == "notify.x"
|
||||
s_empty = NestQuestSettings.from_options({CONF_NOTIFY_TARGET: " "})
|
||||
assert s_empty.notify_target == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# from_options_resilient: per-field fallback.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_from_options_resilient_none_yields_all_defaults() -> None:
|
||||
"""A None mapping yields the all-defaults settings (no warnings)."""
|
||||
s = NestQuestSettings.from_options_resilient(None)
|
||||
assert s.horizon_days == DEFAULT_HORIZON_DAYS
|
||||
assert s.day_rollover_time == DEFAULT_DAY_ROLLOVER_TIME
|
||||
|
||||
|
||||
def test_from_options_resilient_reads_valid_keys() -> None:
|
||||
"""Valid keys are read exactly as from_options would read them."""
|
||||
s = NestQuestSettings.from_options_resilient(
|
||||
{CONF_HORIZON_DAYS: 3, CONF_DAY_ROLLOVER_TIME: "03:30"}
|
||||
)
|
||||
assert s.horizon_days == 3
|
||||
assert s.day_rollover_time == "03:30"
|
||||
|
||||
|
||||
def test_from_options_resilient_keeps_horizon_when_sibling_is_bad(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A malformed SIBLING field cannot reset horizon_days or day_rollover_time.
|
||||
|
||||
The old all-or-nothing fallback reset EVERY field when from_options
|
||||
raised for ANY field: ``{horizon_days: 3, morning_summary_time:
|
||||
'25:00'}`` yielded horizon 14 (was 3). The resilient builder falls
|
||||
back PER FIELD, so horizon_days=3 and day_rollover_time='03:30'
|
||||
survive a malformed morning_summary_time, and the bad field alone
|
||||
falls back to its default with a warning naming it.
|
||||
"""
|
||||
s = NestQuestSettings.from_options_resilient(
|
||||
{
|
||||
CONF_HORIZON_DAYS: 3,
|
||||
CONF_DAY_ROLLOVER_TIME: "03:30",
|
||||
CONF_MORNING_SUMMARY_TIME: "25:00",
|
||||
}
|
||||
)
|
||||
assert s.horizon_days == 3
|
||||
assert s.day_rollover_time == "03:30"
|
||||
# The malformed sibling fell back to its default.
|
||||
assert s.morning_summary_time == DEFAULT_MORNING_SUMMARY_TIME
|
||||
# A warning was logged naming the bad field.
|
||||
assert any(
|
||||
"morning_summary_time" in record.getMessage()
|
||||
and record.levelname == "WARNING"
|
||||
for record in caplog.records
|
||||
)
|
||||
|
||||
|
||||
def test_from_options_resilient_bad_horizon_falls_back_with_warning(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A malformed horizon_days itself falls back to the default + warning."""
|
||||
s = NestQuestSettings.from_options_resilient({CONF_HORIZON_DAYS: True})
|
||||
assert s.horizon_days == DEFAULT_HORIZON_DAYS
|
||||
assert any(
|
||||
"horizon_days" in record.getMessage() and record.levelname == "WARNING"
|
||||
for record in caplog.records
|
||||
)
|
||||
|
||||
|
||||
def test_from_options_resilient_bad_rollover_falls_back_with_warning(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A malformed day_rollover_time falls back to the default + warning."""
|
||||
s = NestQuestSettings.from_options_resilient(
|
||||
{CONF_DAY_ROLLOVER_TIME: "99:99"}
|
||||
)
|
||||
assert s.day_rollover_time == DEFAULT_DAY_ROLLOVER_TIME
|
||||
assert any(
|
||||
"day_rollover_time" in record.getMessage()
|
||||
and record.levelname == "WARNING"
|
||||
for record in caplog.records
|
||||
)
|
||||
|
||||
|
||||
def test_from_options_resilient_none_value_is_not_a_warning(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A None value maps to the default silently (it is absent, not malformed)."""
|
||||
s = NestQuestSettings.from_options_resilient(
|
||||
{CONF_HORIZON_DAYS: None, CONF_MORNING_SUMMARY_TIME: None}
|
||||
)
|
||||
assert s.horizon_days == DEFAULT_HORIZON_DAYS
|
||||
assert s.morning_summary_time == DEFAULT_MORNING_SUMMARY_TIME
|
||||
assert not [r for r in caplog.records if r.levelname == "WARNING"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Construction + frozenness.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_direct_construction_validates() -> None:
|
||||
"""Hand-built instances validate the same way from_options does."""
|
||||
with pytest.raises(ValueError):
|
||||
NestQuestSettings(horizon_days=0)
|
||||
with pytest.raises(ValueError):
|
||||
NestQuestSettings(day_rollover_time="bad")
|
||||
with pytest.raises(ValueError):
|
||||
NestQuestSettings(morning_summary_enabled="yes")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"field,kwargs",
|
||||
[
|
||||
("horizon_days", {"horizon_days": None}),
|
||||
("day_rollover_time", {"day_rollover_time": None}),
|
||||
("notify_target", {"notify_target": None}),
|
||||
("morning_summary_time", {"morning_summary_time": None}),
|
||||
("afternoon_reminder_time", {"afternoon_reminder_time": None}),
|
||||
("end_of_day_report_time", {"end_of_day_report_time": None}),
|
||||
("morning_summary_enabled", {"morning_summary_enabled": None}),
|
||||
("afternoon_reminder_enabled", {"afternoon_reminder_enabled": None}),
|
||||
("end_of_day_report_enabled", {"end_of_day_report_enabled": None}),
|
||||
("celebration_enabled", {"celebration_enabled": None}),
|
||||
],
|
||||
)
|
||||
def test_direct_construction_rejects_none(field: str, kwargs: dict[str, Any]) -> None:
|
||||
"""__post_init__ is STRICT: None is rejected for every field.
|
||||
|
||||
The None->default mapping lives ONLY in from_options (via
|
||||
_option_value). A hand-built NestQuestSettings(field=None) must raise
|
||||
in __post_init__ rather than constructing and later blowing up
|
||||
horizon_window (timedelta(days=None) -> TypeError) or the rollover
|
||||
listener.
|
||||
"""
|
||||
with pytest.raises(ValueError):
|
||||
NestQuestSettings(**kwargs)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [True, False, "5", 1.5])
|
||||
def test_direct_construction_rejects_non_int_horizon(value: Any) -> None:
|
||||
"""A bool or non-int horizon is rejected at construction (not bool-as-int)."""
|
||||
with pytest.raises(ValueError, match="horizon_days"):
|
||||
NestQuestSettings(horizon_days=value)
|
||||
|
||||
|
||||
|
||||
def test_direct_construction_strips_notify_target() -> None:
|
||||
"""__post_init__ normalises notify_target the SAME way from_options does.
|
||||
|
||||
P3: a hand-built ``NestQuestSettings(notify_target=' x ')`` is
|
||||
stripped to ``'x'`` (and a whitespace-only value to ``''``) so direct
|
||||
construction behaves like the options flow; a padded value cannot
|
||||
slip through __post_init__ either.
|
||||
"""
|
||||
assert NestQuestSettings(notify_target=" notify.x ").notify_target == "notify.x"
|
||||
assert NestQuestSettings(notify_target=" ").notify_target == ""
|
||||
assert NestQuestSettings(notify_target="notify.x").notify_target == "notify.x"
|
||||
|
||||
|
||||
def test_settings_is_frozen() -> None:
|
||||
"""The dataclass is frozen so a stale settings object cannot be mutated."""
|
||||
s = NestQuestSettings()
|
||||
with pytest.raises(FrozenInstanceError):
|
||||
s.horizon_days = 99 # type: ignore[misc]
|
||||
|
||||
|
||||
def test_day_rollover_hour_minute() -> None:
|
||||
"""day_rollover_hour_minute returns the parsed (hour, minute)."""
|
||||
s = NestQuestSettings(day_rollover_time="03:30")
|
||||
assert s.day_rollover_hour_minute == (3, 30)
|
||||
assert NestQuestSettings().day_rollover_hour_minute == (0, 0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# horizon_window.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_horizon_window_pinned_today() -> None:
|
||||
"""A pinned today is honored so the window is deterministic.
|
||||
|
||||
``today`` is REQUIRED (no host-clock default) — see
|
||||
:meth:`NestQuestSettings.horizon_window`.
|
||||
"""
|
||||
s = NestQuestSettings(horizon_days=5)
|
||||
pinned = datetime.date(2026, 3, 1)
|
||||
start, end = s.horizon_window(pinned)
|
||||
assert start == pinned
|
||||
assert end == pinned + datetime.timedelta(days=5)
|
||||
|
||||
|
||||
def test_horizon_window_requires_today() -> None:
|
||||
"""horizon_window has no default today: omitting it raises TypeError."""
|
||||
s = NestQuestSettings(horizon_days=5)
|
||||
with pytest.raises(TypeError):
|
||||
s.horizon_window() # type: ignore[call-arg]
|
||||
|
||||
|
||||
def test_horizon_window_non_default_horizon() -> None:
|
||||
"""A non-default horizon_days widens the window accordingly."""
|
||||
pinned = datetime.date(2026, 3, 1)
|
||||
short = NestQuestSettings(horizon_days=3).horizon_window(pinned)
|
||||
long_ = NestQuestSettings(horizon_days=30).horizon_window(pinned)
|
||||
assert short[1] == pinned + datetime.timedelta(days=3)
|
||||
assert long_[1] == pinned + datetime.timedelta(days=30)
|
||||
assert (long_[1] - short[1]).days == 27
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AST: no HA config reads under core/.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
CORE_PKG = Path(_core_mod.__file__).parent
|
||||
|
||||
|
||||
def _is_homeassistant(module: str) -> bool:
|
||||
return module == "homeassistant" or module.startswith("homeassistant.")
|
||||
|
||||
|
||||
#: HA-specific identifiers with no legitimate core use. ``entry`` is
|
||||
#: deliberately NOT in this set: core uses ``entry`` as a domain loop
|
||||
#: variable (iterating weekday_set entries in recurrence/presence/
|
||||
#: quest_definitions), so forbidding the bare name would false-positive.
|
||||
#: The config-entry attribute reads the bare-name check misses are
|
||||
#: caught by _CONFIG_ATTRS and _FORBIDDEN_ATTRS below.
|
||||
_FORBIDDEN_NAMES = frozenset({"hass", "config_entry"})
|
||||
|
||||
#: Attribute names that signal a HA object reference (self.hass,
|
||||
#: self.entry, obj.config_entry). No core module currently uses any of
|
||||
#: these as an attribute, so flagging them is false-positive-free.
|
||||
_FORBIDDEN_ATTRS = frozenset({"hass", "entry", "config_entry"})
|
||||
|
||||
#: Config-entry attribute reads — the shapes ``hass.config``,
|
||||
#: ``config_entry.options``, ``self.entry.options``, ``runtime.options``,
|
||||
#: ``entry.data``. Flagging the attribute ANYWHERE (regardless of root)
|
||||
#: catches ``runtime.options``, whose root name is not a forbidden
|
||||
#: identifier. No core module currently reads any of these attributes.
|
||||
_CONFIG_ATTRS = frozenset({"config", "options", "data"})
|
||||
|
||||
#: Dynamic config-entry reads via getattr/hasattr with a string argument.
|
||||
_GETATTR_FUNCS = frozenset({"getattr", "hasattr"})
|
||||
|
||||
#: The dynamic-import function the string-based import bypass uses.
|
||||
#: ``importlib.import_module("homeassistant...")`` reaches the HA package
|
||||
#: without an ``import homeassistant`` statement the static import check
|
||||
#: sees, so the scanner must recognise the call shape too.
|
||||
_IMPORT_MODULE_FUNCS = frozenset({"import_module"})
|
||||
|
||||
|
||||
def _is_import_module_call(node: ast.Call) -> bool:
|
||||
"""True for ``importlib.import_module(...)`` or a bare ``import_module(...)``."""
|
||||
func = node.func
|
||||
if isinstance(func, ast.Attribute):
|
||||
return func.attr in _IMPORT_MODULE_FUNCS
|
||||
if isinstance(func, ast.Name):
|
||||
return func.id in _IMPORT_MODULE_FUNCS
|
||||
return False
|
||||
|
||||
|
||||
def _scan_source_for_ha_config(
|
||||
source: str, *, name: str = "<snippet>"
|
||||
) -> list[str]:
|
||||
"""Return offender strings for HA-coupling in ``source``.
|
||||
|
||||
Flags (each closes a bypass a root-only ``hass``/``entry`` chain
|
||||
check would not catch):
|
||||
|
||||
- any ``import homeassistant`` / ``from homeassistant ...``;
|
||||
- any ``importlib.import_module('homeassistant...')`` (a string-based
|
||||
import that reaches HA without an ``import homeassistant`` statement
|
||||
the static import check can see);
|
||||
- any ``Name``/``arg`` whose id is ``hass`` or ``config_entry``
|
||||
(HA-specific identifiers with no legitimate core use);
|
||||
- any call keyword whose name is ``hass`` or ``config_entry``
|
||||
(catches ``foo(hass=...)`` / ``handler(config_entry=...)`` — the
|
||||
bare-Name check misses keyword arguments);
|
||||
- any attribute access whose ``attr`` is ``hass``/``entry``/
|
||||
``config_entry`` (catches ``self.hass``, ``self.entry``,
|
||||
``obj.config_entry`` — root names a root-only check could not see);
|
||||
- any attribute access whose ``attr`` is ``config``/``options``/
|
||||
``data`` — these are Home Assistant config-entry attributes with no
|
||||
legitimate core use (``.config`` reads ``hass.config``, ``.options``
|
||||
reads ``config_entry.options``, ``.data`` reads ``config_entry.data``);
|
||||
flagging the attribute ANYWHERE (regardless of root) is what catches
|
||||
``runtime.options``, whose root name is not a forbidden identifier.
|
||||
No core module currently reads any of these attributes;
|
||||
- any ``getattr``/``hasattr`` call whose string argument is
|
||||
``'config'``/``'options'``/``'data'`` (catches the dynamic read
|
||||
``getattr(hass, 'config')`` the static attribute check cannot see).
|
||||
|
||||
Docstring mentions are structurally exempt: AST only sees code, and
|
||||
none of these node shapes appear inside a string literal. The
|
||||
existing docstring references to ``hass.config`` in ``core/events.py``
|
||||
and ``core/store.py`` therefore pass.
|
||||
"""
|
||||
tree = ast.parse(source, filename=name)
|
||||
offenders: list[str] = []
|
||||
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Import):
|
||||
for alias in node.names:
|
||||
if _is_homeassistant(alias.name):
|
||||
offenders.append(
|
||||
f"{name}:{node.lineno} homeassistant import: "
|
||||
f"import {alias.name}"
|
||||
)
|
||||
elif isinstance(node, ast.ImportFrom):
|
||||
base = node.module or ""
|
||||
if _is_homeassistant(base):
|
||||
offenders.append(
|
||||
f"{name}:{node.lineno} homeassistant import: from {base}"
|
||||
)
|
||||
for alias in node.names:
|
||||
combined = f"{base}.{alias.name}" if base else alias.name
|
||||
if _is_homeassistant(combined):
|
||||
offenders.append(
|
||||
f"{name}:{node.lineno} homeassistant import: "
|
||||
f"from {base} import {alias.name}"
|
||||
)
|
||||
elif isinstance(node, ast.Name):
|
||||
if node.id in _FORBIDDEN_NAMES:
|
||||
offenders.append(
|
||||
f"{name}:{node.lineno} forbidden identifier: {node.id!r}"
|
||||
)
|
||||
elif isinstance(node, ast.arg):
|
||||
if node.arg in _FORBIDDEN_NAMES:
|
||||
offenders.append(
|
||||
f"{name}:{node.lineno} forbidden parameter: {node.arg!r}"
|
||||
)
|
||||
elif isinstance(node, ast.keyword):
|
||||
# A call keyword like ``foo(hass=...)``: the bare-Name check
|
||||
# sees the value, not the keyword's own name, so a HA object
|
||||
# could be passed by keyword without tripping it. ``arg`` is
|
||||
# ``None`` for ``**kwargs`` unpacking, which is not a name.
|
||||
if node.arg is not None and node.arg in _FORBIDDEN_NAMES:
|
||||
offenders.append(
|
||||
f"{name}:{node.lineno} forbidden keyword argument: "
|
||||
f"{node.arg!r}"
|
||||
)
|
||||
elif isinstance(node, ast.Attribute):
|
||||
if node.attr in _FORBIDDEN_ATTRS:
|
||||
offenders.append(
|
||||
f"{name}:{node.lineno} forbidden attribute: .{node.attr}"
|
||||
)
|
||||
if node.attr in _CONFIG_ATTRS:
|
||||
offenders.append(
|
||||
f"{name}:{node.lineno} config-entry attribute read: "
|
||||
f".{node.attr}"
|
||||
)
|
||||
elif isinstance(node, ast.Call):
|
||||
if _is_import_module_call(node):
|
||||
# The first positional argument is the module string; a
|
||||
# constant ``'homeassistant...'`` is the bypass shape.
|
||||
for arg in node.args:
|
||||
if (
|
||||
isinstance(arg, ast.Constant)
|
||||
and isinstance(arg.value, str)
|
||||
and _is_homeassistant(arg.value)
|
||||
):
|
||||
offenders.append(
|
||||
f"{name}:{node.lineno} dynamic homeassistant "
|
||||
f"import: import_module({arg.value!r})"
|
||||
)
|
||||
func = node.func
|
||||
if isinstance(func, ast.Name) and func.id in _GETATTR_FUNCS:
|
||||
for arg in node.args:
|
||||
if (
|
||||
isinstance(arg, ast.Constant)
|
||||
and isinstance(arg.value, str)
|
||||
and arg.value in _CONFIG_ATTRS
|
||||
):
|
||||
offenders.append(
|
||||
f"{name}:{node.lineno} {func.id}() dynamic "
|
||||
f"config-entry read: {arg.value!r}"
|
||||
)
|
||||
|
||||
return offenders
|
||||
|
||||
|
||||
def _scan_module_for_ha_config(path: Path) -> list[str]:
|
||||
"""Scan a core module file for HA-coupling (file-backed scan)."""
|
||||
return _scan_source_for_ha_config(
|
||||
path.read_text(encoding="utf-8"), name=path.name
|
||||
)
|
||||
|
||||
|
||||
def test_core_modules_have_no_ha_config_reads() -> None:
|
||||
"""No core module imports homeassistant or reads HA config / entry options.
|
||||
|
||||
The scan forbids the HA-specific identifiers ``hass``/``config_entry``,
|
||||
the HA-object attributes ``.hass``/``.entry``/``.config_entry``, the
|
||||
config-entry attribute reads ``.config``/``.options``/``.data``, and
|
||||
dynamic ``getattr``/``hasattr`` reads of them — so a chain rooted at
|
||||
``self`` or ``runtime`` (whose root name is not a forbidden
|
||||
identifier) is caught. ``.config``/``.options``/``.data`` are banned
|
||||
because they are Home Assistant config-entry attributes with no
|
||||
legitimate core use: core receives the resolved values via the
|
||||
explicit :class:`NestQuestSettings` object the integration builds
|
||||
from ``entry.options``, so any read of those attributes in ``core/``
|
||||
is a config-entry coupling the settings object exists to remove.
|
||||
Docstring mentions pass: AST only sees code.
|
||||
"""
|
||||
py_files = sorted(CORE_PKG.rglob("*.py"))
|
||||
assert py_files, "core package not found"
|
||||
all_offenders: list[str] = []
|
||||
for py_file in py_files:
|
||||
all_offenders.extend(_scan_module_for_ha_config(py_file))
|
||||
assert all_offenders == [], (
|
||||
"core package reads HA config / entry options:\n"
|
||||
+ "\n".join(all_offenders)
|
||||
)
|
||||
|
||||
|
||||
def test_ast_scan_flags_each_bypass_shape() -> None:
|
||||
"""Negative control: the scanner flags each bypass a root-only check misses.
|
||||
|
||||
A root-only scan flags chains rooted at a literal name ``hass``/
|
||||
``entry``. Each snippet below is a shape such a scan would miss;
|
||||
this scanner must flag every one.
|
||||
"""
|
||||
snippets: dict[str, str] = {
|
||||
"self.hass.config": "x = self.hass.config\n",
|
||||
"config_entry.options": "x = config_entry.options\n",
|
||||
"self.entry.options": "x = self.entry.options\n",
|
||||
"getattr(hass,'config')": "x = getattr(hass, 'config')\n",
|
||||
"runtime.options": "x = runtime.options\n",
|
||||
"hasattr(obj,'data')": "x = hasattr(obj, 'data')\n",
|
||||
"importlib.import_module('homeassistant')": (
|
||||
"import importlib\nimportlib.import_module('homeassistant')\n"
|
||||
),
|
||||
"import_module('homeassistant.helpers')": (
|
||||
"from importlib import import_module\n"
|
||||
"import_module('homeassistant.helpers')\n"
|
||||
),
|
||||
"handler(hass=...)": "handler(hass=obj)\n",
|
||||
"handler(config_entry=...)": "handler(config_entry=obj)\n",
|
||||
}
|
||||
for label, src in snippets.items():
|
||||
offenders = _scan_source_for_ha_config(src, name=label)
|
||||
assert offenders, f"scanner missed {label!r}: {src!r}"
|
||||
|
||||
|
||||
def test_ast_scan_allows_non_homeassistant_import_module() -> None:
|
||||
"""A dynamic import of a NON-homeassistant module is not flagged.
|
||||
|
||||
``importlib.import_module`` is a legitimate way to load a non-HA
|
||||
module lazily; only a ``homeassistant...`` target is the bypass shape.
|
||||
"""
|
||||
src = (
|
||||
"import importlib\n"
|
||||
"m = importlib.import_module('custom_components.nestquest.core')\n"
|
||||
)
|
||||
assert _scan_source_for_ha_config(src, name="non-ha-import") == []
|
||||
|
||||
|
||||
def test_ast_scan_allows_non_forbidden_keyword_arguments() -> None:
|
||||
"""A call with non-forbidden keyword arguments is not flagged.
|
||||
|
||||
Only ``hass=``/``config_entry=`` are forbidden as keyword names; an
|
||||
unrelated keyword like ``hass_data=`` or ``entry_id=`` must not
|
||||
false-positive (and ``**kwargs`` unpacking has ``arg=None``).
|
||||
"""
|
||||
src = (
|
||||
"foo(entry_id=1, name='x')\n"
|
||||
"bar(**kwargs)\n"
|
||||
)
|
||||
assert _scan_source_for_ha_config(src, name="non-forbidden-kwargs") == []
|
||||
|
||||
|
||||
def test_ast_scan_allows_legitimate_entry_loop_var() -> None:
|
||||
"""A bare ``entry`` loop variable (a domain concept) is NOT flagged.
|
||||
|
||||
``entry`` is used in core as a loop variable over weekday_set entries
|
||||
(recurrence.py, presence.py, quest_definitions.py) — not a HA config
|
||||
entry. Forbidding the bare name would false-positive on those, so
|
||||
only ``hass``/``config_entry`` are forbidden as bare names; the
|
||||
config-entry reads via ``entry.options``/``entry.data`` are caught by
|
||||
the ``_CONFIG_ATTRS`` attribute check instead.
|
||||
"""
|
||||
offenders = _scan_source_for_ha_config(
|
||||
"for entry in items:\n pass\n", name="loop"
|
||||
)
|
||||
assert offenders == [], offenders
|
||||
|
||||
|
||||
def test_ast_scan_allows_clean_core_shape() -> None:
|
||||
"""A clean snippet with no HA coupling is not flagged (no false positives)."""
|
||||
src = "opts = {'a': 1}\nx = opts.get('a')\nfor entry in items:\n pass\n"
|
||||
offenders = _scan_source_for_ha_config(src, name="clean")
|
||||
assert offenders == [], offenders
|
||||
|
||||
|
||||
def test_settings_module_lives_under_core() -> None:
|
||||
"""settings.py is part of the core package (not the integration root)."""
|
||||
assert (CORE_PKG / "settings.py").is_file()
|
||||
# And it is importable as a core submodule.
|
||||
import custom_components.nestquest.core.settings as mod
|
||||
|
||||
assert hasattr(mod, "NestQuestSettings")
|
||||
assert isinstance(mod.NestQuestSettings, type)
|
||||
|
||||
|
||||
def test_settings_module_has_no_ha_imports() -> None:
|
||||
"""settings.py specifically carries no homeassistant import or config read."""
|
||||
offenders = _scan_module_for_ha_config(CORE_PKG / "settings.py")
|
||||
assert offenders == [], offenders
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Materialization over a non-default horizon: core path + integration path.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _prepare(path, executor):
|
||||
"""Open and migrate a database backed by ``executor`` (executor-only DB access)."""
|
||||
from custom_components.nestquest.core.db import NestQuestDatabase
|
||||
from custom_components.nestquest.core.migrations import apply_migrations
|
||||
|
||||
database = NestQuestDatabase(executor)
|
||||
await database.open(path)
|
||||
await apply_migrations(database)
|
||||
return database
|
||||
|
||||
|
||||
async def test_materialize_over_non_default_horizon(hass, tmp_path) -> None:
|
||||
"""A settings object with a non-default horizon drives the matching window.
|
||||
|
||||
Constructs NestQuestSettings with horizon_days=3 and day_rollover_time
|
||||
'03:30' (both non-default), builds the window via horizon_window, and
|
||||
drives the CORE ``materialize`` over it; asserts the resulting instance
|
||||
rows land exactly on the [today, today+3] window and NOT beyond it.
|
||||
"""
|
||||
from custom_components.nestquest.core.dao_children import ChildrenDao
|
||||
from custom_components.nestquest.core.dao_instances import (
|
||||
QuestInstancesDao,
|
||||
)
|
||||
from custom_components.nestquest.core.materialize import materialize
|
||||
from custom_components.nestquest.core.quest_definitions import (
|
||||
create_quest_definition,
|
||||
)
|
||||
from custom_components.nestquest.core.recurrence import (
|
||||
RuleType,
|
||||
ScheduleRule,
|
||||
)
|
||||
|
||||
database = await _prepare(tmp_path / "nq.db", hass.async_add_executor_job)
|
||||
try:
|
||||
settings = NestQuestSettings(
|
||||
horizon_days=3, day_rollover_time="03:30"
|
||||
)
|
||||
assert settings.horizon_days == 3
|
||||
assert settings.day_rollover_time == "03:30"
|
||||
|
||||
today = datetime.date(2026, 3, 1)
|
||||
start, end = settings.horizon_window(today)
|
||||
assert start == today
|
||||
assert end == today + datetime.timedelta(days=3)
|
||||
|
||||
NOW = "2026-03-01T12:00:00+00:00"
|
||||
child = await ChildrenDao(database).create("Ada", NOW)
|
||||
await create_quest_definition(
|
||||
database,
|
||||
"Daily chore",
|
||||
ScheduleRule(
|
||||
rule_type=RuleType.DAILY, start_date=today.isoformat()
|
||||
),
|
||||
[child.id],
|
||||
["morning"],
|
||||
)
|
||||
|
||||
count = await materialize(
|
||||
database,
|
||||
start.isoformat(),
|
||||
end.isoformat(),
|
||||
today=today,
|
||||
)
|
||||
# 4 days (today..today+3 inclusive), one instance per day.
|
||||
assert count == 4
|
||||
|
||||
records = await QuestInstancesDao(database).list_by_date_range(
|
||||
child.id, start.isoformat(), end.isoformat()
|
||||
)
|
||||
dates = sorted({r.due_date for r in records})
|
||||
assert dates == [
|
||||
(today + datetime.timedelta(days=i)).isoformat()
|
||||
for i in range(4)
|
||||
]
|
||||
|
||||
# Beyond the configured horizon: no instances.
|
||||
beyond = (end + datetime.timedelta(days=1)).isoformat()
|
||||
far = (end + datetime.timedelta(days=10)).isoformat()
|
||||
assert await QuestInstancesDao(database).list_by_date_range(
|
||||
child.id, beyond, far
|
||||
) == []
|
||||
finally:
|
||||
await database.close()
|
||||
|
||||
|
||||
async def test_integration_path_threads_settings(
|
||||
hass, tmp_path, monkeypatch
|
||||
) -> None:
|
||||
"""The integration path threads ``settings`` through horizon_window + rollover.
|
||||
|
||||
``_run_horizon_materialization`` calls
|
||||
``settings.horizon_window(today)`` (so the helper is real and shared,
|
||||
not recomputed inline), and ``_register_day_rollover_listener``
|
||||
registers at ``settings.day_rollover_hour_minute``. This runs the
|
||||
INTEGRATION path with ``horizon_days=3`` and
|
||||
``day_rollover_time='03:30'`` and asserts the resulting instance
|
||||
window matches [today, today+3] AND the registered hour/minute are 3/30.
|
||||
"""
|
||||
import custom_components.nestquest as nestquest
|
||||
from custom_components.nestquest.core.dao_children import ChildrenDao
|
||||
from custom_components.nestquest.core.dao_instances import (
|
||||
QuestInstancesDao,
|
||||
)
|
||||
from custom_components.nestquest.core.quest_definitions import (
|
||||
create_quest_definition,
|
||||
)
|
||||
from custom_components.nestquest.core.recurrence import (
|
||||
RuleType,
|
||||
ScheduleRule,
|
||||
)
|
||||
|
||||
# Freeze the integration clock so "today" is deterministic (the
|
||||
# hass fixture's config.time_zone is UTC, so 2026-03-01 12:00 UTC
|
||||
# localizes to 2026-03-01).
|
||||
freeze_nestquest_clock(
|
||||
monkeypatch, datetime.datetime(2026, 3, 1, 12, 0, tzinfo=datetime.timezone.utc)
|
||||
)
|
||||
|
||||
database = await _prepare(tmp_path / "nq.db", hass.async_add_executor_job)
|
||||
try:
|
||||
settings = NestQuestSettings(horizon_days=3, day_rollover_time="03:30")
|
||||
today = datetime.date(2026, 3, 1)
|
||||
|
||||
NOW = "2026-03-01T12:00:00+00:00"
|
||||
child = await ChildrenDao(database).create("Ada", NOW)
|
||||
await create_quest_definition(
|
||||
database,
|
||||
"Daily chore",
|
||||
ScheduleRule(
|
||||
rule_type=RuleType.DAILY, start_date=today.isoformat()
|
||||
),
|
||||
[child.id],
|
||||
["morning"],
|
||||
)
|
||||
|
||||
# Integration path: the window comes from settings.horizon_window.
|
||||
await nestquest._run_horizon_materialization(hass, database, settings)
|
||||
|
||||
end = today + datetime.timedelta(days=3)
|
||||
records = await QuestInstancesDao(database).list_by_date_range(
|
||||
child.id, today.isoformat(), end.isoformat()
|
||||
)
|
||||
dates = sorted({r.due_date for r in records})
|
||||
assert dates == [
|
||||
(today + datetime.timedelta(days=i)).isoformat()
|
||||
for i in range(4)
|
||||
]
|
||||
beyond = (end + datetime.timedelta(days=1)).isoformat()
|
||||
far = (end + datetime.timedelta(days=10)).isoformat()
|
||||
assert await QuestInstancesDao(database).list_by_date_range(
|
||||
child.id, beyond, far
|
||||
) == []
|
||||
|
||||
# The rollover listener registers at the configured hour/minute.
|
||||
remove = nestquest._register_day_rollover_listener(
|
||||
hass, database, settings
|
||||
)
|
||||
try:
|
||||
reg = hass.time_change.registrations[-1]
|
||||
assert reg["hour"] == 3
|
||||
assert reg["minute"] == 30
|
||||
finally:
|
||||
remove()
|
||||
finally:
|
||||
await database.close()
|
||||
@@ -1,306 +0,0 @@
|
||||
"""Test the pure panel snapshot builder (task 9047a97b).
|
||||
|
||||
The snapshot assembly that lived inline in the coordinator's
|
||||
``_async_update_data`` was extracted into the Home-Assistant-free
|
||||
:func:`custom_components.nestquest.core.snapshot.build_snapshot`, and
|
||||
the panel payload shaper (:func:`instance_payload`) with it. These
|
||||
tests seed a household and assert the pure builder's output against
|
||||
EXPLICIT literal expected values — not self-equality — so the AWAY
|
||||
child's ``next_present``, a due-timed quest's ``overdue`` under a
|
||||
pinned ``now``, the custody ``cycle_day`` (> 1), and the panel payload
|
||||
dict shape (``done`` -> ``completed``, ``on_time`` passthrough, and
|
||||
``missed`` omission) are all exercised as literals.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from conftest import wire_entry_to_registry
|
||||
|
||||
from custom_components.nestquest import async_setup_entry
|
||||
from custom_components.nestquest.children import create_child
|
||||
from custom_components.nestquest.completion import complete_instance
|
||||
from custom_components.nestquest.core.snapshot import (
|
||||
ChildDaySnapshot,
|
||||
NestQuestSnapshot,
|
||||
QuestInstanceView,
|
||||
build_snapshot,
|
||||
instance_payload,
|
||||
)
|
||||
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
||||
from custom_components.nestquest.dao_presence import (
|
||||
PresenceOverridesDao,
|
||||
PresenceSchedulesDao,
|
||||
)
|
||||
from custom_components.nestquest.materialize import materialize
|
||||
from custom_components.nestquest.quest_definitions import create_quest_definition
|
||||
from custom_components.nestquest.recurrence import ScheduleRule
|
||||
|
||||
|
||||
async def test_instance_payload_shape_and_missed_omission() -> None:
|
||||
"""``instance_payload`` is the documented panel shape (§1).
|
||||
|
||||
``done`` maps to ``completed``; ``open`` passes through; ``on_time``
|
||||
is the builder's ``was_on_time`` verbatim; ``missed`` instances are
|
||||
omitted unless ``include_missed`` (the admin payload, D-009).
|
||||
"""
|
||||
def view(state: str, *, overdue: bool = False, on_time: bool | None = None,
|
||||
completed_at: str | None = None, instance_id: int = 1) -> QuestInstanceView:
|
||||
return QuestInstanceView(
|
||||
instance_id=instance_id,
|
||||
definition_id=10,
|
||||
child_id=3,
|
||||
title="Fill bird feeder",
|
||||
icon="bird",
|
||||
window="afternoon",
|
||||
due_date="2026-09-21",
|
||||
due_time="17:00",
|
||||
state=state,
|
||||
overdue=overdue,
|
||||
completed_at=completed_at,
|
||||
was_on_time=on_time,
|
||||
)
|
||||
|
||||
open_view = view("open", overdue=True)
|
||||
done_view = view("done", on_time=True, completed_at="2026-09-21T12:00:00+00:00")
|
||||
missed_view = view("missed", instance_id=2)
|
||||
|
||||
panel = instance_payload([open_view, done_view, missed_view], include_missed=False)
|
||||
assert panel == [
|
||||
{
|
||||
"id": 1,
|
||||
"definition_id": 10,
|
||||
"child_id": 3,
|
||||
"title": "Fill bird feeder",
|
||||
"icon": "bird",
|
||||
"window": "afternoon",
|
||||
"due_time": "17:00",
|
||||
"state": "open",
|
||||
"overdue": True,
|
||||
"completed_at": None,
|
||||
"on_time": None,
|
||||
},
|
||||
{
|
||||
"id": 1,
|
||||
"definition_id": 10,
|
||||
"child_id": 3,
|
||||
"title": "Fill bird feeder",
|
||||
"icon": "bird",
|
||||
"window": "afternoon",
|
||||
"due_time": "17:00",
|
||||
"state": "completed",
|
||||
"overdue": False,
|
||||
"completed_at": "2026-09-21T12:00:00+00:00",
|
||||
"on_time": True,
|
||||
},
|
||||
], "panel payload omits missed and spells done as completed"
|
||||
|
||||
admin = instance_payload(
|
||||
[open_view, done_view, missed_view], include_missed=True
|
||||
)
|
||||
assert len(admin) == 3
|
||||
assert admin[2]["state"] == "missed", (
|
||||
"admin payload keeps missed instances with their missed state"
|
||||
)
|
||||
|
||||
|
||||
async def test_build_snapshot_explicit_values(hass, make_entry, monkeypatch) -> None:
|
||||
"""The builder produces the documented shape as literals, pinned in time.
|
||||
|
||||
Ada is AWAY today (a 2-week schedule whose absent week 0 covers today,
|
||||
plus a presence override marking her present on today+2) so
|
||||
``present`` is False and ``next_present`` is the override date. Her
|
||||
open quest due at 10:00 is OVERDUE under the pinned 12:00 ``now``.
|
||||
Bo's quest due at 17:00 is completed at 12:00 (on time) so its
|
||||
payload ``state`` is ``completed`` and ``on_time`` is True. The first
|
||||
scheduled active child is Ada, whose 2-week cycle anchored one day
|
||||
before today puts ``cycle_day`` at 2.
|
||||
"""
|
||||
entry = wire_entry_to_registry(make_entry(), hass.registry)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
coordinator = entry.runtime_data.coordinator
|
||||
database = entry.runtime_data.database
|
||||
settings = entry.runtime_data.settings
|
||||
|
||||
# Single clock read for the seed/snapshot anchor (no midnight skew).
|
||||
today = coordinator._local_now().date()
|
||||
time_zone = ZoneInfo(hass.config.time_zone)
|
||||
# Pin the build instant at 12:00 local: past Ada's 10:00 due time
|
||||
# (overdue) and before Bo's 17:00, and the completion moment for Bo.
|
||||
pinned_now = datetime.datetime(
|
||||
today.year, today.month, today.day, 12, 0, 0, tzinfo=time_zone
|
||||
)
|
||||
today_iso = today.isoformat()
|
||||
|
||||
ada = await create_child(database, "Ada")
|
||||
bo = await create_child(database, "Bo")
|
||||
|
||||
await create_quest_definition(
|
||||
database,
|
||||
"Pack bag",
|
||||
ScheduleRule.from_dict(
|
||||
{"rule_type": "daily", "start_date": today_iso}
|
||||
),
|
||||
[ada.id],
|
||||
[("morning", "10:00")],
|
||||
)
|
||||
await create_quest_definition(
|
||||
database,
|
||||
"Brush teeth",
|
||||
ScheduleRule.from_dict(
|
||||
{"rule_type": "daily", "start_date": today_iso}
|
||||
),
|
||||
[bo.id],
|
||||
[("morning", "17:00")],
|
||||
)
|
||||
end = (today + datetime.timedelta(days=3)).isoformat()
|
||||
await materialize(database, today_iso, end, today=today)
|
||||
|
||||
# Ada: 2-week cycle anchored yesterday. Week 0 (the empty segment)
|
||||
# covers today, so she is absent today; an override marks her present
|
||||
# on today+2, so next_present resolves to that date and cycle_day is
|
||||
# ((today - (today-1)) % 14) + 1 == 2.
|
||||
await PresenceSchedulesDao(database).upsert_by_child(
|
||||
ada.id,
|
||||
cycle_length_weeks=2,
|
||||
anchor_date=(today - datetime.timedelta(days=1)).isoformat(),
|
||||
pattern="|0,1,2,3,4,5,6",
|
||||
)
|
||||
returns_date = today + datetime.timedelta(days=2)
|
||||
await PresenceOverridesDao(database).create(
|
||||
ada.id,
|
||||
returns_date.isoformat(),
|
||||
returns_date.isoformat(),
|
||||
is_present=True,
|
||||
note="visit",
|
||||
)
|
||||
|
||||
# Complete Bo's instance at the pinned 12:00 (17:00 due => on time).
|
||||
bo_instances = await QuestInstancesDao(database).list_by_child_and_date(
|
||||
bo.id, today_iso
|
||||
)
|
||||
await complete_instance(
|
||||
database,
|
||||
bo_instances[0].id,
|
||||
actor_source="panel",
|
||||
actor_child_id=bo.id,
|
||||
now=pinned_now,
|
||||
today=today,
|
||||
)
|
||||
|
||||
built = await build_snapshot(database, settings, pinned_now)
|
||||
|
||||
assert isinstance(built, NestQuestSnapshot)
|
||||
assert built.today_iso == today_iso
|
||||
assert built.cycle_day == 2, (
|
||||
"Ada's 2-week cycle anchored yesterday => day 2 today"
|
||||
)
|
||||
|
||||
by_id = {child.child_id: child for child in built.children}
|
||||
assert list(by_id) == [ada.id, bo.id], (
|
||||
"active children appear in sort_order, id order"
|
||||
)
|
||||
ada_child = by_id[ada.id]
|
||||
bo_child = by_id[bo.id]
|
||||
|
||||
# Ada: away today, returns on the override date, one open+overdue quest.
|
||||
assert isinstance(ada_child, ChildDaySnapshot)
|
||||
assert ada_child.present is False
|
||||
assert ada_child.next_present == returns_date.isoformat()
|
||||
assert ada_child.due_today == 1
|
||||
assert ada_child.completed_today == 0
|
||||
assert ada_child.remaining_today == 1
|
||||
assert ada_child.completion_pct == 0
|
||||
ada_view = ada_child.instances[0]
|
||||
assert isinstance(ada_view, QuestInstanceView)
|
||||
assert ada_view.state == "open"
|
||||
assert ada_view.due_time == "10:00"
|
||||
assert ada_view.overdue is True, "12:00 now is past the 10:00 due time"
|
||||
assert ada_view.completed_at is None
|
||||
assert ada_view.was_on_time is None
|
||||
|
||||
# Bo: present, one completed instance, on time, not overdue.
|
||||
assert bo_child.present is True
|
||||
assert bo_child.next_present is None
|
||||
assert bo_child.due_today == 1
|
||||
assert bo_child.completed_today == 1
|
||||
assert bo_child.remaining_today == 0
|
||||
assert bo_child.completion_pct == 100
|
||||
bo_view = bo_child.instances[0]
|
||||
assert bo_view.state == "done"
|
||||
assert bo_view.due_time == "17:00"
|
||||
assert bo_view.overdue is False, "overdue is only computed for open state"
|
||||
assert bo_view.completed_at == pinned_now.astimezone(
|
||||
datetime.timezone.utc
|
||||
).isoformat()
|
||||
assert bo_view.was_on_time is True
|
||||
|
||||
# Panel payload shape: Ada open+overdue stays open; Bo done -> completed.
|
||||
ada_payload = instance_payload(ada_child.instances, include_missed=False)
|
||||
assert ada_payload == [
|
||||
{
|
||||
"id": ada_view.instance_id,
|
||||
"definition_id": ada_view.definition_id,
|
||||
"child_id": ada.id,
|
||||
"title": "Pack bag",
|
||||
"icon": None,
|
||||
"window": "morning",
|
||||
"due_time": "10:00",
|
||||
"state": "open",
|
||||
"overdue": True,
|
||||
"completed_at": None,
|
||||
"on_time": None,
|
||||
}
|
||||
]
|
||||
bo_payload = instance_payload(bo_child.instances, include_missed=False)
|
||||
assert bo_payload == [
|
||||
{
|
||||
"id": bo_view.instance_id,
|
||||
"definition_id": bo_view.definition_id,
|
||||
"child_id": bo.id,
|
||||
"title": "Brush teeth",
|
||||
"icon": None,
|
||||
"window": "morning",
|
||||
"due_time": "17:00",
|
||||
"state": "completed",
|
||||
"overdue": False,
|
||||
"completed_at": pinned_now.astimezone(
|
||||
datetime.timezone.utc
|
||||
).isoformat(),
|
||||
"on_time": True,
|
||||
}
|
||||
]
|
||||
|
||||
# Missed omission is real: a missed view is dropped from the panel
|
||||
# payload and kept in the admin payload with its missed state.
|
||||
missed_view = QuestInstanceView(
|
||||
instance_id=999,
|
||||
definition_id=10,
|
||||
child_id=ada.id,
|
||||
title="Stale quest",
|
||||
icon=None,
|
||||
window="morning",
|
||||
due_date=(today - datetime.timedelta(days=1)).isoformat(),
|
||||
due_time="09:00",
|
||||
state="missed",
|
||||
overdue=False,
|
||||
completed_at=None,
|
||||
was_on_time=None,
|
||||
)
|
||||
assert instance_payload([missed_view], include_missed=False) == []
|
||||
assert (
|
||||
instance_payload([missed_view], include_missed=True)[0]["state"]
|
||||
== "missed"
|
||||
)
|
||||
|
||||
# REQUIRED wrapper tie-in: the coordinator's _async_update_data is a
|
||||
# thin WRAPPER over build_snapshot, so pinning its clock to the SAME
|
||||
# pinned_now the builder was called with must reproduce the builder's
|
||||
# snapshot byte-for-byte. The dataclasses are frozen, so == is a full
|
||||
# deep compare — a wrapper that mis-passed today vs now, or stopped
|
||||
# calling the builder, would diverge here even though the literal
|
||||
# assertions above still pass.
|
||||
monkeypatch.setattr(coordinator, "_local_now", lambda: pinned_now)
|
||||
await coordinator.async_refresh()
|
||||
assert coordinator.data == built
|
||||
@@ -186,18 +186,18 @@ async def test_entity_and_event_matrix(hass, make_entry) -> None:
|
||||
original_refresh = coordinator._async_update_data
|
||||
|
||||
async def _future_dated_refresh():
|
||||
import custom_components.nestquest.core.snapshot as snapshot_module
|
||||
import custom_components.nestquest.coordinator as coordinator_module
|
||||
|
||||
real_derive = snapshot_module.derive_state
|
||||
real_derive = coordinator_module.derive_state
|
||||
|
||||
def _derive(instance, latest, today):
|
||||
return real_derive(instance, latest, today + datetime.timedelta(days=1))
|
||||
|
||||
snapshot_module.derive_state = _derive
|
||||
coordinator_module.derive_state = _derive
|
||||
try:
|
||||
return await original_refresh()
|
||||
finally:
|
||||
snapshot_module.derive_state = real_derive
|
||||
coordinator_module.derive_state = real_derive
|
||||
|
||||
coordinator._async_update_data = _future_dated_refresh
|
||||
await coordinator.async_refresh()
|
||||
|
||||
@@ -791,31 +791,3 @@ async def test_shim_day_complete_uses_ha_local_timezone(
|
||||
f"{far_today.isoformat()}, UTC today {utc_today.isoformat()}); "
|
||||
f"got {len(day_complete)} events"
|
||||
)
|
||||
|
||||
|
||||
async def test_build_quest_missed_event_payload_key_set(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""The missed-event payload carries exactly the documented keys —
|
||||
the same shape the nightly sweep hand-builds (§3), with no
|
||||
``was_on_time`` (completion-only)."""
|
||||
from custom_components.nestquest.core.events import (
|
||||
build_quest_missed_event,
|
||||
)
|
||||
|
||||
entry, child, instances = await _setup_and_seed(hass, make_entry)
|
||||
database = entry.runtime_data.database
|
||||
event_type, payload = await build_quest_missed_event(
|
||||
database, instances[0].id
|
||||
)
|
||||
assert event_type == EVENT_QUEST_MISSED
|
||||
assert event_type == "nestquest_quest_missed"
|
||||
assert set(payload) == _UNCOMPLETED_KEYS, (
|
||||
f"missed payload keys drift: got {set(payload)} "
|
||||
f"expected {_UNCOMPLETED_KEYS}"
|
||||
)
|
||||
assert payload["child_id"] == child.id
|
||||
assert payload["child_name"] == "Ada"
|
||||
assert payload["instance_id"] == instances[0].id
|
||||
assert payload["quest_title"] == "Brush teeth"
|
||||
assert _TIMESTAMP.match(payload["occurred_at"]), payload["occurred_at"]
|
||||
|
||||
+17
-72
@@ -12,7 +12,6 @@ from custom_components.nestquest.const import (
|
||||
CONF_ADMIN_USER_IDS,
|
||||
CONF_DAY_ROLLOVER_TIME,
|
||||
CONF_HORIZON_DAYS,
|
||||
CONF_MORNING_SUMMARY_TIME,
|
||||
DEFAULT_HORIZON_DAYS,
|
||||
SERVICE_REGENERATE,
|
||||
DOMAIN as DOMAIN_CONST,
|
||||
@@ -37,12 +36,12 @@ async def test_setup_stores_runtime_data_in_hass_data(hass, make_entry) -> None:
|
||||
assert hass.data[DOMAIN][entry.entry_id] is entry.runtime_data
|
||||
|
||||
|
||||
async def test_setup_runtime_data_has_entry_id_and_settings(hass, make_entry) -> None:
|
||||
async def test_setup_runtime_data_has_entry_id_and_options(hass, make_entry) -> None:
|
||||
entry = _wire(make_entry(options={"horizon_days": 7}), hass.registry)
|
||||
await async_setup_entry(hass, entry)
|
||||
runtime = entry.runtime_data
|
||||
assert runtime.entry_id == entry.entry_id
|
||||
assert runtime.settings.horizon_days == 7
|
||||
assert runtime.options == {"horizon_days": 7}
|
||||
assert callable(runtime.remove_update_listener)
|
||||
|
||||
|
||||
@@ -527,78 +526,24 @@ async def test_setup_backfills_configured_horizon_days(hass, make_entry) -> None
|
||||
).list_by_date_range(child_id, beyond, beyond_end) == []
|
||||
|
||||
|
||||
async def test_setup_succeeds_with_fully_malformed_options(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""A fully malformed options dict falls back per field and setup succeeds.
|
||||
def test_configured_horizon_days_validates_and_falls_back() -> None:
|
||||
"""The configured horizon is validated; a malformed value uses the default."""
|
||||
import custom_components.nestquest as nestquest
|
||||
|
||||
``horizon_days=True``, ``day_rollover_time='99:99'`` and
|
||||
``morning_summary_time='25:00'`` are all invalid; the integration's
|
||||
``from_options_resilient`` falls each back to its default, so setup
|
||||
SUCCEEDS, the day-rollover listener registers at the default hour 0
|
||||
minute 0, and the startup backfill materializes the default horizon
|
||||
window.
|
||||
"""
|
||||
import datetime
|
||||
|
||||
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
||||
from custom_components.nestquest.db import NestQuestDatabase
|
||||
from custom_components.nestquest.migrations import apply_migrations
|
||||
from custom_components.nestquest.store import async_get_db_path
|
||||
|
||||
db_path = await async_get_db_path(hass)
|
||||
pre = NestQuestDatabase(hass.async_add_executor_job)
|
||||
await pre.open(db_path)
|
||||
await apply_migrations(pre)
|
||||
child_id = await _seed_daily_child_and_definition(pre, _local_today(hass))
|
||||
await pre.close()
|
||||
|
||||
entry = _wire(
|
||||
make_entry(
|
||||
options={
|
||||
CONF_HORIZON_DAYS: True,
|
||||
CONF_DAY_ROLLOVER_TIME: "99:99",
|
||||
CONF_MORNING_SUMMARY_TIME: "25:00",
|
||||
}
|
||||
),
|
||||
hass.registry,
|
||||
assert nestquest._configured_horizon_days({CONF_HORIZON_DAYS: 5}) == 5
|
||||
assert nestquest._configured_horizon_days({}) == DEFAULT_HORIZON_DAYS
|
||||
assert (
|
||||
nestquest._configured_horizon_days({CONF_HORIZON_DAYS: True})
|
||||
== DEFAULT_HORIZON_DAYS
|
||||
)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
|
||||
# The day-rollover listener fell back to the default 00:00.
|
||||
registration = hass.time_change.registrations[0]
|
||||
assert registration["hour"] == 0
|
||||
assert registration["minute"] == 0
|
||||
|
||||
# The startup backfill used the default horizon window.
|
||||
today = _local_today(hass)
|
||||
end = (today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat()
|
||||
records = await QuestInstancesDao(
|
||||
entry.runtime_data.database
|
||||
).list_by_date_range(child_id, today.isoformat(), end)
|
||||
assert len(records) == DEFAULT_HORIZON_DAYS + 1
|
||||
|
||||
|
||||
async def test_setup_keeps_horizon_when_sibling_option_is_bad(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""A malformed sibling option does not reset the configured horizon.
|
||||
|
||||
``horizon_days=5`` is valid while ``morning_summary_time='25:00'`` is
|
||||
invalid; ``from_options_resilient`` falls back ONLY the bad sibling,
|
||||
so the entry's resolved settings keep ``horizon_days == 5``.
|
||||
"""
|
||||
entry = _wire(
|
||||
make_entry(
|
||||
options={
|
||||
CONF_HORIZON_DAYS: 5,
|
||||
CONF_MORNING_SUMMARY_TIME: "25:00",
|
||||
}
|
||||
),
|
||||
hass.registry,
|
||||
assert (
|
||||
nestquest._configured_horizon_days({CONF_HORIZON_DAYS: "5"})
|
||||
== DEFAULT_HORIZON_DAYS
|
||||
)
|
||||
assert (
|
||||
nestquest._configured_horizon_days({CONF_HORIZON_DAYS: 0})
|
||||
== DEFAULT_HORIZON_DAYS
|
||||
)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
assert entry.runtime_data.settings.horizon_days == 5
|
||||
|
||||
|
||||
async def test_regenerate_service_registered_and_materializes(
|
||||
|
||||
@@ -238,16 +238,16 @@ async def test_due_today_payload_omits_missed_for_panel_includes_for_admin(
|
||||
entry, coordinator, (ada, bo, cory) = await _setup_seeded_entry(
|
||||
hass, make_entry
|
||||
)
|
||||
import custom_components.nestquest.core.snapshot as snapshot_module
|
||||
import custom_components.nestquest.coordinator as coordinator_module
|
||||
|
||||
tomorrow = _today() + datetime.timedelta(days=1)
|
||||
real_derive_state = snapshot_module.derive_state
|
||||
real_derive_state = coordinator_module.derive_state
|
||||
|
||||
def _derive_against_pinned_future(instance, latest, today):
|
||||
return real_derive_state(instance, latest, tomorrow)
|
||||
|
||||
monkeypatch.setattr(
|
||||
snapshot_module, "derive_state", _derive_against_pinned_future
|
||||
coordinator_module, "derive_state", _derive_against_pinned_future
|
||||
)
|
||||
await coordinator.async_refresh()
|
||||
|
||||
|
||||
+32
-17
@@ -13,9 +13,7 @@ import datetime
|
||||
from types import SimpleNamespace
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from conftest import freeze_nestquest_clock
|
||||
from custom_components.nestquest.const import DEFAULT_HORIZON_DAYS
|
||||
from custom_components.nestquest.core.settings import NestQuestSettings
|
||||
from custom_components.nestquest.dao_children import ChildrenDao
|
||||
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
||||
from custom_components.nestquest.db import NestQuestDatabase
|
||||
@@ -55,10 +53,35 @@ def _freeze_clock(
|
||||
) -> tuple[datetime.datetime, list]:
|
||||
"""Freeze the integration's ``datetime`` clock to a fixed aware instant.
|
||||
|
||||
Thin wrapper over :func:`conftest.freeze_nestquest_clock` kept for the
|
||||
``(frozen, requested)`` return shape the timezone assertions read.
|
||||
Only ``custom_components.nestquest``'s ``datetime`` binding is patched, so
|
||||
the generation path's "today" reads this instant (localized to whatever
|
||||
time zone it asks for) while every other module and the test harness keep
|
||||
the real clock. Returns ``(frozen, requested)`` where ``frozen`` is the
|
||||
instant normalized to UTC and ``requested`` is the list of ``tzinfo``
|
||||
objects the code under test passed to ``now`` — so a test can prove the
|
||||
generation path actually requested the configured HA time zone rather
|
||||
than silently reading UTC.
|
||||
"""
|
||||
return freeze_nestquest_clock(monkeypatch, instant)
|
||||
import custom_components.nestquest as nestquest
|
||||
|
||||
frozen = instant.astimezone(datetime.timezone.utc)
|
||||
requested: list = []
|
||||
|
||||
class _FrozenDatetime(datetime.datetime):
|
||||
@classmethod
|
||||
def now(cls, tz=None):
|
||||
requested.append(tz)
|
||||
if tz is None:
|
||||
return frozen.replace(tzinfo=None)
|
||||
return frozen.astimezone(tz)
|
||||
|
||||
fake_module = SimpleNamespace(
|
||||
datetime=_FrozenDatetime,
|
||||
timedelta=datetime.timedelta,
|
||||
date=datetime.date,
|
||||
)
|
||||
monkeypatch.setattr(nestquest, "datetime", fake_module)
|
||||
return frozen, requested
|
||||
|
||||
|
||||
def _assert_requested_ha_timezone(requested, time_zone: str) -> None:
|
||||
@@ -124,9 +147,7 @@ def test_horizon_uses_newyork_local_date_after_local_midnight(
|
||||
import custom_components.nestquest as nestquest
|
||||
|
||||
child_id = await _seed_daily(database, local_today.isoformat())
|
||||
await nestquest._run_horizon_materialization(
|
||||
hass, database, NestQuestSettings()
|
||||
)
|
||||
await nestquest._run_horizon_materialization(hass, database)
|
||||
records = await QuestInstancesDao(database).list_by_date_range(
|
||||
child_id,
|
||||
local_today.isoformat(),
|
||||
@@ -166,9 +187,7 @@ def test_horizon_uses_local_date_not_utc_when_host_ahead(
|
||||
import custom_components.nestquest as nestquest
|
||||
|
||||
child_id = await _seed_daily(database, local_today.isoformat())
|
||||
await nestquest._run_horizon_materialization(
|
||||
hass, database, NestQuestSettings()
|
||||
)
|
||||
await nestquest._run_horizon_materialization(hass, database)
|
||||
records = await QuestInstancesDao(database).list_by_date_range(
|
||||
child_id,
|
||||
local_today.isoformat(),
|
||||
@@ -212,9 +231,7 @@ def test_horizon_resolves_spring_forward_day_without_off_by_one(
|
||||
requested_batches.append(requested)
|
||||
local_today = instant.date()
|
||||
child_id = await _seed_daily(database, local_today.isoformat())
|
||||
await nestquest._run_horizon_materialization(
|
||||
hass, database, NestQuestSettings()
|
||||
)
|
||||
await nestquest._run_horizon_materialization(hass, database)
|
||||
records = await QuestInstancesDao(database).list_by_date_range(
|
||||
child_id,
|
||||
local_today.isoformat(),
|
||||
@@ -263,9 +280,7 @@ def test_horizon_resolves_fall_back_day_without_off_by_one(
|
||||
requested_batches.append(requested)
|
||||
local_today = instant.date()
|
||||
child_id = await _seed_daily(database, local_today.isoformat())
|
||||
await nestquest._run_horizon_materialization(
|
||||
hass, database, NestQuestSettings()
|
||||
)
|
||||
await nestquest._run_horizon_materialization(hass, database)
|
||||
records = await QuestInstancesDao(database).list_by_date_range(
|
||||
child_id,
|
||||
local_today.isoformat(),
|
||||
|
||||
@@ -2,153 +2,6 @@ version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.11"
|
||||
|
||||
[[package]]
|
||||
name = "annotated-doc"
|
||||
version = "0.0.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "annotated-types"
|
||||
version = "0.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyio"
|
||||
version = "4.15.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "idna" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.15'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2026.7.22"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cffi"
|
||||
version = "2.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/70/d2/16d99a0c4948febc0ebd133a13b2f688ff7f8cb04da971e1128872ce0c03/cffi-2.1.1-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12", size = 183838, upload-time = "2026-08-03T21:19:29.637Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/95/31b535a9f0220ae9f357de4a08d57ce89cb417653c2fd9f075f50822a388/cffi-2.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1", size = 184168, upload-time = "2026-08-03T21:19:30.764Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/5a/4707a0dc1f203f5dde5a907b0d4e3c25d71120241048bd5bc6f1bb9d4e71/cffi-2.1.1-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0", size = 211805, upload-time = "2026-08-03T21:19:31.867Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/66/c19feabb28485b6e0bbaaafa90837a1ef5d302e90f2178bd33f17a49879b/cffi-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813", size = 218716, upload-time = "2026-08-03T21:19:32.896Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/92/500760486c8baab49a7a8a58ba7fc3355ec3974b454b8a09e528efde9e1d/cffi-2.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990", size = 205569, upload-time = "2026-08-03T21:19:34.142Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/a7/a67c733254d6e7373f7822f8082d8d6beade791e0cf12a7611f376fa61c7/cffi-2.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af", size = 204907, upload-time = "2026-08-03T21:19:35.174Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/a4/4399daaf8f7dfee9d7c3327fdb0426ee041cc63edc358b93911ceb2bfc7a/cffi-2.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632", size = 217807, upload-time = "2026-08-03T21:19:36.286Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/f7/dabe6da2466ecbd82dc62e7342dc6b1065dad990c06f00f0ede9ebf2a0ed/cffi-2.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd", size = 221252, upload-time = "2026-08-03T21:19:37.416Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/87/616202d8e51342c07d2534c510111c4cc37201775ce8f60802c9335d1edd/cffi-2.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a", size = 214214, upload-time = "2026-08-03T21:19:38.507Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/c6/ab025d75d2c26c19b087c0124e75ee31cb65032f4fe345d356d8c507ab97/cffi-2.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa", size = 219408, upload-time = "2026-08-03T21:19:39.809Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/e2/7e8109f65445bdc673a7b54f02c677de462db75674220fd1335efc8eb598/cffi-2.1.1-cp311-cp311-win32.whl", hash = "sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3", size = 174470, upload-time = "2026-08-03T21:19:41.246Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/c0/77ba02423c2f7d7091143c45cd49e0e6575c4c1967394bb542bd923a9b74/cffi-2.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0", size = 185096, upload-time = "2026-08-03T21:19:42.615Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/47/9f1f85f9672ceda4984dc6c4f8824e8558992a2972c3d3c81fb8eb28d4ba/cffi-2.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455", size = 179941, upload-time = "2026-08-03T21:19:43.747Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.5.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
@@ -158,124 +11,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cryptography"
|
||||
version = "50.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/bb/ad/5d6702db60b1e40b41ef513b6967ff5848f307d50f8449baf1634f5908f1/cryptography-50.0.1.tar.gz", hash = "sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20", size = 880381, upload-time = "2026-08-25T19:45:45.499Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/19/797e2aaac9df6a66f1550f49979dc1b1e39ecd2077501c30efa81e8d5d67/cryptography-50.0.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:b8f852c65863251b9e3a1b8c150ce21e59b522dbb6a7d4bc80e680d38388e986", size = 4010153, upload-time = "2026-08-25T19:44:03.155Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/34/9ce9a62ed9dc82ca9fd6a34445b6904af56e5f38b3eae2ed32e49c36053d/cryptography-50.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f", size = 4723133, upload-time = "2026-08-25T19:44:05.461Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/26/e6d4fc8512a51a5f9ee7bfdbfb853bce1197087df40c9ad993ad370b846f/cryptography-50.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef", size = 4712478, upload-time = "2026-08-25T19:44:07.375Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/de/d3cdc2815697aae84126cbd6a030ca7b6b452e28a88b501b836bd3aa7a86/cryptography-50.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8", size = 4730726, upload-time = "2026-08-25T19:44:09.294Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/32/38c0d344b98c06d34b5df8946565a9c0d6dbf32c8e0730a7f05f0a3c6cab/cryptography-50.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5fe002589592ed749ce77fe0695fcbd3500dd61d7d6db5858a7544c612fa8e45", size = 5353524, upload-time = "2026-08-25T19:44:11.96Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/1b/82f0f0d8858d4432be1af790477edf62aef90324041aa07c57e57bef1af7/cryptography-50.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad", size = 4746720, upload-time = "2026-08-25T19:44:14.051Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/ba/042ca458b8c64348c768284b5d23e69b92ed53d057ab779fee628564676d/cryptography-50.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49", size = 4361866, upload-time = "2026-08-25T19:44:16.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/3b/e96c1ef71edef71057c7e3c3d982ce8fda554e0c52d0cc19c18845cde3eb/cryptography-50.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f", size = 4730028, upload-time = "2026-08-25T19:44:18.085Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/38/45abd72ef63f2e7d0754a6cacf97bd8b69512ace7f6130d24c39ece65da2/cryptography-50.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:76de83fbd91ac49c0feaaa983d0748fd7a53176afac5fb3bf7478d244f0eb527", size = 5308405, upload-time = "2026-08-25T19:44:20.197Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/66/6ccca4722987ddedaa7fc9c3f4708af7431f5535666c174350830888c6b7/cryptography-50.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a", size = 4746230, upload-time = "2026-08-25T19:44:22.376Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/0e/b1f92e013228111413f2e6743948b80bc24dfd3c1b87ba98ceea16f5df89/cryptography-50.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959", size = 4862596, upload-time = "2026-08-25T19:44:24.472Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/22/c3654cccc856e9d682817b04ac3ee79731cb09ca6f95996a95c904de2883/cryptography-50.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b", size = 5014082, upload-time = "2026-08-25T19:44:26.709Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/8b/cb12b1b60c91b074ca6bf0fdd59aa8f10d8bc5f73af8faece86ef0421b37/cryptography-50.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648", size = 3842826, upload-time = "2026-08-25T19:44:28.784Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/f0/424cb557d99aa86ac55da5e2add02e2882e44047b6264f93ade1b975a993/cryptography-50.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30a125032e5642a21ff816e021152bd4e7e94f03eff3f4b7fca41cd22bc3110f", size = 3973525, upload-time = "2026-08-25T19:44:30.7Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/72/3a2711d967977ab5fc80b782837c7e8d1ac7445e764c20c381a265c57ef3/cryptography-50.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a0b1a59e3a089064a0ec309e9428c8e3ae4e161419d20ac33600767e83fc658a", size = 4708817, upload-time = "2026-08-25T19:44:32.773Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/f2/bb1f56e10815b789df0b409a69fa4992ff3d3fef9c72747f4a6b26fed38e/cryptography-50.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8921d58f426793c5f1b47f0b59575780de9a095214958d0eb37d909593db8367", size = 4697300, upload-time = "2026-08-25T19:44:35.144Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/bd/ed5396be499ffcf8807a585bfe38b71a1fbdd1c342b4f9b6d0ef5162a946/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a8f40ea47330e71b594a7e246898f93177c259490c63183dbaf9e571d71ed9a5", size = 4716039, upload-time = "2026-08-25T19:44:37.192Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/6e/1cf405c5c8e8df7545378048e954792f00b7f2367af8863ce8b8f3e10607/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:a255449073358275b64b67d3f595f268bbef70e72b6edb65e0c70c735bf739c9", size = 5332388, upload-time = "2026-08-25T19:44:39.16Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/92/b4317e8c32c4f47b062f5398bd79106b220a124546f42be83bf32b761e2a/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:8df2de9102026855887e4587084f6eabd80ed0f345b8ad8a7ac27ab9bf4723e0", size = 4730293, upload-time = "2026-08-25T19:44:41.298Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/0d/a1e7633e2c744d0f2983320a27e924ef2264c79c56e1a58d5fb0a1cfd413/cryptography-50.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:ac02b07824d4d1001bd4367599f839c19cb171924c796e52c23508ac14c2c0cc", size = 4346031, upload-time = "2026-08-25T19:44:43.245Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/dd/b215616f9bab3fc18510c78a4e5c9f362d77838503c363dc747c7d4f5c6f/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:cbf74a81765ee67413503ca6e26dcc4f6f5a519822436cc0a1b97aab6c1b8a17", size = 4715344, upload-time = "2026-08-25T19:44:45.291Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/1b/ec3ebd31741d0e963612c4fe43caa39341b9b1e031e469820e42e4c83918/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:16c5ecd954b3330ebfb6605eca4fd952da8bef376551d5cc264534e3770a9ee6", size = 5287201, upload-time = "2026-08-25T19:44:47.297Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/01/0127d11a762b31a9ee0221894f540318761783f3fdc4bc5d057698caebd5/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:79bf008d1f9af6071c797ad133e39915dfee7614f18f18f4db9072eb715064a3", size = 4730023, upload-time = "2026-08-25T19:44:49.435Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/b9/e7425ebfb599241a0c1d7000f1b466c3062da66c19d9525031315dff7213/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:330fbb252391c596f1ae42c5754449dc924e6ad012dca8efe0d703f9f2d12ec6", size = 4847362, upload-time = "2026-08-25T19:44:51.94Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/fd/60d0ddf4defa12e482c9d5e0f554384d6e8ab25341fd15f060028fd92e6a/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:42be3bb70596b3abe4ac097b75be223e8b3ab614a0e5de068e3dcc54d71d6149", size = 4999247, upload-time = "2026-08-25T19:44:53.876Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/56/bc4f2b209e766c93372cfcd59b781a0b2b59700f62a969580415b699c2b2/cryptography-50.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f74455bb086a85d5e81246412602aaa97ed095e504cd40dd261ef50be42205bf", size = 3825806, upload-time = "2026-08-25T19:44:56.209Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/a9/ee16a903f13755e914d1eecc482fe64d1f10761c3960e5d8fa6837377aff/cryptography-50.0.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0", size = 4035307, upload-time = "2026-08-25T19:44:58.305Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/a5/9ec7e81e8526c0d7a387d73386b2daed3f39e10d81a85930bd1b6bfba65c/cryptography-50.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23", size = 4751900, upload-time = "2026-08-25T19:45:00.401Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/3c/0e77bd5ffcf078e9dd27d3074aad6c030d9b10d0bf69329d573c927a188c/cryptography-50.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733", size = 4738357, upload-time = "2026-08-25T19:45:02.786Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/3a/3c5f80daa4dcd47323c7af8a2fcb90de27a33564d4fcac69846c0972691a/cryptography-50.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88", size = 4758474, upload-time = "2026-08-25T19:45:04.889Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/2b/214cf0cf93db9628c3c20c896b229f327f6fb1b20e4b3743d8ad3f00af8b/cryptography-50.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2ebbfb0f1fed745e91796e3e1080a1440423fdae8ece1b995a1d80883a409054", size = 5375862, upload-time = "2026-08-25T19:45:07.163Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/51/3f9701867a46b6c1740c9b52fc4d3bed6cbdcfedcc9b6e64305c07f39cff/cryptography-50.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5", size = 4772942, upload-time = "2026-08-25T19:45:09.396Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/5c/13ea642e08e2544d0f5396122055f4820cfacb3203562197b5967125ea97/cryptography-50.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361", size = 4383347, upload-time = "2026-08-25T19:45:11.659Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/d5/7d1fe1cb93f91c428093ff234e128c89ba8ea61a6f26aab406081f9b996e/cryptography-50.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71", size = 4758050, upload-time = "2026-08-25T19:45:13.745Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/04/557fc5ead96a829e0bc812a3b9dc4a52a2f27e4f7f5950da7ff27653a805/cryptography-50.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80", size = 5332955, upload-time = "2026-08-25T19:45:16.193Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/eb/5d7124083e8d8cda8f5b348f544b71ad6f707ad63193758ef4d8e569da02/cryptography-50.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239", size = 4772694, upload-time = "2026-08-25T19:45:18.315Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/8e/f1f955e0921dd2b6d22eae7e8d24a4c4b638d10735ffbf6a71f99eb0fcb8/cryptography-50.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558", size = 4888413, upload-time = "2026-08-25T19:45:20.4Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/ab/89e2b798d2c3925f82e2bb72d5979f3d2f6da2dd22ef4a8cd8b70d920039/cryptography-50.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e", size = 5044355, upload-time = "2026-08-25T19:45:22.353Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/89/87ef49ffe383ef4e147d27b7bf2088fb0b54ea409dd87b5a89442e5828a5/cryptography-50.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2", size = 3875429, upload-time = "2026-08-25T19:45:24.418Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/27/8d207af749c453ee17ea087340b3f2b4adef75aadd1d277b1b129bdda84e/cryptography-50.0.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9cb3cb952cf5a8abd50c782a98a89d71699715e802fe349704b47f2425b42a94", size = 3974350, upload-time = "2026-08-25T19:45:26.551Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/9a/6d3a4d7852e22d657438b7bf51f66102c7d71c0e1fafeec652281d0403e5/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:5fe939deeb161024a6be98229c953b6591fef1f41214497a78fe793a244c017f", size = 4698675, upload-time = "2026-08-25T19:45:28.658Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/35/5c3717edf9e68a0550ce04e28eab493fe545eccd81742af03f6a75fe260b/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:fb4b9672d389c738b175c4166e78310f8a70358886aacd9173ee03a85ffdc671", size = 4707410, upload-time = "2026-08-25T19:45:30.816Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/e0/e786934472e3ac4ecdecc7b129a0ca1a2a40dffdafcf2c3ea9d4397f8def/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d63ae8f6481fec907ac0f588eee8a90aefde112c633131fe540e5711ddbb5a4e", size = 4698378, upload-time = "2026-08-25T19:45:33.043Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/cf/5b3f53a0b74d122f023476ede40ba5d3e70d5cf475f73b899740d26a4fb2/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:804728ce710890870f3aaa344b2e161172d258d768ac139d02cfd9092d0d94e6", size = 4706889, upload-time = "2026-08-25T19:45:35.086Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/44/711e61f7d014be825ef79b285b047292d1bf893732ac1bc030a351fb517f/cryptography-50.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:693c99b49bd37d0d096e4334c10232c77248c415b98d35236094cdf96d57258b", size = 3824006, upload-time = "2026-08-25T19:45:37.281Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastapi"
|
||||
version = "0.141.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-doc" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "starlette" },
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8a/02/91e3416a8fdd715abb903a952a6bec7cdd8d14eed55d415fc8595524c319/fastapi-0.141.1.tar.gz", hash = "sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1", size = 425799, upload-time = "2026-07-29T17:18:05.568Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/03/10388a42375ee7e4ac9b94eb2c5c569c8b5795e377e701c9ac3ad63de890/fastapi-0.141.1-py3-none-any.whl", hash = "sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3", size = 131954, upload-time = "2026-07-29T17:18:04.364Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h11"
|
||||
version = "0.16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpcore"
|
||||
version = "1.0.9"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "h11" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpx"
|
||||
version = "0.28.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "certifi" },
|
||||
{ name = "httpcore" },
|
||||
{ name = "idna" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.20"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.3.0"
|
||||
@@ -378,14 +113,10 @@ source = { virtual = "." }
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "fastapi" },
|
||||
{ name = "httpx" },
|
||||
{ name = "jinja2" },
|
||||
{ name = "pyjwt", extra = ["crypto"] },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "uvicorn" },
|
||||
{ name = "voluptuous" },
|
||||
]
|
||||
|
||||
@@ -393,14 +124,10 @@ dev = [
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "fastapi", specifier = ">=0.115" },
|
||||
{ name = "httpx", specifier = ">=0.28" },
|
||||
{ name = "jinja2", specifier = ">=3" },
|
||||
{ name = "pyjwt", extras = ["crypto"], specifier = ">=2.9" },
|
||||
{ name = "pytest", specifier = ">=8.0.0" },
|
||||
{ name = "pytest-asyncio", specifier = ">=0.24" },
|
||||
{ name = "pyyaml", specifier = ">=6" },
|
||||
{ name = "uvicorn", specifier = ">=0.30" },
|
||||
{ name = "voluptuous", specifier = ">=0.14" },
|
||||
]
|
||||
|
||||
@@ -422,132 +149,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pycparser"
|
||||
version = "3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
version = "2.13.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-types" },
|
||||
{ name = "pydantic-core" },
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-core"
|
||||
version = "2.46.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/b6/81d2d19ea0be2c03664381b59f65fa72fc7969decedae00bc2c4ad835708/pydantic_core-2.46.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a1dee1b804ff4d11c663636cf15d2ea47e9f79cd56c033fb1cbf08924842a48f", size = 2074737, upload-time = "2026-08-28T09:57:57.711Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/18/b70da8300e292df4099684ea11b1958043580d2f50d2dc8bf7e542bdd84a/pydantic_core-2.46.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d625a186a65201c23a9e3b8ed9c47e90a026e03256608cc91851c6709096844f", size = 1921751, upload-time = "2026-08-28T09:57:59.265Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/1a/0d590341b6ffa4b4aca83508e6b8db4761aaeacfc15a25ca3815876d4797/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f8507560a9284e1370bb048ed4282012fbef4e8d109875b95e884d228552061", size = 1948231, upload-time = "2026-08-28T09:58:00.678Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/1d/02eb35761c51f2f7b1b042d6ab4cda6600f0c8c88a2243b3f734376201e5/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f93c5fe914d75fbec9a49209b00da5f08e9e467d69da2b1510c81940cfd10be", size = 2020708, upload-time = "2026-08-28T09:58:02.267Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/ea/f86073830e35d508cc8ddf9c3d9e6e6840fcb88d34bf726b0b4710186f27/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c767f552b21b10f774aeac128e828eafb796adfa1b666a18bf6321453c3a", size = 2194914, upload-time = "2026-08-28T09:58:03.934Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/d7/fc36240d7791ce90939e51608568c33bfdae26202016f9770c229a487d86/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:701b2e04b560eeb4bddf7a25ab8ca476176e34fdbd9a0e18196f0d12d4685f0b", size = 2235622, upload-time = "2026-08-28T09:58:05.516Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/bc/3fa2d76b83162820a17da7f645b28d1cba99fc8e1e5fc6517067ec450fa1/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49776eab08766a08dfff7012f8b422dcd7e25e43b316eedf0477c24fcfa84b7c", size = 2062091, upload-time = "2026-08-28T09:58:07.135Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/9a/095d557bb492c90cd8a70a6dd048bf793d433d03d86c81c11e912e4cd049/pydantic_core-2.46.5-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:a2468d93d181667a7abd66e1b64bb9f76f361b0fef8faddf687456453576f5ee", size = 2089904, upload-time = "2026-08-28T09:58:08.814Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/98/7b76b1ad10a19a617a52aaa1d80e159115af939b095e86f8e756fd52e0df/pydantic_core-2.46.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:53feb344243bb9510a9dec7bf3cf1b64d88a98af5dc7872a5160465f8b198c8e", size = 2132244, upload-time = "2026-08-28T09:58:10.435Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/32/7d6ca365fadba186a0c8f85de1a701663bce81efd309d9479be58687622f/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:cd5214352ae68f3b5e9af7768bdc5253695ee069675db3480518420b3be881f2", size = 2143901, upload-time = "2026-08-28T09:58:12.033Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/09/eb9a6aa57f22fd1541a9c0aa2a1f3aeef3ec65347d33e10a6da2f43e0ee9/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:9432f3598db432cb51c5b37fdbf29a60fcccc79e30d37a05022776a6bc4ab689", size = 2299425, upload-time = "2026-08-28T09:58:13.614Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/f9/548a5bb9d4ba8cd26e26daf48052236f6b38bb61e7b7241fbc3c995719eb/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8feeac04b5794e513e710af2f9c87d49f31a6dc47967bb264a1fed61a8989bec", size = 2318566, upload-time = "2026-08-28T09:58:15.199Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/20/06454d18834c02c406c9133f1a3b485305fd9ee984f9636c2f730bef6a9d/pydantic_core-2.46.5-cp311-cp311-win32.whl", hash = "sha256:892a881d5f68c2b9ea304b7a6c2c60d9343df578a311b0f86b94bc8f1ffe8129", size = 1954258, upload-time = "2026-08-28T09:58:16.813Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/c2/718b9deb4b72453b5d8c7447a3b14cb77bef36917ef5f514e0948a4096a0/pydantic_core-2.46.5-cp311-cp311-win_amd64.whl", hash = "sha256:40375c2d05acec10323e45dfe2077ac44bc74659008614af5069034e2cfc781c", size = 2041030, upload-time = "2026-08-28T09:58:18.288Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/ea/c1d1a5b72d6e1ff7f377a4d9199f6591f095beb5b409a8a5d89f7238d939/pydantic_core-2.46.5-cp311-cp311-win_arm64.whl", hash = "sha256:28a6a556cd3b6066bea827857f9d9cce027c96f776e512f544a581f9e42161f8", size = 2009234, upload-time = "2026-08-28T09:58:19.929Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/3f/76358795aa7a8c6d4f36e2cb828ad1c90ee118e1393a9281664f5aade9d4/pydantic_core-2.46.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d", size = 2076516, upload-time = "2026-08-28T09:58:21.576Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/50/26b091836076ce4cb2fac264186936acc069e0595772cfd02a563bc4761a/pydantic_core-2.46.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e", size = 1922874, upload-time = "2026-08-28T09:58:23.766Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/f0/2a8ce3849e299d44e2d2c196b6082643a3235565a735cb51db7a6261f614/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29", size = 1951772, upload-time = "2026-08-28T09:58:25.435Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/46/ac0dc8bdd9e6048183a14eb127764e7ad9240021c17513074a4711b0e31e/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4", size = 2031832, upload-time = "2026-08-28T09:58:27.102Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/c2/339de5bef7be36301a2231eaa52e62163742c2281f11b5f4892bc79785cd/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a", size = 2208645, upload-time = "2026-08-28T09:58:28.948Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/a0/9ff22b797724262da14427abaed4dd1d864a139693fc5e7809114376a716/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62", size = 2265935, upload-time = "2026-08-28T09:58:30.625Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/a4/eb9409ec0736e50aa70a412f16c204ed149516846912f7e6724d4c73ee53/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2", size = 2066284, upload-time = "2026-08-28T09:58:32.289Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/02/7f6156ffc926857f1c37c07d9a388682865a81830ab6a1b637082c25e399/pydantic_core-2.46.5-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869", size = 2105889, upload-time = "2026-08-28T09:58:33.986Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/b1/e781d357ebe09fc929f995700f1b3503e8897f1cece183ecb1300d4d67e9/pydantic_core-2.46.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5", size = 2158006, upload-time = "2026-08-28T09:58:35.647Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/0a/644597d84ab400e50609c192120b85c9681c22d3a20461b9060a79be0a7a/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3", size = 2158408, upload-time = "2026-08-28T09:58:37.38Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/ee/ca3b7b3a4b3769ffe9ce9432a7c9be755de9593a46d3b0d54d0409323e44/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b", size = 2309609, upload-time = "2026-08-28T09:58:39.22Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/52/39fa1f451486019524ca685020390e7ca351832fd874530ba30c8628e6dc/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0", size = 2342618, upload-time = "2026-08-28T09:58:40.89Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/5e/468fc630568c61dcef3cd47ad32ffbeed9af643f49208d1ea86ab4f890c4/pydantic_core-2.46.5-cp312-cp312-win32.whl", hash = "sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b", size = 1939475, upload-time = "2026-08-28T09:58:42.591Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/c9/4c19f41b84cf6b622a72fbeed7665b25d47a187d68d47d0d430c07f23268/pydantic_core-2.46.5-cp312-cp312-win_amd64.whl", hash = "sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8", size = 2043140, upload-time = "2026-08-28T09:58:44.272Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/dd/0c1a050299147c746e5256db16d645ab5efd4f78c59937d581a0524e74a2/pydantic_core-2.46.5-cp312-cp312-win_arm64.whl", hash = "sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084", size = 1997729, upload-time = "2026-08-28T09:58:46.13Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/37/5abe39a8372a61d3dc3c1338fc504281c01b32fdb3169cd7187153b56d3e/pydantic_core-2.46.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:b7ca9034437b6022f941f4857459562ee00a560b97e7cce8a0ec5a74fc6766e0", size = 2075885, upload-time = "2026-08-28T09:58:47.856Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/43/6323b1f8b217780454c61304bcd2b38ae4762f50754414124603ccc90bb2/pydantic_core-2.46.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f332f0e72a5a0400141f830744e141bf9f97917878dbe968669e8a7fefea78ff", size = 1922768, upload-time = "2026-08-28T09:58:49.58Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/a3/c05ca796e1197618a774b01e596aeedfefc2f7d8c01ae3054e910b120e8a/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:193375f3548919d3f0b60936ca113ada3e38f264f91b9b8e0508efaad57be931", size = 1951241, upload-time = "2026-08-28T09:58:51.511Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/32/33bc39ac705c52cffc908e8389f9754fdb208aea5c69cceddf4eb3ce99af/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:79bdfa52f843137045b2d081cc05c120ba6665d29b7559c2c47690906f39279f", size = 2031975, upload-time = "2026-08-28T09:58:53.166Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/70/2333e885c0f6a67bc105c5916965dac9b57f2718ee20d81d1a06a4ebdc13/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:24922243639cbdac66c75fcb6fd6495a9cb52b213d62f9a0d16f0310b1ff8038", size = 2208542, upload-time = "2026-08-28T09:58:55.017Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/ea/296debfb4264207bbda5936133892e027c0a58875ad53ebd512fba8ec3a2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c76fe65e607be28c7fd4d56fc3c42b1583aa058ce3408b7ad0fd540171d31f9f", size = 2264692, upload-time = "2026-08-28T09:58:56.767Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/f2/9e4de77a6271e07a76d2d58b11c091a979c191ed2939bf80067568b369d2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f7b393a8b3da82f5c1fc0751e6d01ac6c55b93c18226a60bdfba4a724efafd1", size = 2066633, upload-time = "2026-08-28T09:58:58.531Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/db/f9e9d0c97445987b2084823d5c240de88087338f04fc2cfaa2df186b8049/pydantic_core-2.46.5-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:7ac031912d54f3d83ef3b3eb98dfabc1608802e2202263d25957eeed40b94761", size = 2105235, upload-time = "2026-08-28T09:59:00.421Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/c5/79169b047b3b2c3e99e04bc76372af9637e0bf6db638274fa927df96369e/pydantic_core-2.46.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:837b396ca3d7b74091ca623f6cbd8351bd42d670a79c2683e79fb089f06a2de5", size = 2157367, upload-time = "2026-08-28T09:59:02.442Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/b5/ba6057afb7c291bd449f51b867f95aef2072941c4ce4e5c31d6ffd132d3b/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:5ee239d575f80b08eca11f6e20f90c4c695de7825c67eefe6091fbf20dda648e", size = 2158420, upload-time = "2026-08-28T09:59:04.2Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/28/2057abecaafdc22912afa819603a51f0a62d40643b7c4871c51721fea9be/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:e80675d75ae2cd14372cb65cad5400d9347a3d3f6c13000183f22dfd027283ed", size = 2309588, upload-time = "2026-08-28T09:59:06.048Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/9d/881156dc404e27479c4246128d73538464cab4a239bec61995e227644c30/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:9c4b71f10dd532fb7a5cbc8f58707779e64f03a258c2bf8bfbaecfcd9970b519", size = 2341866, upload-time = "2026-08-28T09:59:08.539Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/38/d66f443a259f84d13babdceae568e572b0ed26da17ca5d0a649ebb110a67/pydantic_core-2.46.5-cp313-cp313-win32.whl", hash = "sha256:97bf8de4d541598c94a59344eeb988a94c08ff76b5723c41f6567ec18c7892ea", size = 1938580, upload-time = "2026-08-28T09:59:10.402Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/1e/1d5371213f4cc9a7ed70c0bfcc7911de22311ee99a662a56077d7292d2ac/pydantic_core-2.46.5-cp313-cp313-win_amd64.whl", hash = "sha256:15f4a94963c95accac15b7b657bb177d3ad82bb90b0d0526d9a9b85079925db5", size = 2041980, upload-time = "2026-08-28T09:59:12.396Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/48/4222d90b1c67568bace4dec6dca6271449c66de3595d72b6d098f5fde597/pydantic_core-2.46.5-cp313-cp313-win_arm64.whl", hash = "sha256:d22a945598fb91236b4dd793a6e42e4f3dd7740bb5aace5ebd7d4c08d13bb575", size = 1997213, upload-time = "2026-08-28T09:59:14.245Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/8a/14596f2a8367da50cf7cbac48169ee5d9c8e11d486a3b527082384630c72/pydantic_core-2.46.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c1c43ad4339643d70ebb8124e1305a7dab423001eff58bb41a0f731adbc98355", size = 2074081, upload-time = "2026-08-28T09:59:16.141Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/d5/d8a4eb6d6c7f66b91dd37c576d76e9e60fba900caf5372c17bcf949febc2/pydantic_core-2.46.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a353f84de772f423b5ffb11d7ae352fbbef0f446f3c0b0af0f8236d7233606e", size = 1920497, upload-time = "2026-08-28T09:59:18.065Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/26/092079428f86e927e030b2c0ced87df69dbb1c875cdeaa67bf42ea2be746/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5086029a57366b8cf81b130a43908738095c270c21a8d7f0e8bdfdb89718e2f3", size = 1952130, upload-time = "2026-08-28T09:59:20.476Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/c3/8ec0e290a9ebaebd64047bf5fda94be835c6b1551b02437e4b76778fbcd7/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46c25dda9d092a06c08db76ffe0a197107904d0dfac653f7d5306bbcd6d6119c", size = 2026371, upload-time = "2026-08-28T09:59:22.227Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/72/4fd20ad520fb8da0157f95b27a7eb05a72790ef08138e7701ac972c342ea/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:37ea7b83c935e5b0d68c9449b82651accf78a10828b2c02b2f2d9e9496446c21", size = 2202822, upload-time = "2026-08-28T09:59:24.277Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/b0/d16e0771206b29314f0d52198b720be21e8a99ab2bf11e3bc0d7c9cebdff/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e64e88d5585bea9ce95861079de72006c7fa6d3df4e3a3b65ba31eb979c15c9f", size = 2262756, upload-time = "2026-08-28T09:59:26.608Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/9b/59634b7ac631c63b2a37760eb6943af3e29573d6b59a4abc5e7f019d4cee/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d510bac3ee52247af28ed4bb18a1e799f040ac60fd2bf5ccd4c92f1fbe786f", size = 2068352, upload-time = "2026-08-28T09:59:29.044Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/7c/570abb1ad2155348dc754ea91be22e5aaa18eb6d69a6068f7c6f2679a6ed/pydantic_core-2.46.5-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:a2a5e1d0ff29adddc9f6d6821a66302e4493f8ca898b715b6b1182c2c201ea0a", size = 2104777, upload-time = "2026-08-28T09:59:30.95Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/25/5bf74adc65a1ac5b7be3f6cb0bcb5433615c1598a801c19d830d84c98ded/pydantic_core-2.46.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:03b9666e41e35d8909852ba191a0607520f81b74eaf12ccf8737005dbb313821", size = 2156312, upload-time = "2026-08-28T09:59:32.604Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/6a/2ef38830675e050121040618135564ed56b860b45433b02d9b4ebece46f3/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:a91c17edf6eea2402cb5457b4c89e99bc5ed1004aa34c4adf1d4258c1a5c22c2", size = 2150067, upload-time = "2026-08-28T09:59:34.453Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/ef/a7dbb03a14a64c2a4621f989c615ed9a892535a6cad938fc27079f919d80/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b49924c73a235e969511bf2aabdff3beebf9820931f646c80274d5d780010c47", size = 2304516, upload-time = "2026-08-28T09:59:36.194Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/f8/6bb4c4b80e8a6fde1904c64a51c62a1d04fcdfa3ea521a66b2ddefa1d885/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:2cbd9a5eff05e51c447c34dfa4632145b26b09120cf04bd0c871e44c1a5e1c9a", size = 2335223, upload-time = "2026-08-28T09:59:37.931Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/80/f46b8c681195190b2c1f1c7c0a81abce60663e987613e09ef64d433dd96b/pydantic_core-2.46.5-cp314-cp314-win32.whl", hash = "sha256:2d5d76654becf5efd62c9e51c3756c67b49498b0c9a40884934c40807adbd074", size = 1934827, upload-time = "2026-08-28T09:59:39.836Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/3c/60674207246bc0a4009d2391b7c7251c7159f279c8d2ab8aae8ef46f3dee/pydantic_core-2.46.5-cp314-cp314-win_amd64.whl", hash = "sha256:fa10ef4112775900e7a0661068635eb67b2ab824fbde764de6e0e21982a93db0", size = 2042648, upload-time = "2026-08-28T09:59:41.792Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/0c/117c562c7c1babdf44576b72a5e496906506c93690387ecfbca7c729ae2e/pydantic_core-2.46.5-cp314-cp314-win_arm64.whl", hash = "sha256:045ab3b6d308439e32b81cc173bba5b9018bc6ed896afd0c65b3b009b1699af5", size = 1989652, upload-time = "2026-08-28T09:59:43.702Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/66/9336ae58f9eb68c41d121894e52c4c89eccb07eb8f602a04ee9c3f37736a/pydantic_core-2.46.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8816f3d218beb4b787de5c9759c259b8fa61f9dec42dc7811f320a33771778b7", size = 2065829, upload-time = "2026-08-28T09:59:45.364Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/02/bc19b47a96c2d3109760711acf22369e56bd7e405ca52f7ade164d2ead57/pydantic_core-2.46.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bce57638e08ac148e5778cce7feb968307a727d66f8e2274a543d0cf0c9ad6a3", size = 1905716, upload-time = "2026-08-28T09:59:47.18Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/a4/70b47c0509923dd98ccfed04fb3e32ea3849c82a0ff2205bb41009b43c00/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:976e1128455aa595ea04c79ccfedff1aaeab96ee013fcc916bed120c4f0ad94f", size = 1934216, upload-time = "2026-08-28T09:59:49.241Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/ab/aa03b65f7bb198585edf806b906c3223ecf1795543e39e23aec4cce27ad2/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b891faeedeafba41b2983e5001a81b6a915b69544c7e7570d1989ce1c36ac7", size = 2010635, upload-time = "2026-08-28T09:59:51.692Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/8b/0da06343f30b84ec549aafd309c6456223d5dc8bd36af504c573faad561d/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f194189415698233dd1114a093a9b56e61e2c57e11b469be3b0506f46f0771c", size = 2209369, upload-time = "2026-08-28T09:59:53.582Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/5b/844c4defaa34a3df66eb9257087d121d70c201298b96abdf9f492fc2f1bf/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:82a36973cf8a2ef5406f4fe2edbf8ed0c99629535d959e0b100c76a32535a111", size = 2253238, upload-time = "2026-08-28T09:59:55.484Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/64/a4e536cb16d7f61a7fd3120b46c577fc7fa7325992f69c4f52bc786d77d8/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdbb78909f52b981d3b2d56b97328d71eb0b974c36bd77c920123a7ebb192829", size = 2065740, upload-time = "2026-08-28T09:59:58.038Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/75/aaa38c6bc2d085f6605b34eabdc6a8a4e0b2e61fc9c8e6e52b28e97b3125/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:52e24eacdb536cade636aa90fb851835222becff8484b7001fdc78cb0290f2aa", size = 2087425, upload-time = "2026-08-28T09:59:59.898Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/ae/fcab4cfc39aba3689e1d20c8b5250ad280957022c09af2ed9cd585602a5e/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:37ae34309d7bd8c0d61ab839668058f2a7962ea1fc51d105d2db228fe0618034", size = 2139306, upload-time = "2026-08-28T10:00:03.057Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/f4/f1d03a4bc9d9acbc62f4d742b8a319af52f71885079868b2ff8e48a651ee/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:0cdbada856a1c69a7624a64d3d9aefe79300bd6ef827b43a4f265010b9b55184", size = 2144589, upload-time = "2026-08-28T10:00:05.645Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/f3/7a53bb1356de514a4cd295f25b6ac39237895620c0462d2592b76c16e114/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:545f26c504b27c3758439a5e6d9349931f0a04f855668d5fe323c89e82300a38", size = 2288882, upload-time = "2026-08-28T10:00:07.931Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/94/5a81583660c175c59d49ffb09f4b3a44debeaf86a19fca664ae1cdd9ee32/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:ff218293c9c806138dca139765e3b067621be52bcd93cdc14c7711be7ddc90a9", size = 2335210, upload-time = "2026-08-28T10:00:10.177Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/9f/5d685c2693b972d1a59c998586e8823712b66603aeff47ee60a4bdaafd37/pydantic_core-2.46.5-cp314-cp314t-win32.whl", hash = "sha256:97cf3eb53a8cccacf9d46686a0926186c9bfb5574f2ed66d3639d5fe117cd3a9", size = 1921180, upload-time = "2026-08-28T10:00:12.35Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/12/5c94ee16d65a37a15f9e869f5e6256df111154491173801a4c5e800ab548/pydantic_core-2.46.5-cp314-cp314t-win_amd64.whl", hash = "sha256:d2f9fc07a8042a8f95925b35c4f04f469707c981fc33245b6ca187cf5d2dd290", size = 2020515, upload-time = "2026-08-28T10:00:14.774Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/19/67830dda664e6bdf9285ee2e40f355d0d7d6b92aa0c42e8d217bb8d33d36/pydantic_core-2.46.5-cp314-cp314t-win_arm64.whl", hash = "sha256:acf8a67ba51f4ca9ddbd0e6b3000a65ac51ab734661778b3e7ba64d99a710f2f", size = 1989276, upload-time = "2026-08-28T10:00:16.984Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/1e/ecca01fce348f7e8afa9572441ff6f7d1cc70d21e4859f33944d10877e1e/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:c14ad3bdc85ee7f318742c457ca3968a92126d144b15721c759033bfb06296c2", size = 2075342, upload-time = "2026-08-28T10:00:51.353Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/4c/af80c7a8032dfc897040ad5cb772bebde529a381186499e6e29987f23f8c/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0bddb4020d8f04175865ccd17eff3040874fc11fb593f424edb452653b4b947c", size = 1907219, upload-time = "2026-08-28T10:00:53.438Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/3e/54d89e2b092e778716bf6153634ef479e955f48c261090be23aa1e0fb0b5/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2471fd51c61c610e1dcf7de44d7299283661654d11264ab4802b303368d69c47", size = 1953393, upload-time = "2026-08-28T10:00:55.58Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/89/828ee90cda28ce17bdefaa3a6eaf74fe430e113295a10e6126beca559d6c/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b10ec717381bdbfafef34607824db4c91de69ff085e4fca3b2af91b4fa17e68a", size = 2099024, upload-time = "2026-08-28T10:00:57.794Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/dd/053c2e4303f791f3b8f8a14ab0b22008e8eb21d868c0c90b4f9be705b76a/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942", size = 2062540, upload-time = "2026-08-28T10:01:00.318Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/dd/a18df751a5e37dd51bfad7f68e766999125bebe68c9e1d10a493ad01bd63/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f", size = 1902040, upload-time = "2026-08-28T10:01:02.529Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/13/01d40f9d07ce8a779fd6e0bd8ad4fba91309500dd67b869e2e219d261a6d/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433", size = 1967479, upload-time = "2026-08-28T10:01:05.004Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/04/c81d4841331c2178b6fb09ae225425e110ed72d990c9fe556c4ec03d1013/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c", size = 2111034, upload-time = "2026-08-28T10:01:07.345Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/21/22102e9950b3049526d20e811b95396508377d87651edd2b80d2b3d28659/pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ab4b66edffb32d9e951efb3814bd104b8367a7501b81b955cacb5726d897389f", size = 2071333, upload-time = "2026-08-28T10:01:09.636Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/18/87aefa427d191e6d3ab1447f1efc1cdcac86af1069239b133e8a0fd7f7c9/pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:337639ba62a11acde6ef3aeb08c8ea755f8ef1fe5e513356c0f36a2b0d7568b0", size = 1912713, upload-time = "2026-08-28T10:01:12.285Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/93/fd89e9ad49b1805ca94d24ce1088b7d305f05c35ffafcedb9819d03588a0/pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:413a717a410d0c817ef5b786a059415550b3794e1d0c2abffd9efb93a3d9f7b4", size = 2090926, upload-time = "2026-08-28T10:01:15.19Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/45/8e59dab6acf8d35f02f0a958980074f31038968bdb2c983fcae9d1efee03/pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1e449def1945a462c464331254e5a44fca7c3b4f9aedf59ec2f50f8066dd8e25", size = 2131303, upload-time = "2026-08-28T10:01:17.937Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/a5/e1d4dc5180dd887a9522efc1f8716b8692b7606b1d3273d7862eaf66be44/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:a445486499897b88a7d6c310c88ed64dd37b1b59bfd7ae9107490bbb362f47d6", size = 2145128, upload-time = "2026-08-28T10:01:20.694Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/d7/ad493864a7fb21c0c4df98f965e2db430cb25a9d7369b5778d5016c09fd9/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:2d330aaba8621b1edcec8ae2c4050f63b84ccf6d98723a8f212e9684713abf0e", size = 2294560, upload-time = "2026-08-28T10:01:23.495Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/8e/b41c84c913f29973a268e6c2b5bbf13c95adb9956c126d10da11ba3b2bef/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b6acfb46a814762367fb7ba0828b0a17d441b92ce249a0e007474c9072662dda", size = 2317531, upload-time = "2026-08-28T10:01:26.334Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/1d/068464f23075f66a8f1b806935e9cd9363ee446636ea70d2c22ee8659dbf/pydantic_core-2.46.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d0a24b40877af2de4950252be9d21eaf7fb07660f3c2cae1f56c6b599ada5266", size = 2140686, upload-time = "2026-08-28T10:01:28.947Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.21.0"
|
||||
@@ -557,20 +158,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyjwt"
|
||||
version = "2.14.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/af/c3/8a3b59c25070cc61dc517fbdfa5dc0904670c96f605cc69759dc09166b99/pyjwt-2.14.0.tar.gz", hash = "sha256:77283c83fb56ecf566a886c757a714bc83668e38156de2cce8263302f42e0b86", size = 113177, upload-time = "2026-09-11T13:11:54.638Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/97/672cb32ce0dfea44b740cb7b4f97038463b9cf7c0ead1aacf595572851d6/pyjwt-2.14.0-py3-none-any.whl", hash = "sha256:ad0cef71c756a56e74863c2919cf0985f72decbcfcb550ee2f422e7c62b5eedc", size = 32896, upload-time = "2026-09-11T13:11:53.409Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
crypto = [
|
||||
{ name = "cryptography" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.1.1"
|
||||
@@ -655,19 +242,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "starlette"
|
||||
version = "1.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b5/b4/205b0d5241d934e8add0c38aa924c4f9fb7330834ff11e5444db964ec3f9/starlette-1.6.0.tar.gz", hash = "sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b", size = 2716969, upload-time = "2026-08-08T18:27:57.512Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/cb/6a6a47d5b464bd08695d254f3da6e7986cc70c9fa5d778eda57538edfe56/starlette-1.6.0-py3-none-any.whl", hash = "sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c", size = 75969, upload-time = "2026-08-08T18:27:56.196Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.16.0"
|
||||
@@ -677,31 +251,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-inspection"
|
||||
version = "0.4.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uvicorn"
|
||||
version = "0.53.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "h11" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5d/ad/04bbb797c84fc1f26cb171f7394716f4865ffb8d8c5e1eef42565c2dfa6b/uvicorn-0.53.0.tar.gz", hash = "sha256:a9356f0cb89b3b8621529c5d5eebd69bfe154f4c3f68b4cf2de47e45fa855c2e", size = 110881, upload-time = "2026-09-14T07:44:23.815Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/76/18/0eea75741ee812e9f598b687619ce2454f6c3a1c5cd21ea990ec6bd26f45/uvicorn-0.53.0-py3-none-any.whl", hash = "sha256:e8dca71ec86dce5f04e333f0d56cdedf942446e6643b9cea1af0d6d3a02cb03e", size = 87081, upload-time = "2026-09-14T07:44:22.179Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "voluptuous"
|
||||
version = "0.16.0"
|
||||
|
||||
Reference in New Issue
Block a user