Compare commits

...
Author SHA1 Message Date
orchestrator-agent 715aac994a Nest notify payload fields under the action's data key
The modern HA action schema passes title and message to the notify
service only under data; fix the morning summary and afternoon
reminder blueprints and the test accessors accordingly.
2026-09-19 14:45:50 -04:00
orchestrator-agent ad6f1768d7 Add the afternoon open-quests reminder blueprint
Lists only present children who still owe quests; sends nothing at
all when every present child is clear (a false template condition
stops the action). Blueprint test helpers move to a shared module.
2026-09-19 14:42:50 -04:00
orchestrator-agent fcc7a29f05 Add the morning summary blueprint 2026-09-19 14:31:02 -04:00
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
10 changed files with 1403 additions and 19 deletions
@@ -0,0 +1,90 @@
blueprint:
name: NestQuest afternoon reminder
description: >-
Sends an afternoon nudge listing ONLY the children who still have
open quests today. Children who are present and all clear are
omitted (the nudge is for stragglers, not a roll call), children
who are not present today are omitted entirely, and the household
rollup sensors are excluded by the same present guard. When every
present child is already done the automation sends NOTHING at all:
a template condition stops the run before the notification. The
notify service is chosen by the household when importing this
blueprint — NestQuest never hard-codes a personal notify target.
domain: automation
input:
afternoon_time:
name: Afternoon time
description: Local time of day the reminder is sent.
default: "15:00"
selector:
time: {}
notify_target:
name: Notify service
description: >-
The Home Assistant notify service to call for the reminder,
for example notify.mobile_app_your_phone — a placeholder;
pick the notify service for your own device.
selector:
text: {}
send_reminder:
name: Send the reminder
description: >-
Turn off to keep the automation installed but skip the
notification entirely.
default: true
selector:
boolean: {}
mode: single
variables:
send_reminder: !input send_reminder
triggers:
- trigger: time
at: !input afternoon_time
conditions:
- condition: template
value_template: "{{ send_reminder | bool }}"
# Send nothing when every present child is already done: the guard is
# false unless at least one present child has remaining quests today.
- condition: template
value_template: |
{%- set ns = namespace(any_open=false) -%}
{%- 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 and (remaining_values | first | int) > 0 -%}
{%- set ns.any_open = true -%}
{%- endif -%}
{%- endif -%}
{%- endfor -%}
{{- ns.any_open -}}
actions:
- action: !input notify_target
data:
title: NestQuest afternoon reminder
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 and (remaining_values | first | int) > 0 -%}
{%- set line = due.attributes.child_name ~ ' still has ' ~ (remaining_values | first | int) ~ ' quest(s) open.' -%}
{%- set ns.lines = ns.lines + [line] -%}
{%- endif -%}
{%- endif -%}
{%- endfor -%}
{{- ns.lines | join('\n') -}}
@@ -0,0 +1,78 @@
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
data:
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 -%}
+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
+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]
+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",
]
+206
View File
@@ -0,0 +1,206 @@
"""Shared harness for the NestQuest automation-blueprint tests.
The blueprints are YAML data consumed by Home Assistant's blueprint
engine; the integration itself never imports yaml or jinja2. The
blueprint tests therefore exercise them exactly the way HA would:
parse the YAML (with a loader that understands ``!input`` tags), then
render the message/condition templates 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`` — plus, for the celebration blueprint, a callable
``states(entity_id)`` lookup over ``input_datetime`` helpers. Jinja's
``search``/``match`` selectattr tests are Home Assistant additions,
registered here explicitly.
This module is plain helper code (deliberately not named ``test_*`` so
pytest never collects it); every blueprint test file imports from it.
"""
from __future__ import annotations
import re
from pathlib import Path
import jinja2
import yaml
REPO_ROOT = Path(__file__).parent.parent
BLUEPRINT_DIR = (
REPO_ROOT / "custom_components" / "nestquest" / "blueprints" / "automation"
)
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 blueprint(name: str) -> dict:
"""Return one parsed blueprint by its file stem."""
blueprints = load_blueprints()
assert name in blueprints, f"{name}.yaml is missing"
return blueprints[name]
def morning_summary() -> dict:
"""Return the parsed morning-summary blueprint."""
return blueprint("morning_summary")
def single_action(bp: dict) -> dict:
"""Return the single action of a one-action blueprint."""
actions = bp["actions"]
assert isinstance(actions, list) and len(actions) == 1
return actions[0]
def morning_action(bp: dict) -> dict:
"""Return the single notify action of the morning summary."""
return single_action(bp)
def notify_data(bp: dict, index: int = 0) -> dict:
"""Return a notify action's service ``data`` (title/message live
there in the modern HA action schema, not at the action's top level)."""
action = bp["actions"][index]
assert isinstance(action.get("data"), dict), (
"notify payload fields must nest under data:"
)
return action["data"]
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)
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 {})
class StatesFixture:
"""HA's template ``states`` object over a fixture entity set.
Like HA's ``AllStates``, it exposes per-domain lists (``.sensor``,
``.binary_sensor``, ``.input_datetime``, ``.input_text``) AND is
callable as ``states(entity_id)`` returning the entity's state
string — or ``None`` when the fixture holds no such entity, the
way HA returns ``None`` for a nonexistent entity id.
"""
def __init__(
self, binary_sensors=(), sensors=(), input_datetimes=(), input_texts=()
):
self.binary_sensor = list(binary_sensors)
self.sensor = list(sensors)
self.input_datetime = list(input_datetimes)
self.input_text = list(input_texts)
self._all = (
self.binary_sensor
+ self.sensor
+ self.input_datetime
+ self.input_text
)
def __call__(self, entity_id: str):
"""Return the entity's state string, or None when absent."""
for entity in self._all:
if entity.entity_id == entity_id:
return entity.state
return None
def make_states(
binary_sensors=(), sensors=(), input_datetimes=(), input_texts=()
) -> StatesFixture:
"""Build the ``states`` fixture: per-domain lists of TemplateState."""
return StatesFixture(
binary_sensors, sensors, input_datetimes, input_texts
)
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)
# ---------------------------------------------------------------------------
# Entity-reference discipline (Feature 10 shapes only). The scanners
# are shared so every blueprint test file can assert the same rules;
# the directory-wide tests in test_blueprints.py apply them to every
# blueprint file automatically.
# ---------------------------------------------------------------------------
#: 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_]+)")
+244
View File
@@ -0,0 +1,244 @@
"""Tests for the NestQuest afternoon-reminder automation blueprint.
The afternoon reminder nudges only the stragglers: a child appears
exactly when they are PRESENT today and still have open quests.
Present-and-clear children, absent children, and the household rollup
sensors are all omitted, and when nobody qualifies the automation
sends nothing at all — the all-clear template condition stops the run
before the notification. The harness (YAML ``!input`` loader, state
fixtures, jinja rendering) is shared in
:mod:`tests.blueprint_helpers`; the directory-wide shape and
entity-discipline tests in test_blueprints.py cover this file too.
"""
from __future__ import annotations
import re
from custom_components.nestquest.const import DEFAULT_AFTERNOON_REMINDER_TIME
from tests.blueprint_helpers import (
notify_data,
BlueprintInput,
blueprint,
iter_strings,
make_states,
render,
single_action,
TemplateState,
)
AFTERNOON_TITLE = "NestQuest afternoon reminder"
def afternoon_reminder() -> dict:
"""Return the parsed afternoon-reminder blueprint."""
return blueprint("afternoon_reminder")
def afternoon_conditions(bp: dict) -> list[dict]:
"""Return the blueprint's conditions: toggle first, guard second."""
conditions = bp["conditions"]
assert isinstance(conditions, list) and len(conditions) == 2
return conditions
def all_clear_guard(bp: dict) -> str:
"""Return the template that is false when every present child is done."""
return afternoon_conditions(bp)[1]["value_template"]
# ---------------------------------------------------------------------------
# Inputs, defaults, and wiring.
# ---------------------------------------------------------------------------
def test_afternoon_reminder_declares_inputs_with_selectors() -> None:
"""The three inputs exist, carry selectors, and default sanely."""
inputs = afternoon_reminder()["blueprint"]["input"]
expected = {"afternoon_time", "notify_target", "send_reminder"}
assert set(inputs) == expected
for name, spec in inputs.items():
assert isinstance(spec["selector"], dict) and spec["selector"], name
assert inputs["afternoon_time"]["default"] == DEFAULT_AFTERNOON_REMINDER_TIME
assert DEFAULT_AFTERNOON_REMINDER_TIME == "15:00"
assert inputs["send_reminder"]["default"] is True
assert "time" in inputs["afternoon_time"]["selector"]
assert "text" in inputs["notify_target"]["selector"]
assert "boolean" in inputs["send_reminder"]["selector"]
def test_afternoon_reminder_wires_inputs_not_literals() -> None:
"""Time trigger, toggle and guard conditions, and notify action use !input."""
bp = afternoon_reminder()
assert bp["variables"]["send_reminder"] == BlueprintInput("send_reminder")
trigger = bp["triggers"][0]
assert trigger["trigger"] == "time"
assert trigger["at"] == BlueprintInput("afternoon_time")
toggle, guard = afternoon_conditions(bp)
assert toggle["condition"] == "template"
assert "send_reminder" in toggle["value_template"]
assert guard["condition"] == "template"
action = single_action(bp)
assert action["action"] == BlueprintInput("notify_target")
data = notify_data(afternoon_reminder())
assert data["title"] == AFTERNOON_TITLE
assert isinstance(data["message"], str) and data["message"].strip()
# ---------------------------------------------------------------------------
# Rendering: only present children with open quests are listed.
# ---------------------------------------------------------------------------
def test_afternoon_reminder_lists_only_present_children_with_open_quests() -> None:
"""Present+open Alice is listed; present+clear Carol, absent Bob,
and the household rollup are all omitted."""
states = make_states(
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_carol_quests_due_today",
"2",
{"child_name": "Carol", "child_id": 2, "present": True},
),
TemplateState(
"sensor.nestquest_carol_quests_remaining_today",
"0",
{"child_id": 2},
),
TemplateState(
"sensor.nestquest_bob_quests_due_today",
"2",
{"child_name": "Bob", "child_id": 3, "present": False},
),
TemplateState(
"sensor.nestquest_bob_quests_remaining_today",
"1",
{"child_id": 3},
),
TemplateState("sensor.nestquest_household_quests_due_today", "7"),
],
)
rendered = render(notify_data(afternoon_reminder())["message"], states=states)
assert rendered == "Alice still has 2 quest(s) open."
assert "Carol" not in rendered
assert "Bob" not in rendered
assert "household" not in rendered
def test_afternoon_reminder_all_clear_guard_renders_false() -> None:
"""With every present child done (and absent children owing), the
guard condition is false: the automation sends nothing at all."""
states = make_states(
sensors=[
TemplateState(
"sensor.nestquest_alice_quests_due_today",
"2",
{"child_name": "Alice", "child_id": 1, "present": True},
),
TemplateState(
"sensor.nestquest_alice_quests_remaining_today",
"0",
{"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", "2"),
],
)
guard = all_clear_guard(afternoon_reminder())
assert render(guard, states=states).strip() == "False"
def test_afternoon_reminder_guard_renders_true_when_a_present_child_owes() -> None:
"""A present child with open quests makes the guard true."""
states = make_states(
sensors=[
TemplateState(
"sensor.nestquest_alice_quests_due_today",
"3",
{"child_name": "Alice", "child_id": 1, "present": True},
),
TemplateState(
"sensor.nestquest_alice_quests_remaining_today",
"1",
{"child_id": 1},
),
],
)
guard = all_clear_guard(afternoon_reminder())
assert render(guard, states=states).strip() == "True"
def test_afternoon_reminder_send_reminder_toggle_renders() -> None:
"""The enable condition renders truthy/false from the input value."""
toggle = afternoon_conditions(afternoon_reminder())[0]
assert render(toggle["value_template"], send_reminder=True).strip() == "True"
assert render(toggle["value_template"], send_reminder=False).strip() == "False"
def test_afternoon_reminder_collision_suffixed_duplicate_names() -> None:
"""Two children sharing a display name (HA registry suffix _2 on the
second one's entity ids) both appear, each with their own count."""
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",
"3",
{"child_name": "Ada", "child_id": 2, "present": True},
),
TemplateState(
"sensor.nestquest_ada_quests_remaining_today_2",
"3",
{"child_id": 2},
),
],
)
rendered = render(notify_data(afternoon_reminder())["message"], states=states)
assert rendered.split("\n") == [
"Ada still has 1 quest(s) open.",
"Ada still has 3 quest(s) open.",
]
def test_afternoon_reminder_never_hard_codes_notify_target() -> None:
"""No notify service literal outside the input's placeholder text."""
bp = afternoon_reminder()
spec = bp["blueprint"]
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 single_action(bp)["action"] == BlueprintInput("notify_target")
+336
View File
@@ -0,0 +1,336 @@
"""Tests for the NestQuest morning-summary automation blueprint.
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 — all via the shared
harness in :mod:`tests.blueprint_helpers`.
This module also carries the directory-wide blueprint discipline tests
(entity-id shapes, domain mentions, notify-target hygiene), which scan
EVERY blueprint file under the automation directory, so new blueprint
files are covered automatically.
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 types import SimpleNamespace
from custom_components.nestquest.const import DEFAULT_MORNING_SUMMARY_TIME
from tests.blueprint_helpers import (
notify_data,
ALLOWED_ENTITY_SHAPES,
BlueprintInput,
_ENTITY_LITERAL_RE,
_MENTION_RE,
iter_strings,
load_blueprints,
make_states,
morning_action,
morning_summary,
render,
TemplateState,
)
MORNING_TITLE = "NestQuest morning summary"
# ---------------------------------------------------------------------------
# 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")
data = notify_data(bp)
assert data["title"] == MORNING_TITLE
assert isinstance(data["message"], str) and data["message"].strip()
# ---------------------------------------------------------------------------
# Entity-reference discipline (Feature 10 shapes only). These tests
# scan EVERY blueprint in the directory, so new blueprint files are
# automatically held to the same rules.
# ---------------------------------------------------------------------------
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 = notify_data(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 = notify_data(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(notify_data(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(notify_data(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(notify_data(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(notify_data(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"
+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] == ""
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"