Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1add535628 | ||
|
|
ee8ef6dcac |
@@ -159,9 +159,50 @@ Sensors the automations read: `sensor.nestquest_<child>_quests_due_today`
|
||||
|
||||
Run these steps in order to display the NestQuest panel on a TouchHub
|
||||
wall device. The panel is an HA Lovelace dashboard, so the kiosk browser
|
||||
is simply a Home Assistant client.
|
||||
is simply a Home Assistant client. The path below is zero-config: the
|
||||
integration registers its panel dashboard itself on setup, so no YAML
|
||||
and no hand-built views are involved.
|
||||
|
||||
1. **Create a dedicated kiosk user.** In *Settings → People → Users*, add
|
||||
1. **Install the integration and add the config entry.** Install
|
||||
NestQuest via HACS or manually (see Installation above) and restart
|
||||
Home Assistant. Then, in *Settings → Devices & Services*, click
|
||||
**Add Integration**, search for **NestQuest**, and submit the flow —
|
||||
it has no fields; it just creates the single config entry. Then open
|
||||
the entry, click **Configure**, and fill the panel-plane API
|
||||
connection in the options flow:
|
||||
|
||||
- **NestQuest API base URL** — the full `http://host:port` URL of
|
||||
your NestQuest API service (default `http://127.0.0.1:8000`).
|
||||
- **Panel service token** — the token the panel presents to the API
|
||||
service as its Bearer credential. It may stay empty until you have
|
||||
one; a pasted token must be unpadded.
|
||||
|
||||
The form's remaining fields (panel idle timeout, coordinator update
|
||||
interval, last-good snapshot staleness) have defaults that serve the
|
||||
panel and need no change here.
|
||||
|
||||
2. **The panel dashboard appears automatically.** On setup the
|
||||
integration registers one storage-mode Lovelace dashboard: a sidebar
|
||||
entry titled **NestQuest** at the URL path `/nestquest`, whose stored
|
||||
config is nothing but the **`custom:nestquest-party` strategy**
|
||||
(`strategy: { type: custom:nestquest-party }`). The strategy
|
||||
generates the views at render time from the household roster:
|
||||
|
||||
- **The Party** — the party-board view at `/nestquest/party`; the
|
||||
dashboard root `/nestquest` shows it as the first view.
|
||||
- one quest-log view per child at `/nestquest/<child-slug>`
|
||||
("Ada's Quest Log", …), in roster order.
|
||||
|
||||
Nothing is hand-configured: no YAML, no manual views, no
|
||||
`child_order`, no `quest_log_path`/`board_path` — the board and the
|
||||
quest logs compute their navigation paths from the dashboard's own
|
||||
URL — and every generated view is already in **Panel** layout (see
|
||||
the two constraints below). If the Lovelace surfaces are unavailable
|
||||
(for example when Lovelace runs in YAML mode), setup logs a warning
|
||||
and skips the registration; create the dashboard manually with the
|
||||
same strategy as described in the manual alternative below.
|
||||
|
||||
3. **Create a dedicated kiosk user.** In *Settings → People → Users*, add
|
||||
a new user for the wall device. Make it **non-admin**, and ensure it is
|
||||
not in the NestQuest admin allowlist. The allowlist blocks the NestQuest
|
||||
**admin** operations — `uncomplete_quest`, `create_quest_definition`,
|
||||
@@ -174,81 +215,101 @@ is simply a Home Assistant client.
|
||||
Assistant user it may also reach other permitted Home Assistant entities
|
||||
and services.
|
||||
|
||||
2. **Build a dashboard with only the NestQuest panel cards.** The
|
||||
recommended path is the **zero-config dashboard strategy**: create a
|
||||
Lovelace dashboard whose URL slug is `nestquest` with
|
||||
4. **Open the dashboard and point TouchHub at it.** Open
|
||||
`http://<home-assistant>/nestquest` in a browser first and check the
|
||||
party board renders with your children's crests. Then, in the
|
||||
TouchHub launcher, add the **Home Assistant Lovelace** app to the
|
||||
dock and paste the dashboard URL from step 2 — the dashboard root,
|
||||
`/nestquest` (TouchHub loads that URL directly as the kiosk landing
|
||||
page). Set TouchHub **Auto-Return** to a value GREATER than the
|
||||
quest log's `idle_return_seconds` (default 40s) — recommend **60s or
|
||||
higher**. If Auto-Return is at or below 40s, TouchHub returns to its
|
||||
launcher before the quest log's idle return can navigate back to the
|
||||
party board.
|
||||
|
||||
```yaml
|
||||
strategy:
|
||||
type: custom:nestquest-party
|
||||
```
|
||||
|
||||
and no views of your own — the strategy generates the party-board and
|
||||
quest-log views (each already in Panel layout) from the household
|
||||
roster at render time. Optional `url_path` and `weather_entity` keys
|
||||
may sit beside `strategy:`. The manual instructions below describe
|
||||
building the two views by hand if you prefer not to use the strategy.
|
||||
|
||||
**Manual alternative:** create a Lovelace dashboard whose **URL slug
|
||||
is `nestquest`** (Settings →
|
||||
Dashboards → Edit → URL slug). A functional panel needs **two** views
|
||||
in that dashboard, each with its own card config from
|
||||
`design/ENTITIES-AND-SERVICES.md` §4. Give the views these exact URL
|
||||
paths (the path segment after the dashboard slug):
|
||||
|
||||
- **View 1 path `board`** (URL `/nestquest/board`) — party board
|
||||
(`type: custom:nestquest-party-board-card`): the household quest
|
||||
board. Set `weather_entity` (optional; the dock falls back to
|
||||
date/time), `quest_log_path: /nestquest/log` (the view the crest tap
|
||||
navigates to), and `child_order: [slug1, slug2, slug3]` listing each
|
||||
child's slug in display order.
|
||||
|
||||
- **View 2 path `log`** (URL `/nestquest/log`) — quest log
|
||||
(`type: custom:nestquest-quest-log-card`): the per-child quest screen
|
||||
the party board hops to. Set `board_path: /nestquest/board` (the
|
||||
party-board view to return to), `weather_entity`,
|
||||
`idle_return_seconds` (must be shorter than TouchHub Auto-Return),
|
||||
`confirm_timeout_seconds`, and `complete_screen_seconds`. The quest
|
||||
log reads the selected child's slug from the view's URL path, so the
|
||||
board → log hop is a plain Lovelace navigation.
|
||||
|
||||
Each view's layout MUST be set to **Panel** (Edit Dashboard → pencil on the
|
||||
view → View type: Panel). Home Assistant's default Masonry layout constrains
|
||||
the 1080px-tall NestQuest card to a narrow column; Panel (single-card)
|
||||
layout is what renders the card full-screen at 1920×1080.
|
||||
|
||||
The dashboard contains only the NestQuest panel card for each view —
|
||||
no other cards, no admin controls.
|
||||
|
||||
The `quest_log_path` and `board_path` values must match the dashboard
|
||||
slug and view paths above: `/<slug>/<view>`. If you chose `nestquest`
|
||||
as the dashboard slug with view paths `board` and `log`, the card
|
||||
configs are already correct. If you pick different names, substitute
|
||||
your actual paths in **both** card configs — e.g. a dashboard slug
|
||||
`chores` with view paths `main` and `history` needs
|
||||
`quest_log_path: /chores/history` and `board_path: /chores/main`.
|
||||
Navigation between the two views fails (the crest tap or back button
|
||||
lands on a 404) if the card config paths do not match the dashboard
|
||||
slug and view paths exactly.
|
||||
|
||||
See `design/ENTITIES-AND-SERVICES.md` §4 for the full card YAML.
|
||||
|
||||
3. **Point TouchHub at it.** In the TouchHub launcher, add the **Home
|
||||
Assistant Lovelace** app to the dock and paste the dashboard URL from
|
||||
step 2 — the party-board view URL, e.g. `/nestquest/board` (TouchHub
|
||||
loads that URL directly as the kiosk landing page). Set TouchHub
|
||||
**Auto-Return** to a value GREATER than the quest log's
|
||||
`idle_return_seconds` (default 40s) — recommend **60s or higher**. If
|
||||
Auto-Return is at or below 40s, TouchHub returns to its launcher before
|
||||
the quest log's idle return can navigate back to the party board.
|
||||
|
||||
4. **Log the kiosk session in.** Sign in on the device as the dedicated
|
||||
kiosk user from step 1, and leave that session logged in.
|
||||
5. **Log the kiosk session in.** Sign in on the device as the dedicated
|
||||
kiosk user from step 3, and leave that session logged in.
|
||||
|
||||
TouchHub loads the dashboard URL directly. The NestQuest cards are normal
|
||||
Lovelace cards, not an iframe, so no Home Assistant framing setting is
|
||||
needed.
|
||||
|
||||
### The two constraints
|
||||
|
||||
- **Panel layout.** Every view must be in **Panel** (single-card)
|
||||
layout: Home Assistant's default Masonry layout constrains the
|
||||
1080px-tall NestQuest card to a narrow column, while Panel renders the
|
||||
card full-screen at 1920×1080. The zero-config path already satisfies
|
||||
the requirement — the strategy generates every view as
|
||||
`type: panel` — while the manual alternative below requires setting
|
||||
the layout by hand on each view (Edit Dashboard → pencil on the view
|
||||
→ View type: Panel).
|
||||
|
||||
- **TouchHub Auto-Return.** TouchHub's **Auto-Return** must be greater
|
||||
than the quest log's `idle_return_seconds` (default 40s) — recommend
|
||||
**60s or higher** — so the launcher does not reclaim the wall device
|
||||
before the quest log's own idle return navigates back to the party
|
||||
board.
|
||||
|
||||
### Manual alternative: build the dashboard by hand
|
||||
|
||||
If you prefer not to use the zero-config dashboard above — or its
|
||||
registration was skipped — configure a Lovelace dashboard whose **URL
|
||||
slug is `nestquest`** (Settings → Dashboards → Edit → URL slug)
|
||||
yourself. Two options, in decreasing order of convenience:
|
||||
|
||||
- **Use the strategy.** Give the dashboard
|
||||
|
||||
```yaml
|
||||
strategy:
|
||||
type: custom:nestquest-party
|
||||
```
|
||||
|
||||
and no views of your own — the strategy generates the party-board and
|
||||
quest-log views (each already in Panel layout) from the household
|
||||
roster at render time. Optional `url_path` and `weather_entity` keys
|
||||
may sit beside `strategy:`.
|
||||
|
||||
- **Build the two views by hand.** A functional panel needs **two**
|
||||
views in that dashboard, each with its own card config from
|
||||
`design/ENTITIES-AND-SERVICES.md` §4. Give the views these exact URL
|
||||
paths (the path segment after the dashboard slug):
|
||||
|
||||
- **View 1 path `board`** (URL `/nestquest/board`) — party board
|
||||
(`type: custom:nestquest-party-board-card`): the household quest
|
||||
board. Set `weather_entity` (optional; the dock falls back to
|
||||
date/time), `quest_log_path: /nestquest/log` (the view the crest tap
|
||||
navigates to), and `child_order: [slug1, slug2, slug3]` listing each
|
||||
child's slug in display order.
|
||||
|
||||
- **View 2 path `log`** (URL `/nestquest/log`) — quest log
|
||||
(`type: custom:nestquest-quest-log-card`): the per-child quest screen
|
||||
the party board hops to. Set `board_path: /nestquest/board` (the
|
||||
party-board view to return to), `weather_entity`,
|
||||
`idle_return_seconds` (must be shorter than TouchHub Auto-Return —
|
||||
see the two constraints above), `confirm_timeout_seconds`, and
|
||||
`complete_screen_seconds`. The quest log reads the selected child's
|
||||
slug from the view's URL path, so the board → log hop is a plain
|
||||
Lovelace navigation.
|
||||
|
||||
The dashboard contains only the NestQuest panel card for each view —
|
||||
no other cards, no admin controls. Each view's layout MUST be set to
|
||||
**Panel** — see the two constraints above for why; the strategy
|
||||
generates it for you, hand-built views do not get it for free.
|
||||
|
||||
The `quest_log_path` and `board_path` values must match the dashboard
|
||||
slug and view paths above: `/<slug>/<view>`. If you chose `nestquest`
|
||||
as the dashboard slug with view paths `board` and `log`, the card
|
||||
configs are already correct. If you pick different names, substitute
|
||||
your actual paths in **both** card configs — e.g. a dashboard slug
|
||||
`chores` with view paths `main` and `history` needs
|
||||
`quest_log_path: /chores/history` and `board_path: /chores/main`.
|
||||
Navigation between the two views fails (the crest tap or back button
|
||||
lands on a 404) if the card config paths do not match the dashboard
|
||||
slug and view paths exactly.
|
||||
|
||||
See `design/ENTITIES-AND-SERVICES.md` §4 for the full card YAML.
|
||||
|
||||
## Developer Setup and Testing
|
||||
|
||||
This project uses [`uv`](https://github.com/astral-sh/uv) for fast Python package and dependency management.
|
||||
|
||||
@@ -41,4 +41,38 @@ class MetaStateDao:
|
||||
f"INSERT INTO {_TABLE} (key, value) VALUES (?, ?) "
|
||||
"ON CONFLICT (key) DO UPDATE SET value = excluded.value",
|
||||
(key, value),
|
||||
)
|
||||
)
|
||||
|
||||
async def claim(self, key: str, value: str) -> bool:
|
||||
"""Atomically set ``key`` to ``value`` unless it already is.
|
||||
|
||||
The single upsert statement carries a ``WHERE`` guard on the
|
||||
conflict path (``t.value IS NOT excluded.value``), so the
|
||||
read-compare-write is ONE SQLite statement — atomic at the
|
||||
DATABASE level, not just within one process: two connections
|
||||
(two processes sharing one SQLite file) cannot both observe
|
||||
"not yet set" and both write, because the second writer's
|
||||
statement re-evaluates the guard against the first writer's
|
||||
committed value and matches zero rows.
|
||||
|
||||
Returns True only when THIS call changed the row: the first
|
||||
claimant wins and every later claimant of the same value
|
||||
returns False. A DIFFERENT value always wins (an advance, e.g.
|
||||
the sweep watermark moving to a new date) and re-arms the
|
||||
same-value loser check for the new value.
|
||||
|
||||
Runs under the shared connection lock and inside one
|
||||
transaction, like :meth:`set`; ``cursor.rowcount`` (1 for a
|
||||
matched write, 0 for a guarded no-op) is captured by the
|
||||
wrapper on the executor thread, so no sqlite3 object crosses
|
||||
back to the event loop.
|
||||
"""
|
||||
async with _connection_lock(self._database):
|
||||
async with self._database.transaction():
|
||||
result = await self._database.execute(
|
||||
f"INSERT INTO {_TABLE} (key, value) VALUES (?, ?) "
|
||||
"ON CONFLICT (key) DO UPDATE SET value = excluded.value "
|
||||
f"WHERE {_TABLE}.value IS NOT excluded.value",
|
||||
(key, value),
|
||||
)
|
||||
return result.rowcount == 1
|
||||
|
||||
@@ -26,12 +26,17 @@ 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.
|
||||
|
||||
The whole watermark read → query → build → write span runs inside the
|
||||
The whole watermark read → query → build span runs inside the
|
||||
per-database sweep lock (one lock per connection wrapper and running
|
||||
loop, the settings store's pattern): 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.
|
||||
a rollover sweep of the SAME process queue instead of interleaving.
|
||||
The announce decision itself is the DATABASE-level claim
|
||||
(:meth:`MetaStateDao.claim`), so two PROCESSES sharing one SQLite file
|
||||
(the integration's rollover sweep and the API service's scheduler)
|
||||
cannot both announce the same missed batch either: both may read the
|
||||
old watermark and build the events, but the single conditional upsert
|
||||
lets exactly one of them win the claim — the loser returns [] and
|
||||
announces nothing.
|
||||
|
||||
The caller supplies ``today`` as a plain :class:`datetime.date`: the
|
||||
household-local calendar date, read from ``hass.config.time_zone`` by
|
||||
@@ -60,11 +65,11 @@ LOGGER = logging.getLogger(__name__)
|
||||
SWEEP_WATERMARK_KEY = "missed_sweep_last_run_date"
|
||||
|
||||
#: One asyncio.Lock per (connection wrapper, running loop), mirroring
|
||||
#: the settings store's pattern: the whole watermark read → query →
|
||||
#: build → 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.
|
||||
#: the settings store's pattern: the watermark read → query → build
|
||||
#: span is one critical section, so an overlapping startup sweep and
|
||||
#: rollover sweep of the SAME process queue instead of interleaving.
|
||||
#: Same-process callers are fully serialised by it; the cross-process
|
||||
#: case is closed by the database-level claim, not by this lock.
|
||||
_SWEEP_LOCKS: dict[tuple[int, int], asyncio.Lock] = {}
|
||||
|
||||
|
||||
@@ -99,15 +104,24 @@ async def run_missed_sweep(
|
||||
the caller decides how to publish them and announces nothing on
|
||||
its own.
|
||||
|
||||
The whole watermark read → query → build → 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. Every event in one run shares one
|
||||
The watermark read → query → build span runs inside the
|
||||
per-database sweep lock, so overlapping callers of ONE process
|
||||
queue instead of interleaving. The announce decision is the
|
||||
DATABASE-level claim (:meth:`MetaStateDao.claim` — a single
|
||||
conditional upsert on the watermark, atomic across processes):
|
||||
every caller builds the events optimistically, then only the
|
||||
caller whose claim actually advanced the watermark returns them;
|
||||
a loser (another process already announced for ``today``) returns
|
||||
[] and announces nothing twice. Every event in one run shares one
|
||||
``occurred_at`` stamp (one run, one announcement moment), and the
|
||||
payload comes from the shared
|
||||
:func:`~.events._instance_payload` builder so the shape can never
|
||||
drift from the documented §3 contract.
|
||||
|
||||
Failure ordering: the watermark is advanced ONLY by the claim,
|
||||
AFTER the events are fully built — a failure before the claim
|
||||
leaves the watermark unadvanced, so a later run re-sweeps the same
|
||||
window and nothing is lost.
|
||||
"""
|
||||
today_iso = today.isoformat()
|
||||
dao = MetaStateDao(database)
|
||||
@@ -134,10 +148,23 @@ async def run_missed_sweep(
|
||||
),
|
||||
)
|
||||
)
|
||||
await dao.set(SWEEP_WATERMARK_KEY, today_iso)
|
||||
# The database-level claim gates the announcement: only the
|
||||
# caller that actually advanced the watermark may publish the
|
||||
# built events, so two processes racing one SQLite file cannot
|
||||
# both announce the same missed batch. A same-value loser's
|
||||
# claim matches zero rows and this run announces nothing.
|
||||
claimed = await dao.claim(SWEEP_WATERMARK_KEY, today_iso)
|
||||
if not claimed:
|
||||
LOGGER.info(
|
||||
"NestQuest missed sweep for %s lost the watermark claim; "
|
||||
"announcing nothing",
|
||||
today_iso,
|
||||
)
|
||||
return []
|
||||
LOGGER.info(
|
||||
"NestQuest missed sweep for %s built %d event(s)",
|
||||
today_iso,
|
||||
len(events),
|
||||
)
|
||||
return events
|
||||
|
||||
|
||||
@@ -21,8 +21,6 @@ missed instances, and ``admin_instances`` includes them (D-009).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import unicodedata
|
||||
from collections.abc import Callable, Iterable
|
||||
from typing import Any
|
||||
|
||||
@@ -34,6 +32,7 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
from homeassistant.util import slugify
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import (
|
||||
@@ -50,9 +49,12 @@ from .core.snapshot import instance_payload
|
||||
#: sensors reuse the same helper.
|
||||
MAX_STATE_LENGTH = 255
|
||||
|
||||
#: Entity-id length cap the slug mirror applies: HA truncates the
|
||||
#: entity_id (``sensor.`` + slug) at MAX_LENGTH_STATE_ENTITY_ID (64),
|
||||
#: so the slugged object id alone may not exceed 64 - len("sensor.").
|
||||
#: Entity-id length cap the roster applies to HA's slugify: HA's
|
||||
#: slugifier itself does NOT truncate, but the entity registry derives
|
||||
#: entity ids as ``<domain>.<slug>`` and truncates the full entity_id at
|
||||
#: MAX_LENGTH_STATE_ENTITY_ID (64), so for this module's ``sensor.``
|
||||
#: entities the slugged object id alone may not exceed
|
||||
#: 64 - len("sensor.").
|
||||
_MAX_SLUG_LENGTH = 64 - len("sensor.")
|
||||
|
||||
|
||||
@@ -108,23 +110,18 @@ def _next_open_quest(
|
||||
def _slugify(text: str) -> str:
|
||||
"""Slugify a display name the way Home Assistant derives entity ids.
|
||||
|
||||
Mirrors ``homeassistant.util.slugify`` (the unicode path HA runs
|
||||
entity names through) for accented-Latin names: NFKD-normalize,
|
||||
fold to ASCII (accents drop out with their combining marks),
|
||||
lowercase, and turn every run of non-alphanumerics into one
|
||||
underscore, capped at HA's entity-id length like the registry does.
|
||||
The documented divergence from HA's vendored slugifier is unidecode:
|
||||
HA transliterates any script to ASCII (so e.g. non-Latin names
|
||||
survive), this mirror drops those characters instead, and a name
|
||||
that then slugs to nothing falls through to HA's ``unknown``
|
||||
sentinel — the same answer HA's wrapper gives. (The harness is
|
||||
mock-only — no real HA to import the helper from — and entity ids
|
||||
are registry identity, so the mirror is deliberate.)
|
||||
Runs ``homeassistant.util.slugify`` — the real slugifier HA runs
|
||||
entity names through, which transliterates every script to ASCII
|
||||
through unidecode (so non-Latin names survive: ``京子`` slugs to
|
||||
``jing_zi``) before folding to lowercase and turning every run of
|
||||
non-alphanumerics into one underscore, answering HA's ``unknown``
|
||||
sentinel when a name slugs to nothing — then caps the result at
|
||||
the object-id length the entity registry enforces (the registry
|
||||
truncates the full ``sensor.`` + slug entity_id at 64 characters,
|
||||
so the slug alone is capped like this; HA's slugify does not
|
||||
truncate by itself).
|
||||
"""
|
||||
slug = unicodedata.normalize("NFKD", text)
|
||||
slug = slug.encode("ascii", "ignore").decode("ascii")
|
||||
slug = re.sub(r"[^a-z0-9]+", "_", slug.lower()).strip("_")
|
||||
return (slug or "unknown")[:_MAX_SLUG_LENGTH]
|
||||
return slugify(text)[:_MAX_SLUG_LENGTH]
|
||||
|
||||
|
||||
def _child_roster(children: Iterable[ChildDaySnapshot]) -> list[dict[str, Any]]:
|
||||
|
||||
@@ -21,6 +21,7 @@ dev = [
|
||||
"pytest>=8.0.0",
|
||||
"pytest-asyncio>=0.24",
|
||||
"pyyaml>=6",
|
||||
"unidecode>=1.4.0",
|
||||
"uvicorn>=0.30",
|
||||
"voluptuous>=0.14",
|
||||
]
|
||||
|
||||
@@ -24,16 +24,20 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import importlib.util
|
||||
import inspect
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
import unicodedata
|
||||
import weakref
|
||||
from dataclasses import dataclass
|
||||
from html.entities import name2codepoint
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from unidecode import unidecode as _unidecode
|
||||
|
||||
from tests.admin_jwt_harness import ADMIN_KEY, KID, AdminRunner, jwks_for
|
||||
|
||||
@@ -46,6 +50,7 @@ _HA_MODULES = (
|
||||
"homeassistant.config_entries",
|
||||
"homeassistant.data_entry_flow",
|
||||
"homeassistant.exceptions",
|
||||
"homeassistant.util",
|
||||
"homeassistant.helpers",
|
||||
"homeassistant.helpers.config_validation",
|
||||
"homeassistant.helpers.entity",
|
||||
@@ -94,6 +99,7 @@ for _parent, _child in (
|
||||
("homeassistant", "data_entry_flow"),
|
||||
("homeassistant", "core"),
|
||||
("homeassistant", "exceptions"),
|
||||
("homeassistant", "util"),
|
||||
("homeassistant", "helpers"),
|
||||
("homeassistant.helpers", "config_validation"),
|
||||
("homeassistant.helpers", "entity"),
|
||||
@@ -218,6 +224,68 @@ _exceptions_mock.ConfigEntryNotReady = ConfigEntryNotReady
|
||||
_exceptions_mock.Unauthorized = Unauthorized
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# homeassistant.util.slugify: a faithful stand-in for the real slugifier.
|
||||
#
|
||||
# Real HA (2024.6) wraps python-slugify's frozen legacy pipeline and answers
|
||||
# its "unknown" sentinel when a non-empty name slugs to nothing. The stub
|
||||
# mirrors that pipeline step for step — quotes to dashes, NFKD-normalize,
|
||||
# TRANSLITERATE through unidecode (the backend python-slugify's "auto" mode
|
||||
# prefers, and a dev dependency here), decode HTML entities, NFKD again,
|
||||
# lowercase, drop quotes, fold runs of non-[a-z0-9-] to one dash, collapse
|
||||
# and strip dashes, then map dashes to the requested separator — and was
|
||||
# verified byte-for-byte against real python-slugify across Latin, Cyrillic,
|
||||
# CJK and Greek household-name shapes. Transliteration is the point: HA
|
||||
# derives the per-child entity ids from a name like "京子" as
|
||||
# "nestquest_jing_zi_…", so the roster's slugs must transliterate too,
|
||||
# never dropping characters into "unknown". Deliberately NOT truncating:
|
||||
# real slugify caps nothing; the entity registry truncates the full
|
||||
# entity_id instead.
|
||||
# ---------------------------------------------------------------------------
|
||||
_HA_QUOTE_PATTERN = re.compile(r"[']+")
|
||||
_HA_CHAR_ENTITY_PATTERN = re.compile(r"&(%s);" % "|".join(name2codepoint))
|
||||
_HA_DECIMAL_PATTERN = re.compile(r"&#(\d+);")
|
||||
_HA_HEX_PATTERN = re.compile(r"&#x([\da-fA-F]+);")
|
||||
_HA_NUMBERS_PATTERN = re.compile(r"(?<=\d),(?=\d)")
|
||||
_HA_DISALLOWED_CHARS_PATTERN = re.compile(r"[^-a-zA-Z0-9]+")
|
||||
_HA_DUPLICATE_DASH_PATTERN = re.compile(r"-{2,}")
|
||||
|
||||
|
||||
def _ha_slugify(text: str | None, *, separator: str = "_") -> str:
|
||||
"""Faithful homeassistant.util.slugify stand-in (see section above)."""
|
||||
if text == "" or text is None:
|
||||
return ""
|
||||
text = str(text)
|
||||
text = _HA_QUOTE_PATTERN.sub("-", text)
|
||||
text = unicodedata.normalize("NFKD", text)
|
||||
text = _unidecode(text)
|
||||
text = _HA_CHAR_ENTITY_PATTERN.sub(
|
||||
lambda match: chr(name2codepoint[match.group(1)]), text
|
||||
)
|
||||
try:
|
||||
text = _HA_DECIMAL_PATTERN.sub(
|
||||
lambda match: chr(int(match.group(1))), text
|
||||
)
|
||||
except (ValueError, OverflowError):
|
||||
pass
|
||||
try:
|
||||
text = _HA_HEX_PATTERN.sub(
|
||||
lambda match: chr(int(match.group(1), 16)), text
|
||||
)
|
||||
except (ValueError, OverflowError):
|
||||
pass
|
||||
text = unicodedata.normalize("NFKD", text)
|
||||
text = text.lower()
|
||||
text = _HA_QUOTE_PATTERN.sub("", text)
|
||||
text = _HA_NUMBERS_PATTERN.sub("", text)
|
||||
text = _HA_DISALLOWED_CHARS_PATTERN.sub("-", text)
|
||||
text = _HA_DUPLICATE_DASH_PATTERN.sub("-", text).strip("-")
|
||||
return "unknown" if text == "" else text.replace("-", separator)
|
||||
|
||||
|
||||
_ha_mock("homeassistant.util").slugify = _ha_slugify
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeviceInfo:
|
||||
"""Stand-in for homeassistant.helpers.entity.DeviceInfo.
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
"""Cross-process safety of the missed-sweep watermark claim.
|
||||
|
||||
Feature 11's sweep guards its announcement with the watermark; the
|
||||
original guard was a read-then-set inside a per-PROCESS asyncio lock,
|
||||
so two PROCESSES sharing one SQLite file (the HA integration's
|
||||
rollover sweep and the API service's scheduler) could both read the
|
||||
old watermark and both announce the same missed batch. These tests
|
||||
pin the fix:
|
||||
|
||||
- :meth:`MetaStateDao.claim` is the atomic, database-level primitive:
|
||||
one conditional upsert whose ``rowcount`` says whether THIS caller
|
||||
changed the row. The first claimant of a value wins; a second
|
||||
claimant of the same value — even on an INDEPENDENT connection to
|
||||
the same file, the shape two processes produce — loses.
|
||||
- :func:`run_missed_sweep` gates the announcement on that claim: two
|
||||
independent ``NestQuestDatabase`` wrappers (two processes' shape)
|
||||
racing one seeded database yields exactly one announcer.
|
||||
- The documented sweep semantics are unchanged: a same-night rerun
|
||||
announces nothing, and a run after days of downtime announces the
|
||||
accumulated ``[watermark, today)`` window exactly once.
|
||||
|
||||
Everything here runs against the bundled HA-free core copy
|
||||
(``custom_components.nestquest.core``) with a plain
|
||||
``asyncio.to_thread`` executor — no Home Assistant, no API server.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from custom_components.nestquest.core.dao_instances import (
|
||||
QuestInstancesDao,
|
||||
)
|
||||
from custom_components.nestquest.core.dao_meta import MetaStateDao
|
||||
from custom_components.nestquest.core.db import NestQuestDatabase
|
||||
from custom_components.nestquest.core.migrations import apply_migrations
|
||||
from custom_components.nestquest.core.children import create_child
|
||||
from custom_components.nestquest.core.quest_definitions import (
|
||||
create_quest_definition,
|
||||
)
|
||||
from custom_components.nestquest.core.materialize import materialize
|
||||
from custom_components.nestquest.core.recurrence import ScheduleRule
|
||||
from custom_components.nestquest.core.sweep import (
|
||||
SWEEP_WATERMARK_KEY,
|
||||
run_missed_sweep,
|
||||
)
|
||||
|
||||
#: One missed event per unswept past-due instance — the sweep's only
|
||||
#: event type.
|
||||
EVENT_QUEST_MISSED = "nestquest_quest_missed"
|
||||
|
||||
|
||||
async def _executor(fn, *args, **kwargs):
|
||||
"""Run a synchronous callable on a worker thread and await it.
|
||||
|
||||
The :class:`NestQuestDatabase` executor contract (the shape of
|
||||
``hass.async_add_executor_job``), so sqlite3 calls never block the
|
||||
event loop.
|
||||
"""
|
||||
return await asyncio.to_thread(fn, *args, **kwargs)
|
||||
|
||||
|
||||
async def _open_database(db_path: str) -> NestQuestDatabase:
|
||||
"""Open (without migrating) one independent connection wrapper."""
|
||||
database = NestQuestDatabase(_executor)
|
||||
await database.open(db_path)
|
||||
return database
|
||||
|
||||
|
||||
async def _seed_missed_household(
|
||||
db_path: str, today: datetime.date
|
||||
) -> SimpleNamespace:
|
||||
"""Seed a household with two past-due open instances on ``db_path``.
|
||||
|
||||
One child, two quests whose ONLY instances are due YESTERDAY
|
||||
(materialized with ``today`` pinned to yesterday, ``end_date``
|
||||
closing the rules so the later today-walk cannot duplicate them).
|
||||
Seeding uses its OWN connection, opened and closed here, so the
|
||||
connections the tests race with start fresh.
|
||||
"""
|
||||
database = await _open_database(db_path)
|
||||
try:
|
||||
await apply_migrations(database)
|
||||
today_iso = today.isoformat()
|
||||
yesterday = today - datetime.timedelta(days=1)
|
||||
yesterday_iso = yesterday.isoformat()
|
||||
rule = ScheduleRule.from_dict(
|
||||
{
|
||||
"rule_type": "daily",
|
||||
"start_date": yesterday_iso,
|
||||
"end_date": yesterday_iso,
|
||||
}
|
||||
)
|
||||
ada = await create_child(database, "Ada", sort_order=0)
|
||||
await create_quest_definition(
|
||||
database, "Stale chore", rule, [ada.id], [("morning", "09:00")]
|
||||
)
|
||||
await create_quest_definition(
|
||||
database, "Old chore", rule, [ada.id], [("evening", "18:30")]
|
||||
)
|
||||
await materialize(database, yesterday_iso, yesterday_iso, today=yesterday)
|
||||
# A quest due TODAY (created after the yesterday walk so it has
|
||||
# no past-due instance): the post-downtime catch-up run's
|
||||
# target — inside [watermark=today, later_today) but not
|
||||
# inside the day-one sweep's window.
|
||||
await create_quest_definition(
|
||||
database,
|
||||
"Recoverable chore",
|
||||
ScheduleRule.from_dict(
|
||||
{"rule_type": "daily", "start_date": today_iso}
|
||||
),
|
||||
[ada.id],
|
||||
[("afternoon", "17:00")],
|
||||
)
|
||||
await materialize(database, today_iso, today_iso, today=today)
|
||||
instances = QuestInstancesDao(database)
|
||||
rows = await instances.list_by_child_and_date(ada.id, yesterday_iso)
|
||||
assert len(rows) == 2
|
||||
today_rows = await instances.list_by_child_and_date(
|
||||
ada.id, today_iso
|
||||
)
|
||||
assert len(today_rows) == 1
|
||||
return SimpleNamespace(
|
||||
child=ada,
|
||||
instances=rows,
|
||||
recoverable_instance=today_rows[0],
|
||||
yesterday_iso=yesterday_iso,
|
||||
today_iso=today_iso,
|
||||
)
|
||||
finally:
|
||||
await database.close()
|
||||
|
||||
|
||||
# --- the claim primitive: atomic at the database level ------------------
|
||||
|
||||
|
||||
async def test_claim_first_caller_wins_and_second_same_value_loses(
|
||||
temp_db_path: str,
|
||||
) -> None:
|
||||
"""claim() is a conditional upsert: first True, same-value second False.
|
||||
|
||||
A different value always wins (an advance re-arms the check), and
|
||||
the stored value follows the winner.
|
||||
"""
|
||||
database = await _open_database(temp_db_path)
|
||||
try:
|
||||
await apply_migrations(database)
|
||||
dao = MetaStateDao(database)
|
||||
assert await dao.get(SWEEP_WATERMARK_KEY) is None
|
||||
|
||||
assert await dao.claim(SWEEP_WATERMARK_KEY, "2026-09-01") is True
|
||||
# Same value again: the row is already there — nothing changed.
|
||||
assert await dao.claim(SWEEP_WATERMARK_KEY, "2026-09-01") is False
|
||||
assert await dao.get(SWEEP_WATERMARK_KEY) == "2026-09-01"
|
||||
# A DIFFERENT value is an advance, not a re-claim: it wins.
|
||||
assert await dao.claim(SWEEP_WATERMARK_KEY, "2026-09-02") is True
|
||||
assert await dao.get(SWEEP_WATERMARK_KEY) == "2026-09-02"
|
||||
assert await dao.claim(SWEEP_WATERMARK_KEY, "2026-09-02") is False
|
||||
finally:
|
||||
await database.close()
|
||||
|
||||
|
||||
async def test_claim_is_atomic_across_independent_connections(
|
||||
temp_db_path: str,
|
||||
) -> None:
|
||||
"""Two connections to one file cannot both claim the same value.
|
||||
|
||||
This is the two-process shape: independent sqlite3 connections (one
|
||||
per ``NestQuestDatabase`` wrapper), racing the SAME key on the SAME
|
||||
event loop with no shared lock. The single conditional upsert is
|
||||
serialized by SQLite itself, so exactly one caller's rowcount is 1.
|
||||
"""
|
||||
first = await _open_database(temp_db_path)
|
||||
second = await _open_database(temp_db_path)
|
||||
try:
|
||||
await apply_migrations(first)
|
||||
won = await asyncio.gather(
|
||||
MetaStateDao(first).claim(SWEEP_WATERMARK_KEY, "2026-09-01"),
|
||||
MetaStateDao(second).claim(SWEEP_WATERMARK_KEY, "2026-09-01"),
|
||||
)
|
||||
assert sorted(won) == [False, True]
|
||||
# One stored row, holding the winner's value.
|
||||
assert await MetaStateDao(first).get(SWEEP_WATERMARK_KEY) == (
|
||||
"2026-09-01"
|
||||
)
|
||||
assert await MetaStateDao(second).get(SWEEP_WATERMARK_KEY) == (
|
||||
"2026-09-01"
|
||||
)
|
||||
finally:
|
||||
await first.close()
|
||||
await second.close()
|
||||
|
||||
|
||||
# --- the sweep: the announcement is gated by the atomic claim -----------
|
||||
|
||||
|
||||
async def test_two_process_shape_race_exactly_one_announces(
|
||||
temp_db_path: str,
|
||||
) -> None:
|
||||
"""Two independent connections racing the sweep: ONE announcer.
|
||||
|
||||
Each wrapper carries its own connection AND its own per-process
|
||||
sweep lock, so nothing in-process serialises the two runs — the
|
||||
only guard left is the database-level claim. Both read the unset
|
||||
watermark and both build the events, but exactly one claim wins:
|
||||
one run returns the missed events, the loser returns [].
|
||||
"""
|
||||
today = datetime.datetime.now().astimezone().date()
|
||||
seed = await _seed_missed_household(temp_db_path, today)
|
||||
first = await _open_database(temp_db_path)
|
||||
second = await _open_database(temp_db_path)
|
||||
try:
|
||||
results = await asyncio.gather(
|
||||
run_missed_sweep(first, today=today),
|
||||
run_missed_sweep(second, today=today),
|
||||
)
|
||||
announcers = [r for r in results if r]
|
||||
assert len(announcers) == 1
|
||||
event_type, payload = announcers[0][0]
|
||||
assert event_type == EVENT_QUEST_MISSED
|
||||
assert payload["due_date"] == seed.yesterday_iso
|
||||
# Both instances of the seeded window, one event each.
|
||||
assert len(announcers[0]) == 2
|
||||
losers = [r for r in results if not r]
|
||||
assert losers == [[]]
|
||||
# The watermark holds the run's date for whoever looks next.
|
||||
assert (
|
||||
await MetaStateDao(first).get(SWEEP_WATERMARK_KEY)
|
||||
== seed.today_iso
|
||||
)
|
||||
finally:
|
||||
await first.close()
|
||||
await second.close()
|
||||
|
||||
|
||||
async def test_same_night_rerun_announces_nothing(temp_db_path: str) -> None:
|
||||
"""A second sweep for the same date is an empty no-op (the watermark)."""
|
||||
today = datetime.datetime.now().astimezone().date()
|
||||
await _seed_missed_household(temp_db_path, today)
|
||||
database = await _open_database(temp_db_path)
|
||||
try:
|
||||
first = await run_missed_sweep(database, today=today)
|
||||
assert len(first) == 2
|
||||
second = await run_missed_sweep(database, today=today)
|
||||
assert second == []
|
||||
finally:
|
||||
await database.close()
|
||||
|
||||
|
||||
async def test_post_downtime_run_announces_accumulated_window_once(
|
||||
temp_db_path: str,
|
||||
) -> None:
|
||||
"""After days of downtime the catch-up run sweeps the window once.
|
||||
|
||||
The first run sweeps yesterday's instances (watermark := today);
|
||||
``today`` then jumps two days and the catch-up run — on a
|
||||
DIFFERENT connection, the two-process shape — announces the
|
||||
accumulated ``[watermark, later_today)`` window exactly once, and
|
||||
a rerun on the original connection is silent again.
|
||||
"""
|
||||
today = datetime.datetime.now().astimezone().date()
|
||||
later_today = today + datetime.timedelta(days=2)
|
||||
seed = await _seed_missed_household(temp_db_path, today)
|
||||
first = await _open_database(temp_db_path)
|
||||
second = await _open_database(temp_db_path)
|
||||
try:
|
||||
# Day one: yesterday's two instances fire once.
|
||||
day_one = await run_missed_sweep(first, today=today)
|
||||
assert len(day_one) == 2
|
||||
assert all(
|
||||
payload["due_date"] == seed.yesterday_iso
|
||||
for _, payload in day_one
|
||||
)
|
||||
|
||||
# The downtime catch-up: the window is [watermark, later_today).
|
||||
catchup = await run_missed_sweep(second, today=later_today)
|
||||
assert len(catchup) == 1
|
||||
event_type, payload = catchup[0]
|
||||
assert event_type == EVENT_QUEST_MISSED
|
||||
assert payload["due_date"] == seed.today_iso
|
||||
assert payload["instance_id"] == seed.recoverable_instance.id
|
||||
assert payload["child_id"] == seed.child.id
|
||||
|
||||
# A further rerun announces nothing.
|
||||
assert await run_missed_sweep(first, today=later_today) == []
|
||||
assert (
|
||||
await MetaStateDao(first).get(SWEEP_WATERMARK_KEY)
|
||||
== later_today.isoformat()
|
||||
)
|
||||
finally:
|
||||
await first.close()
|
||||
await second.close()
|
||||
+53
-2
@@ -12,6 +12,7 @@ from __future__ import annotations
|
||||
import datetime
|
||||
|
||||
import pytest
|
||||
from homeassistant.util import slugify
|
||||
|
||||
from conftest import StubSnapshotClient, set_coordinator_client, wire_entry_to_registry
|
||||
|
||||
@@ -637,8 +638,9 @@ async def test_empty_household_publishes_an_empty_roster(
|
||||
|
||||
|
||||
def test_slugify_mirrors_home_assistant_entity_id_derivation() -> None:
|
||||
"""The slug mirror matches homeassistant.util.slugify for the names
|
||||
children are registered with (verified against HA's own slugify)."""
|
||||
"""The roster slugs match homeassistant.util.slugify — the real
|
||||
slugifier HA derives entity ids from, composed with the entity-id
|
||||
length cap the entity registry applies to ``sensor.`` ids."""
|
||||
assert _slugify("Ada") == "ada"
|
||||
assert _slugify("Mary Jane") == "mary_jane"
|
||||
assert _slugify("Mary-Jane") == "mary_jane"
|
||||
@@ -652,3 +654,52 @@ def test_slugify_mirrors_home_assistant_entity_id_derivation() -> None:
|
||||
# The registry caps the entity_id at 64 characters, so the slugged
|
||||
# object id alone is capped at 64 - len("sensor.").
|
||||
assert _slugify("a" * 80) == "a" * (64 - len("sensor."))
|
||||
|
||||
|
||||
def test_slugify_transliterates_non_latin_names_like_home_assistant() -> None:
|
||||
"""HA's slugifier transliterates every script through unidecode,
|
||||
so a non-Latin child name slugs to its romanization — never to the
|
||||
``unknown`` sentinel — and the roster publishes the same slug HA's
|
||||
entity-id derivation produces for that name."""
|
||||
assert _slugify("Дана") == "dana"
|
||||
assert _slugify("京子") == "jing_zi"
|
||||
assert _slugify("Zoë") == "zoe"
|
||||
|
||||
|
||||
async def test_non_latin_child_names_resolve_auto_discovery(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""A non-Latin child name slugs through the real slugifier, so the
|
||||
roster slug IS the object id of the per-child sensor's entity_id
|
||||
and the cards' auto-discovery resolves that child.
|
||||
|
||||
HA derives the per-child entity id from the entity NAME ("NestQuest
|
||||
京子 quests due today") with the same slugifier the roster uses, so
|
||||
the two must agree exactly; the reviewed mirror dropped non-Latin
|
||||
characters (slugging "京子" to "unknown"), so the cards built
|
||||
"sensor.nestquest_unknown_…" ids that no entity had."""
|
||||
payload = _payload(
|
||||
[
|
||||
_child(ADA, "Дана", [_instance(7, ADA)]),
|
||||
_child(BO, "京子", [_instance(8, BO)]),
|
||||
]
|
||||
)
|
||||
entry, _coordinator, _client = await _setup_entry(hass, make_entry, payload)
|
||||
roster = hass.entities[
|
||||
"nestquest_household_quests_due_today"
|
||||
].extra_state_attributes["child_roster"]
|
||||
assert roster == [
|
||||
{"child_id": ADA, "name": "Дана", "slug": "dana"},
|
||||
{"child_id": BO, "name": "京子", "slug": "jing_zi"},
|
||||
]
|
||||
for roster_entry in roster:
|
||||
due_entity = _sensor(hass, roster_entry["child_id"], "quests_due_today")
|
||||
assert due_entity.name == (
|
||||
f"NestQuest {roster_entry['name']} quests due today"
|
||||
)
|
||||
# The entity id HA's registry derives for this entity (from the
|
||||
# same slugifier) embeds exactly the slug the roster publishes:
|
||||
# the card builds sensor.nestquest_<slug>_quests_due_today.
|
||||
assert slugify(due_entity.name) == (
|
||||
f"nestquest_{roster_entry['slug']}_quests_due_today"
|
||||
)
|
||||
|
||||
@@ -385,6 +385,7 @@ dev = [
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "unidecode" },
|
||||
{ name = "uvicorn" },
|
||||
{ name = "voluptuous" },
|
||||
]
|
||||
@@ -400,6 +401,7 @@ dev = [
|
||||
{ name = "pytest", specifier = ">=8.0.0" },
|
||||
{ name = "pytest-asyncio", specifier = ">=0.24" },
|
||||
{ name = "pyyaml", specifier = ">=6" },
|
||||
{ name = "unidecode", specifier = ">=1.4.0" },
|
||||
{ name = "uvicorn", specifier = ">=0.30" },
|
||||
{ name = "voluptuous", specifier = ">=0.14" },
|
||||
]
|
||||
@@ -689,6 +691,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unidecode"
|
||||
version = "1.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/94/7d/a8a765761bbc0c836e397a2e48d498305a865b70a8600fd7a942e85dcf63/Unidecode-1.4.0.tar.gz", hash = "sha256:ce35985008338b676573023acc382d62c264f307c8f7963733405add37ea2b23", size = 200149, upload-time = "2025-04-24T08:45:03.798Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/b7/559f59d57d18b44c6d1250d2eeaa676e028b9c527431f5d0736478a73ba1/Unidecode-1.4.0-py3-none-any.whl", hash = "sha256:c3c7606c27503ad8d501270406e345ddb480a7b5f38827eafe4fa82a137f0021", size = 235837, upload-time = "2025-04-24T08:45:01.609Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uvicorn"
|
||||
version = "0.53.0"
|
||||
|
||||
Reference in New Issue
Block a user