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:
@@ -15,7 +15,10 @@
|
||||
(`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`,
|
||||
`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
|
||||
implemented straight from chat.
|
||||
- **Never** modify `.agents/PLAN.md` or anything under `.agents/phases/complete/`.
|
||||
@@ -60,16 +63,52 @@
|
||||
|
||||
## 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`
|
||||
fails the build on `http://`/`https://`/`//cdn` references in `app/static/` and
|
||||
templates. CSP is `default-src 'self'` (plus `ws:`/`wss:` same-origin).
|
||||
fails the build on `http://`/`https://`/`//host` references, `@import url(`,
|
||||
`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
|
||||
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.
|
||||
Never trust inbound coordinates, scores, timers, or level ids — validate every
|
||||
inbound field against the room's state (`server/protocol.py`).
|
||||
- **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.
|
||||
- **Frame and rate limits are enforced, not suggested**: `max_message_bytes`,
|
||||
`max_inputs_per_s`, room caps, and message-type whitelists per room state.
|
||||
@@ -92,14 +131,28 @@
|
||||
|
||||
| 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/` |
|
||||
| Level/recipe content | `levels/*.yaml` |
|
||||
| Rooms, lobby lifecycle, session records | `src/undercooked/server/rooms.py`, `room.py` |
|
||||
| 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/` |
|
||||
| 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` |
|
||||
|
||||
Tests mirror that: `tests/unit/`, `tests/server/` (HTTP routes and stores),
|
||||
`tests/integration/` (live uvicorn + WebSocket clients), `tests/sim/` (headless
|
||||
determinism and replays), `tests/e2e/` (Playwright, named `test_usNN_*.py`, marked `e2e`).
|
||||
Tests mirror that: `tests/unit/` (pure, no-I/O logic incl. HTTP-route unit tests),
|
||||
`tests/integration/` (in-process app boot, live uvicorn + WebSocket clients), `tests/sim/`
|
||||
(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
@@ -16,7 +16,8 @@ ENV PYTHONUNBUFFERED=1 \
|
||||
DEBUGPY=0 \
|
||||
UNDERCOOKED_HOST=0.0.0.0 \
|
||||
UNDERCOOKED_PORT=8000 \
|
||||
UNDERCOOKED_LEVELS_DIR=/app/levels
|
||||
UNDERCOOKED_LEVELS_DIR=/app/levels \
|
||||
UNDERCOOKED_STATIC_DIR=/app/app/static
|
||||
WORKDIR /app
|
||||
|
||||
RUN useradd --create-home --uid 10001 player
|
||||
|
||||
@@ -10,10 +10,40 @@ email, no password: you create a room, share the 5-character code, play.
|
||||
|
||||
## Status
|
||||
|
||||
Roadmap authored, implementation pending. This repository currently contains the
|
||||
project scaffold and the `.agents/` phased-execution plan. Execute phases with the
|
||||
`phased-execution` skill (`auto-phase.sh`), starting at
|
||||
`.agents/phases/todo/01_runnable_service_scaffold/`.
|
||||
Phase 01 (runnable service scaffold) is **complete**; the next pending phase is
|
||||
`02_level_content_and_validation`. What runs today: the Python toolchain is locked
|
||||
(`uv.lock`), the pytest harness covers unit, integration and Playwright e2e layers,
|
||||
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)
|
||||
|
||||
@@ -22,9 +52,24 @@ for container builds. **There is no database** — all live game state is in mem
|
||||
|
||||
```bash
|
||||
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-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:
|
||||
@@ -32,12 +77,25 @@ Run the checks:
|
||||
```bash
|
||||
uv run ruff check .
|
||||
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 pytest -m e2e # browser suites (app must be running)
|
||||
bash .agents/validate.sh # the full pipeline gate
|
||||
uv run pytest -m e2e # browser suites only
|
||||
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
|
||||
|
||||
```bash
|
||||
@@ -49,23 +107,31 @@ podman compose up -d
|
||||
|
||||
## Configuration
|
||||
|
||||
All settings come from the environment (a local `.env` is loaded automatically;
|
||||
never commit it — see `.env.example`).
|
||||
All settings come from the environment, parsed once into
|
||||
`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 |
|
||||
|---|---|---|
|
||||
| `UNDERCOOKED_HOST` | `127.0.0.1` | Bind address |
|
||||
| `UNDERCOOKED_PORT` | `8000` | Bind port |
|
||||
| `UNDERCOOKED_TICK_HZ` | `20` | Server simulation rate |
|
||||
| `UNDERCOOKED_SNAPSHOT_HZ` | `20` | Snapshot broadcast rate to players |
|
||||
| `UNDERCOOKED_MAX_PLAYERS` | `4` | Players per room |
|
||||
| `UNDERCOOKED_TICK_HZ` | `20` | Server simulation rate (5-60) |
|
||||
| `UNDERCOOKED_SNAPSHOT_HZ` | `20` | Snapshot broadcast rate to players (<= tick rate) |
|
||||
| `UNDERCOOKED_MAX_PLAYERS` | `4` | Players per room (1-4) |
|
||||
| `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_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_INPUTS_PER_S` | `40` | Per-connection inbound message rate limit |
|
||||
| `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_PORT` | `5678` | debugpy listen port |
|
||||
|
||||
@@ -73,15 +139,26 @@ never commit it — see `.env.example`).
|
||||
|
||||
```
|
||||
src/undercooked/ server: FastAPI app, rooms, WebSocket protocol, simulation
|
||||
levels/ data-driven level definitions (YAML) + recipes
|
||||
app/static/ browser client: ES modules, Canvas 2D renderer, own SVG art
|
||||
tests/ unit/, server/, integration/, sim/, e2e/ (one Playwright suite per story)
|
||||
docs/PROTOCOL.md wire protocol contract (versioned)
|
||||
.agents/ PLAN.md, user_stories/, phases/ — the phased-execution plan
|
||||
src/undercooked/__main__.py the `undercooked` command (CLI > env > default)
|
||||
src/undercooked/config.py typed env settings (the only `os.environ` reader)
|
||||
src/undercooked/debug.py DEBUGPY-gated debugger bootstrap
|
||||
src/undercooked/server/app.py app factory, `/healthz`, `/`, `/static`
|
||||
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
|
||||
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
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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–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™ with its own art, code and content. Every byte
|
||||
you see is served by this one process — no third-party requests, no trackers.
|
||||
</p>
|
||||
</footer>
|
||||
|
||||
<script type="module" src="/static/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ services:
|
||||
UNDERCOOKED_MAX_ROOMS: "200"
|
||||
UNDERCOOKED_MAX_PLAYERS: "4"
|
||||
UNDERCOOKED_LEVELS_DIR: /app/levels
|
||||
UNDERCOOKED_STATIC_DIR: /app/app/static
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test:
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,2 @@
|
||||
__all__: list[str] = []
|
||||
__version__ = "0.1.0"
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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}")
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
Playwright browser suites, one per user story (`test_usNN_*.py`, marker `e2e`).
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
Vendored
@@ -0,0 +1 @@
|
||||
In-process integration tests: FastAPI app boot, HTTP routes, rooms, and the WebSocket protocol.
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
Headless determinism and replay tests against the pure simulation kernel (`sim/`).
|
||||
@@ -0,0 +1 @@
|
||||
Pure, fast unit tests: config parsing, helpers, and other no-I/O logic.
|
||||
@@ -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")
|
||||
@@ -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"
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)) == []
|
||||
@@ -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" },
|
||||
]
|
||||
Reference in New Issue
Block a user