Compare commits

..
Author SHA1 Message Date
orchestrator-agent f31bda2714 Include collision-suffixed entity ids in the morning summary
Duplicate child names get HA registry suffixes (_2); the template now
iterates the due sensors (which carry present/child_id/child_name as
attributes) and pairs counts by child_id, so suffixed duplicates are
listed and the household rollup is excluded by the presence guard.
2026-09-19 14:31:00 -04:00
orchestrator-agent dc7a82f6e2 Add the morning summary blueprint
Iterates present children from the Feature 10 presence sensors, lists
what each owes today from the remaining sensor, omits absent children
entirely. Blueprint inputs (time, notify service, send toggle); the
notify target is the household's choice, never hard-coded. PyYAML and
Jinja2 join the dev-only dependencies for blueprint template tests.
2026-09-19 14:24:50 -04:00
orchestrator-agent 1d5143f850 Add the notification section to the options flow 2026-09-19 14:20:15 -04:00
orchestrator-agent fbde3eeaa1 Allow saving the empty default notify target
An empty target means notifications are not configured yet and is the
shipped default; only malformed strings (padded or whitespace-only)
reject.
2026-09-19 14:20:13 -04:00
orchestrator-agent 3c832c1cee Add the notification section to the options flow
Notify target (empty default, household fills their own service),
three strict HH:MM automation times (08:00/15:00/20:00 — the report
deliberately before the midnight sweep), and four independent enable
toggles, all leniently stored and round-tripped.
2026-09-19 14:18:18 -04:00
orchestrator-agent 8c421abeb2 Add the nightly missed-quest sweep 2026-09-19 14:16:51 -04:00
orchestrator-agent 8cd43141a9 Serialize the missed sweep's watermark claim
The whole watermark read-query-fire-write span runs under a
per-database sweep lock; a racing second sweep re-reads the watermark
inside the lock and announces nothing.
2026-09-19 14:16:50 -04:00
orchestrator-agent 3de9f4b970 Add the nightly missed-quest sweep
Fires one nestquest_quest_missed event per past-due open instance at
the configured rollover time and at startup, watermark-guarded in the
new nestquest_meta_state table (migration 7, strictly additive).
Read-only over the domain tables; never reports an instance that was
ever completed.
2026-09-19 14:15:21 -04:00
orchestrator-agent 25005cda77 Feature 10: Home Assistant Entity and Event Surface 2026-09-19 13:29:29 -04:00
orchestrator-agent 35e66bd9e6 Carry completion timing under the lock; shut down before close
The completed event's was_on_time travels on the CompletionResult
(decided with the write) instead of re-reading latest state a racing
reversal could replace; unload clears coordinator listeners before
the database close.
2026-09-19 13:29:25 -04:00
orchestrator-agent a65e32f577 Add the entity-and-event matrix test 2026-09-19 13:27:09 -04:00
orchestrator-agent 36263d3f19 Add the entity-and-event matrix test
One fixture drives every Feature 10 contract: all per-child sensors
and binary sensors, household rollups, the 255-character limit,
the three events exactly once with schema-valid payloads, the
missed-instance omission rule, and no entities for inactive children.
2026-09-19 13:26:51 -04:00
orchestrator-agent 58cd68354f Handle entity lifecycle for child add, deactivate, reactivate 2026-09-19 13:25:32 -04:00
orchestrator-agent a9d8f1d225 Handle entity lifecycle for child add, deactivate, reactivate
The harness tracks entities per entry so a platform unload removes
exactly that entry's entities; lifecycle tests prove deactivated
children drop out on reload, reactivation restores the original
unique_ids, new children appear without disturbing existing ones,
and the restart equivalent restores live states.
2026-09-19 13:23:35 -04:00
orchestrator-agent 933e29ae76 Refresh entities immediately after completion service calls 2026-09-19 13:21:55 -04:00
22 changed files with 2001 additions and 61 deletions
+25 -10
View File
@@ -35,6 +35,7 @@ from .migrations import apply_migrations
from .services import async_deregister_services, async_register_services
from .store import async_get_db_path
from .admin_allowlist import seed_setup_admin
from .sweep import run_missed_sweep
_LOGGER = LOGGER
@@ -327,18 +328,23 @@ def _register_day_rollover_listener(
database: NestQuestDatabase,
options: dict[str, Any],
) -> Callable[[], Any]:
"""Register the daily materialization listener and return its remover.
"""Register the daily rollover listener and return its remover.
Fires once per day at the configured local ``day_rollover_time`` and
materializes the rolling horizon ``[today, today + horizon_days]``
through the shared :func:`_run_horizon_materialization` path, using the
entry's configured (validated) ``horizon_days``.
Fires once per day at the configured local ``day_rollover_time``:
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``), 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 = _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, horizon_days)
await run_missed_sweep(hass, database)
return async_track_time_change(
hass, _run_materialization, hour=hour, minute=minute
@@ -498,6 +504,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
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
# the next midnight. Watermark-guarded, so this is a no-op
# when the night's sweep already ran.
await run_missed_sweep(hass, database)
_register_services(hass)
# The shared Feature 10 coordinator: one refresh cycle every
# entity reads from (CONF_UPDATE_INTERVAL seconds, default
@@ -615,16 +627,19 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
if unload_error is None:
unload_error = err
finally:
database = getattr(runtime_data, "database", None)
if database is not None:
await database.close()
# Tear the shared coordinator down after the DB close: no
# listener may outlive the connection it reads.
# The coordinator's entity listeners must stop BEFORE the
# database closes: a scheduled refresh starting during the
# close await would touch a cleared connection. (The
# platform unload above already removed the entities; this
# clears the coordinator's own remaining listeners.)
coordinator = getattr(runtime_data, "coordinator", None)
if coordinator is not None:
shutdown = getattr(coordinator, "async_shutdown", None)
if shutdown is not None:
await shutdown()
database = getattr(runtime_data, "database", None)
if database is not None:
await database.close()
# Domain-global services are torn down only once the FINAL entry
# unloads (``hass.data[DOMAIN]`` is now empty), never when a sibling
# entry is still loaded.
@@ -0,0 +1,77 @@
blueprint:
name: NestQuest morning summary
description: >-
Sends a morning notification listing what each child still owes
today. Only children whose NestQuest presence sensor is on are
included; children who are not present today are omitted entirely.
The notify service is chosen by the household when importing this
blueprint — NestQuest never hard-codes a personal notify target.
domain: automation
input:
morning_time:
name: Morning time
description: Local time of day the summary is sent.
default: "08:00"
selector:
time: {}
notify_target:
name: Notify service
description: >-
The Home Assistant notify service to call for the summary,
for example notify.mobile_app_your_phone — a placeholder;
pick the notify service for your own device.
selector:
text: {}
send_summary:
name: Send the summary
description: >-
Turn off to keep the automation installed but skip the
notification entirely.
default: true
selector:
boolean: {}
mode: single
variables:
send_summary: !input send_summary
triggers:
- trigger: time
at: !input morning_time
conditions:
- condition: template
value_template: "{{ send_summary | bool }}"
actions:
- action: !input notify_target
title: NestQuest morning summary
message: |
{%- set ns = namespace(lines=[]) -%}
{%- for due in states.sensor
| selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_due_today(_\\d+)?$') -%}
{%- if due.attributes.get('present') -%}
{%- set remaining_values = states.sensor
| selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_remaining_today(_\\d+)?$')
| selectattr('attributes.child_id', 'eq', due.attributes.child_id)
| map(attribute='state')
| list -%}
{%- if remaining_values -%}
{%- set remaining = remaining_values | first | int -%}
{%- if remaining > 0 -%}
{%- set line = due.attributes.child_name ~ ' owes ' ~ remaining ~ ' quest(s) today.' -%}
{%- else -%}
{%- set line = due.attributes.child_name ~ ' is all clear today (0 quests remaining).' -%}
{%- endif -%}
{%- else -%}
{%- set line = due.attributes.child_name ~ "'s quest count is unavailable today." -%}
{%- endif -%}
{%- set ns.lines = ns.lines + [line] -%}
{%- endif -%}
{%- endfor -%}
{%- if ns.lines -%}
{{- ns.lines | join('\n') -}}
{%- else -%}
No children present today.
{%- endif -%}
+19 -4
View File
@@ -374,14 +374,25 @@ class CompletionResult(str):
appended an event (decided under the same lock as the append), so
a caller — the Feature 09 service path firing bus events —
announces a transition exactly once even under a concurrent
duplicate call where the loser's write is a no-op.
duplicate call where the loser's write is a no-op. A completion
also carries the ``was_on_time`` verdict computed under that same
lock, so the event payload never re-reads mutable latest state a
racing reversal could have replaced.
"""
appended: bool
was_on_time: bool | None
def __new__(cls, state: str, *, appended: bool) -> "CompletionResult":
def __new__(
cls,
state: str,
*,
appended: bool,
was_on_time: bool | None = None,
) -> "CompletionResult":
obj = super().__new__(cls, state)
obj.appended = appended
obj.was_on_time = was_on_time
return obj
@@ -429,6 +440,10 @@ async def complete_instance(
f"event_type must be '{EVENT_COMPLETED}' or "
f"'{EVENT_UNCOMPLETED}', got {latest.event_type!r}"
)
# Computed under the completion lock and carried on the result:
# the bus event must report THIS completion's timing, never a
# re-read of the latest event a racing reversal could replace.
on_time = _was_on_time(instance, now, today)
await append_event(
database,
instance.id,
@@ -437,10 +452,10 @@ async def complete_instance(
actor_source=actor_source,
actor_user_id=actor_user_id,
actor_child_id=actor_child_id,
was_on_time=_was_on_time(instance, now, today),
was_on_time=on_time,
now=now,
)
return CompletionResult("done", appended=True)
return CompletionResult("done", appended=True, was_on_time=on_time)
async def uncomplete_instance(
+20
View File
@@ -73,6 +73,26 @@ CONF_UPDATE_INTERVAL = "update_interval"
DEFAULT_UPDATE_INTERVAL = 300
MIN_UPDATE_INTERVAL = 30
# Notification automations (Feature 11). The notify target is a free-text
# Home Assistant service name (e.g. "notify.mobile_app_dads_phone") the
# household fills in — never a hard-coded personal target; the times are
# strict HH:MM local; each of the four automations has an independent
# enable toggle. Per the owner-settled resolution the end-of-day report
# defaults to 20:00 — deliberately BEFORE the midnight missed sweep —
# and never defaults to the rollover time.
CONF_NOTIFY_TARGET = "notify_target"
CONF_MORNING_SUMMARY_TIME = "morning_summary_time"
DEFAULT_MORNING_SUMMARY_TIME = "08:00"
CONF_AFTERNOON_REMINDER_TIME = "afternoon_reminder_time"
DEFAULT_AFTERNOON_REMINDER_TIME = "15:00"
CONF_END_OF_DAY_REPORT_TIME = "end_of_day_report_time"
DEFAULT_END_OF_DAY_REPORT_TIME = "20:00"
CONF_MORNING_SUMMARY_ENABLED = "morning_summary_enabled"
CONF_AFTERNOON_REMINDER_ENABLED = "afternoon_reminder_enabled"
CONF_END_OF_DAY_REPORT_ENABLED = "end_of_day_report_enabled"
CONF_CELEBRATION_ENABLED = "celebration_enabled"
DEFAULT_AUTOMATION_ENABLED = True
# Quest windows (D-008): a definition may declare several day windows;
# each produces its own instance per day and groups the panel's Quest
# Log into three columns. The clock ranges are stored here so they stay
@@ -493,6 +493,43 @@ class QuestInstancesDao:
)
return [_instance_from_row(row) for row in rows]
async def list_missed_unswept(
self, today_iso: str, watermark: str | None
) -> list[QuestInstanceRecord]:
"""Return past-due open instances the sweep has not announced.
Feature 11's missed-sweep query: ``due_date`` strictly before
``today_iso`` (past due), at or after ``watermark`` when one is
recorded, and with NO completion event ever (the Feature 08
append-only contract: an instance that was ever completed is
never reported missed, even when later reversed — history keeps
both rows and the sweep never mutates any). The watermark is
the HA-local date the sweep last ran, and the window is
``[watermark, today)``: the nightly run entering day D+1 sweeps
exactly day D's instances, so every day's quests are announced
once — at the midnight after their due date — and a same-night
rerun (watermark == today) sees an empty window. Read-only;
ordered deterministically by (due_date, id).
"""
_validate_date(today_iso, "today_iso")
if watermark is not None:
_validate_date(watermark, "watermark")
sql = (
f"SELECT {_INSTANCE_COLUMNS} FROM quest_instances "
"WHERE due_date < ? "
"AND NOT EXISTS ("
" SELECT 1 FROM completion_events "
" WHERE instance_id = quest_instances.id"
")"
)
parameters: tuple = (today_iso,)
if watermark is not None:
sql += " AND due_date >= ?"
parameters += (watermark,)
sql += " ORDER BY due_date, id"
rows = await self._database.fetch_all(sql, parameters)
return [_instance_from_row(row) for row in rows]
async def delete_future_uncompleted(
self, definition_id: int, cutoff_date: str, *, today: datetime.date | None = None
) -> int:
+44
View File
@@ -0,0 +1,44 @@
"""Typed DAO for the integration-owned ``nestquest_meta_state`` table.
Generic persistent key/value bookkeeping that belongs to the
integration itself rather than any domain table — currently the
Feature 11 missed-sweep watermark. Values are plain strings; callers
interpret them. All SQL for this table lives here per the feature
guardrails, and every method runs through
:class:`~.db.NestQuestDatabase` so nothing blocks the event loop.
"""
from __future__ import annotations
from .dao_children import _connection_lock
from .db import NestQuestDatabase
_TABLE = "nestquest_meta_state"
class MetaStateDao:
"""Typed async access to the ``nestquest_meta_state`` table."""
def __init__(self, database: NestQuestDatabase) -> None:
self._database = database
async def get(self, key: str) -> str | None:
"""Return the stored value for ``key``, or None when absent."""
row = await self._database.fetch_one(
f"SELECT value FROM {_TABLE} WHERE key = ?", (key,)
)
return row[0] if row is not None else None
async def set(self, key: str, value: str) -> None:
"""Upsert one key/value row atomically.
The write runs inside one transaction under the shared
connection lock, so a racing reader either sees the old value
or the fully-written new one, never a half-applied state.
"""
async with _connection_lock(self._database):
async with self._database.transaction():
await self._database.execute(
f"INSERT INTO {_TABLE} (key, value) VALUES (?, ?) "
"ON CONFLICT (key) DO UPDATE SET value = excluded.value",
(key, value),
)
+10 -8
View File
@@ -71,22 +71,24 @@ async def _instance_payload(
async def fire_quest_completed(
hass, database: NestQuestDatabase, instance_id: int
hass,
database: NestQuestDatabase,
instance_id: int,
*,
was_on_time: bool | None,
) -> None:
"""Fire ``nestquest_quest_completed`` and, when the completion
cleared the child's whole day, ``nestquest_child_day_complete``.
Called by the complete_quest service handler AFTER an actual
completion transition; a no-op re-complete never reaches here.
completion transition, with the completion's ``was_on_time`` —
computed under the completion lock and carried on the result — so
the payload never re-reads mutable latest state a racing reversal
could have replaced. A no-op re-complete never reaches here.
"""
now_stamp = _now_stamp()
payload = await _instance_payload(database, instance_id, now_stamp)
latest = await CompletionEventsDao(database).get_latest_for_instance(
instance_id
)
payload["was_on_time"] = (
latest.was_on_time if latest is not None else None
)
payload["was_on_time"] = was_on_time
hass.bus.async_fire(EVENT_QUEST_COMPLETED, payload)
# Day-complete: quests were owed today and none remain (the same
+10 -1
View File
@@ -28,7 +28,10 @@ copying each definition's assignee across. Migration 4 adds the
windows table, migration 5 rebuilds ``quest_instances`` onto the
widened (definition_id, child_id, due_date, window) key, and migration
6 rebuilds ``completion_events`` with the D-008 ``actor_child_id``
column. Each callable migration runs inside its own transaction
column. Migration 7 adds the generic ``nestquest_meta_state``
key/value table for integration-owned bookkeeping (Feature 11's
missed-sweep watermark). Each callable migration runs inside its own
transaction
together with its version stamp, so a crash mid-step rolls the
statements and the stamp back together.
@@ -80,6 +83,7 @@ from .schema import (
SCHEMA_V1_STATEMENTS,
SCHEMA_V1_QUEST_DEFINITION_ASSIGNEES_DDL,
SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL,
SCHEMA_V7_META_STATE_DDL,
)
LOGGER = logging.getLogger(__name__)
@@ -414,6 +418,11 @@ MIGRATIONS: Sequence[MigrationStep] = [
# actor_child_id column and its actor-pair coherence CHECK,
# backfilling legacy panel rows to their own child_id.
_add_actor_child_id,
# Migration 7 (Feature 11): the generic meta_state key/value table
# — a plain new table, so CREATE IF NOT EXISTS suffices for both
# fresh databases (which already made it in migration 1) and
# pre-v7 databases. Strictly additive; no existing table changes.
list(SCHEMA_V7_META_STATE_DDL),
]
+114 -1
View File
@@ -11,12 +11,24 @@ from homeassistant.helpers import config_validation as cv
from .const import (
CONF_ADMIN_USER_IDS,
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,
CONF_PANEL_IDLE_TIMEOUT,
CONF_UPDATE_INTERVAL,
DEFAULT_AFTERNOON_REMINDER_TIME,
DEFAULT_AUTOMATION_ENABLED,
DEFAULT_DAY_ROLLOVER_TIME,
DEFAULT_END_OF_DAY_REPORT_TIME,
DEFAULT_HORIZON_DAYS,
DEFAULT_MORNING_SUMMARY_TIME,
DEFAULT_PANEL_IDLE_TIMEOUT,
DEFAULT_UPDATE_INTERVAL,
DOMAIN,
@@ -71,9 +83,51 @@ def _async_validate(user_input: dict[str, Any]) -> dict[str, str]:
):
errors[CONF_UPDATE_INTERVAL] = "invalid"
# The notification section (Feature 11) is OPTIONAL in raw submits
# the same way: a missing key keeps the current value. The three
# times are strict HH:MM; the notify target is a non-empty HA
# service name; the toggles are real booleans.
for time_key in (
CONF_MORNING_SUMMARY_TIME,
CONF_AFTERNOON_REMINDER_TIME,
CONF_END_OF_DAY_REPORT_TIME,
):
time_value = user_input.get(time_key)
if time_value is not None and (
not isinstance(time_value, str) or not _parse_time(time_value)
):
errors[time_key] = "invalid_time"
notify_target = user_input.get(CONF_NOTIFY_TARGET)
if notify_target is not None and not isinstance(notify_target, str):
errors[CONF_NOTIFY_TARGET] = "invalid"
elif isinstance(notify_target, str) and notify_target != (
notify_target.strip()
):
# An EMPTY target is valid — it means "no notifications
# configured yet" and is the shipped default; a value that
# differs from its own trim (padded or whitespace-only) is
# malformed and rejected.
errors[CONF_NOTIFY_TARGET] = "invalid"
for toggle_key in (
CONF_MORNING_SUMMARY_ENABLED,
CONF_AFTERNOON_REMINDER_ENABLED,
CONF_END_OF_DAY_REPORT_ENABLED,
CONF_CELEBRATION_ENABLED,
):
toggle_value = user_input.get(toggle_key)
if toggle_value is not None and not isinstance(toggle_value, bool):
errors[toggle_key] = "invalid"
return errors
def _strict_bool(value: object) -> bool:
"""Require a real bool; voluptuous ``bool`` would coerce strings."""
if not isinstance(value, bool):
raise vol.Invalid(f"expected a boolean, got {value!r}")
return value
def _build_schema(
current: dict[str, Any],
*,
@@ -85,7 +139,10 @@ def _build_schema(
The admin picker is a multi-select of EXISTING Home Assistant users
(never a free-text field — the allowlist stores user IDs); its
default is the current database allowlist, so an untouched submit
re-saves exactly who is already allowed.
re-saves exactly who is already allowed. The notification section
(Feature 11) carries the household's notify target, the three
automation times, and four enable toggles — defaults from
:mod:`~.const`, never a hard-coded personal target.
"""
fields: dict[Any, Any] = {
vol.Required(
@@ -100,6 +157,37 @@ def _build_schema(
vol.Required(
CONF_UPDATE_INTERVAL, default=current[CONF_UPDATE_INTERVAL]
): int,
vol.Required(
CONF_NOTIFY_TARGET, default=current[CONF_NOTIFY_TARGET]
): str,
vol.Required(
CONF_MORNING_SUMMARY_TIME,
default=current[CONF_MORNING_SUMMARY_TIME],
): str,
vol.Required(
CONF_AFTERNOON_REMINDER_TIME,
default=current[CONF_AFTERNOON_REMINDER_TIME],
): str,
vol.Required(
CONF_END_OF_DAY_REPORT_TIME,
default=current[CONF_END_OF_DAY_REPORT_TIME],
): str,
vol.Required(
CONF_MORNING_SUMMARY_ENABLED,
default=current[CONF_MORNING_SUMMARY_ENABLED],
): _strict_bool,
vol.Required(
CONF_AFTERNOON_REMINDER_ENABLED,
default=current[CONF_AFTERNOON_REMINDER_ENABLED],
): _strict_bool,
vol.Required(
CONF_END_OF_DAY_REPORT_ENABLED,
default=current[CONF_END_OF_DAY_REPORT_ENABLED],
): _strict_bool,
vol.Required(
CONF_CELEBRATION_ENABLED,
default=current[CONF_CELEBRATION_ENABLED],
): _strict_bool,
}
if admin_choices is not None:
# HA's supported multi-select validator: the frontend can
@@ -233,6 +321,23 @@ class NestQuestOptionsFlow(config_entries.OptionsFlowWithConfigEntry):
self._current_values()[CONF_UPDATE_INTERVAL],
),
}
# The notification section (Feature 11) stores the household's
# choices the same fall-back-to-current way, so raw submits
# that predate the section keep their stored values.
current = self._current_values()
for notification_key in (
CONF_NOTIFY_TARGET,
CONF_MORNING_SUMMARY_TIME,
CONF_AFTERNOON_REMINDER_TIME,
CONF_END_OF_DAY_REPORT_TIME,
CONF_MORNING_SUMMARY_ENABLED,
CONF_AFTERNOON_REMINDER_ENABLED,
CONF_END_OF_DAY_REPORT_ENABLED,
CONF_CELEBRATION_ENABLED,
):
data[notification_key] = user_input.get(
notification_key, current[notification_key]
)
if admin_ids_present:
data[CONF_ADMIN_USER_IDS] = admin_ids
elif admin_default:
@@ -254,6 +359,14 @@ class NestQuestOptionsFlow(config_entries.OptionsFlowWithConfigEntry):
(CONF_DAY_ROLLOVER_TIME, DEFAULT_DAY_ROLLOVER_TIME),
(CONF_PANEL_IDLE_TIMEOUT, DEFAULT_PANEL_IDLE_TIMEOUT),
(CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL),
(CONF_NOTIFY_TARGET, ""),
(CONF_MORNING_SUMMARY_TIME, DEFAULT_MORNING_SUMMARY_TIME),
(CONF_AFTERNOON_REMINDER_TIME, DEFAULT_AFTERNOON_REMINDER_TIME),
(CONF_END_OF_DAY_REPORT_TIME, DEFAULT_END_OF_DAY_REPORT_TIME),
(CONF_MORNING_SUMMARY_ENABLED, DEFAULT_AUTOMATION_ENABLED),
(CONF_AFTERNOON_REMINDER_ENABLED, DEFAULT_AUTOMATION_ENABLED),
(CONF_END_OF_DAY_REPORT_ENABLED, DEFAULT_AUTOMATION_ENABLED),
(CONF_CELEBRATION_ENABLED, DEFAULT_AUTOMATION_ENABLED),
):
if key in options:
values[key] = options[key]
+16
View File
@@ -384,6 +384,21 @@ SCHEMA_V1_COMPLETION_EVENTS_DDL: list[str] = [
""",
]
#: Generic integration-owned key/value state (schema version 7,
#: Feature 11): persistent bookkeeping that belongs to the integration
#: rather than any domain table — currently the missed-sweep
#: watermark. ``value`` is a plain string; callers interpret it.
META_STATE_TABLE_SQL = """(
key TEXT NOT NULL PRIMARY KEY,
value TEXT NOT NULL
)"""
SCHEMA_V7_META_STATE_DDL: list[str] = [
f"""
CREATE TABLE IF NOT EXISTS nestquest_meta_state {META_STATE_TABLE_SQL}
""",
]
SCHEMA_V1_STATEMENTS: list[str] = [
*SCHEMA_V1_CHILDREN_DDL,
*SCHEMA_V1_ADMIN_USERS_DDL,
@@ -395,6 +410,7 @@ SCHEMA_V1_STATEMENTS: list[str] = [
*SCHEMA_V1_PRESENCE_OVERRIDES_DDL,
*SCHEMA_V1_QUEST_INSTANCES_DDL,
*SCHEMA_V1_COMPLETION_EVENTS_DDL,
*SCHEMA_V7_META_STATE_DDL,
]
+6 -1
View File
@@ -317,7 +317,12 @@ def _complete_quest(
**_actor_kwargs(call),
)
if result.appended:
await fire_quest_completed(hass, runtime.database, instance_id)
await fire_quest_completed(
hass,
runtime.database,
instance_id,
was_on_time=result.was_on_time,
)
await _refresh_entities(runtime)
return _with_errors(_handler)
+132
View File
@@ -0,0 +1,132 @@
"""The nightly missed-quest sweep (Feature 11).
At the configured day-rollover time (the same HA-local listener that
re-materializes the rolling horizon) this job finds every instance
whose ``due_date`` is before the HA-local date, that has NO completion
event, and that falls after the persisted sweep watermark — and fires
ONE ``nestquest_quest_missed`` event per instance with the documented
payload. The sweep is read-only over the domain tables: it never
mutates an instance and never writes a completion event (``missed``
is derived state, never an event_type — Feature 08's contract).
Idempotency is the watermark: after a run, ``nestquest_meta_state``
records the HA-local date the sweep ran, and the next run only
sweeps instances due in ``[watermark, today)``. A second run the
same night therefore fires nothing new, and a run after days of
downtime sweeps exactly the accumulated window once — no
per-instance journal, no re-announcing old misses. An instance swept
as missed that is later completed simply gains its completion event;
history keeps both rows (append-only), and the sweep's later runs
skip it via the no-completion-event rule.
"""
from __future__ import annotations
import asyncio
import datetime
import logging
from zoneinfo import ZoneInfo
from .const import EVENT_QUEST_MISSED
from .dao_children import ChildrenDao
from .dao_instances import QuestInstancesDao
from .dao_meta import MetaStateDao
from .dao_rules import QuestDefinitionsDao
from .db import NestQuestDatabase
LOGGER = logging.getLogger(__name__)
#: meta_state key holding the HA-local date the sweep last ran.
SWEEP_WATERMARK_KEY = "missed_sweep_last_run_date"
#: One asyncio.Lock per (connection wrapper, running loop), mirroring
#: the migration runner's pattern: the whole watermark read → query →
#: fire → write span is one critical section, so an overlapping startup
#: sweep and rollover sweep queue instead of double-announcing. The
#: loser re-reads the watermark INSIDE the lock, sees the winner's
#: date, and fires nothing.
_SWEEP_LOCKS: dict[tuple[int, int], asyncio.Lock] = {}
def _sweep_lock(database: NestQuestDatabase) -> asyncio.Lock:
"""Return the sweep lock bound to ``database``'s running loop."""
loop = asyncio.get_running_loop()
key = (id(database), id(loop))
lock = _SWEEP_LOCKS.get(key)
if lock is None:
lock = asyncio.Lock()
_SWEEP_LOCKS[key] = lock
return lock
#: UTC ISO-8601 timestamp policy shared with the DAO layers.
_UTC_TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%S+00:00"
def _now_stamp() -> str:
return datetime.datetime.now(datetime.timezone.utc).strftime(
_UTC_TIMESTAMP_FORMAT
)
async def run_missed_sweep(
hass,
database: NestQuestDatabase,
*,
today: datetime.date | None = None,
) -> int:
"""Fire one missed event per unswept past-due open instance.
``today`` optionally pins the HA-local date (threaded like the
materialization and completion layers); when omitted it is read
from ``hass.config.time_zone``. Returns the number of events
fired. Fires nothing and returns 0 when the watermark already
covers ``today`` (a same-night rerun).
The whole watermark read → query → fire → write span runs inside
the per-database sweep lock: an overlapping startup sweep and a
rollover sweep queue instead of interleaving, and the loser
re-reads the watermark inside the lock — sees the winner's date —
and announces nothing twice.
"""
today_date = (
today
if today is not None
else datetime.datetime.now(ZoneInfo(hass.config.time_zone)).date()
)
today_iso = today_date.isoformat()
dao = MetaStateDao(database)
async with _sweep_lock(database):
watermark = await dao.get(SWEEP_WATERMARK_KEY)
if watermark == today_iso:
LOGGER.debug(
"NestQuest missed sweep already ran for %s", today_iso
)
return 0
instances_dao = QuestInstancesDao(database)
rows = await instances_dao.list_missed_unswept(today_iso, watermark)
children_dao = ChildrenDao(database)
definitions_dao = QuestDefinitionsDao(database)
now_stamp = _now_stamp()
fired = 0
for instance in rows:
child = await children_dao.get(instance.child_id)
definition = await definitions_dao.get(instance.definition_id)
hass.bus.async_fire(
EVENT_QUEST_MISSED,
{
"child_id": instance.child_id,
"child_name": child.display_name if child else None,
"instance_id": instance.id,
"quest_title": definition.title if definition else None,
"window": instance.window,
"due_date": instance.due_date,
"due_time": instance.due_time,
"occurred_at": now_stamp,
},
)
fired += 1
await dao.set(SWEEP_WATERMARK_KEY, today_iso)
LOGGER.info(
"NestQuest missed sweep for %s fired %d event(s)", today_iso, fired
)
return fired
+2
View File
@@ -8,8 +8,10 @@ dependencies = []
[dependency-groups]
dev = [
"jinja2>=3",
"pytest>=8.0.0",
"pytest-asyncio>=0.24",
"pyyaml>=6",
"voluptuous>=0.14",
]
+34 -16
View File
@@ -704,28 +704,40 @@ def make_hass() -> tuple:
# The minimal entity registry: entities self-register through
# Entity.async_write_ha_state keyed by unique_id, storing the live
# object; tests enumerate created entities and read their state
# directly.
# directly. entities_by_entry mirrors HA's per-entry platform
# scoping: unloading an entry's platforms removes exactly that
# entry's entities.
hass.entities: dict[str, Entity] = {}
hass.entities_by_entry: dict[str, dict[str, Entity]] = {}
def _async_add_entities(new_entities, update_before_add=False):
"""Mirror HA's AddEntitiesCallback: bind hass and register entities.
def _make_add_entities(entry):
entry_entities = hass.entities_by_entry.setdefault(entry.entry_id, {})
Accepts a single entity or an iterable, mirroring HA's
permissive call shape; registration goes through each entity's
async_write_ha_state (the mock state write).
"""
if isinstance(new_entities, Entity):
new_entities = [new_entities]
for entity in new_entities:
entity.hass = hass
entity.async_write_ha_state()
def _async_add_entities(new_entities, update_before_add=False):
"""Mirror HA's AddEntitiesCallback: bind hass and register.
Accepts a single entity or an iterable, mirroring HA's
permissive call shape; registration goes through each
entity's async_write_ha_state (the mock state write) and
is recorded per entry so an unload removes exactly this
entry's entities.
"""
if isinstance(new_entities, Entity):
new_entities = [new_entities]
for entity in new_entities:
entity.hass = hass
entity.async_write_ha_state()
if entity.unique_id is not None:
entry_entities[entity.unique_id] = entity
return _async_add_entities
async def _async_forward_entry_setups(entry, platforms):
"""Mirror hass.config_entries.async_forward_entry_setups.
HA's contract, loosely: import each platform module of the
entry's integration and drive its ``async_setup_entry`` with
the AddEntitiesCallback above. A stub entry without a domain
entry's integration and drive its ``async_setup_entry`` with a
per-entry AddEntitiesCallback. A stub entry without a domain
defaults to this harness's single integration (NestQuest).
"""
for platform in list(platforms):
@@ -733,14 +745,18 @@ def make_hass() -> tuple:
module = importlib.import_module(
f"custom_components.{domain}.{platform}"
)
await module.async_setup_entry(hass, entry, _async_add_entities)
await module.async_setup_entry(
hass, entry, _make_add_entities(entry)
)
hass.config_entries.forwarded_platforms.append(platform)
async def _async_unload_platforms(entry, platforms):
"""Mirror hass.config_entries.async_unload_platforms.
Runs each platform module's ``async_unload_entry`` when it
defines one (per-platform unload is optional, like HA's).
defines one (per-platform unload is optional, like HA's), then
removes the entry's entities — unloading an entry's platforms
removes exactly that entry's entities, never another entry's.
"""
for platform in list(platforms):
domain = getattr(entry, "domain", "nestquest")
@@ -751,6 +767,8 @@ def make_hass() -> tuple:
if unload is not None:
await unload(hass, entry)
hass.config_entries.unloaded_platforms.append(platform)
for unique_id in hass.entities_by_entry.pop(entry.entry_id, {}):
hass.entities.pop(unique_id, None)
hass.config_entries.forwarded_platforms: list[str] = []
hass.config_entries.unloaded_platforms: list[str] = []
+442
View File
@@ -0,0 +1,442 @@
"""Tests for the NestQuest automation blueprints.
The blueprints are YAML data consumed by Home Assistant's blueprint
engine; the integration itself never imports yaml or jinja2. These
tests therefore exercise them exactly the way HA would: parse the YAML
(with a loader that understands ``!input`` tags), then render the
message template with a plain jinja2 environment over a fixture state
set shaped like HA's template state objects.
The harness is mock-only (see conftest.py), so the fixtures model just
the surface the templates touch: ``states.binary_sensor`` /
``states.sensor`` lists of objects carrying ``entity_id``, ``state`` and
``attributes``. Jinja's ``search``/``match`` selectattr tests are
Home Assistant additions, registered here explicitly.
Rendering policy under test (settled for the morning summary): a
PRESENT child always appears — with remaining quests owed, or as "all
clear" at zero remaining — and a child who is not present that day is
omitted entirely, never listed with zero quests.
"""
from __future__ import annotations
import re
from pathlib import Path
from types import SimpleNamespace
import jinja2
import yaml
from custom_components.nestquest.const import DEFAULT_MORNING_SUMMARY_TIME
REPO_ROOT = Path(__file__).parent.parent
BLUEPRINT_DIR = (
REPO_ROOT / "custom_components" / "nestquest" / "blueprints" / "automation"
)
MORNING_TITLE = "NestQuest morning summary"
class BlueprintInput(str):
"""Marker for a parsed blueprint ``!input <name>`` reference."""
def _input_constructor(loader: yaml.SafeLoader, node: yaml.Node) -> BlueprintInput:
return BlueprintInput(loader.construct_scalar(node))
class _BlueprintLoader(yaml.SafeLoader):
"""SafeLoader extended with Home Assistant's ``!input`` blueprint tag."""
_BlueprintLoader.add_constructor("!input", _input_constructor)
def load_blueprints() -> dict[str, dict]:
"""Parse every automation blueprint, asserting the directory exists."""
assert BLUEPRINT_DIR.is_dir(), f"missing blueprint directory {BLUEPRINT_DIR}"
paths = sorted(BLUEPRINT_DIR.glob("*.yaml"))
assert paths, f"no blueprints under {BLUEPRINT_DIR}"
return {
path.stem: yaml.load(path.read_text(encoding="utf-8"), Loader=_BlueprintLoader)
for path in paths
}
def iter_strings(node: object):
"""Yield every string value in a parsed YAML structure, recursively."""
if isinstance(node, str):
yield node
elif isinstance(node, dict):
for value in node.values():
yield from iter_strings(value)
elif isinstance(node, list):
for value in node:
yield from iter_strings(value)
def morning_summary() -> dict:
"""Return the parsed morning-summary blueprint."""
blueprints = load_blueprints()
assert "morning_summary" in blueprints, "morning_summary.yaml is missing"
return blueprints["morning_summary"]
def morning_action(bp: dict) -> dict:
"""Return the single notify action of a summary blueprint."""
actions = bp["actions"]
assert isinstance(actions, list) and len(actions) == 1
return actions[0]
class TemplateState:
"""Stand-in for an HA template state object: entity_id/state/attributes."""
def __init__(self, entity_id: str, state: str, attributes: dict | None = None):
self.entity_id = entity_id
self.state = state
self.attributes = dict(attributes or {})
def make_states(binary_sensors=(), sensors=()) -> SimpleNamespace:
"""Build the ``states`` fixture: per-domain lists of TemplateState."""
return SimpleNamespace(
binary_sensor=list(binary_sensors),
sensor=list(sensors),
)
def make_template_environment() -> jinja2.Environment:
"""A plain jinja2 environment with HA's regex tests and bool filter."""
env = jinja2.Environment()
env.tests["match"] = lambda value, pattern: re.match(pattern, value) is not None
env.tests["search"] = lambda value, pattern: re.search(pattern, value) is not None
def _bool(value, default=False):
if isinstance(value, str):
return value.strip().lower() in ("true", "on", "open", "yes", "1")
return bool(value) or default
env.filters["bool"] = _bool
return env
def render(template: str, **variables) -> str:
"""Render a template the way HA would substitute its variables."""
return make_template_environment().from_string(template).render(**variables)
# ---------------------------------------------------------------------------
# YAML parsing and blueprint shape.
# ---------------------------------------------------------------------------
def test_blueprints_parse_and_declare_valid_shape() -> None:
"""Every blueprint parses and carries the required blueprint keys."""
blueprints = load_blueprints()
assert blueprints, "no blueprints found"
for name, bp in blueprints.items():
assert isinstance(bp, dict), f"{name} did not parse to a mapping"
spec = bp["blueprint"]
assert isinstance(spec["name"], str) and spec["name"].strip()
assert spec["domain"] == "automation"
assert isinstance(spec["input"], dict) and spec["input"]
for key in ("triggers", "conditions", "actions"):
assert isinstance(bp[key], list) and bp[key], f"{name}: empty {key}"
def test_morning_summary_declares_inputs_with_selectors() -> None:
"""The three blueprint inputs exist, carry selectors, and default sanely."""
inputs = morning_summary()["blueprint"]["input"]
expected = {"morning_time", "notify_target", "send_summary"}
assert set(inputs) == expected
for name, spec in inputs.items():
assert isinstance(spec["selector"], dict) and spec["selector"], name
assert inputs["morning_time"]["default"] == DEFAULT_MORNING_SUMMARY_TIME
assert inputs["send_summary"]["default"] is True
assert "time" in inputs["morning_time"]["selector"]
assert "text" in inputs["notify_target"]["selector"]
assert "boolean" in inputs["send_summary"]["selector"]
def test_morning_summary_wires_inputs_not_literals() -> None:
"""Time trigger, toggle condition, and notify action all use !input."""
bp = morning_summary()
assert bp["variables"]["send_summary"] == BlueprintInput("send_summary")
trigger = bp["triggers"][0]
assert trigger["trigger"] == "time"
assert trigger["at"] == BlueprintInput("morning_time")
condition = bp["conditions"][0]
assert condition["condition"] == "template"
assert "send_summary" in condition["value_template"]
action = morning_action(bp)
assert action["action"] == BlueprintInput("notify_target")
assert action["title"] == MORNING_TITLE
assert isinstance(action["message"], str) and action["message"].strip()
# ---------------------------------------------------------------------------
# Entity-reference discipline (Feature 10 shapes only).
# ---------------------------------------------------------------------------
#: Entity shapes Feature 10 actually creates (sensor.py, binary_sensor.py).
ALLOWED_ENTITY_SHAPES: tuple[re.Pattern, ...] = tuple(
re.compile(pattern)
for pattern in (
r"sensor\.nestquest_[a-z0-9_]+_quests_due_today",
r"sensor\.nestquest_[a-z0-9_]+_quests_remaining_today",
r"sensor\.nestquest_[a-z0-9_]+_quests_completed_today",
r"sensor\.nestquest_[a-z0-9_]+_completion_pct_today",
r"sensor\.nestquest_[a-z0-9_]+_next_quest",
r"binary_sensor\.nestquest_[a-z0-9_]+_all_done",
r"binary_sensor\.nestquest_[a-z0-9_]+_present_today",
r"sensor\.nestquest_household_quests_due_today",
r"sensor\.nestquest_household_quests_completed_today",
r"sensor\.nestquest_cycle_day",
)
)
#: An unescaped, id-like entity reference: any found must be a full
#: match for one of the Feature 10 shapes above.
_ENTITY_LITERAL_RE = re.compile(r"(?<![\\\w])(?:sensor|binary_sensor)\.[A-Za-z0-9_]+")
#: A domain-dot mention, ignoring the template's escaped-dot regexes
#: (one backslash is stripped so ``binary_sensor\\.`` counts too).
_MENTION_RE = re.compile(r"(?:sensor|binary_sensor)\.([A-Za-z0-9_]+)")
def test_entity_literals_match_feature_10_shapes() -> None:
"""Every id-like entity string in the YAML is a Feature 10 shape."""
for name, bp in load_blueprints().items():
for value in iter_strings(bp):
for literal in _ENTITY_LITERAL_RE.findall(value):
assert any(
shape.fullmatch(literal) for shape in ALLOWED_ENTITY_SHAPES
), f"{name}: invented entity {literal!r}"
# The validator itself must separate Feature 10 ids from lookalikes.
assert any(
shape.fullmatch("binary_sensor.nestquest_alice_present_today")
for shape in ALLOWED_ENTITY_SHAPES
)
assert any(
shape.fullmatch("sensor.nestquest_bob_quests_remaining_today")
for shape in ALLOWED_ENTITY_SHAPES
)
for foreign in (
"binary_sensor.someone_else_present_today",
"binary_sensor.nestquest_alice_at_home",
"sensor.nestquest_alice_streak",
"vacuum.nestquest_robot",
):
assert not any(shape.fullmatch(foreign) for shape in ALLOWED_ENTITY_SHAPES)
def test_domain_mentions_are_nestquest_prefixed() -> None:
"""Every sensor/binary_sensor mention (regex or literal) is nestquest_."""
for name, bp in load_blueprints().items():
for value in iter_strings(bp):
flattened = value.replace("\\", "")
for tail in _MENTION_RE.findall(flattened):
assert tail.startswith("nestquest_"), (
f"{name}: non-nestquest entity mention in {value!r}"
)
def test_due_sensor_regex_selects_only_nestquest_due_sensors() -> None:
"""The extracted due-sensor regex matches Feature 10 due ids —
including HA collision-suffixed duplicates (a second child with
the same name gets entity ids ending _2) — and rejects everything
else."""
message = morning_action(morning_summary())["message"]
match = re.search(
r"\^(sensor\\\\\.nestquest_\.\*_quests_due_today\(_\\\\d\+\)\?\$)", message
)
assert match is not None, "due-sensor pattern missing from the template"
pattern = match.group(1).replace("\\\\", "\\")
assert pattern.endswith("$")
assert re.search(pattern, "sensor.nestquest_alice_quests_due_today")
assert re.search(pattern, "sensor.nestquest_alice_quests_due_today_2")
assert re.search(pattern, "sensor.nestquest_bob_quests_remaining_today") is None
# The household rollup due sensor unavoidably matches the pattern;
# the template's present-attribute guard excludes it (proven in the
# render test below).
assert re.search(pattern, "sensor.nestquest_household_quests_due_today")
assert re.search(pattern, "sensor.carol_unrelated") is None
def test_morning_summary_never_hard_codes_notify_target() -> None:
"""No notify service literal outside input descriptions; the choice is text."""
bp = morning_summary()
spec = bp["blueprint"]
assert "hard-code" in spec["description"]
assert not re.search(r"notify\.[a-z_]+", spec["description"])
outside_input = {k: v for k, v in bp.items() if k != "blueprint"}
for value in iter_strings(outside_input):
assert not re.search(r"notify\.[a-z_.]+", value), value
target_description = spec["input"]["notify_target"]["description"]
assert re.search(r"notify\.[a-z_]+", target_description), (
"the input description should show a placeholder example"
)
assert morning_action(bp)["action"] == BlueprintInput("notify_target")
# ---------------------------------------------------------------------------
# Message rendering against fixture state sets.
# ---------------------------------------------------------------------------
def morning_fixture() -> SimpleNamespace:
"""Two children — Alice present (2 owed), Bob absent — plus decoys."""
return make_states(
binary_sensors=[
TemplateState("binary_sensor.nestquest_alice_present_today", "on"),
TemplateState("binary_sensor.nestquest_bob_present_today", "off"),
TemplateState("binary_sensor.motion_present_today", "on"),
TemplateState("binary_sensor.nestquest_dave_all_done", "on"),
TemplateState("binary_sensor.nestquest_erin_present_today", "unavailable"),
],
sensors=[
TemplateState(
"sensor.nestquest_alice_quests_due_today",
"3",
{"child_name": "Alice", "child_id": 1, "present": True},
),
TemplateState(
"sensor.nestquest_alice_quests_remaining_today",
"2",
{"child_id": 1},
),
TemplateState(
"sensor.nestquest_bob_quests_due_today",
"2",
{"child_name": "Bob", "child_id": 2, "present": False},
),
TemplateState(
"sensor.nestquest_bob_quests_remaining_today",
"1",
{"child_id": 2},
),
TemplateState("sensor.nestquest_household_quests_due_today", "5"),
TemplateState("sensor.nestquest_carol_unrelated", "7"),
],
)
def test_morning_summary_lists_present_child_and_omits_absent() -> None:
"""Present Alice appears with her count; absent Bob and decoys never do."""
message = morning_action(morning_summary())["message"]
rendered = render(message, states=morning_fixture())
assert rendered == "Alice owes 2 quest(s) today."
assert "Bob" not in rendered
for absent in ("motion", "Dave", "Erin", "household", "carol"):
assert absent not in rendered
def test_morning_summary_present_child_zero_remaining_is_all_clear() -> None:
"""A present child always appears — zero remaining reads as all clear."""
states = make_states(
sensors=[
TemplateState(
"sensor.nestquest_carol_quests_due_today",
"2",
{"child_name": "Carol", "child_id": 1, "present": True},
),
TemplateState(
"sensor.nestquest_carol_quests_remaining_today",
"0",
{"child_id": 1},
),
TemplateState(
"sensor.nestquest_alice_quests_due_today",
"3",
{"child_name": "Alice", "child_id": 2, "present": True},
),
TemplateState(
"sensor.nestquest_alice_quests_remaining_today",
"2",
{"child_id": 2},
),
],
)
rendered = render(morning_action(morning_summary())["message"], states=states)
assert "Alice owes 2 quest(s) today." in rendered
assert "Carol is all clear today (0 quests remaining)." in rendered
def test_morning_summary_present_child_without_sensors_is_flagged() -> None:
"""A present child whose remaining-count sensor is missing (the
platform failed to add it) still gets a line, flagged unavailable."""
states = make_states(
sensors=[
TemplateState(
"sensor.nestquest_frances_quests_due_today",
"2",
{"child_name": "Frances", "child_id": 1, "present": True},
),
],
)
rendered = render(morning_action(morning_summary())["message"], states=states)
assert rendered == "Frances's quest count is unavailable today."
def test_morning_summary_collision_suffixed_duplicate_names() -> None:
"""Two children sharing a display name get HA registry suffixes on
their entity ids (..._2); both must appear, each with their own
count (Codex review regression)."""
states = make_states(
sensors=[
TemplateState(
"sensor.nestquest_ada_quests_due_today",
"2",
{"child_name": "Ada", "child_id": 1, "present": True},
),
TemplateState(
"sensor.nestquest_ada_quests_remaining_today",
"1",
{"child_id": 1},
),
TemplateState(
"sensor.nestquest_ada_quests_due_today_2",
"2",
{"child_name": "Ada", "child_id": 2, "present": True},
),
TemplateState(
"sensor.nestquest_ada_quests_remaining_today_2",
"2",
{"child_id": 2},
),
],
)
rendered = render(morning_action(morning_summary())["message"], states=states)
lines = rendered.split("\n")
assert lines == [
"Ada owes 1 quest(s) today.",
"Ada owes 2 quest(s) today.",
]
def test_morning_summary_no_present_children() -> None:
"""With nobody present the body says so rather than listing zeros."""
states = make_states(
sensors=[
TemplateState(
"sensor.nestquest_alice_quests_due_today",
"3",
{"child_name": "Alice", "child_id": 1, "present": False},
),
TemplateState(
"sensor.nestquest_alice_quests_remaining_today",
"3",
{"child_id": 1},
),
],
)
rendered = render(morning_action(morning_summary())["message"], states=states)
assert rendered == "No children present today."
def test_morning_summary_send_summary_toggle_renders() -> None:
"""The enable condition renders truthy/false from the input value."""
condition = morning_summary()["conditions"][0]
assert render(condition["value_template"], send_summary=True).strip() == "True"
assert render(condition["value_template"], send_summary=False).strip() == "False"
+187
View File
@@ -0,0 +1,187 @@
"""Entity lifecycle across child add/deactivate/reactivate (Feature 10, task 8)."""
from __future__ import annotations
import datetime
from conftest import wire_entry_to_registry
from custom_components.nestquest import async_setup_entry, async_unload_entry
from custom_components.nestquest.children import (
create_child,
list_children,
set_child_active,
)
from custom_components.nestquest.const import DOMAIN
from custom_components.nestquest.materialize import materialize
from custom_components.nestquest.quest_definitions import (
create_quest_definition,
)
from custom_components.nestquest.recurrence import ScheduleRule
SEED_HORIZON_DAYS = 3
PER_CHILD_KINDS = (
"quests_due_today",
"quests_completed_today",
"quests_remaining_today",
"completion_pct_today",
"next_quest",
"all_done",
"present_today",
)
HOUSEHOLD_UNIQUE_IDS = (
"nestquest_household_quests_due_today",
"nestquest_household_quests_completed_today",
"nestquest_cycle_day",
)
async def _reload(hass, entry) -> None:
"""Unload then set up: the harness's restart/reload equivalent."""
assert await async_unload_entry(hass, entry) is True
assert await async_setup_entry(hass, entry) is True
def _child_unique_ids(child_id: int) -> set[str]:
return {f"nestquest_child_{child_id}_{kind}" for kind in PER_CHILD_KINDS}
async def _setup_and_seed(hass, make_entry):
entry = wire_entry_to_registry(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
database = entry.runtime_data.database
ada = await create_child(database, "Ada")
bo = await create_child(database, "Bo")
today = datetime.date.today()
await create_quest_definition(
database,
"Brush teeth",
ScheduleRule.from_dict(
{"rule_type": "daily", "start_date": today.isoformat()}
),
[ada.id, bo.id],
["morning"],
)
end = (today + datetime.timedelta(days=SEED_HORIZON_DAYS)).isoformat()
await materialize(database, today.isoformat(), end, today=today)
await entry.runtime_data.coordinator.async_refresh()
return entry, ada, bo
async def test_deactivated_child_entities_removed_on_reload(
hass, make_entry
) -> None:
entry, ada, bo = await _setup_and_seed(hass, make_entry)
assert _child_unique_ids(ada.id) <= set(hass.entities)
assert _child_unique_ids(bo.id) <= set(hass.entities)
await set_child_active(entry.runtime_data.database, bo.id, False)
await _reload(hass, entry)
assert _child_unique_ids(ada.id) <= set(hass.entities), (
"the surviving child's entities must be untouched"
)
assert not (_child_unique_ids(bo.id) & set(hass.entities)), (
"the deactivated child's entities must be gone after reload"
)
async def test_reactivation_restores_entities_with_original_unique_ids(
hass, make_entry
) -> None:
entry, ada, bo = await _setup_and_seed(hass, make_entry)
original = _child_unique_ids(bo.id)
await set_child_active(entry.runtime_data.database, bo.id, False)
await _reload(hass, entry)
assert not (original & set(hass.entities))
await set_child_active(entry.runtime_data.database, bo.id, True)
await _reload(hass, entry)
assert original <= set(hass.entities), (
"reactivation must restore the exact original unique_ids"
)
bo_due = hass.entities[f"nestquest_child_{bo.id}_quests_due_today"]
assert bo_due.native_value == 1, "restored entities carry live state"
async def test_new_child_gets_entities_without_disturbing_existing(
hass, make_entry
) -> None:
entry, ada, bo = await _setup_and_seed(hass, make_entry)
before_ada = set(hass.entities) & _child_unique_ids(ada.id)
cory = await create_child(entry.runtime_data.database, "Cory")
await _reload(hass, entry)
assert before_ada <= set(hass.entities), "existing entities untouched"
assert _child_unique_ids(cory.id) <= set(hass.entities), (
"the new child's entities appear after reload"
)
assert hass.entities[f"nestquest_child_{cory.id}_quests_due_today"].native_value == 0
async def test_restart_equivalent_restores_all_entity_states(
hass, make_entry
) -> None:
"""Unload + setup (the restart equivalent) restores every active
child's entities with correct states and the household rollups."""
from custom_components.nestquest.completion import complete_instance
entry, ada, bo = await _setup_and_seed(hass, make_entry)
coordinator = entry.runtime_data.coordinator
snapshot_ada = next(
child
for child in coordinator.data.children
if child.child_id == ada.id
)
await complete_instance(
entry.runtime_data.database,
snapshot_ada.instances[0].instance_id,
actor_source="panel",
actor_child_id=ada.id,
)
await coordinator.async_refresh()
assert hass.entities[
f"nestquest_child_{ada.id}_quests_completed_today"
].native_value == 1
await _reload(hass, entry)
assert hass.entities[
f"nestquest_child_{ada.id}_quests_completed_today"
].native_value == 1, "completed count survives the restart"
assert hass.entities[
f"nestquest_child_{bo.id}_quests_completed_today"
].native_value == 0
assert set(HOUSEHOLD_UNIQUE_IDS) <= set(hass.entities)
assert hass.entities[
"nestquest_household_quests_completed_today"
].native_value == 1
async def test_unload_shuts_coordinator_before_closing_database(
hass, make_entry
) -> None:
"""The coordinator's listeners are cleared while the database is
still connected: a scheduled refresh must never race the close."""
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
seen_connected = []
async def _probe_shutdown():
seen_connected.append(database.connected)
original_close = database.close
async def _recording_close():
seen_connected.append("closing")
await original_close()
coordinator.async_shutdown = _probe_shutdown
database.close = _recording_close
assert await async_unload_entry(hass, entry) is True
assert seen_connected == [True, "closing"], (
"coordinator shutdown must precede the database close"
)
+247
View File
@@ -0,0 +1,247 @@
"""The Feature 10 entity-and-event matrix (task 9).
One fixture drives every assertion the done-condition names: correct
values for every sensor and binary sensor (including the zero-quest
child and the 255-character state limit), the three events firing
exactly once with schema-valid payloads, the instances payload
omitting missed instances while admin_instances includes them, and no
entities for an inactive child. The per-task test files cover each
behavior in depth; this file is the single pass that proves them all
against ONE fixture, so a regression in any contract fails here too.
"""
from __future__ import annotations
import datetime
import re
from conftest import wire_entry_to_registry
from custom_components.nestquest import async_setup_entry
from custom_components.nestquest.children import (
create_child,
list_children,
set_child_active,
)
from custom_components.nestquest.completion import derive_state
from custom_components.nestquest.const import (
CONF_ADMIN_USER_IDS,
DOMAIN,
EVENT_CHILD_DAY_COMPLETE,
EVENT_QUEST_COMPLETED,
EVENT_QUEST_UNCOMPLETED,
SERVICE_COMPLETE_QUEST,
SERVICE_UNCOMPLETE_QUEST,
)
from custom_components.nestquest.dao_instances import QuestInstancesDao
from custom_components.nestquest.materialize import materialize
from custom_components.nestquest.quest_definitions import (
create_quest_definition,
)
from custom_components.nestquest.recurrence import ScheduleRule
_TIMESTAMP = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+00:00$")
async def _fixture(hass, make_entry):
"""Two quest children, one zero-quest child, one inactive child."""
entry = wire_entry_to_registry(
make_entry(data={CONF_ADMIN_USER_IDS: ["admin-1"]}), hass.registry
)
assert await async_setup_entry(hass, entry) is True
database = entry.runtime_data.database
ada = await create_child(database, "Ada")
bo = await create_child(database, "Bo")
cory = await create_child(database, "Cory")
ghost = await create_child(database, "Ghost")
await set_child_active(database, ghost.id, False)
today = datetime.date.today()
await create_quest_definition(
database,
"Brush teeth",
ScheduleRule.from_dict(
{"rule_type": "daily", "start_date": today.isoformat()}
),
[ada.id, bo.id],
["morning", "evening"],
)
end = (today + datetime.timedelta(days=3)).isoformat()
await materialize(database, today.isoformat(), end, today=today)
await entry.runtime_data.coordinator.async_refresh()
return entry, (ada, bo, cory, ghost)
def _sensor(hass, child_id, kind):
return hass.entities[f"{DOMAIN}_child_{child_id}_{kind}"]
async def test_entity_and_event_matrix(hass, make_entry) -> None:
entry, (ada, bo, cory, ghost) = await _fixture(hass, make_entry)
coordinator = entry.runtime_data.coordinator
# --- Every active child has the full entity set; the inactive
# child has none.
kinds = (
"quests_due_today",
"quests_completed_today",
"quests_remaining_today",
"completion_pct_today",
"next_quest",
"all_done",
"present_today",
)
for child in (ada, bo, cory):
for kind in kinds:
assert f"{DOMAIN}_child_{child.id}_{kind}" in hass.entities, kind
for kind in kinds:
assert f"{DOMAIN}_child_{ghost.id}_{kind}" not in hass.entities, (
"inactive children must have no entities"
)
# --- Correct values against the fixture: Ada and Bo owe two
# quests (morning + evening), Cory owes none and is still 100%.
for child in (ada, bo):
assert _sensor(hass, child.id, "quests_due_today").native_value == 2
assert (
_sensor(hass, child.id, "quests_completed_today").native_value == 0
)
assert (
_sensor(hass, child.id, "quests_remaining_today").native_value == 2
)
assert (
_sensor(hass, child.id, "completion_pct_today").native_value == 0
)
assert _sensor(hass, child.id, "next_quest").native_value == (
"Brush teeth"
)
assert _sensor(hass, child.id, "all_done").is_on is False
assert _sensor(hass, child.id, "present_today").is_on is True
assert _sensor(hass, cory.id, "quests_due_today").native_value == 0
assert _sensor(hass, cory.id, "completion_pct_today").native_value == 100
assert _sensor(hass, cory.id, "all_done").is_on is False
assert _sensor(hass, cory.id, "next_quest").native_value == "none"
# --- Household rollups equal the per-child sums.
assert hass.entities[
"nestquest_household_quests_due_today"
].native_value == 4
assert hass.entities[
"nestquest_household_quests_completed_today"
].native_value == 0
# --- The 255-character limit holds for every registered entity.
for unique_id, entity in hass.entities.items():
state = getattr(entity, "native_value", None)
if state is None:
state = getattr(entity, "is_on", None)
assert state is None or not (
isinstance(state, str) and len(state) > 255
), unique_id
# --- Completing both of Ada's quests fires the events exactly
# once each and flips her entities immediately.
instances = await QuestInstancesDao(
entry.runtime_data.database
).list_by_date_range(
ada.id, datetime.date.today().isoformat(), datetime.date.today().isoformat()
)
for instance in instances:
await hass.services.call(
DOMAIN,
SERVICE_COMPLETE_QUEST,
{
"instance_id": instance.id,
"actor": "panel",
"actor_child_id": ada.id,
},
)
assert len(hass.bus.fired(EVENT_QUEST_COMPLETED)) == 2
for payload in hass.bus.fired(EVENT_QUEST_COMPLETED):
assert _TIMESTAMP.match(payload["occurred_at"])
assert isinstance(payload["was_on_time"], bool)
assert payload["child_name"] in ("Ada",)
assert payload["quest_title"] == "Brush teeth"
assert isinstance(payload["instance_id"], int)
assert len(hass.bus.fired(EVENT_CHILD_DAY_COMPLETE)) == 1
assert _sensor(hass, ada.id, "all_done").is_on is True
assert _sensor(hass, ada.id, "quests_remaining_today").native_value == 0
assert (
_sensor(hass, ada.id, "completion_pct_today").native_value == 100
)
# --- Reversing one quest fires the uncompleted event exactly once
# and restores the owed counts.
await hass.services.call(
DOMAIN,
SERVICE_UNCOMPLETE_QUEST,
{"instance_id": instances[0].id, "actor": "user"},
context={"user_id": "admin-1"},
)
assert len(hass.bus.fired(EVENT_QUEST_UNCOMPLETED)) == 1
assert _sensor(hass, ada.id, "all_done").is_on is False
assert _sensor(hass, ada.id, "quests_remaining_today").native_value == 1
# --- The instances payload omits missed instances while
# admin_instances includes them (D-009): pin the derive clock
# forward in the TEST ONLY so today's open instances read missed.
original_refresh = coordinator._async_update_data
async def _future_dated_refresh():
import custom_components.nestquest.coordinator as coordinator_module
real_derive = coordinator_module.derive_state
def _derive(instance, latest, today):
return real_derive(instance, latest, today + datetime.timedelta(days=1))
coordinator_module.derive_state = _derive
try:
return await original_refresh()
finally:
coordinator_module.derive_state = real_derive
coordinator._async_update_data = _future_dated_refresh
await coordinator.async_refresh()
attributes = _sensor(hass, bo.id, "quests_due_today").extra_state_attributes
assert attributes["instances"] == [], "panel payload omits missed"
assert len(attributes["admin_instances"]) == 2
assert all(
entry["state"] == "missed" for entry in attributes["admin_instances"]
)
coordinator._async_update_data = original_refresh
await coordinator.async_refresh()
async def test_payload_schema_matches_the_documented_contract(
hass, make_entry
) -> None:
"""Every fired payload carries the documented fields with the
documented types."""
entry, (ada, _bo, _cory, _ghost) = await _fixture(hass, make_entry)
instances = await QuestInstancesDao(
entry.runtime_data.database
).list_by_date_range(
ada.id, datetime.date.today().isoformat(), datetime.date.today().isoformat()
)
await hass.services.call(
DOMAIN,
SERVICE_COMPLETE_QUEST,
{
"instance_id": instances[0].id,
"actor": "panel",
"actor_child_id": ada.id,
},
)
payload = hass.bus.fired(EVENT_QUEST_COMPLETED)[0]
for key, check in (
("child_id", lambda v: isinstance(v, int)),
("child_name", lambda v: isinstance(v, str)),
("instance_id", lambda v: isinstance(v, int)),
("quest_title", lambda v: isinstance(v, str)),
("window", lambda v: v in ("morning", "evening")),
("due_date", lambda v: isinstance(v, str)),
("due_time", lambda v: v is None or isinstance(v, str)),
("occurred_at", lambda v: bool(_TIMESTAMP.match(v))),
("was_on_time", lambda v: isinstance(v, bool)),
):
assert key in payload, key
assert check(payload[key]), key
+4 -1
View File
@@ -10,6 +10,7 @@ from unittest.mock import AsyncMock
import pytest
from custom_components.nestquest.db import NestQuestDatabase
from custom_components.nestquest.schema import SCHEMA_V7_META_STATE_DDL
from custom_components.nestquest.migrations import (
MIGRATIONS,
VERSION_TABLE,
@@ -110,6 +111,7 @@ def test_fresh_file_migration_creates_all_v1_tables(tmp_path) -> None:
"presence_overrides",
"quest_instances",
"completion_events",
"nestquest_meta_state",
VERSION_TABLE,
}
assert _tables(database) == expected
@@ -159,7 +161,8 @@ def test_migration_list_shape() -> None:
assert MIGRATIONS[3] == SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL
assert callable(MIGRATIONS[4])
assert callable(MIGRATIONS[5])
assert len(MIGRATIONS) == 6
assert MIGRATIONS[6] == SCHEMA_V7_META_STATE_DDL
assert len(MIGRATIONS) == 7
# ---------------------------------------------------------------------------
+168 -18
View File
@@ -19,6 +19,18 @@ from custom_components.nestquest.const import (
CONF_HORIZON_DAYS,
CONF_PANEL_IDLE_TIMEOUT,
CONF_UPDATE_INTERVAL,
CONF_AFTERNOON_REMINDER_ENABLED,
CONF_AFTERNOON_REMINDER_TIME,
CONF_CELEBRATION_ENABLED,
CONF_END_OF_DAY_REPORT_ENABLED,
CONF_END_OF_DAY_REPORT_TIME,
CONF_MORNING_SUMMARY_ENABLED,
CONF_MORNING_SUMMARY_TIME,
CONF_NOTIFY_TARGET,
DEFAULT_AFTERNOON_REMINDER_TIME,
DEFAULT_AUTOMATION_ENABLED,
DEFAULT_END_OF_DAY_REPORT_TIME,
DEFAULT_MORNING_SUMMARY_TIME,
DEFAULT_UPDATE_INTERVAL,
DEFAULT_DAY_ROLLOVER_TIME,
DEFAULT_HORIZON_DAYS,
@@ -36,6 +48,22 @@ VALID_INPUT = {
CONF_PANEL_IDLE_TIMEOUT: 60,
}
#: What a schema-validated submit produces from a bare VALID_INPUT on a
#: fresh entry: the Feature 10 refresh interval and the Feature 11
#: notification section fill from their defaults.
FULL_INPUT = {
**VALID_INPUT,
CONF_UPDATE_INTERVAL: DEFAULT_UPDATE_INTERVAL,
CONF_NOTIFY_TARGET: "",
CONF_MORNING_SUMMARY_TIME: DEFAULT_MORNING_SUMMARY_TIME,
CONF_AFTERNOON_REMINDER_TIME: DEFAULT_AFTERNOON_REMINDER_TIME,
CONF_END_OF_DAY_REPORT_TIME: DEFAULT_END_OF_DAY_REPORT_TIME,
CONF_MORNING_SUMMARY_ENABLED: DEFAULT_AUTOMATION_ENABLED,
CONF_AFTERNOON_REMINDER_ENABLED: DEFAULT_AUTOMATION_ENABLED,
CONF_END_OF_DAY_REPORT_ENABLED: DEFAULT_AUTOMATION_ENABLED,
CONF_CELEBRATION_ENABLED: DEFAULT_AUTOMATION_ENABLED,
}
def _run(coro):
return asyncio.new_event_loop().run_until_complete(coro)
@@ -131,10 +159,7 @@ def test_options_flow_submits_through_2024_6_surface() -> None:
result = _run(handler.async_step_init(dict(VALID_INPUT)))
assert result["type"] == "create_entry"
assert result["title"] == "NestQuest"
assert result["data"] == {
**VALID_INPUT,
CONF_UPDATE_INTERVAL: DEFAULT_UPDATE_INTERVAL,
}
assert result["data"] == FULL_INPUT
def test_options_flow_shows_form_initially() -> None:
@@ -214,6 +239,10 @@ def test_options_flow_schema_applies_stored_defaults() -> None:
CONF_DAY_ROLLOVER_TIME: "05:45",
CONF_PANEL_IDLE_TIMEOUT: 120,
CONF_UPDATE_INTERVAL: 90,
CONF_NOTIFY_TARGET: "notify.mobile_app_test",
CONF_MORNING_SUMMARY_TIME: "07:00",
CONF_AFTERNOON_REMINDER_TIME: "14:00",
CONF_END_OF_DAY_REPORT_TIME: "21:30",
}
)
result = _run(_make_flow(entry).async_step_init(None))
@@ -222,6 +251,14 @@ def test_options_flow_schema_applies_stored_defaults() -> None:
CONF_DAY_ROLLOVER_TIME: "05:45",
CONF_PANEL_IDLE_TIMEOUT: 120,
CONF_UPDATE_INTERVAL: 90,
CONF_NOTIFY_TARGET: "notify.mobile_app_test",
CONF_MORNING_SUMMARY_TIME: "07:00",
CONF_AFTERNOON_REMINDER_TIME: "14:00",
CONF_END_OF_DAY_REPORT_TIME: "21:30",
CONF_MORNING_SUMMARY_ENABLED: True,
CONF_AFTERNOON_REMINDER_ENABLED: True,
CONF_END_OF_DAY_REPORT_ENABLED: True,
CONF_CELEBRATION_ENABLED: True,
CONF_ADMIN_USER_IDS: [],
}
@@ -295,16 +332,8 @@ def test_options_flow_creates_entry_with_four_keys() -> None:
result = _run(flow.async_step_init(dict(VALID_INPUT)))
assert result["type"] == "create_entry"
assert result["title"] == "NestQuest"
assert set(result["data"]) == {
CONF_HORIZON_DAYS,
CONF_DAY_ROLLOVER_TIME,
CONF_PANEL_IDLE_TIMEOUT,
CONF_UPDATE_INTERVAL,
}
assert result["data"] == {
**VALID_INPUT,
CONF_UPDATE_INTERVAL: DEFAULT_UPDATE_INTERVAL,
}
assert set(result["data"]) == set(FULL_INPUT)
assert result["data"] == FULL_INPUT
def test_options_flow_create_entry_data_lands_in_entry_options_and_reloads_once() -> None:
@@ -327,10 +356,7 @@ def test_options_flow_create_entry_data_lands_in_entry_options_and_reloads_once(
_run(registry.dispatch_options_update(entry))
assert hass.config_entries.async_reload.call_count == 1
assert registry.reloaded == [entry.entry_id]
assert entry.options == {
**VALID_INPUT,
CONF_UPDATE_INTERVAL: DEFAULT_UPDATE_INTERVAL,
}
assert entry.options == FULL_INPUT
def test_options_flow_validation_never_raises() -> None:
@@ -649,3 +675,127 @@ def test_options_flow_omitted_picker_carries_allowlist_forward(
assert result["data"][CONF_HORIZON_DAYS] == VALID_INPUT[CONF_HORIZON_DAYS]
finally:
_run(database.close())
def test_options_flow_notification_section_defaults() -> None:
"""A bare submit fills the notification section from const defaults:
08:00 / 15:00 / 20:00 — the report deliberately BEFORE the midnight
rollover sweep — an empty notify target, and all toggles enabled."""
flow = _make_flow(_make_entry())
result = _run(flow.async_step_init(dict(VALID_INPUT)))
assert result["type"] == "create_entry"
assert result["data"][CONF_MORNING_SUMMARY_TIME] == "08:00"
assert result["data"][CONF_AFTERNOON_REMINDER_TIME] == "15:00"
assert result["data"][CONF_END_OF_DAY_REPORT_TIME] == "20:00"
assert result["data"][CONF_NOTIFY_TARGET] == ""
for toggle in (
CONF_MORNING_SUMMARY_ENABLED,
CONF_AFTERNOON_REMINDER_ENABLED,
CONF_END_OF_DAY_REPORT_ENABLED,
CONF_CELEBRATION_ENABLED,
):
assert result["data"][toggle] is True
def test_options_flow_notification_times_must_be_strict_hh_mm() -> None:
flow = _make_flow(_make_entry())
for bad_time in ("9:00", "25:00", "08:0", "08-00", 800):
result = _run(
flow.async_step_init(
{
**VALID_INPUT,
CONF_MORNING_SUMMARY_TIME: bad_time,
}
)
)
assert result["type"] == "form"
assert result["errors"] == {CONF_MORNING_SUMMARY_TIME: "invalid_time"}
def test_options_flow_notify_target_validation() -> None:
"""The empty target is VALID (it is the shipped default and must be
savable); only malformed non-empty values reject."""
flow = _make_flow(_make_entry())
# The empty default saves cleanly.
result = _run(
flow.async_step_init(
{**VALID_INPUT, CONF_NOTIFY_TARGET: ""}
)
)
assert result["type"] == "create_entry"
assert result["data"][CONF_NOTIFY_TARGET] == ""
# Malformed non-empty values reject.
for bad_target in (" notify.x ", " ", 42):
flow = _make_flow(_make_entry())
result = _run(
flow.async_step_init(
{
**VALID_INPUT,
CONF_NOTIFY_TARGET: bad_target,
}
)
)
assert result["type"] == "form"
assert result["errors"] == {CONF_NOTIFY_TARGET: "invalid"}
def test_options_flow_toggles_must_be_real_booleans() -> None:
flow = _make_flow(_make_entry())
result = _run(
flow.async_step_init(
{
**VALID_INPUT,
CONF_CELEBRATION_ENABLED: "on",
}
)
)
assert result["type"] == "form"
assert result["errors"] == {CONF_CELEBRATION_ENABLED: "invalid"}
def test_options_flow_notification_stored_values_round_trip() -> None:
"""Stored notification settings are the form's defaults and a raw
submit without them keeps the stored values."""
entry = _make_entry(
options={
**VALID_INPUT,
CONF_UPDATE_INTERVAL: 90,
CONF_NOTIFY_TARGET: "notify.mobile_app_dad",
CONF_MORNING_SUMMARY_TIME: "07:15",
CONF_AFTERNOON_REMINDER_TIME: "16:45",
CONF_END_OF_DAY_REPORT_TIME: "21:00",
CONF_MORNING_SUMMARY_ENABLED: False,
CONF_AFTERNOON_REMINDER_ENABLED: True,
CONF_END_OF_DAY_REPORT_ENABLED: False,
CONF_CELEBRATION_ENABLED: True,
}
)
flow = _make_flow(entry)
form = _run(flow.async_step_init(None))
schema_defaults = form["data_schema"]({})
assert schema_defaults[CONF_NOTIFY_TARGET] == "notify.mobile_app_dad"
assert schema_defaults[CONF_MORNING_SUMMARY_TIME] == "07:15"
assert schema_defaults[CONF_END_OF_DAY_REPORT_ENABLED] is False
# A raw submit carrying only the legacy keys keeps the stored
# notification settings.
result = _run(flow.async_step_init(dict(VALID_INPUT)))
assert result["type"] == "create_entry"
assert result["data"][CONF_NOTIFY_TARGET] == "notify.mobile_app_dad"
assert result["data"][CONF_MORNING_SUMMARY_TIME] == "07:15"
assert result["data"][CONF_MORNING_SUMMARY_ENABLED] is False
assert result["data"][CONF_CELEBRATION_ENABLED] is True
def test_options_flow_no_hard_coded_personal_target_in_source() -> None:
"""The notify target's DEFAULT is the empty string: the household
fills their own service name, and no personal target ships as a
value anywhere in the const/flow defaults."""
from custom_components.nestquest import const
assert getattr(const, "CONF_NOTIFY_TARGET", None) == "notify_target"
# No const default carries a concrete notify service.
assert const.__dict__.get("DEFAULT_NOTIFY_TARGET", "") == ""
form = _run(_make_flow(_make_entry()).async_step_init(None))
assert form["data_schema"]({})[CONF_NOTIFY_TARGET] == ""
+3 -1
View File
@@ -11,6 +11,7 @@ from custom_components.nestquest.schema import (
SCHEMA_V1_ADMIN_USERS_DDL,
SCHEMA_V1_CHILDREN_DDL,
SCHEMA_V1_COMPLETION_EVENTS_DDL,
SCHEMA_V7_META_STATE_DDL,
SCHEMA_V1_PRESENCE_OVERRIDES_DDL,
SCHEMA_V1_PRESENCE_SCHEDULES_DDL,
SCHEMA_V1_SCHEDULE_RULES_DDL,
@@ -168,7 +169,7 @@ def _insert_override(database, child_id, **overrides):
# ---------------------------------------------------------------------------
def test_schema_v1_statements_compose_the_eight_tables() -> None:
def test_schema_v1_statements_compose_the_nine_tables() -> None:
assert SCHEMA_V1_STATEMENTS == [
*SCHEMA_V1_CHILDREN_DDL,
*SCHEMA_V1_ADMIN_USERS_DDL,
@@ -180,6 +181,7 @@ def test_schema_v1_statements_compose_the_eight_tables() -> None:
*SCHEMA_V1_PRESENCE_OVERRIDES_DDL,
*SCHEMA_V1_QUEST_INSTANCES_DDL,
*SCHEMA_V1_COMPLETION_EVENTS_DDL,
*SCHEMA_V7_META_STATE_DDL,
]
+259
View File
@@ -0,0 +1,259 @@
"""Tests for the nightly missed-quest sweep (Feature 11, task 1)."""
from __future__ import annotations
import datetime
import re
from conftest import wire_entry_to_registry
from custom_components.nestquest import async_setup_entry
from custom_components.nestquest.children import create_child, list_children
from custom_components.nestquest.completion import (
complete_instance,
uncomplete_instance,
)
from custom_components.nestquest.const import EVENT_QUEST_MISSED
from custom_components.nestquest.dao_instances import (
CompletionEventsDao,
QuestInstancesDao,
)
from custom_components.nestquest.dao_meta import MetaStateDao
from custom_components.nestquest.materialize import materialize
from custom_components.nestquest.quest_definitions import (
create_quest_definition,
)
from custom_components.nestquest.recurrence import ScheduleRule
from custom_components.nestquest.sweep import (
SWEEP_WATERMARK_KEY,
run_missed_sweep,
)
_TIMESTAMP = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+00:00$")
async def _seed(hass, make_entry) -> tuple:
entry = wire_entry_to_registry(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
database = entry.runtime_data.database
ada = await create_child(database, "Ada")
bo = await create_child(database, "Bo")
today = datetime.date.today()
await create_quest_definition(
database,
"Brush teeth",
ScheduleRule.from_dict(
{"rule_type": "daily", "start_date": today.isoformat()}
),
[ada.id, bo.id],
["morning"],
)
end = (today + datetime.timedelta(days=3)).isoformat()
await materialize(database, today.isoformat(), end, today=today)
return entry, ada, bo
async def test_sweep_fires_one_event_per_past_due_open_instance(
hass, make_entry
) -> None:
entry, ada, bo = await _seed(hass, make_entry)
database = entry.runtime_data.database
today = datetime.date.today()
tomorrow = today + datetime.timedelta(days=1)
fired = await run_missed_sweep(hass, database, today=tomorrow)
assert fired == 2, "one per child for today's now-past-due instance"
events = hass.bus.fired(EVENT_QUEST_MISSED)
assert len(events) == 2
payload = events[0]
assert payload["child_name"] in ("Ada", "Bo")
assert payload["quest_title"] == "Brush teeth"
assert payload["window"] == "morning"
assert payload["due_date"] == today.isoformat()
assert payload["due_time"] is None
assert _TIMESTAMP.match(payload["occurred_at"])
assert {payload["child_id"] for payload in events} == {ada.id, bo.id}
# The sweep is read-only over the domain tables.
assert await _count_events(database) == 0
async def test_sweep_is_idempotent_same_night(hass, make_entry) -> None:
entry, _ada, _bo = await _seed(hass, make_entry)
database = entry.runtime_data.database
tomorrow = datetime.date.today() + datetime.timedelta(days=1)
first = await run_missed_sweep(hass, database, today=tomorrow)
second = await run_missed_sweep(hass, database, today=tomorrow)
assert first == 2
assert second == 0, "the watermark makes a rerun fire nothing"
assert len(hass.bus.fired(EVENT_QUEST_MISSED)) == 2
assert await MetaStateDao(database).get(SWEEP_WATERMARK_KEY) == (
tomorrow.isoformat()
)
async def test_sweep_only_fires_new_instances_after_watermark(
hass, make_entry
) -> None:
entry, ada, _bo = await _seed(hass, make_entry)
database = entry.runtime_data.database
today = datetime.date.today()
tomorrow = today + datetime.timedelta(days=1)
day_after = today + datetime.timedelta(days=2)
# First sweep one day ahead: today's instances fire.
fired = await run_missed_sweep(hass, database, today=tomorrow)
assert fired == 2
first_events = hass.bus.fired(EVENT_QUEST_MISSED)
assert all(
payload["due_date"] == today.isoformat()
for payload in first_events
)
# One more day passes: only tomorrow's instances fire now.
fired = await run_missed_sweep(hass, database, today=day_after)
assert fired == 2
all_events = hass.bus.fired(EVENT_QUEST_MISSED)
assert len(all_events) == 4
assert all(
payload["due_date"] == tomorrow.isoformat()
for payload in all_events[2:]
)
async def test_sweep_never_reports_completed_or_reversed_instances(
hass, make_entry
) -> None:
entry, ada, bo = await _seed(hass, make_entry)
database = entry.runtime_data.database
today = datetime.date.today()
tomorrow = today + datetime.timedelta(days=1)
instances_ada = await QuestInstancesDao(database).list_by_date_range(
ada.id, today.isoformat(), today.isoformat()
)
instances_bo = await QuestInstancesDao(database).list_by_date_range(
bo.id, today.isoformat(), today.isoformat()
)
# Ada completed hers; Bo completed his then reversed it.
await complete_instance(
database,
instances_ada[0].id,
actor_source="panel",
actor_child_id=ada.id,
)
await complete_instance(
database,
instances_bo[0].id,
actor_source="panel",
actor_child_id=bo.id,
)
await uncomplete_instance(
database,
instances_bo[0].id,
actor_source="user",
actor_user_id="admin-1",
)
fired = await run_missed_sweep(hass, database, today=tomorrow)
assert fired == 0, (
"no-completion-event rule excludes both the completed and the "
"reversed instance (append-only history keeps both)"
)
assert hass.bus.fired(EVENT_QUEST_MISSED) == []
async def test_sweep_skips_instance_completed_after_sweep(
hass, make_entry
) -> None:
"""A missed instance completed later is never re-announced; the
late completion still lands in the append-only log."""
entry, ada, _bo = await _seed(hass, make_entry)
database = entry.runtime_data.database
today = datetime.date.today()
tomorrow = today + datetime.timedelta(days=1)
day3 = today + datetime.timedelta(days=2)
await run_missed_sweep(hass, database, today=tomorrow)
instance = (
await QuestInstancesDao(database).list_by_date_range(
ada.id, today.isoformat(), today.isoformat()
)
)[0]
await complete_instance(
database,
instance.id,
actor_source="panel",
actor_child_id=ada.id,
)
fired = await run_missed_sweep(hass, database, today=day3)
assert fired == 0 or all(
payload["instance_id"] != instance.id
for payload in hass.bus.fired(EVENT_QUEST_MISSED)[2:]
)
async def test_rollover_listener_runs_sweep_after_materialization(
hass, make_entry, monkeypatch
) -> None:
import custom_components.nestquest as nestquest
calls = []
async def _record_sweep(hass_arg, database, **kwargs):
calls.append("sweep")
monkeypatch.setattr(nestquest, "run_missed_sweep", _record_sweep)
entry = wire_entry_to_registry(
make_entry(options={"day_rollover_time": "00:00"}), hass.registry
)
assert await async_setup_entry(hass, entry) is True
assert calls == ["sweep"], "startup runs the sweep once"
await hass.time_change.fire()
assert calls == ["sweep", "sweep"], "the rollover listener sweeps too"
async def test_v6_database_migrates_to_v7_meta_state(
hass, make_entry
) -> None:
"""A database stamped at version 6 gains the meta_state table and
version 7 on the next startup; existing rows are untouched."""
entry = wire_entry_to_registry(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
database = entry.runtime_data.database
version = await database.fetch_one(
"SELECT version FROM nestquest_schema_version"
)
assert version == (7,)
tables = await database.fetch_all(
"SELECT name FROM sqlite_master WHERE type='table' "
"AND name='nestquest_meta_state'"
)
assert tables, "migration 7 created the meta_state table"
async def _count_events(database) -> int:
rows = await database.fetch_all("SELECT COUNT(*) FROM completion_events")
return rows[0][0]
async def test_overlapping_sweeps_never_double_announce(
hass, make_entry
) -> None:
"""A startup sweep and a rollover sweep racing on the same night
queue on the sweep lock: the winner announces, the loser re-reads
the watermark inside the lock and fires nothing."""
import asyncio
entry, _ada, _bo = await _seed(hass, make_entry)
database = entry.runtime_data.database
tomorrow = datetime.date.today() + datetime.timedelta(days=1)
first, second = await asyncio.gather(
run_missed_sweep(hass, database, today=tomorrow),
run_missed_sweep(hass, database, today=tomorrow),
)
assert first + second == 2, "each instance announced exactly once"
assert len(hass.bus.fired(EVENT_QUEST_MISSED)) == 2
Generated
+145
View File
@@ -20,6 +20,92 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "jinja2"
version = "3.1.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
]
[[package]]
name = "markupsafe"
version = "3.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" },
{ url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" },
{ url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" },
{ url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" },
{ url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" },
{ url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" },
{ url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" },
{ url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" },
{ url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" },
{ url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" },
{ url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" },
{ url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" },
{ url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" },
{ url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" },
{ url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" },
{ url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" },
{ url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" },
{ url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" },
{ url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" },
{ url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" },
{ url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" },
{ url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" },
{ url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" },
{ url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" },
{ url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" },
{ url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" },
{ url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" },
{ url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" },
{ url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" },
{ url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" },
{ url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" },
{ url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" },
{ url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" },
{ url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" },
{ url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" },
{ url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" },
{ url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" },
{ url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" },
{ url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" },
{ url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" },
{ url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" },
{ url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" },
{ url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" },
{ url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
{ url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
{ url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
{ url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
{ url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
{ url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
{ url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
{ url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
{ url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
{ url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
{ url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
{ url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
{ url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
{ url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
{ url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
{ url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
{ url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
{ url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
{ url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
{ url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
{ url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
]
[[package]]
name = "nestquest"
version = "0.3.0"
@@ -27,8 +113,10 @@ source = { virtual = "." }
[package.dev-dependencies]
dev = [
{ name = "jinja2" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pyyaml" },
{ name = "voluptuous" },
]
@@ -36,8 +124,10 @@ dev = [
[package.metadata.requires-dev]
dev = [
{ name = "jinja2", specifier = ">=3" },
{ name = "pytest", specifier = ">=8.0.0" },
{ name = "pytest-asyncio", specifier = ">=0.24" },
{ name = "pyyaml", specifier = ">=6" },
{ name = "voluptuous", specifier = ">=0.14" },
]
@@ -97,6 +187,61 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" },
{ url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" },
{ url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" },
{ url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" },
{ url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" },
{ url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" },
{ url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" },
{ url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" },
{ url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" },
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
{ 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 = "typing-extensions"
version = "4.16.0"