Feature 11: Progress Automations and Notifications -> dev #122
@@ -60,13 +60,101 @@ triggers:
|
||||
event_type: nestquest_child_day_complete
|
||||
conditions:
|
||||
- condition: template
|
||||
value_template: "{{ trigger.payload.child_name == 'Declan' }}"
|
||||
value_template: "{{ trigger.event.data.child_name == 'Declan' }}"
|
||||
actions:
|
||||
- action: notify.mobile_app_declans_phone
|
||||
data:
|
||||
message: "Every quest cleared — legendary!"
|
||||
```
|
||||
|
||||
## Automation Blueprints
|
||||
|
||||
NestQuest ships four automation blueprints in
|
||||
`custom_components/nestquest/blueprints/automation/`. Import them in
|
||||
Home Assistant under **Settings → Automations & Scenes → Blueprints**
|
||||
(or place the files in your `config/blueprints/automation/` folder),
|
||||
then create one automation per blueprint, filling the inputs with your
|
||||
household's choices.
|
||||
|
||||
### Wiring to the integration's notification settings
|
||||
|
||||
The integration's options flow records your notification choices —
|
||||
notify target, morning/afternoon/end-of-day times, and a toggle per
|
||||
automation. The blueprints take their own inputs at import time;
|
||||
fill them from the same values you stored in the options flow (the
|
||||
integration does not push its settings into imported automations):
|
||||
|
||||
| Blueprint input | Options-flow counterpart |
|
||||
|---|---|
|
||||
| `morning_time` (default 08:00) | `morning_summary_time` |
|
||||
| `afternoon_time` (default 15:00) | `afternoon_reminder_time` |
|
||||
| `report_time` (default 20:00) | `end_of_day_report_time` |
|
||||
| `send_summary` / `send_reminder` / `send_report` / `celebrate` | the four `*_enabled` toggles |
|
||||
| `notify_target` | `notify_target` (your notify service, e.g. `notify.mobile_app_your_phone`) |
|
||||
|
||||
The end-of-day report deliberately runs BEFORE the midnight missed
|
||||
sweep and reads the same day's data — do not schedule it at the
|
||||
day-rollover time.
|
||||
|
||||
### The four blueprints
|
||||
|
||||
1. **Morning summary** (`morning_summary.yaml`) — at the configured
|
||||
time, lists every child whose `binary_sensor.nestquest_<child>_present_today`
|
||||
is on and what they owe from `sensor.nestquest_<child>_quests_remaining_today`.
|
||||
Children not present today are omitted entirely.
|
||||
|
||||
2. **Afternoon reminder** (`afternoon_reminder.yaml`) — at the
|
||||
configured time, notifies only about present children who still
|
||||
have open quests. Sends nothing at all when every present child is
|
||||
clear.
|
||||
|
||||
3. **End-of-day report** (`end_of_day_report.yaml`) — one message per
|
||||
evening: each present child's completion count and the quests they
|
||||
still owe (the ones the midnight sweep will mark
|
||||
`nestquest_quest_missed`). Absent children are omitted. Sends
|
||||
nothing on an all-clear day unless `send_even_when_clear` is on.
|
||||
|
||||
4. **Day-complete celebration** (`day_complete_celebration.yaml`) —
|
||||
triggers on the `nestquest_child_day_complete` event (which only
|
||||
fires when quests were owed and all are complete, so a zero-quest
|
||||
day never celebrates). Create one `input_text` helper
|
||||
(`nestquest_last_celebrated`) for the dedupe: the automation
|
||||
records each celebrated child as `<child_id>:<today>` and
|
||||
celebrates each child at most once per day — a re-cleared day does
|
||||
not re-fire, but a second child clearing the same day still
|
||||
celebrates.
|
||||
|
||||
### Example package
|
||||
|
||||
```yaml
|
||||
# One automation per blueprint — Settings → Automations & Scenes →
|
||||
# Create → Blueprint, then pick each NestQuest blueprint.
|
||||
#
|
||||
# morning_summary: morning_time 08:00, notify_target notify.mobile_app_your_phone
|
||||
# afternoon_reminder: afternoon_time 15:00, same notify target
|
||||
# end_of_day_report: report_time 20:00, send_even_when_clear false, same target
|
||||
# day_complete_celebration: input_text.nestquest_last_celebrated,
|
||||
# celebration_message "Legendary! {{ trigger.event.data.child_name }} cleared every quest today!"
|
||||
#
|
||||
# Every blueprint has its own send/celebrate toggle input; turn an
|
||||
# automation off entirely from HA's automation menu.
|
||||
```
|
||||
|
||||
### Events the automations rely on
|
||||
|
||||
| Event | Payload |
|
||||
|---|---|
|
||||
| `nestquest_quest_completed` | child_id, child_name, instance_id, quest_title, window, due_date, due_time, occurred_at (UTC ISO-8601), was_on_time |
|
||||
| `nestquest_quest_uncompleted` | child_id, child_name, instance_id, quest_title, window, due_date, due_time, occurred_at |
|
||||
| `nestquest_child_day_complete` | child_id, child_name, quests_due, quests_completed, occurred_at |
|
||||
| `nestquest_quest_missed` | child_id, child_name, instance_id, quest_title, window, due_date, due_time, occurred_at (fired by the nightly sweep) |
|
||||
|
||||
Sensors the automations read: `sensor.nestquest_<child>_quests_due_today`
|
||||
(attributes: `child_id`, `child_name`, `present`, `instances`,
|
||||
`admin_instances`), `..._quests_remaining_today`,
|
||||
`..._quests_completed_today`, and
|
||||
`binary_sensor.nestquest_<child>_present_today`.
|
||||
|
||||
## Developer Setup and Testing
|
||||
|
||||
This project uses [`uv`](https://github.com/astral-sh/uv) for fast Python package and dependency management.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,109 @@
|
||||
blueprint:
|
||||
name: NestQuest day complete celebration
|
||||
description: >-
|
||||
Celebrates the moment a child clears every quest for the day: the
|
||||
automation is driven by the nestquest_child_day_complete event,
|
||||
which NestQuest only fires when quests were owed and all of them
|
||||
are complete — a zero-quest day can never trigger this
|
||||
celebration. Once-per-child-per-day dedupe: point
|
||||
last_celebrated_helper at ONE input_text helper (create it under
|
||||
Settings, Devices and Services, Helpers — plain text). The
|
||||
automation records each celebrated child as
|
||||
"<child_id>:<today>" inside that helper, and a child who
|
||||
un-completes a quest and completes it again the same day is
|
||||
celebrated once, not twice — while a SECOND child clearing their
|
||||
day the same date is still celebrated in full. The helper is
|
||||
pruned to today's entries on every celebration, so it never
|
||||
grows past one entry per child per day. A helper that does not
|
||||
exist yet, or has never been set, lets the first celebration
|
||||
through. The notification message may use the event payload's
|
||||
child_name, quests_due and quests_completed. The notify service
|
||||
is chosen by the household when importing this blueprint —
|
||||
NestQuest never hard-codes a personal notify target.
|
||||
domain: automation
|
||||
input:
|
||||
notify_target:
|
||||
name: Notify service
|
||||
description: >-
|
||||
The Home Assistant notify service to call for the celebration,
|
||||
for example notify.mobile_app_your_phone — a placeholder;
|
||||
pick the notify service for your own device.
|
||||
selector:
|
||||
text: {}
|
||||
celebration_message:
|
||||
name: Celebration message
|
||||
description: >-
|
||||
The message sent when a child clears every quest today. May
|
||||
use the event payload: trigger.event.data.child_name,
|
||||
trigger.event.data.quests_due and trigger.event.data.quests_completed.
|
||||
default: "Legendary! {{ trigger.event.data.child_name }} cleared every quest today!"
|
||||
selector:
|
||||
text: {}
|
||||
celebrate:
|
||||
name: Send the celebration
|
||||
description: >-
|
||||
Turn off to keep the automation installed but skip the
|
||||
notification entirely.
|
||||
default: true
|
||||
selector:
|
||||
boolean: {}
|
||||
last_celebrated_helper:
|
||||
name: Last-celebrated helper
|
||||
description: >-
|
||||
A single input_text helper remembering which children were
|
||||
already celebrated today, as "<child_id>:<date>" entries
|
||||
separated by commas. The automation skips a child whose entry
|
||||
for today is already stored, and rewrites the helper with only
|
||||
today's entries plus the new one after each celebration.
|
||||
selector:
|
||||
entity:
|
||||
filter:
|
||||
- domain: input_text
|
||||
|
||||
mode: queued
|
||||
|
||||
variables:
|
||||
celebrate: !input celebrate
|
||||
last_celebrated_helper: !input last_celebrated_helper
|
||||
|
||||
triggers:
|
||||
- trigger: event
|
||||
event_type: nestquest_child_day_complete
|
||||
|
||||
conditions:
|
||||
- condition: template
|
||||
value_template: "{{ celebrate | bool }}"
|
||||
# Once-per-child-per-day dedupe: celebrate only when THIS child has
|
||||
# no equal entry for today in the helper. Entries compare as whole
|
||||
# comma-separated tokens — a substring check would wrongly suppress
|
||||
# child 1 after child 11 was celebrated. An unset or missing helper
|
||||
# reads as '' and celebrates.
|
||||
- condition: template
|
||||
value_template: >-
|
||||
{{ (trigger.event.data.child_id ~ ':' ~ now().strftime('%Y-%m-%d'))
|
||||
not in ((states(last_celebrated_helper) or '')
|
||||
.split(',')
|
||||
| select('ne', '')
|
||||
| list) }}
|
||||
|
||||
actions:
|
||||
- action: !input notify_target
|
||||
data:
|
||||
title: NestQuest day complete
|
||||
message: !input celebration_message
|
||||
# Record this child's celebration, keeping only today's entries so the
|
||||
# helper never outgrows its text limit and stale entries cannot block
|
||||
# next week's celebrations.
|
||||
- action: input_text.set_value
|
||||
target:
|
||||
entity_id: !input last_celebrated_helper
|
||||
data:
|
||||
value: >-
|
||||
{%- set today = now().strftime('%Y-%m-%d') -%}
|
||||
{%- set ns = namespace(entries=[]) -%}
|
||||
{%- for entry in (states(last_celebrated_helper) or '').split(',') -%}
|
||||
{%- if entry and entry.endswith(':' ~ today) -%}
|
||||
{%- set ns.entries = ns.entries + [entry] -%}
|
||||
{%- endif -%}
|
||||
{%- endfor -%}
|
||||
{{ (ns.entries + [trigger.event.data.child_id ~ ':' ~ today]) | join(',') }}
|
||||
@@ -0,0 +1,121 @@
|
||||
blueprint:
|
||||
name: NestQuest end-of-day report
|
||||
description: >-
|
||||
Sends the parent an end-of-day report on how the day went. It runs
|
||||
deliberately BEFORE NestQuest's midnight missed-quest sweep, so it
|
||||
reads the same day's data (what is still open at report time, and
|
||||
how much of the day was completed) rather than tomorrow-morning's
|
||||
hindsight. Every PRESENT child gets a completion line, so the
|
||||
parent always receives the day's summary; children who still owe
|
||||
quests get their open count called out on that line, and children
|
||||
who are not present today are omitted entirely. A child whose
|
||||
remaining-count sensor is unavailable is reported with the
|
||||
completion line only. By default the report is skipped when every
|
||||
present child is already done — turn on send-even-when-clear to
|
||||
always get the day's completion summary. The notify service is
|
||||
chosen by the household when importing this blueprint — NestQuest
|
||||
never hard-codes a personal notify target.
|
||||
domain: automation
|
||||
input:
|
||||
report_time:
|
||||
name: Report time
|
||||
description: >-
|
||||
Local time of day the report is sent — deliberately before the
|
||||
midnight missed-quest sweep, so the report reads the same
|
||||
day's data.
|
||||
default: "20:00"
|
||||
selector:
|
||||
time: {}
|
||||
notify_target:
|
||||
name: Notify service
|
||||
description: >-
|
||||
The Home Assistant notify service to call for the report,
|
||||
for example notify.mobile_app_your_phone — a placeholder;
|
||||
pick the notify service for your own device.
|
||||
selector:
|
||||
text: {}
|
||||
send_report:
|
||||
name: Send the report
|
||||
description: >-
|
||||
Turn off to keep the automation installed but skip the
|
||||
notification entirely.
|
||||
default: true
|
||||
selector:
|
||||
boolean: {}
|
||||
send_even_when_clear:
|
||||
name: Send even when clear
|
||||
description: >-
|
||||
Send the report even when every present child has already
|
||||
finished their quests today. When off (the default), an
|
||||
all-clear day sends nothing.
|
||||
default: false
|
||||
selector:
|
||||
boolean: {}
|
||||
|
||||
mode: single
|
||||
|
||||
variables:
|
||||
send_report: !input send_report
|
||||
send_even_when_clear: !input send_even_when_clear
|
||||
|
||||
triggers:
|
||||
- trigger: time
|
||||
at: !input report_time
|
||||
|
||||
conditions:
|
||||
- condition: template
|
||||
value_template: "{{ send_report | bool }}"
|
||||
# Skip an all-clear day unless the household opted into
|
||||
# send-even-when-clear: the guard is false only when every present
|
||||
# child has a usable remaining count of zero. A MISSING or
|
||||
# non-numeric remaining sensor reads as -1 and counts as open —
|
||||
# a degraded state must reach the parent, not look like all-clear.
|
||||
- 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 not remaining_values or (remaining_values | first | int(-1)) != 0 -%}
|
||||
{%- set ns.any_open = true -%}
|
||||
{%- endif -%}
|
||||
{%- endif -%}
|
||||
{%- endfor -%}
|
||||
{{- send_even_when_clear | bool or ns.any_open -}}
|
||||
|
||||
actions:
|
||||
- action: !input notify_target
|
||||
data:
|
||||
title: NestQuest end-of-day report
|
||||
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 due_count = due.state | int -%}
|
||||
{%- set completed_values = states.sensor
|
||||
| selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_completed_today(_\\d+)?$')
|
||||
| selectattr('attributes.child_id', 'eq', due.attributes.child_id)
|
||||
| map(attribute='state')
|
||||
| list -%}
|
||||
{%- 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 -%}
|
||||
{%- set completed_count = (completed_values | first | int) if completed_values else 0 -%}
|
||||
{%- set remaining_count = (remaining_values | first | int) if remaining_values else none -%}
|
||||
{%- if remaining_count is not none and remaining_count > 0 -%}
|
||||
{%- set line = due.attributes.child_name ~ ': ' ~ remaining_count ~ ' still open (' ~ completed_count ~ '/' ~ due_count ~ ' done).' -%}
|
||||
{%- else -%}
|
||||
{%- set line = due.attributes.child_name ~ ': ' ~ completed_count ~ '/' ~ due_count ~ ' done.' -%}
|
||||
{%- endif -%}
|
||||
{%- set ns.lines = ns.lines + [line] -%}
|
||||
{%- 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 -%}
|
||||
@@ -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:
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -8,8 +8,10 @@ dependencies = []
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"jinja2>=3",
|
||||
"pytest>=8.0.0",
|
||||
"pytest-asyncio>=0.24",
|
||||
"pyyaml>=6",
|
||||
"voluptuous>=0.14",
|
||||
]
|
||||
|
||||
|
||||
@@ -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_]+)")
|
||||
@@ -0,0 +1,268 @@
|
||||
"""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")
|
||||
|
||||
def test_afternoon_reminder_absent_child_regression() -> None:
|
||||
"""The Feature 11 guardrail, stated as one regression test: no
|
||||
reminder message is ever produced for an absent child."""
|
||||
states = make_states(
|
||||
sensors=[
|
||||
TemplateState(
|
||||
"sensor.nestquest_bob_quests_due_today",
|
||||
"3",
|
||||
{"child_name": "Bob", "child_id": 1, "present": False},
|
||||
),
|
||||
TemplateState(
|
||||
"sensor.nestquest_bob_quests_remaining_today",
|
||||
"3",
|
||||
{"child_id": 1},
|
||||
),
|
||||
],
|
||||
)
|
||||
guard = afternoon_conditions(afternoon_reminder())[-1]
|
||||
assert render(guard["value_template"], states=states).strip() == "False"
|
||||
message = render(
|
||||
notify_data(afternoon_reminder())["message"], states=states
|
||||
)
|
||||
assert "Bob" not in message
|
||||
@@ -0,0 +1,313 @@
|
||||
"""Tests for the NestQuest day-complete celebration automation blueprint.
|
||||
|
||||
The celebration fires on the ``nestquest_child_day_complete`` event —
|
||||
which NestQuest only fires when quests were owed and all are complete,
|
||||
so a zero-quest day can never celebrate. Dedupe design (per-child,
|
||||
per-day): the household points ONE ``input_text`` helper at the
|
||||
blueprint; the condition celebrates only when THIS child has no
|
||||
``<child_id>:<today>`` entry stored, and the action rewrites the
|
||||
helper with only today's entries plus the new one — so a child who
|
||||
un-completes and re-completes the same day is celebrated once, not
|
||||
twice, while a second child clearing the same day is still celebrated.
|
||||
|
||||
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. ``now()`` in the dedupe condition is injected by the test as a
|
||||
fixed callable, keeping the render deterministic.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import re
|
||||
|
||||
from custom_components.nestquest.const import EVENT_CHILD_DAY_COMPLETE
|
||||
|
||||
from tests.blueprint_helpers import (
|
||||
BlueprintInput,
|
||||
notify_data,
|
||||
blueprint,
|
||||
iter_strings,
|
||||
make_states,
|
||||
render,
|
||||
TemplateState,
|
||||
)
|
||||
|
||||
CELEBRATION_TITLE = "NestQuest day complete"
|
||||
|
||||
HELPER_ENTITY = "input_text.nestquest_last_celebrated"
|
||||
|
||||
#: The fixed "now" every dedupe render uses: 2026-09-19 15:00 local.
|
||||
FIXED_NOW = datetime.datetime(2026, 9, 19, 15, 0, 0)
|
||||
|
||||
|
||||
def celebration() -> dict:
|
||||
"""Return the parsed day-complete-celebration blueprint."""
|
||||
return blueprint("day_complete_celebration")
|
||||
|
||||
|
||||
def celebration_conditions(bp: dict) -> list[dict]:
|
||||
"""Return the blueprint's conditions: toggle first, dedupe second."""
|
||||
conditions = bp["conditions"]
|
||||
assert isinstance(conditions, list) and len(conditions) == 2
|
||||
return conditions
|
||||
|
||||
|
||||
def dedupe_condition(bp: dict) -> str:
|
||||
"""Return the once-per-child-per-day dedupe template (true = celebrate)."""
|
||||
return celebration_conditions(bp)[1]["value_template"]
|
||||
|
||||
|
||||
def stamp_value_template(bp: dict) -> str:
|
||||
"""Return the helper-stamp action's value template."""
|
||||
stamp = bp["actions"][1]
|
||||
return stamp["data"]["value"]
|
||||
|
||||
|
||||
def trigger_payload(child_id: int, child_name: str) -> dict:
|
||||
"""A nestquest_child_day_complete trigger fixture: HA exposes an
|
||||
event trigger's payload as trigger.event.data."""
|
||||
return {
|
||||
"event": {
|
||||
"data": {
|
||||
"child_id": child_id,
|
||||
"child_name": child_name,
|
||||
"quests_due": 3,
|
||||
"quests_completed": 3,
|
||||
"occurred_at": "2026-09-19T15:00:00+00:00",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def render_dedupe(
|
||||
helper_state: str | None, child_id: int = 1
|
||||
) -> str:
|
||||
"""Render the dedupe condition with the helper holding helper_state.
|
||||
|
||||
``None`` models a helper entity the fixture does not hold (never
|
||||
created or not yet set), which HA exposes as a missing state.
|
||||
"""
|
||||
states = make_states(
|
||||
input_texts=(
|
||||
[]
|
||||
if helper_state is None
|
||||
else [TemplateState(HELPER_ENTITY, helper_state)]
|
||||
)
|
||||
)
|
||||
return render(
|
||||
dedupe_condition(celebration()),
|
||||
states=states,
|
||||
last_celebrated_helper=HELPER_ENTITY,
|
||||
now=lambda: FIXED_NOW,
|
||||
trigger=trigger_payload(child_id, "Ada"),
|
||||
).strip()
|
||||
|
||||
|
||||
def render_stamp(helper_state: str | None, child_id: int = 1) -> str:
|
||||
"""Render the stamp action's value with the helper holding helper_state."""
|
||||
states = make_states(
|
||||
input_texts=(
|
||||
[]
|
||||
if helper_state is None
|
||||
else [TemplateState(HELPER_ENTITY, helper_state)]
|
||||
)
|
||||
)
|
||||
return render(
|
||||
stamp_value_template(celebration()),
|
||||
states=states,
|
||||
last_celebrated_helper=HELPER_ENTITY,
|
||||
now=lambda: FIXED_NOW,
|
||||
trigger=trigger_payload(child_id, "Ada"),
|
||||
).strip()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Trigger, inputs, defaults, and wiring.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_celebration_triggers_on_the_child_day_complete_event() -> None:
|
||||
"""The event trigger references the real nestquest_child_day_complete."""
|
||||
trigger = celebration()["triggers"][0]
|
||||
assert trigger["trigger"] == "event"
|
||||
assert trigger["event_type"] == "nestquest_child_day_complete"
|
||||
assert trigger["event_type"] == EVENT_CHILD_DAY_COMPLETE
|
||||
|
||||
|
||||
def test_celebration_declares_inputs_with_selectors() -> None:
|
||||
"""The four inputs exist, carry selectors, and default sanely."""
|
||||
bp = celebration()
|
||||
inputs = bp["blueprint"]["input"]
|
||||
expected = {
|
||||
"notify_target",
|
||||
"celebration_message",
|
||||
"celebrate",
|
||||
"last_celebrated_helper",
|
||||
}
|
||||
assert set(inputs) == expected
|
||||
for name, spec in inputs.items():
|
||||
assert isinstance(spec["selector"], dict) and spec["selector"], name
|
||||
assert inputs["celebrate"]["default"] is True
|
||||
assert "text" in inputs["notify_target"]["selector"]
|
||||
assert "text" in inputs["celebration_message"]["selector"]
|
||||
assert "boolean" in inputs["celebrate"]["selector"]
|
||||
assert inputs["last_celebrated_helper"]["selector"]["entity"]["filter"] == [
|
||||
{"domain": "input_text"}
|
||||
]
|
||||
# The description documents the zero-quest-day rule and the dedupe.
|
||||
description = bp["blueprint"]["description"]
|
||||
assert "zero-quest day" in description
|
||||
assert "once" in description.lower()
|
||||
|
||||
|
||||
def test_celebration_wires_inputs_not_literals() -> None:
|
||||
"""Notify and stamp actions both use !input; the dedupe reads the helper."""
|
||||
bp = celebration()
|
||||
assert bp["variables"]["celebrate"] == BlueprintInput("celebrate")
|
||||
assert bp["variables"]["last_celebrated_helper"] == BlueprintInput(
|
||||
"last_celebrated_helper"
|
||||
)
|
||||
|
||||
toggle, dedupe = celebration_conditions(bp)
|
||||
assert toggle["condition"] == "template"
|
||||
assert "celebrate" in toggle["value_template"]
|
||||
assert dedupe["condition"] == "template"
|
||||
assert "last_celebrated_helper" in dedupe["value_template"]
|
||||
assert "child_id" in dedupe["value_template"], (
|
||||
"the dedupe must be per child, not per household"
|
||||
)
|
||||
|
||||
notify_action, stamp_action = bp["actions"]
|
||||
assert notify_action["action"] == BlueprintInput("notify_target")
|
||||
data = notify_data(bp)
|
||||
assert data["title"] == CELEBRATION_TITLE
|
||||
assert data["message"] == BlueprintInput("celebration_message")
|
||||
|
||||
assert stamp_action["action"] == "input_text.set_value"
|
||||
assert stamp_action["target"]["entity_id"] == BlueprintInput(
|
||||
"last_celebrated_helper"
|
||||
)
|
||||
assert "now()" in stamp_action["data"]["value"]
|
||||
|
||||
|
||||
def test_celebration_message_default_renders_the_child_name() -> None:
|
||||
"""The default message renders the trigger payload's child_name."""
|
||||
default_message = celebration()["blueprint"]["input"]["celebration_message"][
|
||||
"default"
|
||||
]
|
||||
rendered = render(default_message, trigger=trigger_payload(1, "Ada"))
|
||||
assert rendered == "Legendary! Ada cleared every quest today!"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The once-per-child-per-day dedupe condition.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_celebration_dedupe_true_when_child_not_celebrated_today() -> None:
|
||||
"""A helper without THIS child's entry for today lets the
|
||||
celebration through — including when another child was celebrated
|
||||
earlier the same day."""
|
||||
assert render_dedupe(None) == "True"
|
||||
assert render_dedupe("") == "True"
|
||||
assert render_dedupe("2:2026-09-19") == "True", (
|
||||
"a second child clearing the same day must still celebrate"
|
||||
)
|
||||
assert render_dedupe("1:2026-09-18") == "True", (
|
||||
"yesterday's entry never blocks today"
|
||||
)
|
||||
|
||||
|
||||
def test_celebration_dedupe_false_when_child_already_celebrated_today() -> None:
|
||||
"""A child already celebrated today is suppressed — the un-complete
|
||||
and re-complete the same day case."""
|
||||
assert render_dedupe("1:2026-09-19") == "False"
|
||||
assert render_dedupe("2:2026-09-19,1:2026-09-19") == "False"
|
||||
|
||||
|
||||
def test_celebration_stamp_prunes_to_today_and_appends() -> None:
|
||||
"""The stamp keeps only today's entries plus the new one: yesterday's
|
||||
entries are dropped (the helper never outgrows its text limit and
|
||||
stale entries cannot block next week), and the celebrated child is
|
||||
recorded."""
|
||||
assert render_stamp(None, child_id=1) == "1:2026-09-19"
|
||||
assert render_stamp("1:2026-09-18", child_id=2) == "2:2026-09-19"
|
||||
assert render_stamp("1:2026-09-19", child_id=2) == (
|
||||
"1:2026-09-19,2:2026-09-19"
|
||||
)
|
||||
assert render_stamp("1:2026-09-18,2:2026-09-18", child_id=3) == (
|
||||
"3:2026-09-19"
|
||||
)
|
||||
|
||||
|
||||
def test_celebration_celebrate_toggle_renders() -> None:
|
||||
"""The enable condition renders truthy/false from the input value."""
|
||||
toggle = celebration_conditions(celebration())[0]
|
||||
assert render(toggle["value_template"], celebrate=True).strip() == "True"
|
||||
assert render(toggle["value_template"], celebrate=False).strip() == "False"
|
||||
|
||||
|
||||
def test_celebration_never_hard_codes_notify_target() -> None:
|
||||
"""No notify service literal outside the input's placeholder text."""
|
||||
bp = celebration()
|
||||
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 bp["actions"][0]["action"] == BlueprintInput("notify_target")
|
||||
|
||||
def test_celebration_dedupe_entry_equality_not_substring() -> None:
|
||||
"""Child ids sharing a prefix must not suppress each other: after
|
||||
child 11 is celebrated, child 1 still celebrates (whole-entry
|
||||
comparison, not substring)."""
|
||||
assert render_dedupe("11:2026-09-19", child_id=11) == "False"
|
||||
assert render_dedupe("11:2026-09-19", child_id=1) == "True", (
|
||||
"child 1's entry must not be found inside child 11's entry"
|
||||
)
|
||||
assert render_dedupe("1:2026-09-19", child_id=11) == "True"
|
||||
|
||||
|
||||
def test_celebration_mode_is_queued() -> None:
|
||||
"""Two children completing at nearly the same time must both be
|
||||
processed: queued mode does not drop the second event."""
|
||||
assert celebration()["mode"] == "queued"
|
||||
|
||||
|
||||
def test_every_blueprint_event_reference_is_real() -> None:
|
||||
"""Every event a blueprint triggers on is one NestQuest actually
|
||||
fires or defines (Feature 10/11) — no invented event names."""
|
||||
from tests.blueprint_helpers import load_blueprints
|
||||
|
||||
real_events = {
|
||||
"nestquest_quest_completed",
|
||||
"nestquest_quest_uncompleted",
|
||||
"nestquest_quest_missed",
|
||||
"nestquest_child_day_complete",
|
||||
}
|
||||
for name, bp in load_blueprints().items():
|
||||
for trigger in bp.get("triggers", []):
|
||||
if trigger.get("trigger") == "event":
|
||||
assert trigger["event_type"] in real_events, (
|
||||
f"{name}: invented event {trigger['event_type']!r}"
|
||||
)
|
||||
assert celebration()["triggers"][0]["event_type"] == (
|
||||
"nestquest_child_day_complete"
|
||||
)
|
||||
|
||||
|
||||
def test_celebration_uses_the_event_trigger_data_path() -> None:
|
||||
"""HA exposes an event trigger's payload as trigger.event.data —
|
||||
never trigger.payload (Codex review regression)."""
|
||||
bp = celebration()
|
||||
for value in iter_strings(bp):
|
||||
assert "trigger.payload" not in value, value
|
||||
default_message = bp["blueprint"]["input"]["celebration_message"][
|
||||
"default"
|
||||
]
|
||||
assert "trigger.event.data.child_name" in default_message
|
||||
for condition in celebration_conditions(bp):
|
||||
assert "trigger.payload" not in condition["value_template"]
|
||||
@@ -0,0 +1,338 @@
|
||||
"""Tests for the NestQuest end-of-day report automation blueprint.
|
||||
|
||||
The end-of-day report closes the day for the parent: every PRESENT
|
||||
child gets a completion line (so the parent always receives the day's
|
||||
summary when the report runs), children who still owe quests get their
|
||||
open count called out on that line, and absent children are omitted
|
||||
entirely. The report runs deliberately before the midnight
|
||||
missed-quest sweep so it reads the same day's data, and by default an
|
||||
all-clear day sends nothing — unless the household opts into
|
||||
send-even-when-clear. 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_END_OF_DAY_REPORT_TIME
|
||||
|
||||
from tests.blueprint_helpers import (
|
||||
BlueprintInput,
|
||||
notify_data,
|
||||
blueprint,
|
||||
iter_strings,
|
||||
make_states,
|
||||
render,
|
||||
single_action,
|
||||
TemplateState,
|
||||
)
|
||||
|
||||
REPORT_TITLE = "NestQuest end-of-day report"
|
||||
|
||||
|
||||
def end_of_day_report() -> dict:
|
||||
"""Return the parsed end-of-day-report blueprint."""
|
||||
return blueprint("end_of_day_report")
|
||||
|
||||
|
||||
def report_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 clear_guard(bp: dict) -> str:
|
||||
"""Return the template guarding an all-clear day (false = no report)."""
|
||||
return report_conditions(bp)[1]["value_template"]
|
||||
|
||||
|
||||
def owing_clear_absent_fixture():
|
||||
"""Alice owing (1/3 done, 2 open), Carol present and clear, Bob absent."""
|
||||
return 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_alice_quests_completed_today",
|
||||
"1",
|
||||
{"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_carol_quests_completed_today",
|
||||
"2",
|
||||
{"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_bob_quests_completed_today",
|
||||
"1",
|
||||
{"child_id": 3},
|
||||
),
|
||||
TemplateState("sensor.nestquest_household_quests_due_today", "7"),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def all_clear_fixture():
|
||||
"""Alice present with everything done; nobody owes anything."""
|
||||
return 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",
|
||||
"0",
|
||||
{"child_id": 1},
|
||||
),
|
||||
TemplateState(
|
||||
"sensor.nestquest_alice_quests_completed_today",
|
||||
"3",
|
||||
{"child_id": 1},
|
||||
),
|
||||
TemplateState("sensor.nestquest_household_quests_due_today", "3"),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Inputs, defaults, and wiring.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_end_of_day_report_declares_inputs_with_selectors() -> None:
|
||||
"""The four inputs exist, carry selectors, and default sanely."""
|
||||
bp = end_of_day_report()
|
||||
inputs = bp["blueprint"]["input"]
|
||||
expected = {"report_time", "notify_target", "send_report", "send_even_when_clear"}
|
||||
assert set(inputs) == expected
|
||||
for name, spec in inputs.items():
|
||||
assert isinstance(spec["selector"], dict) and spec["selector"], name
|
||||
assert inputs["report_time"]["default"] == DEFAULT_END_OF_DAY_REPORT_TIME
|
||||
assert DEFAULT_END_OF_DAY_REPORT_TIME == "20:00"
|
||||
assert inputs["send_report"]["default"] is True
|
||||
assert inputs["send_even_when_clear"]["default"] is False
|
||||
assert "time" in inputs["report_time"]["selector"]
|
||||
assert "text" in inputs["notify_target"]["selector"]
|
||||
assert "boolean" in inputs["send_report"]["selector"]
|
||||
assert "boolean" in inputs["send_even_when_clear"]["selector"]
|
||||
# The description must note the deliberate ordering: the report runs
|
||||
# before the midnight missed sweep, reading the same day's data.
|
||||
description = bp["blueprint"]["description"].lower()
|
||||
assert "before" in description
|
||||
assert "sweep" in description
|
||||
assert "same day" in description
|
||||
|
||||
|
||||
def test_end_of_day_report_wires_inputs_not_literals() -> None:
|
||||
"""Time trigger, toggle and guard conditions, and notify action use !input."""
|
||||
bp = end_of_day_report()
|
||||
assert bp["variables"]["send_report"] == BlueprintInput("send_report")
|
||||
assert bp["variables"]["send_even_when_clear"] == BlueprintInput(
|
||||
"send_even_when_clear"
|
||||
)
|
||||
|
||||
trigger = bp["triggers"][0]
|
||||
assert trigger["trigger"] == "time"
|
||||
assert trigger["at"] == BlueprintInput("report_time")
|
||||
|
||||
toggle, guard = report_conditions(bp)
|
||||
assert toggle["condition"] == "template"
|
||||
assert "send_report" in toggle["value_template"]
|
||||
assert guard["condition"] == "template"
|
||||
assert "send_even_when_clear" in guard["value_template"]
|
||||
|
||||
action = single_action(bp)
|
||||
assert action["action"] == BlueprintInput("notify_target")
|
||||
data = notify_data(end_of_day_report())
|
||||
assert data["title"] == REPORT_TITLE
|
||||
assert isinstance(data["message"], str) and data["message"].strip()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rendering the day's summary.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_end_of_day_report_renders_owing_clear_and_absent_children() -> None:
|
||||
"""The owing child's open count is called out with the completion
|
||||
ratio; the present-clear child still gets the day's completion
|
||||
line; the absent child is omitted entirely."""
|
||||
rendered = render(
|
||||
notify_data(end_of_day_report())["message"],
|
||||
states=owing_clear_absent_fixture(),
|
||||
)
|
||||
assert rendered.split("\n") == [
|
||||
"Alice: 2 still open (1/3 done).",
|
||||
"Carol: 2/2 done.",
|
||||
]
|
||||
assert "Bob" not in rendered
|
||||
assert "household" not in rendered
|
||||
|
||||
|
||||
def test_end_of_day_report_all_clear_guard_renders_false_by_default() -> None:
|
||||
"""An all-clear day with send_even_when_clear off sends nothing:
|
||||
the guard condition is false."""
|
||||
rendered = render(
|
||||
clear_guard(end_of_day_report()),
|
||||
states=all_clear_fixture(),
|
||||
send_even_when_clear=False,
|
||||
)
|
||||
assert rendered.strip() == "False"
|
||||
|
||||
|
||||
def test_end_of_day_report_all_clear_sends_summary_when_opted_in() -> None:
|
||||
"""With send_even_when_clear on, the guard lets an all-clear day
|
||||
through and the message renders the day's completion summary."""
|
||||
guard = clear_guard(end_of_day_report())
|
||||
assert (
|
||||
render(guard, states=all_clear_fixture(), send_even_when_clear=True).strip()
|
||||
== "True"
|
||||
)
|
||||
message = render(
|
||||
notify_data(end_of_day_report())["message"],
|
||||
states=all_clear_fixture(),
|
||||
)
|
||||
assert message == "Alice: 3/3 done."
|
||||
|
||||
|
||||
def test_end_of_day_report_guard_true_when_a_present_child_owes() -> None:
|
||||
"""A present child with open quests passes the guard even with
|
||||
send_even_when_clear off."""
|
||||
rendered = render(
|
||||
clear_guard(end_of_day_report()),
|
||||
states=owing_clear_absent_fixture(),
|
||||
send_even_when_clear=False,
|
||||
)
|
||||
assert rendered.strip() == "True"
|
||||
|
||||
|
||||
def test_end_of_day_report_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 counts."""
|
||||
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_completed_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",
|
||||
"0",
|
||||
{"child_id": 2},
|
||||
),
|
||||
TemplateState(
|
||||
"sensor.nestquest_ada_quests_completed_today_2",
|
||||
"3",
|
||||
{"child_id": 2},
|
||||
),
|
||||
],
|
||||
)
|
||||
rendered = render(notify_data(end_of_day_report())["message"], states=states)
|
||||
assert rendered.split("\n") == [
|
||||
"Ada: 1 still open (1/2 done).",
|
||||
"Ada: 3/3 done.",
|
||||
]
|
||||
|
||||
|
||||
def test_end_of_day_report_never_hard_codes_notify_target() -> None:
|
||||
"""No notify service literal outside the input's placeholder text."""
|
||||
bp = end_of_day_report()
|
||||
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")
|
||||
|
||||
def test_end_of_day_report_guard_true_when_remaining_unavailable() -> None:
|
||||
"""A present child whose remaining sensor is missing or reads
|
||||
'unavailable' must NOT look like all-clear: the degraded state
|
||||
reaches the parent (Codex review regression)."""
|
||||
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",
|
||||
"unavailable",
|
||||
{"child_id": 1},
|
||||
),
|
||||
],
|
||||
)
|
||||
rendered = render(
|
||||
clear_guard(end_of_day_report()),
|
||||
states=states,
|
||||
send_even_when_clear=False,
|
||||
)
|
||||
assert rendered.strip() == "True"
|
||||
|
||||
# A missing remaining sensor entirely: also report-worthy.
|
||||
states_missing = make_states(
|
||||
sensors=[
|
||||
TemplateState(
|
||||
"sensor.nestquest_alice_quests_due_today",
|
||||
"3",
|
||||
{"child_name": "Alice", "child_id": 1, "present": True},
|
||||
),
|
||||
],
|
||||
)
|
||||
rendered_missing = render(
|
||||
clear_guard(end_of_day_report()),
|
||||
states=states_missing,
|
||||
send_even_when_clear=False,
|
||||
)
|
||||
assert rendered_missing.strip() == "True"
|
||||
@@ -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"
|
||||
@@ -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
@@ -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] == ""
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user