phase: 01_runnable_service_scaffold

**Phase 01 final verification pass — all criteria green**

- Verified (nothing left to implement; all 4 task files already in `complete/`): toolchain lock, typed `Settings`+`.env.example`, debugpy bootstrap, app factory, CLI (`--host/--port/--reload/--levels-dir`, `python -m undercooked`), static shell + `/static`, security headers, no-CDN/doc guards, `validate.sh`.
- `uv sync --extra dev` → exit 0; `uv.lock` present (untracked → harness commits it with the phase).
- `uv run undercooked --host 127.0.0.1 --port 8000` → `curl /healthz` = 200 `{"status":"ok","version":"0.1.0","rooms":0}` (superset of the criterion's body — now documented in README); `curl /` = 200 shell HTML; static css/js 200; CSP/nosniff/no-referrer/DENY on 200, 404 and 405 too.
- `DEBUGPY=1`: port 5678 is held on this machine by the user's own podman dev-container debugpy adapter — **no live change made/killed**; verified via `DEBUGPY=1 DEBUGPY_PORT=5679` → listener opens, `/healthz` still 200; the occupied-port case degraded gracefully as designed (warning, server serves). Bad config → one line + exit 2 ✓.
- `bash .agents/validate.sh` (root **and** `.agents/`) → exit 0: `ruff check .` 0 errors, `pyright` 0 errors, levels-CLI notice-skip (matches README prose), **284 passed** full suite incl. 10 Playwright e2e, coverage **100.00%** (floor 90), repo hygiene clean. `uv run pytest -m e2e` → 10 passed.
- Docs fixed: README Status → phase 01 complete + next pending 02 (was pointing at `todo/01…`), documented real `/healthz` body, precedence now "CLI flags > env > .env > default", added the PLAN §13-promised **How to play** section (honest about shell-only today), Layout rows marked what exists vs phases 02/07. AGENTS.md: recorded the `data:`-URL exemption in the no-CDN rule (test allows it) and matched the session-token rule to the browser's WebCrypto/base64url implementation. Also corrected a stale "session-wide chromium" docstring in `tests/e2e/conftest.py` (fixtures are function-scoped).
- Deviations: no container build run (needs an image pull; not a phase criterion — `Containerfile`/`compose.yaml` reviewed and consistent with `docs/DEPLOYMENT.md`); `HEAD /` is 405 (routes are GET-only by design; health checks use GET). No git index/history commands run.
- Next pending phase: `02_level_content_and_validation`.
This commit is contained in:
2026-10-06 01:51:20 -04:00
parent f84c6ebaef
commit 6fd83446ec
43 changed files with 5478 additions and 30 deletions
+61 -8
View File
@@ -15,7 +15,10 @@
(`00_phase.md` + `NN_task.md`). Finished work: `.agents/phases/complete/`. (`00_phase.md` + `NN_task.md`). Finished work: `.agents/phases/complete/`.
- Execution is owned by the `phased-execution` skill (`run-task.sh`, `run-phase.sh`, - Execution is owned by the `phased-execution` skill (`run-task.sh`, `run-phase.sh`,
`auto-phase.sh`). Each task runs in a fresh subprocess and must pass `auto-phase.sh`). Each task runs in a fresh subprocess and must pass
`.agents/validate.sh` before it moves to `complete/`. `.agents/validate.sh` before it moves to `complete/`. That gate fails fast in this
order: `ruff check .` → `pyright` → `undercooked-levels validate` (skipped with a
notice until phase 02 creates the loader) → the **full** `pytest` suite with the
coverage floor → repo-hygiene checks. It runs from the repo root or from `.agents/`.
- New work is authored by the `phase-authoring` skill as a phase directory — not - New work is authored by the `phase-authoring` skill as a phase directory — not
implemented straight from chat. implemented straight from chat.
- **Never** modify `.agents/PLAN.md` or anything under `.agents/phases/complete/`. - **Never** modify `.agents/PLAN.md` or anything under `.agents/phases/complete/`.
@@ -60,16 +63,52 @@
## 5. Hard rules ## 5. Hard rules
- **Configuration is read once, in `config.py`.** `src/undercooked/config.py` is
the only module that touches `os.environ`. To add a knob: add a `Settings`
field + `ENV_KEYS` entry + a line in `.env.example` + a row in the README table
(a test fails when those disagree), and read it from `get_settings()`. Values
are validated at startup: an impossible one raises `ConfigError` rather than
half-configuring the server (`undercooked` prints one line and exits `2`).
Precedence is **CLI flag > environment > built-in default**: `__main__.py`
resolves it purely, then hands the flags to `config.apply_cli_overrides()`,
which is the only writer of `os.environ` (so a uvicorn `--reload` child
re-reads the same values). `DEBUGPY=1` binds a debugger listener on `0.0.0.0`
— dev-only, never on a public host.
- **No external CDNs or remote assets.** `tests/unit/test_no_external_assets.py` - **No external CDNs or remote assets.** `tests/unit/test_no_external_assets.py`
fails the build on `http://`/`https://`/`//cdn` references in `app/static/` and fails the build on `http://`/`https://`/`//host` references, `@import url(`,
templates. CSP is `default-src 'self'` (plus `ws:`/`wss:` same-origin). `srcset`, an absolute-URL `fetch()`/`import`, an absolute-URL `WebSocket`, or
`EventSource` anywhere in `app/static/**` (html/css/js/svg), and requires every
`<script src>`/`<link href>`/`<img src>` in `index.html` to be a same-origin
`/static/...` path. It scans the same `STATIC_DIR` `server/app.py` mounts, so it
cannot pass by scanning the wrong folder; the exemptions are XML `xmlns` URIs, which
are identifiers the browser never fetches, and a `data:` URL in an `index.html`
element attribute where the CSP already allows it (`img-src 'self' data:`) — the
app's own bytes either way. The US-01 e2e suite additionally
fails if loading the page ever issues an off-origin request. CSP is
`default-src 'self'` (plus `ws:`/`wss:` same-origin) and is applied by
`server/headers.py` to **every** response, including errors. FastAPI's `/docs`,
`/redoc` and `/openapi.json` stay disabled because their bundled HTML pulls Swagger
assets from `cdn.jsdelivr.net`; the wire contract lives in `docs/PROTOCOL.md`
(written by phase 06) instead.
- **Docs are guarded like code.** `tests/unit/test_docs_commands.py` requires every
command line in a `bash` fence of `README.md`/`docs/*.md` to start with a known-safe
prefix (`uv `, `podman `, `curl `, `cd `, `bash .agents/validate.sh`, `#`, or a
documented `VAR=value` lead) and to launch only a program declared in
`pyproject.toml`, and keeps the `UNDERCOOKED_*`/`DEBUGPY*` names in the docs and the
ones in `config.py`/`.env.example` equal in both directions. So: never document a
command that does not run yet — describe it in prose until the phase that ships it.
`tests/unit/test_docs_index.py` keeps PLAN §13's documentation index and the `docs/`
tree identical (no missing doc for a shipped phase, no doc nothing promises), and
`docs/TESTING.md` is the reference for test layout, markers and artifact policy.
- **No new technology** (dependency, framework, service) without explicit written - **No new technology** (dependency, framework, service) without explicit written
user permission recorded in the phase's `00_phase.md` and signed off into PLAN.md. user permission recorded in the phase's `00_phase.md` and signed off into PLAN.md.
- **The simulation is the single source of truth.** Client-side values are guesses. - **The simulation is the single source of truth.** Client-side values are guesses.
Never trust inbound coordinates, scores, timers, or level ids — validate every Never trust inbound coordinates, scores, timers, or level ids — validate every
inbound field against the room's state (`server/protocol.py`). inbound field against the room's state (`server/protocol.py`).
- **Anonymous by design.** No accounts, no emails, no cookies with personal data. - **Anonymous by design.** No accounts, no emails, no cookies with personal data.
Identity = a random per-browser token (`secrets.token_urlsafe`) held in Identity = 24 random bytes per browser tab (`secrets.token_urlsafe(24)` when the
server mints one, `crypto.getRandomValues` + base64url in `app/static/js/app.js`,
which is the same entropy and the only option in a browser) held in
`sessionStorage`. Names are sanitized, length-capped, non-authoritative. `sessionStorage`. Names are sanitized, length-capped, non-authoritative.
- **Frame and rate limits are enforced, not suggested**: `max_message_bytes`, - **Frame and rate limits are enforced, not suggested**: `max_message_bytes`,
`max_inputs_per_s`, room caps, and message-type whitelists per room state. `max_inputs_per_s`, room caps, and message-type whitelists per room state.
@@ -92,14 +131,28 @@
| Change | Location | | Change | Location |
|---|---| |---|---|
| Configuration (env vars → typed `Settings`), debugpy bootstrap | `src/undercooked/config.py`, `.env.example`, `src/undercooked/debug.py` |
| Pure game rules (movement, stations, orders, scoring, hazards) | `src/undercooked/sim/` | | Pure game rules (movement, stations, orders, scoring, hazards) | `src/undercooked/sim/` |
| Level/recipe content | `levels/*.yaml` | | Level/recipe content | `levels/*.yaml` |
| Rooms, lobby lifecycle, session records | `src/undercooked/server/rooms.py`, `room.py` | | Rooms, lobby lifecycle, session records | `src/undercooked/server/rooms.py`, `room.py` |
| Wire messages + validation | `src/undercooked/server/protocol.py`, `docs/PROTOCOL.md` | | Wire messages + validation | `src/undercooked/server/protocol.py`, `docs/PROTOCOL.md` |
| HTTP app, static serving, headers | `src/undercooked/server/app.py` | | HTTP app (`/`, `/healthz`, `/static`), lifespan | `src/undercooked/server/app.py` |
| Security headers (CSP `default-src 'self'`, no-sniff, frame-ancestors) | `src/undercooked/server/headers.py` |
| CLI entry point (`undercooked`): flags → `--help` → serve | `src/undercooked/__main__.py` |
| Renderer, HUD, input, interpolation | `app/static/js/`, `app/static/assets/` | | Renderer, HUD, input, interpolation | `app/static/js/`, `app/static/assets/` |
| Documentation (design / testing / deployment; per-phase docs) | `docs/*.md`, indexed by PLAN §13 and guarded by `tests/unit/test_docs_index.py` |
| The pipeline gate itself | `.agents/validate.sh` |
| User-visible flow contract | `.agents/user_stories/US-XX_*.md` | | User-visible flow contract | `.agents/user_stories/US-XX_*.md` |
Tests mirror that: `tests/unit/`, `tests/server/` (HTTP routes and stores), Tests mirror that: `tests/unit/` (pure, no-I/O logic incl. HTTP-route unit tests),
`tests/integration/` (live uvicorn + WebSocket clients), `tests/sim/` (headless `tests/integration/` (in-process app boot, live uvicorn + WebSocket clients), `tests/sim/`
determinism and replays), `tests/e2e/` (Playwright, named `test_usNN_*.py`, marked `e2e`). (headless determinism and replays), `tests/e2e/` (Playwright, named `test_usNN_*.py`,
marked `e2e`). `tests/` is not a package; shared fixtures live in `tests/conftest.py`.
- The browser suites use the **`playwright` sync API directly with fixtures written in
`tests/e2e/conftest.py`** — `pytest-playwright` is deliberately not a dependency, so
adding it needs the usual permission. Those `browser`/`context`/`page` fixtures are
**function-scoped on purpose**: a long-lived sync driver keeps an asyncio loop flagged
as running in the main thread and breaks every later `async def` test.
- `live_server` (session-scoped, `tests/conftest.py`) is the only fixture that binds a
socket: an ephemeral `127.0.0.1` port, never a routable interface.
+2 -1
View File
@@ -16,7 +16,8 @@ ENV PYTHONUNBUFFERED=1 \
DEBUGPY=0 \ DEBUGPY=0 \
UNDERCOOKED_HOST=0.0.0.0 \ UNDERCOOKED_HOST=0.0.0.0 \
UNDERCOOKED_PORT=8000 \ UNDERCOOKED_PORT=8000 \
UNDERCOOKED_LEVELS_DIR=/app/levels UNDERCOOKED_LEVELS_DIR=/app/levels \
UNDERCOOKED_STATIC_DIR=/app/app/static
WORKDIR /app WORKDIR /app
RUN useradd --create-home --uid 10001 player RUN useradd --create-home --uid 10001 player
+98 -21
View File
@@ -10,10 +10,40 @@ email, no password: you create a room, share the 5-character code, play.
## Status ## Status
Roadmap authored, implementation pending. This repository currently contains the Phase 01 (runnable service scaffold) is **complete**; the next pending phase is
project scaffold and the `.agents/` phased-execution plan. Execute phases with the `02_level_content_and_validation`. What runs today: the Python toolchain is locked
`phased-execution` skill (`auto-phase.sh`), starting at (`uv.lock`), the pytest harness covers unit, integration and Playwright e2e layers,
`.agents/phases/todo/01_runnable_service_scaffold/`. configuration is a single typed, validated `Settings` object (`DEBUGPY` support
included), and `uv run undercooked` starts a FastAPI server that serves the client
shell at `/`, the browser assets from `app/static/`, and `GET /healthz` — which answers
`{"status":"ok","version":"0.1.0","rooms":0}` (`rooms` stays `0` until phase 05 owns a
supervisor) — with the security headers (CSP `default-src 'self'`) on every response.
The rules are tests and not promises: the no-external-asset rule is
`tests/unit/test_no_external_assets.py`, the documentation set is guarded by
`tests/unit/test_docs_index.py`, `bash .agents/validate.sh` is the gate the phase
pipeline runs after every task, and `docs/` holds the DESIGN / TESTING / DEPLOYMENT
documents. Lobby buttons answer "Not implemented until phase 05.": levels and their
validator land in phase 02, the simulation in 03-04, rooms and the WebSocket protocol
in 05-06, the playable renderer in 07. Execute phases with the `phased-execution` skill
(`auto-phase.sh`); it always takes the lowest-numbered directory still in
`.agents/phases/todo/`.
## How to play
The intended loop, once the room flow ships: open the site, **Create lobby**, read out
the 5-character code, everyone else **Joins** with it, the host picks a level, the room
readies up, and the kitchen starts. Each cook has one movement control and **one action
button** that picks up, uses and puts down whatever is in reach — one carried item at a
time. Orders appear in the HUD with a shrinking deadline; assemble the dish and serve it
at the window before the timer runs out. Levels escalate: more recipes, fewer stations,
hostile floors. Nothing is saved between rounds and nothing identifies you: the browser
keeps only a random per-tab token and the last code you typed, both in `sessionStorage`.
What actually works today is the shell of that: the page loads, normalises a typed code to
five uppercase alphanumerics (codes themselves are Crockford base32, and only get issued
once phase 05 runs the room supervisor), and answers "Not implemented until phase 05."
Movement keys and touch controls are defined by the phase-07/08 client and documented in
`docs/CLIENT.md` once that phase writes it.
## Quickstart (development) ## Quickstart (development)
@@ -22,9 +52,24 @@ for container builds. **There is no database** — all live game state is in mem
```bash ```bash
uv sync --extra dev uv sync --extra dev
uv run undercooked # http://127.0.0.1:8000 uv run undercooked # http://127.0.0.1:8000 (shell page + /healthz)
uv run undercooked --host 0.0.0.0 --port 8000 # e.g. to reach it from a phone
uv run undercooked --reload # dev reload uv run undercooked --reload # dev reload
uv run undercooked-levels validate # check every level file loads and is solvable uv run undercooked --levels-dir levels # point at another content dir
```
`python -m undercooked` is the same command (the container uses it). A flag
always beats the environment, which beats the built-in default. There is no
`/docs` or `/openapi.json`: FastAPI renders those pages from a public CDN, which
this project forbids. `undercooked-levels validate` (check every level file loads
and is solvable) is declared in `pyproject.toml` but becomes runnable in phase 02,
when the content loader lands.
Debug the server with a real debugger attached (dev only - the listener binds
`0.0.0.0`, so never enable it on a public host):
```bash
DEBUGPY=1 uv run undercooked # opens a debugpy listener on :5678, then serves as usual
``` ```
Run the checks: Run the checks:
@@ -32,12 +77,25 @@ Run the checks:
```bash ```bash
uv run ruff check . uv run ruff check .
uv run pyright uv run pyright
uv run pytest -m "not e2e" # unit + integration uv run pytest # everything, coverage floor included
uv run pytest -m "not e2e" # the quick loop: no browser launches
uv run playwright install chromium # once, before the first e2e run uv run playwright install chromium # once, before the first e2e run
uv run pytest -m e2e # browser suites (app must be running) uv run pytest -m e2e # browser suites only
bash .agents/validate.sh # the full pipeline gate bash .agents/validate.sh # the pipeline gate: full suite + ruff + pyright
``` ```
`pytest` with no `-m` runs the browser suites too, so chromium has to be
installed for the pipeline gate. Those suites start the app themselves on a free
loopback port (`live_server` in `tests/conftest.py`) — nothing needs to be
running first, and no test ever listens on a routable interface.
`bash .agents/validate.sh` is the gate a phase task must pass before it moves to
`.agents/phases/complete/`. It stops at the first failure, in this order:
`ruff check .`, `pyright`, `undercooked-levels validate` (skipped with a notice until
phase 02 creates the loader), the **full** `pytest` suite with the 90 % coverage floor,
then repo-hygiene checks (nothing tracked that `.gitignore` excludes, no log drops or
key material committed). Run it from the repo root or from `.agents/`.
## Container ## Container
```bash ```bash
@@ -49,23 +107,31 @@ podman compose up -d
## Configuration ## Configuration
All settings come from the environment (a local `.env` is loaded automatically; All settings come from the environment, parsed once into
never commit it — see `.env.example`). `undercooked.config.Settings` — the only module that reads the environment, so
nothing else in the codebase touches `os.environ`. Precedence: `undercooked` CLI
flags, then exported environment variables, then a local `.env` (found from the
current directory upwards; never commit it), then the built-in defaults. A blank value counts as
unset. Invalid values (`UNDERCOOKED_TICK_HZ=99`) raise `ConfigError` at startup
instead of half-configuring the server. `.env.example` and the table below list
every variable with its default; tests keep both in sync with `config.py`.
| Variable | Default | Meaning | | Variable | Default | Meaning |
|---|---|---| |---|---|---|
| `UNDERCOOKED_HOST` | `127.0.0.1` | Bind address | | `UNDERCOOKED_HOST` | `127.0.0.1` | Bind address |
| `UNDERCOOKED_PORT` | `8000` | Bind port | | `UNDERCOOKED_PORT` | `8000` | Bind port |
| `UNDERCOOKED_TICK_HZ` | `20` | Server simulation rate | | `UNDERCOOKED_TICK_HZ` | `20` | Server simulation rate (5-60) |
| `UNDERCOOKED_SNAPSHOT_HZ` | `20` | Snapshot broadcast rate to players | | `UNDERCOOKED_SNAPSHOT_HZ` | `20` | Snapshot broadcast rate to players (<= tick rate) |
| `UNDERCOOKED_MAX_PLAYERS` | `4` | Players per room | | `UNDERCOOKED_MAX_PLAYERS` | `4` | Players per room (1-4) |
| `UNDERCOOKED_MAX_ROOMS` | `200` | Concurrent rooms before new rooms are refused | | `UNDERCOOKED_MAX_ROOMS` | `200` | Concurrent rooms before new rooms are refused |
| `UNDERCOOKED_LEVELS_DIR` | `levels/` | Directory of level YAML files | | `UNDERCOOKED_LEVELS_DIR` | `levels` | Directory of level YAML files (`~` is expanded) |
| `UNDERCOOKED_STATIC_DIR` | *(derived)* | Directory served at `/static`; empty = `app/static/` beside the source (containers set it) |
| `UNDERCOOKED_LOBBY_IDLE_TIMEOUT_S` | `900` | Close empty/idle lobbies | | `UNDERCOOKED_LOBBY_IDLE_TIMEOUT_S` | `900` | Close empty/idle lobbies |
| `UNDERCOOKED_GUEST_IDLE_TIMEOUT_S` | `45` | Drop a disconnected player's seat after this | | `UNDERCOOKED_GUEST_IDLE_TIMEOUT_S` | `45` | Drop a disconnected player's seat after this |
| `UNDERCOOKED_MAX_MESSAGE_BYTES` | `4096` | Hard cap on inbound WS frame size | | `UNDERCOOKED_MAX_MESSAGE_BYTES` | `4096` | Hard cap on inbound WS frame size |
| `UNDERCOOKED_MAX_INPUTS_PER_S` | `40` | Per-connection inbound message rate limit | | `UNDERCOOKED_MAX_INPUTS_PER_S` | `40` | Per-connection inbound message rate limit |
| `UNDERCOOKED_SIM_LATENCY_MS` | `0` | Injected server-side downlink latency (netcode tests) | | `UNDERCOOKED_SIM_LATENCY_MS` | `0` | Injected server-side downlink latency (netcode tests) |
| `UNDERCOOKED_ALLOWED_ORIGINS` | *(empty)* | Comma-separated `Origin`s accepted on the WebSocket handshake; empty = same-host only |
| `DEBUGPY` | `0` | `1` = open a debugpy listener on `DEBUGPY_PORT` at startup | | `DEBUGPY` | `0` | `1` = open a debugpy listener on `DEBUGPY_PORT` at startup |
| `DEBUGPY_PORT` | `5678` | debugpy listen port | | `DEBUGPY_PORT` | `5678` | debugpy listen port |
@@ -73,15 +139,26 @@ never commit it — see `.env.example`).
``` ```
src/undercooked/ server: FastAPI app, rooms, WebSocket protocol, simulation src/undercooked/ server: FastAPI app, rooms, WebSocket protocol, simulation
levels/ data-driven level definitions (YAML) + recipes src/undercooked/__main__.py the `undercooked` command (CLI > env > default)
app/static/ browser client: ES modules, Canvas 2D renderer, own SVG art src/undercooked/config.py typed env settings (the only `os.environ` reader)
tests/ unit/, server/, integration/, sim/, e2e/ (one Playwright suite per story) src/undercooked/debug.py DEBUGPY-gated debugger bootstrap
docs/PROTOCOL.md wire protocol contract (versioned) src/undercooked/server/app.py app factory, `/healthz`, `/`, `/static`
.agents/ PLAN.md, user_stories/, phases/ — the phased-execution plan src/undercooked/server/headers.py the security-header middleware
.env.example every configuration variable, with its default
levels/ data-driven level definitions (YAML) + recipes (empty until 02)
app/static/ browser client: the ES-module shell today; Canvas 2D renderer
and this repo's SVG art arrive with phase 07
tests/ unit/, integration/, sim/, e2e/ (one Playwright suite per story);
fixtures/levels/ (test-isolated content dir); conftest.py
docs/ DESIGN.md, TESTING.md (how this project is verified),
DEPLOYMENT.md; PROTOCOL / LEVELS / SIMULATION / CLIENT land with
their own phases (the index is PLAN.md §13)
.agents/ PLAN.md, user_stories/, phases/, validate.sh — the phased-execution plan
``` ```
**No external CDNs.** Every byte of JS, CSS, font, and image is served by this app **No external CDNs.** Every byte of JS, CSS, font, and image is served by this app
from `app/static/`. A test enforces it. from `app/static/`. `tests/unit/test_no_external_assets.py` enforces it, and the
e2e suite fails if loading the page ever issues a request off-origin.
## Contributing ## Contributing
+246
View File
@@ -0,0 +1,246 @@
/* Undercooked — base stylesheet (phase 01 shell).
*
* Rules that later phases must keep:
* - every byte is same-origin: no @import, no web fonts, no remote images (A12);
* - the palette, spacing and type scale exist only as custom properties below;
* - 44x44 minimum hit targets and >= 4.5:1 text contrast (PLAN.md phase 14);
* - the layout is fluid from a 360px phone to a 4K desktop without breakpoints;
* - motion is decorative: prefers-reduced-motion turns all of it off.
*/
:root {
/* Palette — warm kitchen, dark by default. Text pairs are >= 4.5:1. */
--uc-bg: #1a1713;
--uc-surface: #241f1a;
--uc-surface-2: #2f2820;
--uc-line: #857259; /* 3.1:1 on every surface: UI edges are visible (WCAG 1.4.11) */
--uc-ink: #f6f1e7; /* 13.9:1 on --uc-bg */
--uc-ink-muted: #cfc5b5; /* 8.6:1 on --uc-bg */
--uc-accent: #ff9f1c; /* 7.6:1 on --uc-bg */
--uc-accent-ink: #1a1713; /* 7.6:1 on --uc-accent */
--uc-danger: #ff8a80; /* 7.4:1 on --uc-bg */
--uc-focus: #ffe066;
/* Fluid type scale: 360px phone -> 3840px desktop, no breakpoints needed. */
--uc-step--1: clamp(0.78rem, 0.74rem + 0.2vw, 0.92rem);
--uc-step-0: clamp(1rem, 0.95rem + 0.3vw, 1.2rem);
--uc-step-1: clamp(1.25rem, 1.15rem + 0.6vw, 1.7rem);
--uc-step-2: clamp(1.7rem, 1.4rem + 1.6vw, 3rem);
--uc-space-1: 0.5rem;
--uc-space-2: 0.75rem;
--uc-space-3: 1.25rem;
--uc-space-4: 2rem;
--uc-radius: 0.65rem;
--uc-hit: 44px; /* WCAG 2.5.5 minimum target size */
--uc-content: 74rem;
--uc-font: ui-sans-serif, system-ui, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--uc-font-ui: ui-rounded, "Segoe UI Variable Display", var(--uc-font);
--uc-ease: cubic-bezier(0.2, 0.8, 0.2, 1);
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
-webkit-text-size-adjust: 100%;
}
body {
margin: 0;
min-height: 100svh;
display: grid;
grid-template-rows: 1fr auto;
gap: var(--uc-space-3);
padding: max(var(--uc-space-3), env(safe-area-inset-top))
max(var(--uc-space-2), env(safe-area-inset-right))
max(var(--uc-space-3), env(safe-area-inset-bottom))
max(var(--uc-space-2), env(safe-area-inset-left));
background: radial-gradient(120% 90% at 50% -10%, var(--uc-surface-2), var(--uc-bg) 60%);
color: var(--uc-ink);
font-family: var(--uc-font);
font-size: var(--uc-step-0);
line-height: 1.45;
text-rendering: optimizeLegibility;
}
.shell,
.colophon {
width: 100%;
max-width: var(--uc-content);
margin-inline: auto;
}
.masthead {
text-align: center;
}
.wordmark {
margin: 0;
font-family: var(--uc-font-ui);
font-size: var(--uc-step-2);
font-weight: 800;
letter-spacing: 0.01em;
color: var(--uc-accent);
}
.tagline,
.hint,
.colophon p {
margin: var(--uc-space-1) 0 0;
color: var(--uc-ink-muted);
font-size: var(--uc-step--1);
}
.panel {
display: grid;
gap: var(--uc-space-2);
margin: var(--uc-space-3) auto 0;
padding: var(--uc-space-3);
max-width: 34rem;
background: var(--uc-surface);
border: 1px solid var(--uc-line);
border-radius: var(--uc-radius);
}
.panel h2 {
margin: 0;
font-family: var(--uc-font-ui);
font-size: var(--uc-step-1);
}
.join-row {
display: flex;
flex-wrap: wrap;
gap: var(--uc-space-2);
}
label {
display: block;
margin-bottom: var(--uc-space-1);
font-size: var(--uc-step--1);
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--uc-ink-muted);
}
input[type="text"] {
flex: 1 1 8.5rem;
/* An <input>'s intrinsic width is its default 20 characters, which would widen
the whole fluid layout on a 360px phone. The value is 5 characters. */
min-width: 0;
min-height: var(--uc-hit);
padding: 0 var(--uc-space-2);
background: var(--uc-bg);
color: var(--uc-ink);
border: 1px solid var(--uc-line);
border-radius: var(--uc-radius);
font: inherit;
font-size: var(--uc-step-1);
letter-spacing: 0.35em;
text-transform: uppercase;
}
input[type="text"]::placeholder {
color: #8d8272; /* 4.6:1 on --uc-bg: placeholder text is real text */
letter-spacing: 0.35em;
}
.btn {
min-height: var(--uc-hit);
min-width: var(--uc-hit);
padding: 0 var(--uc-space-3);
background: var(--uc-surface-2);
color: var(--uc-ink);
border: 1px solid var(--uc-line);
border-radius: var(--uc-radius);
font: inherit;
font-size: var(--uc-step-0);
font-weight: 600;
cursor: pointer;
transition:
transform 120ms var(--uc-ease),
background-color 120ms var(--uc-ease);
}
.btn:hover {
background: #3b3226;
}
.btn:active {
transform: translateY(1px);
}
.btn-primary {
background: var(--uc-accent);
color: var(--uc-accent-ink);
border-color: var(--uc-accent);
}
.btn-primary:hover {
background: #ffb445;
}
/* Visible focus for keyboard and low-vision users; never remove this. */
:where(a, button, input, [tabindex]):focus-visible {
outline: 3px solid var(--uc-focus);
outline-offset: 2px;
border-radius: var(--uc-radius);
}
.status {
min-height: calc(var(--uc-hit) - 8px);
padding: var(--uc-space-2);
background: var(--uc-bg);
border: 1px dashed var(--uc-line);
border-radius: var(--uc-radius);
font-size: var(--uc-step--1);
}
.status[data-kind="error"] {
color: var(--uc-danger);
border-color: var(--uc-danger);
}
#kcanvas {
display: block;
width: 100%;
height: auto;
aspect-ratio: 16 / 10;
background: #0f0d0b;
border: 1px solid var(--uc-line);
border-radius: var(--uc-radius);
}
#kcanvas[hidden] {
display: none;
}
code {
font-family: ui-monospace, "Cascadia Mono", "Segoe UI Mono", Menlo, monospace;
font-size: 0.95em;
color: var(--uc-accent);
}
.colophon {
text-align: center;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
scroll-behavior: auto !important;
}
.btn:active {
transform: none;
}
}
+76
View File
@@ -0,0 +1,76 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover" />
<meta
name="description"
content="Undercooked: anonymous cooperative kitchen chaos in the browser. Create a lobby, read out the 5-character code, cook together."
/>
<meta name="color-scheme" content="dark" />
<title>Undercooked</title>
<!-- No favicon file yet: an empty data: URL stops the browser probing /favicon.ico
while keeping every byte same-origin (PLAN.md A12). -->
<link rel="icon" href="data:," />
<link rel="stylesheet" href="/static/css/base.css" />
</head>
<body>
<main id="app" class="shell">
<header class="masthead">
<p class="wordmark">Undercooked</p>
<p class="tagline">Anonymous co-op kitchen chaos. No accounts, no install, 1&ndash;4 cooks.</p>
</header>
<section id="screen-lobby" class="panel" aria-labelledby="lobby-heading">
<h2 id="lobby-heading">Kitchen lobby</h2>
<p id="lobby-help">
Create a kitchen and read its 5-character code out loud, or join a kitchen that is
already open. Nothing is saved and nothing is asked for.
</p>
<button id="create-lobby" class="btn btn-primary" type="button">Create lobby</button>
<form id="join-form" class="join" autocomplete="off">
<label for="join-code">Room code</label>
<div class="join-row">
<input
id="join-code"
name="code"
type="text"
inputmode="text"
autocapitalize="characters"
spellcheck="false"
size="5"
maxlength="5"
placeholder="ABCDE"
aria-describedby="join-hint"
required
/>
<button class="btn" type="submit">Join</button>
</div>
</form>
<p id="join-hint" class="hint">
Five characters, letters and digits, never <code>I</code>, <code>L</code>, <code>O</code>
or <code>U</code>.
</p>
<div id="status" class="status" role="status" aria-live="polite">
Pick a kitchen to start.
</div>
</section>
<!-- The renderer (phase 07) sizes and unhides this canvas; it stays out of
the layout and the a11y tree until then. -->
<canvas id="kcanvas" hidden aria-hidden="true"></canvas>
</main>
<footer class="colophon">
<p>
An independent homage to Overcooked&trade; with its own art, code and content. Every byte
you see is served by this one process &mdash; no third-party requests, no trackers.
</p>
</footer>
<script type="module" src="/static/js/app.js"></script>
</body>
</html>
+131
View File
@@ -0,0 +1,131 @@
// Undercooked — client shell entry point (phase 01).
//
// There is no game logic here on purpose. Phase 05 replaces the placeholder
// button handlers with the lobby flow, phase 06 adds net/ (WebSocket + snapshots),
// phase 07 adds render/ (Canvas 2D). What this file owns today:
// * the anonymous per-tab session token (PLAN.md A16),
// * the last room code typed in this tab, so a reload can rejoin fast,
// * mounting the lobby screen and announcing state into #status.
// It is a plain ES module served from /static/ — no bundler, no framework, no
// remote import (PLAN.md A10/A12).
const TOKEN_KEY = "uc.token";
const LAST_CODE_KEY = "uc.lastCode";
const CODE_LENGTH = 5;
const NOT_IMPLEMENTED = "Not implemented until phase 05.";
const READY_MESSAGE = "Pick a kitchen to start.";
/** Base64url without padding, so the token is safe in a query string or header. */
function base64url(bytes) {
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
/** A random, unguessable id for this browser tab (identity, not an account). */
export function randomToken() {
const bytes = new Uint8Array(24);
globalThis.crypto.getRandomValues(bytes);
return base64url(bytes);
}
function read(storage, key) {
try {
return storage.getItem(key);
} catch {
return null; // private browsing / storage disabled: stay functional for this tab only
}
}
function write(storage, key, value) {
try {
storage.setItem(key, value);
} catch {
/* nothing to persist to; the in-memory value still works */
}
}
/** The tab's session token, created once per tab and kept in sessionStorage. */
export function getToken(storage = globalThis.sessionStorage) {
const existing = read(storage, TOKEN_KEY);
if (typeof existing === "string" && existing.length > 0) return existing;
const token = randomToken();
write(storage, TOKEN_KEY, token);
return token;
}
/** Uppercase what the user typed and keep only characters a code can contain. */
export function normalizeCode(value) {
return String(value ?? "")
.toUpperCase()
.replace(/[^0-9A-Z]/g, "")
.slice(0, CODE_LENGTH);
}
export function lastCode(storage = globalThis.sessionStorage) {
return read(storage, LAST_CODE_KEY) || "";
}
export function rememberCode(code, storage = globalThis.sessionStorage) {
write(storage, LAST_CODE_KEY, normalizeCode(code));
}
/** Write the live region. `kind` is "info" | "error" and only changes styling. */
export function announce(message, root = document, kind = "info") {
const region = root.getElementById("status");
if (!region) return;
region.textContent = message;
region.dataset.kind = kind;
}
/**
* Wire the lobby screen. Returns the tab identity so later phases can reuse it.
*/
export function mount(root = document) {
const token = getToken();
const createButton = root.getElementById("create-lobby");
const joinForm = root.getElementById("join-form");
const joinInput = root.getElementById("join-code");
const remembered = lastCode();
if (joinInput && remembered) joinInput.value = remembered;
if (createButton) {
createButton.addEventListener("click", () => {
// Phase 05: POST to the room supervisor and open /ws/{code}.
announce(NOT_IMPLEMENTED, root);
});
}
if (joinForm) {
joinForm.addEventListener("submit", (event) => {
event.preventDefault();
const code = normalizeCode(joinInput ? joinInput.value : "");
if (joinInput) joinInput.value = code;
if (code.length !== CODE_LENGTH) {
announce(`A room code is ${CODE_LENGTH} characters.`, root, "error");
if (joinInput) joinInput.focus();
return;
}
rememberCode(code);
// Phase 05: join the room over the WebSocket.
announce(NOT_IMPLEMENTED, root);
});
}
if (!lastCode() && root.getElementById("status")) {
announce(READY_MESSAGE, root);
}
return { token, code: lastCode() };
}
// Auto-mount when loaded as the page's module, but stay importable (and
// therefore testable) without a lobby in the document.
if (typeof document !== "undefined" && document.getElementById("screen-lobby")) {
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", () => mount(), { once: true });
} else {
mount();
}
}
+1
View File
@@ -13,6 +13,7 @@ services:
UNDERCOOKED_MAX_ROOMS: "200" UNDERCOOKED_MAX_ROOMS: "200"
UNDERCOOKED_MAX_PLAYERS: "4" UNDERCOOKED_MAX_PLAYERS: "4"
UNDERCOOKED_LEVELS_DIR: /app/levels UNDERCOOKED_LEVELS_DIR: /app/levels
UNDERCOOKED_STATIC_DIR: /app/app/static
restart: unless-stopped restart: unless-stopped
healthcheck: healthcheck:
test: test:
+48
View File
@@ -0,0 +1,48 @@
# DEPLOYMENT.md — building, running and operating the service
**Status:** stub written by phase 01 · **Filled in by phase 14**, which measures the real
capacity and writes the reverse-proxy/TLS/ops runbook against a production profile.
## Purpose
This document covers everything between "a repository" and "players can reach it": the
multi-stage container build (A19), the single-service `compose.yaml`, the environment
variables an operator actually sets, how TLS and a reverse proxy terminate the WebSocket
connection, and the runbook for the failure modes that an in-memory server has.
`README.md` "Container" stays the copy-paste quickstart; this file is the reasoning and
the operational detail.
## What is deployable today
One artifact, one process, no database (A6/A7): the image builds the package with
`pip install --prefix=/install .` in a builder stage and copies the result into a slim
Python 3.12 runtime that runs as the non-root `player` user (uid 10001), exposes port
8000, and health-checks `GET /healthz` from inside the container. `compose.yaml` runs that
one service with `restart: unless-stopped`. Configuration is environment-only — see the
README table and `.env.example`; the container sets `UNDERCOOKED_HOST=0.0.0.0` and points
`UNDERCOOKED_STATIC_DIR` / `UNDERCOOKED_LEVELS_DIR` at the paths copied into the image,
because the source-checkout defaults do not hold under `site-packages`.
```bash
podman build -t undercooked -f Containerfile .
podman run --rm -p 8000:8000 undercooked
podman compose up -d
```
Rules that hold for every deployment of this project:
- **Never set `DEBUGPY=1` on a host reachable from outside your machine** — the debugger
listener binds `0.0.0.0` (A18, dev-only).
- **Never commit `.env`.** It is gitignored, and `.env.example` is the secret-free
template; the game has no secrets by design (anonymous, A6/A16).
- Everything is in memory: a restart ends every round and erases every scoreboard. That is
a documented product decision, not an incident.
## What this document will contain when it is filled in
Reverse-proxy configuration (WebSocket upgrade, `Connection: upgrade`, idle timeouts
longer than the room's heartbeat), TLS termination and HSTS within the project's
`default-src 'self'` CSP (A12), resource limits derived from the phase-12 capacity
measurement in `BUDGETS.md`, log/metric surfaces that never record a room code with player
contents (§11), a rolling-restart procedure that accepts the round it kills, and the
backup story: none, and why.
+43
View File
@@ -0,0 +1,43 @@
# DESIGN.md — architecture and the decisions behind it
**Status:** stub written by phase 01 · **Filled in by phase 09**, when the client, the
protocol and the first two-player end-to-end story make the prose verifiable.
## Purpose
This document explains the shape of the server and the client in prose: which module owns
what, why the boundaries are drawn where they are, and the reasoning behind the LOCKED
decisions A1–A12 of `.agents/PLAN.md` §3. `PLAN.md` states *what* was decided and stays
frozen; this file is where the *why* lives and where a later phase records the
consequences of those decisions once the code proves them out.
## The two-line version
One asyncio process holds every room. The browser is an untrusted thin client: it sends
inputs, receives snapshots, and interpolates. All truth lives in
`src/undercooked/sim/`, a pure function of `(state, dt)` with no I/O, no clock and no
unseeded randomness, which is what makes headless determinism tests (A5/A21) possible at
all. Everything around that kernel — `server/` (FastAPI app, WebSocket protocol, room
supervisor, in-memory session records) and `app/static/` (vanilla ES modules, Canvas 2D)
— exists only to move bytes between a player's hands and the simulation.
## Module map (as it stands after phase 01)
| Path | Owns | Filled in by |
|---|---|---|
| `src/undercooked/config.py` | the only read of the environment → typed, validated `Settings` | phase 01 (done) |
| `src/undercooked/debug.py` | `DEBUGPY`-gated debugger bootstrap (A18) | phase 01 (done) |
| `src/undercooked/server/app.py` | app factory, `/healthz`, `/`, `/static`, startup logging | phase 01 (done) |
| `src/undercooked/server/headers.py` | CSP + the four other security headers on every response | phase 01 (done) |
| `src/undercooked/sim/` | game truth: model, `step()`, interactions, scoring, hazards | phase 03–04 |
| `src/undercooked/server/room.py`, `rooms.py` | one match, its FSM and tick task; the registry that owns all of them | phase 05–06 |
| `src/undercooked/server/protocol.py` | versioned wire messages, whitelists, inbound validation | phase 06 |
| `src/undercooked/server/sessions.py` | bounded in-memory progression records (A6: no database) | phase 05 |
| `app/static/js/` | screen state machine, net buffer/interpolation/prediction, renderer, HUD | phase 07–08 |
## What this document will contain when it is filled in
The request/response and tick data flow with real timings, the room lifecycle state
machine as built, the arbitration rule for contested resources, the client prediction and
reconciliation model, and one worked paragraph per anchor in A1–A12 covering the
alternatives that were rejected and the measured cost of the choice that was taken.
+80
View File
@@ -0,0 +1,80 @@
# TESTING.md — how this project is verified
**Status:** current as of phase 01 · **Filled in by phase 14**, which re-runs every layer
below against the finished game and records the release-time suite inventory.
## Purpose
This document is the single reference for *how we know the game is right*: the test tree,
what belongs in each directory, how to run each layer without waiting for the others, the
marker set, the coverage floor, and the artifact policy. The layered verification workflow
behind it is `.agents/PLAN.md` §9; the gate that enforces all of it before any phase
completes is `.agents/validate.sh`.
## Layout
`tests/` is deliberately **not** a package (no `__init__.py`); fixtures shared by every
layer live in `tests/conftest.py`, and layer-specific fixtures live in that layer's own
`conftest.py`.
| Directory | Contains | I/O allowed |
|---|---|---|
| `tests/unit/` | pure logic with no I/O: config parsing, header values, CLI precedence, protocol/validator helpers, and the doc/content guards that keep README, `.env.example`, `PLAN.md` and the code agreeing | none (in-process `TestClient` requests are fine) |
| `tests/sim/` | headless determinism and replay tests against the pure kernel (`tests/sim/test_replay_*.py`, golden state hashes) | none — the sim must stay callable without a server |
| `tests/integration/` | multi-module, in-process tests: app boot through `create_app()`, HTTP routes, static serving, room/protocol behaviour with real WebSocket clients (room + protocol integration is PLAN §9 layer 4, and lives here) | in-process app, loopback sockets |
| `tests/e2e/` | one Playwright suite per user story, named `test_usNN_*.py`; multiplayer stories drive **two browser contexts** against one room | real browser, real socket |
| `tests/fixtures/` | fixture data only. `tests/fixtures/levels/` is the content dir every test sees — an autouse session fixture points `UNDERCOOKED_LEVELS_DIR` at it so no test can silently read the shipped `levels/` | none |
There is **no `tests/server/`**: PLAN §9 counts server/route layers of coverage, not
directories. A route asserted as a pure function (header values, status codes, CLI
precedence) is a `tests/unit/` test; a route exercised against a process that actually
binds a socket is `tests/integration/`. Adding a fifth directory would only hide which of
those two a test is.
## Markers
Registered in `pyproject.toml` and enforced with `--strict-markers`:
| Marker | Meaning | Default run |
|---|---|---|
| `unit` | pure, no I/O, fast | yes |
| `integration` | multi-module, in-process server/room tests | yes |
| `e2e` | Playwright browser suites (needs `uv run playwright install chromium`) | yes |
| `slow` | replay/stress suites that exceed the quick loop (the 200-room stress run lives here from phase 12) | yes, excluded explicitly |
The everyday loop is `uv run pytest -m "not e2e"` (no browser launches). Before a phase
completes, and whenever `.agents/validate.sh` runs, the **full** suite runs — coverage gate
included — because the gate is "the entire project is green", not "my files are green".
## Running everything
```bash
uv sync --extra dev # dependencies + test toolchain
uv run playwright install chromium # once, before the first browser run
uv run pytest -q -m "not e2e" # quick loop
uv run pytest -q # everything, with the coverage floor
uv run pytest tests/unit -q -p no:cacheprovider # one file, no nested coverage writes
uv run ruff check . # lint (E,F,W,I,N,UP,B,C4,SIM,ASYNC,S,RUF,ARG,RET,PL,PERF)
uv run pyright # types
bash .agents/validate.sh # the pipeline gate, in that order + hygiene
```
Coverage is configured in `pyproject.toml`: `source = ["src/undercooked"]`, branch
coverage on, `--cov-fail-under=90` (AGENTS.md A20 asks for > 90 % on new/changed code; the
floor is the mechanical half of that).
Test processes never listen on a routable interface. The only fixture that binds a socket
is `live_server` (session-scoped, `tests/conftest.py`), which asks uvicorn for an
ephemeral `127.0.0.1` port and shuts the lifespan down cleanly.
## Artifact policy
- Test output is disposable and **never committed**: `test-results/`, `playwright-report/`,
`blob-report/`, `.playwright/`, `.pytest_cache/`, `.coverage`, `htmlcov/`,
`coverage.xml`, `coverage.json` are all gitignored.
- No test writes into `app/static/`, `levels/`, `src/`, or `docs/`; anything a test needs
is created under `tmp_path`.
- Nothing is downloaded during a test run: browsers are installed explicitly by a human
(or CI step), and no test reaches the network except against `127.0.0.1`.
- A test that cannot pass deterministically is a bug in the test — sleeps are bounded by
conditions, and the sim-facing suites are seeded through the `random_seed` fixture.
View File
+2
View File
@@ -0,0 +1,2 @@
__all__: list[str] = []
__version__ = "0.1.0"
+158
View File
@@ -0,0 +1,158 @@
"""The ``undercooked`` command: resolve settings, then hand the app to uvicorn.
Precedence is **CLI flag > environment (incl. `.env`) > built-in default**, and
:func:`_resolve` holds that rule as a pure function so it is testable without
starting a server. Values the operator passed on the command line are published
through :func:`undercooked.config.apply_cli_overrides` before serving, so a
uvicorn ``--reload`` child — which re-reads the environment from scratch — sees
exactly the same configuration as this process, and so an invalid ``--port`` is
rejected by the same validator as an invalid `UNDERCOOKED_PORT`.
Exit codes: ``0`` after a clean shutdown, ``2`` for a configuration error
(matching argparse's own usage-error code), anything else comes from uvicorn.
"""
from __future__ import annotations
import argparse
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any
from undercooked.config import ConfigError, Settings, apply_cli_overrides
if TYPE_CHECKING:
from collections.abc import Mapping, Sequence
__all__ = ["APP_IMPORT_STRING", "RunOptions", "build_parser", "main"]
#: Uvicorn's "module:attribute" target — the reload-capable form: the reloader
#: hands this string to a fresh interpreter instead of pickling an app object.
APP_IMPORT_STRING = "undercooked.server.app:app"
EXIT_OK = 0
EXIT_CONFIG_ERROR = 2
@dataclass(frozen=True, slots=True)
class RunOptions:
"""Everything uvicorn needs, after precedence has been applied."""
host: str
port: int
reload: bool
levels_dir: Path
def build_parser() -> argparse.ArgumentParser:
"""The CLI surface. Every flag is optional and defaults to `None` = "unset"."""
parser = argparse.ArgumentParser(
prog="undercooked",
description=(
"Run the Undercooked game server: the HTTP client shell, "
"/healthz, and (from phase 06) the game WebSocket."
),
epilog=(
"Configuration also comes from UNDERCOOKED_* / DEBUGPY environment "
"variables and a local .env file; a flag here always wins."
),
)
parser.add_argument(
"--host",
default=None,
help="bind address (default: $UNDERCOOKED_HOST, else 127.0.0.1)",
)
parser.add_argument(
"--port",
default=None,
type=int,
help="bind port (default: $UNDERCOOKED_PORT, else 8000)",
)
parser.add_argument(
"--reload",
action="store_true",
help="restart on source changes (development only; never use in a container)",
)
parser.add_argument(
"--levels-dir",
dest="levels_dir",
default=None,
type=Path,
help="directory of level YAML files (default: $UNDERCOOKED_LEVELS_DIR, else levels/)",
)
return parser
def _cli_overrides(args: argparse.Namespace) -> dict[str, object]:
"""Only the flags the user actually typed, as `Settings` field -> value."""
overrides: dict[str, object] = {}
if args.host is not None:
overrides["host"] = args.host
if args.port is not None:
overrides["port"] = args.port
if args.levels_dir is not None:
overrides["levels_dir"] = args.levels_dir
return overrides
def _resolve(args: argparse.Namespace, settings: Settings) -> RunOptions:
"""Pure precedence: an explicit flag wins over ``settings`` (env > defaults)."""
return RunOptions(
host=args.host if args.host is not None else settings.host,
port=args.port if args.port is not None else settings.port,
reload=bool(args.reload),
levels_dir=(
args.levels_dir if args.levels_dir is not None else settings.levels_dir
),
)
def _uvicorn_log_config(base: Mapping[str, Any]) -> dict[str, Any]:
"""uvicorn's logging config, plus our own ``undercooked.*`` loggers on stdout.
uvicorn only configures its own loggers, so a startup line from
`undercooked.server.app` would vanish into logging's last-resort handler
(WARNING and above). This adds a handler for our tree; it is passed to
uvicorn rather than installed on the root logger because uvicorn re-applies
the config inside every reload child process.
"""
config = dict(base)
loggers = dict(config.get("loggers", {}))
loggers["undercooked"] = {"handlers": ["default"], "level": "INFO", "propagate": False}
config["loggers"] = loggers
return config
def _serve(options: RunOptions) -> None:
"""Block serving until the server stops. Imported lazily: `--help` stays fast."""
import uvicorn # noqa: PLC0415 - only needed once we actually serve
import uvicorn.config # noqa: PLC0415
uvicorn.run(
APP_IMPORT_STRING,
host=options.host,
port=options.port,
reload=options.reload,
log_config=_uvicorn_log_config(uvicorn.config.LOGGING_CONFIG),
)
def main(argv: Sequence[str] | None = None) -> int:
"""Entry point for the `undercooked` console script and `python -m undercooked`."""
args = build_parser().parse_args(argv)
try:
settings = apply_cli_overrides(**_cli_overrides(args))
options = _resolve(args, settings)
except ConfigError as exc:
print(f"undercooked: {exc}", file=sys.stderr)
return EXIT_CONFIG_ERROR
_serve(options)
return EXIT_OK
# `python -m undercooked` runs this; coverage measures the parent process only
# (tests/unit/test_main.py drives the module in a subprocess instead).
if __name__ == "__main__": # pragma: no cover
raise SystemExit(main())
+354
View File
@@ -0,0 +1,354 @@
"""Typed, environment-driven configuration (PLAN.md A17, A18).
`Settings` is the single place this process reads configuration from: the
environment (`UNDERCOOKED_*`, plus the `DEBUGPY` / `DEBUGPY_PORT` pair) is
parsed, defaulted and validated once into an immutable object. No other module
may touch `os.environ`; later phases call :func:`get_settings`.
Precedence, highest first:
1. real environment variables,
2. a local `.env` file (found from the current directory upwards through
python-dotenv; it never overrides an exported variable),
3. the defaults below, which mirror `README.md` "Configuration" and
`.env.example`.
A value that is present but blank (`UNDERCOOKED_PORT=`) counts as unset, so a
commented-out or emptied line falls back to its default instead of failing.
Nothing here reads the environment at import time — that would make tests
order-dependent.
"""
from __future__ import annotations
import functools
import os
from collections.abc import Mapping
from dataclasses import dataclass
from pathlib import Path
from dotenv import find_dotenv, load_dotenv
__all__ = [
"ENV_KEYS",
"ConfigError",
"Settings",
"apply_cli_overrides",
"get_settings",
"load_settings",
"reset_settings_cache",
]
#: Prefix for every project variable except the debugger pair (A18 keeps
#: `DEBUGPY` / `DEBUGPY_PORT` unprefixed so they stay familiar to editors).
ENV_PREFIX = "UNDERCOOKED_"
DEBUGPY_ENV_KEY = "DEBUGPY"
DEBUGPY_PORT_ENV_KEY = "DEBUGPY_PORT"
#: Settings field name -> environment variable name. This is the documented
#: contract shared by README.md, `.env.example` and the test fixtures; changing
#: a key here changes the documented interface (and vice versa — a test fails
#: when the three disagree).
ENV_KEYS: Mapping[str, str] = {
"host": f"{ENV_PREFIX}HOST",
"port": f"{ENV_PREFIX}PORT",
"tick_hz": f"{ENV_PREFIX}TICK_HZ",
"snapshot_hz": f"{ENV_PREFIX}SNAPSHOT_HZ",
"max_players": f"{ENV_PREFIX}MAX_PLAYERS",
"max_rooms": f"{ENV_PREFIX}MAX_ROOMS",
"levels_dir": f"{ENV_PREFIX}LEVELS_DIR",
"static_dir": f"{ENV_PREFIX}STATIC_DIR",
"lobby_idle_timeout_s": f"{ENV_PREFIX}LOBBY_IDLE_TIMEOUT_S",
"guest_idle_timeout_s": f"{ENV_PREFIX}GUEST_IDLE_TIMEOUT_S",
"max_message_bytes": f"{ENV_PREFIX}MAX_MESSAGE_BYTES",
"max_inputs_per_s": f"{ENV_PREFIX}MAX_INPUTS_PER_S",
"sim_latency_ms": f"{ENV_PREFIX}SIM_LATENCY_MS",
"allowed_origins": f"{ENV_PREFIX}ALLOWED_ORIGINS",
"debugpy": DEBUGPY_ENV_KEY,
"debugpy_port": DEBUGPY_PORT_ENV_KEY,
}
# Defaults — keep in sync with README.md "Configuration" and `.env.example`.
DEFAULT_HOST = "127.0.0.1"
DEFAULT_PORT = 8000
DEFAULT_TICK_HZ = 20
DEFAULT_SNAPSHOT_HZ = 20
DEFAULT_MAX_PLAYERS = 4
DEFAULT_MAX_ROOMS = 200
DEFAULT_LEVELS_DIR = Path("levels")
# `None` means "derive it from the installed package layout" (see
# `server.app.STATIC_DIR`); containers set an explicit path instead.
DEFAULT_STATIC_DIR: Path | None = None
DEFAULT_LOBBY_IDLE_TIMEOUT_S = 900.0
DEFAULT_GUEST_IDLE_TIMEOUT_S = 45.0
DEFAULT_MAX_MESSAGE_BYTES = 4096
DEFAULT_MAX_INPUTS_PER_S = 40
DEFAULT_SIM_LATENCY_MS = 0
DEFAULT_DEBUGPY = False
DEFAULT_DEBUGPY_PORT = 5678
DEFAULT_ALLOWED_ORIGINS: tuple[str, ...] = ()
# Accepted ranges (PLAN.md A4/A10 §10/§11); outside them the process refuses
# to start rather than run a broken simulation.
TICK_HZ_MIN, TICK_HZ_MAX = 5, 60
SNAPSHOT_HZ_MIN = 1
PORT_MIN, PORT_MAX = 1, 65535
MAX_PLAYERS_MIN, MAX_PLAYERS_MAX = 1, 4
MIN_ROOMS = 1
MIN_MESSAGE_BYTES = 256
MIN_INPUTS_PER_S = 1
MIN_LATENCY_MS = 0
MIN_TIMEOUT_S = 0.0
_TRUTHY = frozenset({"1", "true", "yes", "on"})
_FALSY = frozenset({"0", "false", "no", "off"})
class ConfigError(ValueError):
"""An environment variable is malformed or out of its accepted range."""
@dataclass(frozen=True, slots=True)
class Settings:
"""Immutable snapshot of every runtime knob.
Field meanings (the env var for each is in :data:`ENV_KEYS`):
host, port: bind address/port of the uvicorn server.
tick_hz: server simulation rate (A4); 5-60 Hz.
snapshot_hz: snapshot broadcast rate to players; at most ``tick_hz``.
max_players: seats per room (PLAN 2: 1-4).
max_rooms: process-wide room cap before new rooms are refused.
levels_dir: directory of level YAML files (A8); ``~`` is expanded.
static_dir: directory served at ``/static``; ``None`` derives it from the
package layout (``<repo>/app/static``), which is wrong inside a
container image, so those set it explicitly.
lobby_idle_timeout_s: close a lobby with no activity after this.
guest_idle_timeout_s: drop a disconnected player's seat after this.
max_message_bytes: hard cap on an inbound WebSocket frame.
max_inputs_per_s: per-connection inbound message rate limit.
sim_latency_ms: injected server-side downlink latency for netcode tests.
debugpy: open a debugger listener at startup (A18).
debugpy_port: debugger listen port.
allowed_origins: `Origin` values accepted on the WebSocket handshake;
empty means "same-host only" (decided by the server at request time).
"""
host: str = DEFAULT_HOST
port: int = DEFAULT_PORT
tick_hz: int = DEFAULT_TICK_HZ
snapshot_hz: int = DEFAULT_SNAPSHOT_HZ
max_players: int = DEFAULT_MAX_PLAYERS
max_rooms: int = DEFAULT_MAX_ROOMS
levels_dir: Path = DEFAULT_LEVELS_DIR
static_dir: Path | None = DEFAULT_STATIC_DIR
lobby_idle_timeout_s: float = DEFAULT_LOBBY_IDLE_TIMEOUT_S
guest_idle_timeout_s: float = DEFAULT_GUEST_IDLE_TIMEOUT_S
max_message_bytes: int = DEFAULT_MAX_MESSAGE_BYTES
max_inputs_per_s: int = DEFAULT_MAX_INPUTS_PER_S
sim_latency_ms: int = DEFAULT_SIM_LATENCY_MS
debugpy: bool = DEFAULT_DEBUGPY
debugpy_port: int = DEFAULT_DEBUGPY_PORT
allowed_origins: tuple[str, ...] = DEFAULT_ALLOWED_ORIGINS
def _text(env: Mapping[str, str], key: str) -> str | None:
"""Return the stripped value of ``key``, or `None` when unset/blank."""
raw = env.get(key)
if raw is None:
return None
return raw.strip() or None
def _range_error(
key: str, value: int | float, minimum: float, maximum: float | None
) -> ConfigError:
expect = f">= {minimum}" if maximum is None else f">= {minimum}, <= {maximum}"
return ConfigError(f"{key}={value!r} is invalid: must be {expect}")
def _check_range(
key: str, value: int | float, minimum: float, maximum: float | None = None
) -> int | float:
"""Return ``value`` when it is inside the bound(s), else raise ConfigError."""
if value < minimum:
raise _range_error(key, value, minimum, maximum)
if maximum is not None and value > maximum:
raise _range_error(key, value, minimum, maximum)
return value
def _int(
env: Mapping[str, str],
key: str,
default: int,
minimum: float,
maximum: float | None = None,
) -> int:
text = _text(env, key)
if text is None:
return default
try:
value = int(text, 10)
except ValueError:
raise ConfigError(f"{key}={text!r} is not an integer") from None
_check_range(key, value, minimum, maximum)
return value
def _float(
env: Mapping[str, str],
key: str,
default: float,
minimum: float,
maximum: float | None = None,
) -> float:
text = _text(env, key)
if text is None:
return default
try:
value = float(text)
except ValueError:
raise ConfigError(f"{key}={text!r} is not a number") from None
_check_range(key, value, minimum, maximum)
return value
def _bool(env: Mapping[str, str], key: str, default: bool) -> bool:
text = _text(env, key)
if text is None:
return default
lowered = text.lower()
if lowered in _TRUTHY:
return True
if lowered in _FALSY:
return False
raise ConfigError(f"{key}={text!r} is not a boolean (use 1/0, true/false, yes/no, on/off)")
def _path(env: Mapping[str, str], key: str, default: Path) -> Path:
text = _text(env, key)
if text is None:
return default
return Path(text).expanduser()
def _optional_path(env: Mapping[str, str], key: str) -> Path | None:
"""Like :func:`_path` but with no default: unset means "derive me later"."""
text = _text(env, key)
return None if text is None else Path(text).expanduser()
def _origins(env: Mapping[str, str], key: str, default: tuple[str, ...]) -> tuple[str, ...]:
text = _text(env, key)
if text is None:
return default
return tuple(part.strip() for part in text.split(",") if part.strip())
@functools.lru_cache(maxsize=1)
def _load_dotenv_once() -> bool:
"""Load a `.env` file at most once per process, never overriding the env.
Cached rather than a module-level flag so that :func:`reset_settings_cache`
(used by tests) also makes a freshly written `.env` visible again.
"""
dotenv_file = find_dotenv(usecwd=True)
if not dotenv_file:
return False
return load_dotenv(dotenv_file, override=False)
def load_settings(env: Mapping[str, str] | None = None) -> Settings:
"""Build :class:`Settings` from ``env``, or the process environment.
Passing an explicit mapping (tests, tools) skips `.env` loading entirely, so
a test can never be influenced by a developer's local file.
Raises:
ConfigError: a variable is not an integer/number/boolean/path, or is
outside its accepted range.
"""
if env is None:
_load_dotenv_once()
env = os.environ
prefix = ENV_PREFIX
tick_hz = _int(env, f"{prefix}TICK_HZ", DEFAULT_TICK_HZ, TICK_HZ_MIN, TICK_HZ_MAX)
max_players = _int(
env, f"{prefix}MAX_PLAYERS", DEFAULT_MAX_PLAYERS, MAX_PLAYERS_MIN, MAX_PLAYERS_MAX
)
snapshot_hz = _int(env, f"{prefix}SNAPSHOT_HZ", DEFAULT_SNAPSHOT_HZ, SNAPSHOT_HZ_MIN, tick_hz)
return Settings(
host=_text(env, f"{prefix}HOST") or DEFAULT_HOST,
port=_int(env, f"{prefix}PORT", DEFAULT_PORT, PORT_MIN, PORT_MAX),
tick_hz=tick_hz,
snapshot_hz=snapshot_hz,
max_players=max_players,
max_rooms=_int(env, f"{prefix}MAX_ROOMS", DEFAULT_MAX_ROOMS, MIN_ROOMS),
levels_dir=_path(env, f"{prefix}LEVELS_DIR", DEFAULT_LEVELS_DIR),
static_dir=_optional_path(env, f"{prefix}STATIC_DIR"),
lobby_idle_timeout_s=_float(
env, f"{prefix}LOBBY_IDLE_TIMEOUT_S", DEFAULT_LOBBY_IDLE_TIMEOUT_S, MIN_TIMEOUT_S
),
guest_idle_timeout_s=_float(
env, f"{prefix}GUEST_IDLE_TIMEOUT_S", DEFAULT_GUEST_IDLE_TIMEOUT_S, MIN_TIMEOUT_S
),
max_message_bytes=_int(
env, f"{prefix}MAX_MESSAGE_BYTES", DEFAULT_MAX_MESSAGE_BYTES, MIN_MESSAGE_BYTES
),
max_inputs_per_s=_int(
env, f"{prefix}MAX_INPUTS_PER_S", DEFAULT_MAX_INPUTS_PER_S, MIN_INPUTS_PER_S
),
sim_latency_ms=_int(env, f"{prefix}SIM_LATENCY_MS", DEFAULT_SIM_LATENCY_MS, MIN_LATENCY_MS),
debugpy=_bool(env, DEBUGPY_ENV_KEY, DEFAULT_DEBUGPY),
debugpy_port=_int(env, DEBUGPY_PORT_ENV_KEY, DEFAULT_DEBUGPY_PORT, PORT_MIN, PORT_MAX),
allowed_origins=_origins(env, f"{prefix}ALLOWED_ORIGINS", DEFAULT_ALLOWED_ORIGINS),
)
@functools.lru_cache(maxsize=1)
def _load_cached() -> Settings:
return load_settings()
def get_settings() -> Settings:
"""Process-wide settings, read lazily and cached.
Tests that change the environment must call :func:`reset_settings_cache`
first (and, ideally, use :func:`load_settings` with an explicit mapping).
"""
return _load_cached()
def reset_settings_cache() -> None:
"""Drop the cached :class:`Settings` and the "`.env` already read" flag."""
_load_dotenv_once.cache_clear()
_load_cached.cache_clear()
def apply_cli_overrides(**field_values: object) -> Settings:
"""Publish command-line overrides as environment variables and rebuild.
Only :mod:`undercooked.__main__` calls this. uvicorn's ``--reload`` (and any
future worker) starts a fresh interpreter that re-reads configuration from
the environment, so a flag such as ``--levels-dir`` has to *become* an
environment variable to survive into it — otherwise "CLI beats env" would be
true in the parent and false in every child. Writing here keeps this module
the only writer of ``os.environ``, and re-running :func:`load_settings`
validates the CLI values (an out-of-range ``--port`` is a `ConfigError`, not
a surprise bind).
Args:
**field_values: ``Settings`` field name -> value, only for the flags the
user actually passed.
Raises:
ConfigError: an unknown field name, or a value outside its range.
"""
for name, value in field_values.items():
key = ENV_KEYS.get(name)
if key is None:
raise ConfigError(f"{name!r} is not a configuration field")
os.environ[key] = ("1" if value else "0") if isinstance(value, bool) else str(value)
reset_settings_cache()
return get_settings()
+54
View File
@@ -0,0 +1,54 @@
"""Optional debugpy bootstrap (PLAN.md A18).
The realtime server is miserable to debug with prints, so `DEBUGPY=1` opens a
listener the editor can attach to. The debugger is a *dev extra*: it is imported
lazily inside :func:`maybe_start_debugpy` so a production container never needs
it, and a failure to start it must never stop the server — a broken debugger is
an annoyance, a dead game server is an outage.
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from undercooked.config import Settings
__all__ = ["DEBUGPY_BIND_HOST", "maybe_start_debugpy"]
logger = logging.getLogger("undercooked.debug")
#: The debugger listens on all interfaces so an editor on the host can reach a
#: container. Debug-only: never enable `DEBUGPY` on a public deployment.
DEBUGPY_BIND_HOST = "0.0.0.0" # noqa: S104 - deliberate dev-only bind, not a public service
def maybe_start_debugpy(settings: Settings) -> bool:
"""Start the debugpy listener when ``settings.debugpy``; report whether it is up.
Returns `False` (and leaves the server running) when the debugger is
disabled, when the dev extra is not installed, or when the port cannot be
bound. Never raises.
"""
if not settings.debugpy:
return False
try:
import debugpy # noqa: PLC0415 - dev extra (A18); prod images must not need it
except ImportError:
logger.warning("DEBUGPY=1 but debugpy is not installed (dev extra); starting without it")
return False
endpoint = f"{DEBUGPY_BIND_HOST}:{settings.debugpy_port}"
try:
# debugpy >= 1.8 `listen()` never blocks (waiting for a client is the
# separate `wait_for_client()` call, which we deliberately never make):
# the server must boot and serve whether or not an editor attaches.
debugpy.listen((DEBUGPY_BIND_HOST, settings.debugpy_port))
except Exception as exc:
logger.warning("debugpy could not listen on %s (%s); continuing without it", endpoint, exc)
return False
logger.info("debugpy listening on %s (attach with a Python debugger)", endpoint)
return True
View File
+150
View File
@@ -0,0 +1,150 @@
"""The FastAPI application factory — the HTTP surface of the server.
This is the process later phases attach to: rooms (`server/room.py`), the
WebSocket endpoint (`/ws/{code}`, phase 06) and the level manifest (phase 02)
all register on the app built here. Nothing game-like exists yet; the contract
is only:
* ``GET /healthz`` — liveness probe for containers and tests,
* ``GET /`` — the client shell from ``app/static/index.html``,
* ``/static/*`` — the client's ES modules, CSS and art (never a CDN, A12),
* security headers on every response (:mod:`undercooked.server.headers`).
``create_app`` takes a :class:`~undercooked.config.Settings` as plain data, so a
test can build an app without touching the environment; the module-level ``app``
used by uvicorn is built lazily on first attribute access (PEP 562) so that
importing this module stays side-effect free.
"""
from __future__ import annotations
import logging
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from undercooked import __version__
from undercooked.config import ConfigError, Settings, get_settings
from undercooked.debug import maybe_start_debugpy
from undercooked.server.headers import SecurityHeadersMiddleware
__all__ = [
"STATIC_DIR",
"create_app",
"get_app",
"resolve_static_dir",
"settings_log_line",
]
logger = logging.getLogger(__name__)
#: Where the client lives in a source checkout: <repo>/app/static. An installed
#: or containerised copy points `UNDERCOOKED_STATIC_DIR` at its own tree instead
#: (see Containerfile), because this anchor is meaningless under site-packages.
STATIC_DIR = Path(__file__).resolve().parents[3] / "app" / "static"
#: Settings echoed once at startup. Everything in `Settings` is operator
#: configuration, never player data or a `.env` file's contents (AGENTS.md §5).
LOGGED_SETTINGS = (
"host",
"port",
"tick_hz",
"snapshot_hz",
"max_players",
"max_rooms",
"levels_dir",
"lobby_idle_timeout_s",
"guest_idle_timeout_s",
"max_message_bytes",
"max_inputs_per_s",
"sim_latency_ms",
"allowed_origins",
"debugpy",
)
def resolve_static_dir(settings: Settings) -> Path:
"""The directory to serve at `/static`: the override, else the checkout's."""
return settings.static_dir if settings.static_dir is not None else STATIC_DIR
def settings_log_line(settings: Settings) -> str:
"""A single-line, fixed-vocabulary summary of the resolved settings."""
return " ".join(f"{name}={getattr(settings, name)}" for name in LOGGED_SETTINGS)
def create_app(settings: Settings | None = None) -> FastAPI:
"""Build the application.
Args:
settings: configuration to bake in; defaults to
:func:`undercooked.config.get_settings` (env + `.env`).
Raises:
ConfigError: the resolved static directory does not exist — better to
fail at startup than to serve 500s for every asset.
"""
resolved = settings if settings is not None else get_settings()
static_dir = resolve_static_dir(resolved)
if not static_dir.is_dir():
raise ConfigError(
f"static directory {static_dir} does not exist "
"(set UNDERCOOKED_STATIC_DIR to the directory holding index.html)"
)
@asynccontextmanager
async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
logger.info("undercooked %s starting: %s", __version__, settings_log_line(resolved))
maybe_start_debugpy(resolved)
yield
logger.info("undercooked %s stopped", __version__)
application = FastAPI(
title="Undercooked",
version=__version__,
summary="Anonymous cooperative in-browser kitchen game server.",
lifespan=lifespan,
# No /docs, /redoc or /openapi.json: FastAPI renders those pages with
# assets from a public CDN, which PLAN.md A12 forbids.
docs_url=None,
redoc_url=None,
openapi_url=None,
)
application.state.settings = resolved
application.state.rooms = None # phase 05 installs the RoomSupervisor here
application.add_middleware(SecurityHeadersMiddleware)
@application.get("/healthz")
async def healthz() -> dict[str, object]:
"""Liveness probe. `rooms` is a literal until phase 05 owns a supervisor."""
return {"status": "ok", "version": __version__, "rooms": 0}
@application.get("/")
async def index() -> FileResponse:
"""The client shell: one HTML file, then the browser loads ES modules."""
return FileResponse(static_dir / "index.html", media_type="text/html")
application.mount("/static", StaticFiles(directory=static_dir), name="static")
return application
_app: FastAPI | None = None
def get_app() -> FastAPI:
"""The process-wide app, built on first use (uvicorn's string target)."""
global _app # noqa: PLW0603 - one lazily-built process-wide ASGI app
if _app is None:
_app = create_app()
return _app
def __getattr__(name: str) -> FastAPI:
"""Expose `app` lazily so importing this module does not read the environment."""
if name == "app":
return get_app()
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
+77
View File
@@ -0,0 +1,77 @@
"""Security headers for every HTTP response (PLAN.md §11, A12).
One middleware, one source of truth: the values below are what a browser is told
about this app, so they live in code (not in a template) and are asserted
route-by-route by the test suite. There are deliberately **no** reporting
endpoints — a `Report-To`/`report-uri` target would be an outbound request to a
third party, which the no-external-asset anchor forbids.
`default-src 'self'` plus explicit `script-src`/`style-src` means an injected
`<script>` or inline `style` cannot run, and `connect-src 'self' ws: wss:` is
what lets the game's same-origin WebSocket through while still blocking any
remote host.
"""
from __future__ import annotations
from collections.abc import Awaitable, Callable, Mapping
from types import MappingProxyType
from typing import Final
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response
__all__ = ["SECURITY_HEADERS", "SecurityHeadersMiddleware", "apply_security_headers"]
#: CSP for the app's own pages and assets (PLAN §11). `data:` images are allowed
#: because the client ships inline SVG sprites; `ws:`/`wss:` because the game
#: socket is same-origin but scheme-different.
CONTENT_SECURITY_POLICY: Final = (
"default-src 'self'; "
"script-src 'self'; "
"style-src 'self'; "
"img-src 'self' data:; "
"connect-src 'self' ws: wss:; "
"font-src 'self'; "
"base-uri 'none'; "
"frame-ancestors 'none'"
)
#: Header name -> value, applied to *every* response: HTML, static assets, JSON,
#: redirects and error pages alike. Immutable so a test can compare against it.
SECURITY_HEADERS: Final[Mapping[str, str]] = MappingProxyType(
{
"Content-Security-Policy": CONTENT_SECURITY_POLICY,
# Refuse to MIME-sniff a static file into something executable.
"X-Content-Type-Options": "nosniff",
# The game is anonymous: never leak the room code in a Referer header.
"Referrer-Policy": "no-referrer",
# Legacy frame-busting, kept alongside CSP frame-ancestors.
"X-Frame-Options": "DENY",
# The client needs no device capability (no camera/mic/GPS).
"Permissions-Policy": "camera=(), microphone=(), geolocation=()",
}
)
def apply_security_headers(response: Response) -> None:
"""Add every :data:`SECURITY_HEADERS` entry to ``response`` in place."""
for name, value in SECURITY_HEADERS.items():
response.headers[name] = value
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
"""Wrap the whole app — including mounted static files — in the headers.
Only `http` scopes are touched: Starlette passes `websocket` scopes straight to
the inner app, so the phase-06 `/ws/{code}` endpoint is unaffected (headers are
a handshake concern there, and the origin check owns that).
"""
async def dispatch(
self, request: Request, call_next: Callable[[Request], Awaitable[Response]]
) -> Response:
response = await call_next(request)
apply_security_headers(response)
return response
View File
View File
+161
View File
@@ -0,0 +1,161 @@
"""Shared pytest configuration and fixtures for the Undercooked test suite.
`live_server` is the one fixture that binds a real socket (an ephemeral loopback
port) because the Playwright e2e tests need a URL a browser can load; everything
else stays in-process.
Fixtures that depend on modules created by later phase-01 tasks
(`undercooked.server.app`) import them lazily via `importlib`, so this conftest
keeps working before those modules exist.
"""
from __future__ import annotations
import importlib
import os
import threading
import time
from collections.abc import Callable, Iterator
from pathlib import Path
from typing import Any
import httpx
import pytest
from fastapi.testclient import TestClient
TESTS_DIR = Path(__file__).resolve().parent
FIXTURE_LEVELS_DIR = TESTS_DIR / "fixtures" / "levels"
LIVE_HOST = "127.0.0.1" # loopback only: tests never listen on a routable interface
def _as_env_value(value: object) -> str:
"""Render a Python override value the way `load_settings` would read it."""
if isinstance(value, bool):
return "1" if value else "0"
if isinstance(value, str | Path):
return str(value)
if isinstance(value, list | tuple):
return ",".join(str(item) for item in value)
return str(value)
@pytest.fixture(scope="session", autouse=True)
def isolated_levels_dir() -> Iterator[None]:
"""Point `UNDERCOOKED_LEVELS_DIR` at an empty fixture dir for every test.
No test may read the real `levels/` content implicitly; the shipped
content pipeline only arrives with phase 02.
"""
FIXTURE_LEVELS_DIR.mkdir(parents=True, exist_ok=True)
previous = os.environ.get("UNDERCOOKED_LEVELS_DIR")
os.environ["UNDERCOOKED_LEVELS_DIR"] = str(FIXTURE_LEVELS_DIR)
yield
if previous is None:
os.environ.pop("UNDERCOOKED_LEVELS_DIR", None)
else:
os.environ["UNDERCOOKED_LEVELS_DIR"] = previous
def _wait_for_health(url: str, thread: threading.Thread) -> None:
"""Poll `/healthz` until it answers 200, failing fast if the server thread died."""
timeout = float(os.environ.get("UNDERCOOKED_TEST_SOCKET_TIMEOUT", "30"))
deadline = time.monotonic() + timeout
probe = httpx.Client(timeout=1.0, trust_env=False)
try:
while time.monotonic() < deadline:
try:
if probe.get(f"{url}/healthz").status_code == 200:
return
except httpx.HTTPError:
pass
thread.join(timeout=0.1)
if not thread.is_alive():
raise RuntimeError(f"live server on {url} died during startup")
finally:
probe.close()
raise RuntimeError(f"live server on {url} never became healthy")
@pytest.fixture(scope="session")
def live_server() -> Iterator[str]:
"""Base URL of a real uvicorn server, started once for the session.
The Playwright e2e tests need an address a browser can actually load, which
the in-process `TestClient` cannot provide. uvicorn binds the socket before
the serving thread starts, so the ephemeral port is known exactly (no log
scraping, no collisions between concurrent runs), and shutdown is requested
through `should_exit` so the lifespan stops cleanly.
"""
uvicorn = importlib.import_module("uvicorn")
app_module = importlib.import_module("undercooked.server.app")
config = uvicorn.Config(
app_module.create_app(),
host=LIVE_HOST,
port=0,
log_level="warning",
lifespan="on",
)
sock = config.bind_socket()
url = f"http://{LIVE_HOST}:{sock.getsockname()[1]}"
server = uvicorn.Server(config)
thread = threading.Thread(
target=server.run, args=([sock],), name="undercooked-live", daemon=True
)
thread.start()
_wait_for_health(url, thread)
try:
yield url
finally:
server.should_exit = True
thread.join(timeout=15)
if thread.is_alive(): # pragma: no cover - only if uvicorn stops cooperating
pytest.fail("live server did not shut down within 15 s")
@pytest.fixture
def random_seed() -> int:
"""Deterministic seed for sim/replay tests (determinism is a contract)."""
return 0x5EED_0401
@pytest.fixture
def make_settings() -> Callable[..., Any]:
"""Build `undercooked.config.Settings` from field-name overrides.
Overrides are translated into their env vars using the authoritative
`config.ENV_KEYS` contract (field name -> `UNDERCOOKED_*` / `DEBUGPY*`), and
`load_settings(env)` is called with a copy of the process environment that
has those vars stripped, so ambient env and `.env` files cannot leak into
tests.
"""
def _make(**overrides: object) -> Any:
config = importlib.import_module("undercooked.config")
env: dict[str, str] = {
key: value
for key, value in os.environ.items()
if not key.startswith(("UNDERCOOKED_", "DEBUGPY"))
}
for name, value in overrides.items():
if name not in config.ENV_KEYS:
pytest.fail(f"unknown settings field override: {name!r}")
env[config.ENV_KEYS[name]] = _as_env_value(value)
return config.load_settings(env)
return _make
@pytest.fixture
def app() -> Any:
"""Fresh FastAPI application built through `undercooked.server.app.create_app()`."""
module = importlib.import_module("undercooked.server.app")
return module.create_app()
@pytest.fixture
def client(app: Any) -> Iterator[TestClient]:
"""`TestClient` bound to the `app` fixture, running startup/shutdown."""
with TestClient(app) as test_client:
yield test_client
+1
View File
@@ -0,0 +1 @@
Playwright browser suites, one per user story (`test_usNN_*.py`, marker `e2e`).
+66
View File
@@ -0,0 +1,66 @@
"""Playwright fixtures for the browser suites in `tests/e2e/`.
The project depends on the `playwright` package, not on `pytest-playwright`, so
the three fixtures the plugin would provide are defined here — a headless chromium,
a fresh context and a fresh page, all function-scoped (see `browser` for why the scope
is load-bearing). A fresh context per test matters too: `sessionStorage` (the anonymous
`uc.token`, `uc.lastCode`) is scoped to it, so tests cannot observe each other's
identity.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
import pytest
from playwright.sync_api import sync_playwright
if TYPE_CHECKING:
from collections.abc import Iterator
from playwright.sync_api import Browser, BrowserContext, Page, ViewportSize
# The default desktop viewport; story tests that care about small screens call
# `page.set_viewport_size` before navigating.
VIEWPORT: ViewportSize = {"width": 1280, "height": 720}
@pytest.fixture
def browser() -> Iterator[Browser]:
"""A headless chromium, live for exactly one test.
Function scope is load-bearing, not shyness about startup time. The
*synchronous* Playwright API drives the browser from an asyncio event loop it
owns in the main thread, and that loop stays flagged as running for as long as
`sync_playwright()` is open — so a session- or module-scoped fixture makes every
later `async def` test in the run die with
`RuntimeError: Runner.run() cannot be called from a running event loop`
(pytest-asyncio, `asyncio_mode = "auto"`). Closing the whole stack inside the
test leaves the main thread clean for whatever runs next.
"""
with sync_playwright() as playwright:
launched = playwright.chromium.launch(headless=True)
try:
yield launched
finally:
launched.close()
@pytest.fixture
def context(browser: Browser) -> Iterator[BrowserContext]:
"""A brand-new browser context per test: isolated storage and cookies."""
created = browser.new_context(viewport=VIEWPORT)
try:
yield created
finally:
created.close()
@pytest.fixture
def page(context: BrowserContext) -> Iterator[Page]:
"""A tab inside `context`, closed after the test."""
opened = context.new_page()
try:
yield opened
finally:
opened.close()
+174
View File
@@ -0,0 +1,174 @@
"""US-01 (landing surface) e2e: a real browser loads the shell, cleanly.
Phase 01 has no gameplay, so this suite asserts only what a browser can prove and
`TestClient` cannot: the document parses, the ES module runs without a single
console error, the lobby controls are reachable, and — the point of PLAN A12 —
loading and clicking the page makes requests to *nothing* but this process.
"""
from __future__ import annotations
import re
from typing import TYPE_CHECKING
import pytest
from playwright.sync_api import expect
from undercooked import __version__
if TYPE_CHECKING:
from playwright.sync_api import Page
pytestmark = pytest.mark.e2e
# Strings a player sees, kept in sync with app/static/index.html and
# app/static/js/app.js by hand: changing one must be a deliberate act.
READY = "Pick a kitchen to start."
NOT_IMPLEMENTED = "Not implemented until phase 05."
SHORT_CODE = "A room code is 5 characters."
TOKEN_PATTERN = re.compile(r"^[A-Za-z0-9_-]{20,}$")
@pytest.fixture
def console_errors(page: Page) -> list[str]:
"""Console errors and uncaught exceptions raised while the page runs (empty = good)."""
errors: list[str] = []
page.on(
"console",
lambda message: errors.append(f"{message.type}: {message.text}")
if message.type == "error"
else None,
)
page.on("pageerror", lambda exception: errors.append(f"pageerror: {exception}"))
return errors
def open_shell(page: Page, live_server: str) -> None:
"""Load the lobby and wait for its one module request to settle."""
page.goto(f"{live_server}/", wait_until="networkidle")
def test_the_shell_loads_without_console_errors(
page: Page, live_server: str, console_errors: list[str]
) -> None:
open_shell(page, live_server)
assert page.title() == "Undercooked"
expect(page.get_by_role("heading", name="Kitchen lobby")).to_be_visible()
expect(page.get_by_role("button", name="Create lobby")).to_be_visible()
expect(page.locator("#status")).to_have_text(READY)
assert console_errors == []
def test_the_lobby_is_readable_and_zoomable(page: Page, live_server: str) -> None:
"""Viewport meta + a single fluid column: the shell works on a phone (PLAN A19)."""
page.set_viewport_size({"width": 360, "height": 640})
open_shell(page, live_server)
assert page.evaluate(
"document.querySelector('meta[name=viewport]').content"
) == "width=device-width,initial-scale=1,viewport-fit=cover"
for selector in ("#create-lobby", "#join-code"):
box = page.locator(selector).bounding_box()
assert box is not None and box["width"] >= 44 and box["height"] >= 44, (selector, box)
# Nothing sticks out of the 360 px viewport: no horizontal scrolling.
assert page.evaluate("document.documentElement.scrollWidth") <= 360
def test_create_and_join_explain_that_the_phase_is_missing(
page: Page, live_server: str, console_errors: list[str]
) -> None:
open_shell(page, live_server)
status = page.locator("#status")
page.get_by_role("button", name="Create lobby").click()
expect(status).to_have_text(NOT_IMPLEMENTED)
page.locator("#join-code").press_sequentially("abcde")
page.get_by_role("button", name="Join").click()
expect(status).to_have_text(NOT_IMPLEMENTED)
# The code is normalised for the player: uppercase, five characters.
assert page.locator("#join-code").input_value() == "ABCDE"
assert console_errors == []
def test_a_short_code_is_rejected_before_any_network_use(page: Page, live_server: str) -> None:
open_shell(page, live_server)
page.locator("#join-code").press_sequentially("ab")
page.get_by_role("button", name="Join").click()
expect(page.locator("#status")).to_have_text(SHORT_CODE)
# The field refuses a sixth character rather than letting it reach the server.
page.locator("#join-code").fill("")
page.locator("#join-code").press_sequentially("abcdef")
assert page.locator("#join-code").input_value() == "abcde"
def test_the_only_requests_the_page_makes_are_ours(page: Page, live_server: str) -> None:
"""PLAN A12 in a real browser: no CDN, no web font, no beacon, no analytics."""
requested: list[str] = []
page.on("request", lambda request: requested.append(request.url))
open_shell(page, live_server)
page.get_by_role("button", name="Create lobby").click()
assert [url for url in requested if not url.startswith(live_server)] == []
# …and the check above is not vacuous: the shell really did fetch its own assets.
assert sorted(url for url in requested if url != f"{live_server}/") == [
f"{live_server}/static/css/base.css",
f"{live_server}/static/js/app.js",
]
def test_the_document_arrives_locked_down(page: Page, live_server: str) -> None:
response = page.goto(f"{live_server}/")
assert response is not None
assert response.status == 200
assert response.headers["content-security-policy"].startswith("default-src 'self';")
assert "frame-ancestors 'none'" in response.headers["content-security-policy"]
assert response.headers["x-content-type-options"] == "nosniff"
assert response.headers["x-frame-options"] == "DENY"
def test_the_page_can_still_call_its_own_health_endpoint(
page: Page, live_server: str, console_errors: list[str]
) -> None:
"""`connect-src 'self'` must allow same-origin fetches, or phase 05 cannot work."""
open_shell(page, live_server)
result = page.evaluate(
"""async () => {
const response = await fetch('/healthz');
return {status: response.status, body: await response.json()};
}"""
)
assert result == {"status": 200, "body": {"status": "ok", "version": __version__, "rooms": 0}}
assert console_errors == []
def test_one_anonymous_token_per_tab_and_no_cookies(
page: Page, live_server: str
) -> None:
"""Identity is a random per-tab token (PLAN A16); a reload must not mint another."""
open_shell(page, live_server)
token = page.evaluate("sessionStorage.getItem('uc.token')")
assert isinstance(token, str), token
assert TOKEN_PATTERN.match(token), token
open_shell(page, live_server)
assert page.evaluate("sessionStorage.getItem('uc.token')") == token
assert page.context.cookies() == [] # anonymous by design: nothing identifying
def test_the_last_code_is_remembered_for_the_next_visit(page: Page, live_server: str) -> None:
open_shell(page, live_server)
page.locator("#join-code").press_sequentially("k7xyz")
page.get_by_role("button", name="Join").click()
expect(page.locator("#status")).to_have_text(NOT_IMPLEMENTED)
open_shell(page, live_server)
assert page.evaluate("sessionStorage.getItem('uc.lastCode')") == "K7XYZ"
assert page.locator("#join-code").input_value() == "K7XYZ"
def test_the_canvas_stays_hidden_until_the_renderer_exists(page: Page, live_server: str) -> None:
"""Phase 07 unhides `#kcanvas`; until then it is out of the layout and a11y tree."""
open_shell(page, live_server)
canvas = page.locator("#kcanvas")
expect(canvas).to_be_hidden()
assert canvas.get_attribute("aria-hidden") == "true"
View File
+1
View File
@@ -0,0 +1 @@
In-process integration tests: FastAPI app boot, HTTP routes, rooms, and the WebSocket protocol.
+319
View File
@@ -0,0 +1,319 @@
"""Integration: the app boots through `create_app()` and serves the real surface.
Covers the HTTP contract of phase 01 task 03 — `/healthz`, the client shell at
`/`, the `/static` mount, path-traversal refusal, the startup hook, and the
security headers on every kind of response (PLAN.md §4, §11, A12).
"""
from __future__ import annotations
import dataclasses
import importlib
import logging
import sys
from collections.abc import Iterator
from pathlib import Path
from typing import TYPE_CHECKING, Any
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from undercooked import __version__
from undercooked.config import ENV_KEYS, ConfigError, Settings, reset_settings_cache
from undercooked.server import app as app_module
from undercooked.server.app import (
LOGGED_SETTINGS,
STATIC_DIR,
create_app,
get_app,
resolve_static_dir,
settings_log_line,
)
from undercooked.server.headers import SECURITY_HEADERS
if TYPE_CHECKING:
from collections.abc import Callable
from starlette.types import Message
pytestmark = pytest.mark.integration
REPO_ROOT = Path(__file__).resolve().parents[2]
APP_LOGGER = "undercooked.server.app"
#: One route of every kind the app answers with: HTML page, JSON endpoint,
#: static asset, and the 404 that catches everything else.
ROUTE_KINDS = ("/", "/healthz", "/static/css/base.css", "/definitely-not-a-route")
@pytest.fixture(autouse=True)
def fresh_settings_cache() -> Iterator[None]:
"""No test may inherit a cached `Settings` (or a `.env` read) from another."""
reset_settings_cache()
yield
reset_settings_cache()
async def _asgi_get(application: FastAPI, target: str) -> tuple[int, bytes]:
"""GET ``target`` against the ASGI app verbatim; return `(status, body)`.
httpx normalizes dot-segments, so a traversal attempt written through
`TestClient` never reaches the static mount as `..`. Driving the ASGI app by
hand is how a raw client (curl, a browser proxy, a hostile socket) reaches it.
"""
scope: dict[str, Any] = {
"type": "http",
"asgi": {"version": "3.0", "spec_version": "2.3"},
"http_version": "1.1",
"method": "GET",
"scheme": "http",
"path": target,
"raw_path": target.encode("ascii"),
"query_string": b"",
"root_path": "",
"headers": [(b"host", b"testserver"), (b"accept", b"*/*")],
"client": ("127.0.0.1", 54321),
"server": ("testserver", 80),
}
sent: list[Message] = []
async def receive() -> dict[str, Any]:
return {"type": "http.request", "body": b"", "more_body": False}
async def send(message: Message) -> None:
sent.append(message)
await application(scope, receive, send)
start = next(message for message in sent if message["type"] == "http.response.start")
body = b"".join(
message.get("body", b"") for message in sent if message["type"] == "http.response.body"
)
return int(start["status"]), body
# ----------------------------------------------------------------------------- /healthz
def test_healthz_is_ok_with_the_expected_shape(client: TestClient) -> None:
response = client.get("/healthz")
assert response.status_code == 200
assert response.headers["content-type"].startswith("application/json")
assert response.json() == {"status": "ok", "version": __version__, "rooms": 0}
# ----------------------------------------------------------------------------- client shell
def test_root_serves_the_shell_html(client: TestClient) -> None:
response = client.get("/")
assert response.status_code == 200
assert response.headers["content-type"].startswith("text/html")
assert int(response.headers["content-length"]) > 500 # streamed from the file
html = response.text
assert 'src="/static/js/app.js"' in html
assert 'href="/static/css/base.css"' in html
assert 'id="screen-lobby"' in html
assert 'aria-live="polite"' in html
assert (
'name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"' in html
)
def test_shell_offers_the_lobby_controls(client: TestClient) -> None:
html = client.get("/").text
assert 'id="create-lobby"' in html
assert 'id="join-form"' in html
assert 'id="join-code"' in html
assert 'inputmode="text"' in html
assert 'autocapitalize="characters"' in html
assert 'size="5"' in html # five characters wide: no 20-char intrinsic box
assert 'maxlength="5"' in html
assert 'for="join-code"' in html # the code input has a real label
def test_static_css_and_js_are_served_with_real_mime_types(client: TestClient) -> None:
css = client.get("/static/css/base.css")
assert css.status_code == 200
assert css.headers["content-type"].startswith("text/css")
assert "--uc-hit: 44px" in css.text # the 44px hit-target contract lives in CSS
script = client.get("/static/js/app.js")
assert script.status_code == 200
assert script.headers["content-type"].startswith("text/javascript")
assert "uc.token" in script.text
def test_unknown_path_is_404(client: TestClient) -> None:
assert client.get("/definitely-not-a-route").status_code == 404
@pytest.mark.parametrize("path", ROUTE_KINDS)
def test_every_response_carries_the_security_headers(client: TestClient, path: str) -> None:
response = client.get(path)
for header, value in SECURITY_HEADERS.items():
assert response.headers[header] == value, f"{path} is missing {header}"
# ----------------------------------------------------------------------------- traversal
@pytest.mark.parametrize(
"path",
[
"/static/%2e%2e/pyproject.toml", # encoded ".."
"/static/%2e%2e%2fpyproject.toml", # encoded "../"
"/static/./../../pyproject.toml",
"/static/README.md",
"/static/.env",
],
)
def test_static_mount_cannot_escape_its_directory(client: TestClient, path: str) -> None:
response = client.get(path)
assert response.status_code in (400, 404)
assert "requires-python" not in response.text
@pytest.mark.parametrize(
"target",
[
"/static/../pyproject.toml", # what a raw client sends: no normalization
"/static/../../../etc/passwd",
"/static/%2e%2e/%2e%2e/pyproject.toml",
],
)
async def test_asgi_layer_refuses_a_literal_traversal(target: str) -> None:
status, body = await _asgi_get(create_app(Settings()), target)
assert status in (400, 404)
assert b"requires-python" not in body
assert b"root:" not in body
# ----------------------------------------------------------------------------- settings wiring
def test_app_state_holds_the_settings_it_was_built_with() -> None:
settings = Settings(max_players=2, port=8137, tick_hz=30, snapshot_hz=30)
application = create_app(settings)
assert application.state.settings is settings
def test_overridden_settings_are_visible_through_a_live_request(
make_settings: Callable[..., Settings],
) -> None:
application = create_app(make_settings(max_players=1, max_rooms=3))
with TestClient(application) as test_client:
assert test_client.get("/healthz").status_code == 200
assert application.state.settings.max_players == 1
assert application.state.settings.max_rooms == 3
def test_static_dir_override_is_served(tmp_path: Path) -> None:
(tmp_path / "probe.txt").write_text("from the override", encoding="utf-8")
settings = Settings(static_dir=tmp_path)
assert resolve_static_dir(settings) == tmp_path
with TestClient(create_app(settings)) as test_client:
assert test_client.get("/static/probe.txt").text == "from the override"
def test_default_static_dir_is_the_repository_shell() -> None:
assert STATIC_DIR == REPO_ROOT / "app" / "static"
assert resolve_static_dir(Settings()) == STATIC_DIR
assert (STATIC_DIR / "index.html").is_file()
def test_missing_static_directory_fails_at_startup(tmp_path: Path) -> None:
with pytest.raises(ConfigError, match=r"static directory .* does not exist"):
create_app(Settings(static_dir=tmp_path / "nope"))
# ----------------------------------------------------------------------------- startup hook
def test_startup_logs_the_resolved_settings_once(caplog: pytest.LogCaptureFixture) -> None:
with (
caplog.at_level(logging.INFO, logger=APP_LOGGER),
TestClient(create_app(Settings())) as test_client,
):
assert test_client.get("/healthz").status_code == 200
assert caplog.text.count("starting") == 1
assert "port=8000" in caplog.text
assert "tick_hz=20" in caplog.text
assert "stopped" in caplog.text # the shutdown half of the lifespan
def test_startup_log_never_dumps_the_environment(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
"""The hook logs resolved *values*, never variable names or a `.env` file."""
monkeypatch.delenv("UNDERCOOKED_PORT", raising=False)
monkeypatch.setenv("UNDERCOOKED_SIM_LATENCY_MS", "5")
reset_settings_cache()
# settings straight from the environment, exactly as `uv run undercooked` does
with caplog.at_level(logging.INFO, logger=APP_LOGGER), TestClient(create_app()):
pass
text = caplog.text
assert "port=8000" in text
assert "sim_latency_ms=5" in text
assert not any(name in text for name in ENV_KEYS.values())
assert ".env" not in text
def test_settings_log_line_is_a_fixed_vocabulary_summary() -> None:
line = settings_log_line(Settings())
assert line == (
"host=127.0.0.1 port=8000 tick_hz=20 snapshot_hz=20 max_players=4 max_rooms=200 "
"levels_dir=levels lobby_idle_timeout_s=900.0 guest_idle_timeout_s=45.0 "
"max_message_bytes=4096 max_inputs_per_s=40 sim_latency_ms=0 "
"allowed_origins=() debugpy=False"
)
names = {field.name for field in dataclasses.fields(Settings)}
assert set(LOGGED_SETTINGS) <= names
def test_startup_hook_passes_the_app_settings_to_the_debugpy_bootstrap(
monkeypatch: pytest.MonkeyPatch,
) -> None:
calls: list[Settings] = []
monkeypatch.setattr(
app_module, "maybe_start_debugpy", lambda settings: calls.append(settings) or False
)
settings = Settings(debugpy=True, debugpy_port=5999)
with TestClient(create_app(settings)):
pass
assert calls == [settings]
def test_booting_the_app_with_debugpy_disabled_never_imports_it(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
"""A `None` entry makes any real `import debugpy` raise, so a stray listener
would fail loudly instead of binding a port during the suite."""
monkeypatch.setitem(sys.modules, "debugpy", None)
with caplog.at_level(logging.DEBUG, logger="undercooked.debug"), TestClient(
create_app(Settings())
):
pass
assert caplog.records == []
# ----------------------------------------------------------------------------- module `app`
def test_module_level_app_is_lazy_and_cached() -> None:
module = importlib.import_module("undercooked.server.app")
assert module.app is module.app # built once, then cached
assert isinstance(module.app, FastAPI)
assert get_app() is module.app
def test_unknown_module_attribute_raises() -> None:
with pytest.raises(AttributeError, match="has no attribute"):
_ = app_module.not_a_real_attribute
def test_docs_endpoints_are_disabled_because_they_load_a_cdn() -> None:
"""A12: FastAPI renders /docs and /redoc with assets from a public CDN."""
with TestClient(create_app(Settings())) as test_client:
for path in ("/docs", "/redoc", "/openapi.json"):
assert test_client.get(path).status_code == 404
+1
View File
@@ -0,0 +1 @@
Headless determinism and replay tests against the pure simulation kernel (`sim/`).
+1
View File
@@ -0,0 +1 @@
Pure, fast unit tests: config parsing, helpers, and other no-I/O logic.
+471
View File
@@ -0,0 +1,471 @@
"""Unit tests for `undercooked.config` (PLAN.md A17): defaults, overrides, validation.
Everything here calls `load_settings({...})` with an explicit mapping wherever
possible, so a developer's local `.env` can never change what a test asserts.
The `.env` / cache behaviour is exercised separately, in an isolated directory.
"""
from __future__ import annotations
import dataclasses
import os
import subprocess
import sys
from collections.abc import Callable, Iterator
from pathlib import Path
import pytest
from undercooked import config
from undercooked.config import (
ENV_KEYS,
ConfigError,
Settings,
apply_cli_overrides,
get_settings,
load_settings,
reset_settings_cache,
)
pytestmark = pytest.mark.unit
REPO_ROOT = Path(__file__).resolve().parents[2]
@pytest.fixture(autouse=True)
def pristine_env() -> Iterator[None]:
"""Undo env/cache side effects - `load_dotenv` mutates `os.environ` in place."""
saved = dict(os.environ)
reset_settings_cache()
yield
os.environ.clear()
os.environ.update(saved)
reset_settings_cache()
# ----------------------------------------------------------------------------- defaults
def test_defaults_match_the_documented_table() -> None:
settings = load_settings({})
assert settings == Settings()
assert settings.host == "127.0.0.1"
assert settings.port == 8000
assert settings.tick_hz == 20
assert settings.snapshot_hz == 20
assert settings.max_players == 4
assert settings.max_rooms == 200
assert settings.levels_dir == Path("levels")
assert settings.static_dir is None # derived from the package layout, see server.app
assert settings.lobby_idle_timeout_s == 900.0
assert settings.guest_idle_timeout_s == 45.0
assert settings.max_message_bytes == 4096
assert settings.max_inputs_per_s == 40
assert settings.sim_latency_ms == 0
assert settings.debugpy is False
assert settings.debugpy_port == 5678
assert settings.allowed_origins == ()
def test_env_keys_cover_exactly_the_settings_fields() -> None:
assert set(ENV_KEYS) == {field.name for field in dataclasses.fields(Settings)}
def test_settings_is_frozen_and_uses_slots() -> None:
settings = Settings()
with pytest.raises(dataclasses.FrozenInstanceError):
settings.port = 1234 # type: ignore[misc]
assert not hasattr(settings, "__dict__")
def test_config_error_is_a_value_error() -> None:
assert issubclass(ConfigError, ValueError)
# ----------------------------------------------------------------------------- overrides
@pytest.mark.parametrize(
("field", "raw", "expected"),
[
("host", "localhost", "localhost"),
("port", "8123", 8123),
("tick_hz", "30", 30),
("snapshot_hz", "15", 15),
("max_players", "2", 2),
("max_rooms", "1", 1),
("lobby_idle_timeout_s", "60", 60.0),
("guest_idle_timeout_s", "7.5", 7.5),
("max_message_bytes", "8192", 8192),
("max_inputs_per_s", "60", 60),
("sim_latency_ms", "150", 150),
("debugpy_port", "5679", 5679),
],
)
def test_single_variable_override(field: str, raw: str, expected: object) -> None:
settings = load_settings({ENV_KEYS[field]: raw})
assert getattr(settings, field) == expected
assert settings.levels_dir == Path("levels") # untouched fields keep their default
@pytest.mark.parametrize("raw", ["1", "true", "TRUE", "yes", "on"])
def test_debugpy_truthy_values(raw: str) -> None:
assert load_settings({"DEBUGPY": raw}).debugpy is True
@pytest.mark.parametrize("raw", ["0", "false", "False", "no", "off"])
def test_debugpy_falsy_values(raw: str) -> None:
assert load_settings({"DEBUGPY": raw}).debugpy is False
@pytest.mark.parametrize(
("raw", "expected"),
[
("", ()),
(" ", ()),
("http://localhost:3000", ("http://localhost:3000",)),
("http://a , http://b", ("http://a", "http://b")),
("http://a,,http://b,", ("http://a", "http://b")),
(",,", ()),
],
)
def test_allowed_origins_is_comma_split(raw: str, expected: tuple[str, ...]) -> None:
assert load_settings({ENV_KEYS["allowed_origins"]: raw}).allowed_origins == expected
@pytest.mark.parametrize(
("raw", "expected"),
[
("levels", Path("levels")),
("levels/", Path("levels")),
("/srv/undercooked/levels", Path("/srv/undercooked/levels")),
("~/kitchens", Path.home() / "kitchens"),
("./kitchens", Path("kitchens")),
],
)
def test_levels_dir_path_handling(raw: str, expected: Path) -> None:
assert load_settings({ENV_KEYS["levels_dir"]: raw}).levels_dir == expected
@pytest.mark.parametrize(
("raw", "expected"),
[
("/app/app/static", Path("/app/app/static")),
("~/static", Path.home() / "static"),
("static/", Path("static")),
("", None),
(" ", None),
],
)
def test_static_dir_is_optional_and_expanded(raw: str, expected: Path | None) -> None:
"""An unset `UNDERCOOKED_STATIC_DIR` means "derive it from the package"."""
assert load_settings({ENV_KEYS["static_dir"]: raw}).static_dir == expected
@pytest.mark.parametrize(
"field", ["port", "tick_hz", "lobby_idle_timeout_s", "host", "allowed_origins"]
)
@pytest.mark.parametrize("blank", ["", " ", "\t"])
def test_blank_value_counts_as_unset(field: str, blank: str) -> None:
assert load_settings({ENV_KEYS[field]: blank}) == Settings()
def test_surrounding_whitespace_is_tolerated() -> None:
settings = load_settings({ENV_KEYS["port"]: " 8123 ", ENV_KEYS["host"]: "\nlocalhost\t"})
assert (settings.port, settings.host) == (8123, "localhost")
def test_snapshot_hz_may_equal_tick_hz_but_not_exceed_it() -> None:
both_ten = load_settings({ENV_KEYS["tick_hz"]: "10", ENV_KEYS["snapshot_hz"]: "10"})
assert both_ten.snapshot_hz == 10
with pytest.raises(ConfigError, match=r"UNDERCOOKED_SNAPSHOT_HZ.*must be >= 1, <= 10"):
load_settings({ENV_KEYS["tick_hz"]: "10", ENV_KEYS["snapshot_hz"]: "20"})
# ----------------------------------------------------------------------------- validation errors
@pytest.mark.parametrize(
("field", "raw", "message"),
[
("port", "http", "UNDERCOOKED_PORT='http' is not an integer"),
("port", "8.5", "UNDERCOOKED_PORT='8.5' is not an integer"),
("port", "0", "UNDERCOOKED_PORT=0 is invalid: must be >= 1, <= 65535"),
("port", "65536", "UNDERCOOKED_PORT=65536 is invalid: must be >= 1, <= 65535"),
("tick_hz", "4", "UNDERCOOKED_TICK_HZ=4 is invalid: must be >= 5, <= 60"),
("tick_hz", "99", "UNDERCOOKED_TICK_HZ=99 is invalid: must be >= 5, <= 60"),
("tick_hz", "twenty", "UNDERCOOKED_TICK_HZ='twenty' is not an integer"),
("snapshot_hz", "0", "UNDERCOOKED_SNAPSHOT_HZ=0 is invalid: must be >= 1, <= 20"),
("max_players", "0", "UNDERCOOKED_MAX_PLAYERS=0 is invalid: must be >= 1, <= 4"),
("max_players", "5", "UNDERCOOKED_MAX_PLAYERS=5 is invalid: must be >= 1, <= 4"),
("max_rooms", "0", "UNDERCOOKED_MAX_ROOMS=0 is invalid: must be >= 1"),
("max_rooms", "-3", "UNDERCOOKED_MAX_ROOMS=-3 is invalid: must be >= 1"),
(
"lobby_idle_timeout_s",
"-1",
"UNDERCOOKED_LOBBY_IDLE_TIMEOUT_S=-1.0 is invalid: must be >= 0.0",
),
("lobby_idle_timeout_s", "soon", "UNDERCOOKED_LOBBY_IDLE_TIMEOUT_S='soon' is not a number"),
(
"guest_idle_timeout_s",
"-0.5",
"UNDERCOOKED_GUEST_IDLE_TIMEOUT_S=-0.5 is invalid: must be >= 0.0",
),
(
"max_message_bytes",
"255",
"UNDERCOOKED_MAX_MESSAGE_BYTES=255 is invalid: must be >= 256",
),
("max_inputs_per_s", "0", "UNDERCOOKED_MAX_INPUTS_PER_S=0 is invalid: must be >= 1"),
("sim_latency_ms", "-1", "UNDERCOOKED_SIM_LATENCY_MS=-1 is invalid: must be >= 0"),
("debugpy_port", "0", "DEBUGPY_PORT=0 is invalid: must be >= 1, <= 65535"),
(
"debugpy",
"maybe",
"DEBUGPY='maybe' is not a boolean (use 1/0, true/false, yes/no, on/off)",
),
],
)
def test_invalid_values_raise_config_error(field: str, raw: str, message: str) -> None:
with pytest.raises(ConfigError) as excinfo:
load_settings({ENV_KEYS[field]: raw})
assert str(excinfo.value) == message
def test_out_of_range_tick_hz_fails_from_the_real_environment() -> None:
"""The documented criterion: `UNDERCOOKED_TICK_HZ=99` fails loudly at startup."""
env = {
key: value
for key, value in os.environ.items()
if not key.startswith(("UNDERCOOKED_", "DEBUGPY"))
}
env["UNDERCOOKED_TICK_HZ"] = "99"
result = subprocess.run( # fixed argv, repo's own interpreter
[sys.executable, "-c", "from undercooked.config import load_settings; load_settings()"],
cwd=REPO_ROOT,
env=env,
capture_output=True,
text=True,
check=False,
timeout=120,
)
assert result.returncode != 0
assert "ConfigError" in result.stderr
assert "UNDERCOOKED_TICK_HZ=99" in result.stderr
# ----------------------------------------------------------------------------- cached accessor
def test_get_settings_caches_until_reset(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
monkeypatch.chdir(tmp_path)
monkeypatch.setenv("UNDERCOOKED_PORT", "8123")
first = get_settings()
assert first.port == 8123
monkeypatch.setenv("UNDERCOOKED_PORT", "9999")
assert get_settings() is first # still the cached object
reset_settings_cache()
assert get_settings().port == 9999
def test_get_settings_applies_defaults_with_an_empty_environment(
monkeypatch: pytest.MonkeyPatch,
) -> None:
for key in list(os.environ):
if key.startswith(("UNDERCOOKED_", "DEBUGPY")):
monkeypatch.delenv(key, raising=False)
monkeypatch.setenv("PYTHON_DOTENV_DISABLED", "true")
assert get_settings() == Settings()
# ----------------------------------------------------------------------------- .env loading
def test_dotenv_file_is_read_from_the_current_directory(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
monkeypatch.delenv("UNDERCOOKED_PORT", raising=False)
monkeypatch.delenv("UNDERCOOKED_MAX_ROOMS", raising=False)
(tmp_path / ".env").write_text(
"UNDERCOOKED_PORT=8123\nUNDERCOOKED_MAX_ROOMS=7\n", encoding="utf-8"
)
monkeypatch.chdir(tmp_path)
settings = load_settings()
assert (settings.port, settings.max_rooms) == (8123, 7)
def test_dotenv_is_found_in_a_parent_directory(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
monkeypatch.delenv("UNDERCOOKED_PORT", raising=False)
(tmp_path / ".env").write_text("UNDERCOOKED_PORT=8245\n", encoding="utf-8")
nested = tmp_path / "src" / "undercooked"
nested.mkdir(parents=True)
monkeypatch.chdir(nested)
assert load_settings().port == 8245
def test_dotenv_never_overrides_an_exported_variable(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
monkeypatch.setenv("UNDERCOOKED_PORT", "7000")
(tmp_path / ".env").write_text("UNDERCOOKED_PORT=8123\n", encoding="utf-8")
monkeypatch.chdir(tmp_path)
assert load_settings().port == 7000
def test_dotenv_is_read_once_until_the_cache_is_reset(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
loaded: list[str] = []
(tmp_path / ".env").write_text("UNDERCOOKED_PORT=8123\n", encoding="utf-8")
monkeypatch.chdir(tmp_path)
def fake_load_dotenv(path: object, **_kwargs: object) -> bool:
loaded.append(str(path))
return True
monkeypatch.setattr(config, "load_dotenv", fake_load_dotenv)
load_settings()
load_settings()
assert len(loaded) == 1 # one `.env` read per process, not one per call
reset_settings_cache()
load_settings()
assert len(loaded) == 2
def test_missing_dotenv_file_is_not_an_error(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
monkeypatch.delenv("UNDERCOOKED_PORT", raising=False)
monkeypatch.chdir(tmp_path)
assert load_settings().port == 8000
def test_explicit_env_mapping_skips_dotenv(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
monkeypatch.chdir(tmp_path)
def never_find(**_kwargs: object) -> str:
pytest.fail("dotenv must not be read when an explicit env mapping is given")
monkeypatch.setattr(config, "find_dotenv", never_find)
assert load_settings({"UNDERCOOKED_PORT": "8123"}).port == 8123
# ----------------------------------------------------------------------------- .env.example
def parse_env_example(path: Path) -> dict[str, str]:
"""Read `KEY=value` lines from an env template, ignoring comments."""
values: dict[str, str] = {}
for line in path.read_text(encoding="utf-8").splitlines():
stripped = line.strip()
if not stripped or stripped.startswith("#") or "=" not in stripped:
continue
key, _, value = stripped.partition("=")
values[key.strip()] = value.strip()
return values
def test_env_example_documents_every_variable_with_its_default() -> None:
example = parse_env_example(REPO_ROOT / ".env.example")
assert set(example) == set(ENV_KEYS.values())
assert load_settings(example) == Settings()
@pytest.mark.parametrize("forbidden", ["token", "password", "secret", "api_key", "reese", ".com"])
def test_env_example_values_carry_no_secrets_or_hostnames(forbidden: str) -> None:
"""The template is committed, so its values must stay placeholders (A16/A17)."""
for key, value in parse_env_example(REPO_ROOT / ".env.example").items():
assert forbidden not in value.lower(), f"{key} looks like it holds a secret or a real host"
assert len(value) <= 40, f"{key} is too long to be a default"
# ----------------------------------------------------------------------------- README table
def parse_readme_config_table(path: Path) -> dict[str, str]:
"""Read the VAR / default / meaning rows of the README configuration table.
A default written as *(empty)* means "no value", i.e. an empty string.
"""
lines = path.read_text(encoding="utf-8").splitlines()
try:
start = lines.index("## Configuration")
except ValueError: # pragma: no cover - fails loudly if the section is renamed
pytest.fail("README.md has no '## Configuration' section")
table: dict[str, str] = {}
for line in lines[start + 1 :]:
if line.startswith("## "):
break
stripped = line.strip()
if not stripped.startswith("|"):
continue
cells = [cell.strip() for cell in stripped.strip("|").split("|")]
key = cells[0].strip("`")
if not key.isupper():
continue # header and separator rows
default = cells[1].strip("`")
table[key] = "" if default.startswith("*") else default
return table
def test_readme_documents_every_variable_with_its_default() -> None:
table = parse_readme_config_table(REPO_ROOT / "README.md")
assert set(table) == set(ENV_KEYS.values())
assert load_settings(table) == Settings()
# ----------------------------------------------------------------------------- CLI overrides
def test_apply_cli_overrides_publishes_env_and_rebuilds(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.delenv("UNDERCOOKED_LEVELS_DIR", raising=False)
settings = apply_cli_overrides(levels_dir=Path("kitchens"), port=9123)
assert settings.levels_dir == Path("kitchens")
assert settings.port == 9123
assert os.environ["UNDERCOOKED_LEVELS_DIR"] == "kitchens"
assert get_settings() == settings # a later get_settings() sees the same thing
def test_apply_cli_overrides_beats_an_existing_environment_value(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("UNDERCOOKED_PORT", "8123")
assert apply_cli_overrides(port=9200).port == 9200
def test_apply_cli_overrides_validates_the_values_it_writes() -> None:
with pytest.raises(ConfigError, match=r"UNDERCOOKED_PORT=0 is invalid"):
apply_cli_overrides(port=0)
def test_apply_cli_overrides_rejects_an_unknown_field() -> None:
with pytest.raises(ConfigError, match="'nonsense' is not a configuration field"):
apply_cli_overrides(**{"nonsense": 1})
def test_apply_cli_overrides_renders_booleans_as_env_flags() -> None:
assert apply_cli_overrides(debugpy=True).debugpy is True
assert os.environ["DEBUGPY"] == "1"
assert apply_cli_overrides(debugpy=False).debugpy is False
assert os.environ["DEBUGPY"] == "0"
def test_apply_cli_overrides_with_nothing_still_validates_the_environment(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("UNDERCOOKED_TICK_HZ", "99")
with pytest.raises(ConfigError, match="UNDERCOOKED_TICK_HZ=99"):
apply_cli_overrides()
# ----------------------------------------------------------------------------- conftest factory
def test_make_settings_fixture_builds_settings(make_settings: Callable[..., Settings]) -> None:
settings = make_settings(tick_hz=30, snapshot_hz=15, debugpy=True, allowed_origins=["http://a", "http://b"])
assert (settings.tick_hz, settings.snapshot_hz) == (30, 15)
assert settings.debugpy is True
assert settings.allowed_origins == ("http://a", "http://b")
assert settings.levels_dir == Path("levels")
+114
View File
@@ -0,0 +1,114 @@
"""Unit tests for the debugpy bootstrap (PLAN.md A18).
The real debugger must never bind a port during the suite: each test injects a
fake `debugpy` through `sys.modules`, which is exactly what the lazy import in
`undercooked.debug` resolves against.
"""
from __future__ import annotations
import logging
import sys
from typing import Any
import pytest
from undercooked import debug
from undercooked.config import Settings
pytestmark = pytest.mark.unit
class FakeDebugpy:
"""Stand-in for the `debugpy` module; records `listen` calls, may fail."""
def __init__(self, error: Exception | None = None) -> None:
self.error = error
self.calls: list[tuple[Any, dict[str, Any]]] = []
def listen(self, address: Any, **kwargs: Any) -> Any:
self.calls.append((address, kwargs))
if self.error is not None:
raise self.error
return address
def install(monkeypatch: pytest.MonkeyPatch, fake: FakeDebugpy | None) -> None:
"""Put `fake` (or `None`, for "not installed") where `import debugpy` looks."""
monkeypatch.setitem(sys.modules, "debugpy", fake)
def test_disabled_is_a_no_op(monkeypatch: pytest.MonkeyPatch) -> None:
# `None` in sys.modules makes any `import debugpy` raise, so reaching the
# import at all would fail the test instead of silently binding a port.
install(monkeypatch, None)
assert debug.maybe_start_debugpy(Settings()) is False
def test_disabled_never_logs(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
with caplog.at_level(logging.DEBUG, logger="undercooked.debug"):
debug.maybe_start_debugpy(Settings(debugpy=False, debugpy_port=5999))
assert caplog.records == []
def test_enabled_listens_once_without_waiting(monkeypatch: pytest.MonkeyPatch) -> None:
fake = FakeDebugpy()
install(monkeypatch, fake)
assert debug.maybe_start_debugpy(Settings(debugpy=True)) is True
assert len(fake.calls) == 1
address, kwargs = fake.calls[0]
assert address == (debug.DEBUGPY_BIND_HOST, 5678)
# Non-blocking is the whole point: startup must not stall waiting for an editor.
assert kwargs.get("wait_for_client", False) is False
def test_enabled_honours_a_custom_port(monkeypatch: pytest.MonkeyPatch) -> None:
fake = FakeDebugpy()
install(monkeypatch, fake)
assert debug.maybe_start_debugpy(Settings(debugpy=True, debugpy_port=5999)) is True
assert fake.calls[0][0] == (debug.DEBUGPY_BIND_HOST, 5999)
def test_enabled_logs_the_endpoint(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
install(monkeypatch, FakeDebugpy())
with caplog.at_level(logging.INFO, logger="undercooked.debug"):
debug.maybe_start_debugpy(Settings(debugpy=True, debugpy_port=5999))
assert "5999" in caplog.text
assert "debugpy" in caplog.text.lower()
def test_missing_debugpy_does_not_stop_the_server(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
install(monkeypatch, None)
with caplog.at_level(logging.WARNING, logger="undercooked.debug"):
assert debug.maybe_start_debugpy(Settings(debugpy=True)) is False
assert "not installed" in caplog.text
@pytest.mark.parametrize(
"error",
[
OSError(98, "Address already in use"),
RuntimeError("already listening"),
ValueError("bad address"),
],
)
def test_listen_failures_are_swallowed(
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
error: Exception,
) -> None:
install(monkeypatch, FakeDebugpy(error))
with caplog.at_level(logging.WARNING, logger="undercooked.debug"):
assert debug.maybe_start_debugpy(Settings(debugpy=True, debugpy_port=5678)) is False
assert "could not listen" in caplog.text
def test_logger_is_named_after_the_module() -> None:
assert isinstance(debug.logger, logging.Logger)
assert debug.logger.name == "undercooked.debug"
+409
View File
@@ -0,0 +1,409 @@
"""Docs guard: what `README.md` tells a human to type must be real (phase 01 task 04).
Documentation rots in two ways this file makes impossible:
1. **Pseudo-commands.** A fenced block that reads plausibly but cannot run — an invented
script, a tool that is not part of this project, a placeholder like `make deploy`.
Every command line inside a `bash`/`sh`/`console`/`shell` fence of `README.md` or
`docs/*.md` must start with a known-safe prefix, and whatever it launches through
`uv run` must be a console script or a dependency declared in `pyproject.toml`. That is
what keeps the Quickstart true after a rename — and what stops a promise (e.g. the
phase-02 `undercooked-levels` CLI) from being written as a runnable command before it
exists.
2. **Config drift.** A variable documented but not implemented, or implemented but never
documented. AGENTS.md §5 requires the `Settings` field, the `ENV_KEYS` entry, the
`.env.example` line and the README row to land together; here the documentation side is
compared against `config.py` and `.env.example`, so they cannot disagree.
"""
from __future__ import annotations
import re
import tomllib
from collections.abc import Iterable
from dataclasses import dataclass
from pathlib import Path
import pytest
from undercooked.config import ENV_KEYS
pytestmark = pytest.mark.unit
REPO_ROOT = Path(__file__).resolve().parents[2]
README = REPO_ROOT / "README.md"
ENV_EXAMPLE = REPO_ROOT / ".env.example"
PYPROJECT = REPO_ROOT / "pyproject.toml"
#: Markdown documents whose command blocks a human may copy.
DOCUMENTED_MARKDOWN: tuple[Path, ...] = (README, *sorted((REPO_ROOT / "docs").glob("*.md")))
#: Shell fences that promise "type this".
COMMAND_FENCE_LANGUAGES = frozenset({"bash", "sh", "console", "shell"})
#: The only things a documented command line may start with. Anything else is prose
#: wearing a code fence, and it will be deleted rather than run.
SAFE_PREFIXES = ("uv ", "podman ", "bash .agents/validate.sh", "curl ", "cd ", "#")
#: `FOO=bar cmd …` is still a command; only documented variables may lead a line.
ENV_ASSIGNMENT = re.compile(r"""^(?:[A-Z][A-Z0-9_]*=(?:"[^"]*"|'[^']*'|\S)+\s+)+""")
#: Every `UNDERCOOKED_*` / `DEBUGPY*` name written anywhere in prose, a table or a fence.
#: A name has to start with a real character, so the `UNDERCOOKED_*` wildcard in prose is
#: not read as a variable nobody implemented.
DOCUMENTED_VARIABLE = re.compile(r"\bUNDERCOOKED_[A-Z0-9_]*[A-Z0-9]\b|\bDEBUGPY(?:_[A-Z0-9]+)?\b")
#: `uv run` options that consume the token after them, so the program name is further along.
UV_VALUE_OPTIONS = frozenset({"--extra", "--python", "--with", "--directory", "--project", "-m"})
#: `KEY=value` lines of an env template.
_TEMPLATE_VARIABLE = re.compile(r"^([A-Z][A-Z0-9_]*)\s*=")
@dataclass(frozen=True, slots=True)
class Command:
"""One logical command line from a fenced block (continuations joined)."""
document: str
line: int
text: str
def __str__(self) -> str:
return f"{self.document}:{self.line}: {self.text}"
def _strip_assignment(command: str) -> tuple[tuple[str, ...], str]:
"""Split `A=1 B=2 uv run …` into its variable names and the command underneath."""
names: list[str] = []
while match := ENV_ASSIGNMENT.match(command):
names.extend(part.split("=", 1)[0] for part in match.group(0).split())
command = command[match.end(0) :].lstrip()
return tuple(names), command
def fenced_commands(markdown: str, *, document: str = "<memory>") -> list[Command]:
"""Every command inside a `bash`-family fence, with the line it started on.
A trailing-backslash continuation becomes one `Command`, because only the first line
of a wrapped command carries the program name.
"""
commands: list[Command] = []
fence: str | None = None
pending: list[str] = []
pending_line = 0
for number, raw in enumerate(markdown.splitlines(), start=1):
marker = re.match(r"^\s*```(\w*)\s*$", raw)
if marker:
fence = None if fence is not None else marker.group(1).lower()
if pending:
commands.append(Command(document, pending_line, " ".join(pending)))
pending = []
continue
if fence is None or fence not in COMMAND_FENCE_LANGUAGES:
continue
stripped = raw.strip()
if not stripped:
if pending:
commands.append(Command(document, pending_line, " ".join(pending)))
pending = []
continue
if not pending:
pending_line = number
if stripped.endswith("\\"):
pending.append(stripped[:-1].strip())
continue
pending.append(stripped)
commands.append(Command(document, pending_line, " ".join(pending)))
pending = []
if pending: # pragma: no cover - a document ending mid-continuation
commands.append(Command(document, pending_line, " ".join(pending)))
return commands
def uv_run_program(command: str) -> str | None:
"""The program a `uv run …` line launches, with uv's own options skipped; else `None`.
That program must be declared in `pyproject.toml`, which is what makes a doc line
inventing a script fail the build.
"""
if not command.startswith("uv run"):
return None
tokens = command.split()[2:]
index = 0
while index < len(tokens):
token = tokens[index]
if token in UV_VALUE_OPTIONS:
index += 2
continue
if token.startswith("-"):
index += 1
continue
return token.lower()
return None
def declared_uv_programs() -> frozenset[str]:
"""Program names `uv run` may launch: this project's scripts plus its dependencies."""
def names(requirements: list[str]) -> set[str]:
return {re.split(r"[<>=!;\[ ]", name)[0].strip().lower() for name in requirements}
data = tomllib.loads(PYPROJECT.read_text(encoding="utf-8"))
project = data["project"]
packages = names(list(project.get("dependencies", [])))
for extra in project.get("optional-dependencies", {}).values():
packages |= names(list(extra))
return frozenset(set(project.get("scripts", {})) | packages | {"python"})
def template_variables() -> set[str]:
"""Names declared in the committed `.env.example` template (comments ignored)."""
return {
match.group(1)
for line in ENV_EXAMPLE.read_text(encoding="utf-8").splitlines()
if (match := _TEMPLATE_VARIABLE.match(line.strip()))
}
def variables_in(path: Path) -> set[str]:
"""Every configuration variable named anywhere in one markdown file."""
return set(DOCUMENTED_VARIABLE.findall(path.read_text(encoding="utf-8")))
def _fail(header: str, offenders: list[str]) -> None:
pytest.fail(f"{header}\n" + "\n".join(f" {offender}" for offender in offenders))
def unsafe_commands(commands: Iterable[Command]) -> list[str]:
"""Commands whose first token is none of `SAFE_PREFIXES`, once env assignments are peeled."""
offenders: list[str] = []
for command in commands:
_, bare = _strip_assignment(command.text)
if not bare.startswith(SAFE_PREFIXES):
offenders.append(str(command))
return offenders
def undeclared_programs(commands: Iterable[Command], allowed: frozenset[str]) -> list[str]:
"""`uv run <thing>` lines whose `<thing>` pyproject.toml does not declare."""
offenders: list[str] = []
for command in commands:
_, bare = _strip_assignment(command.text)
program = uv_run_program(bare)
if program is not None and program not in allowed:
offenders.append(f"{command} -> {program!r} is not in pyproject.toml")
return offenders
def documented_but_unimplemented(documented: set[str], implemented: set[str]) -> list[str]:
"""Variables the docs name and `config.py` does not read."""
return sorted(documented - implemented)
def implemented_but_undocumented(implemented: set[str], documented: set[str]) -> list[str]:
"""Variables `config.py` reads and the docs never mention."""
return sorted(set(implemented) - documented)
# --------------------------------------------------------------------------- command blocks
def test_readme_has_command_blocks_worth_checking() -> None:
"""Non-vacuous: if the Quickstart stops being a bash fence, this has to notice."""
commands = fenced_commands(README.read_text(encoding="utf-8"), document="README.md")
assert len(commands) >= 8, f"README.md has lost its command blocks: {commands}"
assert any(command.text.startswith("uv run undercooked") for command in commands)
assert any(command.text.startswith("uv run pytest") for command in commands)
def test_documented_command_lines_start_with_a_known_prefix() -> None:
"""No pseudo-commands in the docs a player or an operator copy-pastes from."""
offenders: list[str] = []
for document in DOCUMENTED_MARKDOWN:
commands = fenced_commands(document.read_text(encoding="utf-8"), document=document.name)
offenders.extend(unsafe_commands(commands))
if offenders:
_fail(f"documented commands must start with one of {SAFE_PREFIXES}:", offenders)
def test_documented_commands_only_name_declared_programs() -> None:
"""`uv run <thing>` may only launch a script or dependency of this project."""
allowed = declared_uv_programs()
offenders: list[str] = []
for document in DOCUMENTED_MARKDOWN:
commands = fenced_commands(document.read_text(encoding="utf-8"), document=document.name)
offenders.extend(undeclared_programs(commands, allowed))
if offenders:
_fail(f"`uv run` may only run {sorted(allowed)}:", offenders)
def test_command_fence_is_a_fence_and_not_a_table() -> None:
"""Pins the extractor: fenced bash is a command, indented prose and other fences are not."""
sample = "\n".join(
[
"text `not-a-command --yolo`", # 1: inline code is not a command
"",
"```bash", # 3
"uv run undercooked \\", # 4 \\ the continuation joins the next line
" --port 8137", # 5
"", # 6: a blank line ends a command, it never joins across it
"uv run pytest -q", # 7
"# a comment", # 8
"uv run ruff check \\", # 9 \\ a continuation cut off by a blank line still counts
"",
"uv run pyright \\", # 11 \\ and one cut off by the closing fence counts too
"```",
"",
"```text", # 14: not a command fence
"made-up-command --yolo",
"```",
]
)
assert fenced_commands(sample) == [
Command("<memory>", 4, "uv run undercooked --port 8137"),
Command("<memory>", 7, "uv run pytest -q"),
Command("<memory>", 8, "# a comment"),
Command("<memory>", 9, "uv run ruff check"),
Command("<memory>", 11, "uv run pyright"),
]
assert str(Command("README.md", 12, "uv run undercooked")) == "README.md:12: uv run undercooked"
def test_leading_environment_assignments_are_peeled_off() -> None:
names, bare = _strip_assignment("DEBUGPY=1 UNDERCOOKED_PORT=8137 uv run undercooked")
assert names == ("DEBUGPY", "UNDERCOOKED_PORT")
assert bare == "uv run undercooked"
@pytest.mark.parametrize(
("command", "program"),
[
("uv run pytest -q", "pytest"),
("uv run --extra dev ruff check .", "ruff"),
("uv run undercooked --reload", "undercooked"),
("uv run python -c 'print(1)'", "python"),
],
)
def test_the_uv_run_extractor_finds_the_program(command: str, program: str) -> None:
assert uv_run_program(command) == program
@pytest.mark.parametrize("command", ["podman build .", "uv sync --extra dev", "cd .", "uv run", ""])
def test_the_uv_run_extractor_returns_none_for_everything_else(command: str) -> None:
assert uv_run_program(command) is None
def test_the_declared_programs_cover_the_real_toolchain() -> None:
"""Derived from pyproject.toml, so a dependency removal breaks the docs loudly."""
allowed = declared_uv_programs()
assert {"undercooked", "pytest", "ruff", "pyright", "playwright", "python"} <= allowed
assert "undercooked-levels" in allowed # declared now, importable from phase 02
# --------------------------------------------------------------------------- variable names
def test_every_variable_named_in_the_docs_is_implemented() -> None:
"""README and docs may only name a variable `config.py` or `.env.example` defines."""
implemented = set(ENV_KEYS.values()) | template_variables()
offenders = [
f"{document.name}: {name}"
for document in DOCUMENTED_MARKDOWN
for name in documented_but_unimplemented(variables_in(document), implemented)
]
if offenders:
_fail("documented but not implemented:", offenders)
def test_every_implemented_variable_is_documented() -> None:
"""The reverse direction: a knob nobody documented is a knob nobody can find."""
documented = variables_in(README) | variables_in(ENV_EXAMPLE)
missing = implemented_but_undocumented(set(ENV_KEYS.values()), documented)
if missing:
_fail("config.py defines variables neither README.md nor .env.example mentions:", missing)
def test_readme_and_env_template_name_the_same_variables() -> None:
"""The two operator-facing lists cannot diverge from each other."""
documented, template = variables_in(README), variables_in(ENV_EXAMPLE)
only_readme = sorted(documented - template)
only_template = sorted(template - documented)
assert not only_readme, f"named in README.md but absent from .env.example: {only_readme}"
assert not only_template, f"named in .env.example but absent from README.md: {only_template}"
# --------------------------------------------------------------------------- the gate is documented
def test_readme_documents_the_gate_and_the_cdn_rule() -> None:
"""The commands AGENTS.md calls binding are findable in the human-facing docs."""
readme = README.read_text(encoding="utf-8")
assert "bash .agents/validate.sh" in readme, "the pipeline gate is not documented in README"
assert re.search(r"no external cdns", readme, re.IGNORECASE), "A12 is not stated in README"
def test_a_pseudo_command_is_reported() -> None:
"""The prefix rule actually fires: planted prose-commands are named, real ones are not."""
sample = "\n".join(
[
"```bash", # 1
"uv run undercooked --port 8137", # 2
"DEBUGPY=1 uv run undercooked", # 3: an env lead is still a command
"make deploy-to-staging", # 4
"kubectl port-forward svc/undercooked 8000", # 5
"# a comment is a valid line", # 6
"```",
]
)
offenders = unsafe_commands(fenced_commands(sample, document="<memory>"))
assert offenders == [
"<memory>:4: make deploy-to-staging",
"<memory>:5: kubectl port-forward svc/undercooked 8000",
]
def test_an_invented_program_is_reported() -> None:
"""A doc line cannot run a script pyproject.toml does not declare."""
sample = "\n".join(
[
"```bash",
"uv run --frozen undercooked-levels validate",
"uv run made-up-cli validate",
"```",
]
)
commands = fenced_commands(sample, document="<memory>")
offenders = undeclared_programs(commands, frozenset({"undercooked-levels"}))
assert len(offenders) == 1, offenders
assert "made-up-cli" in offenders[0]
def test_the_variable_diffs_work_in_both_directions() -> None:
"""The two drift directions are tested, not just asserted to be empty on today's docs."""
implemented = {"UNDERCOOKED_PORT", "DEBUGPY"}
documented = {"UNDERCOOKED_PORT", "DEBUGPY", "UNDERCOOKED_MADE_UP", "DEBUGPY_PORT"}
assert documented_but_unimplemented(documented, implemented) == [
"DEBUGPY_PORT",
"UNDERCOOKED_MADE_UP",
]
assert implemented_but_undocumented(implemented, documented) == []
assert implemented_but_undocumented({"UNDERCOOKED_NEW_KNOB"}, documented) == [
"UNDERCOOKED_NEW_KNOB"
]
def test_the_failure_helper_lists_every_offender() -> None:
"""`_fail` is the message an executor reads; it has to enumerate, not truncate."""
with pytest.raises(pytest.fail.Exception, match="two"):
_fail("broken:", ["one", "two"])
def test_agents_md_only_names_test_paths_that_exist() -> None:
"""Every concrete `tests/**.py|md` path AGENTS.md cites is a real file."""
agents = (REPO_ROOT / "AGENTS.md").read_text(encoding="utf-8")
cited = re.findall(r"tests/[A-Za-z0-9_./-]+\.(?:py|md)", agents)
assert "tests/unit/test_no_external_assets.py" in cited
assert cited, "AGENTS.md cites no test files at all"
missing = sorted({path for path in cited if not (REPO_ROOT / path).is_file()})
assert missing == [], f"AGENTS.md points at files that do not exist: {missing}"
+357
View File
@@ -0,0 +1,357 @@
"""PLAN §13 doc-index guard: the promised documentation set and the `docs/` tree cannot drift.
`.agents/PLAN.md` §13 closes with the canonical documentation index and the sentence
"`tests/unit/test_docs_index.py` (phase 01) keeps this table and the `docs/` tree identical,
so neither can drift silently." This is that file. It closes both directions of drift:
* **A promised doc is missing.** Every index row whose creating phase has already completed
(according to the `.agents/phases/complete/` tree) must exist on disk, so no phase can
reference a document that was never written. A range in the "Created by" column —
`phase 02/03` — promises the file by the *end* of that range, so its obligation starts at
the later phase; a `→` marks the stub-then-fill pattern, where the earlier phase owes the
stub.
* **A doc exists that nothing promises.** Every `docs/*.md` must be listed in the index or be
a document a phase file of the roadmap itself creates — `docs/ROOMS.md` comes from phase 05
and is not in §13, so the roadmap may extend the doc set, but an untracked file may not
appear there silently.
Only markdown is compared: the index promises *documents*, and a diagram or fixture a phase
puts under `docs/` is not a documentation promise.
"""
from __future__ import annotations
import re
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path
import pytest
pytestmark = pytest.mark.unit
REPO_ROOT = Path(__file__).resolve().parents[2]
PLAN = REPO_ROOT / ".agents/PLAN.md"
DOCS_DIR = REPO_ROOT / "docs"
PHASES_DIR = REPO_ROOT / ".agents/phases"
COMPLETE_DIR = PHASES_DIR / "complete"
INDEX_HEADING = "### Documentation index (canonical)"
PHASE_DIRECTORY = re.compile(r"^(\d{2})_")
PHASE_REFERENCE = re.compile(r"phase\s+(\d{1,2})", re.IGNORECASE)
NUMBER = re.compile(r"\d{1,2}")
DOC_REFERENCE = re.compile(r"docs/[A-Za-z0-9_-]+\.md")
#: The index exactly as PLAN §13 writes it. Changing this set means editing PLAN.md first.
CANONICAL_DOCS = frozenset(
{
"README.md",
"AGENTS.md",
"BUDGETS.md",
"docs/DESIGN.md",
"docs/SIMULATION.md",
"docs/LEVELS.md",
"docs/PROTOCOL.md",
"docs/CLIENT.md",
"docs/DEPLOYMENT.md",
"docs/TESTING.md",
"docs/ACCESSIBILITY.md",
"docs/SECURITY.md",
"tests/e2e/README.md",
}
)
@dataclass(frozen=True, slots=True)
class DocRow:
"""One row of the PLAN §13 index: path, contents, and the phase(s) that create it."""
path: str
contents: str
created_by: str
@property
def promised_from(self) -> int:
"""First phase after which this file must exist.
The first comma/arrow-separated group is the creation promise. Inside it, a slash
range (`phase 02/03`) is owed by the end of the range, so the later number wins;
anything else is owed by the earliest number mentioned.
"""
first_group = re.split(r"[→,;]", self.created_by, maxsplit=1)[0]
numbers = [int(number) for number in NUMBER.findall(first_group)]
if not numbers:
numbers = [int(number) for number in PHASE_REFERENCE.findall(self.created_by)]
if not numbers:
pytest.fail(f"PLAN index row {self.path!r} names no phase: {self.created_by!r}")
return numbers[-1] if "/" in first_group and len(numbers) > 1 else numbers[0]
def parse_documentation_index(plan_text: str) -> list[DocRow]:
"""Read the canonical documentation-index table out of PLAN.md text."""
lines = plan_text.splitlines()
try:
start = lines.index(INDEX_HEADING)
except ValueError: # pragma: no cover - PLAN is locked; fail loudly if it is renamed
pytest.fail(f".agents/PLAN.md has no {INDEX_HEADING!r} heading")
rows: list[DocRow] = []
for raw in lines[start + 1 :]:
stripped = raw.strip()
if not stripped.startswith("|"):
if rows:
break
continue
cells = [cell.strip() for cell in stripped.strip("|").split("|")]
if len(cells) < 3 or cells[0].strip("`") in {"Path", ""} or set(cells[0]) <= {"-", ":"}:
continue
rows.append(DocRow(path=cells[0].strip("`"), contents=cells[1], created_by=cells[2]))
return rows
def latest_completed_phase(complete_dir: Path = COMPLETE_DIR) -> int:
"""Highest phase number with anything in `complete/` (0 before the first phase finishes)."""
return max(
(
int(match.group(1))
for entry in complete_dir.glob("*")
if (match := PHASE_DIRECTORY.match(entry.name))
),
default=0,
)
def roadmap_promised_docs(phases_dir: Path = PHASES_DIR) -> set[str]:
"""Every `docs/*.md` a phase overview or task file itself names."""
return {
match.group(0)
for phase_file in sorted(phases_dir.rglob("*.md"))
for match in DOC_REFERENCE.finditer(phase_file.read_text(encoding="utf-8"))
}
def documentation_index(plan: Path = PLAN) -> list[DocRow]:
return parse_documentation_index(plan.read_text(encoding="utf-8"))
def written_docs(docs_dir: Path = DOCS_DIR, *, root: Path = REPO_ROOT) -> set[str]:
"""The markdown currently in `docs/`, as repo-relative paths."""
return {path.relative_to(root).as_posix() for path in sorted(docs_dir.rglob("*.md"))}
# --------------------------------------------------------------------------- the index itself
def test_the_documentation_index_is_parseable() -> None:
rows = documentation_index()
assert len(rows) >= len(CANONICAL_DOCS), rows
paths = [row.path for row in rows]
assert len(paths) == len(set(paths)), f"duplicate index rows: {paths}"
assert all(row.contents and row.created_by for row in rows), paths
def test_the_index_still_lists_the_canonical_doc_set() -> None:
"""Adding or dropping a row is a PLAN §13 change and must fail a test first."""
assert {row.path for row in documentation_index()} == CANONICAL_DOCS
@pytest.mark.parametrize(
("created_by", "expected"),
[
("phase 01", 1),
("phase 01, refined throughout", 1),
("phase 01 (stub) \u2192 phase 09", 1),
("phase 02, phase 11", 2),
("phase 02/03", 3),
("phase 14", 14),
("refined throughout, phase 05", 5),
],
)
def test_the_promised_from_rule_reads_the_phase_column(created_by: str, expected: int) -> None:
row = DocRow(path="docs/X.md", contents="whatever", created_by=created_by)
assert row.promised_from == expected
def test_an_index_row_that_names_no_phase_fails_loudly() -> None:
row = DocRow(path="docs/X.md", contents="whatever", created_by="someday, maybe")
with pytest.raises(pytest.fail.Exception, match="names no phase"):
_ = row.promised_from
# --------------------------------------------------------------------------- promised vs present
def test_every_doc_promised_by_a_completed_phase_exists() -> None:
"""No phase may reference a document whose creating phase is already in `complete/`."""
completed = latest_completed_phase()
assert completed >= 1, "no completed phase found — the guard would be vacuous"
missing = [
f"{row.path} (owed by phase {row.promised_from:02d}, {completed:02d} is complete)"
for row in documentation_index()
if row.promised_from <= completed and not (REPO_ROOT / row.path).is_file()
]
report = "\n ".join(missing)
assert missing == [], f"documentation the project owes but does not have:\n {report}"
def test_no_doc_file_exists_that_nothing_promises() -> None:
"""The `docs/` tree is exactly the index plus what the phase roadmap creates."""
promised = {row.path for row in documentation_index()} | roadmap_promised_docs()
orphans = sorted(written_docs() - promised)
report = "\n ".join(orphans)
assert orphans == [], (
f"unpromised files under docs/ — add each to PLAN §13's index or to the phase "
f"that creates it:\n {report}"
)
def test_the_docs_on_disk_are_the_three_phase_01_documents() -> None:
"""Pins today's tree so that adding a document is a deliberate, named act."""
assert written_docs() == {"docs/DESIGN.md", "docs/DEPLOYMENT.md", "docs/TESTING.md"}
def stub_without_owner(relative: str, text: str) -> str | None:
"""One sentence of complaint about a document, or `None` when it is honest.
Split out of the test below so a planted stub can prove each check fires; a guard that
only ever sees healthy input is not a guard.
"""
if not text.startswith("# "):
return f"{relative} does not open with a title"
if "## Purpose" not in text:
return f"{relative} has no Purpose section"
purpose = text.split("## Purpose", 1)[1].split("\n##", 1)[0].strip()
if len(purpose.split()) < 25:
return f"{relative}'s Purpose paragraph is too thin to guide"
names_stub = re.search(r"\bstub\b", text, re.IGNORECASE) is not None
has_owner = re.search(r"[Ff]illed in by phase \d\d", text) is not None
if names_stub and not has_owner:
return f"{relative} is a stub with no 'Filled in by phase NN' marker"
return None
def dishonest_references(
references: dict[str, set[str]], *, exists: Callable[[str], bool], owed_later: set[str]
) -> list[str]:
"""`document: path` for each cited doc that neither exists nor is owed by a later phase."""
offenders: list[str] = []
for name, cited in sorted(references.items()):
offenders.extend(
f"{name}: {path}"
for path in sorted(cited)
if not exists(path) and path not in owed_later
)
return offenders
def test_the_stub_docs_say_who_fills_them_in() -> None:
"""A stub with no owner stays a stub forever, so each one names its phase."""
complaints = [
complaint
for relative in sorted(written_docs())
if (complaint := stub_without_owner(relative, (REPO_ROOT / relative).read_text("utf-8")))
]
report = "\n ".join(complaints)
assert complaints == [], f"documents that are not self-describing:\n {report}"
def test_readme_and_agents_reference_only_honest_doc_paths() -> None:
"""A doc path in README/AGENTS either exists or is owed by a phase that has not run."""
completed = latest_completed_phase()
rows = documentation_index()
owed_later = {row.path for row in rows if row.promised_from > completed}
owed_later |= roadmap_promised_docs()
root_docs = {row.path for row in rows if not row.path.startswith("docs/")}
references: dict[str, set[str]] = {}
for name in ("README.md", "AGENTS.md"):
text = (REPO_ROOT / name).read_text(encoding="utf-8")
references[name] = set(DOC_REFERENCE.findall(text)) | {
path for path in root_docs if path in text
}
offenders = dishonest_references(
references, exists=lambda path: (REPO_ROOT / path).is_file(), owed_later=owed_later
)
report = "\n ".join(offenders)
assert offenders == [], f"references to documentation nothing promises to create:\n {report}"
# --------------------------------------------------------------------------- the guard itself
def test_a_missing_promised_doc_is_named(tmp_path: Path) -> None:
"""Plant a row whose phase already shipped: the parse must expose it as owed."""
plan = tmp_path / "PLAN.md"
plan.write_text(
"\n".join(
[
"## Risks",
"",
INDEX_HEADING,
"",
"| Path | Contents | Created by |",
"|---|---|---|",
"| `docs/GONE.md` | whatever it says | phase 01 |",
"| `docs/LATER.md` | not owed yet | phase 09 |",
"",
"prose after the table",
]
),
encoding="utf-8",
)
rows = parse_documentation_index(plan.read_text(encoding="utf-8"))
assert [row.path for row in rows] == ["docs/GONE.md", "docs/LATER.md"]
owed = [row.path for row in rows if row.promised_from <= 1]
assert owed == ["docs/GONE.md"]
assert not (tmp_path / owed[0]).is_file()
def test_an_unpromised_doc_file_is_reported(tmp_path: Path) -> None:
"""The orphan rule works on a temp tree too — it is not README-specific magic."""
(tmp_path / "docs").mkdir()
(tmp_path / "docs" / "WANTED.md").write_text("# WANTED\n", encoding="utf-8")
(tmp_path / "docs" / "MYSTERY.md").write_text("# MYSTERY\n", encoding="utf-8")
(tmp_path / "docs" / "tile.svg").write_text("<svg/>", encoding="utf-8")
assert written_docs(tmp_path, root=tmp_path) == {
"docs/MYSTERY.md",
"docs/WANTED.md",
}
assert written_docs(tmp_path, root=tmp_path) - {"docs/WANTED.md"} == {"docs/MYSTERY.md"}
def test_an_unhonest_document_is_described_precisely() -> None:
"""Each complaint the stub check can make is exercised on planted text."""
body = "word " * 30
good = f"# Title\n\n## Purpose\n{body}\nA stub here. Filled in by phase 09.\n"
assert stub_without_owner("docs/GOOD.md", good) is None
assert stub_without_owner("docs/ORPHAN.md", good.replace(" Filled in by phase 09.", "")) == (
"docs/ORPHAN.md is a stub with no 'Filled in by phase NN' marker"
)
assert stub_without_owner("docs/UNTITLED.md", f"## Purpose\n{body}") == (
"docs/UNTITLED.md does not open with a title"
)
assert stub_without_owner("docs/NOPURPOSE.md", "# Title\nProse only.\n") == (
"docs/NOPURPOSE.md has no Purpose section"
)
assert stub_without_owner("docs/THIN.md", "# Title\n\n## Purpose\ntoo short\n") == (
"docs/THIN.md's Purpose paragraph is too thin to guide"
)
def test_a_reference_to_a_doc_nobody_promises_is_named() -> None:
references = {
"AGENTS.md": {"docs/HERE.md"},
"README.md": {"docs/HERE.md", "docs/GHOST.md", "docs/OWED.md"},
}
offenders = dishonest_references(
references,
exists=lambda path: path in {"docs/HERE.md", "docs/OWED.md"},
owed_later={"docs/OWED.md"},
)
assert offenders == ["README.md: docs/GHOST.md"]
def test_the_roadmap_extension_of_the_doc_set_is_tracked() -> None:
"""`docs/ROOMS.md` is phase 05's, not PLAN §13's — and must not read as an orphan."""
roadmap = roadmap_promised_docs()
assert "docs/ROOMS.md" in roadmap
assert "docs/ROOMS.md" not in CANONICAL_DOCS
assert latest_completed_phase() >= 1
+90
View File
@@ -0,0 +1,90 @@
"""Phase-01 task-01 harness smoke tests: package metadata and pytest wiring."""
from __future__ import annotations
import os
import re
import subprocess
import sys
from pathlib import Path
import pytest
import undercooked
pytestmark = pytest.mark.unit
REPO_ROOT = Path(__file__).resolve().parents[2]
HARNESS_MARKERS = ("unit", "integration", "e2e", "slow")
TEST_SUBDIRS = ("unit", "integration", "sim", "e2e")
def test_package_metadata() -> None:
assert undercooked.__version__ == "0.1.0"
assert undercooked.__all__ == []
def test_harness_markers_registered(pytestconfig: pytest.Config) -> None:
registered = {line.split(":", 1)[0].strip() for line in pytestconfig.getini("markers")}
assert set(HARNESS_MARKERS) <= registered
def test_strict_markers_enabled(pytestconfig: pytest.Config) -> None:
assert "--strict-markers" in pytestconfig.getini("addopts")
@pytest.mark.parametrize("name", TEST_SUBDIRS)
def test_test_tree_directories_exist_with_readme(name: str) -> None:
directory = REPO_ROOT / "tests" / name
assert directory.is_dir()
readme = directory / "README.md"
assert readme.is_file()
assert readme.read_text(encoding="utf-8").strip()
def test_tests_dir_is_not_a_package() -> None:
assert not (REPO_ROOT / "tests" / "__init__.py").exists()
def test_fixture_levels_dir_exists(isolated_levels_dir: None) -> None:
levels_dir = Path(os.environ["UNDERCOOKED_LEVELS_DIR"])
assert levels_dir == REPO_ROOT / "tests" / "fixtures" / "levels"
assert levels_dir.is_dir()
assert not list(levels_dir.glob("*.yaml"))
def test_e2e_tests_follow_the_story_naming_convention() -> None:
"""`pytest -m e2e --collect-only` selects only `test_usNN_*.py` story suites.
Task 01 could only assert "the marker collects nothing yet". Now that the first
browser suite exists the durable invariant is the naming contract in
`tests/e2e/README.md`: a story suite that is not named for its story is one that
nobody can find, and one that cannot be collected is one that never runs.
"""
result = subprocess.run( # fixed argv, repo's own interpreter
[
sys.executable,
"-m",
"pytest",
"-m",
"e2e",
"--collect-only",
"-q",
"-o",
"addopts=", # drop the outer coverage flags: no nested .coverage writes
"-p",
"no:cacheprovider",
],
cwd=REPO_ROOT,
capture_output=True,
text=True,
check=False,
timeout=120,
)
assert result.returncode == pytest.ExitCode.OK, result.stdout + result.stderr
assert "error" not in result.stderr.lower(), result.stderr
files = sorted({line.split("::")[0] for line in result.stdout.splitlines() if "::" in line})
assert files, f"the e2e marker selected nothing:\n{result.stdout}"
for path in files:
assert path.startswith("tests/e2e/"), path
assert re.fullmatch(r"test_us\d{2}_.+\.py", Path(path).name), path
+105
View File
@@ -0,0 +1,105 @@
"""Unit tests for the security headers (PLAN.md §11, A12).
These assert the *values*, not just their presence: a header that silently
weakened (a `unsafe-inline`, a `*`, a reporting endpoint) is a security
regression dressed up as a passing test.
"""
from __future__ import annotations
import pytest
from starlette.responses import JSONResponse, PlainTextResponse, Response
from undercooked.server.headers import (
CONTENT_SECURITY_POLICY,
SECURITY_HEADERS,
SecurityHeadersMiddleware,
apply_security_headers,
)
pytestmark = pytest.mark.unit
EXPECTED = {
"Content-Security-Policy": CONTENT_SECURITY_POLICY,
"X-Content-Type-Options": "nosniff",
"Referrer-Policy": "no-referrer",
"X-Frame-Options": "DENY",
"Permissions-Policy": "camera=(), microphone=(), geolocation=()",
}
def test_the_five_documented_headers_are_defined() -> None:
assert dict(SECURITY_HEADERS) == EXPECTED
def test_apply_adds_every_header_to_a_response() -> None:
response = PlainTextResponse("hi")
apply_security_headers(response)
for header, value in EXPECTED.items():
assert response.headers[header] == value
def test_apply_works_on_json_and_empty_bodies() -> None:
payload = JSONResponse({"a": 1})
apply_security_headers(payload)
assert payload.headers["content-security-policy"] == CONTENT_SECURITY_POLICY
empty = Response(status_code=204)
apply_security_headers(empty)
assert empty.headers["x-content-type-options"] == "nosniff"
def test_apply_is_idempotent_and_overrides_weaker_values() -> None:
response = PlainTextResponse("hi", headers={"X-Frame-Options": "SAMEORIGIN"})
apply_security_headers(response)
apply_security_headers(response)
assert response.headers["x-frame-options"] == "DENY"
def _csp() -> dict[str, str]:
"""The CSP as `directive -> whole clause`, so assertions read like the header."""
return {part.split(" ", 1)[0]: part for part in CONTENT_SECURITY_POLICY.split("; ")}
def test_csp_locks_scripts_and_styles_to_self() -> None:
directives = _csp()
assert directives["default-src"] == "default-src 'self'"
assert directives["script-src"] == "script-src 'self'"
assert directives["style-src"] == "style-src 'self'"
assert directives["font-src"] == "font-src 'self'"
assert directives["base-uri"] == "base-uri 'none'"
assert directives["frame-ancestors"] == "frame-ancestors 'none'"
def test_csp_allows_only_same_origin_websockets_and_data_images() -> None:
directives = _csp()
assert directives["connect-src"] == "connect-src 'self' ws: wss:"
assert directives["img-src"] == "img-src 'self' data:"
@pytest.mark.parametrize(
"forbidden", ["unsafe-inline", "unsafe-eval", " https://", "http://", "*", "report-uri"]
)
def test_csp_contains_no_unsafe_source_or_remote_endpoint(forbidden: str) -> None:
assert forbidden not in CONTENT_SECURITY_POLICY
def test_no_header_points_at_a_remote_reporting_service() -> None:
"""`Report-To`/`report-uri` would phone a third party: A12 forbids it."""
assert not any(name.lower() in {"report-to", "nel"} for name in SECURITY_HEADERS)
for value in SECURITY_HEADERS.values():
assert "://" not in value
assert "report" not in value.lower()
def test_headers_are_immutable() -> None:
with pytest.raises(TypeError):
SECURITY_HEADERS["X-Frame-Options"] = "ALLOWALL" # type: ignore[index]
def test_middleware_is_constructible_with_an_app_stub() -> None:
async def stub_app(scope: object, receive: object, send: object) -> None: # pragma: no cover
return None
middleware = SecurityHeadersMiddleware(stub_app)
assert middleware.app is stub_app
+257
View File
@@ -0,0 +1,257 @@
"""Unit tests for the `undercooked` CLI (task 03): precedence, exit codes, wiring.
`_resolve` and `_uvicorn_log_config` are pure, so precedence is testable without
a socket. `main()` is tested with uvicorn's `run` replaced by a recorder — the
real server is started by the integration/phase criteria, not by a unit test.
"""
from __future__ import annotations
import copy
import os
import subprocess
import sys
from pathlib import Path
from typing import TYPE_CHECKING, Any
import pytest
import uvicorn
import uvicorn.config
from undercooked.__main__ import (
APP_IMPORT_STRING,
EXIT_CONFIG_ERROR,
EXIT_OK,
RunOptions,
_cli_overrides,
_resolve,
_uvicorn_log_config,
build_parser,
main,
)
from undercooked.config import (
ENV_KEYS,
ConfigError,
Settings,
apply_cli_overrides,
get_settings,
reset_settings_cache,
)
if TYPE_CHECKING:
from collections.abc import Iterator
pytestmark = pytest.mark.unit
REPO_ROOT = Path(__file__).resolve().parents[2]
# A string in an assertion, never a socket bind: the CLI flag is what is under test.
ALL_INTERFACES = "0.0.0.0" # noqa: S104 - a test assertion, not a socket bind
@pytest.fixture(autouse=True)
def pristine_env() -> Iterator[None]:
"""`main()` publishes CLI flags into `os.environ`; nothing may leak out."""
saved = dict(os.environ)
reset_settings_cache()
yield
os.environ.clear()
os.environ.update(saved)
reset_settings_cache()
@pytest.fixture
def uvicorn_calls(monkeypatch: pytest.MonkeyPatch) -> list[dict[str, Any]]:
"""Replace uvicorn's `run` with a recorder; returns the recorded invocations."""
calls: list[dict[str, Any]] = []
def fake_run(target: str, **kwargs: Any) -> None:
calls.append({"target": target, **kwargs})
monkeypatch.setattr(uvicorn, "run", fake_run)
return calls
@pytest.fixture
def bare_env(monkeypatch: pytest.MonkeyPatch) -> None:
"""No `UNDERCOOKED_*`/`DEBUGPY*` in the environment: defaults are observable."""
for key in list(os.environ):
if key.startswith(("UNDERCOOKED_", "DEBUGPY")):
monkeypatch.delenv(key, raising=False)
monkeypatch.setenv("PYTHON_DOTENV_DISABLED", "true")
def parse(argv: list[str]) -> Any:
"""Parse ``argv`` the way `main` would, without serving anything."""
return build_parser().parse_args(argv)
# ----------------------------------------------------------------------------- parser
def test_every_flag_defaults_to_unset() -> None:
args = parse([])
assert (args.host, args.port, args.reload, args.levels_dir) == (None, None, False, None)
def test_flags_are_parsed() -> None:
args = parse(
["--host", ALL_INTERFACES, "--port", "9000", "--reload", "--levels-dir", "kitchens"]
)
assert (args.host, args.port, args.reload) == (ALL_INTERFACES, 9000, True)
assert args.levels_dir == Path("kitchens")
def test_help_names_the_env_fallback() -> None:
text = build_parser().format_help()
assert "--levels-dir" in text
assert "UNDERCOOKED_PORT" in text
# ----------------------------------------------------------------------------- precedence
def test_cli_beats_environment_and_defaults() -> None:
settings = Settings(host="10.0.0.1", port=8123, levels_dir=Path("env-levels"))
options = _resolve(
parse(["--host", "127.0.0.1", "--port", "9999", "--levels-dir", "cli-levels"]), settings
)
assert options == RunOptions(
host="127.0.0.1", port=9999, reload=False, levels_dir=Path("cli-levels")
)
def test_settings_supply_everything_the_cli_left_unset() -> None:
settings = Settings(host=ALL_INTERFACES, port=8123, levels_dir=Path("env-levels"))
options = _resolve(parse([]), settings)
assert options == RunOptions(
host=ALL_INTERFACES, port=8123, reload=False, levels_dir=Path("env-levels")
)
def test_settings_default_to_the_documented_defaults_in_a_bare_environment(
bare_env: None,
) -> None:
options = _resolve(parse([]), get_settings())
assert options == RunOptions(
host="127.0.0.1", port=8000, reload=False, levels_dir=Path("levels")
)
def test_environment_beats_the_builtin_default(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("UNDERCOOKED_PORT", "8123")
monkeypatch.setenv("UNDERCOOKED_HOST", ALL_INTERFACES)
reset_settings_cache()
options = _resolve(parse([]), get_settings())
assert (options.host, options.port) == (ALL_INTERFACES, 8123)
def test_reload_is_a_flag_not_an_environment_variable() -> None:
assert _resolve(parse(["--reload"]), Settings()).reload is True
assert _resolve(parse([]), Settings()).reload is False
def test_cli_overrides_only_report_flags_that_were_given() -> None:
assert _cli_overrides(parse([])) == {}
assert _cli_overrides(parse(["--port", "9000", "--levels-dir", "x"])) == {
"port": 9000,
"levels_dir": Path("x"),
}
# ----------------------------------------------------------------------------- main()
def test_main_serves_the_app_string_with_resolved_options(
uvicorn_calls: list[dict[str, Any]], bare_env: None
) -> None:
assert main([]) == EXIT_OK
assert len(uvicorn_calls) == 1
call = uvicorn_calls[0]
assert call["target"] == APP_IMPORT_STRING # reload-capable "module:attribute" form
assert (call["host"], call["port"], call["reload"]) == ("127.0.0.1", 8000, False)
assert "undercooked" in call["log_config"]["loggers"]
def test_main_passes_cli_flags_to_uvicorn(
uvicorn_calls: list[dict[str, Any]], bare_env: None
) -> None:
assert main(["--host", ALL_INTERFACES, "--port", "9123", "--reload"]) == EXIT_OK
call = uvicorn_calls[0]
assert (call["host"], call["port"], call["reload"]) == (ALL_INTERFACES, 9123, True)
def test_main_publishes_cli_flags_to_the_environment(
uvicorn_calls: list[dict[str, Any]], bare_env: None
) -> None:
"""A uvicorn --reload child re-reads the environment, so flags must live there."""
assert main(["--levels-dir", "kitchens", "--port", "9124"]) == EXIT_OK
assert os.environ[ENV_KEYS["levels_dir"]] == "kitchens"
assert os.environ[ENV_KEYS["port"]] == "9124"
assert get_settings().levels_dir == Path("kitchens")
assert get_settings().port == 9124
def test_main_validates_cli_values_like_environment_values(
uvicorn_calls: list[dict[str, Any]], capsys: pytest.CaptureFixture[str]
) -> None:
assert main(["--port", "0"]) == EXIT_CONFIG_ERROR
assert uvicorn_calls == []
err = capsys.readouterr().err
assert err.strip().startswith("undercooked:")
assert "UNDERCOOKED_PORT" in err
assert err.count("\n") == 1 # a one-line message, not a traceback
def test_main_reports_an_invalid_environment(
uvicorn_calls: list[dict[str, Any]],
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
monkeypatch.setenv("UNDERCOOKED_TICK_HZ", "99")
reset_settings_cache()
assert main([]) == EXIT_CONFIG_ERROR
assert uvicorn_calls == []
assert "UNDERCOOKED_TICK_HZ=99" in capsys.readouterr().err
def test_unknown_flag_is_an_argument_error() -> None:
with pytest.raises(SystemExit) as excinfo:
main(["--nope"])
assert excinfo.value.code == EXIT_CONFIG_ERROR
def test_an_unknown_settings_field_is_a_config_error() -> None:
with pytest.raises(ConfigError, match="not a configuration field"):
apply_cli_overrides(**{"nonsense": 1})
# ----------------------------------------------------------------------------- logging
def test_uvicorn_log_config_adds_our_loggers_without_touching_uvicorns() -> None:
base = copy.deepcopy(uvicorn.config.LOGGING_CONFIG)
config = _uvicorn_log_config(base)
assert config is not base
assert set(base["loggers"]) == {"uvicorn", "uvicorn.error", "uvicorn.access"}
ours = config["loggers"]["undercooked"]
assert ours["handlers"] == ["default"]
assert ours["level"] == "INFO"
assert config["loggers"]["uvicorn"] is base["loggers"]["uvicorn"]
assert set(config["formatters"]) == set(base["formatters"]) # the handler exists
# ----------------------------------------------------------------------------- the console script
def test_python_m_undercooked_prints_usage_and_exits_zero() -> None:
result = subprocess.run( # fixed argv, the repo's own interpreter
[sys.executable, "-m", "undercooked", "--help"],
cwd=REPO_ROOT,
capture_output=True,
text=True,
check=False,
timeout=120,
)
assert result.returncode == EXIT_OK, result.stderr
assert "usage: undercooked" in result.stdout
assert "--reload" in result.stdout
+392
View File
@@ -0,0 +1,392 @@
"""PLAN A12 guard: the browser must load nothing this process does not serve.
This is the mechanical half of "no external CDNs" (LOCKED decision A12, AGENTS.md §5).
It has two halves on purpose:
1. **A text scan** of every `app/static/**` asset (`.html`, `.css`, `.js`, `.svg`) for the
shapes that reach off-origin: `http(s)://`, protocol-relative `//host/`, hosted-library
mirrors (`jsdelivr`, `unpkg`, …), CSS `@import url(`, protocol-relative stylesheets,
`srcset`, `fetch()` / `import` of an absolute URL, an absolute-URL `WebSocket`, and
`EventSource` (SSE is not this project's transport). A single remote reference is both
an availability and a privacy defect, so the scan is blunt and needs no allow-list —
with one documented exception, the `xmlns` namespace URIs of SVG/XML, which are
identifiers the browser never fetches (A11 ships in-repo SVG art).
2. **An element check** of `index.html`: every `<script src>`, `<link href>` and
`<img src>` must be a same-origin `/static/...` path (a `data:` URL is allowed where
the CSP allows it — `img-src 'self' data:` — because it is the app's own bytes).
The root it scans is imported from `undercooked.server.app.STATIC_DIR`, i.e. the very
directory the server mounts at `/static`, so the guard cannot pass by scanning some other
folder. The last group of tests plants each forbidden shape in a temporary tree and
asserts the scan names the offending file, which is what keeps this file from rotting
into a test that scans nothing.
"""
from __future__ import annotations
import re
from collections.abc import Iterator
from dataclasses import dataclass
from pathlib import Path
import pytest
from undercooked.config import Settings
from undercooked.server.app import STATIC_DIR, resolve_static_dir
pytestmark = pytest.mark.unit
REPO_ROOT = Path(__file__).resolve().parents[2]
#: Every file type a browser can be told to load or parse.
ASSET_SUFFIXES = frozenset({".html", ".css", ".js", ".svg"})
#: Files whose text may name a hosted library. CSS/SVG are covered by the URL rules below;
#: HTML and JS are where a `<script>`/`import` of a hosted bundle actually shows up.
HOSTED_TOKEN_SUFFIXES = frozenset({".html", ".js"})
#: Hosted-library / hosted-font mirrors that must never appear in shipped client text.
HOSTED_LIBRARY_TOKENS = ("googleapis", "gstatic", "jsdelivr", "unpkg", "cdnjs", "tailwindcss")
#: An absolute or protocol-relative authority in a URL-ish position.
_ABS = r"(?:https?:)?//"
#: XML namespace declarations: identifiers, not requests (SVG art is first-class, A11).
_NAMESPACE_DECL = re.compile(
r"""xmlns(?::[\w.-]+)?\s*=\s*["']?https?://www\.w3\.org/[^\s"'<>]*["']?""",
re.IGNORECASE,
)
#: (rule name, pattern) pairs applied to each masked line of every asset.
BANNED_PATTERNS: tuple[tuple[str, re.Pattern[str]], ...] = (
(
"absolute URL",
re.compile(r"https?://", re.IGNORECASE),
),
(
"protocol-relative URL",
# `//host.tld/...` — the scheme is deliberately NOT part of this pattern, so the
# lookbehind really does keep `https://` from being reported twice, and the
# host-shaped lookahead keeps JS/CSS comments (`// note`) from matching at all.
re.compile(r"(?<![A-Za-z0-9:])//(?![\s/])(?:[\w-]+\.)+[a-z]{2,}", re.IGNORECASE),
),
(
"CSS @import url()",
re.compile(r"@import\s+url\s*\(", re.IGNORECASE),
),
(
"protocol-relative stylesheet",
re.compile(
rf"""<link\b[^>]*?rel=["']?[^"'<>]*stylesheet[^>"']*["']?[^>]*?href=["']?{_ABS}""",
re.IGNORECASE,
),
),
(
"srcset attribute",
re.compile(r"\bsrcset\s*=", re.IGNORECASE),
),
(
"fetch() of an off-origin URL",
re.compile(rf"\bfetch\s*\(\s*[\"'`]{_ABS}", re.IGNORECASE),
),
(
"import of an off-origin URL",
re.compile(rf"\b(?:import|export)\b[^;\n]*?\bfrom\s*[\"'`]{_ABS}", re.IGNORECASE),
),
(
"bare import of an off-origin URL",
re.compile(rf"\bimport\s*[\"'`]{_ABS}", re.IGNORECASE),
),
(
"WebSocket with an absolute URL",
# Same-origin is built from `location`, never written out (`WebSocket("ws…`).
re.compile(r"\bWebSocket\s*\(\s*[\"'`](?:wss?|https?)://", re.IGNORECASE),
),
(
"EventSource (server-sent events)",
re.compile(r"\bnew\s+EventSource\s*\(|\bEventSource\s*\(", re.IGNORECASE),
),
)
#: Same-origin prefixes an element URL may start with.
STATIC_PREFIX = "/static/"
@dataclass(frozen=True, slots=True)
class Violation:
"""One forbidden reference, in one file, on one line."""
path: Path
line: int
rule: str
text: str
def describe(self) -> str:
"""Human-readable failure line naming the offending file."""
return f"{self.path}:{self.line}: {self.rule}: {self.text.strip()}"
def _mask(text: str) -> str:
"""Blank out the text that looks external but cannot cause a request."""
return _NAMESPACE_DECL.sub(lambda match: " " * len(match.group(0)), text)
def iter_asset_files(root: Path) -> Iterator[Path]:
"""Every scannable asset under ``root``, depth-first and in a stable order."""
if not root.is_dir(): # pragma: no cover - guarded by the caller's own assertion
return
for path in sorted(root.rglob("*")):
if path.is_file() and path.suffix.lower() in ASSET_SUFFIXES:
yield path
def scan_text(path: Path, text: str) -> list[Violation]:
"""Every forbidden reference in ``text``, attributed to ``path``."""
violations: list[Violation] = []
for number, raw in enumerate(_mask(text).splitlines(), start=1):
for rule, pattern in BANNED_PATTERNS:
if pattern.search(raw):
violations.append(Violation(path=path, line=number, rule=rule, text=raw))
if path.suffix.lower() in HOSTED_TOKEN_SUFFIXES:
lowered = raw.lower()
violations.extend(
Violation(path=path, line=number, rule=f"hosted library {token!r}", text=raw)
for token in HOSTED_LIBRARY_TOKENS
if token in lowered
)
if path.suffix.lower() == ".html":
violations.extend(scan_element_urls(path, text))
return violations
def scan_file(path: Path) -> list[Violation]:
"""Read one asset and return its forbidden references."""
return scan_text(path, path.read_text(encoding="utf-8"))
def scan_tree(root: Path) -> list[Violation]:
"""Scan every asset under ``root`` (the shipped client, when called with STATIC_DIR)."""
return [violation for path in iter_asset_files(root) for violation in scan_file(path)]
def _element_attributes(tag: str) -> dict[str, str]:
"""Parse `name="value"` pairs of one tag into a lower-cased mapping."""
found: dict[str, str] = {}
for name, value in re.findall(r"""([\w-]+)\s*=\s*["']([^"']*)["']""", tag):
found[name.lower()] = value
return found
def _is_allowed(value: str, *, data_ok: bool) -> bool:
if value.startswith(STATIC_PREFIX):
return True
return data_ok and value.startswith("data:")
def scan_element_urls(path: Path, text: str) -> list[Violation]:
"""Every `<script src>` / `<link href>` / `<img src>` in an HTML document that this
app does not serve.
`<script src>` and stylesheet `<link>`s must be `/static/...`. `<img>` and
non-stylesheet `<link>`s (the `data:` favicon) may additionally be `data:`, matching
the CSP's `img-src 'self' data:` — in-app bytes either way.
"""
violations: list[Violation] = []
for match in re.finditer(r"<(script|link|img)\b((?:[^>'\"]|\"[^\"]*\"|'[^']*')*)>", text, re.I):
name = match.group(1).lower()
attrs = _element_attributes(match.group(2))
if name == "script":
attribute, data_ok = "src", False
elif name == "link":
attribute, data_ok = "href", "stylesheet" not in attrs.get("rel", "").lower()
else:
attribute, data_ok = "src", True
value = attrs.get(attribute)
if value is None or _is_allowed(value, data_ok=data_ok):
continue
line = text.count("\n", 0, match.start()) + 1
rule = f"<{name} {attribute}> is not a same-origin /static/ path"
violations.append(Violation(path=path, line=line, rule=rule, text=match.group(0)))
return violations
def format_violations(violations: list[Violation]) -> str:
"""Render violations for a pytest failure message."""
return "\n".join(violation.describe() for violation in violations)
@pytest.fixture
def static_tree(tmp_path: Path) -> Path:
"""A throwaway static root, shaped like the real one, for planted violations."""
root = tmp_path / "static"
(root / "js").mkdir(parents=True)
(root / "css").mkdir(parents=True)
(root / "index.html").write_text("<!doctype html><title>t</title>", encoding="utf-8")
return root
# --------------------------------------------------------------------------- the scanned root
def test_the_guard_scans_the_directory_the_server_actually_serves() -> None:
"""Re-deriving the root here is what stops a green run against the wrong folder."""
assert STATIC_DIR == REPO_ROOT / "app" / "static"
assert STATIC_DIR.is_dir(), f"{STATIC_DIR} is not a directory"
assert (STATIC_DIR / "index.html").is_file()
assert resolve_static_dir(Settings()) == STATIC_DIR
def test_the_scan_walks_the_shipped_client_files() -> None:
names = {path.relative_to(STATIC_DIR).as_posix() for path in iter_asset_files(STATIC_DIR)}
assert {"index.html", "js/app.js", "css/base.css"} <= names, names
# --------------------------------------------------------------------------- the shipped client
def test_shipped_client_references_nothing_external() -> None:
"""A12: zero forbidden references anywhere under app/static/."""
violations = scan_tree(STATIC_DIR)
report = format_violations(violations)
assert violations == [], f"the client loads bytes this app does not serve:\n{report}"
def test_shipped_index_html_loads_only_same_origin_assets() -> None:
"""Non-vacuous on purpose: the index must actually declare a script and a stylesheet."""
html = (STATIC_DIR / "index.html").read_text(encoding="utf-8")
tags = re.findall(r"<(script|link|img)\b", html, re.I)
assert len(tags) >= 3, "index.html no longer declares the assets this test guards"
assert 'src="/static/js/app.js"' in html
assert scan_element_urls(STATIC_DIR / "index.html", html) == []
# --------------------------------------------------------------------------- planted violations
@pytest.mark.parametrize(
("name", "content"),
[
("script_src", '<!doctype html><script src="https://cdn.example.com/x.js"></script>'),
(
"module_src",
'<!doctype html><script type="module" src="//cdn.example.com/x.js"></script>',
),
("stylesheet_link", '<link rel="stylesheet" href="https://fonts.example.com/f.css">'),
("protocol_relative_stylesheet", '<link rel="stylesheet" href="//cdn.example.com/a.css">'),
("img_src", '<img src="https://evil.example.com/pixel.png">'),
("img_srcset", '<img src="/static/a.svg" srcset="https://cdn.example.com/b.svg 2x">'),
],
)
def test_a_planted_html_reference_fails_the_scan(
static_tree: Path, name: str, content: str
) -> None:
page = static_tree / f"{name}.html"
page.write_text(content, encoding="utf-8")
violations = scan_tree(static_tree)
assert violations, content
assert all(violation.path == page for violation in violations), format_violations(violations)
assert page.name in format_violations(violations)
@pytest.mark.parametrize(
("suffix", "content", "rule"),
[
(
".js",
'import { x } from "https://cdn.example.com/lib.js";',
"import of an off-origin URL",
),
(
".js",
'import "https://cdn.example.com/side-effect.js";',
"bare import of an off-origin URL",
),
(".js", 'export * from "//cdn.example.com/lib.js";', "import of an off-origin URL"),
(".js", 'fetch("https://api.example.com/state");', "fetch() of an off-origin URL"),
(".js", 'fetch("//api.example.com/state");', "fetch() of an off-origin URL"),
(
".js",
'new WebSocket("wss://remote.example.com/ws/ABCDE");',
"WebSocket with an absolute URL",
),
(".js", 'new EventSource("/events");', "EventSource (server-sent events)"),
(".js", 'const u = "https://cdn.example.com/x.js";', "absolute URL"),
(".css", "@import url(https://fonts.example.com/one.css);", "CSS @import url()"),
(".css", "@import url(//fonts.example.com/one.css);", "CSS @import url()"),
(".css", "body { background: url(//cdn.example.com/bg.png); }", "protocol-relative URL"),
(
".svg",
'<svg xmlns="http://www.w3.org/2000/svg">'
'<image href="https://x.example.com/a.png"/></svg>',
"absolute URL",
),
(".html", '<a href="https://external.example.com">out</a>', "absolute URL"),
],
)
def test_a_planted_asset_reference_fails_the_scan(
static_tree: Path, suffix: str, content: str, rule: str
) -> None:
planted = static_tree / "js" / f"planted{suffix}"
planted.write_text(content, encoding="utf-8")
violations = scan_tree(static_tree)
assert violations, content
assert rule in {violation.rule for violation in violations}, format_violations(violations)
assert any(violation.path == planted for violation in violations)
assert planted.name in format_violations(violations)
@pytest.mark.parametrize("token", HOSTED_LIBRARY_TOKENS)
@pytest.mark.parametrize("suffix", [".js", ".html"])
def test_a_planted_hosted_library_token_fails_the_scan(
static_tree: Path, token: str, suffix: str
) -> None:
planted = static_tree / f"planted{suffix}"
planted.write_text(f"/* vendored from {token} once */", encoding="utf-8")
violations = [v for v in scan_tree(static_tree) if v.path == planted]
assert violations, token
assert token in format_violations(violations)
# --------------------------------------------------------------------------- what must stay green
@pytest.mark.parametrize(
("suffix", "content"),
[
(
".svg",
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">'
'<rect width="16" height="16"/></svg>',
),
(
".svg",
'<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">'
'<use xlink:href="#tile"/></svg>',
),
(".js", "// a comment about docs/PROTOCOL.md and a/b/c paths\nexport const ok = 1;\n"),
(".js", 'const ws = new WebSocket(`${location.origin}/ws/${code}`);\n'),
(".js", 'fetch("/static/api/levels");\n'),
(".css", ".a { background: url(/static/assets/tile.svg); }\n"),
(".html", '<img src="data:image/svg+xml,<svg/>">\n'),
],
)
def test_same_origin_and_namespace_references_are_allowed(
static_tree: Path, suffix: str, content: str
) -> None:
"""The guard must not train anyone to delete real code: same-origin stays green."""
(static_tree / "js" / f"fine{suffix}").write_text(content, encoding="utf-8")
assert scan_tree(static_tree) == []
def test_non_asset_files_are_out_of_scope(static_tree: Path) -> None:
"""Notes and images are not client instructions; the scan is about loadable code."""
(static_tree / "NOTES.md").write_text("see https://example.com for details", encoding="utf-8")
(static_tree / "logo.png").write_bytes(b"\x89PNG\r\n")
assert scan_tree(static_tree) == []
def test_an_empty_tree_is_not_a_passing_signal(tmp_path: Path) -> None:
"""Documented explicitly: an empty root scans clean, so the root tests above matter."""
assert scan_tree(tmp_path) == []
assert list(iter_asset_files(tmp_path)) == []
Generated
+978
View File
@@ -0,0 +1,978 @@
version = 1
revision = 3
requires-python = ">=3.12"
[[package]]
name = "annotated-doc"
version = "0.0.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" },
]
[[package]]
name = "annotated-types"
version = "0.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" },
]
[[package]]
name = "anyio"
version = "4.15.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "typing-extensions", marker = "python_full_version < '3.15'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" },
]
[[package]]
name = "certifi"
version = "2026.7.22"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
]
[[package]]
name = "click"
version = "8.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "coverage"
version = "7.16.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/2f/55/d1eaf3e73781174340a00dc1ba2aee8a65f82fadb18e2797b192b6b3925b/coverage-7.16.2.tar.gz", hash = "sha256:ca64d9f1f384f151b9511bec01126072acd2f313439f8ed015a22d8790aab6fa", size = 971999, upload-time = "2026-09-27T12:29:01.118Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5e/2c/f8296c63c5d542f3d21aed685e56b7031a419037d155bb3382fc0940d249/coverage-7.16.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:218d742afca2b5ad5ca759e93eddedfbcc6eadf8322f080dcefc40b7bd4e2d48", size = 223969, upload-time = "2026-09-27T12:26:16.753Z" },
{ url = "https://files.pythonhosted.org/packages/90/23/6f3dcb1423a0d43216e402ea1746e4a7c7c44f38896b97dd573790f56a40/coverage-7.16.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a9a638be322a8d76a41cdb17781c7f82aaee6a66493d8ffb7e2c09ee22423d99", size = 224328, upload-time = "2026-09-27T12:26:18.15Z" },
{ url = "https://files.pythonhosted.org/packages/ac/7d/8f3b6dc920e3fc6732f7678785a2091db439f186afbec30dbf2214d9b1f7/coverage-7.16.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:724bd0f1e81856b35e59fc98cf7b4e544a3cb662e4e0864dca73d4326ee9d808", size = 255832, upload-time = "2026-09-27T12:26:19.799Z" },
{ url = "https://files.pythonhosted.org/packages/d1/36/6c45f15be4eca4ac1062c6a55a323286494c99726a7e58951fe85967ac08/coverage-7.16.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5375ebd99038021b35e99dc88255022912c06565d316212f4a576e4b08d30f5d", size = 258571, upload-time = "2026-09-27T12:26:21.199Z" },
{ url = "https://files.pythonhosted.org/packages/34/fb/b54cbeba3ad89082c2e441278681859e538322cc34b84b2af7ebff00080f/coverage-7.16.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a076277ca9f5750cc230f0f578ebd2620cec60255b25707361699fef6fb465c", size = 259680, upload-time = "2026-09-27T12:26:22.822Z" },
{ url = "https://files.pythonhosted.org/packages/6e/a2/0dc65ec3d61930e1e4c2e371763b15eb4290896eb343a12d5d3091308116/coverage-7.16.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:58d4a54c6ea672afef66d49be922a2c69826c5ae1a42a9cd94f0c9c2bacdf800", size = 261945, upload-time = "2026-09-27T12:26:24.336Z" },
{ url = "https://files.pythonhosted.org/packages/d6/93/5fad7a61f2c14e08e98946fc31c1c7ffc1195061bf3fdc351db3be77a863/coverage-7.16.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0dcbcfcc059117284c603ff8cb61a65872512882f84a8cf0339241f7f7c2f148", size = 256191, upload-time = "2026-09-27T12:26:25.89Z" },
{ url = "https://files.pythonhosted.org/packages/2d/47/74e5de9227b939ece9f64e729645ddc4296bea10dbfa98721c1333c8be2e/coverage-7.16.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:afdf43b72ef3876c1fe66423b91466e37877c9e81e8cec70542b7e8525b9d1b7", size = 257610, upload-time = "2026-09-27T12:26:27.35Z" },
{ url = "https://files.pythonhosted.org/packages/13/fe/2cf28d40b43645d1b72388fe3ee7f7c747533a6a9557bb8c24a7ae74fe1a/coverage-7.16.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:9acc7f7ec4a1b5f89bd929fde5b8a714f6fafdc6cc18725413d510aa082b47ad", size = 255757, upload-time = "2026-09-27T12:26:28.949Z" },
{ url = "https://files.pythonhosted.org/packages/d7/3d/7c149fd99fc8bbc39c80db5e688d1d39fd040be2ecb78b8335a51a55b9c0/coverage-7.16.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:80d3f7b48d43ee8fc5e8707a8adb43d743a5a1a85256c25a24f9d6d0e2238fa6", size = 259822, upload-time = "2026-09-27T12:26:30.515Z" },
{ url = "https://files.pythonhosted.org/packages/e6/3f/b283fce09d5995e227bd8e513358dd7471bedc0f78abc85a925ebdb0a2f6/coverage-7.16.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:126d1af8804d7224421fe991ff65d3ce649081560df7a98b1a5ffff07f9923bd", size = 255325, upload-time = "2026-09-27T12:26:32.037Z" },
{ url = "https://files.pythonhosted.org/packages/bf/91/f3325edf0c4223fb1fe1532b8dbef2a1d2f729459a9a7d1a44d073bae534/coverage-7.16.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c19cd6d025c1673f22afcd22c7df8a662d779e05d8e3fa6820c22afb895b0206", size = 257191, upload-time = "2026-09-27T12:26:33.525Z" },
{ url = "https://files.pythonhosted.org/packages/c4/89/21eb5e83ecf2eed523c4eb3d65ae513cd082c8fd1b6deb34c4cb6c332f97/coverage-7.16.2-cp312-cp312-win32.whl", hash = "sha256:152877cdc8a07264882cfcd503ba56a3ef6cba56a70e8c70f6eb8ffd7384789a", size = 226034, upload-time = "2026-09-27T12:26:35.021Z" },
{ url = "https://files.pythonhosted.org/packages/db/de/e3ad6d864c0833624b4f1f9b53f9e58e116c945e5e965c3f1e172c5e84cd/coverage-7.16.2-cp312-cp312-win_amd64.whl", hash = "sha256:e6c52d3307824ff93b39efd99e4185d557db40bd841452abfb32e5d9151ca162", size = 226569, upload-time = "2026-09-27T12:26:36.604Z" },
{ url = "https://files.pythonhosted.org/packages/3e/c1/bccc58ebe5489cc70628f635c1932fd371f5d7da850dbcf960f95f4c4afc/coverage-7.16.2-cp312-cp312-win_arm64.whl", hash = "sha256:a678c0b6b22086ec2427359d22e37445d4a792f5fdbbc744112c7dade65cad02", size = 226372, upload-time = "2026-09-27T12:26:38.406Z" },
{ url = "https://files.pythonhosted.org/packages/f0/f6/8eb4f220ef24f84fb27d852d4f9bf83e0c73ec1a4a08dd9a87e3f4529739/coverage-7.16.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1a37c6e478cf687e1aa30a593d19c92c02fad9d122b51ab73f51b8dc7a0c0fc9", size = 223993, upload-time = "2026-09-27T12:26:40.164Z" },
{ url = "https://files.pythonhosted.org/packages/40/23/d4bbaf0c154e0b0c2b5264890dbf6ef098dcb50ec8f2469be9490d191660/coverage-7.16.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0993d0e90858c03943d3cb152e068a20dd4707924deec84dd2230261baae3b1b", size = 224368, upload-time = "2026-09-27T12:26:41.762Z" },
{ url = "https://files.pythonhosted.org/packages/7f/48/fc1e88fd571ec5cb38150b7f89f7696ca1bdf9920e01432febb69774cc85/coverage-7.16.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bb2fc905bbf4e6b7f40806ea79e31515abf6349594cdf0adf27c4215f0463204", size = 255358, upload-time = "2026-09-27T12:26:43.442Z" },
{ url = "https://files.pythonhosted.org/packages/1d/56/6785397d07c29c8e70fbb9a07e97d062b43c21ffc5f12385917847f09f63/coverage-7.16.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4358b9c8c0125b460407f3017c6cce8156e904b32772c5630d27112f52bdbfe5", size = 257955, upload-time = "2026-09-27T12:26:45.725Z" },
{ url = "https://files.pythonhosted.org/packages/27/3b/c8cdd07721e5f99abd81cea970d971997f99bf158c0b85f51bd284179c8b/coverage-7.16.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f15254427c9b33eedac4f198eaf9e356eb4f6214551afb43da6194a2c088ad7", size = 259183, upload-time = "2026-09-27T12:26:47.208Z" },
{ url = "https://files.pythonhosted.org/packages/9b/11/606b192fe43d32574ec6238549d48de588fdcc18485682a5ec0a8ac357f2/coverage-7.16.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9a75a4704ff640e46170042eec1f984385a121227c505d5a16ad8e495f452541", size = 261325, upload-time = "2026-09-27T12:26:49.084Z" },
{ url = "https://files.pythonhosted.org/packages/67/90/eea481f8b0305ceeb33f081a5f47e298391dbd1b589de0c4b3b3aa50d3f2/coverage-7.16.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:14253fc7bb15749b849795a06f5d3b6d8bc3fb8a4b5ddc341faf7a89dce205fc", size = 255531, upload-time = "2026-09-27T12:26:50.509Z" },
{ url = "https://files.pythonhosted.org/packages/6b/be/dedbf9aea1457b120c27ac10b8fc2a357f37fa2b54c3e7286d42980a0a2a/coverage-7.16.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:921415102a90637fcc2e3f169f61dad7699ecf690e8639fc21b813acbedc0967", size = 257329, upload-time = "2026-09-27T12:26:52.005Z" },
{ url = "https://files.pythonhosted.org/packages/fa/cb/b25c19d5bb2bd0f2e4e27fe8e2ffcae80c7a91ae181c0dc749ed60e9b1a4/coverage-7.16.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cce2bc991293f15cc4084ca116827b5900c5f34e1a54dfe83f10ab5c43162eb7", size = 255294, upload-time = "2026-09-27T12:26:53.634Z" },
{ url = "https://files.pythonhosted.org/packages/5f/a2/892c5c5f4ad44b7b2ca009aee705191f3f268f15052244f2f9e3539b2e35/coverage-7.16.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:e1fa594c887365b69745f25a416806e61085dd07b94c9eae68a6e20730629b23", size = 259444, upload-time = "2026-09-27T12:26:55.243Z" },
{ url = "https://files.pythonhosted.org/packages/ed/99/a562537deba0a3e370182ae71c149be796c39d8087365f17a09188f27145/coverage-7.16.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:11e597173af1dc33d5f8a7332ada544199269a223af1ee1770ddd5e245ad0fe8", size = 255111, upload-time = "2026-09-27T12:26:56.851Z" },
{ url = "https://files.pythonhosted.org/packages/2d/20/854ec68641a9b3362ff068a32dfa41637299761617ef253791dbade6fc76/coverage-7.16.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3e7f99698ba3a7d13988bdd984b7ebf13af4dbe2166dc8502eef90d77603b0a4", size = 256882, upload-time = "2026-09-27T12:26:58.41Z" },
{ url = "https://files.pythonhosted.org/packages/db/0d/748e4518b0ac0f9ff2687c248a6e5f8c0737306e709372632a2556f84443/coverage-7.16.2-cp313-cp313-win32.whl", hash = "sha256:f80bd9f9633eafc73d0a913ba2645c96ba58bba1befc30590f7c0fbfde59d865", size = 226045, upload-time = "2026-09-27T12:26:59.983Z" },
{ url = "https://files.pythonhosted.org/packages/31/fa/6e46edba66a183fe4d99d4bb52c173287e9b8dddabe0888d24cb8210e580/coverage-7.16.2-cp313-cp313-win_amd64.whl", hash = "sha256:8be099e979fc42559328a21828281b4578304191ae46ed4e80a407048a82eee6", size = 226581, upload-time = "2026-09-27T12:27:01.494Z" },
{ url = "https://files.pythonhosted.org/packages/1b/d9/9ef6845367600b336ff75d000444a0d32497d6972c833141bd39356abf68/coverage-7.16.2-cp313-cp313-win_arm64.whl", hash = "sha256:28ff850182a67d117990fa2ce5ea1032836d8c9630dae867e8bdd3bff4533b79", size = 226409, upload-time = "2026-09-27T12:27:03.116Z" },
{ url = "https://files.pythonhosted.org/packages/59/4c/577fc0803dab4155dcf808faffbdd7b159256781c0874a8586e17b81b149/coverage-7.16.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4ee546b9e4872ffa194bf07ac87bfa1202ebb824d0795dc1ef22f175545ca90a", size = 224138, upload-time = "2026-09-27T12:27:05.141Z" },
{ url = "https://files.pythonhosted.org/packages/75/9e/e3785ba3ecba2bd11efc74bfe2801ca4b78c4480b15a375648d809a59da3/coverage-7.16.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a2fac6895eb299a2e52d7bbb8fb3903502b9da8d3f5309ceb16ec40c646b58ee", size = 224445, upload-time = "2026-09-27T12:27:06.805Z" },
{ url = "https://files.pythonhosted.org/packages/f0/d0/963ff22d3fd27117da3b8cc442f5bdc91196f783321e1a8ff0ec43476772/coverage-7.16.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:57ff3783f99d75a1e81dd56a9737eb5665e6736a5d93258ba596b6dcad8fd05b", size = 256112, upload-time = "2026-09-27T12:27:08.43Z" },
{ url = "https://files.pythonhosted.org/packages/a8/d4/a306940c81c6ae759e82fff27d20b7fdc6896e422b821f51313cce212b6c/coverage-7.16.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:35f37886699cb9abd29958247d718628d5bc6f39e623dff66a09e546c42a7e03", size = 258727, upload-time = "2026-09-27T12:27:09.927Z" },
{ url = "https://files.pythonhosted.org/packages/b9/a3/d3d99d93b02517087aa05bc0cf2d04d372956b849e5443e059079901429b/coverage-7.16.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0fd7a86fdda7cb6d616d178654bd0ad6bc0f3f33c2e478aa598500a1a9e34eda", size = 259909, upload-time = "2026-09-27T12:27:11.55Z" },
{ url = "https://files.pythonhosted.org/packages/08/44/39dd599181726758dd185ae4dc0c0ab3aeabf7ca70e68e145060feeaaa16/coverage-7.16.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ac0f3b379c94acc2f7dce5f5f0b24d44fa1cc6a509717ef83dfee07450c2117c", size = 262481, upload-time = "2026-09-27T12:27:13.17Z" },
{ url = "https://files.pythonhosted.org/packages/99/e8/91ee43f6ded411460c359d7e1aebde4d6fd8f00a2e5394182d9d212eb23c/coverage-7.16.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7d0732c83746bc24123c581a85d9dd96b70ddb538c9076020aa1a041790361e9", size = 256012, upload-time = "2026-09-27T12:27:14.91Z" },
{ url = "https://files.pythonhosted.org/packages/11/8c/e9499ddc33197bd7eabcb1118ca81756fc874457b324e2b479a4804b2ad2/coverage-7.16.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b451c68218c150f616bc9649783ec8de76a59792c759b43aa0c9c0466a465e4", size = 257898, upload-time = "2026-09-27T12:27:16.588Z" },
{ url = "https://files.pythonhosted.org/packages/5f/6e/c081cb5991a0afba99f9c4ad6c74a5fce9513a38ddc64e3e6680c6fed9af/coverage-7.16.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a56ac4fa5a75c7e182e8f62600cfb4aff43c5ed7356a034f3557659c3bec1d90", size = 255938, upload-time = "2026-09-27T12:27:18.19Z" },
{ url = "https://files.pythonhosted.org/packages/b2/42/1c3d819e8f9b6eb01c2fe90874d67a8882adb9507e0bbb09361ed131ea89/coverage-7.16.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4cc4f73aa3fabc36e32046d6cd2971405948d8a903636508a3d3b2f9128b3a95", size = 260368, upload-time = "2026-09-27T12:27:19.903Z" },
{ url = "https://files.pythonhosted.org/packages/19/4f/d70eac07901fd587b6ab05e659b52afe13959992aa5113bf6cce059cc572/coverage-7.16.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:723dcdab91357159b722935b500ee8abc0a66c8c432e1e9fabf4cc7598952de8", size = 255620, upload-time = "2026-09-27T12:27:21.621Z" },
{ url = "https://files.pythonhosted.org/packages/34/5e/6d87af88317d3d9a9b18a9ca1bc1673eb516917f296e579d0d4a55cb3490/coverage-7.16.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5397e21a90dde0e9c6896b77ded8f0be26b66f8b22b33aed41f6043ed95d55e6", size = 257521, upload-time = "2026-09-27T12:27:23.358Z" },
{ url = "https://files.pythonhosted.org/packages/79/bb/90c2641170d2fa1a6757b3f8450ba2740197317b0ddd749e9604b914e886/coverage-7.16.2-cp314-cp314-win32.whl", hash = "sha256:848893e1d361448c113dc2f0913503522a6f7be231d0e38333d2a22d9698a011", size = 226248, upload-time = "2026-09-27T12:27:25.153Z" },
{ url = "https://files.pythonhosted.org/packages/30/08/d8d0478bb02c8eb0ae20a496fc80c40fcf4d3450bd184300d682ba2d28a6/coverage-7.16.2-cp314-cp314-win_amd64.whl", hash = "sha256:5a27b731c171e43dc8b5f32b76a5051dde2ec9b9366c87028f08a7088ebc2c7b", size = 226732, upload-time = "2026-09-27T12:27:26.907Z" },
{ url = "https://files.pythonhosted.org/packages/32/3f/0001da22155b0a8ce063ec0f7e64ecbe17b373f306e7a74435f6d6accb72/coverage-7.16.2-cp314-cp314-win_arm64.whl", hash = "sha256:1c569a9fd25505f1cd6bea90588818f90373ce90e2632e2cacf19ddbd6e14fdb", size = 226645, upload-time = "2026-09-27T12:27:28.588Z" },
{ url = "https://files.pythonhosted.org/packages/d7/85/6d8813aff9b8b8586691a9d33c43c5604f7227622574da7cdc3d91a86861/coverage-7.16.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d93db87adb6b1c1b408dce4763314b55d76a9f589e96783a84ac9e7689e48bdf", size = 224871, upload-time = "2026-09-27T12:27:30.32Z" },
{ url = "https://files.pythonhosted.org/packages/5c/70/444f3a4981ac2cda40fdcf4cc9b56a4e1a33c222abeb33e51ed3e3eb2a6b/coverage-7.16.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:aa62c85046473959c13ba9edca9dc90a77d5c1095b1ba313556314d77fe5b036", size = 225123, upload-time = "2026-09-27T12:27:32.33Z" },
{ url = "https://files.pythonhosted.org/packages/d0/c1/980681cd7b33eb66ac835044116ef0a92e11fcc7bdd866cc89d10b1130b9/coverage-7.16.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:db76506aa5416081f3e8974ae0f7965c58ada0bb0ef7339ac86099588dbb20d3", size = 265049, upload-time = "2026-09-27T12:27:34.085Z" },
{ url = "https://files.pythonhosted.org/packages/b2/e3/87679875c33bb2191f0f05544a1cc9adcc940fe0c35443a10f2df753dde5/coverage-7.16.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a0f2285329dac10ab08f79cb11f5692c497018e6c7c511f95e6fd63a70b8f831", size = 267717, upload-time = "2026-09-27T12:27:36.025Z" },
{ url = "https://files.pythonhosted.org/packages/76/64/5d372776d6eb523d4e93bafba2253f96984e3b18261c4cc56a50863c6d0d/coverage-7.16.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:382d3346d56b0eec1b793d53a4c88799c8053f516aa3a8d7c44315696954bacf", size = 270047, upload-time = "2026-09-27T12:27:37.96Z" },
{ url = "https://files.pythonhosted.org/packages/be/c1/44082ff0cbf9f97d0043f57970a71204097ec7ba606361a9fd2065393669/coverage-7.16.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:648352b94507179d82637292e7ae8802508d95f78e2f00a705a50b6c48011681", size = 271360, upload-time = "2026-09-27T12:27:39.766Z" },
{ url = "https://files.pythonhosted.org/packages/b8/17/9a215efe25b5e0ecc87c89dbe525c4a87d14d87c8c0c7316ef140a5f6f3e/coverage-7.16.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fb2bde05838fffae1a1bf75e5d411a6cac3e4e9bb97e6640fed8cd47888b33f0", size = 264736, upload-time = "2026-09-27T12:27:42.072Z" },
{ url = "https://files.pythonhosted.org/packages/a2/da/7f0a31af8e448107d4d32844bd684757f51ea907bc0c68c8fd537b2123ff/coverage-7.16.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6a75180829efb8ae62b4aded25be6ddca1c888d138d2d82e21d93bfbd88f41cb", size = 267600, upload-time = "2026-09-27T12:27:43.85Z" },
{ url = "https://files.pythonhosted.org/packages/dd/a4/3bfecbd3366b775bacdcb3330394d356cf384b5d8f5b2146ac4b14b252b5/coverage-7.16.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:99704f73721e23859112072d522076e11c31744fc96b5652e5dd2018aa4359f7", size = 264767, upload-time = "2026-09-27T12:27:45.768Z" },
{ url = "https://files.pythonhosted.org/packages/b8/3f/5d62163732d87e4a0c4710a0eab30f0fd6a2d480112abe2029f014fe8c9d/coverage-7.16.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:29309ccc86b7f33df7db12813c299f215bbbc470ed6292d0bedd63ffae1ebf64", size = 269026, upload-time = "2026-09-27T12:27:47.787Z" },
{ url = "https://files.pythonhosted.org/packages/49/4d/8e4579f225426535085a9be371cc75e3b026d058d679b80affbdfb4c3ef0/coverage-7.16.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:30c1b65d529e46569899fadca59e4a87c1faf2886923f1307ba61e654d4f3c20", size = 264147, upload-time = "2026-09-27T12:27:49.681Z" },
{ url = "https://files.pythonhosted.org/packages/d1/36/ef1f77e2c3f7bb03c2b13b9a2006f88700fdd75535ef158d70049f425c1c/coverage-7.16.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dcf4bc2aab4e16b1c4c0c2005918f23a7dd5d7821ddae82caed9e3342dc2fcce", size = 266376, upload-time = "2026-09-27T12:27:51.551Z" },
{ url = "https://files.pythonhosted.org/packages/be/79/0cb2bf4428830dec971c718c2c841a039c084415c99e67281f5a72841aab/coverage-7.16.2-cp314-cp314t-win32.whl", hash = "sha256:a9cd3de0a5bfe7b0e21ee10e1a14e3d61bf52efc88217ab1d95d6ace6970bd46", size = 226585, upload-time = "2026-09-27T12:27:53.945Z" },
{ url = "https://files.pythonhosted.org/packages/3c/f9/da17121c16667fd84998e972200ae226a41540f6ea4795776c6d99e8976f/coverage-7.16.2-cp314-cp314t-win_amd64.whl", hash = "sha256:611a44e5229a59d7483ce830160e1a0e85f700562c7a5651c7c63fb8f4eb528c", size = 227378, upload-time = "2026-09-27T12:27:55.778Z" },
{ url = "https://files.pythonhosted.org/packages/74/89/01179c62d1b7e6e33bd5001566b02d7f778cf33d3ec1e81e94ca170c517f/coverage-7.16.2-cp314-cp314t-win_arm64.whl", hash = "sha256:22957cef43ce038641de78ba995de7568d2d6a37c6ddbf7fa0fd7d1ae2344d91", size = 227064, upload-time = "2026-09-27T12:27:57.496Z" },
{ url = "https://files.pythonhosted.org/packages/4c/57/52935003c3f627ba6e5203d7179aad32448c10899663a30336aba8e81a2c/coverage-7.16.2-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:414c26dfdb96aac2d570a54e03008f001e32eb2d413705365503648c6bd361d8", size = 224140, upload-time = "2026-09-27T12:27:59.343Z" },
{ url = "https://files.pythonhosted.org/packages/31/38/df472520f3e626524d7e2fc9d6da0afe7895a2f1489d36b48af8ca40bb41/coverage-7.16.2-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:00d3eb96e9988c45f50cccd1f1496571ac5c1f91386ac02c4d55516eeda19a24", size = 224449, upload-time = "2026-09-27T12:28:01.299Z" },
{ url = "https://files.pythonhosted.org/packages/0c/aa/3be084d5b82e63ccdad4ed751e4acbae294673573e30481d29f8b7402eec/coverage-7.16.2-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4dbbd1155ca46e6e0b6b89d204428c56ef6a459af21333f365d135a2820e5a09", size = 256060, upload-time = "2026-09-27T12:28:03.185Z" },
{ url = "https://files.pythonhosted.org/packages/de/29/48fca82a7ebf7ff7b2e35019cc9537e7f65e4d2aa1215cc5a8792c989251/coverage-7.16.2-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8fc15cc8d0d06e873c00ef18e1372d605f9aaf3de27d8c24e50782e75bc8b843", size = 259106, upload-time = "2026-09-27T12:28:05.15Z" },
{ url = "https://files.pythonhosted.org/packages/06/3d/b2d5986f2dd53fe201aa1be2e4ab204fa1aed5101e67c0dbbb419b850aee/coverage-7.16.2-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c6afdd69218202bc1758c9a14b86b8cf1084f37ed2ca143e567a103772b16d1", size = 260674, upload-time = "2026-09-27T12:28:06.868Z" },
{ url = "https://files.pythonhosted.org/packages/ce/7e/b50160be3506ead12e6480d14279af7f0f17627694300a2d1fd2c42d2ff5/coverage-7.16.2-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:aba5c63b7afdc749cc9eae943d5b868cba2b261a176378fa1c5a30bc8bc89982", size = 263139, upload-time = "2026-09-27T12:28:08.771Z" },
{ url = "https://files.pythonhosted.org/packages/14/5e/7c805ac9a32606de1399bd7e9bd375aa2f973dc61b12680d9e6403c2e891/coverage-7.16.2-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9174f0af24e5eff248b9dbfe76ec5275a3d19d37edbc2810543f12cf97347a34", size = 256591, upload-time = "2026-09-27T12:28:10.842Z" },
{ url = "https://files.pythonhosted.org/packages/ab/9e/76f1ed129a2daf658a3ea17122824cf2e3b91fea0460d8d3664fc5a61018/coverage-7.16.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:80e9fdb4c3d926b6ba721d4bf7435bdb869c3527ae7803290361d0ab73db13b6", size = 258708, upload-time = "2026-09-27T12:28:12.962Z" },
{ url = "https://files.pythonhosted.org/packages/5a/b7/8d62e75f48b527619239a65294f842d4b7fd02a0839d43ae1de80184e2df/coverage-7.16.2-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:7b3bce4a0d05401d70b7d0d5ca783e686bc9d30e81dbd7d980d532609bf809e4", size = 256562, upload-time = "2026-09-27T12:28:14.934Z" },
{ url = "https://files.pythonhosted.org/packages/b8/8d/0a15f95c3afb78e947c52644786ba4bc9de259905687dd720d5e6fae2e76/coverage-7.16.2-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:44f21e407b278efdfc1ee5e481e00518bd1d500310a30a5fbf2bcbedfef4aaf0", size = 261077, upload-time = "2026-09-27T12:28:17.215Z" },
{ url = "https://files.pythonhosted.org/packages/25/00/88389987305a47d732866c07c8a500000ab574df9505e3114ac69c8d027f/coverage-7.16.2-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:59c3926585e1cd1f2190f4b2ac9014de1bbeaf0d5d0587b0dc6b0aa90d17896a", size = 256034, upload-time = "2026-09-27T12:28:19.08Z" },
{ url = "https://files.pythonhosted.org/packages/92/02/34d079d4952ad461bde037d353f9a6e037a7edc45fe0f9ee8781ff73f028/coverage-7.16.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:066429634299e14dd2d511e1e85f8f9cecc500781f6b41907c0dd6f1baea7e63", size = 258034, upload-time = "2026-09-27T12:28:21.242Z" },
{ url = "https://files.pythonhosted.org/packages/f6/d8/3e59a62879285b464ec1b10fd824fbc1af9ce66e842cd39974f80a0becc4/coverage-7.16.2-cp315-cp315-win32.whl", hash = "sha256:893ea9cf86cb8d2546812ac93d973aaf2ee1fb45110a873b014214fd23e3725e", size = 226252, upload-time = "2026-09-27T12:28:23.102Z" },
{ url = "https://files.pythonhosted.org/packages/f4/e1/128026e1b2836e9ad6b219207ba9edf1c5e0088a7869e23088aee7fbbe7a/coverage-7.16.2-cp315-cp315-win_amd64.whl", hash = "sha256:01c6908bc613b420c26c818fe948e1b97dfd041a53c98b01c63bd8321f5c9aae", size = 226728, upload-time = "2026-09-27T12:28:25.21Z" },
{ url = "https://files.pythonhosted.org/packages/a8/f4/c9fa8e7cf525ca7748ac52b0ee89331d13fe09808e45c679830708782e90/coverage-7.16.2-cp315-cp315-win_arm64.whl", hash = "sha256:967d72c835d7a8cf0af99ec813a2d06e3db6df706402f1fe85b31b437645f495", size = 226648, upload-time = "2026-09-27T12:28:27.136Z" },
{ url = "https://files.pythonhosted.org/packages/a2/13/e96b045447a856666f36f9c653e2a80bdaa732aaaf72412b19aa2c26a473/coverage-7.16.2-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:98d9c97f51b334b0adce7b964442a9af33c1a00c6ac856984cc5dc8d18f81c75", size = 224858, upload-time = "2026-09-27T12:28:29.169Z" },
{ url = "https://files.pythonhosted.org/packages/23/90/087f6ad1bd3df059632ca3407a4e6552ed1053ee35354de0a771acf35423/coverage-7.16.2-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:3e861f1071dcc2fec1e88bef0920f6b1eaa66a143555b4f8ab79ba2b0f30ef55", size = 225139, upload-time = "2026-09-27T12:28:31.131Z" },
{ url = "https://files.pythonhosted.org/packages/7e/8e/285dcef0184358044e7cbcd810a1bdc9566bc620f54702d605477155df4a/coverage-7.16.2-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fb9d92ecfe2d5b494367c67f7446f8b75b68d8d0c8cf3bc3e6997478be25d9e2", size = 265090, upload-time = "2026-09-27T12:28:33.04Z" },
{ url = "https://files.pythonhosted.org/packages/06/b2/cc83f3a6e5789a4e89059c69555bc641c2efcde568405a1c06fc702951ab/coverage-7.16.2-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb57acff4a74246ae513c142d4b36e18c389c3aed8661914a53f7cd0071031b2", size = 268254, upload-time = "2026-09-27T12:28:35.135Z" },
{ url = "https://files.pythonhosted.org/packages/ac/41/f548c19530f5d66ac6e3c92bbcbc49da7261de3a458b9f3e54a3efb1a0b2/coverage-7.16.2-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:444889f7f66b74e4455c0a97e0e166dd41177f1dca8c0239a47cff25e05ba7e1", size = 270695, upload-time = "2026-09-27T12:28:36.959Z" },
{ url = "https://files.pythonhosted.org/packages/94/61/4dc27cf82ef96434d2874110ad0cc10ea4621025705dc5049862bd3bd181/coverage-7.16.2-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a740ea6f083c6db7b926534d159508f80ba275ab35e722522de0d18d0f56e55f", size = 271853, upload-time = "2026-09-27T12:28:38.821Z" },
{ url = "https://files.pythonhosted.org/packages/38/29/bf8072b1b8bd5f2de8b21460a404460b1a2b97e80a9464c78ec0271f6199/coverage-7.16.2-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8e209591f7c41ae4a9171335cf6156afda0b21de73b02f73f5aa95b2d5fbb08d", size = 265644, upload-time = "2026-09-27T12:28:40.815Z" },
{ url = "https://files.pythonhosted.org/packages/7c/2f/0aecb8721be5cdeb8afd9d6d9f6b463f074e4d8d37f00f4c42442522709f/coverage-7.16.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:396bb16e04ce04efbb3df91456ae4e3da918e69ecdf67fb711b0a0fdf35ccce0", size = 268530, upload-time = "2026-09-27T12:28:42.725Z" },
{ url = "https://files.pythonhosted.org/packages/ab/0b/92b4b7628268ee711249958e68fc0328779bd3d9a7ab4715379465aedb84/coverage-7.16.2-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:9cdf19874e0d247f32f03609200370343c3c7aa260b191d8c2bb251d36198283", size = 265154, upload-time = "2026-09-27T12:28:44.684Z" },
{ url = "https://files.pythonhosted.org/packages/7b/d9/41c95c1ab29b3dcd357cd1227181d1c98185632aca41ce670ce671b23a43/coverage-7.16.2-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:fd3d72233eb8b48acc94fa57d44e2d32ce8e7abed02882ccb6d855ccc4ed33ec", size = 269799, upload-time = "2026-09-27T12:28:46.672Z" },
{ url = "https://files.pythonhosted.org/packages/80/07/ebeb259aa5362b033a137b86d7274ff4b109d59be8cc9913889b783bf75a/coverage-7.16.2-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:bb4ffe96aa663cee727659db5a2afeb38c95f8677b747d447b90d6d4874ea2c5", size = 265196, upload-time = "2026-09-27T12:28:48.996Z" },
{ url = "https://files.pythonhosted.org/packages/b2/18/8437620f90d023680a072eee02f968055f3658bbfb7d386d0ea34cfb7f30/coverage-7.16.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:dba2edfb054f6d4a08df9d1637c39a5aa3865bca6617c13c86be21e45658a59c", size = 267266, upload-time = "2026-09-27T12:28:51.361Z" },
{ url = "https://files.pythonhosted.org/packages/28/6c/f08e8ee4293e6434035424180bef4d45e028e8ecc006c61bf9453e74405e/coverage-7.16.2-cp315-cp315t-win32.whl", hash = "sha256:251aed777c47c77aba047096d4542889db089227655711dfc2b9c54ef0e15e35", size = 226583, upload-time = "2026-09-27T12:28:53.33Z" },
{ url = "https://files.pythonhosted.org/packages/f7/fd/3f939c2847f4a72c20cff8b1ac33da78ea91a2d38d9b43336e60db719103/coverage-7.16.2-cp315-cp315t-win_amd64.whl", hash = "sha256:2aca0bdfa9e91621d5b09d815357bf63def4fc0e9cb66da67bf2cf93f3b1a6f5", size = 227374, upload-time = "2026-09-27T12:28:55.158Z" },
{ url = "https://files.pythonhosted.org/packages/5a/35/b98cdc354c952402132e675a87f2cc3227fb68f959c84aaa491fbe15933d/coverage-7.16.2-cp315-cp315t-win_arm64.whl", hash = "sha256:b88841e654f09732804809e435b3e005a929ffd9998b872b7b213957b8759cb8", size = 227064, upload-time = "2026-09-27T12:28:57.075Z" },
{ url = "https://files.pythonhosted.org/packages/3f/0c/7a64e1ac90541a8edf50daef0914848011fb057a5bf55284a4811e21939a/coverage-7.16.2-py3-none-any.whl", hash = "sha256:11d28e9123a9156cb405d8d27b44256c9a58fb5decc2073a8f17862057e3aa0f", size = 215754, upload-time = "2026-09-27T12:28:59.075Z" },
]
[[package]]
name = "debugpy"
version = "1.8.22"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/44/9d/3cb6693342acf96802dba89934a5b8da43c201d764ebd1f2c0514a3d42bd/debugpy-1.8.22.tar.gz", hash = "sha256:e489c7268e1c7b41e13b438d9c533d2a7af73fb59bf8cd30fead8286c1c39c4e", size = 1710877, upload-time = "2026-09-15T21:44:02.578Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/73/21/5c7f5c66eaeee57c9dadb0bbff22efddc8d99cdbd3d036c806175f1828cc/debugpy-1.8.22-cp312-cp312-macosx_15_0_universal2.whl", hash = "sha256:4ad076f4f66cb8acb79e4384d48b1ea60a0b1957aa0b1112d4887ea3a4df60e0", size = 2499375, upload-time = "2026-09-15T21:44:18.478Z" },
{ url = "https://files.pythonhosted.org/packages/7e/ec/7389798843643ea5bc6a08480ba1b855377c70e2ed90189c0a03594e401e/debugpy-1.8.22-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:8a697acec45dbc70d17fb5d9f4f61989fc294d273c69de487fd10cb35fdd75eb", size = 4000941, upload-time = "2026-09-15T21:44:20.245Z" },
{ url = "https://files.pythonhosted.org/packages/20/f4/64ce7a2499550929e08654360b270409eeda3e321b7c27027d9c919aa36b/debugpy-1.8.22-cp312-cp312-win32.whl", hash = "sha256:12bc7f368182b517cf26c76a2393fff65354e365fa1552b6241e66edff17997b", size = 5359040, upload-time = "2026-09-15T21:44:22.218Z" },
{ url = "https://files.pythonhosted.org/packages/4b/fe/e7d3b72874cdd327bda295d1995a01e426857ae9cbaaba74fd997bc3afbc/debugpy-1.8.22-cp312-cp312-win_amd64.whl", hash = "sha256:371a4ba4a5975eb958393903f3254cf983da7b1c7c178b3f987ee427f42515e3", size = 5398304, upload-time = "2026-09-15T21:44:24.119Z" },
{ url = "https://files.pythonhosted.org/packages/6b/f4/fee6d40f5865aa84e1100c9df1a0f2cf8991776b61f11f433ff277df4415/debugpy-1.8.22-cp313-cp313-macosx_15_0_universal2.whl", hash = "sha256:c21dd7e1ec22556bb41dc3bf6b86c603e440ec889c4acb27d7206354b2106c54", size = 2493531, upload-time = "2026-09-15T21:44:25.835Z" },
{ url = "https://files.pythonhosted.org/packages/bc/4f/8df91270afbf61fc5ab202951f28451194a7eb093704b26da11cb0ecd308/debugpy-1.8.22-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:c1ffb9953708b648ecf6acd2e5ad2c002bf68785ed618197dd4463a2a7226f39", size = 3994351, upload-time = "2026-09-15T21:44:27.31Z" },
{ url = "https://files.pythonhosted.org/packages/4f/11/2e34a4f21d6155b0c58003fd6fca9819df7d6fe7d7acd83b146b648a57ee/debugpy-1.8.22-cp313-cp313-win32.whl", hash = "sha256:ba810a66b437e3c43ca0ce0892404f010338286263ca8e5108442d76a9485337", size = 5359071, upload-time = "2026-09-15T21:44:29.064Z" },
{ url = "https://files.pythonhosted.org/packages/d0/f5/c2b3e8260388d7b76bc4e5a3a29648a0ca47dca6a2721d6549ff2d944eee/debugpy-1.8.22-cp313-cp313-win_amd64.whl", hash = "sha256:f49b1d6cecf326b63c06d7f517e4a6642777f758c58cb799e7bc5a6dd74721c2", size = 5400362, upload-time = "2026-09-15T21:44:30.857Z" },
{ url = "https://files.pythonhosted.org/packages/ea/95/d97240bb68b86db8d0a07602be068aae9e50f17063d696a4c6f619975311/debugpy-1.8.22-cp314-cp314-macosx_15_0_universal2.whl", hash = "sha256:e6744ac1850c73c2ba7b29a126cf9ab74efd1831190720e8fee17ef5389c8f5d", size = 2493148, upload-time = "2026-09-15T21:44:32.518Z" },
{ url = "https://files.pythonhosted.org/packages/70/9e/994fe1fb23dd4a5a8d059a0dc95da4b0821907200a61c2d5a4fdc21b6cf8/debugpy-1.8.22-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:0c1104233340196e5cbf5514e7dfdbb98968e730cfd7fdd6f3d72a082be838a9", size = 3961025, upload-time = "2026-09-15T21:44:34.387Z" },
{ url = "https://files.pythonhosted.org/packages/37/e0/66319918883cf6dd6dea3805210c3cafd9e65dfaa6f8b852482cff10a026/debugpy-1.8.22-cp314-cp314-win32.whl", hash = "sha256:feea785c7bbeb8cfd5b01a63f9061c899dc468c8be81671141a29305774fa294", size = 5359408, upload-time = "2026-09-15T21:44:36.128Z" },
{ url = "https://files.pythonhosted.org/packages/94/53/5b4d91f7fffb9393ac907133201d1da0c070d57e463ee8e2ec1db3cb63b6/debugpy-1.8.22-cp314-cp314-win_amd64.whl", hash = "sha256:d593a330297332ec435f3965c448b6d500e03f33675cf2063178bc76377a788e", size = 5397429, upload-time = "2026-09-15T21:44:38.429Z" },
{ url = "https://files.pythonhosted.org/packages/24/71/580df4aed96c9b23c9cbb049907d8ac1347050afdb8f450df20649d51578/debugpy-1.8.22-cp315-cp315-macosx_15_0_universal2.whl", hash = "sha256:a9eca6ab09a61534e923064400081e016f7e1d8430cc6e8b7a9cecd2f59e2b07", size = 2493380, upload-time = "2026-09-15T21:44:40.074Z" },
{ url = "https://files.pythonhosted.org/packages/b0/10/a323945b0966e560c40fe48470d2847bfc7692a8f550d7086e9a28f5b623/debugpy-1.8.22-cp315-cp315-manylinux_2_34_x86_64.whl", hash = "sha256:b7dbde1fb822d100802d505b2aed6d0813b1a0a2015d495cb8798b2215d5d1e5", size = 3975456, upload-time = "2026-09-15T21:44:41.623Z" },
{ url = "https://files.pythonhosted.org/packages/ef/f9/4383635281999c3eaa44bf327de523ba42240b35eb87c1130adac4a829c6/debugpy-1.8.22-cp315-cp315-win32.whl", hash = "sha256:225d063f81708c2546999e7edfac0198b2d5c2f144797dc64858f867948633e0", size = 5359469, upload-time = "2026-09-15T21:44:43.234Z" },
{ url = "https://files.pythonhosted.org/packages/7b/14/4fe9f46a9c825c8cbc6dc4aebc2a885b0bceb37ce2a37699c170482e7a40/debugpy-1.8.22-cp315-cp315-win_amd64.whl", hash = "sha256:b17a4896520f1c6da09ce76ec8215df0b85b0b6f3617526a4c65c2315340875a", size = 5397869, upload-time = "2026-09-15T21:44:44.783Z" },
{ url = "https://files.pythonhosted.org/packages/56/3d/c7dc9f35bc9e22cdb53679f844bee40fd5cff871862f60595869a433400d/debugpy-1.8.22-py2.py3-none-any.whl", hash = "sha256:a9e9d3550e15ca479c59333e90845029190531f0cacfedab3b815a57bd913947", size = 5374857, upload-time = "2026-09-15T21:44:54.454Z" },
]
[[package]]
name = "fastapi"
version = "0.142.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
{ name = "opentelemetry-api" },
{ name = "pydantic" },
{ name = "starlette" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/56/4f/f7c30a73127e0a8bbffe788369b8359e530b01ae06e2757936fa35bc5e6d/fastapi-0.142.2.tar.gz", hash = "sha256:06366626f2e70576367714d9ab2fe8472e6c8456dba69b399f9f797ab5e92570", size = 466721, upload-time = "2026-09-30T09:51:11.277Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/b6/78aaf9141fb46742928c113f3cf6ef2259d538cb02b604b7656c1dc9883c/fastapi-0.142.2-py3-none-any.whl", hash = "sha256:bd5f4d81f1e93a88bcd77caf4dfe3c2dbffc3805407a0007e9a114c18b3a670b", size = 144409, upload-time = "2026-09-30T09:51:09.8Z" },
]
[[package]]
name = "greenlet"
version = "3.5.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/3e/6e/0091f175ccd02b02bc8811bbcbcc6ac2e980be116e3b2f7a736ca322bf84/greenlet-3.5.6.tar.gz", hash = "sha256:8e67c43bdfc88d5fee6db0d3e40175b362fc95fb85f0412d233b9b203c53a575", size = 207653, upload-time = "2026-09-14T15:42:51.806Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/72/18/3fc6d951466ae9a2a688edcddde3b2e388da0a8244e0caf7117bbeb0eb95/greenlet-3.5.6-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:a5876d0a60355af98d535c47f6cd6eb0f8a432396dab26845d380b92f8412422", size = 295668, upload-time = "2026-09-14T14:22:33.241Z" },
{ url = "https://files.pythonhosted.org/packages/27/89/366d2af5061eeefa5012f510d95a99c8620dcc457609838db4d538820318/greenlet-3.5.6-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e85880b538e59a59f55117b81f208a6660ad5ac328aad9305f812d9b8bc67a0f", size = 611700, upload-time = "2026-09-14T15:12:01.962Z" },
{ url = "https://files.pythonhosted.org/packages/54/1c/07f133f865fd58ae593dd2bbec3144acaee9b04ffe2eb48c6e121747ceef/greenlet-3.5.6-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f0ba7c2a329d650628f4c8572fd1db29f0a59dd70a3e3e0710dcf18a35cce9d8", size = 624223, upload-time = "2026-09-14T15:20:42.459Z" },
{ url = "https://files.pythonhosted.org/packages/a7/f2/844dc823ff2752ad049caa6b59d57e4572f9c445934b02d3518f4c67197c/greenlet-3.5.6-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ee7d9da3bf493909cf811a3f038840cb34fab5ae2956b8a263919f6e289ab188", size = 629529, upload-time = "2026-09-14T15:25:06.354Z" },
{ url = "https://files.pythonhosted.org/packages/66/6a/1594f3869c57c149abdb380492529e04d4c0229b5e4d79572c5bd0aaa673/greenlet-3.5.6-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:975736b002ed080d124cf81a79cb7e05cb26d6b3f5c7a7b651c0fcce70353aa1", size = 621404, upload-time = "2026-09-14T14:35:59.027Z" },
{ url = "https://files.pythonhosted.org/packages/c0/42/b1f8dbc89a53b9e77859fc1ad1627d106fc361daa3ea4bdf43a91ebb4338/greenlet-3.5.6-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:71890d5247020c25c21a6b65202782bfc281d4e6e244842419d30e3492bb6dcc", size = 432385, upload-time = "2026-09-14T15:28:37.369Z" },
{ url = "https://files.pythonhosted.org/packages/a2/f5/33e5c9e48178b9259fd000f8f45caa4a65036f65d3d0c06a602f570f025d/greenlet-3.5.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0616b8f878098c5681fd8f0dc92d887551717402342a70f0abcbfea5f5ad8a44", size = 1584998, upload-time = "2026-09-14T15:10:06.653Z" },
{ url = "https://files.pythonhosted.org/packages/ef/31/9b4e140bc24d0ad7927ebd651f5608b0acc2334d061748c3b6ad19085cfa/greenlet-3.5.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3dbb4596a6a4e5d47121a33ff20533a81e60f302d9e67b69909a8bc21a43f0a7", size = 1647568, upload-time = "2026-09-14T14:35:49.787Z" },
{ url = "https://files.pythonhosted.org/packages/c3/71/d79f1791f824f8ff15c2978746640467ae932a2365e0201069f7f272395f/greenlet-3.5.6-cp312-cp312-win_amd64.whl", hash = "sha256:7ac4abb3877c43af320392c664774eef6fa2cc063c79a55fc02d844a3cbe7395", size = 324203, upload-time = "2026-09-14T14:22:54.504Z" },
{ url = "https://files.pythonhosted.org/packages/63/af/42aca4d56e8cb321912203069d8d34734cb288222f10ad2ae102718cc577/greenlet-3.5.6-cp312-cp312-win_arm64.whl", hash = "sha256:301102a49120b095e72a7838792b41233975fc1c155daec6d98f81c00c9280e0", size = 308310, upload-time = "2026-09-14T14:24:03.008Z" },
{ url = "https://files.pythonhosted.org/packages/f1/a1/e720a38852366c589e1a46cf570b886507ad2cf591050c203365638baab0/greenlet-3.5.6-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:f96f0e30b5a95c7631b12bfe214cbc90ec8fe8cfa36920596c10514a65743519", size = 294627, upload-time = "2026-09-14T14:24:40.102Z" },
{ url = "https://files.pythonhosted.org/packages/eb/c3/58187858df41354a11e6a55b421e7af9059798abdab3a384cc51b8567c38/greenlet-3.5.6-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c75116c9de79949de23006e2d9b35ee82874c594fcf5c0311b439acaa14b8441", size = 614356, upload-time = "2026-09-14T15:12:03.399Z" },
{ url = "https://files.pythonhosted.org/packages/ce/b9/3a7e67d5f05c9760b1ad411fa52264bd69cc08e22a2ebfb4018b90628ced/greenlet-3.5.6-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cad5782f93f7f738b62c6527b6f32a60694d924029f299a8b524758cfa53d815", size = 626756, upload-time = "2026-09-14T15:20:44.269Z" },
{ url = "https://files.pythonhosted.org/packages/c6/7c/40400455f5b5a65bb83e94fde66d1be9e5ec518638113f8083ace746c309/greenlet-3.5.6-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a93ee7c6e8fd0f8a83525a51bd777be57ee17787e91d805bd8d6faf9dcada18e", size = 632632, upload-time = "2026-09-14T15:25:07.813Z" },
{ url = "https://files.pythonhosted.org/packages/85/cb/ab0c123c514ed4e94c0dc9ee2e86362633e6b998cfc05de7fc9ac2eb9690/greenlet-3.5.6-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f98e8215e172f567ce80eeaed9107fb4d32b6c44f26983d9b8334658136a205a", size = 623779, upload-time = "2026-09-14T14:36:01.104Z" },
{ url = "https://files.pythonhosted.org/packages/f9/67/1f35cff30a6c51c3f23b63d4afcc7313ab4f97490ba3676fa78178984b27/greenlet-3.5.6-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:7f731ebac68ea06d628658295cb2d217b10186329fcf9a3b6a149045059bf92e", size = 434933, upload-time = "2026-09-14T15:28:38.858Z" },
{ url = "https://files.pythonhosted.org/packages/a5/26/fda8a5a06e7073333ccb038133c5893b9e0c4fe29d5992a17e83c241bc6e/greenlet-3.5.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df19e2d0b1620039af5102563fbd96e8938c7f5c3f5828528d641d9fc585525e", size = 1584930, upload-time = "2026-09-14T15:10:08.234Z" },
{ url = "https://files.pythonhosted.org/packages/2f/37/50f8813163148d6234e08b23dcad6a9e37f01d148c8ec976e4c44ea2d918/greenlet-3.5.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:06c0e933290fba8ffe53ead4ae1b8044b0e9754b75cebf381aa2bc3e50d82fac", size = 1647590, upload-time = "2026-09-14T14:35:51.173Z" },
{ url = "https://files.pythonhosted.org/packages/86/da/b7669b09586365654083a62bd0724cf06cb74bd5085a15cdd161271f992f/greenlet-3.5.6-cp313-cp313-win_amd64.whl", hash = "sha256:5b602b4201b965a8354d74e232364a66ff243dd142e350d035f46169bb36e13d", size = 324086, upload-time = "2026-09-14T14:23:48.428Z" },
{ url = "https://files.pythonhosted.org/packages/e5/5d/c9663cfe84a2a9e0aa96f066f5b0594c227ea4c647511e087e2e11d4ac0a/greenlet-3.5.6-cp313-cp313-win_arm64.whl", hash = "sha256:876077e7ebb8c84ed068e2b23d4c62ebb010d60df84b9591af1be2f39010ffb2", size = 308211, upload-time = "2026-09-14T14:28:01.634Z" },
{ url = "https://files.pythonhosted.org/packages/66/c0/d254544ae2b8bdd311aef000fafc02828c2771b17d994b3075620ea7cc6e/greenlet-3.5.6-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8cddea1b8339451c2fb3388e138347b6126744f33b611bdb55b7357361cfef46", size = 295221, upload-time = "2026-09-14T14:25:11.583Z" },
{ url = "https://files.pythonhosted.org/packages/18/18/eb54be16b9cc3971e09ca5b73334e1b8c804a4630d9addaaf218a4fe300f/greenlet-3.5.6-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c59acfa8eb73a1e0d484392dc002bdf001fd4ce73394e0132df3d1ab6093d7cb", size = 660992, upload-time = "2026-09-14T15:12:04.876Z" },
{ url = "https://files.pythonhosted.org/packages/8f/b4/e193efe65671dcf294bc51fcc59efb52d154adf8612c4ea016da0d2c486c/greenlet-3.5.6-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3b4a01c6da07ef9f80d4fe8933b994bc99747bcea3eab0330a9c34d3c12655b", size = 673428, upload-time = "2026-09-14T15:20:45.756Z" },
{ url = "https://files.pythonhosted.org/packages/fd/21/631bb45fafde1dca782152377c0676d182ec924820064047f533a3627b28/greenlet-3.5.6-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dd0b83bed3405b586a3133629f1d1a5bc7bfd64822a3b7ab342bdc68e6dbc61b", size = 677688, upload-time = "2026-09-14T15:25:09.279Z" },
{ url = "https://files.pythonhosted.org/packages/45/ac/28fa7a9e50f2859466214c4ac584d776db52c1604ad4dd158960a5af2a1f/greenlet-3.5.6-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9a09d59bef1db94f384b5bcc2d523694d338f3df6b757aeeaf7baca5d0c0be88", size = 670773, upload-time = "2026-09-14T14:36:02.577Z" },
{ url = "https://files.pythonhosted.org/packages/40/30/2b0a73e68e1e18e30b601d0d183cfdfc2beca4de5a6843c630f0fc9fb90c/greenlet-3.5.6-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:fdacf26402389bdd89857ad3c045a26fe8f3314f9a8b28226f82f88463a65b77", size = 480475, upload-time = "2026-09-14T15:28:40.741Z" },
{ url = "https://files.pythonhosted.org/packages/c3/cd/fb7d6cdd86ff3427c1494854f0e35437eba05142be91f530f6da75e09e19/greenlet-3.5.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8b7c73d1cef3d9ae963e9ff03f6222df43efbb9054ffd2f1969c935b7fc84c02", size = 1631900, upload-time = "2026-09-14T15:10:09.745Z" },
{ url = "https://files.pythonhosted.org/packages/f6/40/143bdbb20a516628cb15074ae52ed17d850b450292609c7a6fccac6dbece/greenlet-3.5.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8b27df301f56e3b3d2298095c8f7d6b68f2521f6b1693e901fa039bdbae34424", size = 1693740, upload-time = "2026-09-14T14:35:52.959Z" },
{ url = "https://files.pythonhosted.org/packages/c9/9e/019642432e6ae283301df1361227d47610709d2dc69a38f95edef266d713/greenlet-3.5.6-cp314-cp314-win_amd64.whl", hash = "sha256:f8f0bd690e1a41294ac87905e8121c81a3761ec2583c768f13467428606c8c7a", size = 327473, upload-time = "2026-09-14T14:28:12.948Z" },
{ url = "https://files.pythonhosted.org/packages/e9/7f/8aafc7bf70c948786dba7221d0dc0838e5329bebc6d434ef2208b4f0e760/greenlet-3.5.6-cp314-cp314-win_arm64.whl", hash = "sha256:8cda13494d86a4f12429641117cb6ac4bbbc9c30a33f711f7d3a2e5fbe4b0b7e", size = 311095, upload-time = "2026-09-14T14:28:00.7Z" },
{ url = "https://files.pythonhosted.org/packages/14/7e/7a205688a5b3074933b18a906608d46d106e9a79d776bdab5a4abf4b4feb/greenlet-3.5.6-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:97c5a53e8c1754df58e73f047a99e287d4da1bdfe64b0072fb25c87000897951", size = 305352, upload-time = "2026-09-14T14:21:31.962Z" },
{ url = "https://files.pythonhosted.org/packages/78/cb/9c4a57a9d9dd0256e20b8f7f4f06554c2c92badebf0ab73ce344321b78b9/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fea4427d1ffdb3b523d7daa6712038428a4c16c450b9777bdd1221cfee0eab49", size = 672671, upload-time = "2026-09-14T15:12:06.347Z" },
{ url = "https://files.pythonhosted.org/packages/97/52/c6729681ebbd298f4decd28746815acc8a0b0a0fde21d2df33776fd4d042/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73a29b5ba642e35433166a03a3e02935e7238c4b3467fbd77523b99edea23e5b", size = 679489, upload-time = "2026-09-14T15:20:47.291Z" },
{ url = "https://files.pythonhosted.org/packages/71/76/3c11c21e0716b1f1dc7c1a4b3d690abb1d3b448c69a9d32049fecb64010a/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:61a61b4a95a4f97922c3a6f5606d3e360851584bd47e500a5161373c53810e3d", size = 681303, upload-time = "2026-09-14T15:25:11.088Z" },
{ url = "https://files.pythonhosted.org/packages/58/c5/2b6c721ba8b8963da42d5a0f57f25b8aaeb1fe9bdd156875e57f3be648a2/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:460e70b033aba8ed47e2ac9b5d0d2157b05a34fbfa30a241400aef4118902cdc", size = 676608, upload-time = "2026-09-14T14:36:03.959Z" },
{ url = "https://files.pythonhosted.org/packages/3f/26/3ae402202452cd5941bbbd483e5a74297e2397e7aa3182c2a5e3ab7d5666/greenlet-3.5.6-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:fe3170a69fe039b18ad18171e66faa9a75f6fe9d78f968fd9b54e09fbd714d81", size = 510112, upload-time = "2026-09-14T15:28:42.112Z" },
{ url = "https://files.pythonhosted.org/packages/b2/04/0d018e0d05bcdde19a0fcb907834155f1fc853a9bedd3f3f5e6acadcae19/greenlet-3.5.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ca80a49b53ed1d22f7282da7255f7bb2fd1935fd0f623d8613fda38745f18961", size = 1641479, upload-time = "2026-09-14T15:10:11.216Z" },
{ url = "https://files.pythonhosted.org/packages/59/bb/f02ef9073919158f6403fe3701d4ed4403d646720e7201dfc6e9d264bac3/greenlet-3.5.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:916f92f2a8db10508f739d0b5e00b83defe5d1115a997c54532a6d7cf8c95404", size = 1698758, upload-time = "2026-09-14T14:35:54.336Z" },
{ url = "https://files.pythonhosted.org/packages/08/a5/1f48fe647473a2dcccfd1839b2ff2c78eb57009be776b4da071e901c9bff/greenlet-3.5.6-cp314-cp314t-win_amd64.whl", hash = "sha256:886bcf1870af74c32bc310fd00a6b803445e17e51b7d5a107c7b35c0f362cc16", size = 331574, upload-time = "2026-09-14T14:27:18.451Z" },
{ url = "https://files.pythonhosted.org/packages/cd/72/3882855a75838faeb54a58aeef4fd77d20b2a86d4bad570c70d41b565dcf/greenlet-3.5.6-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:3ac3494c381dab876cad7d0b22f3a722f3e0c8deb3a65b9e7f35ad7f58b8fcb3", size = 295926, upload-time = "2026-09-14T14:27:21.16Z" },
{ url = "https://files.pythonhosted.org/packages/10/1f/be4d957d8a9b90bcbe8db206548a42134d96222d43e5ed3fc4708fb6e24b/greenlet-3.5.6-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:602024dae6d77e161f4b89491b62ca1d4f19949d79d47b2db057e476d21179d6", size = 666910, upload-time = "2026-09-14T15:12:07.901Z" },
{ url = "https://files.pythonhosted.org/packages/a1/af/60d62571a7d6de961e4ce7625d6c2faf359345659fc782d2cdf517c34577/greenlet-3.5.6-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f8e63209c3e1e828ee6a457529b4a6d8b05d050fe0ae03a7ae49e967c5d312e0", size = 677415, upload-time = "2026-09-14T15:20:48.817Z" },
{ url = "https://files.pythonhosted.org/packages/f5/41/b3114c97c10e796010f00a30f51c81470072bca4b53e396ccca87484fcf7/greenlet-3.5.6-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9133d68624b1f2e89ec2f554d56aea8a5b0d7168cd9320200ba58d4d794845a4", size = 681337, upload-time = "2026-09-14T15:25:12.812Z" },
{ url = "https://files.pythonhosted.org/packages/fb/16/ac9e547b611539aaed1870eb1d6ddc57abdd5924b3a99bb9b5f0b44176b8/greenlet-3.5.6-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccadce0130fd813ec86ebfe969a6c58b42acc1d0fe55a47525375b740e07b605", size = 675927, upload-time = "2026-09-14T14:36:05.34Z" },
{ url = "https://files.pythonhosted.org/packages/48/1b/d41861c2fa00968e39e467a495ca8db9ce9b6310a5d9b57561b3d0dc48fa/greenlet-3.5.6-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:5adcbbfe78bdc242c71740a02e0991cc1b2f34d33c8bb15ca45eee8fd1140942", size = 487339, upload-time = "2026-09-14T15:28:43.497Z" },
{ url = "https://files.pythonhosted.org/packages/c4/b1/b7ba08d6431121741f1d30be0d5d292e76873325179a63586cd9217b62f6/greenlet-3.5.6-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:9297fb9c39b9a2c039dbcd306c410bd6906b95244dec3bba4318d36c718c164c", size = 1637236, upload-time = "2026-09-14T15:10:12.442Z" },
{ url = "https://files.pythonhosted.org/packages/af/c5/3b1cbc68f0c082022fc8717f7fe4b8b13b8d583c52352be37f4e9f55bcd2/greenlet-3.5.6-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:b374e79ffa7511afc11773aef40a4ccea6191fba1c856ea2f9c56738dca69d7a", size = 1698526, upload-time = "2026-09-14T14:35:56.039Z" },
{ url = "https://files.pythonhosted.org/packages/de/56/12941ed2711400451c89d544e10f831800a2770f19dd55eac8f0f7f2003b/greenlet-3.5.6-cp315-cp315-win_amd64.whl", hash = "sha256:7969bffa322c097bd46ae595ada6a931cefda613f18ba64587e9cff4cb320756", size = 327739, upload-time = "2026-09-14T14:23:55.768Z" },
{ url = "https://files.pythonhosted.org/packages/c5/3b/576b9ed5ac929252e340cf60b4bcb6a8515350dc20797064b1922dc4ea75/greenlet-3.5.6-cp315-cp315-win_arm64.whl", hash = "sha256:8dba0129b93e7091dfefaf4cf7000172741bff7f47bf6326fcf17f32fbb54d6b", size = 311715, upload-time = "2026-09-14T14:28:25.154Z" },
{ url = "https://files.pythonhosted.org/packages/16/c2/86cfc5555a98e12b86966ddbd24fd39af32f71f2f785c6595b7feb2db156/greenlet-3.5.6-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:de3de000d459402cda015068fd135aa50c0bf6f2477a80d4da1e646f123b4e78", size = 306244, upload-time = "2026-09-14T14:27:57.565Z" },
{ url = "https://files.pythonhosted.org/packages/14/6d/83ffc9d05a75a80ab3a7595dbb1d9604e5d4fc2996d73a8ae2dbd1284900/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45663c01a4de48b9a64a2ee1509d92d1dfd3afb02b2ccfc9333029d11aef996a", size = 676578, upload-time = "2026-09-14T15:12:09.468Z" },
{ url = "https://files.pythonhosted.org/packages/5d/d6/c2cf684810e5caded075970aaadea654ecb58b8382b9aecf1d231b936894/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3deccbb57a481e3a408fe61cdfd5c13e0678fc0a30fdd09597917ca87b4be877", size = 684203, upload-time = "2026-09-14T15:20:50.261Z" },
{ url = "https://files.pythonhosted.org/packages/f2/d1/039c353d5593a97a89699e989324c9bc86af499e6c6152fe0180f5742204/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:63aff70fe5aac59c72215f42ec39fcb59ff46774fa966e717f8ecb6ee2273577", size = 686023, upload-time = "2026-09-14T15:25:14.528Z" },
{ url = "https://files.pythonhosted.org/packages/62/19/00e1bee5d2af890dc8f400b54d0b0f9b489965f92bc12b407ff72cc6f469/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:311018b46472fb26ee85870847fb89eb64cc8aaddb617400789d87076f7cfeec", size = 681253, upload-time = "2026-09-14T14:36:06.742Z" },
{ url = "https://files.pythonhosted.org/packages/8a/62/97ceb8e0b2ea96046cdf8e95b042715020ebb12d83ea0690db80a8f03d23/greenlet-3.5.6-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:520648db8fb92eef7b3e6013f5a6f901cdf0d6685f639c2f7a245879f865bef7", size = 517058, upload-time = "2026-09-14T15:28:44.924Z" },
{ url = "https://files.pythonhosted.org/packages/89/58/c9275fd0ca195d1d3402931bcce8cfcc74726ff76efb1883d229e6e1a3d7/greenlet-3.5.6-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:7f924a5a9d5890649566f2f6682e0d8ad8ca23028bacffbbac36dbd7fd680176", size = 1645988, upload-time = "2026-09-14T15:10:13.758Z" },
{ url = "https://files.pythonhosted.org/packages/e0/36/b35747582fa4f1a5453f8f3002405dbac788e450cec7674dc2d204b6ccb5/greenlet-3.5.6-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:de9923832f2d8c1a5ecd8d7260465a6ca5a86888a0d129e3bd5cf0406d2fc5bf", size = 1702383, upload-time = "2026-09-14T14:35:58.143Z" },
{ url = "https://files.pythonhosted.org/packages/ed/69/6ec22ac9351e474d2a134d0ff9400dc80362d1c20f0721088ffffdfc205b/greenlet-3.5.6-cp315-cp315t-win_amd64.whl", hash = "sha256:2ab5f42ac6c238eb71770715e6e909ad9a1a92b6c681ccb64cd5a0f07edb953f", size = 331845, upload-time = "2026-09-14T14:27:41.723Z" },
{ url = "https://files.pythonhosted.org/packages/30/cf/697c051fd534e223461fb8b523890e21a24eeca229cd50624cff6f02fabd/greenlet-3.5.6-cp315-cp315t-win_arm64.whl", hash = "sha256:f9fe868463ec7e1363733af77e38a5fda3e9b63940337048c945d69e0c80ff24", size = 315070, upload-time = "2026-09-14T14:22:21.476Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httptools"
version = "0.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/88/1d21a36da8f5cb0fa49eafd4b169eba5608d57e75bbcf61845cbc6243216/httptools-0.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:880490234c10f70a9830743097e8958d6e4b9f5a0ffc24515023afeef984054d", size = 208247, upload-time = "2026-05-25T22:17:07.843Z" },
{ url = "https://files.pythonhosted.org/packages/a5/42/cc4feea2945cb3051038f090c9b36bd5b8a9d7f5a894a506a8983e33fd1c/httptools-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5931891fb7b441b8a3853cf1b85c82c903defce084dd5f6771ca46e31bf862c5", size = 113064, upload-time = "2026-05-25T22:17:09.136Z" },
{ url = "https://files.pythonhosted.org/packages/e3/a6/febbb8b8db0f58b38e44ad6cb946e6a255ae49b55f2e8543408fb7501ccd/httptools-0.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2", size = 523851, upload-time = "2026-05-25T22:17:10.106Z" },
{ url = "https://files.pythonhosted.org/packages/b7/e4/f90a0df0b83beff265b7e3b65f2a4cefd95792d4be0ac3e16049f2acd3c2/httptools-0.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:425f83884fd6343828d8c565f046cb72b6d19063f6924093e11bcd8e1548cd09", size = 518842, upload-time = "2026-05-25T22:17:11.218Z" },
{ url = "https://files.pythonhosted.org/packages/9e/2d/0c9ac76dd2c893841fbf6498d6acec4f2442e1b7067f6e3e316a80e494e8/httptools-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a", size = 501238, upload-time = "2026-05-25T22:17:12.728Z" },
{ url = "https://files.pythonhosted.org/packages/ca/42/906adc91ae3a5fa9c59c0a2f21c139725bd7e5b41ae6acd485cd14123ebf/httptools-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a1afd7c9fbff0d9f5d489c4ce2768bd09c84a46ddefc7161e6aa82ae35c85745", size = 509567, upload-time = "2026-05-25T22:17:13.842Z" },
{ url = "https://files.pythonhosted.org/packages/05/0b/4240efeb672751ee5b9b380cb0e3fdc050bc05f68adc7a8aefc4fcd9a69a/httptools-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150", size = 90918, upload-time = "2026-05-25T22:17:15.155Z" },
{ url = "https://files.pythonhosted.org/packages/5e/e5/8cfcabc5546e8022f168be28bcdaa128a240a0befdd03b59d558b4f18bd6/httptools-0.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:614ceea8ea606848bece2338ac03b3ce5324bcb4be8dc7d377ed708012fa4db8", size = 205148, upload-time = "2026-05-25T22:17:16.333Z" },
{ url = "https://files.pythonhosted.org/packages/2a/0e/0fb14848c19a686c8062ff9067c1a48793e3224b47bc5b201535b6036fce/httptools-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2d689918c15a013c65ef52d9fd495d766893ab831a2c8d89f2ac5940a5df847c", size = 111368, upload-time = "2026-05-25T22:17:17.586Z" },
{ url = "https://files.pythonhosted.org/packages/2e/1b/46f1cecf06b9bbde8e4b8c88034ac7908989e5ff7a3a388ef38392949c1f/httptools-0.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb3028cca2fc0a6d720e52ef61d8ebb62fcbfeb1de56874546d858d3f25a26b7", size = 486447, upload-time = "2026-05-25T22:17:18.564Z" },
{ url = "https://files.pythonhosted.org/packages/77/00/258bfc0837221f81d9725c45f9b948a6a6b2994a147a4fb66e85100c668f/httptools-0.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88bdd940f2b5d487b4d032c6afa5489a7dc4694410d43de3c38c4fb3af0dc45d", size = 482448, upload-time = "2026-05-25T22:17:19.912Z" },
{ url = "https://files.pythonhosted.org/packages/04/ab/d1cef3b5523f4d272a70f42a776c3169a2dddfe3a54de4b2ce4a36341528/httptools-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a43c9dd399758ccc0531acb0a3c4a6c299ee893ee9400e9c893b7bdcfae0681", size = 464460, upload-time = "2026-05-25T22:17:20.882Z" },
{ url = "https://files.pythonhosted.org/packages/ce/48/5d1d072442277bb2b3434e0e60690b8e8c23840ef7de8b6ea54040a536d3/httptools-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683", size = 471312, upload-time = "2026-05-25T22:17:22.085Z" },
{ url = "https://files.pythonhosted.org/packages/0d/66/b96623b27e51a68199ef4efdda0613cced9233fe3062ac74e50749c5ad37/httptools-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1", size = 90117, upload-time = "2026-05-25T22:17:23.074Z" },
{ url = "https://files.pythonhosted.org/packages/1a/12/fa3fbf5f9517b273edea2dc982aa82a8c634091e67c590792b729017bc6f/httptools-0.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6", size = 206183, upload-time = "2026-05-25T22:17:24.004Z" },
{ url = "https://files.pythonhosted.org/packages/30/fc/5e7c4cb443370f2090a3aba0453a07384d29ff66b7435bb90e77e1037599/httptools-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b", size = 112079, upload-time = "2026-05-25T22:17:25.216Z" },
{ url = "https://files.pythonhosted.org/packages/ba/53/771bd891eb0f236f32145d6a1775777ec85745f3cc983a1f23d1a3b8ddfe/httptools-0.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0", size = 481596, upload-time = "2026-05-25T22:17:26.186Z" },
{ url = "https://files.pythonhosted.org/packages/62/42/94e15bc68ce3d423243c45d7f1b0c7561f13844f97dc52ae23182fb65628/httptools-0.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e", size = 480865, upload-time = "2026-05-25T22:17:27.542Z" },
{ url = "https://files.pythonhosted.org/packages/1c/7c/fe2980fc03723272e30f135b62360b075f513dfe7cc73aef36c7f04012bd/httptools-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b", size = 463189, upload-time = "2026-05-25T22:17:28.546Z" },
{ url = "https://files.pythonhosted.org/packages/15/1b/47fc5fff68acd1bfa20b4734059c9a06cadb88119dcd5258b5b0d21d91c8/httptools-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0", size = 466610, upload-time = "2026-05-25T22:17:29.816Z" },
{ url = "https://files.pythonhosted.org/packages/60/bd/07b13c93ffd9bec9546e0d43f8e19378dd696dbd278511406bc07371ef1f/httptools-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527", size = 92705, upload-time = "2026-05-25T22:17:31.133Z" },
{ url = "https://files.pythonhosted.org/packages/fd/c4/121648f68ce066d7bd762d6b6d97e620847642d38d54f3d90ff11d947629/httptools-0.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568", size = 215023, upload-time = "2026-05-25T22:17:32.401Z" },
{ url = "https://files.pythonhosted.org/packages/b9/b0/312a062ae741ae3e8baa8c8bf20be81b2e67337b259ab4349bebc7b6142e/httptools-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b", size = 117405, upload-time = "2026-05-25T22:17:33.742Z" },
{ url = "https://files.pythonhosted.org/packages/fc/37/fccd705f795386bb05bf413012fecff2a33e5aa8c2f069096de3e9fd8702/httptools-0.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca", size = 558497, upload-time = "2026-05-25T22:17:34.732Z" },
{ url = "https://files.pythonhosted.org/packages/bd/39/f172e8003576de35f5ba77ff417cf0e34429d35dc014deef15afa337a72c/httptools-0.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f", size = 571585, upload-time = "2026-05-25T22:17:35.813Z" },
{ url = "https://files.pythonhosted.org/packages/3e/b9/f5564760af99f3dbbf3f9104dc00e5da27e96cf433c6bdcf77617f70bf3f/httptools-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d", size = 543297, upload-time = "2026-05-25T22:17:37.08Z" },
{ url = "https://files.pythonhosted.org/packages/99/67/8d9f2c313618e161b82f3873188e7196126da1d6e29688df40eb3997c77a/httptools-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081", size = 539535, upload-time = "2026-05-25T22:17:38.032Z" },
{ url = "https://files.pythonhosted.org/packages/48/63/b906c01e53f50d432c0defe43ce52764a111dc1bdd028bafbeb54dcfd008/httptools-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77", size = 108209, upload-time = "2026-05-25T22:17:39.473Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "idna"
version = "3.20"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
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 = "nodeenv"
version = "1.11.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/105de02c1322cfada6d9710d9146ef8026419d433c9d08359a2d35805811/nodeenv-1.11.0.tar.gz", hash = "sha256:3ce8fe5b71d16e8af7039ca65257354100bc772965d6bc549070649e53b1b146", size = 99301, upload-time = "2026-09-26T11:29:21.367Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/c8/12811c9b48fde162bb72b6f2e78fada9a247a09d7bb5be2050a5d099c77b/nodeenv-1.11.0-py2.py3-none-any.whl", hash = "sha256:edaa16e6c14d7cf395d75d4bbd5a26390f4dc06501a33b4e76282b02cc688a25", size = 34277, upload-time = "2026-09-26T11:29:19.933Z" },
]
[[package]]
name = "opentelemetry-api"
version = "1.45.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1f/dc/e12c1fe1ed8a7b7149777127b1a0e12ce5bd5a81d97408bedc2128c260f5/opentelemetry_api-1.45.0.tar.gz", hash = "sha256:711ede81773c8025c2c03dac0450bc89f3d30aea6eabcc815c570d4e35a963f7", size = 72115, upload-time = "2026-09-25T12:32:28.399Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/44/b9/040d1a1c7836922828e6480cd2366bb8fe0ebf75b413d2bb51a9b0e7f78f/opentelemetry_api-1.45.0-py3-none-any.whl", hash = "sha256:80e068aba7cd56c8b58512d6a36f8d25cb1dfaa0c0a4cc1c938ccf9f362d9cb3", size = 60020, upload-time = "2026-09-25T12:32:03.192Z" },
]
[[package]]
name = "packaging"
version = "26.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
]
[[package]]
name = "playwright"
version = "1.63.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "greenlet" },
{ name = "pyee" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/19/dd/fbb3d34228ad753bc14464d5f2252585b73367539775a88d0a3949cf5a45/playwright-1.63.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:84c540759e8e7f01e690e197e04060f48899d37cea322299f255843273d3385d", size = 44278407, upload-time = "2026-09-15T16:49:06.045Z" },
{ url = "https://files.pythonhosted.org/packages/f5/9a/948b930b1a8c4ee869e5a139a2b7747caa06aab56a3f09a2f0abdcbda221/playwright-1.63.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fd1aa00631d44d55e56e0975bf3f3f285fac4a9fd2813183f0c12a488f1a1b24", size = 42944679, upload-time = "2026-09-15T16:49:10.039Z" },
{ url = "https://files.pythonhosted.org/packages/94/11/dc5c13fa1602371603acd461be47529c1b3513815d3a0dc98f642c291a10/playwright-1.63.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:c89fc4736502a1f0fac2c8ca5d10c0cbc1c669f1f4774a2d8507a43140e4d53f", size = 44278410, upload-time = "2026-09-15T16:49:13.861Z" },
{ url = "https://files.pythonhosted.org/packages/27/9c/103a5037789062bdab27c7dca53f3ca6b075b572ab2cd96eec825b3aec4e/playwright-1.63.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:ad21bc07516b187965a7521c5cf0df0bd657b17482eaad74335272d35a2b07de", size = 48217159, upload-time = "2026-09-15T16:49:17.404Z" },
{ url = "https://files.pythonhosted.org/packages/f3/82/3d85505284c5a210f2da6c07b8f757524e79d1fba9cfdafe1eafb766ae59/playwright-1.63.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:354e15b29503565fc598b89f16fbe070459343bef9d7498a93e304864000c6a7", size = 47902708, upload-time = "2026-09-15T16:49:21.055Z" },
{ url = "https://files.pythonhosted.org/packages/69/8d/f74ff6b52751859f4b69caf66aa7d4a3c8d6c1f0c7dc9920da103612ee39/playwright-1.63.0-py3-none-win32.whl", hash = "sha256:660c00c62639e31b16700ba5456b351ddba55bb766b7ce8261223aa34928e482", size = 38606075, upload-time = "2026-09-15T16:49:29.546Z" },
{ url = "https://files.pythonhosted.org/packages/76/eb/d6b8d92658038e260dbc7dd69fb3fdbe245aac283953de8b50b02bfe5f61/playwright-1.63.0-py3-none-win_amd64.whl", hash = "sha256:2f9a707a6c6c91157ed77bff2b8caeb04b3c8d46e70d585fc134298cbe4b5cc6", size = 38606082, upload-time = "2026-09-15T16:49:32.838Z" },
{ url = "https://files.pythonhosted.org/packages/be/75/2432b3e3c7c62103b72d4c4cc8b16a56383ada372bbb0d1278591e988f71/playwright-1.63.0-py3-none-win_arm64.whl", hash = "sha256:1e4a3a838ce22fb68ad17193fcd142a19610d9d70fb9966d2239f5dddc0cc05b", size = 34523554, upload-time = "2026-09-15T16:49:36.455Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pydantic"
version = "2.13.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
{ name = "pydantic-core" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" },
]
[[package]]
name = "pydantic-core"
version = "2.46.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/82/3f/76358795aa7a8c6d4f36e2cb828ad1c90ee118e1393a9281664f5aade9d4/pydantic_core-2.46.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d", size = 2076516, upload-time = "2026-08-28T09:58:21.576Z" },
{ url = "https://files.pythonhosted.org/packages/db/50/26b091836076ce4cb2fac264186936acc069e0595772cfd02a563bc4761a/pydantic_core-2.46.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e", size = 1922874, upload-time = "2026-08-28T09:58:23.766Z" },
{ url = "https://files.pythonhosted.org/packages/09/f0/2a8ce3849e299d44e2d2c196b6082643a3235565a735cb51db7a6261f614/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29", size = 1951772, upload-time = "2026-08-28T09:58:25.435Z" },
{ url = "https://files.pythonhosted.org/packages/87/46/ac0dc8bdd9e6048183a14eb127764e7ad9240021c17513074a4711b0e31e/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4", size = 2031832, upload-time = "2026-08-28T09:58:27.102Z" },
{ url = "https://files.pythonhosted.org/packages/c4/c2/339de5bef7be36301a2231eaa52e62163742c2281f11b5f4892bc79785cd/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a", size = 2208645, upload-time = "2026-08-28T09:58:28.948Z" },
{ url = "https://files.pythonhosted.org/packages/7b/a0/9ff22b797724262da14427abaed4dd1d864a139693fc5e7809114376a716/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62", size = 2265935, upload-time = "2026-08-28T09:58:30.625Z" },
{ url = "https://files.pythonhosted.org/packages/c0/a4/eb9409ec0736e50aa70a412f16c204ed149516846912f7e6724d4c73ee53/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2", size = 2066284, upload-time = "2026-08-28T09:58:32.289Z" },
{ url = "https://files.pythonhosted.org/packages/c0/02/7f6156ffc926857f1c37c07d9a388682865a81830ab6a1b637082c25e399/pydantic_core-2.46.5-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869", size = 2105889, upload-time = "2026-08-28T09:58:33.986Z" },
{ url = "https://files.pythonhosted.org/packages/92/b1/e781d357ebe09fc929f995700f1b3503e8897f1cece183ecb1300d4d67e9/pydantic_core-2.46.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5", size = 2158006, upload-time = "2026-08-28T09:58:35.647Z" },
{ url = "https://files.pythonhosted.org/packages/70/0a/644597d84ab400e50609c192120b85c9681c22d3a20461b9060a79be0a7a/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3", size = 2158408, upload-time = "2026-08-28T09:58:37.38Z" },
{ url = "https://files.pythonhosted.org/packages/1e/ee/ca3b7b3a4b3769ffe9ce9432a7c9be755de9593a46d3b0d54d0409323e44/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b", size = 2309609, upload-time = "2026-08-28T09:58:39.22Z" },
{ url = "https://files.pythonhosted.org/packages/ce/52/39fa1f451486019524ca685020390e7ca351832fd874530ba30c8628e6dc/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0", size = 2342618, upload-time = "2026-08-28T09:58:40.89Z" },
{ url = "https://files.pythonhosted.org/packages/81/5e/468fc630568c61dcef3cd47ad32ffbeed9af643f49208d1ea86ab4f890c4/pydantic_core-2.46.5-cp312-cp312-win32.whl", hash = "sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b", size = 1939475, upload-time = "2026-08-28T09:58:42.591Z" },
{ url = "https://files.pythonhosted.org/packages/cf/c9/4c19f41b84cf6b622a72fbeed7665b25d47a187d68d47d0d430c07f23268/pydantic_core-2.46.5-cp312-cp312-win_amd64.whl", hash = "sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8", size = 2043140, upload-time = "2026-08-28T09:58:44.272Z" },
{ url = "https://files.pythonhosted.org/packages/af/dd/0c1a050299147c746e5256db16d645ab5efd4f78c59937d581a0524e74a2/pydantic_core-2.46.5-cp312-cp312-win_arm64.whl", hash = "sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084", size = 1997729, upload-time = "2026-08-28T09:58:46.13Z" },
{ url = "https://files.pythonhosted.org/packages/f5/37/5abe39a8372a61d3dc3c1338fc504281c01b32fdb3169cd7187153b56d3e/pydantic_core-2.46.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:b7ca9034437b6022f941f4857459562ee00a560b97e7cce8a0ec5a74fc6766e0", size = 2075885, upload-time = "2026-08-28T09:58:47.856Z" },
{ url = "https://files.pythonhosted.org/packages/21/43/6323b1f8b217780454c61304bcd2b38ae4762f50754414124603ccc90bb2/pydantic_core-2.46.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f332f0e72a5a0400141f830744e141bf9f97917878dbe968669e8a7fefea78ff", size = 1922768, upload-time = "2026-08-28T09:58:49.58Z" },
{ url = "https://files.pythonhosted.org/packages/0f/a3/c05ca796e1197618a774b01e596aeedfefc2f7d8c01ae3054e910b120e8a/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:193375f3548919d3f0b60936ca113ada3e38f264f91b9b8e0508efaad57be931", size = 1951241, upload-time = "2026-08-28T09:58:51.511Z" },
{ url = "https://files.pythonhosted.org/packages/68/32/33bc39ac705c52cffc908e8389f9754fdb208aea5c69cceddf4eb3ce99af/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:79bdfa52f843137045b2d081cc05c120ba6665d29b7559c2c47690906f39279f", size = 2031975, upload-time = "2026-08-28T09:58:53.166Z" },
{ url = "https://files.pythonhosted.org/packages/b0/70/2333e885c0f6a67bc105c5916965dac9b57f2718ee20d81d1a06a4ebdc13/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:24922243639cbdac66c75fcb6fd6495a9cb52b213d62f9a0d16f0310b1ff8038", size = 2208542, upload-time = "2026-08-28T09:58:55.017Z" },
{ url = "https://files.pythonhosted.org/packages/f7/ea/296debfb4264207bbda5936133892e027c0a58875ad53ebd512fba8ec3a2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c76fe65e607be28c7fd4d56fc3c42b1583aa058ce3408b7ad0fd540171d31f9f", size = 2264692, upload-time = "2026-08-28T09:58:56.767Z" },
{ url = "https://files.pythonhosted.org/packages/d3/f2/9e4de77a6271e07a76d2d58b11c091a979c191ed2939bf80067568b369d2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f7b393a8b3da82f5c1fc0751e6d01ac6c55b93c18226a60bdfba4a724efafd1", size = 2066633, upload-time = "2026-08-28T09:58:58.531Z" },
{ url = "https://files.pythonhosted.org/packages/8d/db/f9e9d0c97445987b2084823d5c240de88087338f04fc2cfaa2df186b8049/pydantic_core-2.46.5-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:7ac031912d54f3d83ef3b3eb98dfabc1608802e2202263d25957eeed40b94761", size = 2105235, upload-time = "2026-08-28T09:59:00.421Z" },
{ url = "https://files.pythonhosted.org/packages/07/c5/79169b047b3b2c3e99e04bc76372af9637e0bf6db638274fa927df96369e/pydantic_core-2.46.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:837b396ca3d7b74091ca623f6cbd8351bd42d670a79c2683e79fb089f06a2de5", size = 2157367, upload-time = "2026-08-28T09:59:02.442Z" },
{ url = "https://files.pythonhosted.org/packages/26/b5/ba6057afb7c291bd449f51b867f95aef2072941c4ce4e5c31d6ffd132d3b/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:5ee239d575f80b08eca11f6e20f90c4c695de7825c67eefe6091fbf20dda648e", size = 2158420, upload-time = "2026-08-28T09:59:04.2Z" },
{ url = "https://files.pythonhosted.org/packages/6e/28/2057abecaafdc22912afa819603a51f0a62d40643b7c4871c51721fea9be/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:e80675d75ae2cd14372cb65cad5400d9347a3d3f6c13000183f22dfd027283ed", size = 2309588, upload-time = "2026-08-28T09:59:06.048Z" },
{ url = "https://files.pythonhosted.org/packages/71/9d/881156dc404e27479c4246128d73538464cab4a239bec61995e227644c30/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:9c4b71f10dd532fb7a5cbc8f58707779e64f03a258c2bf8bfbaecfcd9970b519", size = 2341866, upload-time = "2026-08-28T09:59:08.539Z" },
{ url = "https://files.pythonhosted.org/packages/5a/38/d66f443a259f84d13babdceae568e572b0ed26da17ca5d0a649ebb110a67/pydantic_core-2.46.5-cp313-cp313-win32.whl", hash = "sha256:97bf8de4d541598c94a59344eeb988a94c08ff76b5723c41f6567ec18c7892ea", size = 1938580, upload-time = "2026-08-28T09:59:10.402Z" },
{ url = "https://files.pythonhosted.org/packages/2c/1e/1d5371213f4cc9a7ed70c0bfcc7911de22311ee99a662a56077d7292d2ac/pydantic_core-2.46.5-cp313-cp313-win_amd64.whl", hash = "sha256:15f4a94963c95accac15b7b657bb177d3ad82bb90b0d0526d9a9b85079925db5", size = 2041980, upload-time = "2026-08-28T09:59:12.396Z" },
{ url = "https://files.pythonhosted.org/packages/5a/48/4222d90b1c67568bace4dec6dca6271449c66de3595d72b6d098f5fde597/pydantic_core-2.46.5-cp313-cp313-win_arm64.whl", hash = "sha256:d22a945598fb91236b4dd793a6e42e4f3dd7740bb5aace5ebd7d4c08d13bb575", size = 1997213, upload-time = "2026-08-28T09:59:14.245Z" },
{ url = "https://files.pythonhosted.org/packages/8e/8a/14596f2a8367da50cf7cbac48169ee5d9c8e11d486a3b527082384630c72/pydantic_core-2.46.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c1c43ad4339643d70ebb8124e1305a7dab423001eff58bb41a0f731adbc98355", size = 2074081, upload-time = "2026-08-28T09:59:16.141Z" },
{ url = "https://files.pythonhosted.org/packages/ae/d5/d8a4eb6d6c7f66b91dd37c576d76e9e60fba900caf5372c17bcf949febc2/pydantic_core-2.46.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a353f84de772f423b5ffb11d7ae352fbbef0f446f3c0b0af0f8236d7233606e", size = 1920497, upload-time = "2026-08-28T09:59:18.065Z" },
{ url = "https://files.pythonhosted.org/packages/8e/26/092079428f86e927e030b2c0ced87df69dbb1c875cdeaa67bf42ea2be746/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5086029a57366b8cf81b130a43908738095c270c21a8d7f0e8bdfdb89718e2f3", size = 1952130, upload-time = "2026-08-28T09:59:20.476Z" },
{ url = "https://files.pythonhosted.org/packages/08/c3/8ec0e290a9ebaebd64047bf5fda94be835c6b1551b02437e4b76778fbcd7/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46c25dda9d092a06c08db76ffe0a197107904d0dfac653f7d5306bbcd6d6119c", size = 2026371, upload-time = "2026-08-28T09:59:22.227Z" },
{ url = "https://files.pythonhosted.org/packages/01/72/4fd20ad520fb8da0157f95b27a7eb05a72790ef08138e7701ac972c342ea/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:37ea7b83c935e5b0d68c9449b82651accf78a10828b2c02b2f2d9e9496446c21", size = 2202822, upload-time = "2026-08-28T09:59:24.277Z" },
{ url = "https://files.pythonhosted.org/packages/31/b0/d16e0771206b29314f0d52198b720be21e8a99ab2bf11e3bc0d7c9cebdff/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e64e88d5585bea9ce95861079de72006c7fa6d3df4e3a3b65ba31eb979c15c9f", size = 2262756, upload-time = "2026-08-28T09:59:26.608Z" },
{ url = "https://files.pythonhosted.org/packages/2c/9b/59634b7ac631c63b2a37760eb6943af3e29573d6b59a4abc5e7f019d4cee/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d510bac3ee52247af28ed4bb18a1e799f040ac60fd2bf5ccd4c92f1fbe786f", size = 2068352, upload-time = "2026-08-28T09:59:29.044Z" },
{ url = "https://files.pythonhosted.org/packages/08/7c/570abb1ad2155348dc754ea91be22e5aaa18eb6d69a6068f7c6f2679a6ed/pydantic_core-2.46.5-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:a2a5e1d0ff29adddc9f6d6821a66302e4493f8ca898b715b6b1182c2c201ea0a", size = 2104777, upload-time = "2026-08-28T09:59:30.95Z" },
{ url = "https://files.pythonhosted.org/packages/8e/25/5bf74adc65a1ac5b7be3f6cb0bcb5433615c1598a801c19d830d84c98ded/pydantic_core-2.46.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:03b9666e41e35d8909852ba191a0607520f81b74eaf12ccf8737005dbb313821", size = 2156312, upload-time = "2026-08-28T09:59:32.604Z" },
{ url = "https://files.pythonhosted.org/packages/90/6a/2ef38830675e050121040618135564ed56b860b45433b02d9b4ebece46f3/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:a91c17edf6eea2402cb5457b4c89e99bc5ed1004aa34c4adf1d4258c1a5c22c2", size = 2150067, upload-time = "2026-08-28T09:59:34.453Z" },
{ url = "https://files.pythonhosted.org/packages/90/ef/a7dbb03a14a64c2a4621f989c615ed9a892535a6cad938fc27079f919d80/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b49924c73a235e969511bf2aabdff3beebf9820931f646c80274d5d780010c47", size = 2304516, upload-time = "2026-08-28T09:59:36.194Z" },
{ url = "https://files.pythonhosted.org/packages/68/f8/6bb4c4b80e8a6fde1904c64a51c62a1d04fcdfa3ea521a66b2ddefa1d885/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:2cbd9a5eff05e51c447c34dfa4632145b26b09120cf04bd0c871e44c1a5e1c9a", size = 2335223, upload-time = "2026-08-28T09:59:37.931Z" },
{ url = "https://files.pythonhosted.org/packages/2a/80/f46b8c681195190b2c1f1c7c0a81abce60663e987613e09ef64d433dd96b/pydantic_core-2.46.5-cp314-cp314-win32.whl", hash = "sha256:2d5d76654becf5efd62c9e51c3756c67b49498b0c9a40884934c40807adbd074", size = 1934827, upload-time = "2026-08-28T09:59:39.836Z" },
{ url = "https://files.pythonhosted.org/packages/f7/3c/60674207246bc0a4009d2391b7c7251c7159f279c8d2ab8aae8ef46f3dee/pydantic_core-2.46.5-cp314-cp314-win_amd64.whl", hash = "sha256:fa10ef4112775900e7a0661068635eb67b2ab824fbde764de6e0e21982a93db0", size = 2042648, upload-time = "2026-08-28T09:59:41.792Z" },
{ url = "https://files.pythonhosted.org/packages/69/0c/117c562c7c1babdf44576b72a5e496906506c93690387ecfbca7c729ae2e/pydantic_core-2.46.5-cp314-cp314-win_arm64.whl", hash = "sha256:045ab3b6d308439e32b81cc173bba5b9018bc6ed896afd0c65b3b009b1699af5", size = 1989652, upload-time = "2026-08-28T09:59:43.702Z" },
{ url = "https://files.pythonhosted.org/packages/e8/66/9336ae58f9eb68c41d121894e52c4c89eccb07eb8f602a04ee9c3f37736a/pydantic_core-2.46.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8816f3d218beb4b787de5c9759c259b8fa61f9dec42dc7811f320a33771778b7", size = 2065829, upload-time = "2026-08-28T09:59:45.364Z" },
{ url = "https://files.pythonhosted.org/packages/c5/02/bc19b47a96c2d3109760711acf22369e56bd7e405ca52f7ade164d2ead57/pydantic_core-2.46.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bce57638e08ac148e5778cce7feb968307a727d66f8e2274a543d0cf0c9ad6a3", size = 1905716, upload-time = "2026-08-28T09:59:47.18Z" },
{ url = "https://files.pythonhosted.org/packages/52/a4/70b47c0509923dd98ccfed04fb3e32ea3849c82a0ff2205bb41009b43c00/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:976e1128455aa595ea04c79ccfedff1aaeab96ee013fcc916bed120c4f0ad94f", size = 1934216, upload-time = "2026-08-28T09:59:49.241Z" },
{ url = "https://files.pythonhosted.org/packages/52/ab/aa03b65f7bb198585edf806b906c3223ecf1795543e39e23aec4cce27ad2/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b891faeedeafba41b2983e5001a81b6a915b69544c7e7570d1989ce1c36ac7", size = 2010635, upload-time = "2026-08-28T09:59:51.692Z" },
{ url = "https://files.pythonhosted.org/packages/3c/8b/0da06343f30b84ec549aafd309c6456223d5dc8bd36af504c573faad561d/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f194189415698233dd1114a093a9b56e61e2c57e11b469be3b0506f46f0771c", size = 2209369, upload-time = "2026-08-28T09:59:53.582Z" },
{ url = "https://files.pythonhosted.org/packages/d6/5b/844c4defaa34a3df66eb9257087d121d70c201298b96abdf9f492fc2f1bf/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:82a36973cf8a2ef5406f4fe2edbf8ed0c99629535d959e0b100c76a32535a111", size = 2253238, upload-time = "2026-08-28T09:59:55.484Z" },
{ url = "https://files.pythonhosted.org/packages/f4/64/a4e536cb16d7f61a7fd3120b46c577fc7fa7325992f69c4f52bc786d77d8/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdbb78909f52b981d3b2d56b97328d71eb0b974c36bd77c920123a7ebb192829", size = 2065740, upload-time = "2026-08-28T09:59:58.038Z" },
{ url = "https://files.pythonhosted.org/packages/5f/75/aaa38c6bc2d085f6605b34eabdc6a8a4e0b2e61fc9c8e6e52b28e97b3125/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:52e24eacdb536cade636aa90fb851835222becff8484b7001fdc78cb0290f2aa", size = 2087425, upload-time = "2026-08-28T09:59:59.898Z" },
{ url = "https://files.pythonhosted.org/packages/55/ae/fcab4cfc39aba3689e1d20c8b5250ad280957022c09af2ed9cd585602a5e/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:37ae34309d7bd8c0d61ab839668058f2a7962ea1fc51d105d2db228fe0618034", size = 2139306, upload-time = "2026-08-28T10:00:03.057Z" },
{ url = "https://files.pythonhosted.org/packages/2d/f4/f1d03a4bc9d9acbc62f4d742b8a319af52f71885079868b2ff8e48a651ee/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:0cdbada856a1c69a7624a64d3d9aefe79300bd6ef827b43a4f265010b9b55184", size = 2144589, upload-time = "2026-08-28T10:00:05.645Z" },
{ url = "https://files.pythonhosted.org/packages/83/f3/7a53bb1356de514a4cd295f25b6ac39237895620c0462d2592b76c16e114/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:545f26c504b27c3758439a5e6d9349931f0a04f855668d5fe323c89e82300a38", size = 2288882, upload-time = "2026-08-28T10:00:07.931Z" },
{ url = "https://files.pythonhosted.org/packages/cd/94/5a81583660c175c59d49ffb09f4b3a44debeaf86a19fca664ae1cdd9ee32/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:ff218293c9c806138dca139765e3b067621be52bcd93cdc14c7711be7ddc90a9", size = 2335210, upload-time = "2026-08-28T10:00:10.177Z" },
{ url = "https://files.pythonhosted.org/packages/5a/9f/5d685c2693b972d1a59c998586e8823712b66603aeff47ee60a4bdaafd37/pydantic_core-2.46.5-cp314-cp314t-win32.whl", hash = "sha256:97cf3eb53a8cccacf9d46686a0926186c9bfb5574f2ed66d3639d5fe117cd3a9", size = 1921180, upload-time = "2026-08-28T10:00:12.35Z" },
{ url = "https://files.pythonhosted.org/packages/70/12/5c94ee16d65a37a15f9e869f5e6256df111154491173801a4c5e800ab548/pydantic_core-2.46.5-cp314-cp314t-win_amd64.whl", hash = "sha256:d2f9fc07a8042a8f95925b35c4f04f469707c981fc33245b6ca187cf5d2dd290", size = 2020515, upload-time = "2026-08-28T10:00:14.774Z" },
{ url = "https://files.pythonhosted.org/packages/63/19/67830dda664e6bdf9285ee2e40f355d0d7d6b92aa0c42e8d217bb8d33d36/pydantic_core-2.46.5-cp314-cp314t-win_arm64.whl", hash = "sha256:acf8a67ba51f4ca9ddbd0e6b3000a65ac51ab734661778b3e7ba64d99a710f2f", size = 1989276, upload-time = "2026-08-28T10:00:16.984Z" },
{ url = "https://files.pythonhosted.org/packages/df/dd/053c2e4303f791f3b8f8a14ab0b22008e8eb21d868c0c90b4f9be705b76a/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942", size = 2062540, upload-time = "2026-08-28T10:01:00.318Z" },
{ url = "https://files.pythonhosted.org/packages/d7/dd/a18df751a5e37dd51bfad7f68e766999125bebe68c9e1d10a493ad01bd63/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f", size = 1902040, upload-time = "2026-08-28T10:01:02.529Z" },
{ url = "https://files.pythonhosted.org/packages/b7/13/01d40f9d07ce8a779fd6e0bd8ad4fba91309500dd67b869e2e219d261a6d/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433", size = 1967479, upload-time = "2026-08-28T10:01:05.004Z" },
{ url = "https://files.pythonhosted.org/packages/fa/04/c81d4841331c2178b6fb09ae225425e110ed72d990c9fe556c4ec03d1013/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c", size = 2111034, upload-time = "2026-08-28T10:01:07.345Z" },
]
[[package]]
name = "pyee"
version = "13.0.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8b/04/e7c1fe4dc78a6fdbfd6c337b1c3732ff543b8a397683ab38378447baa331/pyee-13.0.1.tar.gz", hash = "sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8", size = 31655, upload-time = "2026-02-14T21:12:28.044Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/c4/b4d4827c93ef43c01f599ef31453ccc1c132b353284fc6c87d535c233129/pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228", size = 15659, upload-time = "2026-02-14T21:12:26.263Z" },
]
[[package]]
name = "pygments"
version = "2.21.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
]
[[package]]
name = "pyright"
version = "1.1.414"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nodeenv" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e1/1b/244c7b710031ada80f27e579ec20d28a2285dfc318fed0339866b1047f12/pyright-1.1.414.tar.gz", hash = "sha256:523c0a97c60da6333234955c277730c9cf4f5bd6d5399e7b7d2b0fc5d3599524", size = 4154638, upload-time = "2026-09-10T12:26:53.181Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d7/ba/18b6e682ead424ad24bcc134339ae5d1b931cd9ae260540592a058a91279/pyright-1.1.414-py3-none-any.whl", hash = "sha256:2a6b4b3298c9eec174c5ed83bd338de6eee82df2992f3e1930e6199d381be36f", size = 6225049, upload-time = "2026-09-10T12:26:51.427Z" },
]
[[package]]
name = "pytest"
version = "9.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
]
[[package]]
name = "pytest-asyncio"
version = "1.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" }
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 = "pytest-cov"
version = "7.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "coverage" },
{ name = "pluggy" },
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
]
[[package]]
name = "python-dotenv"
version = "1.2.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/74/26/2fbeedb218a787a5eea551c7532cac4e009f83d689dd2faa0d0353473f86/python_dotenv-1.2.4.tar.gz", hash = "sha256:f0d53e69935a851c0dcc78f3ab7aaccd8cabef0b92382b576b824212902873c0", size = 60824, upload-time = "2026-10-01T05:36:10Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/60/d1/38f3a3405989a89ac18390803e70c6ad7c7760da4f9b83cbeca0c44a0c72/python_dotenv-1.2.4-py3-none-any.whl", hash = "sha256:42269a8a5b3fd54ffa6f3d84b18abed50064717576b4ecf03dc4a55d8aa04fdc", size = 23266, upload-time = "2026-10-01T05:36:08.633Z" },
]
[[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/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 = "ruff"
version = "0.16.10"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c4/49/23802c45f093eb14bde54b141d2b2f058edfa63a06db7beed047308cc08f/ruff-0.16.10.tar.gz", hash = "sha256:eff4728c4eaae93f0955cd264d24b2ab348e74bf59986ccf282ba6dc16b3b017", size = 4958724, upload-time = "2026-10-01T18:03:21.697Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2f/21/ebce22e1d90cdb2cd691026b9c6e9bec6499f0b396089481755b5d49efff/ruff-0.16.10-py3-none-linux_armv6l.whl", hash = "sha256:488b0fe3f3574210e5cf80d9f59b9e3ab17a127a8155de3f307b392589cfb511", size = 10095557, upload-time = "2026-10-01T18:02:36.072Z" },
{ url = "https://files.pythonhosted.org/packages/cb/98/a54de85876a8b2612bfa0d84c7b9abfb39c6a3354aee7800b09c1649b9e3/ruff-0.16.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e748ff95c934c4e978783b8e687bc174e7bd84e8ad24e3243e1ecfcda5e0282d", size = 10340577, upload-time = "2026-10-01T18:02:39.258Z" },
{ url = "https://files.pythonhosted.org/packages/9f/16/1a5a4a2657effe4806110f2b907313802f1367fcdbb29e8122766e407fab/ruff-0.16.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3031a4a2e8e7b8a46f70be45f198c35a11ece509a94b80334d8d397a33c67550", size = 9774282, upload-time = "2026-10-01T18:02:41.79Z" },
{ url = "https://files.pythonhosted.org/packages/6e/fb/470085af734da396e68cd80fb0a3e7c109a459716ae59588c0f6fab8a17d/ruff-0.16.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:494401c86df4c4c25f69b9419605d944467ee98c42fb6ad405ef4fa40b8fb67d", size = 9920895, upload-time = "2026-10-01T18:02:44.458Z" },
{ url = "https://files.pythonhosted.org/packages/57/de/f10cffe4f88a37ea6615ec460f01bf76f0bc1477737a35d9ee61a630a78b/ruff-0.16.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d203abc0ff2b773ee33d00ab8df0bb67046fbc7c07b119332f08b9b345cf8221", size = 9902707, upload-time = "2026-10-01T18:02:47.041Z" },
{ url = "https://files.pythonhosted.org/packages/ff/44/3fdcedf83ae60ef837dd239170e480dce606a9142cfa2db911afdf855fd9/ruff-0.16.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bd83d1235a5258d318477bc5b576303974cbdef5df0c01a1bff14efcc23bd12a", size = 10619287, upload-time = "2026-10-01T18:02:49.485Z" },
{ url = "https://files.pythonhosted.org/packages/c1/62/02e76a5574002153618eafbb70e159468addc72a5d2c488e65cbb4639d2d/ruff-0.16.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bc2610fb269fa56dd8a68669ae470fa6272902668c0fc2ebc3aa112b2633d5b8", size = 11339942, upload-time = "2026-10-01T18:02:52.008Z" },
{ url = "https://files.pythonhosted.org/packages/6b/c4/cde27d47ad8d4126c587e608c67c46e7feac11763f606d261a1bc8a489e6/ruff-0.16.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3e70175e29cc94c26ea296c80e470180b744b7419026898e58f520c6ab32578e", size = 10934316, upload-time = "2026-10-01T18:02:54.811Z" },
{ url = "https://files.pythonhosted.org/packages/e4/03/17234145f302a645a123e8c3bb2411ecf4669fbc350de3b0d803230a1729/ruff-0.16.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f33f43a864a8483eebd160e713336c8bab02c934feaff0a33cf5ccb41546d09a", size = 10387968, upload-time = "2026-10-01T18:02:57.497Z" },
{ url = "https://files.pythonhosted.org/packages/71/29/2493af60240ee7644b38a4b821f5f9c3b5a4fa3178770fe1d0c685217395/ruff-0.16.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1dfc6f0088149fb6a362c1c446bcbb3fd2157b3852fe2fa68409276eab9ad9b3", size = 10537906, upload-time = "2026-10-01T18:03:00.006Z" },
{ url = "https://files.pythonhosted.org/packages/2e/9a/f56b28f3b143bb9e518c34fb89ab191b626bca8b70dbf8af0d2e2d473572/ruff-0.16.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6553498afc35f580f036030795810b9e6bcea31604b0fd9e8d352795473042e3", size = 10012938, upload-time = "2026-10-01T18:03:03.006Z" },
{ url = "https://files.pythonhosted.org/packages/c2/c4/fca37362848ea4d4d80712df13632e645e7c7cfb1bdedf140699ac7a090b/ruff-0.16.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a3b8471dea115d37f123882be852bed13403746d3a76c11de4a19ec5f9ff5a03", size = 9897945, upload-time = "2026-10-01T18:03:05.818Z" },
{ url = "https://files.pythonhosted.org/packages/7d/c7/e0bc57664d6e0c61fd22f620af260fe9662d7e1ddb320ea7f160e76ef165/ruff-0.16.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:92e59a70bcbd9d3a5483656da906ec28edfdacfce00afd99edb8b4e9d15644be", size = 10333134, upload-time = "2026-10-01T18:03:08.25Z" },
{ url = "https://files.pythonhosted.org/packages/21/aa/5c9f3b68737c0e4a33a1db5dfab44f7b786d96ca91a7233112ddab1e6dc2/ruff-0.16.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:7ae7375f803b5520dc9f546bed7e3a0acb70b91812e9bb4b19927de22f25b77d", size = 10741702, upload-time = "2026-10-01T18:03:10.775Z" },
{ url = "https://files.pythonhosted.org/packages/78/fa/0f9c2020dc316c53d983be011720b8157cc052b97e3f4dfa7db6d0f880a6/ruff-0.16.10-py3-none-win32.whl", hash = "sha256:2a12e01cb9156c10c466f63b46eaae5ecea28dfbd21b5836353ae498e7d1349a", size = 10139176, upload-time = "2026-10-01T18:03:13.267Z" },
{ url = "https://files.pythonhosted.org/packages/99/29/cfb0df9448d4d4ad48c2de029ada9ebd71baa6da983a6c77ee6c6cd0fe82/ruff-0.16.10-py3-none-win_amd64.whl", hash = "sha256:97f2015c92aa97105b0eab19eb5d224884399281cfc5da86a92db4ab5e7fb2ca", size = 10584734, upload-time = "2026-10-01T18:03:16.006Z" },
{ url = "https://files.pythonhosted.org/packages/fc/05/c16957eb287c3fc062e032619a25868d93d408a844b725bcf514f7a378ff/ruff-0.16.10-py3-none-win_arm64.whl", hash = "sha256:25a65fe998c4e6861ec079ada5826a2fc605e6cbccbe9dcd7fac1f54e791621b", size = 10366440, upload-time = "2026-10-01T18:03:19.04Z" },
]
[[package]]
name = "starlette"
version = "1.7.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7b/2b/3850dc6bf7ef71b088962eba31dafc6cffd2f96e577ebb0bb316df96da3e/starlette-1.7.0.tar.gz", hash = "sha256:c79f74ea63cff761804fbbfb182f1e0b440c2d07b164d24700c5a1bab5d6ff5d", size = 2736246, upload-time = "2026-09-23T07:30:26.35Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4e/d6/1ec1b290f9e0fb067899b61e1d37a30c923068bad260b216dbe37a7d2967/starlette-1.7.0-py3-none-any.whl", hash = "sha256:67f8e99895493dd2911a03f11314af6ceebeae4e704bb9f43dfc6a9db151c93e", size = 78980, upload-time = "2026-09-23T07:30:24.567Z" },
]
[[package]]
name = "typing-extensions"
version = "4.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
]
[[package]]
name = "typing-inspection"
version = "0.4.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" },
]
[[package]]
name = "undercooked"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "fastapi" },
{ name = "pydantic" },
{ name = "python-dotenv" },
{ name = "pyyaml" },
{ name = "uvicorn", extra = ["standard"] },
{ name = "websockets" },
]
[package.optional-dependencies]
dev = [
{ name = "debugpy" },
{ name = "httpx" },
{ name = "playwright" },
{ name = "pyright" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-cov" },
{ name = "ruff" },
]
[package.metadata]
requires-dist = [
{ name = "debugpy", marker = "extra == 'dev'", specifier = ">=1.8" },
{ name = "fastapi", specifier = ">=0.115" },
{ name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27" },
{ name = "playwright", marker = "extra == 'dev'", specifier = ">=1.48" },
{ name = "pydantic", specifier = ">=2.9" },
{ name = "pyright", marker = "extra == 'dev'", specifier = ">=1.1.380" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3" },
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.24" },
{ name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=5.0" },
{ name = "python-dotenv", specifier = ">=1.0" },
{ name = "pyyaml", specifier = ">=6.0" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.7" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.32" },
{ name = "websockets", specifier = ">=13.0" },
]
provides-extras = ["dev"]
[[package]]
name = "uvicorn"
version = "0.54.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/da/34/30e9280707135d2cfc589dfff3cb796bd07a3aeb1a3e415ba09dd89d7bb4/uvicorn-0.54.0.tar.gz", hash = "sha256:a2e33cbfaa0306f8e6b0c13e0cb89d7d7a2da3e62b90c66e18c33d9807b28620", size = 112283, upload-time = "2026-09-25T06:52:37.601Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/38/0c/b54a4fdd7f90a3af8b02ebc9ce6712c2c208b7926a2f7bad95c33ebbe943/uvicorn-0.54.0-py3-none-any.whl", hash = "sha256:505bdb0f318731d45f1f712071fc781a8981f6847a31c902c9f5e652d4f67faf", size = 87427, upload-time = "2026-09-25T06:52:35.829Z" },
]
[package.optional-dependencies]
standard = [
{ name = "httptools" },
{ name = "python-dotenv" },
{ name = "pyyaml" },
{ name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" },
{ name = "watchfiles" },
{ name = "websockets" },
]
[[package]]
name = "uvloop"
version = "0.23.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fa/42/02c739ce85fb2ee8d99212c61417da8140c6b87e9d97c430bea520d76044/uvloop-0.23.0.tar.gz", hash = "sha256:28d160f51ab4da3b187063652e643dea6831072add4adc1e6d62afbe73b6be27", size = 2559185, upload-time = "2026-10-01T03:17:04.4Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/05/98/04e766a6de99e6f7f955ecb7829e8d5a557de3427cb85be2236de54dda0c/uvloop-0.23.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:93935ab27b6eaef4c3e5489aebc84284f0644592f7ab516df60ee1b27eaf5eb3", size = 1393055, upload-time = "2026-10-01T03:15:42.526Z" },
{ url = "https://files.pythonhosted.org/packages/33/8a/499e7b863a848ede009539bce39806b66205da5f8779354228e785601144/uvloop-0.23.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4448e9124537620f9c25d004c227bb5104440b58955c19bbd312d910af919a63", size = 768909, upload-time = "2026-10-01T03:15:43.974Z" },
{ url = "https://files.pythonhosted.org/packages/3d/95/a880f8ce3b87ac5b307c354e8ee480be4658d24bf01f87921d57e3530b4a/uvloop-0.23.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7548ede3ee908cfabc0d068106e303a9a2d811af959cdf6ab85676344cedcda", size = 4419106, upload-time = "2026-10-01T03:15:45.551Z" },
{ url = "https://files.pythonhosted.org/packages/51/27/c1d2f9fa977f8f42ea294604166df10e0027e6dc6cd17f85ede386c9bf36/uvloop-0.23.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:090865d8ce7a03986755a3ce711b7dd0d4b44eb14ab74368b717f3fad1180208", size = 4532597, upload-time = "2026-10-01T03:15:47.258Z" },
{ url = "https://files.pythonhosted.org/packages/42/dd/2cb6a2c8a30ca55c07a882dd4ae4ceae0fa7d8c15b25b3b7cb9a4b6cf4ca/uvloop-0.23.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bd6f2f81c7b9da99d301c0b16b82044e76fe887086e42e1590ecf520b94dbdac", size = 4230048, upload-time = "2026-10-01T03:15:49.119Z" },
{ url = "https://files.pythonhosted.org/packages/f4/52/29989cbaa4022dc4ef35c1dd60a4ab989e4c2065f341ed483ae71d2bd950/uvloop-0.23.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a6ac96da66c35bf789bdcde78a88dc7d56b7907d8379648c54adc1c61594575d", size = 4394152, upload-time = "2026-10-01T03:15:50.829Z" },
{ url = "https://files.pythonhosted.org/packages/5f/83/eb980d64e6dd5da46d4dc35755fa6afd6b5b47141437cf89615f1117c5a6/uvloop-0.23.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2dcff2d69be43e6559e5dad2c5a7a2dbfb60e05a77311b6c4b7a4a8123d86c65", size = 1412726, upload-time = "2026-10-01T03:15:52.49Z" },
{ url = "https://files.pythonhosted.org/packages/04/c1/02a725e7698134c647904bdee6589e2be14a0e7fc9942c74f86e2b90d48b/uvloop-0.23.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:19c64108b507cd0bc140e400e3396bacebd9d504956aa7726272bf6de7d9aabb", size = 779071, upload-time = "2026-10-01T03:15:54.02Z" },
{ url = "https://files.pythonhosted.org/packages/0b/1d/cde53c79e8c01884ad1cdca8e407e086d523362cfe4139e2c2a8dde27304/uvloop-0.23.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1748321e3c59a14a75404b1ae8d5a8d81c4e201803ea0e14c1b6fd84421024b5", size = 4395323, upload-time = "2026-10-01T03:15:55.549Z" },
{ url = "https://files.pythonhosted.org/packages/98/54/b12915bebbf99d7ae0796211e7f5977b95f069830dca45dc1a346d84125d/uvloop-0.23.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2cba180d6451822763eda8364f342435a873bcfb3849cbd82fdeca248ca65eb", size = 4480449, upload-time = "2026-10-01T03:15:57.362Z" },
{ url = "https://files.pythonhosted.org/packages/f7/8e/da6de68c31549a052a105fc76f5a9a204f6df22cb0909440aa4dbb06f9a2/uvloop-0.23.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:dc61e4f9e37b507069dc7e659ae28bca7adcb04c993c3508214315d12c63f848", size = 4219177, upload-time = "2026-10-01T03:15:59.351Z" },
{ url = "https://files.pythonhosted.org/packages/a1/c3/1b53c6a89dc9c9d5cb75eb9a0b891ad69b32e1421ad3aa01617a9cbdcc78/uvloop-0.23.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7337b06a9f9ed9ea3049f04b76f65819db9b19bb832ee598e97b388eadf25e5f", size = 4346132, upload-time = "2026-10-01T03:16:01.064Z" },
{ url = "https://files.pythonhosted.org/packages/4e/a4/00e85345871c59c834a23c136c1771205856028ecc8ba940b3951178e59b/uvloop-0.23.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:b90397a50ad6332ed3e459c648ac20d182cce24a557354363ad85fc9ea4a17cd", size = 1421363, upload-time = "2026-10-01T03:16:02.599Z" },
{ url = "https://files.pythonhosted.org/packages/d0/a9/e5f0f3cfde30af3ec32eba8ec07bccdba2b5116afbd1ecc53edfeb0a0790/uvloop-0.23.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:be53e1d5f83de43dc175c87612ecc128d444b38e5c56cb3f807f5a73d6887476", size = 785177, upload-time = "2026-10-01T03:16:04.018Z" },
{ url = "https://files.pythonhosted.org/packages/9e/79/9ddf78f8cd75a15c14a09a57f59c587b8cd9d82802c5c8368b9c3ebefa0b/uvloop-0.23.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b3cbc4f96ddfa1fb88a78a69dd851369825b7816d9702eee8c4461505ba172e", size = 4381060, upload-time = "2026-10-01T03:16:05.642Z" },
{ url = "https://files.pythonhosted.org/packages/1e/20/57d63c44d32326878fcad5c63854afc9deb394ed95673c1b1a429178c79d/uvloop-0.23.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:31e0cf90bc8fd88784f6802cdba968a51fb1aec1cc3feec74d862b2d371d1330", size = 4418891, upload-time = "2026-10-01T03:16:07.326Z" },
{ url = "https://files.pythonhosted.org/packages/12/c5/0795abecda2cc3dfe41033f880a32a9ff103be4e6b177ac736833c153a0e/uvloop-0.23.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa8ed556fcc87a4091cf61587ef172fa104323dc89ecc085a618ba7ff8629a8f", size = 4214811, upload-time = "2026-10-01T03:16:09.13Z" },
{ url = "https://files.pythonhosted.org/packages/20/18/9010dacd5221eec1bd79a4a83ac68f3db6a42d7bb657f7b640c4838ca6b6/uvloop-0.23.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f3fbfe82829d8e381426a289b87e59e585278728361db9ce975b88b51f64f410", size = 4294876, upload-time = "2026-10-01T03:16:10.875Z" },
{ url = "https://files.pythonhosted.org/packages/b1/08/f6384a03c771d00067cba4f542a69b2fc1a982e9fd78b357c2f788678d72/uvloop-0.23.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:7e35c9bc977760981693e1a7a51493b58ee5a501f9ebb1e547565ee40b6c6208", size = 1494811, upload-time = "2026-10-01T03:16:12.399Z" },
{ url = "https://files.pythonhosted.org/packages/ac/01/756a4fb24a449f313cf4a153eb0c6210b49cfe5539255ec9fb1e17d2c4ef/uvloop-0.23.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5bb9be71d9ee39b4359b832f9569518ec9bc08704194034e79e4958e6bc4d46d", size = 819396, upload-time = "2026-10-01T03:16:14.094Z" },
{ url = "https://files.pythonhosted.org/packages/3e/45/e314b0c600b14f53dad3a3c2d7a922a249a88225fd727652b53e1854b9dd/uvloop-0.23.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e84575f11873c109cf3962ad0bdf679094466184125f4cadcc41a73febff41f", size = 4734966, upload-time = "2026-10-01T03:16:15.815Z" },
{ url = "https://files.pythonhosted.org/packages/66/0d/8686a7f0b1b2d55ebd770ba21f8e0e4ffa0cde5ab738f43ffb8264499052/uvloop-0.23.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbbdb8fcd5e7062e546eec1ac78c28bb21ae7df54c18f8e4b06e15a18d661a49", size = 4584963, upload-time = "2026-10-01T03:16:18.198Z" },
{ url = "https://files.pythonhosted.org/packages/78/b2/034a2d47e435ac02357c42956246887167bdc0357bdd6ad31c5f6d94497b/uvloop-0.23.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:76345f51367fb1f23e08605c6efb18374f669be5b223658fbab6b17627950507", size = 4421388, upload-time = "2026-10-01T03:16:19.953Z" },
{ url = "https://files.pythonhosted.org/packages/f0/77/131f4b583e6b4b715c404a66b51c812d701db20f25c9018b188a2b00062c/uvloop-0.23.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c7ef4701a96553514b2688e342ef1bf2beae6cfd172d89a76c768292aabf405", size = 4402414, upload-time = "2026-10-01T03:16:21.716Z" },
{ url = "https://files.pythonhosted.org/packages/58/3d/ee11f4718ea1280595c67ed25c83d4c92115dc100bbdfd192d3ed9339168/uvloop-0.23.0-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:f1341c6abcee1c31277cfe28d34e46196f2143ec3d755e6efe7452126e1f626d", size = 1418095, upload-time = "2026-10-01T03:16:23.241Z" },
{ url = "https://files.pythonhosted.org/packages/f8/0c/7ca516a0671418517d79a09d3ff2ccbb44af94c75711afa6e4cf58aa6f65/uvloop-0.23.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:e095f9e105af76593b4c183bb0bcbdae64bd913a59ec595732dc108b48730ab5", size = 784837, upload-time = "2026-10-01T03:16:24.666Z" },
{ url = "https://files.pythonhosted.org/packages/35/95/75d4e28e596d505b7ae11de517646b4ca3d369fb8537ba755410380da11a/uvloop-0.23.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f673d835bdb1a60229cc3609a113fd2c9ce3f4a3c75ad4eaed111180c00199d2", size = 4380276, upload-time = "2026-10-01T03:16:26.389Z" },
{ url = "https://files.pythonhosted.org/packages/10/99/68daf827ad62efaf4667d1f3fda127046d42161178396bdd93aab3684082/uvloop-0.23.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c3f23f403a273900d57de6ee5ca0614c650f7f58563065dad1a4744498960e53", size = 4451496, upload-time = "2026-10-01T03:16:28.364Z" },
{ url = "https://files.pythonhosted.org/packages/71/69/f67e696ee688f426a96f99099bae26fec14a1d0fa75dccdd6518ee267c0c/uvloop-0.23.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:cbe8d03d4efcccdb7fcedecbaa1e1fa02913eaf3a74cb933634a6bc6d2ea9e2a", size = 4212541, upload-time = "2026-10-01T03:16:30.014Z" },
{ url = "https://files.pythonhosted.org/packages/f1/6a/c8c436a9d7453297b4be70bdf6a9f9fc9400da45e0059ddf7b28ab63f4c7/uvloop-0.23.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:4f1798f56c6f4ba5ac11fa2869e5717926e4470d97a1dd42b4f59219d43b5027", size = 4319377, upload-time = "2026-10-01T03:16:31.705Z" },
{ url = "https://files.pythonhosted.org/packages/3b/2c/8fc15a03489299aab8a6212dfe0f137dc39836f915c87f7fd9d9ddd814de/uvloop-0.23.0-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:098a85e1393ef5202767b7e5fb41a32cd8bd81e6ee4af364c179801c4aa3f6d4", size = 1493428, upload-time = "2026-10-01T03:16:33.859Z" },
{ url = "https://files.pythonhosted.org/packages/b7/7c/05e4a210790229607f71460fcb2ed4a2c7bc72668d8a928ce577c22e38f8/uvloop-0.23.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:5a2bbad3a63007f7e9524d4903ba04fee252557c2acd86f9a3d4f91786695254", size = 818115, upload-time = "2026-10-01T03:16:35.45Z" },
{ url = "https://files.pythonhosted.org/packages/65/14/a40b11c6c024213803b13955664a15754c72f64c873a33d986b26ec9ff5b/uvloop-0.23.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a08875543bbd4519faf30497506c9cda8a48470467ffdf967c7313c7a5981a8", size = 4734149, upload-time = "2026-10-01T03:16:37.025Z" },
{ url = "https://files.pythonhosted.org/packages/9f/83/f421a077712c1e87603bfec62744c3cd3a2f4b47378025db3d740df9af0d/uvloop-0.23.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:12634f15e6625f78b3f2922f91404c4d7173487eba11746764153f556e9852dc", size = 4661763, upload-time = "2026-10-01T03:16:38.719Z" },
{ url = "https://files.pythonhosted.org/packages/f5/62/25dcaa6b7e7b48f82ce633854ce96597ab768f9650931f4f86c572de392c/uvloop-0.23.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:378188efbb1524f2219d05246a3e1e5907217848d2882144dff59585f1b81d55", size = 4421324, upload-time = "2026-10-01T03:16:40.488Z" },
{ url = "https://files.pythonhosted.org/packages/05/46/04628239b43dcef703af314202a3307d6060918e2d76aa86c5b1188f5551/uvloop-0.23.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:4b8e207c67d207a8608fec57e116511030af3495dc0109b8c333cf9cb412b16f", size = 4462501, upload-time = "2026-10-01T03:16:42.359Z" },
]
[[package]]
name = "watchfiles"
version = "1.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b3/68/e6aa0b77d217b31f8f486ec0cdfe5e00e6e38dc0be657e7d85819b9faf0a/watchfiles-1.3.0.tar.gz", hash = "sha256:99aee4a07847c06820765fd7b1b49ceac4f3f711ccb7d104655a33231de1c207", size = 108119, upload-time = "2026-09-21T09:08:55.664Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/68/fa/c0b840d5d8bafe640925408ce11095948c8945bb61be1e67d0ab629b872a/watchfiles-1.3.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:000b9688fc8133037a8b075c8ebf98f32844ff8964dda61e85c1db547dafc441", size = 399703, upload-time = "2026-09-21T09:08:13.518Z" },
{ url = "https://files.pythonhosted.org/packages/c7/8a/894799b485fe9473ad10422a0d9668e53fb78e3a2b5cc6061159572844ad/watchfiles-1.3.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:bbc1198edfdc90fda0600f825aa94150f428dfcbf8138746f55998e0e660d64c", size = 397647, upload-time = "2026-09-21T09:08:15.16Z" },
{ url = "https://files.pythonhosted.org/packages/5a/61/f277cd5ead05f5d37b98b41fbdd0c943ee0c04d49738f62e902cb291cf43/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4af9c464d410e8c44b58ddf1cbec7c3a02c1cb2c6c80df9c74659ba2fbda0ab5", size = 454994, upload-time = "2026-09-21T09:08:16.587Z" },
{ url = "https://files.pythonhosted.org/packages/46/66/ea4c01382975e53b1ac1b0a0ea1b353416b8127fb6e04f5c1c313119aab6/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb94e40b9a0db19636b5e3fed0a9803452ecc7381df13b385272abb779ee83ca", size = 460105, upload-time = "2026-09-21T09:08:17.961Z" },
{ url = "https://files.pythonhosted.org/packages/31/c3/1a501b597817096c9831c255f6270c124ce0e2faeb74124be24dcc93b86d/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7398930a2b76b9dc67a4e6b8bfc7baf30946892a86f417a3920c07bdd6febc30", size = 493761, upload-time = "2026-09-21T09:08:19.327Z" },
{ url = "https://files.pythonhosted.org/packages/5a/16/77352d05e152c7786a016e9f1decdb4507e8aa5bcf3343c72142a0413d14/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:025b108f2d5cc2799cb941f79380df3e8b22bfb589c218e801767fe78d598c95", size = 577500, upload-time = "2026-09-21T09:08:20.723Z" },
{ url = "https://files.pythonhosted.org/packages/95/fe/81001c55b24466fe9911135e50269fd3a29f43e446627a92adfab54553b6/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:be9ef3cd403d756a304a0a08a112163c63da7ec72c8c9d083c98818291ff8f29", size = 469209, upload-time = "2026-09-21T09:08:22.346Z" },
{ url = "https://files.pythonhosted.org/packages/8a/cf/e3ad894ed5909a7c44b0707e89bc25d19a3d78b3a7aec846119443b6ae56/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b5768b49e426fd5b550b012c866db347cdf15c398ef98dc557b6e6b72fa74cd1", size = 458353, upload-time = "2026-09-21T09:08:23.7Z" },
{ url = "https://files.pythonhosted.org/packages/7f/f5/2f106829e6e2bc91ad74614730f6df484202b9a4d20d927dbf2f08d37d44/watchfiles-1.3.0-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:d36c72fcdc08f143d87316a4c4c3b064e340da2c7383d39803950740ed418935", size = 468707, upload-time = "2026-09-21T09:08:25.167Z" },
{ url = "https://files.pythonhosted.org/packages/15/37/2741ab776b978669c1f3bba1e573f1a184f727ab8b4d3a6d9c61090800ed/watchfiles-1.3.0-cp310-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:f0c9865240e065a2247f4b5248528ae3d01e0e8ae071462c250c884dc16e3ee3", size = 632510, upload-time = "2026-09-21T09:08:26.532Z" },
{ url = "https://files.pythonhosted.org/packages/48/64/db4a4684275fe9b696ee5118c194705c1423b3d42181c3f8ee70194d5c73/watchfiles-1.3.0-cp310-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:380f513d26cc2e598266b88d66456e07d67ef4be8f0f435a1154d9c44b43d509", size = 661723, upload-time = "2026-09-21T09:08:28.004Z" },
{ url = "https://files.pythonhosted.org/packages/52/80/95ddb24f6a2f595c031c24432ab7b8aa7d6d1ada0c6d63a552166e06e8d1/watchfiles-1.3.0-cp310-abi3-win32.whl", hash = "sha256:509d9f74d2bec5c1f4cd868ddcfe0c9601f7ea53066dfcb19d8ede742c4de661", size = 277761, upload-time = "2026-09-21T09:08:29.335Z" },
{ url = "https://files.pythonhosted.org/packages/48/a1/1c4a1b3c8030cd03206232612be8a6e8799093490c220b0fcb935d02b75a/watchfiles-1.3.0-cp310-abi3-win_amd64.whl", hash = "sha256:1acabde19b67e673274e89a04d613b7fb1f122e2c4b8ec9a581e59c149264b61", size = 290643, upload-time = "2026-09-21T09:08:30.45Z" },
{ url = "https://files.pythonhosted.org/packages/82/e0/d2db577c632d26d08078c4390dd870b8555c4e558df25d22270f0166c71f/watchfiles-1.3.0-cp310-abi3-win_arm64.whl", hash = "sha256:d978cc1dd7ba44f5590d7de74f7a9c8be7262d9e469c2c0efb8bbfc2574321dc", size = 285510, upload-time = "2026-09-21T09:08:31.561Z" },
{ url = "https://files.pythonhosted.org/packages/4f/55/a122743607939429ab6a7272672f49303aa02bbd34c3f06676765a3861de/watchfiles-1.3.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:e0204f90677b7fb8d3a824279a71a01166bea9f6e53923b8056592d87b33b0b6", size = 395978, upload-time = "2026-09-21T09:08:32.747Z" },
{ url = "https://files.pythonhosted.org/packages/3e/9d/0ee1b0ea42acfe72afa2484f0222e2386dd09992e9034a81e63cc3cdab0a/watchfiles-1.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7c1125e99f84934a92996ae343501285617373acd66f16b59fd5654b27ad8d80", size = 387997, upload-time = "2026-09-21T09:08:33.953Z" },
{ url = "https://files.pythonhosted.org/packages/b7/f2/4319c6b683e8bd28465dfc53c750617a2a07f072eb2e3ab0182ccf812229/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a85e243c87109d9f48a2e0d10d6abdee8768cc114848b32281ec83d7f81d86ef", size = 448067, upload-time = "2026-09-21T09:08:35.104Z" },
{ url = "https://files.pythonhosted.org/packages/b7/71/9f37ac192a51a24de3a2d7fe9aac1e99b2b2668300674ff7a9f85d95beb1/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:dd538c71766c59c732e2c5cedc39323c99c11897b99f93eac773438e54af206a", size = 452881, upload-time = "2026-09-21T09:08:36.238Z" },
{ url = "https://files.pythonhosted.org/packages/34/07/1224667c32a4a96876ddd642da69733cbfe6a15897db0865aec5a882cebc/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:da213822ec9082b62cadf3008e07a8c690ec7a3c89f6dc5e55a8cf07b56a1b26", size = 484802, upload-time = "2026-09-21T09:08:37.717Z" },
{ url = "https://files.pythonhosted.org/packages/bd/4d/7f29cd87e1f826df86fc0418d0b8f370ca0311c29c618406dc492aad9951/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d4593392e87669836670f87d62fc754059eaeb0e6157af44060a46558cebe5a3", size = 567522, upload-time = "2026-09-21T09:08:38.94Z" },
{ url = "https://files.pythonhosted.org/packages/e3/ac/3aa1f84abd4dbbfbae99a817dd931f77b439261c911189cad4c30fdfd470/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:26b81bc515a0f03f66a69a6fea86eea54c8eca4173c46dd7222569f79dd2f977", size = 461997, upload-time = "2026-09-21T09:08:40.142Z" },
{ url = "https://files.pythonhosted.org/packages/82/1c/27c66764d5b0dbb70a36770a32eca217eba739c28b24e8583a035087a3d7/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca39661934749df580d89f3dde266f2af7c6968f4c21c72ce2dc387d299e6aa7", size = 451856, upload-time = "2026-09-21T09:08:41.305Z" },
{ url = "https://files.pythonhosted.org/packages/1e/b3/a45827084d953c1d0dd3d75abf85dcac26a0f5d115ad2fe0b7e9e931573b/watchfiles-1.3.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:04c5500faa725d69a99b0f63750e80795e455bb951849e9c98a811b4324997c4", size = 459889, upload-time = "2026-09-21T09:08:42.58Z" },
{ url = "https://files.pythonhosted.org/packages/7a/3d/07740d23ce2bb23419bb24055c435a9abe5fe03efdaea1ba0720b831aab6/watchfiles-1.3.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:ec3cd4bb181a7b6329134204c2277c7144ab5f62cce9c5335d6f901ee7ce8d6a", size = 624482, upload-time = "2026-09-21T09:08:44.064Z" },
{ url = "https://files.pythonhosted.org/packages/91/46/9540f8123eaec86df989f26d7704cad0448d9fb7271763a4f8f9fe23617d/watchfiles-1.3.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:e5871d4a7f788a9f64fb06f5793389d29d171e5c32d37c3ebfd520b017cc7694", size = 653724, upload-time = "2026-09-21T09:08:45.257Z" },
{ url = "https://files.pythonhosted.org/packages/25/9d/b9deb52f1e4528ffe44338c5ebab95c32ed79203082da661de50288e130a/watchfiles-1.3.0-cp314-cp314t-win32.whl", hash = "sha256:81c989d7267bfb7b676cd32c83bec97914e396870983cbf493e29415037bff5e", size = 270863, upload-time = "2026-09-21T09:08:46.518Z" },
{ url = "https://files.pythonhosted.org/packages/59/59/8b1881afccbafda06248be4185e28034dd73c80c85e4da24f2cb0c1321eb/watchfiles-1.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d754e049006e81a9be49dc78ba7802081cee426e968cbc63ee19e164fa2d0e39", size = 286879, upload-time = "2026-09-21T09:08:47.874Z" },
{ url = "https://files.pythonhosted.org/packages/46/dd/41c12ae9745d5d72f709333287679d71cbba68ad7678fed872207bba94f4/watchfiles-1.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:a5b631db08fd3032db57ec6a92d1777cd60f88803287e4e941a4858c53aab1e0", size = 276924, upload-time = "2026-09-21T09:08:48.976Z" },
]
[[package]]
name = "websockets"
version = "17.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/89/3f825ab71c242fffb62ea8fe638741c290f62f8d7aadf8125ff897747af3/websockets-17.2.tar.gz", hash = "sha256:36c2fb94c990cc2545143b12690e2de6c16300f9dbe5b4f33fa300cf57dc8792", size = 188355, upload-time = "2026-10-03T14:56:53.5Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bc/de/87854af9b38fe4738fd85f7f21c5b49558ae20aec898880894e435f33375/websockets-17.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:916ebdfd82e7fc68041d36b2b5f60361b9abce1e087454da15f8bd004839e090", size = 217757, upload-time = "2026-10-03T14:53:23.029Z" },
{ url = "https://files.pythonhosted.org/packages/3a/2e/1e80b5efa41544f626d56bd15ccb53dbfc56bf28bf80ab9cd6f82c4b1d20/websockets-17.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3621f3686397708b8eeabfd0a9d75267c1f29a7537d2fe31e65d099e71587fa4", size = 215439, upload-time = "2026-10-03T14:53:24.531Z" },
{ url = "https://files.pythonhosted.org/packages/3b/6e/82c78b595aee05be76a7ee78539323da1593c1848e4fef51c704c696568f/websockets-17.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a81e19710d48da88653473b6b9c366d47e99fe4f58e37ce415be47966748f31f", size = 215703, upload-time = "2026-10-03T14:53:26.226Z" },
{ url = "https://files.pythonhosted.org/packages/f8/c4/905ef6aa80423c03dba99e1e26fc0acf63a2a9a6a2d9e8c0e6a63caaf952/websockets-17.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f2731f9067976c8c4127212c0d2f2ada42d497d935e470419e029802365b12bb", size = 225023, upload-time = "2026-10-03T14:53:27.744Z" },
{ url = "https://files.pythonhosted.org/packages/03/c0/a6d8be9c43e4456fb9597fdf8b5e0ce1f0a5df41503acce6d869536e4e23/websockets-17.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6627b913b8586b1c06db9516b31dd0dfbc621de3bb9312616d92a7e44f268a5b", size = 225299, upload-time = "2026-10-03T14:53:29.171Z" },
{ url = "https://files.pythonhosted.org/packages/2f/d4/976d34b5491258b0a86c2ce9b9aabb9fdd68919ffd7fe65999c14a502a98/websockets-17.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0198c4ec6a3406a2f7557c032967de426474c2c995c81076585e09d29a9f407b", size = 226540, upload-time = "2026-10-03T14:53:31.635Z" },
{ url = "https://files.pythonhosted.org/packages/83/2f/c4cfd42f53c697a8ed123fd82b8f85fcd13b6360d47f9f1d1d45d6ec6627/websockets-17.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:88c6a42c2632ff469e84155e44f6ed92cb15ccb047bf5fcb59225ae5a12fd33d", size = 229371, upload-time = "2026-10-03T14:53:33.061Z" },
{ url = "https://files.pythonhosted.org/packages/e7/55/9a221b29c6232ff9282eecb2fc102402cb9e42a3479264db0e5fc4fe6835/websockets-17.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:eb0023e6cdb4b8ece0b33875188dd16104ad8c335361d396a98394f99e30ff7a", size = 227173, upload-time = "2026-10-03T14:53:34.502Z" },
{ url = "https://files.pythonhosted.org/packages/8f/07/125e6d010c56c253d3d2b93cabaea0f96d33898151a16b49066a594acecf/websockets-17.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c1c09d5d4646eb96bda2cfb97493bcea21a0956a981de116e6b1f4a9de07f3fd", size = 225929, upload-time = "2026-10-03T14:53:36.071Z" },
{ url = "https://files.pythonhosted.org/packages/23/a8/aad3bd902aee84e1b261ad6ab83b405e4a564af43101b8ad1dc0293ff4f4/websockets-17.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0360c4dc13ac569cc245e0efa2f4d4b1e4733d24c47b8ab3f3747227b1356348", size = 223167, upload-time = "2026-10-03T14:53:37.528Z" },
{ url = "https://files.pythonhosted.org/packages/1f/f4/ec8ab9be1a5310b4fea829f088c7aa2b7a58b61d34bce1b2a9338635ff12/websockets-17.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:76693a16dead737946b651375ee3109d7db7ad9569a1c55c60aaed3ef85cfcc6", size = 225974, upload-time = "2026-10-03T14:53:38.959Z" },
{ url = "https://files.pythonhosted.org/packages/65/45/ba6503f8257d3f98b0f07ebaad0fd099c9023eae744fd5b775416743597e/websockets-17.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:77a42cc507993ec5471b5283f7eef869239173b6000031543e3938a86d1af0fd", size = 224581, upload-time = "2026-10-03T14:53:40.496Z" },
{ url = "https://files.pythonhosted.org/packages/d0/45/05cca59a876c6776727d96fc7ba59e0b6f9aa496afbf13e7e04ad0b63678/websockets-17.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3bbc5543e39ee025d524077c5c15c2d67bc11c9f6676afe5b531839e24d701f6", size = 225347, upload-time = "2026-10-03T14:53:42.061Z" },
{ url = "https://files.pythonhosted.org/packages/1c/00/cf0e43292ae949b13f67535be84317102891d69fd1986ec2bf2ead42747b/websockets-17.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8da58558bfb0ca6ccac2419773521f1111e40654038b1afabdfc69c02cb82614", size = 226457, upload-time = "2026-10-03T14:53:43.575Z" },
{ url = "https://files.pythonhosted.org/packages/79/0d/9a5c61a18f0cc9876d94c70ccb3daf7614a9fee56abbb37c0e64e757fb96/websockets-17.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:01420cb1cb47433e8e7075d32cb8017ad3ffed0654bd1e48c0251b865920dec3", size = 224011, upload-time = "2026-10-03T14:53:45.077Z" },
{ url = "https://files.pythonhosted.org/packages/34/ed/991c1ab80ab2ce40e1c939fef6fa8f971c3ef3b21caf988a7a107e0ad27d/websockets-17.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:c49c9edd47d0e44d360299e2d8865e2950d2fcf1b4098782c9d7dcd070919e5a", size = 224990, upload-time = "2026-10-03T14:53:46.8Z" },
{ url = "https://files.pythonhosted.org/packages/e7/7a/363c835d17923e967fb66376188e67b9a261c85d826a0cd5e4dd3471221d/websockets-17.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:96f6c8d0fe21930d1f982bfce2382789d2e8d005d2ab63d21280660f95ef8fe1", size = 225265, upload-time = "2026-10-03T14:53:48.382Z" },
{ url = "https://files.pythonhosted.org/packages/c8/90/6c51f6d78636bd1cd6781fae8ea5ea7bf1d5b4059354f3c1f5f8de793338/websockets-17.2-cp312-cp312-win32.whl", hash = "sha256:b25659ab2d655d742701487d5591e3f98e8f8b329fc999e05e3d59691ab344a1", size = 218228, upload-time = "2026-10-03T14:53:49.867Z" },
{ url = "https://files.pythonhosted.org/packages/c6/2a/90008411c652dcfae34345a2169f4becd066a4ba71eebfa8dd801e0445e1/websockets-17.2-cp312-cp312-win_amd64.whl", hash = "sha256:faa763b677e96f1beccc6b4d7e8c079dfeed2f249f57a19debc321b519ee64ec", size = 218528, upload-time = "2026-10-03T14:53:51.486Z" },
{ url = "https://files.pythonhosted.org/packages/1f/a1/b8ad6c17f8e75ba2215422fffe0d7f0c4b690dcff1c47c0473db0d253d51/websockets-17.2-cp312-cp312-win_arm64.whl", hash = "sha256:63499fc49efe48bccc2fca40723bc7adb198866cbe159093dd979905316994b6", size = 218457, upload-time = "2026-10-03T14:53:52.938Z" },
{ url = "https://files.pythonhosted.org/packages/54/54/a935a32dbc2e7365b1b59eb74b5ab7515456f02370fdca4c4efc3574e96f/websockets-17.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:b24b83fbb34b2d8de06cf0f0d4bd7737344ef854482a614826d4356c0c3f0c12", size = 217752, upload-time = "2026-10-03T14:53:54.59Z" },
{ url = "https://files.pythonhosted.org/packages/cd/95/cb8881851abe2662730e6c61cc521b4c96513fdf9103a44f169afce2eba8/websockets-17.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8a829db795e3f87053904493d184b185c8eb1f497c852f434168ec856aa6f997", size = 215436, upload-time = "2026-10-03T14:53:56.034Z" },
{ url = "https://files.pythonhosted.org/packages/ca/1e/621bb93f35ab7d337be98f1958294437527e2a1797089b5e734ddc5eec5f/websockets-17.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cf8811d285acc91216368df7fb55cc8c9bf6fcd90eea42429c7186c7385a12b9", size = 215690, upload-time = "2026-10-03T14:53:57.587Z" },
{ url = "https://files.pythonhosted.org/packages/62/4a/49d0c983c082676d5d413b28e6ba5ae1d174c00268467bf78d9fe986a2d2/websockets-17.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:89c4898da776193577279173dcf9860487590611d7320d379435a145881b048d", size = 225080, upload-time = "2026-10-03T14:53:59.081Z" },
{ url = "https://files.pythonhosted.org/packages/04/13/95a45eb410019772002d8f53d81396dad4120f7df39ca9962f86f5d7cd01/websockets-17.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d87091c4347daadbcc0833b65812ff38d7350c67339625d4e4a512cf38e3e8ef", size = 225361, upload-time = "2026-10-03T14:54:00.61Z" },
{ url = "https://files.pythonhosted.org/packages/f8/fe/0f0eda80bb441f54becdaf793eb20ee080926f8d2356388377cf262187e5/websockets-17.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1110fbfd530c447380e6e6db88b7e43ffe33d54178f5b0ff0aaa5a280301e668", size = 226602, upload-time = "2026-10-03T14:54:02.098Z" },
{ url = "https://files.pythonhosted.org/packages/5c/36/067fc09d8e6f154abde7c2f747c52cc442a02c5eb14816f5c39cb9f8bcc6/websockets-17.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:83abd8beab056aa77a116364811f8fc262dffbcc7abea48de0c85ccbfc6f1428", size = 228035, upload-time = "2026-10-03T14:54:03.545Z" },
{ url = "https://files.pythonhosted.org/packages/4f/a2/939bade7a396b4c381aebbf3941969f124d0f98d56753f81cd256f3fc4d6/websockets-17.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:876da8ca5520d65b5d0f2ca6b4e7a00d35bb90ccda35cb2ce3cda4b6c711e84a", size = 227227, upload-time = "2026-10-03T14:54:05.045Z" },
{ url = "https://files.pythonhosted.org/packages/e5/8a/37b1033e21709dd7fa39239ea4d9cd7f348ad5bcba94eb47253878576f8a/websockets-17.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8462395df8f224d2daa3d80db3ae4450d9d4b7243c8483ac79a82862f1599dd6", size = 225985, upload-time = "2026-10-03T14:54:06.81Z" },
{ url = "https://files.pythonhosted.org/packages/a0/3a/0d89539900b06d86366facb7558198046de125ab8c371d9248d6262da70d/websockets-17.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6e9a04e69456015e6ae5e0d486d995137fd435794442122b00ce5f9526ea3ba8", size = 223226, upload-time = "2026-10-03T14:54:08.583Z" },
{ url = "https://files.pythonhosted.org/packages/31/9a/bfc5633e3d538d0a71cfbe7a5fee56c712e16c2dbd0ce17c83196a2a96a9/websockets-17.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8a2321bcb73758c44c8076509024d02c15ee484fe77ce04edea4bf4d257492cc", size = 226042, upload-time = "2026-10-03T14:54:10.254Z" },
{ url = "https://files.pythonhosted.org/packages/bb/1f/cbaf1786d8e3aeafe9d76951fc01139ec353b92555580336f23669382a55/websockets-17.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:8be4a87b3baca380ec3c7b1643b2dd268ac9d42c5097c0e8dc9a49342faf4774", size = 224639, upload-time = "2026-10-03T14:54:11.911Z" },
{ url = "https://files.pythonhosted.org/packages/80/49/175faa5bd169486f835602ac0ae6303318aa65693b79cdc72c5ee53b148d/websockets-17.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:eb7b737ce8d18c8a08beb68f751572b7bf6a18093ecd1406ca1256b50592552e", size = 225407, upload-time = "2026-10-03T14:54:13.489Z" },
{ url = "https://files.pythonhosted.org/packages/ac/d1/3662f612456cfb2dcc128c8e596f0a55fb7b695025e2ebe8ba2abb355c3b/websockets-17.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d6605630c2808b33f362d6d08582e79821f77ed2bd3f49f9d467ea70defea06d", size = 226513, upload-time = "2026-10-03T14:54:15.046Z" },
{ url = "https://files.pythonhosted.org/packages/73/6b/07af5177a49e30156b0922556fa93624a920a2b17d3e63bf4ad94668112c/websockets-17.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:dd9252828073fd0d69e7667af4275a1b17c18d0833b1ab7f59db272f194a6b9a", size = 224072, upload-time = "2026-10-03T14:54:16.574Z" },
{ url = "https://files.pythonhosted.org/packages/eb/34/d18054ff4d8314524164f8b8efec2cb17627287e099f122c28ed6fa598e0/websockets-17.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:06c7386128a9d85de4e1960114604f3031c084d2f4eee8db382637f1634cbab1", size = 225022, upload-time = "2026-10-03T14:54:18.143Z" },
{ url = "https://files.pythonhosted.org/packages/e9/12/75433caa3e9fa3e51d7751dc6bad24a86addf76cbfb51e52b11d037ba7fd/websockets-17.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:98f2d03df74977fd252831c997c388cd6c3f691a8a9d022b266d3cbd9849838f", size = 225303, upload-time = "2026-10-03T14:54:19.679Z" },
{ url = "https://files.pythonhosted.org/packages/6f/de/23e21c002aa2786ac9807c0876faa3b2576493b29ca3386287b0db46f021/websockets-17.2-cp313-cp313-win32.whl", hash = "sha256:5b43a1f7e4853ce08c3f6d3bf69799ee5b46548bfb71792a8158f7e45d66b547", size = 218219, upload-time = "2026-10-03T14:54:21.232Z" },
{ url = "https://files.pythonhosted.org/packages/13/eb/960411c0c574535d629c16e96a2b4e5353dbe4109df8ecea859e1b5245ee/websockets-17.2-cp313-cp313-win_amd64.whl", hash = "sha256:27c7a59b5352a8f741b422820adfe89dfe47c8f2d84fb32111e76111edaa0e83", size = 218531, upload-time = "2026-10-03T14:54:23.025Z" },
{ url = "https://files.pythonhosted.org/packages/a0/1a/3ac07bb52378952eff1d52d04a7ee6e82ce84e3da319a52a4739cd9c78f5/websockets-17.2-cp313-cp313-win_arm64.whl", hash = "sha256:533b7c82bb1eafbeb921dfe131c9f88e55451ddc328d84bde1c9340ba72d2808", size = 218466, upload-time = "2026-10-03T14:54:24.857Z" },
{ url = "https://files.pythonhosted.org/packages/8b/74/6bc991a28ac983600e65de408ebd1b1413d554ed0468ae5c831bc52dded6/websockets-17.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:ecb748910e9ba4624ebe2057791df51dcbffb48c37108ab94a3c593472023c9e", size = 217791, upload-time = "2026-10-03T14:54:26.381Z" },
{ url = "https://files.pythonhosted.org/packages/cb/2f/158e99426be6e71d09520bae53f29294fbb614b2fc5fbf8867b1d08395a7/websockets-17.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2ab9af5cb7265899e659f079eb71691375a1025b6d5fbd3caa495dd08f70833a", size = 215486, upload-time = "2026-10-03T14:54:27.962Z" },
{ url = "https://files.pythonhosted.org/packages/5c/09/1abf942723c0001d9c2fca1551907dade6304517b982b0bf10bba107fa81/websockets-17.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:06e46da092bca3a52e98f0458c66b247993ce501a07cd09c858be3296511ab7d", size = 215699, upload-time = "2026-10-03T14:54:29.523Z" },
{ url = "https://files.pythonhosted.org/packages/a7/1d/1ade03963ef497c47e6bad79e24370827b2fe6145fa8f58070ff2b7dcbac/websockets-17.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fcce735ffd72ac4056db05325d9f0232382b74826f0196eb6a15ca903abdaa0f", size = 225081, upload-time = "2026-10-03T14:54:31.278Z" },
{ url = "https://files.pythonhosted.org/packages/9f/fd/47b8a0361c49da939b976a07b27a72a9f893d01dfcf4d2a28b53419ce1ef/websockets-17.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:42cbca10f82a8b2fb1536e8a0830ca6ceeb6bb3d8d64b766e0795369135654a8", size = 225430, upload-time = "2026-10-03T14:54:32.917Z" },
{ url = "https://files.pythonhosted.org/packages/f0/26/f4d4c76264ee037c5556ab5f50fcba302746dabf7528955534e4dda9965e/websockets-17.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c63ff5a21f26bd0e6a8464b53fadbe174825c8718ac14180df45665eaacdb6af", size = 226676, upload-time = "2026-10-03T14:54:34.833Z" },
{ url = "https://files.pythonhosted.org/packages/37/b3/c8b1c981322a050c4babfd327ffc9880f9c3834f5b15d2574e37eeb8768c/websockets-17.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:63f543463601c1558b755f8dd7618b6ec3dd0934dda051d3b7030d8c76e54de2", size = 228048, upload-time = "2026-10-03T14:54:36.424Z" },
{ url = "https://files.pythonhosted.org/packages/f0/5a/1cb29ddb23e6bc27ffd1c5316cd3616360d1ba0c3854eaa134ee3207bd28/websockets-17.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4c32eb565ad9ce8a6444248e5b7a19dbb86a81c811fe5fcc2fba7a735aed5163", size = 227281, upload-time = "2026-10-03T14:54:38.01Z" },
{ url = "https://files.pythonhosted.org/packages/ba/64/135274572dc0c845fc1111e2b932c807c395daac75d6eae6cfa148d8a208/websockets-17.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5d459bbb6c22f26dcebea56924a362aba50d453b9867912862c970434fcf0d94", size = 226025, upload-time = "2026-10-03T14:54:39.613Z" },
{ url = "https://files.pythonhosted.org/packages/58/75/f1e386aec3124489411caf5138cdd5a2bc43d3fd4a681c69adcf5f6272a5/websockets-17.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f19ca1a21871f024e38faf4107b433047df27558dff1b72a1dac31481e2c1fe5", size = 223277, upload-time = "2026-10-03T14:54:41.165Z" },
{ url = "https://files.pythonhosted.org/packages/60/eb/24733a0f568c2eb99e60f9faa620a98fb228c06a01e7e2f348b33290ed9c/websockets-17.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c76b4bcbf0f713194591673fc86a42820e14da6bbd1bb445d3d002cc4d1e4521", size = 226148, upload-time = "2026-10-03T14:54:42.779Z" },
{ url = "https://files.pythonhosted.org/packages/55/6d/ea66a30af74f5983cae31ebb9ef78b178b366a12856a414e1472225c4a34/websockets-17.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:30201a7f69833b015556c72feb69ea501b645986fd0b90dab13f589e995ff428", size = 224615, upload-time = "2026-10-03T14:54:44.41Z" },
{ url = "https://files.pythonhosted.org/packages/87/80/c6f2228ad89774429d270179375ebddb657119215f52d1df7c680d65cad7/websockets-17.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0c8600aec354cc259f1691b0b42816f04a9886a953f82cb227246df76057f97a", size = 225398, upload-time = "2026-10-03T14:54:46.063Z" },
{ url = "https://files.pythonhosted.org/packages/f7/4a/3d8da19732ad468d4be7f1e3ac298078b60bdda55edde6589bef84a5eb7e/websockets-17.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:307fc22ea496be8542d67b82ae8c867a978dfd19ac35573d4f15943fd9277dfe", size = 226571, upload-time = "2026-10-03T14:54:47.672Z" },
{ url = "https://files.pythonhosted.org/packages/58/22/1231657122d9cc24791bb90af13cc2f4e84cf0d3a454cb37e3abfdcb2fd9/websockets-17.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:9c88697fa943bd4ef67cc919a17d81de6581846f52bfa8c6f64a916098986556", size = 224125, upload-time = "2026-10-03T14:54:49.537Z" },
{ url = "https://files.pythonhosted.org/packages/1a/04/350ca2445da758bc42cdb4218b44d4ce0d5a9c1d5e4cc4a58d64348ad9da/websockets-17.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f7eac84d4969da82166d5e90d9c38d2f416fe24f9708a7013569b193745b9a31", size = 225081, upload-time = "2026-10-03T14:54:51.075Z" },
{ url = "https://files.pythonhosted.org/packages/da/c4/dec952b0df3a5d918ed2a545abb0c25ae519c3bc2d9aba3b7c46abae8f05/websockets-17.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:313f6703023d53baabab6d6c5c37cf637b2c4fee255acf2ed5e92ad69e28f1b7", size = 225376, upload-time = "2026-10-03T14:54:52.675Z" },
{ url = "https://files.pythonhosted.org/packages/f2/b4/198a260afbcc086ff4979774e51834ed7fb5b95f9ef305e0c4924630b857/websockets-17.2-cp314-cp314-win32.whl", hash = "sha256:08d90cf344bdb971ba3a826b78d4da9bfd56cc6a97a604d9b88cbd40bfa6c735", size = 217760, upload-time = "2026-10-03T14:54:54.247Z" },
{ url = "https://files.pythonhosted.org/packages/e5/9e/0523f8bc2f7aaddf39562d4fa01b4d38fa61b23d980917a16d2dd19c8dac/websockets-17.2-cp314-cp314-win_amd64.whl", hash = "sha256:dac93bf7a9beb215be3282b8441173cd50806c41c007b8be9bb24e03c60ad563", size = 218104, upload-time = "2026-10-03T14:54:55.845Z" },
{ url = "https://files.pythonhosted.org/packages/55/17/7b8bb4cb64a199e7082f1f9be784d657842fefc327ac777d6c1493504804/websockets-17.2-cp314-cp314-win_arm64.whl", hash = "sha256:2ab742249f953d148a9ba696c8b9944361e8cb92e8bc61ba2dd53a178403afd3", size = 217989, upload-time = "2026-10-03T14:54:57.376Z" },
{ url = "https://files.pythonhosted.org/packages/ee/76/f54ed054b6e860f1e0bbc7019542a048352d41231fdff6d904b379f881c7/websockets-17.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a69ce25be5f1330ee1c74eb6fabbbceaa96b384beedd2627cecded7546490c40", size = 218125, upload-time = "2026-10-03T14:54:58.943Z" },
{ url = "https://files.pythonhosted.org/packages/e6/4c/0f3375cea66a125ae01d21fb9c537aae955ef499bfe7e2b2376a34362f2a/websockets-17.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8e24b878cf54843a63985d90480f163ca7f692689fbcbe9cdbd8165521083a8b", size = 215658, upload-time = "2026-10-03T14:55:00.674Z" },
{ url = "https://files.pythonhosted.org/packages/0c/05/7c871a67bfb4b61adc1fe13583db97803f87dfeca644fe6ef51df7bb276d/websockets-17.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f33c7908a6885dcae9f462a4a8347b637053b4ff2b96beb4c23fba1cf7818e5f", size = 215858, upload-time = "2026-10-03T14:55:02.379Z" },
{ url = "https://files.pythonhosted.org/packages/41/8e/59df4d9cd357e902d1c74b13c3c0c3841c8df6e4b1b3d131bf26a23fdcb1/websockets-17.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c796a1bb3e4015249639849f30e8e680df8a431b45d417ba8acf843d2451d95f", size = 225443, upload-time = "2026-10-03T14:55:03.966Z" },
{ url = "https://files.pythonhosted.org/packages/5c/64/5e486a3a44e041203c62eccf1fc89c7f8824e21104a7b82b182e5b21c228/websockets-17.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:983bcdc898662f6ba9d6a025c30d29946ff0986d9ad60d400af0da3671f7cbf3", size = 225726, upload-time = "2026-10-03T14:55:05.797Z" },
{ url = "https://files.pythonhosted.org/packages/f0/98/b6eb53121c91fbe8b6897aba06861ce60f9ab58faffc6bca5750cbc21681/websockets-17.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:35e0f088ddfd9d9bc5019e27ff3767411779e92b59db5bb1507f2731a5b61158", size = 226895, upload-time = "2026-10-03T14:55:07.626Z" },
{ url = "https://files.pythonhosted.org/packages/8a/18/8c091321b99c91eb3eaec9acbd940e69308b4e465b5605c430af0cf7d3a5/websockets-17.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:19e2511412ad3393191de652513bc7a0ca3c93af143b32d96d46e59fbbddf1d4", size = 229040, upload-time = "2026-10-03T14:55:09.321Z" },
{ url = "https://files.pythonhosted.org/packages/1a/96/3a92f944305b7de42fcb7530b9fa69607b4b4ce993c36a9f2330dbc318ba/websockets-17.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb5e2bf969ac99a6ae3c71208a5eb05cfde973192540ffa6e1068b57fb78c4f8", size = 227469, upload-time = "2026-10-03T14:55:10.935Z" },
{ url = "https://files.pythonhosted.org/packages/ea/a9/624f6d75ba326c22d03698b34c0ada984f1d76196322a62f6c22903b831d/websockets-17.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:691780fca2be3dec512cb603cb91060271968cb4af86b51d07c57445c5754a37", size = 226202, upload-time = "2026-10-03T14:55:12.536Z" },
{ url = "https://files.pythonhosted.org/packages/47/af/1e6e8c625aeb268830af2c4227fe05e8db59f4f4debe1dadfd0ada214895/websockets-17.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2d39c19b1ba6a6791050383fd69efdd3b63533e2254693d0263879cd5f5921ba", size = 223743, upload-time = "2026-10-03T14:55:14.164Z" },
{ url = "https://files.pythonhosted.org/packages/dd/81/33c5280f4f6f81637c93ae065c6a594dfe35935622af135a5f7c3768bf22/websockets-17.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e48ac2b302986c6f55cf61e8e36b4dd97d0132c5078a713a697a940934ba422e", size = 226492, upload-time = "2026-10-03T14:55:15.796Z" },
{ url = "https://files.pythonhosted.org/packages/1d/f3/7aa9fc36e67caccbcfee2c48f4ada41e9da512d41523c024d039f0f22ba3/websockets-17.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:e136197f1262620ef2e507afc3ea759c1ae7d221886da20eec5f4c9f2618c2aa", size = 224940, upload-time = "2026-10-03T14:55:17.661Z" },
{ url = "https://files.pythonhosted.org/packages/3f/8c/457aff7081a63d1261608bb4d7b0b0f9dfe780697a2a334671745742850b/websockets-17.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3eb44019a2b0b3b91bac95998f1e4e5589730421170e060fe654a2b7be727dc7", size = 225835, upload-time = "2026-10-03T14:55:19.607Z" },
{ url = "https://files.pythonhosted.org/packages/3e/c3/7a13a3b3050db2c36772ded49f8d48f99eb080948e9f6f762e7529925ab5/websockets-17.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e5855e574804398859c5fbaf4fc7882b96278b7f6572a3d889627e6eb6cfca59", size = 226848, upload-time = "2026-10-03T14:55:21.274Z" },
{ url = "https://files.pythonhosted.org/packages/c4/3e/d5b2c1e473b1031a4a0ec0e10de69df5b981ab4a10aa482bb45c18dd43f5/websockets-17.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5dc29815520c329f5662f6eb3ebadecf0d4f8c82dfa416d4d6efbf8f39245559", size = 224541, upload-time = "2026-10-03T14:55:22.874Z" },
{ url = "https://files.pythonhosted.org/packages/79/5d/bb81976cc1aa546afb51395ce42913521e9dea062bb34a61308cfff30726/websockets-17.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d1a4f9462da6496b6cb79bbb09c60d17f7e63e8a1df136797b3afabec9560e4d", size = 225315, upload-time = "2026-10-03T14:55:24.443Z" },
{ url = "https://files.pythonhosted.org/packages/f4/6b/314962d5440c61b4c107914599c13ceeecc6bdb6e2e73a5f7e566a7d1f26/websockets-17.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9496bff5541086478264678bac73c0a75b2fde94fdf6568893bca1f7c6d50d18", size = 225747, upload-time = "2026-10-03T14:55:26.033Z" },
{ url = "https://files.pythonhosted.org/packages/98/fc/9eb64b34a3a4458eb08f3f24bde01508f72a00790330723c158ebb965048/websockets-17.2-cp314-cp314t-win32.whl", hash = "sha256:e1e3bc8090a7eae79fdf634b63bdbfa3c93999991023c37c6fd3b469fc8ff5dc", size = 217891, upload-time = "2026-10-03T14:55:27.681Z" },
{ url = "https://files.pythonhosted.org/packages/ba/ed/3a4e2a09b0822d6e525cbc6e44a4885669bad5b22ab9c64fa2444bc15325/websockets-17.2-cp314-cp314t-win_amd64.whl", hash = "sha256:65a89a5bde227bfe908016f35b5bd347970cd1e5b0360f389502eba1c7fde6e0", size = 218229, upload-time = "2026-10-03T14:55:29.314Z" },
{ url = "https://files.pythonhosted.org/packages/b5/66/cffb75ee746dd060984c3c3e2eac7f875a866225a30dfa53e2cd18232565/websockets-17.2-cp314-cp314t-win_arm64.whl", hash = "sha256:1c27339934109dfaca83f18ab2c23db06714e9d5deca2c8e37e8f492ab90d20b", size = 218146, upload-time = "2026-10-03T14:55:31.001Z" },
{ url = "https://files.pythonhosted.org/packages/12/e9/10a9b1633b63594054c87b97af048628cea2b21b5089a52a9fc1e0af60a3/websockets-17.2-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:a7c4bb26de6ef496d24822aee4f6a305d97cd33d21a2b85f290292d69ba1c25e", size = 217719, upload-time = "2026-10-03T14:55:32.674Z" },
{ url = "https://files.pythonhosted.org/packages/0c/00/ff4020fe0886dac7199a16ce2805c7afd7b981bd2e81d3fa18dff5d9863a/websockets-17.2-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:c08da1f15040bd1e1a6074bd4518a6ef20e67b1594ecfb0aa75e5b45f87e6d6d", size = 215448, upload-time = "2026-10-03T14:55:34.338Z" },
{ url = "https://files.pythonhosted.org/packages/66/06/bc7b944f81514378b2c2ab96c17df19e871cd33b9be0f1f6dfc975457e5e/websockets-17.2-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:3117abfd32b183bdb6194df9317766d32c6517f3d1c0aa8c62d5c6ccfda0b4a8", size = 215674, upload-time = "2026-10-03T14:55:35.918Z" },
{ url = "https://files.pythonhosted.org/packages/a8/da/2b2b76faa2f10c4813e3872c9577fd13a798f5918b1785b86ff7d635eb2a/websockets-17.2-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a046227daa7f191e843d26b911c1146233e9a33d249e0c954dcb3ac7c398710e", size = 225119, upload-time = "2026-10-03T14:55:37.777Z" },
{ url = "https://files.pythonhosted.org/packages/ae/d4/22cbe288c0d5cef7620503be92c0098d82220353fc7e188034a19c517240/websockets-17.2-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2901bdf24f20bc884124b3e88c61f7ece260c20c81e610f2196007395264a4aa", size = 225549, upload-time = "2026-10-03T14:55:39.364Z" },
{ url = "https://files.pythonhosted.org/packages/4c/0a/504b0d3063679f2c60430c3539482d42a4cb8bd1a76646baf742030a93cc/websockets-17.2-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f60e39adfecf998488166aca8ff24ab1ac406c9ecbecbcf9b3bcfc43cb1ec9a1", size = 226717, upload-time = "2026-10-03T14:55:40.942Z" },
{ url = "https://files.pythonhosted.org/packages/4e/ea/5da9309cc55c2665a6eebc22c369d9918c0d77258c61e92058e6b08d5ff1/websockets-17.2-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d4df62fd8448a85c752bbea1803cb3a2785e6fc8352009ab64ad7447af079b3c", size = 228413, upload-time = "2026-10-03T14:55:42.54Z" },
{ url = "https://files.pythonhosted.org/packages/a6/74/5a24df72aa5500f311105687af864c27f1f9da910e968e97818c6149e6b0/websockets-17.2-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c8eea55fdfa9ba65c6981eea38bd20c800bce2f092a2803d82de764ecf0f071a", size = 227196, upload-time = "2026-10-03T14:55:44.251Z" },
{ url = "https://files.pythonhosted.org/packages/5e/ee/ca32cc1ed892dc4ac30a922e8f648048233fbdb8b0bce7048860ec4c60ec/websockets-17.2-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3f0def1279644acaa9bc861d4234af3f82ea9cee7e460dffac5cb63e691501e9", size = 226092, upload-time = "2026-10-03T14:55:45.842Z" },
{ url = "https://files.pythonhosted.org/packages/7d/0c/12d4a73324aa9798d5165d20c088f9dba66c75c871960e5d921ec66694e4/websockets-17.2-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fb78fb4158c12f77a934a003006784108a27a6553cfc0c6f10483c9c02e94f48", size = 223486, upload-time = "2026-10-03T14:55:47.45Z" },
{ url = "https://files.pythonhosted.org/packages/bc/a4/7fe15da5abb8f0f61e6a357593f7f2ed55724825b7db0ffe72b5c5fad68d/websockets-17.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:f8969ad228115ad8869b5fed801f899e52ab8ad376fdb165ba4760a277c8258a", size = 226200, upload-time = "2026-10-03T14:55:49.126Z" },
{ url = "https://files.pythonhosted.org/packages/08/b9/4cd3a311f96a2eea0ed458bc01fe2cce42f9cd50aa9e64315dfc855d63a9/websockets-17.2-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:4a49ca342efc0800e6ae94ed5c9cbdcb319308f75e73c21181e4c24d6710e8dd", size = 224862, upload-time = "2026-10-03T14:55:50.674Z" },
{ url = "https://files.pythonhosted.org/packages/41/b5/22caa3460f75e42bfcc74028870b556d22847ea9a9034aa03986f07f16a9/websockets-17.2-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:06fa3ce9c3154826c33d4395b225b2994aa64f1f3bcd8be8ed932019175d9268", size = 225391, upload-time = "2026-10-03T14:55:52.393Z" },
{ url = "https://files.pythonhosted.org/packages/95/be/8d28f92092076abf1ddfb3206b0ce956120a22e7c3105f6a3029d727deae/websockets-17.2-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:50644d8715be7e0ec0682f9d7744b63008e199c5e1618a48fa153756a332235f", size = 226545, upload-time = "2026-10-03T14:55:54.127Z" },
{ url = "https://files.pythonhosted.org/packages/cb/7b/ff943fa383e540fe17f066cc10a3eeedef26e50fd45aae2bdc6746d6f95a/websockets-17.2-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:60deca33e584c09e91f70f8b55a0b1de7d671d6a63f051d154920f48bed717c7", size = 224352, upload-time = "2026-10-03T14:55:55.856Z" },
{ url = "https://files.pythonhosted.org/packages/e9/df/1e6c3e06c473c9fd833a5c1620b15e2c3b37647b91b7d41871d20bc098de/websockets-17.2-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:b5f79366a8d8dbb981d53ba800bb54a95454595ab8a4548c2b95501b32a08326", size = 225255, upload-time = "2026-10-03T14:55:57.497Z" },
{ url = "https://files.pythonhosted.org/packages/db/f8/d8a4f988f7cbb568d8bd69da4632c5b6010aa9cd9366f285e23b73b678d9/websockets-17.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f2bbf3f28d0b63157577c8b774b9136f076afa6797e1a52a2ecd477f23cad3a8", size = 225513, upload-time = "2026-10-03T14:55:59.338Z" },
{ url = "https://files.pythonhosted.org/packages/75/e0/920357165b2797a2530fc9e271d79a9b5fee2b750b154c990c740f767af3/websockets-17.2-cp315-cp315-win32.whl", hash = "sha256:74836317b7010b579522bb52426f1e225608b042c9e78cbe2493522bebb8a318", size = 217722, upload-time = "2026-10-03T14:56:01.307Z" },
{ url = "https://files.pythonhosted.org/packages/5f/eb/25bdca25bbc329ffb330ef33993397d6556a871e40a0d196e757699ea3f7/websockets-17.2-cp315-cp315-win_amd64.whl", hash = "sha256:aaead3d926e9ab4124ada727d20cd62d396649917822df4f771d1f07f1079b40", size = 218017, upload-time = "2026-10-03T14:56:02.914Z" },
{ url = "https://files.pythonhosted.org/packages/fa/cb/ea30a552bbcd1c75f0d14bfce6c884ee36187030b85b74a242aacc02406e/websockets-17.2-cp315-cp315-win_arm64.whl", hash = "sha256:40960554e60eb60c3eec4ff9e42a80f84f8cd3ca9bc80a5481a61f1e64d807c9", size = 217929, upload-time = "2026-10-03T14:56:04.604Z" },
{ url = "https://files.pythonhosted.org/packages/4a/01/477664c619af8aa3c908d482e2a95e13ceed9d78f21d15902013c3bc6c28/websockets-17.2-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:9a2a60a7f0ea5f239efb6391d2b28630a640d82dad63e3bee47cf2c623c4495d", size = 218029, upload-time = "2026-10-03T14:56:06.336Z" },
{ url = "https://files.pythonhosted.org/packages/2a/a9/b0be62ff1c0e2bc966da56b36d3d820c7e2ad3c0c4a4ac414fc7335b214f/websockets-17.2-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:cca2fcb72c007103740fa4fc3df19fdb1a318c641c69f3b0cc47ed63a889336e", size = 215607, upload-time = "2026-10-03T14:56:08.035Z" },
{ url = "https://files.pythonhosted.org/packages/fc/2b/a6738530de0437a31c1b168e4096ecf790aafaf561f33a009886c7d8042e/websockets-17.2-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:b789356bc4e2e6c20ba52817f92c3fed74e24657654237ecd536c54843b80c6c", size = 215817, upload-time = "2026-10-03T14:56:09.852Z" },
{ url = "https://files.pythonhosted.org/packages/c3/c2/2fc44ddc419cbb09ee1708af3e78d8a4b018db01fc7e4f91bd730e2f8d9e/websockets-17.2-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:222fb626fa15701a850eccc778be17312142b2f6a0e16aea80770b7459adb784", size = 225979, upload-time = "2026-10-03T14:56:11.85Z" },
{ url = "https://files.pythonhosted.org/packages/2e/91/a215b14caa7ea65bc36db81609108899c259503300d1560dae9c70a135e7/websockets-17.2-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4497e87c34a2d21cbec1227858fec3af8e514dd70c47625557a122fcebc081dc", size = 226250, upload-time = "2026-10-03T14:56:13.548Z" },
{ url = "https://files.pythonhosted.org/packages/65/b9/9406a18e9edf558ed504d2a7679371d0f8107e4ef526c80b154ea4ec9752/websockets-17.2-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6281c171557ce0e408e19d9a223f22d915117ac38a5a7f32ed83809e7492316c", size = 227579, upload-time = "2026-10-03T14:56:15.143Z" },
{ url = "https://files.pythonhosted.org/packages/fe/45/a73af119244f46f5130005d7ab63f1c75890c890141a0ca2adc9d97d4671/websockets-17.2-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:08d97098644728bd1895caa7ecf3090b8e563d70809870d2adb33a107bd061d0", size = 229205, upload-time = "2026-10-03T14:56:17.086Z" },
{ url = "https://files.pythonhosted.org/packages/c1/92/ccd8e2e921d134a56f1ed4642d276500d9e33b3dc4d6deb63d614b3e53a6/websockets-17.2-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1fdb8d5a1660307dc6d36d0b7fc725213cbd7f80800904dc4896aa3208b89121", size = 228011, upload-time = "2026-10-03T14:56:18.716Z" },
{ url = "https://files.pythonhosted.org/packages/e0/ef/7d71105d19a7aaab5ff87b9c712f6c1dda44e72ea56aa0e7b777f2fc274b/websockets-17.2-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:18b0a46e5e9b315e2b54ce8c3bafdeef0e1388ca363114fa868e6aab2dc58512", size = 226892, upload-time = "2026-10-03T14:56:20.412Z" },
{ url = "https://files.pythonhosted.org/packages/56/f7/87012d628b21e66e699440f39bfa7cc55fae7f52b2c532ab62184a589624/websockets-17.2-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7f115d5d804a2163dd89245710049078b0e726a58c1f44a1f86c2c6e79055d76", size = 224241, upload-time = "2026-10-03T14:56:22.257Z" },
{ url = "https://files.pythonhosted.org/packages/55/f5/495371068b27ee5f7c435187f9dafd62402f195e2c76063bdd4653da1565/websockets-17.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:1d829946a2e7630f92f9d7b45b62f3abe9f393cc2dea6a35edb3988f865e75f2", size = 227076, upload-time = "2026-10-03T14:56:23.909Z" },
{ url = "https://files.pythonhosted.org/packages/18/18/3dce3cc6099be5e044e0fd5d0e0c9931c8e3387511cdec8014a345f619e5/websockets-17.2-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:6c274fc1572edf7c197094a0eb1887d45fdc95254bc80597dc7599550486c06a", size = 225727, upload-time = "2026-10-03T14:56:25.689Z" },
{ url = "https://files.pythonhosted.org/packages/47/30/57d0c7aaf8d4473926fa8829b8136483f561388d1e747ae71c9f2a83d5fd/websockets-17.2-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:4173a4b8a025ae44313d9d9b4ecf31e886c7b7faf45386d51a8ca4ff2dcf3f2a", size = 226225, upload-time = "2026-10-03T14:56:27.246Z" },
{ url = "https://files.pythonhosted.org/packages/0c/9f/9dce1203756756c00b407b9a6b13a7500fcd38f2634d4daa3f65575814ec/websockets-17.2-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:d8cfe9522ad69b6abb26b413ed1deca43cb915cefc588433d557cb3ae1c783e2", size = 227333, upload-time = "2026-10-03T14:56:28.811Z" },
{ url = "https://files.pythonhosted.org/packages/9a/2f/d3b6b876678ebb03017b7afd7111fe44d54b93f036a80ebb4b481dd1ab74/websockets-17.2-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:908d81d88bb16141613a6275059b5114656d5c2f0b5400b421d54fe6f1943507", size = 225082, upload-time = "2026-10-03T14:56:30.578Z" },
{ url = "https://files.pythonhosted.org/packages/32/b0/a69b573a5e56d2e7a5dcbb447466f442380cf81515e1cb1220cd626c8042/websockets-17.2-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:c6590e1eb624ff6b15b872421bc9a10bc6d2057635d69c6cd244ac3f928f85c6", size = 225945, upload-time = "2026-10-03T14:56:32.32Z" },
{ url = "https://files.pythonhosted.org/packages/70/be/a72911dc8e33f74c196012366ce4d99b1a803894a377a1ed0c8e66df9caa/websockets-17.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:61040f6f7da5a279d2f77496c69d51132aba75f701c52bded400d4c639277b18", size = 226241, upload-time = "2026-10-03T14:56:34.142Z" },
{ url = "https://files.pythonhosted.org/packages/7d/a9/02a68c1d8e5572918e0962d3aad881078f73ede43abd9b1336e4efaa8909/websockets-17.2-cp315-cp315t-win32.whl", hash = "sha256:f90bad2839c185a1edf8ee22a257cfc8a39e0e337a0490ab185dfa76ef04d1bd", size = 217847, upload-time = "2026-10-03T14:56:36.204Z" },
{ url = "https://files.pythonhosted.org/packages/2b/bf/3d7c33b8d5e7712a60e0149c017ed50394ec5e8cf72e5cb6a1ffaf11a42d/websockets-17.2-cp315-cp315t-win_amd64.whl", hash = "sha256:315551f4ccedbbf9fd4f7e8bf037a5948c976ade0e919ba5d8f581d465f6f725", size = 218169, upload-time = "2026-10-03T14:56:37.79Z" },
{ url = "https://files.pythonhosted.org/packages/27/57/ab34cc6460c5322e6932750fa5c6c64be89e6ee4e2707d13c4e9d3312b25/websockets-17.2-cp315-cp315t-win_arm64.whl", hash = "sha256:0a6220bdf8d5f11af71251a599092d89ac1d6bfac691c7f5951c5b07953947a0", size = 218089, upload-time = "2026-10-03T14:56:39.427Z" },
{ url = "https://files.pythonhosted.org/packages/8a/58/835cd51934d6780fa586f275b5d9901eead6d81569b4343b3767cdbaae4c/websockets-17.2-py3-none-any.whl", hash = "sha256:6aa59f0ef92e796b2db6f5f26550c4713c0e4036899fadf02f55e2ed4db0b7ae", size = 211883, upload-time = "2026-10-03T14:56:51.898Z" },
]