diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e15c987 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +.git/ +.github/ +.idea/ +.venv/ +__pycache__/ +*.py[cod] +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.coverage +htmlcov/ +tests/ +data/ +*.md +!README.md diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..6a5e2fe --- /dev/null +++ b/.editorconfig @@ -0,0 +1,47 @@ +# EditorConfig — https://editorconfig.org +# Top-level config; all editor plugins must stop traversing here. +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 4 +insert_final_newline = true +trim_trailing_whitespace = true +max_line_length = 100 + +# Python — ruff is the source of truth for formatting and import order +[*.py] +indent_size = 4 +ij_formatter_enabled = false + +# Data and config formats +[*.{json,yml,yaml}] +indent_size = 2 +ij_formatter_enabled = false + +[*.toml] +indent_size = 4 + +# Markdown — preserve trailing whitespace for hard line breaks +[*.md] +indent_size = 2 +trim_trailing_whitespace = false +max_line_length = off +ij_formatter_enabled = false + +# Makefiles require tabs +[Makefile] +indent_style = tab + +# Dockerfiles +[{Dockerfile,Dockerfile.*}] +indent_size = 2 + +# DSV test fixtures are byte-exact inputs — an editor must not touch them. +[*.dsv[5-8]] +trim_trailing_whitespace = false +insert_final_newline = false +max_line_length = off +ij_formatter_enabled = false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..83a9eec --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,93 @@ +# ============================================================================= +# CI — every check runs through the Makefile, so `make` locally and CI cannot +# diverge. +# +# Pull requests targeting main only. It deliberately does NOT run on push to +# main: branch protection requires this workflow to pass before a PR can merge, +# so re-running the identical commit on main would validate nothing new. +# +# Branch protection requires exactly one check from this file — `CI passed` (the +# `ci-ok` job). Add or rename the real work below without touching the ruleset. +# ============================================================================= +name: CI + +on: + pull_request: + branches: [main] # only PRs onto main, not PRs onto feature branches + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + name: dsv-parser + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - uses: astral-sh/setup-uv@v9.0.0 + with: + python-version-file: .python-version + enable-cache: true + cache-dependency-glob: uv.lock + + - name: Install (uv sync) + run: make install + + - name: Format check + run: make format-check + + - name: Lint + run: make lint + + - name: Typecheck + run: make typecheck + + - name: Test + coverage + run: make test-it + + - name: Audit + run: make audit + + image: + name: image + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: docker/setup-buildx-action@v3 + - name: Build image + uses: docker/build-push-action@v6 + with: + context: . + push: false + cache-from: type=gha + cache-to: type=gha,mode=max + + # --------------------------------------------------------------------------- + # The single required status check. Branch protection requires THIS job, never + # the individual ones — so jobs can be added, split or renamed above without + # editing the ruleset. Nothing here is conditional, so every job must actually + # succeed: a skip means something went wrong upstream, not "nothing to do". + # --------------------------------------------------------------------------- + ci-ok: + name: CI passed + if: always() + needs: [build, image] + runs-on: ubuntu-latest + steps: + - name: Verify every CI job succeeded + env: + RESULTS: ${{ needs.build.result }} ${{ needs.image.result }} + run: | + set -euo pipefail + for r in $RESULTS; do + if [ "$r" != "success" ]; then + echo "::error::A CI job ended with '$r'." + exit 1 + fi + done + echo "All CI jobs passed." diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml new file mode 100644 index 0000000..a1d7c53 --- /dev/null +++ b/.github/workflows/release-please.yml @@ -0,0 +1,154 @@ +# ============================================================================= +# Release — version, changelog and GitHub Release, driven by Release Please. +# +# Two things happen in this one workflow, both on a push to main: +# +# 1. Ordinary merge to main → Release Please reads the Conventional Commits +# since the last tag and opens (or updates) a single "Release PR" that +# bumps the version everywhere and writes the CHANGELOG entry. No tag, no +# release yet — the PR is the proposal. +# 2. The Release PR itself is merged → Release Please recognises its own +# commit, pushes the `vX.Y.Z` tag and publishes the GitHub Release with the +# changelog section as its body. Nothing else to approve. +# +# So the whole cycle is: land features on main, merge the Release PR when you +# want to cut a version. `fix:` → patch, `feat:` → minor, `feat!:`/`BREAKING +# CHANGE:` → major. `chore:`/`docs:`/`ci:` alone never produce a release. +# +# What gets bumped is declared in release-please-config.json, not here. +# +# On the run that actually cuts the release — and only then — a second job builds +# the sdist and the wheel from the freshly pushed tag and uploads them to PyPI, so +# `pip install dsv-parser` tracks the GitHub Releases with no separate manual step. +# +# Everything mutating runs under a GitHub App token, not GITHUB_TOKEN: resources +# created by GITHUB_TOKEN do not trigger further workflows, so a Release PR opened +# with it would never get its required `CI passed` check and could only be merged +# by an admin overriding the ruleset. Under the App token the PR is a normal PR — +# CI runs, the check reports, auto-merge works. +# +# Prerequisites (GitHub-side, not expressible in-repo): +# * A GitHub App (contents: write, pull requests: write, issues: write — Release +# Please labels its PR through the issues API — metadata: read), installed on +# this repository. Its identifier is the RELEASE_PLEASE_APP_CLIENT_ID repository +# *variable* (an App id is not a credential on its own); only the private key is +# a secret, RELEASE_PLEASE_APP_PRIVATE_KEY. +# * Settings → General → Pull Requests → "Allow auto-merge" enabled. +# * A PyPI trusted publisher for the project `dsv-parser`, pointing at owner +# `nilskntl`, repository `dsv-parser`, workflow `release-please.yml`, +# environment `pypi`. Trusted publishing exchanges the job's OIDC token for a +# short-lived upload token, so there is no PyPI API token to store or rotate. +# Before the first release the publisher is registered as a *pending* one — +# the project does not exist on PyPI yet; the first upload creates it. +# ============================================================================= +name: Release + +on: + push: + branches: [main] + +# GITHUB_TOKEN needs nothing here — every mutation goes through the App token +# minted in the job below, which carries its own installation permissions. +permissions: + contents: read + +# One release run at a time, and never cancelled: a half-cut release (tag pushed, +# GitHub Release missing) is worse than a queued run. +concurrency: + group: release-please + cancel-in-progress: false + +jobs: + release-please: + runs-on: ubuntu-latest + outputs: + release_created: ${{ steps.rp.outputs.release_created }} + tag_name: ${{ steps.rp.outputs.tag_name }} + steps: + - name: Mint GitHub App token + id: app + uses: actions/create-github-app-token@v3 + with: + client-id: ${{ vars.RELEASE_PLEASE_APP_CLIENT_ID }} + private-key: ${{ secrets.RELEASE_PLEASE_APP_PRIVATE_KEY }} + + - uses: googleapis/release-please-action@v5 + id: rp + with: + token: ${{ steps.app.outputs.token }} + config-file: release-please-config.json + manifest-file: .release-please-manifest.json + + - name: Summarise + env: + CREATED: ${{ steps.rp.outputs.release_created }} + TAG: ${{ steps.rp.outputs.tag_name }} + PR: ${{ steps.rp.outputs.pr }} + run: | + set -euo pipefail + if [ "${CREATED:-}" = "true" ]; then + echo "::notice::Released ${TAG}." + elif [ -n "${PR:-}" ]; then + echo "::notice::Release PR is open and up to date." + else + echo "::notice::Nothing to release — no releasable commits since the last tag." + fi + # --------------------------------------------------------------------------- + # Publish to PyPI. + # + # Runs only on the push that Release Please recognised as its own Release PR + # merge, i.e. the run that pushed the tag — every other push to main leaves + # `release_created` unset and this job is skipped. + # + # It checks out the tag rather than the pushed ref: same commit today, but the + # tag is what the GitHub Release points at, so the artefact and the release + # notes cannot drift apart if the workflow is ever re-run. + # + # No credentials: `id-token: write` lets the job mint an OIDC token that PyPI + # trades for a short-lived upload token under the trusted publisher above. The + # `pypi` environment name is part of what PyPI verifies, so it must match the + # publisher configuration. + # --------------------------------------------------------------------------- + publish: + name: Publish to PyPI + needs: release-please + if: needs.release-please.outputs.release_created == 'true' + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/dsv-parser + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ needs.release-please.outputs.tag_name }} + + - uses: astral-sh/setup-uv@v9.0.0 + with: + python-version-file: .python-version + enable-cache: true + cache-dependency-glob: uv.lock + + - name: Build sdist and wheel + run: make build + + # A version on PyPI is permanent — it can be yanked but never replaced. So + # verify that what hatchling stamped into the artefacts is the version the + # tag promises before anything is uploaded, rather than discovering a stale + # `0.0.0` afterwards. + - name: Verify the built version matches the tag + env: + TAG: ${{ needs.release-please.outputs.tag_name }} + run: | + set -euo pipefail + wheel=$(ls dist/*.whl) + built=$(basename "$wheel" | cut -d- -f2) + if [ "v$built" != "$TAG" ]; then + echo "::error::Built version $built does not match tag $TAG." + exit 1 + fi + echo "::notice::Publishing dsv-parser $built to PyPI." + + - uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aba2608 --- /dev/null +++ b/.gitignore @@ -0,0 +1,28 @@ +# --- Python --------------------------------------------------------------- +__pycache__/ +*.py[cod] +*.egg-info/ +build/ +dist/ +.venv/ + +# --- Tooling caches ------------------------------------------------------- +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.coverage +coverage.xml +htmlcov/ + +# --- Editors / OS --------------------------------------------------------- +.idea/ +.vscode/ +.DS_Store +**/*.iml + +# --- Local data ----------------------------------------------------------- +# Real DSV files carry personal data (athletes, officials, bank details) and must +# never be committed. Put throwaway samples here; the test fixtures are synthetic. +/data/ +*.local.* +CLAUDE.md diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..b783b61 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,30 @@ +# ============================================================================= +# dsv-parser — pre-commit hooks. +# +# Auto-format ONLY the staged files BEFORE the commit, so CI never fails on +# formatting alone. The hooks invoke the repo's OWN toolchain (`uv run ruff`) on +# exactly the staged paths — no whole-repo sweep. Run `make install` once before +# committing, which you would need anyway. +# +# CI keeps `format-check` + `lint` as the backstop for anyone bypassing the hook. +# ============================================================================= +minimum_pre_commit_version: "3.2.0" +default_install_hook_types: [pre-commit] + +repos: + - repo: local + hooks: + # Ruff covers import order (the `I` rule, which `ruff format` does NOT + # touch) and formatting in one go — matching `make format`. + - id: ruff-import-order + name: ruff — import order (staged) + entry: uv run ruff check --select I --fix --force-exclude + language: system + types_or: [python, pyi] + require_serial: true + - id: ruff-format + name: ruff — format (staged) + entry: uv run ruff format --force-exclude + language: system + types_or: [python, pyi] + require_serial: true diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..466df71 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "0.1.0" +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2e76f50 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,48 @@ +# syntax=docker/dockerfile:1 +# Stage 1 — build the virtualenv with uv +FROM python:3.12-slim AS builder + +COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv + +ENV UV_LINK_MODE=copy \ + UV_COMPILE_BYTECODE=1 \ + UV_PYTHON_DOWNLOADS=never + +WORKDIR /app + +# Lockfile first, then the dependencies without the project, so this layer is +# keyed only on pyproject.toml/uv.lock and is reused when only sources change. +COPY pyproject.toml uv.lock .python-version ./ +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --frozen --no-dev --no-install-project --extra api + +# Sources last, then the project itself into the already-built environment. +# --refresh-package rebuilds from the freshly copied source on every build: the +# version string is stable, so uv would otherwise reuse a stale cached wheel. +COPY dsv_parser ./dsv_parser +COPY README.md ./ +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --frozen --no-dev --no-editable --extra api --refresh-package dsv-parser + +# Stage 2 — runtime +FROM python:3.12-slim AS runtime + +# No system packages: the parser is pure Python and the HTTP stack ships wheels. +RUN useradd --create-home --uid 10001 app +WORKDIR /app + +COPY --from=builder --chown=app:app /app/.venv /app/.venv +ENV PATH="/app/.venv/bin:$PATH" \ + PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + LOG_LEVEL=INFO + +USER app +EXPOSE 8000 + +# The image serves the API by default; the CLI is one `docker run … dsv-parser +# parse -` away, reading the file from stdin. +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/health').status==200 else 1)" + +CMD ["uvicorn", "dsv_parser.api:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..dbb55aa --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 The dsv-parser contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..8f982f0 --- /dev/null +++ b/Makefile @@ -0,0 +1,75 @@ +# ============================================================================= +# dsv-parser — standalone parser for DSV swim-meet interchange files. +# +# One self-contained Makefile covering the whole target surface (install / lint / +# format / typecheck / test / test-it / audit), so the same commands drive the +# project locally and in CI. +# +# The `api` extra is installed by `make install`: the HTTP surface is optional +# for a library consumer, but the test suite covers it, so the dev environment +# always has it. +# ============================================================================= + +SHELL := /usr/bin/env bash +.DEFAULT_GOAL := help +PACKAGE := dsv_parser +IMAGE := dsv-parser +PORT ?= 8000 +COMPOSE_DEV := -f docker-compose.yaml -f docker-compose.dev.yaml + +.PHONY: help install dev serve build image up up-dev down restart logs ps sh \ + test test-it lint format format-check typecheck audit spec clean + +help: ## Show this help. + @awk 'BEGIN {FS=":.*##"} /^[a-zA-Z0-9_.-]+:.*##/ \ + {printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST) + +install: ## Install / refresh dependencies (uv sync, with the api extra). + uv sync --extra api +dev: ## Print the CLI help, listing every subcommand. + uv run python -m $(PACKAGE) --help +serve: ## Run the FastAPI service with auto-reload on $(PORT). + uv run uvicorn dsv_parser.api:app --host 0.0.0.0 --port $(PORT) --reload +spec: ## Print the element table this parser implements. + uv run python -m $(PACKAGE) spec +build: ## Build the sdist and the wheel into dist/ — what the release publishes. + rm -rf dist + uv build +image: ## Build the runtime container image. + docker build -t $(IMAGE):local . + +# --- container stack ------------------------------------------------------- +# `up` runs the production image; `up-dev` layers the reload overlay on top. +up: ## Build and start the stack in the background. + docker compose up -d --build +up-dev: ## Start the stack with sources mounted and uvicorn --reload. + docker compose $(COMPOSE_DEV) up -d --build +down: ## Stop and remove the stack. + docker compose $(COMPOSE_DEV) down --remove-orphans +restart: ## Recreate the stack from a fresh build. + $(MAKE) down && $(MAKE) up +logs: ## Follow the service logs. + docker compose logs -f dsv-parser +ps: ## Show the stack's containers and health. + docker compose ps +sh: ## Open a shell in the running container. + docker compose exec dsv-parser sh + +test: ## Unit tests only — fast, offline, no coverage gate. + uv run pytest -m "not integration" --no-cov +test-it: ## Full test gate: unit + integration tests and the coverage threshold. + uv run pytest +lint: ## Ruff check. + uv run ruff check . +format: ## Ruff: sort imports (I) + format in place. + uv run ruff check --select I --fix . + uv run ruff format . +format-check: ## Verify import order (I) + formatting without writing. + uv run ruff check --select I . + uv run ruff format --check . +typecheck: ## mypy static type check. + uv run mypy $(PACKAGE) +audit: ## pip-audit. + uv run pip-audit +clean: ## Remove caches, build output and the virtualenv. + rm -rf .pytest_cache .mypy_cache .ruff_cache .coverage htmlcov dist .venv diff --git a/README.md b/README.md index d7e947d..ce47261 100644 --- a/README.md +++ b/README.md @@ -1 +1,227 @@ -Wort +# DSV Parser + +Parser for DSV swim-meet files (`.dsv5` through `.dsv8`, including `.dsv8z`) into a typed data schema. Usable as a Python library, as a CLI, and as a FastAPI service whose OpenAPI document exposes the same schema to services in other languages. + +Format **6**, **7** and **8** are covered in full, transcribed from the official specifications of the Deutscher Schwimm-Verband. + +--- + +## Tech Stack + +[![Python][Python.com]][Python-url] +[![Pydantic][Pydantic]][Pydantic-url] +[![FastAPI][FastAPI]][FastAPI-url] +[![Uvicorn][Uvicorn]][Uvicorn-url] +[![OpenAPI][OpenAPI]][OpenAPI-url] +[![uv][UV]][UV-url] +[![Hatch][Hatch]][Hatch-url] +[![Ruff][Ruff]][Ruff-url] +[![mypy][Mypy]][Mypy-url] +[![pytest][Pytest]][Pytest-url] +[![pytest-cov][PytestCov]][PytestCov-url] +[![pip-audit][PipAudit]][PipAudit-url] +[![pre-commit][PreCommit]][PreCommit-url] +[![GNU Make][Make]][Make-url] +[![Docker][Docker]][Docker-url] +[![GitHub Actions][Actions]][Actions-url] + +--- + +## Format Coverage + +| Format | Valid | State | +| ------ | ----------------------- | ------------------------------------------------ | +| **8** | from 01.08.2026 | Complete, specification of 14.03.2026 | +| **7** | 01.01.2023 – 31.12.2026 | Complete, specification of 31.08.2022 | +| **6** | 01.09.2015 – 31.07.2023 | Complete, specification of 01.11.2015 | +| **5** | until 31.12.2015 | Derived from the Format 6 change log | + +All four types (Wettkampfdefinitions-, Vereinsmelde-, Vereinsergebnis- and Wettkampfergebnisliste) with every element and attribute. The packed `.dsv8z` variant is unwrapped transparently. + +Checked against nine real EasyWk files in Format 6 and 7, about 26 000 elements, with no errors or warnings. Details in the [format reference](docs/dsv-format.md). + +--- + +## Quick Start + +### Prerequisites + +| Tool | Version | Needed for | +| -------------------- | ------- | ---------------------------------------------- | +| **uv** | — | dependencies, every `make` target | +| **Python** | 3.12 | fetched by uv from `.python-version` | +| **Docker** + Compose | — | `make up`, `make image` | +| **pre-commit** | — | the formatting hook before a commit (optional) | + +### Install + +As a dependency of another project: + +```bash +pip install dsv-parser # library + CLI +pip install "dsv-parser[api]" # plus the FastAPI service +uv add dsv-parser +``` + +Every GitHub Release is published to PyPI automatically, so the version there tracks the tags in this repository. + +To work on the parser itself: + +```bash +make install # uv sync --extra api +``` + +The `api` extra adds FastAPI and uvicorn. The parser itself needs only Pydantic, so a library-only consumer can leave the extra out. + +### Run + +```bash +make dev # CLI help with every subcommand +make spec # the element table as implemented +make serve # FastAPI on :8000 with reload +make up # the same in Docker +make ps # container status and health +make logs # follow the logs +make down # stop and remove +``` + +### Use it + +```python +from dsv_parser import parse_file + +result = parse_file("2026-06-13-Berlin-Pr.DSV8") + +print(result.document.meet.name, result.document.file_type) +for swim in result.document.individual_results: + print(swim.place, swim.name, swim.time_millis) + +for entry in result.diagnostics.entries: + print(entry.render()) +``` + +```bash +dsv-parser parse meet.DSV8 -o meet.json # document as JSON +dsv-parser parse meet.DSV8 --summary # header and element counts only +dsv-parser check meet.DSV8 # validate, exit 1 on data loss +dsv-parser spec --json # element table as data +``` + +--- + +## HTTP Surface + +| Method | Path | Purpose | +| ------ | --------------- | ---------------------------------------------- | +| `POST` | `/parse` | Parse a file and return the document | +| `POST` | `/check` | Validate a file without returning the document | +| `GET` | `/spec` | Element table and all code lists | +| `GET` | `/health` | Liveness and the supported format versions | +| `GET` | `/openapi.json` | The schema, for generated clients | + +```bash +curl -F file=@meet.DSV8 http://localhost:8000/parse +curl -F file=@meet.DSV8 http://localhost:8000/check +``` + +A readable upload always returns **200**. Content problems come back as diagnostics rather than HTTP errors, since a partially readable file is a normal result. Check `clean` and `diagnostics`, not the status code. + +The document schema in `/openapi.json` is the same Pydantic model the library returns. See the [HTTP API](docs/api.md). + +--- + +## Configuration + +| Variable | Default | Effect | +| ----------- | ------- | --------- | +| `LOG_LEVEL` | `INFO` | Log level | + +There is nothing else. The service holds no state and can be mounted into another app with `app.include_router(dsv_parser.api.router)`. + +--- + +## Design + +The attribute layouts live as data in [`dsv_parser/spec/elements.py`](dsv_parser/spec/elements.py), one entry per element, with `versions=` and `file_types=` wherever the four format versions or the four list kinds differ. For example, `WETTKAMPF` has no best-list attribute in a Vereinsmeldeliste, which moves the two qualification attributes one position forward. In the table that is a single `file_types=` on one attribute. + +```mermaid +flowchart LR + A["bytes
.dsv · .dsv8z"] -->|decoding| B["text"] + B -->|lexer| C["element lines"] + C -->|binder| D["element models"] + D -->|parser| E["DsvDocument"] + T[("spec table
spec/elements.py")] -.->|layout| C + T -.->|layout| D + C -.-> G(["diagnostics"]) + D -.-> G + E -.-> G +``` + +More in the [architecture notes](docs/architecture.md). + +--- + +## Common Commands + +```bash +make help # every target +make test # unit tests, offline, no coverage gate +make test-it # full run incl. integration and coverage +make lint # ruff check +make format # ruff: import order and formatting +make format-check # the same, read-only +make typecheck # mypy +make audit # pip-audit +make image # build the runtime image +make clean # remove caches and the venv +``` + +--- + +## Further Reading + +- [Architecture](docs/architecture.md) — pipeline, element table, decisions +- [Format reference](docs/dsv-format.md) — the format and the version differences +- [Data schema](docs/data-schema.md) — how the document is laid out +- [HTTP API](docs/api.md) — endpoints, payloads, client generation +- [CLI](docs/cli.md) — subcommands, flags, exit codes +- [Extending](docs/extending.md) — adding elements, versions and code lists + +## License + +[MIT](LICENSE) — use it, change it, ship it, commercially or not. + + + +[Python.com]: https://img.shields.io/badge/Python%203.12-3776AB?style=for-the-badge&logo=python&logoColor=white +[Python-url]: https://www.python.org +[Pydantic]: https://img.shields.io/badge/Pydantic-E92063?style=for-the-badge&logo=pydantic&logoColor=white +[Pydantic-url]: https://docs.pydantic.dev +[FastAPI]: https://img.shields.io/badge/FastAPI-009688?style=for-the-badge&logo=fastapi&logoColor=white +[FastAPI-url]: https://fastapi.tiangolo.com +[Uvicorn]: https://img.shields.io/badge/Uvicorn-499848?style=for-the-badge&logoColor=white +[Uvicorn-url]: https://www.uvicorn.org +[OpenAPI]: https://img.shields.io/badge/OpenAPI-6BA539?style=for-the-badge&logo=openapiinitiative&logoColor=white +[OpenAPI-url]: https://www.openapis.org +[UV]: https://img.shields.io/badge/uv-DE5FE9?style=for-the-badge&logo=astral&logoColor=white +[UV-url]: https://docs.astral.sh/uv/ +[Hatch]: https://img.shields.io/badge/Hatchling-4051B5?style=for-the-badge&logo=python&logoColor=white +[Hatch-url]: https://hatch.pypa.io +[Ruff]: https://img.shields.io/badge/Ruff-D7FF64?style=for-the-badge&logo=ruff&logoColor=black +[Ruff-url]: https://docs.astral.sh/ruff/ +[Mypy]: https://img.shields.io/badge/mypy-1F5082?style=for-the-badge&logo=python&logoColor=white +[Mypy-url]: https://mypy-lang.org +[Pytest]: https://img.shields.io/badge/pytest-0A9EDC?style=for-the-badge&logo=pytest&logoColor=white +[Pytest-url]: https://docs.pytest.org +[PytestCov]: https://img.shields.io/badge/pytest--cov-0A9EDC?style=for-the-badge&logoColor=white +[PytestCov-url]: https://pytest-cov.readthedocs.io +[PipAudit]: https://img.shields.io/badge/pip--audit-2C5BB4?style=for-the-badge&logo=python&logoColor=white +[PipAudit-url]: https://pypi.org/project/pip-audit/ +[PreCommit]: https://img.shields.io/badge/pre--commit-FAB040?style=for-the-badge&logo=precommit&logoColor=black +[PreCommit-url]: https://pre-commit.com +[Make]: https://img.shields.io/badge/GNU%20Make-A42E2B?style=for-the-badge&logo=gnu&logoColor=white +[Make-url]: https://www.gnu.org/software/make/ +[Docker]: https://img.shields.io/badge/Docker-2496ED?style=for-the-badge&logo=docker&logoColor=white +[Docker-url]: https://www.docker.com +[Actions]: https://img.shields.io/badge/GitHub%20Actions-2088FF?style=for-the-badge&logo=githubactions&logoColor=white +[Actions-url]: https://github.com/features/actions diff --git a/docker-compose.dev.yaml b/docker-compose.dev.yaml new file mode 100644 index 0000000..805c1e6 --- /dev/null +++ b/docker-compose.dev.yaml @@ -0,0 +1,33 @@ +# ============================================================================= +# dsv-parser — development overlay. +# +# Layered on top of docker-compose.yaml (`make up-dev`): mounts the sources into +# the container and runs uvicorn with --reload, so an edit is live without a +# rebuild. The read-only root filesystem of the base file is lifted here because +# the reloader writes bytecode caches. +# ============================================================================= + +services: + dsv-parser: + build: + context: . + target: builder + command: + # Full path: only the runtime stage puts /app/.venv/bin on PATH, and this + # overlay runs the builder stage. + - /app/.venv/bin/uvicorn + - dsv_parser.api:app + - --host + - 0.0.0.0 + - --port + - "8000" + - --reload + environment: + LOG_LEVEL: ${LOG_LEVEL:-DEBUG} + # The image installs dsv_parser as a wheel (--no-editable), which would + # shadow the bind mount below; /app first on sys.path makes the mounted + # sources win, so --reload actually serves the edit. + PYTHONPATH: /app + volumes: + - ./dsv_parser:/app/dsv_parser:ro + read_only: false diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..4e789e0 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,46 @@ +# ============================================================================= +# dsv-parser — local stack. +# +# One service, because that is all there is: the parser is stateless, has no +# database, no cache and no broker. The compose file exists so the service is +# started the same way here as anywhere else, and so another stack can pull it +# in with `extends` or an `include`. +# +# Driven by the Makefile: `make up`, `make down`, `make logs`, `make ps`. +# ============================================================================= +name: dsv-parser + +services: + dsv-parser: + build: + context: . + dockerfile: Dockerfile + image: dsv-parser:local + container_name: dsv-parser + restart: unless-stopped + environment: + # Consumed by dsv_parser.api.app.configure_logging. + LOG_LEVEL: ${LOG_LEVEL:-INFO} + ports: + # Host port overridable so the container does not fight another service + # for 8000; the container port is fixed, matching the Dockerfile CMD. + - "${DSV_PARSER_PORT:-8000}:8000" + healthcheck: + test: + - CMD + - python + - -c + - | + import sys, urllib.request + sys.exit(0 if urllib.request.urlopen("http://127.0.0.1:8000/health").status == 200 else 1) + interval: 30s + timeout: 3s + start_period: 5s + retries: 3 + # The parser only ever reads its request body, so it needs no write access + # to its own filesystem and no extra capabilities. + read_only: true + security_opt: + - no-new-privileges:true + cap_drop: + - ALL diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..c5ffd8f --- /dev/null +++ b/docs/api.md @@ -0,0 +1,115 @@ +# HTTP API + +Optional extra: `uv sync --extra api`, then + +```bash +uvicorn dsv_parser.api:app --host 0.0.0.0 --port 8000 +# or: make serve / make up (Docker) +``` + +The service is stateless and can be mounted into another app: + +```python +app.include_router(dsv_parser.api.router, prefix="/dsv") +``` + +## Endpoints + +| Method | Path | Purpose | +| --- | --- | --- | +| `POST` | `/parse` | Parse an uploaded file into the document schema | +| `POST` | `/check` | Validate without returning the document | +| `GET` | `/spec` | The element table and every coded vocabulary | +| `GET` | `/health` | Liveness and the supported format versions | +| `GET` | `/openapi.json` | The full schema — generate your client from this | + +### `POST /parse` + +`multipart/form-data` with one `file` field. Query parameter `exclude_none=true` +drops null fields from the response, which is much smaller but gives a variable +shape — leave it off for a generated client. + +```bash +curl -F file=@meet.DSV8 http://localhost:8000/parse +``` + +```json +{ + "filename": "meet.DSV8", + "clean": true, + "source": { "encoding": "utf-8", "zipped": false, "member": null }, + "document": { "file_type": "meet_results", "version": 8, "...": "..." }, + "diagnostics": [] +} +``` + +A readable upload always returns 200. Content problems come back as diagnostics +rather than HTTP errors, since a partially readable file is a normal result. Check +`clean` and `diagnostics`, not the status code. + +### `POST /check` + +Returns the header, the per-block element counts and the diagnostics, without the +document. Useful for an ingest pipeline deciding whether to accept a file, and for +a UI reporting what is wrong with one a user just picked. + +```json +{ + "filename": "meet.DSV8", + "clean": false, + "file_type": "meet_results", + "version": 8, + "source": { "encoding": "cp1252", "zipped": false, "member": null }, + "elements": { "sections": 4, "events": 62, "individual_results": 1841 }, + "diagnostics": [ + { + "severity": "error", + "message": "unknown Stroke code", + "line": 412, + "element": "WETTKAMPF", + "attribute": 6, + "field": "Technik", + "value": "Z" + } + ] +} +``` + +### `GET /spec` + +The element table as data: every element with its attributes, their German names +from the spec, types and applicability, plus every vocabulary as +`code → speaking value`. Generated from the table in `spec/elements.py`. + +## Errors + +| Status | When | +| --- | --- | +| 200 | Readable upload — including one with parse diagnostics | +| 413 | Upload above 32 MB (`MAX_UPLOAD_BYTES` in `api/routes.py`) | +| 422 | No `file` field | + +## Using it from another language + +`/openapi.json` publishes the same Pydantic model the library returns, so a +generated client has the exact types: + +```bash +openapi-generator generate -i http://localhost:8000/openapi.json -g go -o ./dsv +npx openapi-typescript http://localhost:8000/openapi.json -o dsv.d.ts +``` + +Two things to keep in mind: + +- Swim times are integer milliseconds. `null` means "no time" (the format's + `00:00:00,00` placeholder) and is not the same as `0`. +- Coded values are speaking strings rather than file codes: `"freestyle"`, not + `"F"`. `GET /spec` gives the mapping in both directions. + +## Configuration + +| Variable | Default | Effect | +| --- | --- | --- | +| `LOG_LEVEL` | `INFO` | Root log level | + +There is nothing else to set. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..38f3eb0 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,173 @@ +# Architecture + +## Pipeline + +Reading a file goes through four stages, each in its own module: + +```mermaid +flowchart LR + subgraph read [" "] + direction LR + A["bytes"] -->|"decoding
zip · encoding · BOM"| B["text"] + B -->|"lexer
comments · split"| C["element lines"] + C -->|"binder
coerce per layout"| D["element models"] + D -->|"parser
assemble"| E["DsvDocument"] + end + T[("spec/elements.py
the element table")] -.-> C + T -.-> D + C -.-> G(["Diagnostics"]) + D -.-> G + E -.-> G +``` + +| Stage | Module | Responsibility | +| -------- | ------------------- | ----------------------------------------------------- | +| Decode | `core/decoding.py` | ZIP unwrapping, encoding detection, BOM stripping | +| Lex | `core/lexer.py` | Comments, element/attribute split, line numbers | +| Bind | `core/binder.py` | Positional text to typed model, driven by the table | +| Assemble | `core/parser.py` | `FORMAT` first, dispatch, `DATEIENDE`, duplicates | + +Only `core/parser.py` needs the whole file in view, because the `FORMAT` line +decides how every following line is read. + +## The element table + +The layout of every element is declared in `spec/elements.py` as an +`ElementSpec`: the element name, its attributes in file order, and the conditions +under which each attribute is present. + +```python +ElementSpec( + element="WETTKAMPF", + target="events", + model=el.Event, + attributes=( + A("number", Kind.INT, "Wettkampfnummer"), + ... + A("best_list_category", Kind.ENUM, "Zuordnung Bestenliste", + enum=BestListCategory, file_types=WITH_BEST_LIST), + A("qualification_event_number", Kind.INT, "Qualifikationswettkampf"), + A("qualification_round", Kind.ENUM, "Qualifikationswettkampfart", enum=Round), + ), +) +``` + +The binder filters the attributes by version and list kind, then zips the result +against the raw values. Positional shifts therefore fall out of the declaration. +The `file_types=` above is the whole handling of the case where a Vereinsmeldeliste +omits the best-list attribute and both qualification attributes move one position +forward. A mistake there would not crash; it would file the qualification round +under "best list", which is why it is worth keeping declarative. + +Two further consequences: + +- Adding an element means adding one table entry, not a case plus a handler plus + a list on the document. +- `dsv-parser spec` and `GET /spec` render the table itself, so the reference + documentation is generated from the implementation. + +`tests/unit/test_spec.py` asserts the table's invariants: every attribute exists +on its model, every target on the document, every `ENUM` attribute names a +vocabulary, and no vocabulary has duplicate codes. + +### Choosing a layout + +`Registry.lookup(element, version, file_type)` returns the first declared layout +whose `versions=` and `file_types=` admit the file. Declaration order matters, so +the more specific variant comes first. `STAFFELPERSON` is the only element with +two genuinely different shapes (four attributes in an entry list, twelve in a +result list) and is declared twice. + +```mermaid +flowchart TD + L["element line"] --> R{"Registry.lookup
element · version · file_type"} + R -->|"element unknown"| W2["warning: unknown element"] + R -->|"known, no applicable variant"| W1["warning: no layout
for this file kind"] + R -->|"first applicable"| S["ElementSpec"] + S --> F["filter attributes by
versions= and file_types="] + F --> Z["zip against the raw values"] + Z --> M["model instance"] + Z -->|"surplus attributes"| W3["warning: layout not
modelled for this version"] +``` + +When the version or list kind is unknown, every attribute is treated as present. +For a file whose header could not be read, that is the reading that loses least. + +## Diagnostics + +The reader does not raise on file content. Problems become a `Diagnostic` with +`severity`, `line`, `element`, `attribute`, `field` and the offending `value`: + +| Severity | Meaning | +| --------- | -------------------------------------------------------------------------- | +| `ERROR` | Data was lost. The attribute is `None`; the rest of the element survives. | +| `WARNING` | Recovered: unknown element, non-UTF-8 encoding, missing `DATEIENDE`, and so on. | + +```mermaid +flowchart TD + V["raw attribute"] --> P{"matches its
declared type?"} + P -->|yes| OK["typed value on the model"] + P -->|no| ERR["value stays None
+ ERROR diagnostic"] + OK --> C["element and file
continue either way"] + ERR --> C + C --> D{"any errors?"} + D -->|no| CLEAN["result.clean is True"] + D -->|yes| PARTIAL["partial document
+ what was lost, per line"] +``` + +One warning is worth watching: attributes beyond the declared layout. That is +what an unmodelled format version looks like from inside the parser. + +`parse_file` does raise `OSError`. An unreadable file is the caller's problem, a +malformed one is the parser's. + +## Decisions + +**Swim times are integer milliseconds.** Consumers compare, sum and store them, +and an integer needs no serialisation convention in JSON, SQL or any client +language. `00:00:00,00` is the format's placeholder for "no time" and maps to +`None`, which keeps "did not swim" distinct from "swam in zero". + +**Enums carry a speaking value and a file code.** `Stroke.FREESTYLE` serialises as +`"freestyle"`, not `"F"`. The JSON leaves this service for consumers who do not +have the DSV code tables. + +**One document type for all four list kinds.** A definition list has events and +fees and no results, a meet protocol the other way round. With one container, +consumers generate a client once and an empty block is data rather than a +different type. + +**Every field is optional.** The attribute count of an element depends on version +and list kind, optional attributes are routinely blank, and the reader is +tolerant. Callers that need guarantees should validate the assembled document. + +**The HTTP surface is an optional extra.** A consumer that only imports +`parse_bytes` should not have to install FastAPI and uvicorn. + +## Source layout + +```text +dsv_parser/ +├── __init__.py public API: parse_bytes / parse_text / parse_file +├── cli.py argparse CLI: parse, check, spec +├── naming.py the DSV file-naming convention +├── core/ +│ ├── decoding.py bytes → text (zip, encoding, BOM) +│ ├── lexer.py text → element lines +│ ├── values.py Zeit / Datum / Uhrzeit / Betrag and their formatters +│ ├── binder.py element line + layout → model +│ ├── parser.py assembly, FORMAT handling, diagnostics wiring +│ └── diagnostics.py Diagnostic, Severity, collector +├── model/ +│ ├── document.py DsvDocument +│ ├── elements.py one Pydantic model per element +│ └── enums.py the coded vocabulary +├── spec/ +│ ├── fields.py Attribute, ElementSpec, Registry, Kind +│ ├── elements.py the element table +│ └── render.py table → text / JSON for `spec` and GET /spec +└── api/ + ├── app.py FastAPI factory + ├── routes.py /parse /check /spec /health + └── schemas.py request and response envelopes +``` diff --git a/docs/cli.md b/docs/cli.md new file mode 100644 index 0000000..f067297 --- /dev/null +++ b/docs/cli.md @@ -0,0 +1,74 @@ +# CLI + +Installed as `dsv-parser`, and runnable as `python -m dsv_parser`. Every +subcommand takes one file, or `-` to read from stdin. + +## `parse` — file to JSON + +```bash +dsv-parser parse meet.DSV8 # indented JSON on stdout +dsv-parser parse meet.DSV8 -o meet.json # …or to a file +cat meet.DSV8 | dsv-parser parse - # …or from a pipe +``` + +| Flag | Effect | +| --- | --- | +| `-o, --output PATH` | Write to a file instead of stdout | +| `--compact` | Single-line JSON | +| `--exclude-none` | Drop null fields — far smaller, variable shape | +| `--diagnostics` | Wrap document and diagnostics in one envelope | +| `--summary` | Header and element counts only, no document | +| `-q, --quiet` | Do not print diagnostics to stderr | +| `--strict` | Exit 1 when any data was lost | + +Diagnostics go to stderr and the document to stdout, so +`dsv-parser parse meet.DSV8 > meet.json` writes clean JSON and still shows the +problems. + +## `check` — validate + +```bash +dsv-parser check meet.DSV8 +``` + +``` +warning: line 1204 (VEREIN): 2 attribute(s) beyond the 4 this layout declares, ignored … +meet.DSV8: meet_results format 8, 9686 elements, 0 error(s), 1 warning(s) +``` + +Exit code 0 when nothing was lost, 1 otherwise. Intended for CI and batch +validation: + +```bash +find . -iname '*.dsv*' -exec dsv-parser check {} \; | grep -v '0 error' +``` + +## `spec` — the element table + +```bash +dsv-parser spec # plain text +dsv-parser spec --json # machine-readable, same as GET /spec +dsv-parser spec -o SPEC.txt +``` + +Rendered from `spec/elements.py`, so it always matches the implementation. + +``` +WETTKAMPF + One event of the programme. The Vereinsmeldeliste omits the Bestenliste … + 1. Wettkampfnummer number int + 2. Wettkampfart round Round + … + 9. Zuordnung Bestenliste best_list_category BestListCategory (definition, club_results, meet_results) +``` + +## Exit codes + +| Code | Meaning | +| --- | --- | +| 0 | Success | +| 1 | Data was lost (`check` always, `parse` with `--strict`) | +| 2 | No subcommand given | + +An unreadable file raises `OSError` and exits through the normal traceback. That +is a caller error rather than a content problem. diff --git a/docs/data-schema.md b/docs/data-schema.md new file mode 100644 index 0000000..2028954 --- /dev/null +++ b/docs/data-schema.md @@ -0,0 +1,127 @@ +# Data Schema + +The parser returns a `DsvDocument`, a Pydantic model defined in +`dsv_parser/model/document.py`. It is the same model the HTTP service publishes in +`/openapi.json`. + +## One container for four list kinds + +There is one document type, not one per Listart. Which blocks are populated +depends on `file_type`: a definition list has events, age groups and fees but no +results; a meet protocol the other way round. Consumers therefore generate a +client once, and an empty block is data rather than a different type. + +`element_counts()` returns the non-empty repeated blocks with their sizes, which +is a cheap way to see what a file actually contained. + +```python +>>> document.element_counts() +{'sections': 4, 'events': 62, 'age_groups': 145, 'clubs': 31, + 'individual_results': 1841, 'splits': 3120} +``` + +## Layout + +| Group | Fields | +| ------------ | --------------------------------------------------------------------------------------------------------------------------------------------- | +| Header | `file_type` `version` `generator` `meet` `venue` `announcement_url` `organizer_name` `host` `entry_address` `contact_person` `entry_deadline` `bank_account` `direct_debit_only` `remarks` `proof_of_time` | +| Programme | `sections` `events` `age_groups` `qualification_times` `entry_fees` | +| Participants | `clubs` `coaches` `swimmers` `handicaps` `judge_nominations` `judge_section_wishes` `judge_assignments` | +| Entries | `individual_entries` `relays` `relay_entries` `relay_swimmers` | +| Results | `individual_results` `splits` `reactions` `relay_results` `relay_splits` `relay_takeoffs` | + +Every list preserves file order. For `events` that order is the meet programme and +carries meaning; for the rest it is whatever the writing software chose. + +Element models live in `dsv_parser/model/elements.py`, one per DSV element, with +the field order matching the attribute order of the spec. Each carries +`source_line`, the one-based line it was read from. + +## How the parts refer to each other + +The document is flat: the lists are not nested, and the links between them are +the numeric ids the format itself uses. Resolving them is the consumer's job. + +```mermaid +erDiagram + DsvDocument ||--o{ Event : "events" + DsvDocument ||--o{ Section : "sections" + DsvDocument ||--o{ Club : "clubs" + DsvDocument ||--o{ Swimmer : "swimmers" + DsvDocument ||--o{ Relay : "relays" + Event ||--o{ AgeGroup : "event_number + round" + Event }o--|| Section : "section_number" + Swimmer ||--o{ IndividualEntry : "swimmer_local_id" + Swimmer ||--o{ IndividualResult : "swimmer_local_id" + IndividualResult ||--o{ Split : "swimmer + event + round" + IndividualResult ||--o| Reaction : "swimmer + event + round" + Relay ||--o{ RelaySwimmer : "relay_local_id" + Relay ||--o{ RelayResult : "relay_local_id" + RelayResult ||--o{ RelaySplit : "relay + event + round" + RelayResult ||--o{ RelayTakeoff : "relay + event + round" +``` + +`local_id` (Veranstaltungs-ID) is unique within one meet and is the key that ties +swimmers and relays to their entries and results. `dsv_id` is the swimmer's +national id and is `0` when unknown. An event is identified by the pair +`event_number` and `round`, since event 12 prelim and event 12 final are two +separate `WETTKAMPF` elements. + +## Everything is optional + +Every field is `| None`. The reason is the format, not laziness: an element's +attribute count depends on version and list kind, optional attributes are +routinely left blank, and the reader turns a value it cannot represent into `None` +plus a diagnostic. Callers that need guarantees should validate the assembled +document against their own requirements. + +## Scalar conventions + +| Concept | Representation | Note | +| ------------- | ----------------------------- | ------------------------------------------------- | +| Swim time | `int` milliseconds | `None` for the `00:00:00,00` placeholder | +| Reaction time | `int` milliseconds + `negative: bool` | `negative` marks a start before the signal | +| Money | `int` euro cents | | +| Date | `datetime.date` | | +| Time of day | `datetime.time` | | +| Deadline | `datetime.datetime` | `MELDESCHLUSS` merges its date and clock | +| Flag | `bool \| None` | `None` means the attribute was absent, not `N` | +| `JGAK` bounds | `str` | `"2010"`, `"AK"`, `"80+"`, deliberately unparsed | + +Milliseconds are always a multiple of ten, since the format's resolution is a +hundredth of a second. + +## Coded values + +Vocabulary members serialise as speaking strings, not as the file codes: + +```json +{ "stroke": "freestyle", "round": "timed_final", "status": "disqualified" } +``` + +Each member also keeps its file code, which is what the reader matches on and what +a writer would emit: + +```python +>>> Stroke.FREESTYLE.value, Stroke.FREESTYLE.code +('freestyle', 'F') +>>> Stroke.from_code(' f ') # case-insensitive, whitespace tolerated + +``` + +`GET /spec` and `dsv-parser spec --json` return the full mapping for every +vocabulary. + +`ResultStatus.RANKED` is the one member with no code: a regularly ranked swim has +an empty `Grund der Nichtwertung` attribute, which the reader leaves as `None`. + +## JSON output + +```python +document.model_dump(mode="json") # full shape, nulls included +document.model_dump(mode="json", exclude_none=True) # much smaller, variable shape +``` + +The CLI exposes both as `dsv-parser parse` and `--exclude-none`; the API as +`POST /parse` and `?exclude_none=true`. Generated clients should stay with the +full shape. diff --git a/docs/dsv-format.md b/docs/dsv-format.md new file mode 100644 index 0000000..e14de43 --- /dev/null +++ b/docs/dsv-format.md @@ -0,0 +1,193 @@ +# DSV Format + +The DSV-Standard is the interchange format of the Deutscher Schwimm-Verband for +swim-meet entries and results. It is plain UTF-8 text, intended to be readable by +a program and by a person. + +## Sources + +This parser is transcribed from the official specifications: + +| Version | Document date | In force | Notes | +| --- | --- | --- | --- | +| Format 6 | 01.11.2015 | 01.09.2015 – 31.07.2023 | | +| Format 7 | 31.08.2022 | 01.01.2023 – 31.12.2026 | | +| Format 8 | 14.03.2026 | from 01.08.2026 | sole valid format from 01.01.2027 | + +The Format 5 document is no longer published; its deltas here are derived from +the Format 6 change log. See [Version deltas](#version-deltas). + +```mermaid +timeline + title Validity of the DSV formats + 2015 : Format 6 in force (01.09.) + 2016 : Format 5 expires (01.01.) + 2023 : Format 7 in force (01.01.) : Format 6 expires (01.08.) + 2026 : Format 8 in force (01.08.) + 2027 : Format 7 expires (01.01.) +``` + +## Grammar + +``` +(* a comment, one line, may appear anywhere *) +FORMAT: Wettkampfergebnisliste;8; +VERANSTALTUNG: 26. Erzgebirgs-Schwimmcup;Marienberg;25;AUTOMATISCH; +WETTKAMPF: 1;E;1;1;100;S;GL;W;SW;;; (* 100m Schmetterling weiblich *) +DATEIENDE +``` + +- One element per line: a constant, a colon, then semicolon-separated attributes. +- Attributes are **positional**. An optional attribute that is left out still + needs its separator, so `A;;C;` has an empty second attribute. +- The trailing semicolon is a **terminator**, not a separator: `A;B;` is two + attributes, `A;B;;` is three. +- Comments are `(* … *)` and single-line. Real files place them at the end of data + lines, so a parser that only skips lines beginning with `(*` will leave the + comment sitting in the last attribute slot. +- Leading and trailing spaces inside an attribute are explicitly allowed. +- `FORMAT` must be the first element, `DATEIENDE` the last. +- `WETTKAMPF` elements should appear in the order the events actually run, which + is not necessarily ascending by number. The parser preserves that order. + +## Data types + +| Spec type | Literal | Python | +| --- | --- | --- | +| `ZK` | any text without `;` or newline | `str` | +| `Zahl` | unsigned 32-bit integer | `int` | +| `Zeit` | `HH:MM:SS,hh` | `int` milliseconds — `00:00:00,00` → `None` | +| `Datum` | `TT.MM.JJJJ` | `datetime.date` | +| `Uhrzeit` | `HH:MM`, 24-hour | `datetime.time` | +| `Betrag` | `12,50` | `int` euro cents | +| `JGAK` | year (`2010`), age class (`A`…`J`, `20`, `25`, …) or relay minimum (`80+`) | `str` — deliberately not parsed | + +## The four Listarten + +| Listart | File suffix | Contents | +| --- | --- | --- | +| Wettkampfdefinitionsliste | `-Wk` | The announcement: venue, sections, events, age groups, qualifying times, fees | +| Vereinsmeldeliste | `--Me` | One club's entries: swimmers, starts, relays, coaches, judges | +| Vereinsergebnisliste | `--Pr` | One club's results | +| Wettkampfergebnisliste | `-Pr` | The full meet protocol, all clubs | + +The list kind decides which elements may occur, and for `ABSCHNITT`, `WETTKAMPF` +and `STAFFELPERSON` it also decides the attribute layout. + +They form the exchange around one meet: the host publishes the announcement, each +club answers with its entries, and the host returns the protocol. + +```mermaid +flowchart LR + H["Ausrichter"] -->|"Wettkampfdefinitionsliste
-Wk"| V["Verein"] + V -->|"Vereinsmeldeliste
-⟨Verein⟩-Me"| H + H -->|"Wettkampfergebnisliste
-Pr"| P["Verbände · Presse · Bestenlisten"] + H -->|"Vereinsergebnisliste
-⟨Verein⟩-Pr"| V +``` + +## File naming + +``` +JJJJ-MM-TT-Ort-Zusatz.DSV8 +``` + +The date is the **last** section's date; `Ort` is the meet city — not the pool — +truncated to eight characters; club names are truncated to sixteen. Spaces and +hyphens are dropped and umlauts transliterated (ä→ae, ö→oe, ü→ue, ß→ss). Several +files for the same day and city are numbered: `…-Berlin1-Pr.DSV8`. + +Implemented in `dsv_parser/naming.py`. + +Format 8 adds a ZIP-packed variant, `.DSV8z`, introduced because editors kept +corrupting the encoding of plain files. The archive holds exactly one DSV file of +the same name. The parser detects it by magic bytes rather than by extension. + +## Element coverage + +All elements of all four list kinds are implemented. `dsv-parser spec` prints the +full table with every attribute, its German spec name, its type and its +applicability; `dsv-parser spec --json` and `GET /spec` give the same as data. + +| Group | Elements | +| --- | --- | +| Header | `FORMAT` `ERZEUGER` `VERANSTALTUNG` `VERANSTALTUNGSORT` `AUSSCHREIBUNGIMNETZ` `VERANSTALTER` `AUSRICHTER` `MELDEADRESSE` `ANSPRECHPARTNER` `MELDESCHLUSS` `BANKVERBINDUNG` `LASTSCHRIFT` `BESONDERES` `NACHWEIS` | +| Programme | `ABSCHNITT` `WETTKAMPF` `WERTUNG` `PFLICHTZEIT` `MELDEGELD` | +| Participants | `VEREIN` `TRAINER` `PNMELDUNG` `PERSON` `HANDICAP` `KARIMELDUNG` `KARIABSCHNITT` `KAMPFGERICHT` | +| Entries | `STARTPN` `STMELDUNG` `STAFFEL` `STARTST` `STAFFELPERSON` | +| Results | `PERSONENERGEBNIS` `PNERGEBNIS` `PNZWISCHENZEIT` `PNREAKTION` `STAFFELERGEBNIS` `STERGEBNIS` `STZWISCHENZEIT` `STABLOESE` | +| Terminator | `DATEIENDE` | + +## Version deltas + +Each of these is a `versions=` marker in `spec/elements.py` or a docstring in +`model/enums.py`. + +### Format 7 → 8 + +Six changes, from the Format 8 change log: + +| Change | Effect here | +| --- | --- | +| `BANKVERBINDUNG` gains `Kontoinhaber` | 4th attribute, `versions=V8` | +| `LASTSCHRIFT` — new element, Wettkampfdefinitionsliste | element, `versions=V8` | +| `VEREIN` gains a `Lastschrift` flag | 5th attribute, `versions=V8`, **Vereinsmeldeliste only** | +| `TRAINER` gains `Geschlecht` | 3rd attribute, `versions=V8` | +| `KARIMELDUNG` gains `Geschlecht` | 4th attribute, `versions=V8` | +| `MELDEGELD`: `Teilnehmermeldegeld`, `Abschnittspauschale` | vocabulary | +| `Ausübung`: `KB`/`KR` (Kicks Bauch-/Rückenlage, only with `Technik = S`) | vocabulary | +| Packed `.DSV8z` variant | `core/decoding.py` | + +### Format 6 → 7 + +| Change | Effect here | +| --- | --- | +| `Nationalität 1/2/3` on entries and results | three attributes, `versions=V7_PLUS`, on `PNMELDUNG` `PERSON` `PNERGEBNIS` `STAFFELPERSON` | +| Para classification: `HANDICAP` | element, `versions=V7_PLUS` | +| Vereinfachte Wettkämpfe: best-list `EW` | vocabulary | +| Para best-list `PA` | vocabulary | +| Freiwasser best-list `FS` dropped | vocabulary — kept so Format 6 files still read | +| Gender `D` (divers) | vocabulary | +| Six more judge positions: `STO` `WKH` `ASCH` `SIB` `SAUF` `VER` | vocabulary | + +The nationality change is the one that moves attribute positions. It is confirmed +against real files: `PNERGEBNIS` has 16 attributes in a Format 6 protocol and 19 +in a Format 7 one. + +### Format 5 → 6 + +Derived from the Format 6 change log, not from the Format 5 document: + +| Change | Effect here | +| --- | --- | +| `ABSCHNITT` gains `Relative Angabe` | last attribute, `versions=V6_PLUS` | +| `PNREAKTION` — new element | element, `versions=V6_PLUS` | +| `STABLOESE` — new element | element, `versions=V6_PLUS` | +| `NACHWEIS` — new element | element, `versions=V6_PLUS` | +| Reaction time as `PNZWISCHENZEIT` with `Distanz = 0` removed | **not modelled** — see below | + +### The Format 5 caveat + +The Format 6 change log is dated after the "Format 6, März 2015" release, so +strictly it records amendments to Format 6 rather than differences against Format +5. A Format 5 file predates all of them in either reading. A Format 6 file written +between March and October 2015 will also lack them, which does no harm: the +missing attribute is the last one of its element, and a missing element is simply +absent. + +One Format 5 idiom is not modelled. A reaction time used to be carried as a +`PNZWISCHENZEIT` with distance 0; this parser reads those as splits. Parsing a +Format 5 file emits a warning that says so. See `TODO(spec-v5)` in +`spec/elements.py`. + +## Real-world quirks the parser handles + +| Quirk | Handling | +| --- | --- | +| Trailing inline comments on data lines | Stripped anywhere, before the split | +| CP1252-encoded Format 5/6 files (spec says UTF-8) | Sniffed; warning on the fallback | +| UTF-8 BOM (spec forbids it) | Stripped | +| Wrong file extension on a ZIP | Detected by magic bytes | +| Spaces inside attributes (`STAFFELPERSON:2525; 4;…`) | Trimmed in the lexer | +| Truncated optional attribute tail | Missing attributes are simply absent | +| Missing `DATEIENDE` | Warning; the document is still returned | +| Content after `DATEIENDE` | Ignored, with a warning | diff --git a/docs/extending.md b/docs/extending.md new file mode 100644 index 0000000..5a18c1c --- /dev/null +++ b/docs/extending.md @@ -0,0 +1,125 @@ +# Extending + +Almost every change to the format is a change to one table. This page covers the +four cases that come up. + +## Adding an element + +Say a future format adds `SPONSOR: Name;Betrag;`. + +**1. Add the model** in `model/elements.py`: + +```python +class Sponsor(DsvElement): + """``SPONSOR`` — a sponsor of the meet.""" + + name: str | None = None + amount_cents: int | None = None +``` + +**2. Add the document field** in `model/document.py`: + +```python +sponsors: list[Sponsor] = Field(default_factory=list, description="SPONSOR.") +``` + +**3. Add the table entry** in `spec/elements.py`: + +```python +( + ElementSpec( + element="SPONSOR", + target="sponsors", + model=el.Sponsor, + versions=V9, + file_types=DEFINITION, + description="A sponsor of the meet.", + attributes=( + A("name", Kind.TEXT, "Sponsorname"), + A("amount_cents", Kind.AMOUNT, "Betrag"), + ), + ), +) +``` + +Nothing else changes. The binder, the CLI, the API and the generated spec output +pick it up. `tests/unit/test_spec.py` checks that the field names line up. + +## Adding a version delta + +An attribute added in a later version gets a `versions=` marker. Position matters: +an attribute in the middle shifts everything after it, which is exactly what the +marker expresses. + +```python +(A("account_holder", Kind.TEXT, "Kontoinhaber", versions=V8),) +``` + +An attribute that exists only in some list kinds works the same way: + +```python +(A("direct_debit_approved", Kind.FLAG, "Lastschrift", versions=V8, file_types=CLUB_ENTRIES),) +``` + +A whole element gets the marker on the `ElementSpec` instead. The shorthands are +declared at the top of `spec/elements.py`: `V8`, `V7_PLUS`, `V6_PLUS`. + +Add a test in `tests/unit/test_spec.py` asserting the attribute count per version: + +```python +def test_bankverbindung_gains_kontoinhaber_in_format_8() -> None: + spec = REGISTRY.lookup("BANKVERBINDUNG", 7, FileType.DEFINITION) + assert len(spec.active(7, FileType.DEFINITION)) == 3 + assert len(spec.active(8, FileType.DEFINITION)) == 4 +``` + +## Adding a variant layout + +When an element has two genuinely different shapes rather than one shape with +optional attributes, declare it twice. Order matters: `Registry.lookup` returns +the first applicable entry, so the specific variant goes first. + +```python +(ElementSpec(element="STAFFELPERSON", file_types=CLUB_ENTRIES, attributes=(...)),) +(ElementSpec(element="STAFFELPERSON", attributes=(...)),) # everything else +``` + +## Adding a code + +Vocabularies live in `model/enums.py`. A member is `NAME = ("speaking_value", +"CODE")`; put the version it appeared in into the docstring, since the enum itself +is not version-scoped. + +```python +class Exercise(DsvEnum): + KICKS_PRONE = ("kicks_prone", "KB") + """Kicks Bauchlage — Format 8 onwards, and only with ``Technik = S``.""" +``` + +Keeping the enum a superset across versions is deliberate: an older file never +contains the newer code, and a file that does contain it can be read. + +Removed codes stay too, so that older files keep parsing. `BestListCategory. +OPEN_WATER` (`FS`) is the example; Format 7 dropped it. + +## Supporting a new format version + +1. Add the number to `VERSIONS` in `spec/fields.py`. +2. Add the shorthand (`V9 = frozenset({9})`) in `spec/elements.py`. +3. Work through the change log of the new specification and mark each entry. +4. Note the source document in the module docstring of `spec/elements.py`. +5. Add a fixture under `tests/fixtures/` and a test per structural change. + +The parser accepts an unknown version already: it warns, then reads with the +newest known layout. + +## Checking the result + +```bash +make test # includes the table invariants +make spec # eyeball the rendered table +dsv-parser check real-file.DSV8 +``` + +`check` against real files is the useful last step. A layout this table does not +model shows up as "attribute(s) beyond the N this layout declares". diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..6eedeb6 --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,97 @@ +# Getting Started + +## Install + +```bash +make install # uv sync --extra api +``` + +The `api` extra adds FastAPI and uvicorn. The parsing core needs only Pydantic: + +```bash +uv add dsv-parser # library only +uv add "dsv-parser[api]" # with the HTTP service +``` + +## Parse a file + +```python +from dsv_parser import parse_file + +result = parse_file("2026-06-13-Berlin-Pr.DSV8") +``` + +`ParseResult` has three parts: + +| Attribute | Contents | +| ------------- | --------------------------------------------------------------------- | +| `document` | The parsed [`DsvDocument`](./data-schema.md), never `None` | +| `diagnostics` | Errors and warnings collected while reading | +| `source` | How the bytes were decoded: encoding, and whether the file was zipped | + +```python +document = result.document +print(document.file_type, document.version) # meet_results 8 +print(document.meet.name, document.meet.city) +print(document.element_counts()) # {'sections': 4, 'events': 62, …} + +for swim in document.individual_results: + print(swim.place, swim.name, swim.time_millis) # milliseconds, None = no time +``` + +Two more entry points take bytes or already-decoded text: + +```python +from dsv_parser import parse_bytes, parse_text + +parse_bytes(upload.read()) # unwraps .dsv8z on its own +parse_text(source) # when you decoded the file yourself +``` + +## Handle diagnostics + +```python +if not result.clean: + for entry in result.diagnostics.errors: # data was lost + print(entry.render()) + for entry in result.diagnostics.warnings: # recovered + print(entry.render()) +``` + +```text +error: line 412 (WETTKAMPF#6 Technik): unknown Stroke code +warning: line 1204 (VEREIN): 2 attribute(s) beyond the 4 this layout declares, ignored +``` + +Each `Diagnostic` carries `severity`, `message`, `line`, `element`, `attribute`, +`field` (the German name from the spec) and the offending `value`, so a UI can +point at the exact position in the file. `render()` is only a convenience format. + +`parse_file` does raise `OSError` when the file cannot be read at all. An +unreadable file is a caller error; a malformed one is not. + +## From the command line + +```bash +dsv-parser parse meet.DSV8 -o meet.json # document as JSON +dsv-parser check meet.DSV8 # validate, exit 1 on data loss +dsv-parser spec # the element table +``` + +Diagnostics go to stderr and the document to stdout, so +`dsv-parser parse meet.DSV8 > meet.json` writes clean JSON and still shows the +problems. Full reference: [CLI](./cli.md). + +## As a service + +```bash +make serve # uvicorn on :8000 with reload +make up # the same in Docker +``` + +```bash +curl -F file=@meet.DSV8 http://localhost:8000/parse +``` + +Interactive docs at `http://localhost:8000/docs`, the schema at `/openapi.json`. +Full reference: [HTTP API](./api.md). diff --git a/docs/intro.md b/docs/intro.md new file mode 100644 index 0000000..71f31f3 --- /dev/null +++ b/docs/intro.md @@ -0,0 +1,49 @@ +# Introduction + +DSV Parser reads the swim-meet interchange files of the Deutscher Schwimm-Verband +(`.dsv5` through `.dsv8`, including the packed `.dsv8z`) into a typed data schema. + +## Coverage + +Format 6, 7 and 8 are transcribed from the official specifications and cover all +four Listarten with every element and attribute. Format 5 is read as well; its +layouts are derived from the Format 6 change log rather than from the Format 5 +document, and the parser says so in a warning. Every version difference is one +marked line in one table, described in the [format reference](./dsv-format.md). + +## Reading is tolerant + +The reader does not raise on file content. A value it cannot represent becomes +`null` and produces a diagnostic that names the line, the element, the attribute +position and its German name from the spec. A caller therefore gets a partial +document plus a list of what was lost, which is what an import pipeline needs in +order to mark a file as partially imported instead of failing it. + +## Three surfaces, one reader + +The Python library, the CLI and the FastAPI service all go through the same code. +The service publishes the document schema as OpenAPI, so consumers in Go, Java or +TypeScript can generate a client instead of reimplementing the format. + +## Where to go next + +| | | +| --- | --- | +| [Getting Started](./getting-started.md) | Install, parse a file, run the service | +| [Architecture](./architecture.md) | Pipeline and the element table | +| [DSV Format](./dsv-format.md) | The format and the version differences | +| [Data Schema](./data-schema.md) | How the document is laid out | +| [HTTP API](./api.md) | Endpoints, payloads, client generation | +| [CLI](./cli.md) | Subcommands, flags, exit codes | +| [Extending](./extending.md) | Adding elements, versions and code lists | + +## Out of scope + +Writing DSV files is not implemented. The element table would be usable for a +writer and `core/values.py` already has the `format_*` counterparts, but nothing +consumes them yet. `dsv_parser/naming.py` does implement the file-naming +convention. + +Cross-element validation is also out of scope. The reader checks that a line +matches its declared layout, not that a `STARTPN` refers to a `PNMELDUNG` that +exists. That check needs the assembled document and belongs in the consumer. diff --git a/dsv_parser/__init__.py b/dsv_parser/__init__.py new file mode 100644 index 0000000..f669341 --- /dev/null +++ b/dsv_parser/__init__.py @@ -0,0 +1,40 @@ +"""Parser for DSV swim-meet interchange files (Format 5 through 8). + +The public surface is deliberately three functions and two types:: + + from dsv_parser import parse_file + + result = parse_file("2026-06-13-Berlin-Pr.DSV8") + print(result.document.meet.name, len(result.document.individual_results)) + for entry in result.diagnostics.entries: + print(entry.render()) + +Everything else — the element table, the lexer, the binder — is available for a +caller that needs it, but reading a file needs none of it. +""" + +from importlib.metadata import PackageNotFoundError, version + +from .core.diagnostics import Diagnostic, Diagnostics, Severity +from .core.parser import ParseResult, parse_bytes, parse_file, parse_text +from .model.document import DsvDocument +from .model.enums import FileType + +__all__ = [ + "Diagnostic", + "Diagnostics", + "DsvDocument", + "FileType", + "ParseResult", + "Severity", + "parse_bytes", + "parse_file", + "parse_text", +] + +#: Read from the installed distribution metadata, so the version lives in exactly one +#: place (``pyproject.toml``, bumped by Release Please) and cannot drift from it. +try: + __version__ = version("dsv-parser") +except PackageNotFoundError: # pragma: no cover - only when running from a bare checkout + __version__ = "0.0.0" diff --git a/dsv_parser/__main__.py b/dsv_parser/__main__.py new file mode 100644 index 0000000..4f04b3f --- /dev/null +++ b/dsv_parser/__main__.py @@ -0,0 +1,8 @@ +"""``python -m dsv_parser`` entrypoint.""" + +from __future__ import annotations + +from .cli import main + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/dsv_parser/api/__init__.py b/dsv_parser/api/__init__.py new file mode 100644 index 0000000..29db3a4 --- /dev/null +++ b/dsv_parser/api/__init__.py @@ -0,0 +1,11 @@ +"""The optional HTTP surface. + +Requires the ``api`` extra (``uv sync --extra api``). Run it with:: + + uvicorn dsv_parser.api:app --host 0.0.0.0 --port 8000 +""" + +from .app import app, create_app +from .routes import router + +__all__ = ["app", "create_app", "router"] diff --git a/dsv_parser/api/app.py b/dsv_parser/api/app.py new file mode 100644 index 0000000..ac5640b --- /dev/null +++ b/dsv_parser/api/app.py @@ -0,0 +1,64 @@ +"""FastAPI application factory. + +The service is a pure function over its request body — no database, no cache, no +background work, no state between requests — so there is no lifespan, no +settings object and nothing to warm up. Keeping it that way is the point: it +scales by replication and it can be embedded in another app with +``app.include_router(dsv_parser.api.router)``. +""" + +from __future__ import annotations + +import logging +import os + +from fastapi import FastAPI + +from .. import __version__ +from .routes import router + +DESCRIPTION = """ +Parses DSV swim-meet interchange files (Format 5 through 8, including ZIP-packed +`.dsv8z`) into a typed JSON document. + +The document schema is published in this OpenAPI document, so a client in any +language can be generated from `/openapi.json`. `GET /spec` returns the element +table the parser implements, including every coded vocabulary. +""".strip() + + +def configure_logging(level: str | None = None) -> None: + """Install a root log handler. + + uvicorn leaves the root logger without a handler, which silently drops every + record the package's module loggers emit. + + Args: + level: The level name; defaults to ``$LOG_LEVEL`` or ``INFO``. + """ + resolved = (level or os.getenv("LOG_LEVEL") or "INFO").upper() + logging.basicConfig( + level=resolved, + format="%(asctime)s %(levelname)-8s %(name)s: %(message)s", + force=True, + ) + + +def create_app() -> FastAPI: + """Build the application. + + Returns: + The configured FastAPI app. + """ + configure_logging() + application = FastAPI( + title="DSV Parser", + description=DESCRIPTION, + version=__version__, + ) + application.include_router(router) + return application + + +#: Module-level instance for ``uvicorn dsv_parser.api:app``. +app = create_app() diff --git a/dsv_parser/api/routes.py b/dsv_parser/api/routes.py new file mode 100644 index 0000000..f91df66 --- /dev/null +++ b/dsv_parser/api/routes.py @@ -0,0 +1,156 @@ +"""The HTTP endpoints. + +Four routes, all stateless and all synchronous. Parsing is CPU-bound and fast +(a large meet protocol is a few megabytes of text), so the handlers are plain +``def`` — FastAPI runs those in a worker thread, which keeps the event loop free +without the ceremony of an executor. + +Upload size is capped: the service is meant to be called by other services, and +an unbounded ``UploadFile`` read is the one way a caller can take the process +down with a single request. +""" + +from __future__ import annotations + +from fastapi import APIRouter, File, HTTPException, Query, Response, UploadFile, status +from fastapi.responses import JSONResponse + +from .. import __version__ +from ..core.parser import ParseResult, parse_bytes +from ..spec.fields import VERSIONS +from ..spec.render import describe_registry +from .schemas import CheckResponse, HealthResponse, ParseResponse, SourceInfo + +#: Refuse an upload larger than this. A DSV meet protocol for a large national +#: championship is well under 10 MB even uncompressed; 32 MB leaves generous room +#: while still bounding the memory one request can claim. +MAX_UPLOAD_BYTES = 32 * 1024 * 1024 + +router = APIRouter() + + +def _read_upload(upload: UploadFile) -> bytes: + """Read an uploaded file, refusing anything oversized. + + Args: + upload: The multipart upload. + + Returns: + The raw bytes. + + Raises: + HTTPException: 413 if the upload exceeds :data:`MAX_UPLOAD_BYTES`. + """ + content = upload.file.read(MAX_UPLOAD_BYTES + 1) + if len(content) > MAX_UPLOAD_BYTES: + raise HTTPException( + status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, + detail=f"file exceeds the {MAX_UPLOAD_BYTES} byte limit", + ) + return content + + +def _source(result: ParseResult) -> SourceInfo: + """Project the decoding provenance onto its response model. + + Args: + result: The parse result. + + Returns: + The source info. + """ + return SourceInfo( + encoding=result.source.encoding, + zipped=result.source.zipped, + member=result.source.member, + ) + + +@router.get("/health", response_model=HealthResponse, tags=["meta"]) +def health() -> HealthResponse: + """Report liveness and what this build understands. + + Returns: + The health payload. + """ + return HealthResponse(status="ok", version=__version__, supported_formats=sorted(VERSIONS)) + + +@router.get("/spec", tags=["meta"]) +def spec() -> dict[str, object]: + """Return the element table this parser implements. + + Generated from the table itself, so it can never drift from the reader. + + Returns: + The elements and the coded vocabularies. + """ + return describe_registry() + + +@router.post("/parse", response_model=ParseResponse, tags=["parse"]) +def parse( + file: UploadFile = File(description="A .dsv5–.dsv8 file, or a ZIP-packed .dsv8z."), + exclude_none: bool = Query( + default=False, + description=( + "Omit null fields from the document. Much smaller responses, at the cost " + "of a variable shape — a generated client should leave this off." + ), + ), +) -> Response | ParseResponse: + """Parse an uploaded DSV file into the typed document schema. + + The response is always 200 for a readable upload: content problems are + reported as diagnostics, not as HTTP errors, because a partially readable + file is a normal and useful outcome for the callers this serves. + + Args: + file: The uploaded DSV file. + exclude_none: Whether to drop null fields from the document. + + Returns: + The document, its diagnostics and the decoding provenance. With + ``exclude_none`` the pruned payload is written straight to the response: + returning the model instead would have FastAPI re-serialise it through + ``response_model``, which puts every null back. + """ + result = parse_bytes(_read_upload(file)) + response = ParseResponse( + filename=file.filename, + clean=result.clean, + source=_source(result), + document=result.document, + diagnostics=result.diagnostics.entries, + ) + if exclude_none: + return JSONResponse(response.model_dump(mode="json", exclude_none=True)) + return response + + +@router.post("/check", response_model=CheckResponse, tags=["parse"]) +def check( + file: UploadFile = File(description="A .dsv5–.dsv8 file, or a ZIP-packed .dsv8z."), +) -> CheckResponse: + """Validate an uploaded DSV file without returning the document. + + The cheap call for an ingest pipeline that only needs to know whether a file + is worth taking, and for a UI that wants to show the user what is wrong with + the file they just picked. + + Args: + file: The uploaded DSV file. + + Returns: + The header, element counts and diagnostics. + """ + result = parse_bytes(_read_upload(file)) + return CheckResponse( + filename=file.filename, + clean=result.clean, + file_type=result.document.file_type.value if result.document.file_type else None, + version=result.document.version, + source=_source(result), + elements=result.document.element_counts(), + diagnostics=result.diagnostics.entries, + ) diff --git a/dsv_parser/api/schemas.py b/dsv_parser/api/schemas.py new file mode 100644 index 0000000..3b99f8d --- /dev/null +++ b/dsv_parser/api/schemas.py @@ -0,0 +1,68 @@ +"""Request and response envelopes of the HTTP surface. + +The document schema itself lives in :mod:`dsv_parser.model` and is reused +verbatim, so the OpenAPI document FastAPI generates is the same schema the +library exposes — a client generated from ``/openapi.json`` in any language gets +exactly the types this parser produces. +""" + +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, Field + +from ..core.diagnostics import Diagnostic +from ..model.document import DsvDocument + + +class SourceInfo(BaseModel): + """How the uploaded bytes were decoded.""" + + model_config = ConfigDict(extra="forbid") + + encoding: str = Field(description="The encoding the file was decoded with.") + zipped: bool = Field(description="Whether the upload was a ZIP-packed .DSV8z.") + member: str | None = Field(default=None, description="Name of the archive member, when zipped.") + + +class ParseResponse(BaseModel): + """The result of parsing one uploaded file.""" + + model_config = ConfigDict(extra="forbid") + + filename: str | None = Field( + default=None, description="The uploaded file's name, echoed back for correlation." + ) + clean: bool = Field(description="True when no data was lost.") + source: SourceInfo = Field(description="Decoding provenance.") + document: DsvDocument = Field(description="The parsed document.") + diagnostics: list[Diagnostic] = Field( + default_factory=list, description="Everything the reader had to complain about." + ) + + +class CheckResponse(BaseModel): + """The result of validating one uploaded file, without the document.""" + + model_config = ConfigDict(extra="forbid") + + filename: str | None = Field(default=None, description="The uploaded file's name.") + clean: bool = Field(description="True when no data was lost.") + file_type: str | None = Field(default=None, description="Listart of the FORMAT element.") + version: int | None = Field(default=None, description="Declared format version.") + source: SourceInfo = Field(description="Decoding provenance.") + elements: dict[str, int] = Field( + default_factory=dict, description="Element counts per document block." + ) + diagnostics: list[Diagnostic] = Field( + default_factory=list, description="Errors and warnings, in file order." + ) + + +class HealthResponse(BaseModel): + """Liveness payload.""" + + model_config = ConfigDict(extra="forbid") + + status: str = Field(description="Always 'ok' when the service is serving.") + version: str = Field(description="The dsv-parser package version.") + supported_formats: list[int] = Field(description="The DSV format versions understood.") diff --git a/dsv_parser/cli.py b/dsv_parser/cli.py new file mode 100644 index 0000000..5b63d33 --- /dev/null +++ b/dsv_parser/cli.py @@ -0,0 +1,229 @@ +"""Command-line interface. + +Three subcommands, each doing one thing to one file: + +``parse`` + Read a DSV file and write the document as JSON — the workhorse. Reads stdin + when the path is ``-``, so it composes in a shell pipeline. +``check`` + Read a DSV file and report only the diagnostics, exiting non-zero when data + was lost. Built for CI and batch validation. +``spec`` + Print the element table this parser implements, generated from the table + itself. + +``serve`` is deliberately absent: the HTTP surface is an optional extra, and +running it is ``uvicorn dsv_parser.api:app`` — no wrapper worth maintaining. +""" + +from __future__ import annotations + +import argparse +import json +import sys +from collections.abc import Sequence +from pathlib import Path +from typing import Any + +from .core.parser import ParseResult, parse_bytes +from .spec.render import describe_registry, render_text + +EXIT_OK = 0 +EXIT_DATA_LOSS = 1 +EXIT_USAGE = 2 + + +def _read_input(path: str) -> bytes: + """Read the file to parse, from disk or stdin. + + Args: + path: A filesystem path, or ``-`` for stdin. + + Returns: + The raw bytes. + """ + if path == "-": + return sys.stdin.buffer.read() + return Path(path).read_bytes() + + +def _write_json(payload: Any, destination: str | None, *, indent: int | None) -> None: + """Write a JSON payload to a file or stdout. + + Args: + payload: Anything :func:`json.dumps` accepts. + destination: Output path, or ``None`` for stdout. + indent: Indentation, or ``None`` for the compact single-line form. + """ + text = json.dumps(payload, ensure_ascii=False, indent=indent, default=str) + if destination is None: + sys.stdout.write(text + "\n") + else: + Path(destination).write_text(text + "\n", encoding="utf-8") + + +def _report(result: ParseResult, stream: Any) -> None: + """Print every diagnostic of a parse run. + + Args: + result: The parse result. + stream: Where to write — stderr for ``parse``, stdout for ``check``. + """ + for entry in result.diagnostics.entries: + print(entry.render(), file=stream) + + +def cmd_parse(args: argparse.Namespace) -> int: + """Parse a file and emit the document as JSON. + + Args: + args: Parsed CLI arguments. + + Returns: + ``0`` on a clean parse, ``1`` when data was lost and ``--strict`` is set. + """ + result = parse_bytes(_read_input(args.path)) + payload: Any + if args.summary: + payload = { + "file_type": result.document.file_type, + "version": result.document.version, + "encoding": result.source.encoding, + "zipped": result.source.zipped, + "elements": result.document.element_counts(), + "errors": len(result.diagnostics.errors), + "warnings": len(result.diagnostics.warnings), + } + else: + payload = result.document.model_dump(mode="json", exclude_none=args.exclude_none) + if args.diagnostics: + payload = { + "document": payload, + "diagnostics": [ + entry.model_dump(mode="json", exclude_none=True) + for entry in result.diagnostics.entries + ], + } + _write_json(payload, args.output, indent=None if args.compact else 2) + if not args.quiet: + _report(result, sys.stderr) + return EXIT_DATA_LOSS if args.strict and not result.clean else EXIT_OK + + +def cmd_check(args: argparse.Namespace) -> int: + """Validate a file and report its diagnostics. + + Args: + args: Parsed CLI arguments. + + Returns: + ``0`` when no data was lost, ``1`` otherwise. + """ + result = parse_bytes(_read_input(args.path)) + _report(result, sys.stdout) + errors = len(result.diagnostics.errors) + warnings = len(result.diagnostics.warnings) + counts = result.document.element_counts() + total = sum(counts.values()) + file_type = result.document.file_type + print( + f"{args.path}: {file_type.value if file_type else 'unknown list kind'} " + f"format {result.document.version or '?'}, {total} elements, " + f"{errors} error(s), {warnings} warning(s)" + ) + return EXIT_DATA_LOSS if errors else EXIT_OK + + +def cmd_spec(args: argparse.Namespace) -> int: + """Print the element table this parser implements. + + Args: + args: Parsed CLI arguments. + + Returns: + Always ``0``. + """ + if args.json: + _write_json(describe_registry(), args.output, indent=2) + elif args.output is None: + sys.stdout.write(render_text()) + else: + Path(args.output).write_text(render_text(), encoding="utf-8") + return EXIT_OK + + +def build_parser() -> argparse.ArgumentParser: + """Build the argument parser. + + Returns: + The configured parser, with one subparser per subcommand. + """ + parser = argparse.ArgumentParser( + prog="dsv-parser", + description="Parse DSV swim-meet interchange files (Format 5–8) into a typed schema.", + ) + parser.set_defaults(handler=None) + subcommands = parser.add_subparsers(dest="command", metavar="COMMAND") + + parse_cmd = subcommands.add_parser( + "parse", help="Parse a DSV file and write the document as JSON." + ) + parse_cmd.add_argument("path", help="Path to the DSV file, or '-' for stdin.") + parse_cmd.add_argument("-o", "--output", help="Write to this path instead of stdout.") + parse_cmd.add_argument( + "--compact", action="store_true", help="Emit single-line JSON instead of indented." + ) + parse_cmd.add_argument( + "--exclude-none", + action="store_true", + help="Omit null fields — much smaller output, at the cost of a variable shape.", + ) + parse_cmd.add_argument( + "--diagnostics", + action="store_true", + help="Wrap the document together with the diagnostics in one envelope.", + ) + parse_cmd.add_argument( + "--summary", + action="store_true", + help="Emit only element counts and the header, not the document.", + ) + parse_cmd.add_argument( + "-q", "--quiet", action="store_true", help="Do not print diagnostics to stderr." + ) + parse_cmd.add_argument( + "--strict", action="store_true", help="Exit non-zero when any data was lost." + ) + parse_cmd.set_defaults(handler=cmd_parse) + + check_cmd = subcommands.add_parser( + "check", help="Validate a DSV file and report its diagnostics." + ) + check_cmd.add_argument("path", help="Path to the DSV file, or '-' for stdin.") + check_cmd.set_defaults(handler=cmd_check) + + spec_cmd = subcommands.add_parser( + "spec", help="Print the element table this parser implements." + ) + spec_cmd.add_argument("--json", action="store_true", help="Emit JSON instead of text.") + spec_cmd.add_argument("-o", "--output", help="Write to this path instead of stdout.") + spec_cmd.set_defaults(handler=cmd_spec) + + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + """Run the CLI. + + Args: + argv: Argument vector, defaulting to :data:`sys.argv`. + + Returns: + The process exit code. + """ + parser = build_parser() + args = parser.parse_args(argv) + if args.handler is None: + parser.print_help() + return EXIT_USAGE + return int(args.handler(args)) diff --git a/dsv_parser/core/__init__.py b/dsv_parser/core/__init__.py new file mode 100644 index 0000000..e6c585e --- /dev/null +++ b/dsv_parser/core/__init__.py @@ -0,0 +1 @@ +"""The reader: bytes → text → element lines → typed document.""" diff --git a/dsv_parser/core/binder.py b/dsv_parser/core/binder.py new file mode 100644 index 0000000..9c6ec9f --- /dev/null +++ b/dsv_parser/core/binder.py @@ -0,0 +1,145 @@ +"""Element line + layout → typed model instance. + +This is the generic half of the parser: it knows how to turn positional text into +a typed object given an :class:`~dsv_parser.spec.fields.ElementSpec`, and it knows +nothing whatsoever about any particular DSV element. Every element-specific fact +lives in the table. + +The binding contract is tolerant by design, matching the reader's promise that a +single bad value never costs more than that value: + +* A value that does not match its declared type becomes ``None`` and an ``ERROR`` + diagnostic naming the line, the attribute position and the spec attribute name. +* An unknown enum code becomes ``None`` and an ``ERROR``. +* Missing trailing attributes are simply absent — writers legitimately truncate + the optional tail. +* Surplus attributes beyond the layout produce one ``WARNING`` per line. This is + the signal that catches a version delta the table does not know about yet, so it + is worth a diagnostic even though nothing is lost. +""" + +from __future__ import annotations + +from typing import Any + +from ..model.elements import DsvElement +from ..model.enums import FileType +from ..spec.fields import Attribute, ElementSpec, Kind +from .diagnostics import Diagnostics +from .lexer import ElementLine +from .values import ( + DsvValueError, + parse_amount_cents, + parse_clock, + parse_date, + parse_flag, + parse_integer, + parse_sign, + parse_swim_time, +) + +#: Coercion for every :class:`~dsv_parser.spec.fields.Kind` except ``ENUM`` and +#: ``TEXT``, both of which need context the table carries. +_SCALARS = { + Kind.INT: parse_integer, + Kind.SWIM_TIME: parse_swim_time, + Kind.DATE: parse_date, + Kind.CLOCK: parse_clock, + Kind.AMOUNT: parse_amount_cents, + Kind.FLAG: parse_flag, + Kind.SIGN: parse_sign, +} + + +def bind( + line: ElementLine, + spec: ElementSpec, + version: int | None, + file_type: FileType | None, + diagnostics: Diagnostics, +) -> DsvElement: + """Bind one element line to its model. + + Args: + line: The tokenized element line. + spec: The layout to read it with. + version: The declared format version, steering conditional attributes. + file_type: The declared list kind, steering conditional attributes. + diagnostics: Collector for malformed values and surplus attributes. + + Returns: + The populated model. Never ``None``: an element with only bad values still + yields an instance, carrying its source line so the caller can find it. + """ + active = spec.active(version, file_type) + values: dict[str, Any] = {"source_line": line.line} + for position, attribute in enumerate(active): + if position >= len(line.attributes): + break + raw = line.attributes[position] + values[attribute.name] = _coerce(raw, attribute, position, line, diagnostics) + if len(line.attributes) > len(active): + surplus = len(line.attributes) - len(active) + diagnostics.warning( + f"{surplus} attribute(s) beyond the {len(active)} this layout declares, ignored" + f" — the file may use a format version this parser does not model yet", + line=line.line, + element=line.element, + ) + return spec.model(**values) + + +def _coerce( + raw: str, + attribute: Attribute, + position: int, + line: ElementLine, + diagnostics: Diagnostics, +) -> object | None: + """Convert one raw attribute to its declared type. + + Args: + raw: The raw attribute text, untrimmed. + attribute: The layout entry describing it. + position: Zero-based position, for the diagnostic. + line: The element line, for the diagnostic. + diagnostics: Collector for a malformed value or unknown code. + + Returns: + The typed value, or ``None`` when the attribute is blank or unreadable. + """ + if attribute.kind is Kind.TEXT: + return raw.strip() or None + + if attribute.kind is Kind.ENUM: + if attribute.enum is None: # pragma: no cover - a bug in the element table + raise TypeError( + f"attribute {attribute.name!r} of {line.element} is declared ENUM " + "but names no vocabulary" + ) + if not raw.strip(): + return None + member = attribute.enum.from_code(raw) + if member is None: + diagnostics.error( + f"unknown {attribute.enum.__name__} code", + line=line.line, + element=line.element, + attribute=position + 1, + field=attribute.german, + value=raw.strip()[:40], + ) + return member + + try: + return _SCALARS[attribute.kind](raw) + except DsvValueError as exc: + diagnostics.error( + str(exc), + line=line.line, + element=line.element, + attribute=position + 1, + field=attribute.german, + value=raw.strip()[:40], + ) + return None diff --git a/dsv_parser/core/decoding.py b/dsv_parser/core/decoding.py new file mode 100644 index 0000000..d7d094d --- /dev/null +++ b/dsv_parser/core/decoding.py @@ -0,0 +1,134 @@ +"""Raw bytes to DSV source text. + +Three real-world facts sit between a ``.dsv*`` file on disk and the text the lexer +wants, and each one is handled here so no other module has to care: + +1. **ZIP packaging.** ``.DSV8z`` (and the occasional ``.DSV7z``) is a ZIP archive + around a single plain DSV file. Detected by the ``PK\\x03\\x04`` magic, not by + the file extension — the extension is frequently wrong. +2. **Encoding.** Format 7/8 mandate UTF-8, but Format 5/6 predate that and are + almost always Windows-1252 (EasyWk on Windows). There is no declaration in the + file, so the encoding is sniffed: BOM wins, then strict UTF-8, then CP1252. + A wrong guess yields mojibake rather than an exception, so the fallback is + reported as a warning and the caller can re-read with an explicit encoding. +3. **Byte-order mark.** Forbidden by the spec and emitted anyway; stripped. +""" + +from __future__ import annotations + +import io +import zipfile +from dataclasses import dataclass + +from .diagnostics import Diagnostics + +#: A ZIP file starts with one of three "PK" signatures: a local file header +#: (\x03\x04, the normal case), the end-of-central-directory record (\x05\x06, an +#: archive with no entries) or a spanned-archive marker (\x07\x08). Detecting only +#: the first would silently hand an empty archive's bytes to the lexer as text. +ZIP_SIGNATURES = (b"PK\x03\x04", b"PK\x05\x06", b"PK\x07\x08") +UTF8_BOM = b"\xef\xbb\xbf" +UTF16_LE_BOM = b"\xff\xfe" +UTF16_BE_BOM = b"\xfe\xff" + +#: Tried in order, strictly, once the BOM check comes up empty. CP1252 leaves five +#: byte values undefined, so even it can fail — :func:`_decode_text` closes with a +#: lossy pass so that :func:`decode` never raises. +ENCODING_CANDIDATES = ("utf-8", "cp1252") + + +@dataclass(frozen=True, slots=True) +class DecodedSource: + """The decoded text of one DSV file plus how it got there. + + Attributes: + text: The DSV source, BOM-stripped, with the original line endings intact. + encoding: The encoding that was used, for reporting and round-tripping. + zipped: Whether the input was a ZIP-packed ``.DSV8z``-style archive. + member: Name of the archive member the text came from, if zipped. + """ + + text: str + encoding: str + zipped: bool = False + member: str | None = None + + +def decode(content: bytes, diagnostics: Diagnostics) -> DecodedSource: + """Turn raw file bytes into DSV source text. + + Args: + content: The raw bytes — either plain DSV text or a ZIP archive around it. + diagnostics: Collector for archive problems and encoding fallbacks. + + Returns: + The decoded source. An unreadable archive yields empty text plus an error + diagnostic rather than an exception, so a batch import can carry on. + """ + payload, zipped, member = _unzip_if_needed(content, diagnostics) + text, encoding = _decode_text(payload, diagnostics) + return DecodedSource(text=text, encoding=encoding, zipped=zipped, member=member) + + +def _unzip_if_needed(content: bytes, diagnostics: Diagnostics) -> tuple[bytes, bool, str | None]: + """Unwrap a ZIP-packed DSV file; pass plain content through untouched. + + Args: + content: The raw bytes. + diagnostics: Collector for an empty or corrupt archive. + + Returns: + A triple of (payload bytes, whether it was zipped, archive member name). + """ + if not content.startswith(ZIP_SIGNATURES): + return content, False, None + try: + with zipfile.ZipFile(io.BytesIO(content)) as archive: + names = [name for name in archive.namelist() if not name.endswith("/")] + if not names: + diagnostics.error("ZIP archive contains no file entry") + return b"", True, None + if len(names) > 1: + diagnostics.warning( + f"ZIP archive contains {len(names)} entries; reading only {names[0]!r}" + ) + return archive.read(names[0]), True, names[0] + except (zipfile.BadZipFile, OSError, RuntimeError, NotImplementedError) as exc: + # RuntimeError: zipfile's complaint about an encrypted entry; + # NotImplementedError: a compression method the stdlib cannot decompress. + diagnostics.error(f"ZIP archive could not be read: {exc}") + return b"", True, None + + +def _decode_text(payload: bytes, diagnostics: Diagnostics) -> tuple[str, str]: + """Decode payload bytes, sniffing the encoding and stripping any BOM. + + Args: + payload: The (already unzipped) text bytes. + diagnostics: Collector for the non-UTF-8 fallback warning. + + Returns: + A pair of (decoded text, the encoding name that was used). + """ + if payload.startswith(UTF8_BOM): + return payload[len(UTF8_BOM) :].decode("utf-8", errors="replace"), "utf-8-sig" + for bom, encoding in ((UTF16_LE_BOM, "utf-16-le"), (UTF16_BE_BOM, "utf-16-be")): + if payload.startswith(bom): + diagnostics.warning(f"file is {encoding} encoded; the DSV spec mandates UTF-8") + return payload[len(bom) :].decode(encoding, errors="replace"), encoding + for encoding in ENCODING_CANDIDATES: + try: + text = payload.decode(encoding) + except UnicodeDecodeError: + continue + if encoding != "utf-8": + diagnostics.warning( + f"file is not valid UTF-8; decoded as {encoding} " + "(usual for Format 5/6 files written on Windows)" + ) + return text, encoding + # Every strict candidate failed (CP1252 leaves 0x81/0x8D/0x8F/0x90/0x9D undefined). + # Latin-1 maps all 256 byte values, so this pass cannot fail and keeps the readable + # ASCII backbone of the file — element names, numbers, separators — intact. + diagnostics.warning("file matches no known encoding; decoded as latin-1, text may be garbled") + return payload.decode("latin-1"), "latin-1" diff --git a/dsv_parser/core/diagnostics.py b/dsv_parser/core/diagnostics.py new file mode 100644 index 0000000..d7b992e --- /dev/null +++ b/dsv_parser/core/diagnostics.py @@ -0,0 +1,125 @@ +"""Parse diagnostics: what the tolerant reader had to complain about. + +The parser never raises on content problems. Everything it cannot represent, and +everything it had to recover from, is collected here with full provenance (line +number, element, attribute position) so a caller can decide for itself whether a +file is good enough — an import pipeline can mark a file ``PARTIAL`` on errors, a +validation UI can point at the offending line, and a batch job can simply count +them. + +Severity is a two-value scale on purpose: + +``ERROR`` + Data was lost. The attribute could not be represented and is ``None`` in the + document; the surrounding element is still kept with everything else intact. +``WARNING`` + An oddity that was recovered without losing data — an unknown element, a + surplus attribute, a non-UTF-8 encoding, a missing ``DATEIENDE``. +""" + +from __future__ import annotations + +from enum import StrEnum + +from pydantic import BaseModel, Field + + +class Severity(StrEnum): + """How bad a diagnostic is for the resulting document.""" + + ERROR = "error" + WARNING = "warning" + + +class Diagnostic(BaseModel): + """One problem found while reading a DSV file. + + Attributes: + severity: Whether data was lost (``ERROR``) or merely recovered (``WARNING``). + message: Human-readable description, in English, without the location prefix. + line: One-based line number in the decoded file, or ``None`` for + whole-file problems (encoding, empty archive). + element: The DSV element constant the problem occurred in, e.g. ``WETTKAMPF``. + attribute: One-based attribute position within the element, when the + problem is attribute-scoped. + field: The spec name of the offending attribute, e.g. ``technik``. + value: The raw attribute text that could not be read, truncated. + """ + + severity: Severity + message: str + line: int | None = None + element: str | None = None + attribute: int | None = None + field: str | None = None + value: str | None = None + + def render(self) -> str: + """Format the diagnostic as a single log/CLI line. + + Returns: + A string of the shape ``error: line 42 (WETTKAMPF#6 technik): …``. + """ + location = [] + if self.line is not None: + location.append(f"line {self.line}") + if self.element is not None: + scope = self.element + if self.attribute is not None: + scope += f"#{self.attribute}" + if self.field is not None: + scope += f" {self.field}" + location.append(f"({scope})") + prefix = " ".join(location) + return ( + f"{self.severity.value}: {prefix}: {self.message}" + if prefix + else (f"{self.severity.value}: {self.message}") + ) + + +class Diagnostics(BaseModel): + """Mutable collector handed through one parse run. + + A plain list would do, but funnelling every report through two named methods + keeps the severity decision at the call site readable and gives one place to + add a cap should a pathological file ever produce millions of entries. + + Attributes: + entries: Every diagnostic in the order it was reported. + """ + + entries: list[Diagnostic] = Field(default_factory=list) + + def error(self, message: str, **location: object) -> None: + """Record data loss. + + Args: + message: What went wrong. + **location: Any of the :class:`Diagnostic` location fields. + """ + self.entries.append(Diagnostic(severity=Severity.ERROR, message=message, **location)) # type: ignore[arg-type] + + def warning(self, message: str, **location: object) -> None: + """Record a recovered oddity. + + Args: + message: What was odd. + **location: Any of the :class:`Diagnostic` location fields. + """ + self.entries.append(Diagnostic(severity=Severity.WARNING, message=message, **location)) # type: ignore[arg-type] + + @property + def errors(self) -> list[Diagnostic]: + """Only the entries that mean data was lost.""" + return [entry for entry in self.entries if entry.severity is Severity.ERROR] + + @property + def warnings(self) -> list[Diagnostic]: + """Only the entries that were recovered without data loss.""" + return [entry for entry in self.entries if entry.severity is Severity.WARNING] + + @property + def clean(self) -> bool: + """Whether the file parsed without any data loss.""" + return not self.errors diff --git a/dsv_parser/core/lexer.py b/dsv_parser/core/lexer.py new file mode 100644 index 0000000..0b70e0d --- /dev/null +++ b/dsv_parser/core/lexer.py @@ -0,0 +1,131 @@ +"""DSV source text to element lines. + +The DSV grammar is deliberately tiny: every meaningful line is +``ELEMENT: attr1;attr2;…;`` and everything between ``(*`` and ``*)`` is a comment. +The one element without a colon is the file terminator ``DATEIENDE``. + +Two details are easy to get wrong and are the reason this is its own module: + +* **Comments are inline, not line-based.** Real EasyWk output routinely ends a data + line with ``(* 100m Schmetterling weiblich *)``. A tokenizer that only skips lines + *starting* with ``(*`` leaves that comment sitting in the last attribute slot, + where it silently becomes a value as soon as an element gains one more attribute. + Comments are therefore stripped wherever they occur, before the split. +* **The trailing semicolon is a terminator, not a separator.** ``A;B;`` has two + attributes, not three, so exactly one trailing empty field is dropped — while + ``A;B;;`` genuinely has an empty third attribute and must keep it. + +Attributes come out trimmed: the spec allows leading and trailing spaces inside an +attribute regardless of its data type, and real files are full of them +(``STAFFELPERSON:2525; 4;4713;1;``). Trimming once here beats trimming in every +value parser. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass + +from .diagnostics import Diagnostics + +#: ``(* … *)`` comments, non-greedy and spanning no more than one line (the format +#: has no multi-line comments; treating them as line-local keeps a stray ``(*`` +#: from swallowing the rest of the file). +COMMENT = re.compile(r"\(\*.*?\*\)") + +#: The three line endings DSV files actually use. Deliberately not +#: ``str.splitlines``, which also breaks on U+2028/U+2029/U+0085/VT/FF — characters +#: that legitimately occur *inside* text attributes (a web-form paste, or byte 0x85 +#: through the latin-1 fallback) and must not cut an element line in two. +LINE_BREAK = re.compile(r"\r\n|\r|\n") + +#: The file terminator, the only element line without a colon. +END_OF_FILE = "DATEIENDE" + + +@dataclass(frozen=True, slots=True) +class ElementLine: + """One tokenized element line. + + Attributes: + element: The element constant before the colon, upper-cased, + e.g. ``WETTKAMPF``. + attributes: The attribute texts in file order, trimmed. Optional + attributes that were left empty are present as empty strings, so + positional indexing stays correct. + line: One-based line number in the decoded file. + raw: The original line, comments and all, for error messages. + """ + + element: str + attributes: tuple[str, ...] + line: int + raw: str + + +def tokenize(text: str, diagnostics: Diagnostics) -> list[ElementLine]: + """Split DSV source text into element lines in file order. + + Blank lines and pure-comment lines are dropped silently; a non-blank line that + is neither an element nor a comment is dropped with a warning. + + Args: + text: The decoded DSV source. + diagnostics: Collector for unparseable lines. + + Returns: + The element lines, in file order. + """ + lines: list[ElementLine] = [] + for index, raw in enumerate(LINE_BREAK.split(text)): + number = index + 1 + stripped = COMMENT.sub("", raw).strip() + if not stripped: + continue + if ":" not in stripped: + if stripped.upper() == END_OF_FILE: + lines.append(ElementLine(END_OF_FILE, (), number, raw)) + else: + diagnostics.warning( + f"not an element line, skipped: {_shorten(stripped)}", line=number + ) + continue + element, _, tail = stripped.partition(":") + lines.append( + ElementLine( + element=element.strip().upper(), + attributes=_split_attributes(tail), + line=number, + raw=raw, + ) + ) + return lines + + +def _split_attributes(tail: str) -> tuple[str, ...]: + """Split the part after the colon into positional attributes. + + Args: + tail: Everything after the element's colon, comments already removed. + + Returns: + The trimmed attribute texts, with the single terminating semicolon + consumed. + """ + attributes = [value.strip() for value in tail.split(";")] + if attributes and not attributes[-1]: + attributes.pop() + return tuple(attributes) + + +def _shorten(raw: str, limit: int = 60) -> str: + """Truncate line content for a diagnostic message. + + Args: + raw: The line content. + limit: Maximum number of characters to keep. + + Returns: + At most ``limit`` characters, ellipsised when cut. + """ + return raw if len(raw) <= limit else raw[: limit - 3] + "..." diff --git a/dsv_parser/core/parser.py b/dsv_parser/core/parser.py new file mode 100644 index 0000000..15fdd00 --- /dev/null +++ b/dsv_parser/core/parser.py @@ -0,0 +1,299 @@ +"""The reader: raw bytes to a typed :class:`~dsv_parser.model.document.DsvDocument`. + +The pipeline is four stages, each in its own module and each testable on its own: + +.. code-block:: text + + bytes ──decoding──▶ text ──lexer──▶ element lines ──binder──▶ models ──▶ document + (zip, encoding) (comments, split) (spec table) (this module) + +What is left here is only the part that genuinely needs the whole file in view: +the ``FORMAT`` line has to be read before anything else because the list kind and +version steer every subsequent layout choice, singular elements have to notice a +duplicate, and the ``DATEIENDE`` terminator has to be enforced. + +The reader never raises on file content. Everything it cannot do is a diagnostic, +so a batch import gets a partial document plus a precise list of what it lost +rather than an exception and nothing. +""" + +from __future__ import annotations + +import datetime as dt +from dataclasses import dataclass +from pathlib import Path + +from ..model.document import DsvDocument +from ..model.elements import DsvElement, EntryDeadline, Format +from ..model.enums import FileType +from ..spec import REGISTRY +from ..spec.fields import VERSIONS, Registry +from .binder import bind +from .decoding import DecodedSource, decode +from .diagnostics import Diagnostics +from .lexer import END_OF_FILE, ElementLine, tokenize + + +@dataclass(frozen=True, slots=True) +class ParseResult: + """The outcome of reading one DSV file. + + Attributes: + document: The best-effort document. Never ``None``, but incomplete when + :attr:`diagnostics` holds errors. + diagnostics: Everything the reader had to complain about. + source: How the bytes were decoded — encoding and ZIP provenance. + """ + + document: DsvDocument + diagnostics: Diagnostics + source: DecodedSource + + @property + def clean(self) -> bool: + """Whether the file parsed without any data loss.""" + return self.diagnostics.clean + + +def parse_bytes(content: bytes, *, registry: Registry = REGISTRY) -> ParseResult: + """Read one DSV file from raw bytes. + + Args: + content: The file bytes — plain DSV text or a ZIP-packed ``.DSV8z``. + registry: The element table to read with. Overridable so a caller can + extend the table without forking the reader. + + Returns: + The parse result: document plus diagnostics plus decoding provenance. + """ + diagnostics = Diagnostics() + source = decode(content, diagnostics) + lines = tokenize(source.text, diagnostics) + document = _assemble(lines, registry, diagnostics) + return ParseResult(document=document, diagnostics=diagnostics, source=source) + + +def parse_text(text: str, *, registry: Registry = REGISTRY) -> ParseResult: + """Read one DSV file from already-decoded text. + + Args: + text: The DSV source. + registry: The element table to read with. + + Returns: + The parse result. + """ + return parse_bytes(text.encode("utf-8"), registry=registry) + + +def parse_file(path: str | Path, *, registry: Registry = REGISTRY) -> ParseResult: + """Read one DSV file from disk. + + Args: + path: Path to the ``.dsv5`` … ``.dsv8``/``.dsv8z`` file. + registry: The element table to read with. + + Returns: + The parse result. + + Raises: + OSError: If the file cannot be read. Unlike content problems, an + unreadable file is the caller's problem, not a diagnostic. + """ + return parse_bytes(Path(path).read_bytes(), registry=registry) + + +def _assemble( + lines: list[ElementLine], registry: Registry, diagnostics: Diagnostics +) -> DsvDocument: + """Turn tokenized element lines into a document. + + Args: + lines: The element lines in file order. + registry: The element table. + diagnostics: Collector for structural problems. + + Returns: + The assembled document. + """ + document = DsvDocument() + if not lines: + diagnostics.error("file contains no elements") + return document + + version, file_type = _read_format(lines, document, registry, diagnostics) + + seen_singular: set[str] = set() + terminated = False + format_seen = False + for line in lines: + if line.element == END_OF_FILE: + terminated = True + continue + if terminated: + diagnostics.warning(f"element {line.element} after DATEIENDE, ignored", line=line.line) + continue + if line.element == "FORMAT": + # The first FORMAT is consumed by _read_format; a second one would + # contradict every layout decision already made and must not pass + # silently like the other singular duplicates would not either. + if format_seen: + diagnostics.warning( + "element FORMAT occurs more than once; the first one wins", + line=line.line, + ) + format_seen = True + continue + _dispatch(line, document, registry, version, file_type, seen_singular, diagnostics) + + if not terminated: + diagnostics.warning("file is not terminated by DATEIENDE") + return document + + +def _read_format( + lines: list[ElementLine], + document: DsvDocument, + registry: Registry, + diagnostics: Diagnostics, +) -> tuple[int | None, FileType | None]: + """Read the ``FORMAT`` line up front and record it on the document. + + The list kind and version steer which layout every other element is read + with, so they cannot wait for their turn in the main loop. + + Args: + lines: The element lines in file order. + document: The document to record the header on. + registry: The element table. + diagnostics: Collector for a missing, misplaced or unreadable header. + + Returns: + A pair of (format version, list kind), either of which may be ``None`` + when the header could not be read — the layouts then keep every attribute. + """ + header = next((line for line in lines if line.element == "FORMAT"), None) + if header is None: + diagnostics.error("mandatory FORMAT element is missing") + return None, None + if header is not lines[0]: + diagnostics.warning("FORMAT is not the first element of the file", line=header.line) + + spec = registry.lookup("FORMAT", None, None) + assert spec is not None, "the element table always declares FORMAT" + parsed = bind(header, spec, None, None, diagnostics) + assert isinstance(parsed, Format) + + document.file_type = parsed.file_type + document.version = parsed.version + + if parsed.file_type is None: + diagnostics.error( + "unknown Listart; every element is read with its most permissive layout", + line=header.line, + element="FORMAT", + attribute=1, + field="Listart", + ) + if parsed.version is None: + diagnostics.warning( + "no format version declared; reading as Format 7/8", + line=header.line, + element="FORMAT", + ) + elif parsed.version not in VERSIONS: + # An unclamped out-of-range version would fail every versions= marker and + # collapse the layouts to the bare common skeleton — the opposite of what + # the diagnostic promises. The nearest supported layout loses the least. + nearest = max(VERSIONS) if parsed.version > max(VERSIONS) else min(VERSIONS) + diagnostics.warning( + f"format version {parsed.version} is outside the supported range " + f"{min(VERSIONS)}–{max(VERSIONS)}; reading with the Format {nearest} layout", + line=header.line, + element="FORMAT", + ) + return nearest, parsed.file_type + elif parsed.version == 5: + # The Format 5 layouts are derived from the Format 6 change log rather than + # from the Format 5 document, which the DSV no longer publishes — see + # TODO(spec-v5) in dsv_parser.spec.elements. Saying so is the honest + # alternative to presenting a derived reading as a verified one. + diagnostics.warning( + "Format 5 layouts are derived from the Format 6 change log, not from " + "the Format 5 specification; reaction times carried as a " + "PNZWISCHENZEIT with Distanz 0 are read as splits, not reactions", + line=header.line, + element="FORMAT", + ) + return parsed.version, parsed.file_type + + +def _dispatch( + line: ElementLine, + document: DsvDocument, + registry: Registry, + version: int | None, + file_type: FileType | None, + seen_singular: set[str], + diagnostics: Diagnostics, +) -> None: + """Read one element line and store it on the document. + + Args: + line: The element line. + document: The document under construction. + registry: The element table. + version: The declared format version. + file_type: The declared list kind. + seen_singular: Element names of the singular elements already stored, + so a second occurrence can be reported. + diagnostics: Collector for unknown elements and duplicates. + """ + spec = registry.lookup(line.element, version, file_type) + if spec is None: + if registry.knows(line.element): + diagnostics.warning( + f"element {line.element} has no layout for this file kind, ignored", + line=line.line, + ) + else: + diagnostics.warning(f"unknown element {line.element}, ignored", line=line.line) + return + + parsed = bind(line, spec, version, file_type, diagnostics) + target = spec.target + if target is None: # pragma: no cover - only FORMAT, consumed by _read_format + return + + if spec.repeated: + getattr(document, target).append(parsed) + return + + if spec.element in seen_singular: + diagnostics.warning( + f"element {spec.element} occurs more than once; the last one wins", + line=line.line, + ) + seen_singular.add(spec.element) + setattr(document, target, _unwrap(spec.unwrap, spec.element, parsed)) + + +def _unwrap(unwrap: str | None, element: str, parsed: DsvElement) -> object: + """Reduce a bound element to the value the document field expects. + + Args: + unwrap: The model field to lift out, for single-attribute elements. + element: The element constant, for the post-processed special cases. + parsed: The bound model. + + Returns: + The value to assign to the document field. + """ + if unwrap is not None: + return getattr(parsed, unwrap) + if element == "MELDESCHLUSS": + assert isinstance(parsed, EntryDeadline) + if parsed.date is None: + return None + return dt.datetime.combine(parsed.date, parsed.time or dt.time.min) + return parsed diff --git a/dsv_parser/core/values.py b/dsv_parser/core/values.py new file mode 100644 index 0000000..fe7e7d1 --- /dev/null +++ b/dsv_parser/core/values.py @@ -0,0 +1,292 @@ +"""The primitive DSV attribute types ("Datentypen" in the spec). + +Four scalar shapes carry every non-text value in a DSV file: + +============ ================ ================================================== +Spec type Literal Python +============ ================ ================================================== +``Zeit`` ``HH:MM:SS,hh`` ``int`` milliseconds (``00:00:00,00`` → ``None``) +``Datum`` ``TT.MM.JJJJ`` :class:`datetime.date` +``Uhrzeit`` ``HH:MM`` :class:`datetime.time` +``Betrag`` ``12,50`` ``int`` euro cents +============ ================ ================================================== + +Swim times are milliseconds rather than :class:`~datetime.timedelta` on purpose: +they are compared, summed and stored by every consumer, and an integer survives +JSON, SQL and every client language without a serialisation convention. The +hundredth is the format's resolution, so the value is always a multiple of ten. + +Every parser here raises :class:`DsvValueError` on malformed input and returns +``None`` for a blank attribute. Turning that exception into a diagnostic is the +binder's job — a single bad value must never abort a file. +""" + +from __future__ import annotations + +import datetime as dt + +MILLIS_PER_HUNDREDTH = 10 +MILLIS_PER_SECOND = 1_000 +MILLIS_PER_MINUTE = 60 * MILLIS_PER_SECOND +MILLIS_PER_HOUR = 60 * MILLIS_PER_MINUTE + + +class DsvValueError(ValueError): + """A raw attribute value does not match its declared DSV type.""" + + +def parse_swim_time(raw: str) -> int | None: + """Parse a DSV ``Zeit`` into milliseconds. + + Tolerates a dot instead of the comma decimal separator and surrounding + whitespace, but nothing looser: the fraction must be exactly two digits + (``,5`` is ambiguous between five tenths and five hundredths) and minutes and + seconds must stay below 60 — a signed or out-of-range component would + otherwise become a silently wrong number of milliseconds. The all-zero time + is the spec's placeholder for "no time". + + Args: + raw: The raw attribute text. + + Returns: + The time in milliseconds, or ``None`` for a blank value or ``00:00:00,00``. + + Raises: + DsvValueError: If the value is not a well-formed ``Zeit``. + """ + value = raw.strip() + if not value: + return None + clock, separator, hundredths = value.replace(".", ",").partition(",") + parts = clock.split(":") + if ( + not separator + or len(parts) != 3 + or len(hundredths) != 2 + or not all(part.isascii() and part.isdigit() for part in (*parts, hundredths)) + ): + raise DsvValueError(f"not a Zeit (HH:MM:SS,hh): {raw!r}") + hours, minutes, seconds = (int(part) for part in parts) + fraction = int(hundredths) + if minutes > 59 or seconds > 59: + raise DsvValueError(f"not a Zeit (HH:MM:SS,hh): {raw!r}") + millis = ( + hours * MILLIS_PER_HOUR + + minutes * MILLIS_PER_MINUTE + + seconds * MILLIS_PER_SECOND + + fraction * MILLIS_PER_HUNDREDTH + ) + return millis or None + + +def format_swim_time(millis: int | None) -> str: + """Render milliseconds as a DSV ``Zeit``. + + Args: + millis: The time in milliseconds, or ``None`` for "no time". + + Returns: + The formatted ``HH:MM:SS,hh`` literal; ``None`` renders as the + ``00:00:00,00`` placeholder. + """ + value = millis or 0 + hundredths = (value // MILLIS_PER_HUNDREDTH) % 100 + seconds = (value // MILLIS_PER_SECOND) % 60 + minutes = (value // MILLIS_PER_MINUTE) % 60 + hours = value // MILLIS_PER_HOUR + return f"{hours:02d}:{minutes:02d}:{seconds:02d},{hundredths:02d}" + + +def parse_date(raw: str) -> dt.date | None: + """Parse a DSV ``Datum`` (``TT.MM.JJJJ``). + + Args: + raw: The raw attribute text. + + Returns: + The date, or ``None`` for a blank value. + + Raises: + DsvValueError: If the value is not a well-formed, existing date. + """ + value = raw.strip() + if not value: + return None + parts = value.split(".") + if len(parts) != 3: + raise DsvValueError(f"not a Datum (TT.MM.JJJJ): {raw!r}") + try: + day, month, year = (int(part) for part in parts) + return dt.date(year, month, day) + except (ValueError, OverflowError) as exc: + # OverflowError: dt.date rejects a year beyond C-long range with it, not + # with ValueError, and the reader must not raise on file content. + raise DsvValueError(f"not a Datum (TT.MM.JJJJ): {raw!r}") from exc + + +def format_date(value: dt.date | None) -> str: + """Render a date as a DSV ``Datum``. + + Args: + value: The date, or ``None``. + + Returns: + The ``TT.MM.JJJJ`` literal, or an empty string for ``None``. + """ + return "" if value is None else f"{value.day:02d}.{value.month:02d}.{value.year:04d}" + + +def parse_clock(raw: str) -> dt.time | None: + """Parse a DSV ``Uhrzeit`` (``HH:MM``, 24-hour clock). + + Args: + raw: The raw attribute text. + + Returns: + The time of day, or ``None`` for a blank value. + + Raises: + DsvValueError: If the value is not a well-formed time of day. + """ + value = raw.strip() + if not value: + return None + parts = value.split(":") + if len(parts) != 2: + raise DsvValueError(f"not an Uhrzeit (HH:MM): {raw!r}") + try: + hour, minute = (int(part) for part in parts) + return dt.time(hour, minute) + except (ValueError, OverflowError) as exc: + # OverflowError: dt.time rejects an hour beyond C-long range with it, not + # with ValueError, and the reader must not raise on file content. + raise DsvValueError(f"not an Uhrzeit (HH:MM): {raw!r}") from exc + + +def format_clock(value: dt.time | None) -> str: + """Render a time of day as a DSV ``Uhrzeit``. + + Args: + value: The time of day, or ``None``. + + Returns: + The ``HH:MM`` literal, or an empty string for ``None``. + """ + return "" if value is None else f"{value.hour:02d}:{value.minute:02d}" + + +def parse_amount_cents(raw: str) -> int | None: + """Parse a DSV ``Betrag`` into euro cents. + + Tolerates a dot instead of the comma decimal separator, a missing fraction and + a single-digit fraction (``5,5`` → 550 cents). + + Args: + raw: The raw attribute text. + + Returns: + The amount in cents, or ``None`` for a blank value. + + Raises: + DsvValueError: If the value is not a well-formed amount. + """ + value = raw.strip() + if not value: + return None + euros_text, _, fraction_text = value.replace(".", ",").partition(",") + if fraction_text.count(",") or not euros_text.strip(): + raise DsvValueError(f"not a Betrag (12,50): {raw!r}") + try: + euros = int(euros_text) + if not fraction_text: + cents = 0 + else: + padded = fraction_text.strip().ljust(2, "0") + if len(padded) != 2: + raise DsvValueError(f"not a Betrag (12,50): {raw!r}") + cents = int(padded) + except ValueError as exc: + raise DsvValueError(f"not a Betrag (12,50): {raw!r}") from exc + sign = -1 if euros_text.strip().startswith("-") else 1 + return euros * 100 + sign * cents + + +def format_amount_cents(cents: int | None) -> str: + """Render euro cents as a DSV ``Betrag``. + + Args: + cents: The amount in cents, or ``None``. + + Returns: + The ``12,50`` literal, or an empty string for ``None``. + """ + if cents is None: + return "" + sign = "-" if cents < 0 else "" + return f"{sign}{abs(cents) // 100},{abs(cents) % 100:02d}" + + +def parse_integer(raw: str) -> int | None: + """Parse a plain integer attribute. + + Args: + raw: The raw attribute text. + + Returns: + The integer, or ``None`` for a blank value. + + Raises: + DsvValueError: If the value is not an integer. + """ + value = raw.strip() + if not value: + return None + try: + return int(value) + except ValueError as exc: + raise DsvValueError(f"not a number: {raw!r}") from exc + + +def parse_flag(raw: str) -> bool | None: + """Parse a ``J``/``N`` flag. + + The tri-state return is deliberate: several elements distinguish an absent + optional flag ("not declared") from an explicit ``N``. + + Args: + raw: The raw attribute text. + + Returns: + ``True`` for ``J``, ``False`` for ``N``, ``None`` when absent. + + Raises: + DsvValueError: If the value is neither ``J`` nor ``N``. + """ + value = raw.strip().upper() + if not value: + return None + if value == "J": + return True + if value == "N": + return False + raise DsvValueError(f"not a J/N flag: {raw!r}") + + +def parse_sign(raw: str) -> bool | None: + """Parse a reaction-time sign (``+`` after the signal, ``-`` before it). + + Args: + raw: The raw attribute text. + + Returns: + ``True`` when the sign is negative, ``False`` for ``+`` or a blank value. + + Raises: + DsvValueError: If the value is neither ``+`` nor ``-``. + """ + value = raw.strip() + if not value or value == "+": + return False + if value == "-": + return True + raise DsvValueError(f"not a +/- sign: {raw!r}") diff --git a/dsv_parser/model/__init__.py b/dsv_parser/model/__init__.py new file mode 100644 index 0000000..58a67c8 --- /dev/null +++ b/dsv_parser/model/__init__.py @@ -0,0 +1,6 @@ +"""The data schema: the document, its elements and the coded vocabulary.""" + +from .document import DsvDocument +from .enums import DsvEnum, FileType + +__all__ = ["DsvDocument", "DsvEnum", "FileType"] diff --git a/dsv_parser/model/document.py b/dsv_parser/model/document.py new file mode 100644 index 0000000..bf7406a --- /dev/null +++ b/dsv_parser/model/document.py @@ -0,0 +1,137 @@ +"""The parsed document — one container for all four DSV list kinds. + +Which blocks are populated depends on :attr:`DsvDocument.file_type`; a definition +list has events and fees and no results, a meet protocol the other way round. One +container rather than four subclasses is a deliberate trade: consumers in other +languages get a single, stable JSON shape they can generate a client for once, +and the emptiness of a block is data rather than a type distinction. + +Element lists preserve file order. For :attr:`DsvDocument.events` that order *is* +the meet programme and is semantically relevant; for the rest it is simply the +order the writer chose, and consumers should not rely on it. +""" + +from __future__ import annotations + +import datetime as dt + +from pydantic import BaseModel, ConfigDict, Field + +from .elements import ( + AgeGroup, + BankAccount, + Club, + Coach, + Contact, + EntryFee, + Event, + Generator, + Handicap, + Host, + IndividualEntry, + IndividualResult, + JudgeAssignment, + JudgeNomination, + JudgeSectionWish, + MeetInfo, + ProofOfTime, + QualificationTime, + Reaction, + Relay, + RelayEntry, + RelayResult, + RelaySplit, + RelaySwimmer, + RelayTakeoff, + Section, + Split, + Swimmer, + Venue, +) +from .enums import FileType + + +class DsvDocument(BaseModel): + """The complete, lossless in-memory representation of one DSV file.""" + + model_config = ConfigDict(extra="forbid") + + # --- header --- + file_type: FileType | None = Field(default=None, description="Listart of the FORMAT element.") + version: int | None = Field( + default=None, description="DSV format version (5, 6, 7 or 8) as declared by FORMAT." + ) + generator: Generator | None = Field(default=None, description="ERZEUGER.") + meet: MeetInfo | None = Field(default=None, description="VERANSTALTUNG.") + venue: Venue | None = Field(default=None, description="VERANSTALTUNGSORT.") + announcement_url: str | None = Field(default=None, description="AUSSCHREIBUNGIMNETZ.") + organizer_name: str | None = Field(default=None, description="VERANSTALTER.") + host: Host | None = Field(default=None, description="AUSRICHTER.") + entry_address: Contact | None = Field(default=None, description="MELDEADRESSE.") + contact_person: Contact | None = Field(default=None, description="ANSPRECHPARTNER.") + entry_deadline: dt.datetime | None = Field( + default=None, description="MELDESCHLUSS — date and clock merged into one instant." + ) + bank_account: BankAccount | None = Field(default=None, description="BANKVERBINDUNG.") + direct_debit_only: bool | None = Field( + default=None, description="LASTSCHRIFT (Format 8); None means the element was absent." + ) + remarks: str | None = Field(default=None, description="BESONDERES.") + proof_of_time: ProofOfTime | None = Field(default=None, description="NACHWEIS.") + + # --- programme --- + sections: list[Section] = Field(default_factory=list, description="ABSCHNITT.") + events: list[Event] = Field(default_factory=list, description="WETTKAMPF, in programme order.") + age_groups: list[AgeGroup] = Field(default_factory=list, description="WERTUNG.") + qualification_times: list[QualificationTime] = Field( + default_factory=list, description="PFLICHTZEIT." + ) + entry_fees: list[EntryFee] = Field(default_factory=list, description="MELDEGELD.") + + # --- participants --- + clubs: list[Club] = Field(default_factory=list, description="VEREIN.") + coaches: list[Coach] = Field(default_factory=list, description="TRAINER.") + swimmers: list[Swimmer] = Field(default_factory=list, description="PNMELDUNG / PERSON.") + handicaps: list[Handicap] = Field(default_factory=list, description="HANDICAP.") + judge_nominations: list[JudgeNomination] = Field( + default_factory=list, description="KARIMELDUNG." + ) + judge_section_wishes: list[JudgeSectionWish] = Field( + default_factory=list, description="KARIABSCHNITT." + ) + judge_assignments: list[JudgeAssignment] = Field( + default_factory=list, description="KAMPFGERICHT." + ) + + # --- entries --- + individual_entries: list[IndividualEntry] = Field(default_factory=list, description="STARTPN.") + relays: list[Relay] = Field(default_factory=list, description="STMELDUNG / STAFFEL.") + relay_entries: list[RelayEntry] = Field(default_factory=list, description="STARTST.") + relay_swimmers: list[RelaySwimmer] = Field(default_factory=list, description="STAFFELPERSON.") + + # --- results --- + individual_results: list[IndividualResult] = Field( + default_factory=list, description="PERSONENERGEBNIS / PNERGEBNIS." + ) + splits: list[Split] = Field(default_factory=list, description="PNZWISCHENZEIT.") + reactions: list[Reaction] = Field(default_factory=list, description="PNREAKTION.") + relay_results: list[RelayResult] = Field( + default_factory=list, description="STAFFELERGEBNIS / STERGEBNIS." + ) + relay_splits: list[RelaySplit] = Field(default_factory=list, description="STZWISCHENZEIT.") + relay_takeoffs: list[RelayTakeoff] = Field(default_factory=list, description="STABLOESE.") + + def element_counts(self) -> dict[str, int]: + """Summarise how many of each repeated element the document holds. + + Useful as a cheap smoke check after an import and as the payload of the + CLI's ``--summary`` output. + + Returns: + A mapping of field name to element count, skipping empty blocks. + """ + counts: dict[str, int] = {} + for name, value in self: + if isinstance(value, list) and value: + counts[name] = len(value) + return counts diff --git a/dsv_parser/model/elements.py b/dsv_parser/model/elements.py new file mode 100644 index 0000000..37bf804 --- /dev/null +++ b/dsv_parser/model/elements.py @@ -0,0 +1,445 @@ +"""One pydantic model per DSV element. + +These are the leaves of the data schema: each class mirrors exactly one element +line of a DSV file, with the attribute order of the spec preserved in the field +order. Field names are English and speaking; the German element and attribute +names live in the spec tables (:mod:`dsv_parser.spec`), which is the only place +that has to change when a format version moves an attribute. + +Every field is optional. That is not laziness — the same element carries a +different attribute count depending on format version and list kind, optional +attributes are routinely left empty, and the reader is tolerant by contract: a +value it cannot represent becomes ``None`` plus a diagnostic, never an exception. +Callers that need a guarantee should validate the assembled +:class:`~dsv_parser.model.document.DsvDocument`, not the individual line. +""" + +from __future__ import annotations + +import datetime as dt + +from pydantic import BaseModel, ConfigDict + +from .enums import ( + AgeClassType, + BestListCategory, + Course, + EnmStatus, + EntryFeeType, + EventGender, + Exercise, + FileType, + Gender, + JudgeGroup, + JudgePosition, + ProofCourse, + ResultStatus, + Round, + Stroke, + TimingSystem, +) + + +class DsvElement(BaseModel): + """Base for every element model. + + Attributes: + source_line: One-based line number the element was read from. Kept on the + model so a consumer can point a user back at the file — the reason the + models are not plain dataclasses. + """ + + model_config = ConfigDict(extra="forbid", frozen=True) + + source_line: int | None = None + + +# --- Single-attribute wrappers -------------------------------------------- + + +class ScalarText(DsvElement): + """An element whose whole payload is one free-text attribute. + + ``AUSSCHREIBUNGIMNETZ``, ``VERANSTALTER`` and ``BESONDERES`` share this shape. + They are unwrapped onto a plain string field of the document, so the model + exists only to keep them in the element table like everything else. + """ + + value: str | None = None + + +class ScalarFlag(DsvElement): + """An element whose whole payload is one ``J``/``N`` flag — ``LASTSCHRIFT``.""" + + value: bool | None = None + + +class EntryDeadline(DsvElement): + """``MELDESCHLUSS`` — the closing date and clock, merged by the assembler.""" + + date: dt.date | None = None + time: dt.time | None = None + + +# --- File header ---------------------------------------------------------- + + +class Format(DsvElement): + """``FORMAT`` — the mandatory first element: list kind and format version.""" + + file_type: FileType | None = None + version: int | None = None + + +class Generator(DsvElement): + """``ERZEUGER`` — the software that wrote the file.""" + + software: str | None = None + version: str | None = None + contact: str | None = None + + +class MeetInfo(DsvElement): + """``VERANSTALTUNG`` — meet name, city, pool length, timing.""" + + name: str | None = None + city: str | None = None + course: Course | None = None + timing: TimingSystem | None = None + + +class Venue(DsvElement): + """``VERANSTALTUNGSORT`` — the pool and how to reach it.""" + + name: str | None = None + street: str | None = None + postal_code: str | None = None + city: str | None = None + nation: str | None = None + phone: str | None = None + fax: str | None = None + email: str | None = None + + +class Host(DsvElement): + """``AUSRICHTER`` — the organising club and its contact.""" + + name: str | None = None + contact_name: str | None = None + street: str | None = None + postal_code: str | None = None + city: str | None = None + nation: str | None = None + phone: str | None = None + fax: str | None = None + email: str | None = None + + +class Contact(DsvElement): + """``MELDEADRESSE`` / ``ANSPRECHPARTNER`` — the two share one attribute layout.""" + + name: str | None = None + street: str | None = None + postal_code: str | None = None + city: str | None = None + nation: str | None = None + phone: str | None = None + fax: str | None = None + email: str | None = None + + +class BankAccount(DsvElement): + """``BANKVERBINDUNG`` — where entry fees are paid.""" + + bank_name: str | None = None + iban: str | None = None + bic: str | None = None + account_holder: str | None = None + + +class ProofOfTime(DsvElement): + """``NACHWEIS`` — the window and course a qualifying time may come from.""" + + valid_from: dt.date | None = None + valid_until: dt.date | None = None + course: ProofCourse | None = None + + +# --- Programme ------------------------------------------------------------ + + +class Section(DsvElement): + """``ABSCHNITT`` — one session of the meet. + + The definition list carries the admission and judges'-briefing times; every + other list kind carries only the start time. + """ + + number: int | None = None + date: dt.date | None = None + admission_time: dt.time | None = None + judges_meeting_time: dt.time | None = None + start_time: dt.time | None = None + relative_timing: bool | None = None + + +class Event(DsvElement): + """``WETTKAMPF`` — one event of the programme, in programme order.""" + + number: int | None = None + round: Round | None = None + section_number: int | None = None + relay_legs: int | None = None + distance: int | None = None + stroke: Stroke | None = None + exercise: Exercise | None = None + gender: EventGender | None = None + best_list_category: BestListCategory | None = None + qualification_event_number: int | None = None + qualification_round: Round | None = None + + +class AgeGroup(DsvElement): + """``WERTUNG`` — one scoring group of an event.""" + + event_number: int | None = None + round: Round | None = None + scoring_id: int | None = None + class_type: AgeClassType | None = None + lower_bound: str | None = None + upper_bound: str | None = None + gender: EventGender | None = None + name: str | None = None + + +class QualificationTime(DsvElement): + """``PFLICHTZEIT`` — the qualifying time for one event and age group.""" + + event_number: int | None = None + round: Round | None = None + class_type: AgeClassType | None = None + lower_bound: str | None = None + upper_bound: str | None = None + time_millis: int | None = None + gender: Gender | None = None + + +class EntryFee(DsvElement): + """``MELDEGELD`` — one fee position of the announcement.""" + + fee_type: EntryFeeType | None = None + amount_cents: int | None = None + event_number: int | None = None + + +# --- Participants --------------------------------------------------------- + + +class Club(DsvElement): + """``VEREIN`` — a participating club.""" + + name: str | None = None + dsv_club_id: int | None = None + lsv_code: int | None = None + nation: str | None = None + direct_debit_approved: bool | None = None + + +class Coach(DsvElement): + """``TRAINER`` — a coach nominated by a club.""" + + number: int | None = None + name: str | None = None + gender: Gender | None = None + + +class Swimmer(DsvElement): + """``PNMELDUNG`` (entry list, with coach) / ``PERSON`` (result list, without).""" + + name: str | None = None + dsv_id: int | None = None + local_id: int | None = None + gender: Gender | None = None + birth_year: int | None = None + age_class: int | None = None + coach_number: int | None = None + nationality_1: str | None = None + nationality_2: str | None = None + nationality_3: str | None = None + + +class Handicap(DsvElement): + """``HANDICAP`` — a para swimmer's classification.""" + + swimmer_local_id: int | None = None + dbs_id: str | None = None + ipc_id: str | None = None + start_class: str | None = None + start_class_breast: str | None = None + start_class_medley: str | None = None + exceptions: str | None = None + + +class JudgeNomination(DsvElement): + """``KARIMELDUNG`` — a judge a club brings to the meet.""" + + number: int | None = None + name: str | None = None + group: JudgeGroup | None = None + gender: Gender | None = None + + +class JudgeSectionWish(DsvElement): + """``KARIABSCHNITT`` — which section a nominated judge is available for.""" + + judge_number: int | None = None + section_number: int | None = None + position: JudgePosition | None = None + + +class JudgeAssignment(DsvElement): + """``KAMPFGERICHT`` — a judge actually assigned in a result list.""" + + section_number: int | None = None + position: JudgePosition | None = None + name: str | None = None + club_name: str | None = None + + +# --- Entries -------------------------------------------------------------- + + +class IndividualEntry(DsvElement): + """``STARTPN`` — one swimmer's entry into one event.""" + + swimmer_local_id: int | None = None + event_number: int | None = None + entry_time_millis: int | None = None + + +class Relay(DsvElement): + """``STMELDUNG`` (entry list) / ``STAFFEL`` (result list) — a relay team.""" + + team_number: int | None = None + local_id: int | None = None + class_type: AgeClassType | None = None + lower_bound: str | None = None + upper_bound: str | None = None + name: str | None = None + + +class RelayEntry(DsvElement): + """``STARTST`` — one relay team's entry into one event.""" + + relay_local_id: int | None = None + event_number: int | None = None + entry_time_millis: int | None = None + + +class RelaySwimmer(DsvElement): + """``STAFFELPERSON`` — one leg of a relay. + + The entry-list shape references the swimmer by its meet-local id; the + result-list shape identifies the swimmer inline and carries the round. + """ + + relay_local_id: int | None = None + event_number: int | None = None + round: Round | None = None + swimmer_local_id: int | None = None + name: str | None = None + dsv_id: int | None = None + leg_number: int | None = None + gender: Gender | None = None + birth_year: int | None = None + age_class: int | None = None + nationality_1: str | None = None + nationality_2: str | None = None + nationality_3: str | None = None + + +# --- Results -------------------------------------------------------------- + + +class IndividualResult(DsvElement): + """``PERSONENERGEBNIS`` (club list) / ``PNERGEBNIS`` (meet protocol).""" + + swimmer_local_id: int | None = None + event_number: int | None = None + round: Round | None = None + scoring_id: int | None = None + place: int | None = None + time_millis: int | None = None + status: ResultStatus | None = None + name: str | None = None + dsv_id: int | None = None + gender: Gender | None = None + birth_year: int | None = None + age_class: int | None = None + club_name: str | None = None + club_dsv_id: int | None = None + remark: str | None = None + enm: EnmStatus | None = None + nationality_1: str | None = None + nationality_2: str | None = None + nationality_3: str | None = None + + +class Split(DsvElement): + """``PNZWISCHENZEIT`` — one intermediate time of an individual swim.""" + + swimmer_local_id: int | None = None + event_number: int | None = None + round: Round | None = None + distance: int | None = None + time_millis: int | None = None + + +class Reaction(DsvElement): + """``PNREAKTION`` — the start reaction time of an individual swim.""" + + swimmer_local_id: int | None = None + event_number: int | None = None + round: Round | None = None + negative: bool | None = None + time_millis: int | None = None + + +class RelayResult(DsvElement): + """``STAFFELERGEBNIS`` (club list) / ``STERGEBNIS`` (meet protocol).""" + + relay_local_id: int | None = None + event_number: int | None = None + round: Round | None = None + scoring_id: int | None = None + place: int | None = None + time_millis: int | None = None + status: ResultStatus | None = None + team_number: int | None = None + club_name: str | None = None + club_dsv_id: int | None = None + disqualified_leg: int | None = None + remark: str | None = None + enm: EnmStatus | None = None + + +class RelaySplit(DsvElement): + """``STZWISCHENZEIT`` — one intermediate time of a relay swim.""" + + relay_local_id: int | None = None + event_number: int | None = None + round: Round | None = None + leg_number: int | None = None + distance: int | None = None + time_millis: int | None = None + + +class RelayTakeoff(DsvElement): + """``STABLOESE`` — one relay changeover time.""" + + relay_local_id: int | None = None + event_number: int | None = None + round: Round | None = None + leg_number: int | None = None + negative: bool | None = None + time_millis: int | None = None diff --git a/dsv_parser/model/enums.py b/dsv_parser/model/enums.py new file mode 100644 index 0000000..fca0852 --- /dev/null +++ b/dsv_parser/model/enums.py @@ -0,0 +1,309 @@ +"""The coded vocabulary of the DSV formats. + +Every enum here is a :class:`DsvEnum`: its *value* is a speaking, stable name +(``"freestyle"``) and its ``code`` is the literal the file uses (``"F"``). The +split matters because this package has two audiences — the JSON that leaves the +API is read by services in other languages, where ``"freestyle"`` is +self-describing and ``"F"`` is a lookup table nobody has; while the reader and a +future writer need the exact file literal. + +Codes are matched case-insensitively and with surrounding whitespace ignored, both +of which the spec explicitly permits. +""" + +from __future__ import annotations + +from enum import StrEnum +from typing import TypeVar + +E = TypeVar("E", bound="DsvEnum") + + +class DsvEnum(StrEnum): + """A vocabulary enum with a speaking value and a DSV file code. + + Subclasses declare members as ``NAME = ("speaking_value", "CODE")``. A member + whose code is ``None`` has no file representation and is expressed by an empty + attribute — :attr:`ResultStatus.RANKED` is the one such case. + """ + + code: str | None + + def __new__(cls, value: str, code: str | None = None) -> DsvEnum: + """Build a member carrying both its speaking value and its file code. + + Args: + value: The speaking, serialised value. + code: The literal used in DSV files, or ``None`` when the member is + represented by an empty attribute. + + Returns: + The enum member. + """ + member = str.__new__(cls, value) + member._value_ = value + member.code = code + return member + + @classmethod + def from_code(cls: type[E], raw: str | None) -> E | None: + """Resolve a raw file literal to its member. + + Args: + raw: The attribute text from the file; may be ``None`` or blank. + + Returns: + The matching member, or ``None`` when the value is blank or unknown. + """ + if raw is None: + return None + needle = raw.strip().casefold() + if not needle: + return None + for member in cls: + if member.code is not None and member.code.casefold() == needle: + return member + return None + + @classmethod + def codes(cls) -> dict[str, str]: + """Map every file code to its speaking value, for the ``/spec`` endpoint. + + Returns: + A mapping of DSV code to speaking value, skipping codeless members. + """ + return {member.code: member.value for member in cls if member.code is not None} + + +class FileType(DsvEnum): + """The four DSV list kinds — attribute ``Listart`` of the ``FORMAT`` element. + + The list kind is the single most load-bearing value in a file: it decides which + element blocks may occur at all and, for a handful of elements, which attribute + layout applies. It is unchanged from Format 6 to Format 8. + """ + + DEFINITION = ("definition", "Wettkampfdefinitionsliste") + """The announcement (``-Wk``): sections, events, age groups, fees.""" + + CLUB_ENTRIES = ("club_entries", "Vereinsmeldeliste") + """One club's entries (``--Me``): swimmers, starts, relays, judges.""" + + CLUB_RESULTS = ("club_results", "Vereinsergebnisliste") + """One club's results (``--Pr``).""" + + MEET_RESULTS = ("meet_results", "Wettkampfergebnisliste") + """The full meet protocol (``-Pr``).""" + + +class Course(DsvEnum): + """Pool length — attribute ``Bahnlänge`` of ``VERANSTALTUNG``.""" + + SCM_16 = ("scm_16", "16") + SCM_20 = ("scm_20", "20") + SCM_25 = ("scm_25", "25") + SCM_33 = ("scm_33", "33") + LCM_50 = ("lcm_50", "50") + OPEN_WATER = ("open_water", "FW") + OTHER = ("other", "X") + + +class TimingSystem(DsvEnum): + """How the meet was timed — attribute ``Zeitmessung`` of ``VERANSTALTUNG``.""" + + AUTOMATIC = ("automatic", "AUTOMATISCH") + SEMI_AUTOMATIC = ("semi_automatic", "HALBAUTOMATISCH") + MANUAL = ("manual", "HANDZEIT") + + +class Gender(DsvEnum): + """Gender of a person — ``M``/``W``/``D`` in the file.""" + + MALE = ("male", "M") + FEMALE = ("female", "W") + + DIVERSE = ("diverse", "D") + """Format 7 onwards.""" + + +class EventGender(DsvEnum): + """Gender an event is open to — attribute ``Geschlecht`` of ``WETTKAMPF``.""" + + MALE = ("male", "M") + FEMALE = ("female", "W") + DIVERSE = ("diverse", "D") + MIXED = ("mixed", "X") + + +class Round(DsvEnum): + """Round of an event — attribute ``Wettkampfart``. + + Together with the event number this identifies an event within a meet: event 12 + prelim and event 12 final are two distinct ``WETTKAMPF`` elements. + """ + + PRELIM = ("prelim", "V") + SEMI = ("semi", "Z") + FINAL = ("final", "F") + TIMED_FINAL = ("timed_final", "E") + + SWIM_OFF = ("swim_off", "A") + """Ausschwimmen — result lists only.""" + + RE_SWIM = ("re_swim", "N") + """Nachschwimmen — result lists only.""" + + +class Stroke(DsvEnum): + """Swim stroke — attribute ``Technik`` of ``WETTKAMPF``.""" + + FREESTYLE = ("freestyle", "F") + BACKSTROKE = ("backstroke", "R") + BREASTSTROKE = ("breaststroke", "B") + BUTTERFLY = ("butterfly", "S") + MEDLEY = ("medley", "L") + OTHER = ("other", "X") + + +class Exercise(DsvEnum): + """Form of the swim — attribute ``Ausübung`` of ``WETTKAMPF``. + + Almost every event is ``WHOLE_STROKE``; the remaining members describe the + technique-training events that appear in youth meets. + """ + + WHOLE_STROKE = ("whole_stroke", "GL") + LEGS = ("legs", "BE") + ARMS = ("arms", "AR") + START = ("start", "ST") + TURN = ("turn", "WE") + GLIDE = ("glide", "GB") + + KICKS_PRONE = ("kicks_prone", "KB") + """Kicks Bauchlage — Format 8 onwards, and only with ``Technik = S``.""" + + KICKS_SUPINE = ("kicks_supine", "KR") + """Kicks Rückenlage — Format 8 onwards, and only with ``Technik = S``.""" + + OTHER = ("other", "X") + + +class AgeClassType(DsvEnum): + """How an age group is bounded — attribute ``Wertungsklasse``.""" + + YEAR_OF_BIRTH = ("year_of_birth", "JG") + AGE_CLASS = ("age_class", "AK") + + +class BestListCategory(DsvEnum): + """Best-list category an event counts towards — attribute ``Bestenliste``.""" + + SWIMMING = ("swimming", "SW") + """Youth and open class — the default.""" + + MASTERS = ("masters", "MS") + KIDS = ("kids", "KG") + + SIMPLIFIED = ("simplified", "EW") + """Vereinfachter Wettkampf — Format 7 onwards.""" + + PARA = ("para", "PA") + """Para swimming — Format 7 onwards.""" + + OPEN_WATER = ("open_water", "FS") + """Freiwasserschwimmen — Format 6 and earlier; dropped by Format 7 ("Kennzeichen + Freiwasser bei Zuordnung Bestenliste entfällt"), kept so old files still read.""" + + OTHER = ("other", "XX") + + +class ResultStatus(DsvEnum): + """Why a swim was not ranked — attribute ``Grund der Nichtwertung``. + + A regularly ranked swim carries :attr:`RANKED`, which the file expresses by an + empty attribute; every other value forces ``Platz = 0``. + """ + + RANKED = ("ranked", None) + DISQUALIFIED = ("disqualified", "DS") + DID_NOT_START = ("did_not_start", "NA") + WITHDRAWN = ("withdrawn", "AB") + DID_NOT_FINISH = ("did_not_finish", "AU") + TIME_LIMIT_EXCEEDED = ("time_limit_exceeded", "ZU") + + +class EnmStatus(DsvEnum): + """Status towards the Endkampf-Norm-Meldung — attribute ``ENM`` of a result.""" + + NORM_MET = ("norm_met", "E") + ENM_DUE = ("enm_due", "F") + NORM_PROVEN = ("norm_proven", "N") + + +class ProofCourse(DsvEnum): + """Which course a qualifying time may be proven on — ``NACHWEIS``.""" + + SCM_25 = ("scm_25", "25") + LCM_50 = ("lcm_50", "50") + OPEN_WATER = ("open_water", "FW") + ALL = ("all", "AL") + + +class EntryFeeType(DsvEnum): + """What an entry fee is charged for — ``MELDEGELD``.""" + + FLAT_RATE = ("flat_rate", "Meldegeldpauschale") + """A flat amount per club.""" + + PER_INDIVIDUAL_START = ("per_individual_start", "Einzelmeldegeld") + PER_RELAY_START = ("per_relay_start", "Staffelmeldegeld") + + PER_EVENT = ("per_event", "Wkmeldegeld") + """Per-event fee; takes precedence and requires the Wettkampfnr. attribute.""" + + PER_TEAM = ("per_team", "Mannschaftmeldegeld") + + PER_PARTICIPANT = ("per_participant", "Teilnehmermeldegeld") + """A flat amount per entered swimmer — Format 8 onwards.""" + + PER_SECTION = ("per_section", "Abschnittspauschale") + """A flat amount per club per entered section — Format 8 onwards.""" + + +class JudgeGroup(DsvEnum): + """Qualification group a nominated judge belongs to — ``KARIMELDUNG``.""" + + COMPETITION_JUDGE = ("competition_judge", "WKR") + EVALUATOR = ("evaluator", "AUS") + REFEREE = ("referee", "SCH") + ANNOUNCER = ("announcer", "SPR") + + +class JudgePosition(DsvEnum): + """Position a judge is nominated or assigned to — ``KARIABSCHNITT``/``KAMPFGERICHT``.""" + + REFEREE = ("referee", "SCH") + STARTER = ("starter", "STA") + CHIEF_FINISH_JUDGE = ("chief_finish_judge", "ZRO") + FINISH_JUDGE = ("finish_judge", "ZR") + CHIEF_TIMEKEEPER = ("chief_timekeeper", "ZNO") + TIMEKEEPER = ("timekeeper", "ZN") + RESERVE_TIMEKEEPER = ("reserve_timekeeper", "RZN") + STROKE_JUDGE = ("stroke_judge", "SR") + CHIEF_TURN_JUDGE = ("chief_turn_judge", "WRO") + TURN_JUDGE = ("turn_judge", "WR") + EVALUATOR = ("evaluator", "AUS") + ANNOUNCER = ("announcer", "SP") + PROTOCOL_KEEPER = ("protocol_keeper", "PKF") + + MISCELLANEOUS = ("miscellaneous", "ZBV") + """Sonstige Kampfrichter. ``KAMPFGERICHT`` only — never a KARIABSCHNITT wish.""" + + # The six below are the Format 7 addition "Einführung weiterer Bezeichnungen + # für Kampfrichter". WKH, like ZBV, is a KAMPFGERICHT position only. + CLERK_OF_COURSE = ("clerk_of_course", "STO") + COMPETITION_HELPER = ("competition_helper", "WKH") + ASSISTANT_REFEREE = ("assistant_referee", "ASCH") + SAFETY_OFFICER = ("safety_officer", "SIB") + COURSE_SUPERVISOR = ("course_supervisor", "SAUF") + SUPPLY_MARSHAL = ("supply_marshal", "VER") diff --git a/dsv_parser/naming.py b/dsv_parser/naming.py new file mode 100644 index 0000000..1130b27 --- /dev/null +++ b/dsv_parser/naming.py @@ -0,0 +1,98 @@ +"""The DSV file-naming convention. + +Chapter 2 of the standard prescribes the file name as strictly as it prescribes +the content:: + + JJJJ-MM-TT-Ort-Zusatz.DSV7 + +The date is the **last** section's date, ``Ort`` is the meet city — not the pool +name — truncated to eight characters, and the ``Zusatz`` identifies the list kind. +Spaces and hyphens are dropped and umlauts transliterated (ä→ae, ö→oe, ü→ue, +ß→ss) in both the city and the club name, which is truncated to sixteen. + +This module is here because reading a DSV file usually means writing one back, or +at least checking that an uploaded file is named the way the receiving federation +expects — and because the rule is fiddly enough that everyone gets it wrong once. +""" + +from __future__ import annotations + +import datetime as dt +import re + +from .model.enums import FileType + +CITY_MAX_LENGTH = 8 +CLUB_MAX_LENGTH = 16 +FALLBACK = "Unbekannt" + +#: Everything the convention strips out — it keeps only ASCII letters and digits. +_DISALLOWED = re.compile(r"[^A-Za-z0-9]") + +_TRANSLITERATION = str.maketrans( + {"ä": "ae", "ö": "oe", "ü": "ue", "Ä": "Ae", "Ö": "Oe", "Ü": "Ue", "ß": "ss"} +) + +#: The ``Zusatz`` per list kind. The two club-scoped kinds are prefixed with the +#: normalised club name. +_SUFFIX = { + FileType.DEFINITION: "Wk", + FileType.CLUB_ENTRIES: "Me", + FileType.CLUB_RESULTS: "Pr", + FileType.MEET_RESULTS: "Pr", +} + +#: The list kinds whose Zusatz carries the club name. +_CLUB_SCOPED = frozenset({FileType.CLUB_ENTRIES, FileType.CLUB_RESULTS}) + + +def normalize(raw: str | None, max_length: int) -> str: + """Apply the convention to one name part. + + Args: + raw: The raw city or club name; may be ``None``. + max_length: How many characters to keep. + + Returns: + The transliterated, stripped and truncated part; :data:`FALLBACK` when + nothing usable remains. + """ + if raw is None: + return FALLBACK + cleaned = _DISALLOWED.sub("", raw.translate(_TRANSLITERATION)) + return cleaned[:max_length] if cleaned else FALLBACK + + +def file_name( + last_section_date: dt.date, + city: str | None, + file_type: FileType, + *, + club_name: str | None = None, + version: int = 8, + zipped: bool = False, + sequence: int | None = None, +) -> str: + """Build the conventional file name for one DSV file. + + Args: + last_section_date: Date of the meet's last section. + city: The Veranstaltungsort — the city, not the pool. + file_type: Which of the four list kinds the file is. + club_name: The club, for the two club-scoped list kinds; ignored otherwise. + version: The DSV format version, which is also the extension. + zipped: Whether this is the ZIP-packed ``.DSV8z`` variant (Format 8 only). + sequence: Discriminator for several files of the same day and city — the + standard appends it to the city part (``…-Berlin1-Pr.DSV8``). + + Returns: + The file name, e.g. ``2026-06-13-Berlin-Pr.DSV8``. + """ + place = normalize(city, CITY_MAX_LENGTH) + if sequence is not None: + place = f"{place}{sequence}" + suffix = _SUFFIX[file_type] + if file_type in _CLUB_SCOPED: + suffix = f"{normalize(club_name, CLUB_MAX_LENGTH)}-{suffix}" + extension = f"DSV{version}{'z' if zipped else ''}" + return f"{last_section_date:%Y-%m-%d}-{place}-{suffix}.{extension}" diff --git a/dsv_parser/py.typed b/dsv_parser/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/dsv_parser/spec/__init__.py b/dsv_parser/spec/__init__.py new file mode 100644 index 0000000..83c6dfa --- /dev/null +++ b/dsv_parser/spec/__init__.py @@ -0,0 +1,20 @@ +"""The DSV element table and the vocabulary it is written in. + +Importing :data:`REGISTRY` is the only thing the reader needs to know about the +format; every attribute order, every version delta and every list-kind variant +lives in :mod:`dsv_parser.spec.elements`. +""" + +from .elements import ELEMENTS, POST_PROCESSED, REGISTRY +from .fields import VERSIONS, Attribute, ElementSpec, Kind, Registry + +__all__ = [ + "ELEMENTS", + "POST_PROCESSED", + "REGISTRY", + "VERSIONS", + "Attribute", + "ElementSpec", + "Kind", + "Registry", +] diff --git a/dsv_parser/spec/elements.py b/dsv_parser/spec/elements.py new file mode 100644 index 0000000..9919bb0 --- /dev/null +++ b/dsv_parser/spec/elements.py @@ -0,0 +1,767 @@ +"""The element table — the single source of truth for every DSV layout. + +Read this file as the specification itself: one :class:`~dsv_parser.spec.fields.ElementSpec` +per element, attributes in the order the file writes them, with ``versions=`` and +``file_types=`` marking every place the formats disagree. Nothing else in the +package knows the attribute order of anything. + +Provenance +---------- +The table is transcribed from the two official DSV documents: + +* *„DSV Standard“ — Standardisierung des Datenaustausches*, **Format 6**, + 1 November 2015, in force 1 September 2015. +* *ebenda*, **Format 7**, 31 August 2022, in force 1 January 2023. +* *ebenda*, **Format 8**, 14 March 2026, in force 1 August 2026; Format 7 stays + valid until 31 December 2026. + +Every ``versions=`` marker is one line of one of those documents' change logs: +``V8`` from the Format 8 log, ``V7_PLUS`` from the Format 7 log against Format 6, +``V6_PLUS`` from the Format 6 log. The layouts were additionally checked against +real EasyWk output in Format 6 and Format 7. + +Coverage by format version +-------------------------- +**Format 7 and 8** are complete: all four Listarten, every element, every +attribute, with the six Format 8 deltas marked — + +1. ``BANKVERBINDUNG`` gains ``Kontoinhaber``. +2. ``LASTSCHRIFT`` is a new element in the Wettkampfdefinitionsliste. +3. ``VEREIN`` gains a ``Lastschrift`` flag, in the Vereinsmeldeliste only. +4. ``TRAINER`` gains ``Geschlecht``. +5. ``KARIMELDUNG`` gains ``Geschlecht``. +6. ``MELDEGELD`` gains the types *Teilnehmermeldegeld* and *Abschnittspauschale*; + ``Ausübung`` gains ``KB``/``KR`` (Kicks Bauch-/Rückenlage). Both are vocabulary + additions, marked in :mod:`dsv_parser.model.enums` rather than here. + +The Format 8 ZIP variant (``.DSV8z``) is handled in +:mod:`dsv_parser.core.decoding`. + +**Format 6** is Format 7 minus the Format 7 change log, of which two entries move +attribute positions and are therefore modelled here: the three ``Nationalität`` +attributes do not exist (verified against real Format 6 protocols — ``PNERGEBNIS`` +carries 16 attributes there against 19 in Format 7), and ``HANDICAP`` does not +exist. The rest of that change log is vocabulary: ``EW``/``PA`` best-list +categories and the gender ``D`` arrive in Format 7, the Freiwasser best-list code +``FS`` leaves with it, and six judge positions are added — all marked in +:mod:`dsv_parser.model.enums`. + +**Format 5** is Format 6 minus the Format 6 change log, whose four structural +entries are modelled here: ``ABSCHNITT`` has no ``Relative Angabe`` attribute, and +the elements ``NACHWEIS``, ``PNREAKTION`` and ``STABLOESE`` do not exist. One +Format 5 idiom has no representation and is called out by +:mod:`dsv_parser.core.parser`: a reaction time was carried as a +``PNZWISCHENZEIT`` with ``Distanz = 0``, which Format 6 replaced with +``PNREAKTION``. See ``TODO(spec-v5)`` below for the residual uncertainty. +""" + +from __future__ import annotations + +from ..model import elements as el +from ..model.enums import ( + AgeClassType, + BestListCategory, + Course, + EnmStatus, + EntryFeeType, + EventGender, + Exercise, + FileType, + Gender, + JudgeGroup, + JudgePosition, + ProofCourse, + ResultStatus, + Round, + Stroke, + TimingSystem, +) +from .fields import Attribute as A +from .fields import ElementSpec, Kind, Registry + +# --- shorthand for the applicability markers ------------------------------ + +V8 = frozenset({8}) +#: Attributes and elements the Format 7 change log introduced against Format 6. +V7_PLUS = frozenset({7, 8}) +#: Attributes and elements the Format 6 change log introduced — absent from Format 5. +V6_PLUS = frozenset({6, 7, 8}) +DEFINITION = frozenset({FileType.DEFINITION}) +CLUB_ENTRIES = frozenset({FileType.CLUB_ENTRIES}) +CLUB_RESULTS = frozenset({FileType.CLUB_RESULTS}) +MEET_RESULTS = frozenset({FileType.MEET_RESULTS}) +RESULT_LISTS = CLUB_RESULTS | MEET_RESULTS +#: Every list kind except the Vereinsmeldeliste — the one that omits the +#: Bestenliste attribute of ``WETTKAMPF`` and thereby shifts the two +#: qualification attributes one position forward. +WITH_BEST_LIST = DEFINITION | RESULT_LISTS + +# A postal contact block, shared verbatim by MELDEADRESSE and ANSPRECHPARTNER. +_CONTACT = ( + A("name", Kind.TEXT, "Name"), + A("street", Kind.TEXT, "Strasse"), + A("postal_code", Kind.TEXT, "PLZ"), + A("city", Kind.TEXT, "Ort"), + A("nation", Kind.TEXT, "Land"), + A("phone", Kind.TEXT, "Telefon"), + A("fax", Kind.TEXT, "Fax"), + A("email", Kind.TEXT, "eMail"), +) + +# The inline swimmer identity repeated by the result-list element shapes. +# Introduced by Format 7 ("Einführung der Nationalität bei Meldungen und +# Ergebnissen"). They sit at the end of every element that carries them, so in a +# Format 6 file the element is simply three attributes shorter. +_NATIONALITIES = ( + A("nationality_1", Kind.TEXT, "Nationalität 1", versions=V7_PLUS), + A("nationality_2", Kind.TEXT, "Nationalität 2", versions=V7_PLUS), + A("nationality_3", Kind.TEXT, "Nationalität 3", versions=V7_PLUS), +) + + +ELEMENTS: tuple[ElementSpec, ...] = ( + # === File header ===================================================== + ElementSpec( + element="FORMAT", + target=None, + model=el.Format, + repeated=False, + description="Mandatory first line: list kind and format version.", + attributes=( + A("file_type", Kind.ENUM, "Listart", enum=FileType), + A("version", Kind.INT, "Version"), + ), + ), + ElementSpec( + element="ERZEUGER", + target="generator", + model=el.Generator, + repeated=False, + description="The software that wrote the file.", + attributes=( + A("software", Kind.TEXT, "Software"), + A("version", Kind.TEXT, "Version"), + A("contact", Kind.TEXT, "Kontakt"), + ), + ), + ElementSpec( + element="VERANSTALTUNG", + target="meet", + model=el.MeetInfo, + repeated=False, + description="Meet name, city, pool length and timing system.", + attributes=( + A("name", Kind.TEXT, "Veranstaltungsbezeichnung"), + A("city", Kind.TEXT, "Veranstaltungsort"), + A("course", Kind.ENUM, "Bahnlänge", enum=Course), + A("timing", Kind.ENUM, "Zeitmessung", enum=TimingSystem), + ), + ), + ElementSpec( + element="VERANSTALTUNGSORT", + target="venue", + model=el.Venue, + repeated=False, + file_types=DEFINITION, + description="The pool and how to reach it.", + attributes=( + A("name", Kind.TEXT, "Bezeichnung"), + A("street", Kind.TEXT, "Strasse"), + A("postal_code", Kind.TEXT, "PLZ"), + A("city", Kind.TEXT, "Ort"), + A("nation", Kind.TEXT, "Land"), + A("phone", Kind.TEXT, "Telefon"), + A("fax", Kind.TEXT, "Fax"), + A("email", Kind.TEXT, "eMail"), + ), + ), + ElementSpec( + element="AUSSCHREIBUNGIMNETZ", + target="announcement_url", + model=el.ScalarText, + unwrap="value", + repeated=False, + file_types=DEFINITION, + description="URL of the online announcement.", + attributes=(A("value", Kind.TEXT, "Internetadresse"),), + ), + ElementSpec( + element="VERANSTALTER", + target="organizer_name", + model=el.ScalarText, + unwrap="value", + repeated=False, + file_types=DEFINITION | RESULT_LISTS, + description="The association or club staging the meet.", + attributes=(A("value", Kind.TEXT, "Name"),), + ), + ElementSpec( + element="BESONDERES", + target="remarks", + model=el.ScalarText, + unwrap="value", + repeated=False, + file_types=DEFINITION, + description="Free-text remarks of the announcement.", + attributes=(A("value", Kind.TEXT, "Bemerkungen"),), + ), + ElementSpec( + element="LASTSCHRIFT", + target="direct_debit_only", + model=el.ScalarFlag, + unwrap="value", + repeated=False, + versions=V8, + file_types=DEFINITION, + description="Whether entry fees are collected by direct debit only.", + attributes=(A("value", Kind.FLAG, "Nur Lastschrift"),), + ), + ElementSpec( + element="MELDESCHLUSS", + target="entry_deadline", + model=el.EntryDeadline, + repeated=False, + file_types=DEFINITION, + description="Entry deadline; the assembler merges date and clock into one instant.", + attributes=( + A("date", Kind.DATE, "Meldeschluss Datum"), + A("time", Kind.CLOCK, "Meldeschluss Uhrzeit"), + ), + ), + ElementSpec( + element="AUSRICHTER", + target="host", + model=el.Host, + repeated=False, + file_types=DEFINITION | RESULT_LISTS, + description="The organising club and its contact person.", + attributes=( + A("name", Kind.TEXT, "Name"), + A("contact_name", Kind.TEXT, "Kontakt"), + A("street", Kind.TEXT, "Strasse"), + A("postal_code", Kind.TEXT, "PLZ"), + A("city", Kind.TEXT, "Ort"), + A("nation", Kind.TEXT, "Land"), + A("phone", Kind.TEXT, "Telefon"), + A("fax", Kind.TEXT, "Fax"), + A("email", Kind.TEXT, "eMail"), + ), + ), + ElementSpec( + element="MELDEADRESSE", + target="entry_address", + model=el.Contact, + repeated=False, + file_types=DEFINITION, + description="Where entries are sent.", + attributes=_CONTACT, + ), + ElementSpec( + element="ANSPRECHPARTNER", + target="contact_person", + model=el.Contact, + repeated=False, + file_types=CLUB_ENTRIES, + description="The entering club's contact person.", + attributes=_CONTACT, + ), + ElementSpec( + element="BANKVERBINDUNG", + target="bank_account", + model=el.BankAccount, + repeated=False, + file_types=DEFINITION, + description="Where entry fees are paid.", + attributes=( + A("bank_name", Kind.TEXT, "Bankname"), + A("iban", Kind.TEXT, "IBAN"), + A("bic", Kind.TEXT, "BIC"), + A("account_holder", Kind.TEXT, "Kontoinhaber", versions=V8), + ), + ), + ElementSpec( + element="NACHWEIS", + target="proof_of_time", + model=el.ProofOfTime, + repeated=False, + versions=V6_PLUS, + file_types=DEFINITION, + description="Window and course a qualifying time may be proven on.", + attributes=( + A("valid_from", Kind.DATE, "Nachweis von"), + A("valid_until", Kind.DATE, "Nachweis bis"), + A("course", Kind.ENUM, "Bahnlänge", enum=ProofCourse), + ), + ), + # === Programme ======================================================= + ElementSpec( + element="ABSCHNITT", + target="sections", + model=el.Section, + description=( + "One session. The definition list additionally carries the admission " + "and judges'-briefing times, which shift the start time two positions." + ), + attributes=( + A("number", Kind.INT, "Abschnittsnummer"), + A("date", Kind.DATE, "Abschnittsdatum"), + A("admission_time", Kind.CLOCK, "Einlass", file_types=DEFINITION), + A( + "judges_meeting_time", + Kind.CLOCK, + "Kampfrichtersitzung", + file_types=DEFINITION, + ), + A("start_time", Kind.CLOCK, "Anfangszeit"), + A("relative_timing", Kind.FLAG, "Relative Angabe", versions=V6_PLUS), + ), + ), + ElementSpec( + element="WETTKAMPF", + target="events", + model=el.Event, + description=( + "One event of the programme. The Vereinsmeldeliste omits the " + "Bestenliste attribute, shifting both qualification attributes forward." + ), + attributes=( + A("number", Kind.INT, "Wettkampfnummer"), + A("round", Kind.ENUM, "Wettkampfart", enum=Round), + A("section_number", Kind.INT, "Abschnittsnummer"), + A("relay_legs", Kind.INT, "Anzahl Starter"), + A("distance", Kind.INT, "Einzelstrecke"), + A("stroke", Kind.ENUM, "Technik", enum=Stroke), + A("exercise", Kind.ENUM, "Ausübung", enum=Exercise), + A("gender", Kind.ENUM, "Geschlecht", enum=EventGender), + A( + "best_list_category", + Kind.ENUM, + "Zuordnung Bestenliste", + enum=BestListCategory, + file_types=WITH_BEST_LIST, + ), + A("qualification_event_number", Kind.INT, "Qualifikationswettkampf"), + A("qualification_round", Kind.ENUM, "Qualifikationswettkampfart", enum=Round), + ), + ), + ElementSpec( + element="WERTUNG", + target="age_groups", + model=el.AgeGroup, + file_types=DEFINITION | RESULT_LISTS, + description="One scoring group of an event.", + attributes=( + A("event_number", Kind.INT, "Wettkampfnummer"), + A("round", Kind.ENUM, "Wettkampfart", enum=Round), + A("scoring_id", Kind.INT, "Wertungs-ID"), + A("class_type", Kind.ENUM, "Wertungsklasse", enum=AgeClassType), + A("lower_bound", Kind.TEXT, "Mindest-JG/AK"), + A("upper_bound", Kind.TEXT, "Höchst-JG/AK"), + A("gender", Kind.ENUM, "Geschlecht", enum=EventGender), + A("name", Kind.TEXT, "Wertungsname"), + ), + ), + ElementSpec( + element="PFLICHTZEIT", + target="qualification_times", + model=el.QualificationTime, + file_types=DEFINITION, + description="The qualifying time for one event and age group.", + attributes=( + A("event_number", Kind.INT, "Wettkampfnummer"), + A("round", Kind.ENUM, "Wettkampfart", enum=Round), + A("class_type", Kind.ENUM, "Wertungsklasse", enum=AgeClassType), + A("lower_bound", Kind.TEXT, "Mindest-JG/AK"), + A("upper_bound", Kind.TEXT, "Höchst-JG/AK"), + A("time_millis", Kind.SWIM_TIME, "Pflichtzeit"), + A("gender", Kind.ENUM, "Geschlecht", enum=Gender), + ), + ), + ElementSpec( + element="MELDEGELD", + target="entry_fees", + model=el.EntryFee, + file_types=DEFINITION, + description="One fee position of the announcement.", + attributes=( + A("fee_type", Kind.ENUM, "Meldegeldtyp", enum=EntryFeeType), + A("amount_cents", Kind.AMOUNT, "Betrag"), + A("event_number", Kind.INT, "Wettkampfnummer"), + ), + ), + # === Participants ==================================================== + ElementSpec( + element="VEREIN", + target="clubs", + model=el.Club, + file_types=CLUB_ENTRIES | RESULT_LISTS, + description="A participating club.", + attributes=( + A("name", Kind.TEXT, "Vereinsbezeichnung"), + A("dsv_club_id", Kind.INT, "Vereinskennzahl"), + A("lsv_code", Kind.INT, "Landesschwimmverband"), + A("nation", Kind.TEXT, "Nation"), + A( + "direct_debit_approved", + Kind.FLAG, + "Lastschrift", + versions=V8, + file_types=CLUB_ENTRIES, + ), + ), + ), + ElementSpec( + element="TRAINER", + target="coaches", + model=el.Coach, + file_types=CLUB_ENTRIES, + description="A coach nominated by the entering club.", + attributes=( + A("number", Kind.INT, "Trainernummer"), + A("name", Kind.TEXT, "Trainername"), + A("gender", Kind.ENUM, "Geschlecht", enum=Gender, versions=V8), + ), + ), + ElementSpec( + element="PNMELDUNG", + target="swimmers", + model=el.Swimmer, + file_types=CLUB_ENTRIES, + description="A swimmer in an entry list — carries the coach reference.", + attributes=( + A("name", Kind.TEXT, "Name"), + A("dsv_id", Kind.INT, "DSV-ID"), + A("local_id", Kind.INT, "Veranstaltungs-ID"), + A("gender", Kind.ENUM, "Geschlecht", enum=Gender), + A("birth_year", Kind.INT, "Jahrgang"), + A("age_class", Kind.INT, "Altersklasse"), + A("coach_number", Kind.INT, "Trainernummer"), + *_NATIONALITIES, + ), + ), + ElementSpec( + element="PERSON", + target="swimmers", + model=el.Swimmer, + file_types=CLUB_RESULTS, + description="A swimmer in a club result list — no coach reference.", + attributes=( + A("name", Kind.TEXT, "Name"), + A("dsv_id", Kind.INT, "DSV-ID"), + A("local_id", Kind.INT, "Veranstaltungs-ID"), + A("gender", Kind.ENUM, "Geschlecht", enum=Gender), + A("birth_year", Kind.INT, "Jahrgang"), + A("age_class", Kind.INT, "Altersklasse"), + *_NATIONALITIES, + ), + ), + ElementSpec( + element="HANDICAP", + target="handicaps", + model=el.Handicap, + versions=V7_PLUS, + file_types=CLUB_ENTRIES, + description="A para swimmer's classification.", + attributes=( + A("swimmer_local_id", Kind.INT, "Veranstaltungs-ID"), + A("dbs_id", Kind.TEXT, "DBS-ID"), + A("ipc_id", Kind.TEXT, "IPC-ID"), + A("start_class", Kind.TEXT, "Startklasse"), + A("start_class_breast", Kind.TEXT, "Startklasse Brust"), + A("start_class_medley", Kind.TEXT, "Startklasse Lagen"), + A("exceptions", Kind.TEXT, "Ausnahmegenehmigungen"), + ), + ), + ElementSpec( + element="KARIMELDUNG", + target="judge_nominations", + model=el.JudgeNomination, + file_types=CLUB_ENTRIES, + description="A judge the entering club brings to the meet.", + attributes=( + A("number", Kind.INT, "Kampfrichternummer"), + A("name", Kind.TEXT, "Kampfrichtername"), + A("group", Kind.ENUM, "Kampfrichtergruppe", enum=JudgeGroup), + A("gender", Kind.ENUM, "Geschlecht", enum=Gender, versions=V8), + ), + ), + ElementSpec( + element="KARIABSCHNITT", + target="judge_section_wishes", + model=el.JudgeSectionWish, + file_types=CLUB_ENTRIES, + description="Which section a nominated judge is available for.", + attributes=( + A("judge_number", Kind.INT, "Kampfrichternummer"), + A("section_number", Kind.INT, "Abschnittsnummer"), + A("position", Kind.ENUM, "Einsatzwunsch", enum=JudgePosition), + ), + ), + ElementSpec( + element="KAMPFGERICHT", + target="judge_assignments", + model=el.JudgeAssignment, + file_types=RESULT_LISTS, + description="A judge actually assigned to a section.", + attributes=( + A("section_number", Kind.INT, "Abschnittsnummer"), + A("position", Kind.ENUM, "Kampfrichteraufgabe", enum=JudgePosition), + A("name", Kind.TEXT, "Name"), + A("club_name", Kind.TEXT, "Verein"), + ), + ), + # === Entries ========================================================= + ElementSpec( + element="STARTPN", + target="individual_entries", + model=el.IndividualEntry, + file_types=CLUB_ENTRIES, + description="One swimmer's entry into one event.", + attributes=( + A("swimmer_local_id", Kind.INT, "Veranstaltungs-ID"), + A("event_number", Kind.INT, "Wettkampfnummer"), + A("entry_time_millis", Kind.SWIM_TIME, "Meldezeit"), + ), + ), + ElementSpec( + element="STMELDUNG", + target="relays", + model=el.Relay, + file_types=CLUB_ENTRIES, + description="A relay team in an entry list.", + attributes=( + A("team_number", Kind.INT, "Mannschaftsnummer"), + A("local_id", Kind.INT, "Veranstaltungs-ID"), + A("class_type", Kind.ENUM, "Wertungsklasse", enum=AgeClassType), + A("lower_bound", Kind.TEXT, "Mindest-JG/AK"), + A("upper_bound", Kind.TEXT, "Höchst-JG/AK"), + A("name", Kind.TEXT, "Staffelname"), + ), + ), + ElementSpec( + element="STAFFEL", + target="relays", + model=el.Relay, + file_types=CLUB_RESULTS, + description=( + "A relay team in a club result list. The Wettkampfergebnisliste has no " + "STAFFEL element — STERGEBNIS identifies the team inline." + ), + attributes=( + A("team_number", Kind.INT, "Mannschaftsnummer"), + A("local_id", Kind.INT, "Veranstaltungs-ID"), + A("class_type", Kind.ENUM, "Wertungsklasse", enum=AgeClassType), + A("lower_bound", Kind.TEXT, "Mindest-JG/AK"), + A("upper_bound", Kind.TEXT, "Höchst-JG/AK"), + ), + ), + ElementSpec( + element="STARTST", + target="relay_entries", + model=el.RelayEntry, + file_types=CLUB_ENTRIES, + description="One relay team's entry into one event.", + attributes=( + A("relay_local_id", Kind.INT, "Veranstaltungs-ID"), + A("event_number", Kind.INT, "Wettkampfnummer"), + A("entry_time_millis", Kind.SWIM_TIME, "Meldezeit"), + ), + ), + # Two shapes, specific first: the entry list references the swimmer by its + # meet-local id, the result lists identify the swimmer inline. + ElementSpec( + element="STAFFELPERSON", + target="relay_swimmers", + model=el.RelaySwimmer, + file_types=CLUB_ENTRIES, + description="One leg of a relay, referencing an already-declared swimmer.", + attributes=( + A("relay_local_id", Kind.INT, "Veranstaltungs-ID Staffel"), + A("event_number", Kind.INT, "Wettkampfnummer"), + A("swimmer_local_id", Kind.INT, "Veranstaltungs-ID Person"), + A("leg_number", Kind.INT, "Startfolge"), + ), + ), + ElementSpec( + element="STAFFELPERSON", + target="relay_swimmers", + model=el.RelaySwimmer, + file_types=RESULT_LISTS, + description="One leg of a relay, with the swimmer identified inline.", + attributes=( + A("relay_local_id", Kind.INT, "Veranstaltungs-ID Staffel"), + A("event_number", Kind.INT, "Wettkampfnummer"), + A("round", Kind.ENUM, "Wettkampfart", enum=Round), + A("name", Kind.TEXT, "Name"), + A("dsv_id", Kind.INT, "DSV-ID"), + A("leg_number", Kind.INT, "Startfolge"), + A("gender", Kind.ENUM, "Geschlecht", enum=Gender), + A("birth_year", Kind.INT, "Jahrgang"), + A("age_class", Kind.INT, "Altersklasse"), + *_NATIONALITIES, + ), + ), + # === Results ========================================================= + ElementSpec( + element="PERSONENERGEBNIS", + target="individual_results", + model=el.IndividualResult, + file_types=CLUB_RESULTS, + description="An individual result in a club result list.", + attributes=( + A("swimmer_local_id", Kind.INT, "Veranstaltungs-ID"), + A("event_number", Kind.INT, "Wettkampfnummer"), + A("round", Kind.ENUM, "Wettkampfart", enum=Round), + A("scoring_id", Kind.INT, "Wertungs-ID"), + A("place", Kind.INT, "Platz"), + A("time_millis", Kind.SWIM_TIME, "Endzeit"), + A("status", Kind.ENUM, "Grund der Nichtwertung", enum=ResultStatus), + A("remark", Kind.TEXT, "Bemerkung"), + A("enm", Kind.ENUM, "ENM", enum=EnmStatus), + ), + ), + ElementSpec( + element="PNERGEBNIS", + target="individual_results", + model=el.IndividualResult, + file_types=MEET_RESULTS, + description="An individual result in the meet protocol, swimmer identified inline.", + attributes=( + A("event_number", Kind.INT, "Wettkampfnummer"), + A("round", Kind.ENUM, "Wettkampfart", enum=Round), + A("scoring_id", Kind.INT, "Wertungs-ID"), + A("place", Kind.INT, "Platz"), + A("status", Kind.ENUM, "Grund der Nichtwertung", enum=ResultStatus), + A("name", Kind.TEXT, "Name"), + A("dsv_id", Kind.INT, "DSV-ID"), + A("swimmer_local_id", Kind.INT, "Veranstaltungs-ID"), + A("gender", Kind.ENUM, "Geschlecht", enum=Gender), + A("birth_year", Kind.INT, "Jahrgang"), + A("age_class", Kind.INT, "Altersklasse"), + A("club_name", Kind.TEXT, "Vereinsbezeichnung"), + A("club_dsv_id", Kind.INT, "Vereinskennzahl"), + A("time_millis", Kind.SWIM_TIME, "Endzeit"), + A("remark", Kind.TEXT, "Bemerkung"), + A("enm", Kind.ENUM, "ENM", enum=EnmStatus), + *_NATIONALITIES, + ), + ), + ElementSpec( + element="PNZWISCHENZEIT", + target="splits", + model=el.Split, + file_types=RESULT_LISTS, + description="One intermediate time of an individual swim.", + attributes=( + A("swimmer_local_id", Kind.INT, "Veranstaltungs-ID"), + A("event_number", Kind.INT, "Wettkampfnummer"), + A("round", Kind.ENUM, "Wettkampfart", enum=Round), + A("distance", Kind.INT, "Zwischenzeitdistanz"), + A("time_millis", Kind.SWIM_TIME, "Zwischenzeit"), + ), + ), + ElementSpec( + element="PNREAKTION", + target="reactions", + model=el.Reaction, + versions=V6_PLUS, + file_types=RESULT_LISTS, + description="The start reaction time of an individual swim.", + attributes=( + A("swimmer_local_id", Kind.INT, "Veranstaltungs-ID"), + A("event_number", Kind.INT, "Wettkampfnummer"), + A("round", Kind.ENUM, "Wettkampfart", enum=Round), + A("negative", Kind.SIGN, "Vorzeichen"), + A("time_millis", Kind.SWIM_TIME, "Reaktionszeit"), + ), + ), + ElementSpec( + element="STAFFELERGEBNIS", + target="relay_results", + model=el.RelayResult, + file_types=CLUB_RESULTS, + description="A relay result in a club result list.", + attributes=( + A("relay_local_id", Kind.INT, "Veranstaltungs-ID"), + A("event_number", Kind.INT, "Wettkampfnummer"), + A("round", Kind.ENUM, "Wettkampfart", enum=Round), + A("scoring_id", Kind.INT, "Wertungs-ID"), + A("place", Kind.INT, "Platz"), + A("time_millis", Kind.SWIM_TIME, "Endzeit"), + A("status", Kind.ENUM, "Grund der Nichtwertung", enum=ResultStatus), + A("disqualified_leg", Kind.INT, "Startfolge DS"), + A("remark", Kind.TEXT, "Bemerkung"), + A("enm", Kind.ENUM, "ENM", enum=EnmStatus), + ), + ), + ElementSpec( + element="STERGEBNIS", + target="relay_results", + model=el.RelayResult, + file_types=MEET_RESULTS, + description="A relay result in the meet protocol, club identified inline.", + attributes=( + A("event_number", Kind.INT, "Wettkampfnummer"), + A("round", Kind.ENUM, "Wettkampfart", enum=Round), + A("scoring_id", Kind.INT, "Wertungs-ID"), + A("place", Kind.INT, "Platz"), + A("status", Kind.ENUM, "Grund der Nichtwertung", enum=ResultStatus), + A("team_number", Kind.INT, "Mannschaftsnummer"), + A("relay_local_id", Kind.INT, "Veranstaltungs-ID"), + A("club_name", Kind.TEXT, "Vereinsbezeichnung"), + A("club_dsv_id", Kind.INT, "Vereinskennzahl"), + A("time_millis", Kind.SWIM_TIME, "Endzeit"), + A("disqualified_leg", Kind.INT, "Startfolge DS"), + A("remark", Kind.TEXT, "Bemerkung"), + A("enm", Kind.ENUM, "ENM", enum=EnmStatus), + ), + ), + ElementSpec( + element="STZWISCHENZEIT", + target="relay_splits", + model=el.RelaySplit, + file_types=RESULT_LISTS, + description="One intermediate time of a relay swim.", + attributes=( + A("relay_local_id", Kind.INT, "Veranstaltungs-ID"), + A("event_number", Kind.INT, "Wettkampfnummer"), + A("round", Kind.ENUM, "Wettkampfart", enum=Round), + A("leg_number", Kind.INT, "Startfolge"), + A("distance", Kind.INT, "Zwischenzeitdistanz"), + A("time_millis", Kind.SWIM_TIME, "Zwischenzeit"), + ), + ), + ElementSpec( + element="STABLOESE", + target="relay_takeoffs", + model=el.RelayTakeoff, + versions=V6_PLUS, + file_types=RESULT_LISTS, + description="One relay changeover time.", + attributes=( + A("relay_local_id", Kind.INT, "Veranstaltungs-ID"), + A("event_number", Kind.INT, "Wettkampfnummer"), + A("round", Kind.ENUM, "Wettkampfart", enum=Round), + A("leg_number", Kind.INT, "Startfolge"), + A("negative", Kind.SIGN, "Vorzeichen"), + A("time_millis", Kind.SWIM_TIME, "Ablösezeit"), + ), + ), +) + +#: Elements the assembler post-processes because they do not map onto a document +#: field one-to-one: ``FORMAT`` steers the whole parse (its two attributes become +#: two document fields) and ``MELDESCHLUSS`` merges a date and a clock into one +#: instant. ``DATEIENDE`` carries no attributes and is handled by the parser loop. +POST_PROCESSED: frozenset[str] = frozenset({"FORMAT", "MELDESCHLUSS"}) + +# TODO(spec-v5): the Format 5 deltas above are derived from the Format 6 change log, +# not from the Format 5 document itself — the DSV no longer publishes it. The log is +# dated after the "Format 6, März 2015" release, so strictly it records amendments to +# Format 6; a Format 5 file predates all of them either way, but a Format 6 file written +# between March and October 2015 will also lack them (harmlessly: the missing attribute +# is the last one of its element, and a missing element is simply absent). Confirm +# against a copy of the Format 5 document or against real Format 5 files, then drop the +# residual warning in `dsv_parser.core.parser`. + +REGISTRY = Registry(ELEMENTS) diff --git a/dsv_parser/spec/fields.py b/dsv_parser/spec/fields.py new file mode 100644 index 0000000..f397343 --- /dev/null +++ b/dsv_parser/spec/fields.py @@ -0,0 +1,261 @@ +"""The vocabulary the element table is written in. + +A DSV element is fully described by three things: its name, the ordered list of +its attributes, and the conditions under which each attribute is present. Encoding +that as data rather than as code is the one structural decision this parser makes, +and everything else follows from it: + +* **Version and list-kind deltas stop being branches.** ``WETTKAMPF`` has a + best-list attribute in every list kind except the Vereinsmeldeliste, where the + two qualification attributes shift one position forward. In a hand-written + parser that is an ``if`` and an index arithmetic bug waiting to happen; here it + is one ``file_types=`` on one attribute. +* **Adding an element is one table entry**, not a new ``case`` plus a new handler. +* **The spec becomes introspectable.** ``dsv-parser spec`` and ``GET /spec`` render + the table itself, so the documentation cannot drift from the implementation. +""" + +from __future__ import annotations + +from collections.abc import Iterator +from dataclasses import dataclass, field +from enum import Enum +from typing import TYPE_CHECKING + +from ..model.enums import DsvEnum, FileType + +if TYPE_CHECKING: + from ..model.elements import DsvElement + +#: Every format version this parser knows about. +VERSIONS: frozenset[int] = frozenset({5, 6, 7, 8}) + + +def _applies( + versions: frozenset[int] | None, + file_types: frozenset[FileType] | None, + version: int | None, + file_type: FileType | None, +) -> bool: + """Whether a versions=/file_types= marked entry is present in a file flavour. + + The one tolerance policy for attributes and elements alike: an unknown + version or list kind counts as applicable, because the reader has to do + something with a file whose ``FORMAT`` line it could not read, and keeping + everything is the reading that loses the least data. + + Args: + versions: The format versions the entry is declared for, ``None`` for all. + file_types: The list kinds the entry is declared for, ``None`` for all. + version: The declared format version, or ``None`` when unknown. + file_type: The declared list kind, or ``None`` when unknown. + + Returns: + ``True`` when the entry occupies a position in this file. + """ + if versions is not None and version is not None and version not in versions: + return False + return not (file_types is not None and file_type is not None and file_type not in file_types) + + +class Kind(Enum): + """The primitive type of an attribute, as declared by the spec's Datentypen.""" + + TEXT = "text" + """Free text; empty means absent.""" + + INT = "int" + """A plain integer.""" + + SWIM_TIME = "swim_time" + """``Zeit`` (``HH:MM:SS,hh``), carried as milliseconds.""" + + DATE = "date" + """``Datum`` (``TT.MM.JJJJ``).""" + + CLOCK = "clock" + """``Uhrzeit`` (``HH:MM``).""" + + AMOUNT = "amount" + """``Betrag``, carried as euro cents.""" + + FLAG = "flag" + """A ``J``/``N`` flag; absent stays distinct from an explicit ``N``.""" + + SIGN = "sign" + """A ``+``/``-`` reaction-time sign.""" + + ENUM = "enum" + """A coded value; the member is resolved via :meth:`DsvEnum.from_code`.""" + + +@dataclass(frozen=True, slots=True) +class Attribute: + """One positional attribute of an element. + + Attributes: + name: The field name on the element model. + kind: The primitive type to coerce the raw text to. + german: The attribute's name in the DSV specification, kept for the + generated documentation and for diagnostics a German-speaking user + can match against the spec PDF. + enum: The vocabulary for :attr:`Kind.ENUM` attributes. + versions: The format versions that carry this attribute. ``None`` means + all of them. + file_types: The list kinds that carry this attribute. ``None`` means all + of them. An attribute that drops out shifts every following attribute + one position forward — which is exactly the intent. + """ + + name: str + kind: Kind + german: str + enum: type[DsvEnum] | None = None + versions: frozenset[int] | None = None + file_types: frozenset[FileType] | None = None + + def applies(self, version: int | None, file_type: FileType | None) -> bool: + """Whether this attribute is present in the given file flavour. + + Delegates to the module's single tolerance policy — see :func:`_applies`. + + Args: + version: The declared format version, or ``None`` when unknown. + file_type: The declared list kind, or ``None`` when unknown. + + Returns: + ``True`` when the attribute occupies a position in this file. + """ + return _applies(self.versions, self.file_types, version, file_type) + + +@dataclass(frozen=True, slots=True) +class ElementSpec: + """The layout of one DSV element line. + + Attributes: + element: The element constant as it appears in the file, e.g. ``WETTKAMPF``. + target: The :class:`~dsv_parser.model.document.DsvDocument` field the + parsed element is stored in — a list field for repeated elements, a + scalar field otherwise. ``None`` for ``FORMAT``, whose two attributes + become two document fields and which the parser reads up front. + model: The pydantic model the attributes are bound to. + attributes: The attributes in file order. + repeated: Whether the element may occur many times. A repeated element is + appended to its target list; a singular one overwrites, with a warning + on the second occurrence. + versions: The format versions the element exists in. ``None`` means all. + file_types: The list kinds the element may appear in. ``None`` means all. + An element outside its declared list kinds is still parsed — the + declaration drives validation and documentation, not rejection. + unwrap: For an element whose whole payload is a single attribute, the model + field to lift out so the document stores the bare value instead of a + one-field object. + description: One line for the generated spec documentation. + """ + + element: str + target: str | None + model: type[DsvElement] + attributes: tuple[Attribute, ...] + repeated: bool = True + versions: frozenset[int] | None = None + file_types: frozenset[FileType] | None = None + unwrap: str | None = None + description: str = "" + + def applies(self, version: int | None, file_type: FileType | None) -> bool: + """Whether this layout is the right one for the given file flavour. + + Delegates to the module's single tolerance policy — see :func:`_applies`. + + Args: + version: The declared format version, or ``None`` when unknown. + file_type: The declared list kind, or ``None`` when unknown. + + Returns: + ``True`` when this spec should be used to read the element. + """ + return _applies(self.versions, self.file_types, version, file_type) + + def active(self, version: int | None, file_type: FileType | None) -> tuple[Attribute, ...]: + """The attributes that occupy a position in the given file flavour. + + Args: + version: The declared format version, or ``None`` when unknown. + file_type: The declared list kind, or ``None`` when unknown. + + Returns: + The applicable attributes, in file order. + """ + return tuple(a for a in self.attributes if a.applies(version, file_type)) + + +@dataclass(frozen=True, slots=True) +class Registry: + """All element layouts, indexed by element constant. + + An element may have more than one layout — ``STAFFELPERSON`` is a different + shape in a Vereinsmeldeliste than in a result list. Layouts are tried in + declaration order and the first applicable one wins, so a specific variant must + be declared before the general fallback. + + Attributes: + specs: Every layout, in declaration order. + """ + + specs: tuple[ElementSpec, ...] + _by_element: dict[str, tuple[ElementSpec, ...]] = field( + default_factory=dict, init=False, repr=False, compare=False + ) + + def __post_init__(self) -> None: + """Build the element index once, at construction.""" + index: dict[str, list[ElementSpec]] = {} + for spec in self.specs: + index.setdefault(spec.element, []).append(spec) + object.__setattr__(self, "_by_element", {key: tuple(value) for key, value in index.items()}) + + def lookup( + self, element: str, version: int | None, file_type: FileType | None + ) -> ElementSpec | None: + """Find the layout to read one element line with. + + Args: + element: The element constant from the file, upper-cased. + version: The declared format version, or ``None`` when unknown. + file_type: The declared list kind, or ``None`` when unknown. + + Returns: + The first applicable layout, or ``None`` when the element is unknown + to this parser or exists in no applicable variant. With an unknown + list kind every variant applies; the widest one is returned then, + honouring the parser's "most permissive layout" diagnostic — the + declaration-order tie-break alone would pick an arbitrary variant. + """ + candidates = [ + spec for spec in self._by_element.get(element, ()) if spec.applies(version, file_type) + ] + if not candidates: + return None + if file_type is None and len(candidates) > 1: + return max(candidates, key=lambda spec: len(spec.active(version, file_type))) + return candidates[0] + + def knows(self, element: str) -> bool: + """Whether the element constant appears in the table at all. + + Distinguishes "unknown element" from "known element, wrong flavour", which + are very different diagnostics for the person holding the file. + + Args: + element: The element constant from the file, upper-cased. + + Returns: + ``True`` when at least one layout declares this element. + """ + return element in self._by_element + + def __iter__(self) -> Iterator[ElementSpec]: + """Iterate every layout in declaration order.""" + return iter(self.specs) diff --git a/dsv_parser/spec/render.py b/dsv_parser/spec/render.py new file mode 100644 index 0000000..f3964ea --- /dev/null +++ b/dsv_parser/spec/render.py @@ -0,0 +1,112 @@ +"""Render the element table as documentation. + +The table is the specification this parser implements, so the documentation is +generated from it rather than written alongside it — there is no second copy to +drift. Both the CLI's ``spec`` subcommand and the API's ``GET /spec`` render from +here. +""" + +from __future__ import annotations + +from typing import Any + +from ..model.enums import DsvEnum +from .elements import REGISTRY +from .fields import ElementSpec, Kind + + +def describe_element(spec: ElementSpec) -> dict[str, Any]: + """Describe one element layout as a JSON-serialisable dict. + + Args: + spec: The layout. + + Returns: + A dict with the element name, its target field and its attributes. + """ + return { + "element": spec.element, + "description": spec.description, + "target": spec.target, + "repeated": spec.repeated, + "versions": sorted(spec.versions) if spec.versions else "all", + "file_types": (sorted(ft.value for ft in spec.file_types) if spec.file_types else "all"), + "attributes": [ + { + "position": position + 1, + "name": attribute.name, + "german": attribute.german, + "kind": attribute.kind.value, + "enum": attribute.enum.__name__ if attribute.enum else None, + "versions": sorted(attribute.versions) if attribute.versions else "all", + "file_types": ( + sorted(ft.value for ft in attribute.file_types) + if attribute.file_types + else "all" + ), + } + for position, attribute in enumerate(spec.attributes) + ], + } + + +def describe_registry() -> dict[str, Any]: + """Describe the whole element table plus every vocabulary it references. + + Returns: + A dict with an ``elements`` list and a ``vocabularies`` mapping of enum + name to its ``code`` → speaking-value table. + """ + vocabularies: dict[str, dict[str, str]] = {} + for spec in REGISTRY: + for attribute in spec.attributes: + if attribute.kind is Kind.ENUM and attribute.enum is not None: + vocabularies.setdefault(attribute.enum.__name__, attribute.enum.codes()) + return { + "elements": [describe_element(spec) for spec in REGISTRY], + "vocabularies": dict(sorted(vocabularies.items())), + } + + +def render_text() -> str: + """Render the element table as a plain-text reference for the terminal. + + Returns: + The table, one element per block, attributes numbered by position. + """ + lines: list[str] = [] + for spec in REGISTRY: + scope = [] + if spec.versions: + scope.append("v" + "/".join(str(v) for v in sorted(spec.versions))) + if spec.file_types: + scope.append(", ".join(sorted(ft.value for ft in spec.file_types))) + suffix = f" [{'; '.join(scope)}]" if scope else "" + lines.append(f"{spec.element}{suffix}") + if spec.description: + lines.append(f" {spec.description}") + for position, attribute in enumerate(spec.attributes, start=1): + marks = [] + if attribute.versions: + marks.append("v" + "/".join(str(v) for v in sorted(attribute.versions))) + if attribute.file_types: + marks.append(", ".join(sorted(ft.value for ft in attribute.file_types))) + kind = attribute.enum.__name__ if attribute.enum else attribute.kind.value + note = f" ({'; '.join(marks)})" if marks else "" + lines.append( + f" {position:>2}. {attribute.german:<28} {attribute.name:<28} {kind}{note}" + ) + lines.append("") + return "\n".join(lines) + + +def vocabulary_table(enum: type[DsvEnum]) -> str: + """Render one vocabulary as ``CODE = speaking_value`` lines. + + Args: + enum: The vocabulary. + + Returns: + One line per member that has a file code. + """ + return "\n".join(f" {code:<20} {value}" for code, value in enum.codes().items()) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..609bfe9 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,134 @@ +[project] +name = "dsv-parser" +version = "0.0.0" +description = "Standalone parser for DSV swim-meet interchange files (DSV5–DSV8) into a typed data schema — usable as a library, a CLI and a FastAPI service." +requires-python = ">=3.12" +license = "MIT" +license-files = ["LICENSE"] +authors = [{ name = "Nils Knittel" }] + +# The PyPI project page is rendered from README.md — the same document the +# repository shows, so there is one description to keep current, not two. +readme = "README.md" +keywords = ["dsv", "dsv7", "dsv8", "swimming", "swim-meet", "parser", "pydantic"] + +# No "License :: ..." classifier here on purpose: the license is declared as an +# SPDX expression above (PEP 639), and PyPI rejects a distribution that carries +# both. +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Framework :: FastAPI", + "Framework :: Pydantic :: 2", + "Natural Language :: German", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: File Formats", + "Topic :: Software Development :: Libraries :: Python Modules", + "Typing :: Typed", +] + +dependencies = [ + # The parsing core itself has no third-party runtime dependency beyond pydantic: + # decoding, lexing and value coercion are pure standard library. pydantic carries + # the data schema (validation + JSON Schema + serialisation), which is the whole + # point of the project — other services consume the schema, not the Python objects. + "pydantic>=2.13.4", +] + +# The HTTP surface is optional: a consumer that only imports `dsv_parser.parse_bytes` +# (or runs the CLI) must not have to pull in FastAPI and uvicorn. `uv sync --extra api` +# and the container image install it; the library install stays lean. +[project.optional-dependencies] +api = [ + "fastapi>=0.141.1", + "uvicorn[standard]>=0.52.0", + "python-multipart>=0.0.32", +] + +[project.scripts] +dsv-parser = "dsv_parser.cli:main" + +[project.urls] +Homepage = "https://github.com/nilskntl/dsv-parser" +Repository = "https://github.com/nilskntl/dsv-parser" +Issues = "https://github.com/nilskntl/dsv-parser/issues" +Changelog = "https://github.com/nilskntl/dsv-parser/blob/main/CHANGELOG.md" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["dsv_parser"] + +# Listed explicitly rather than left to hatchling's "everything not VCS-ignored" +# default, so what ships in the sdist is a decision, not a side effect of the +# working tree. Tests and docs are in: they are what makes a source release +# reviewable and reproducible. +[tool.hatch.build.targets.sdist] +include = ["dsv_parser", "tests", "docs", "README.md", "CHANGELOG.md", "LICENSE"] + +[dependency-groups] +dev = [ + "pytest>=9.1.1", + "pytest-cov>=7.1.0", + "httpx>=0.28.1", + "mypy>=2.3.0", + "ruff>=0.16.0", + "pip-audit>=2.10.1", +] + +[tool.ruff] +line-length = 100 +target-version = "py312" + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B", "SIM"] + +[tool.ruff.lint.flake8-bugbear] +# FastAPI uses callables (Depends/File/Query/…) as default argument values by design — +# these are not the mutable-default antipattern B008 targets. +extend-immutable-calls = [ + "fastapi.Depends", + "fastapi.File", + "fastapi.Form", + "fastapi.Query", + "fastapi.Body", + "fastapi.Path", + "fastapi.Header", + "fastapi.UploadFile", +] + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" + +[tool.mypy] +python_version = "3.12" +ignore_missing_imports = true +warn_return_any = true +plugins = ["pydantic.mypy"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "--cov=dsv_parser --cov-report=term-missing --strict-markers" +markers = [ + "integration: full-wiring tests (FastAPI test client / CLI end-to-end); deselected by 'make test', run by 'make test-it'", +] + +# Coverage gate. +# fail_under is enforced on the full run ('make test-it'); 'make test' passes --no-cov. +[tool.coverage.run] +branch = true +omit = ["*/__main__.py"] + +[tool.coverage.report] +fail_under = 80 +show_missing = true +exclude_also = [ + "if TYPE_CHECKING:", + "if __name__ == .__main__.:", +] diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 0000000..9b63cac --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "packages": { + ".": { + "release-type": "python", + "package-name": "dsv-parser", + "changelog-path": "CHANGELOG.md", + "include-component-in-tag": false + } + }, + "changelog-sections": [ + { "type": "feat", "section": "Features" }, + { "type": "fix", "section": "Bug Fixes" }, + { "type": "perf", "section": "Performance" }, + { "type": "refactor", "section": "Refactoring" }, + { "type": "docs", "section": "Documentation", "hidden": true }, + { "type": "test", "section": "Tests", "hidden": true }, + { "type": "build", "section": "Build", "hidden": true }, + { "type": "ci", "section": "CI", "hidden": true }, + { "type": "chore", "section": "Chores", "hidden": true } + ] +} diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..8402048 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,45 @@ +"""Shared fixtures. + +The DSV files under ``tests/fixtures`` are synthetic. Real files carry names, +addresses, birth dates and bank details of actual people, and none of that +belongs in a repository — see the ``/data/`` entry in ``.gitignore`` for where to +drop real files when reproducing a bug locally. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +FIXTURES = Path(__file__).parent / "fixtures" + + +@pytest.fixture +def fixtures() -> Path: + """The directory holding the synthetic DSV fixtures.""" + return FIXTURES + + +@pytest.fixture +def definition_bytes() -> bytes: + """A Format 7 Wettkampfdefinitionsliste.""" + return (FIXTURES / "definition.dsv7").read_bytes() + + +@pytest.fixture +def results_bytes() -> bytes: + """A Format 8 Wettkampfergebnisliste.""" + return (FIXTURES / "results.dsv8").read_bytes() + + +@pytest.fixture +def entries_bytes() -> bytes: + """A Format 7 Vereinsmeldeliste.""" + return (FIXTURES / "entries.dsv7").read_bytes() + + +@pytest.fixture +def broken_bytes() -> bytes: + """A file with one problem of every kind the reader must survive.""" + return (FIXTURES / "broken.dsv7").read_bytes() diff --git a/tests/fixtures/broken.dsv7 b/tests/fixtures/broken.dsv7 new file mode 100644 index 0000000..be7984d --- /dev/null +++ b/tests/fixtures/broken.dsv7 @@ -0,0 +1,6 @@ +FORMAT: Wettkampfdefinitionsliste;7; +VERANSTALTUNG: Kaputt;Musterstadt;99;TELEPATHIE; +ABSCHNITT: eins;32.13.2026;25:99;08:15;08:45;; +WETTKAMPF: 1;Q;1;1;100;Z;GL;W;SW;;; +UNBEKANNT: 1;2;3; +kein element diff --git a/tests/fixtures/definition.dsv7 b/tests/fixtures/definition.dsv7 new file mode 100644 index 0000000..ad11b30 --- /dev/null +++ b/tests/fixtures/definition.dsv7 @@ -0,0 +1,24 @@ +FORMAT: Wettkampfdefinitionsliste;7; +ERZEUGER: Fixture;1.0;dev@example.org; +VERANSTALTUNG: 1. Synthetisches Sportfest;Musterstadt;25;AUTOMATISCH; +VERANSTALTUNGSORT: Hallenbad Nord;Badstrasse 1;12345;Musterstadt;GER;;;bad@example.org; +AUSSCHREIBUNGIMNETZ: https://example.org/ausschreibung; +VERANSTALTER: SV Musterstadt; +AUSRICHTER: SV Musterstadt;Muster, Erika;Badstrasse 1;12345;Musterstadt;GER;;;meldung@example.org; +MELDEADRESSE: Muster, Erika;Badstrasse 1;12345;Musterstadt;GER;;;meldung@example.org; +MELDESCHLUSS: 01.05.2026;18:00; +BANKVERBINDUNG: Musterbank;DE00000000000000000000;MUSTDEFF; +BESONDERES: Keine Nachmeldungen.; +NACHWEIS: 01.05.2025;01.05.2026;AL; +ABSCHNITT: 1;16.05.2026;07:30;08:15;08:45;; +ABSCHNITT: 2;16.05.2026;00:00;00:00;00:30;J; (* Uhrzeiten relativ nach vorherigem Abschnitt *) +WETTKAMPF: 1;E;1;1;100;S;GL;W;SW;;; (* 100m Schmetterling weiblich *) +WETTKAMPF: 2;E;1;1;100;S;GL;M;SW;;; +WETTKAMPF: 3;V;2;1;50;F;GL;X;SW;;; +WETTKAMPF: 4;F;2;1;50;F;GL;X;SW;3;V; +WERTUNG: 1;E;1;JG;2010;2012;W;Jahrgang 2010-2012 weiblich; +WERTUNG: 2;E;1;JG;2010;2012;M;Jahrgang 2010-2012 maennlich; +PFLICHTZEIT: 1;E;JG;2010;2012;00:01:15,00;W; +MELDEGELD: Meldegeldpauschale;15,00;; +MELDEGELD: Einzelmeldegeld;3,50;; +DATEIENDE diff --git a/tests/fixtures/entries.dsv7 b/tests/fixtures/entries.dsv7 new file mode 100644 index 0000000..ff5c624 --- /dev/null +++ b/tests/fixtures/entries.dsv7 @@ -0,0 +1,14 @@ +FORMAT: Vereinsmeldeliste;7; +ERZEUGER: Fixture;1.0;dev@example.org; +VERANSTALTUNG: 1. Synthetisches Sportfest;Musterstadt;25;AUTOMATISCH; +ANSPRECHPARTNER: Trainer, Tom;Weg 2;12345;Musterstadt;GER;;;tom@example.org; +ABSCHNITT: 1;16.05.2026;08:45;; +VEREIN: SV Musterstadt;1234;12;GER; +TRAINER: 1;Trainer, Tom;M; +WETTKAMPF: 1;E;1;1;100;S;GL;W;;; +PNMELDUNG: Musterfrau, Mia;111111;1;W;2010;16;1;GER;;; +STARTPN: 1;1;00:01:15,00; +STMELDUNG: 1;901;JG;2010;2012;Staffel A; +STARTST: 901;1;00:02:15,00; +STAFFELPERSON: 901;1;1;1; +DATEIENDE diff --git a/tests/fixtures/entries.dsv8 b/tests/fixtures/entries.dsv8 new file mode 100644 index 0000000..39d81a1 --- /dev/null +++ b/tests/fixtures/entries.dsv8 @@ -0,0 +1,14 @@ +FORMAT: Vereinsmeldeliste;8; +ERZEUGER: Fixture;1.0;dev@example.org; +VERANSTALTUNG: 1. Synthetisches Sportfest;Musterstadt;25;AUTOMATISCH; +ABSCHNITT: 1;16.05.2026;08:45;; +WETTKAMPF: 1;E;1;1;50;S;KB;W;;; +VEREIN: SV Musterstadt;1234;12;GER;J; +ANSPRECHPARTNER: Trainer, Tom;Weg 2;12345;Musterstadt;GER;;;tom@example.org; +KARIMELDUNG: 1;Schiri, Sabine;WKR;W; +KARIABSCHNITT: 1;1;SCH; +TRAINER: 1;Trainer, Tom;M; +PNMELDUNG: Musterfrau, Mia;111111;1;D;2010;16;1;GER;;; +HANDICAP: 1;DBS-1;IPC-1;S9;SB8;SM9;T A 3; +STARTPN: 1;1;00:00:35,00; +DATEIENDE diff --git a/tests/fixtures/results.dsv6 b/tests/fixtures/results.dsv6 new file mode 100644 index 0000000..3484bbe --- /dev/null +++ b/tests/fixtures/results.dsv6 @@ -0,0 +1,11 @@ +FORMAT: Wettkampfergebnisliste;6; +ERZEUGER: Fixture;1.0;dev@example.org; +VERANSTALTUNG: 1. Synthetisches Sportfest;Musterstadt;25;AUTOMATISCH; +VERANSTALTER: SV Musterstadt; +ABSCHNITT: 1;16.05.2026;08:45;; +VEREIN: SV Musterstadt;1234;12;GER; +WETTKAMPF: 1;E;1;1;100;S;GL;W;SW;;; +WERTUNG: 1;E;1;JG;2010;2012;W;Jahrgang 2010-2012 weiblich; +PNERGEBNIS: 1;E;1;1;;Musterfrau, Mia;111111;1;W;2010;16;SV Musterstadt;1234;00:01:12,34;;; +STAFFELPERSON: 901;1;E;Musterfrau, Mia;111111;1;W;2010;16; +DATEIENDE diff --git a/tests/fixtures/results.dsv8 b/tests/fixtures/results.dsv8 new file mode 100644 index 0000000..49a511a --- /dev/null +++ b/tests/fixtures/results.dsv8 @@ -0,0 +1,21 @@ +FORMAT: Wettkampfergebnisliste;8; +ERZEUGER: Fixture;1.0;dev@example.org; +VERANSTALTUNG: 1. Synthetisches Sportfest;Musterstadt;25;AUTOMATISCH; +VERANSTALTER: SV Musterstadt; +AUSRICHTER: SV Musterstadt;Muster, Erika;;;;;;;meldung@example.org; +ABSCHNITT: 1;16.05.2026;08:45;; +KAMPFGERICHT: 1;SCH;Schiri, Sabine;SV Musterstadt; +KAMPFGERICHT: 1;STA;Starter, Stefan;SC Beispiel; +VEREIN: SV Musterstadt;1234;12;GER; +VEREIN: SC Beispiel;5678;12;GER; +WETTKAMPF: 1;E;1;1;100;S;GL;W;SW;;; +WERTUNG: 1;E;1;JG;2010;2012;W;Jahrgang 2010-2012 weiblich; +PNERGEBNIS: 1;E;1;1;;Musterfrau, Mia;111111;1;W;2010;16;SV Musterstadt;1234;00:01:12,34;;;GER;;; +PNZWISCHENZEIT: 1;1;E;50;00:00:34,12; +PNREAKTION: 1;1;E;+;00:00:00,68; +PNERGEBNIS: 1;E;1;0;DS;Beispiel, Bea;222222;2;W;2011;15;SC Beispiel;5678;00:00:00,00;Fehlstart;;GER;;; +STERGEBNIS: 1;E;1;1;;1;901;SV Musterstadt;1234;00:02:10,00;;;; +STAFFELPERSON: 901;1;E;Musterfrau, Mia;111111;1;W;2010;16;GER;;; +STZWISCHENZEIT: 901;1;E;1;50;00:00:33,00; +STABLOESE: 901;1;E;2;-;00:00:00,04; +DATEIENDE diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/test_api.py b/tests/integration/test_api.py new file mode 100644 index 0000000..6d6f435 --- /dev/null +++ b/tests/integration/test_api.py @@ -0,0 +1,83 @@ +"""The HTTP surface, against the real FastAPI application.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +from dsv_parser.api import create_app +from dsv_parser.api.routes import MAX_UPLOAD_BYTES + +pytestmark = pytest.mark.integration + + +@pytest.fixture +def client() -> TestClient: + return TestClient(create_app()) + + +def _upload(path: Path) -> dict[str, tuple[str, bytes, str]]: + return {"file": (path.name, path.read_bytes(), "application/octet-stream")} + + +def test_health_reports_the_supported_formats(client: TestClient) -> None: + response = client.get("/health") + assert response.status_code == 200 + body = response.json() + assert body["status"] == "ok" + assert body["supported_formats"] == [5, 6, 7, 8] + + +def test_spec_lists_elements_and_vocabularies(client: TestClient) -> None: + body = client.get("/spec").json() + assert any(element["element"] == "WETTKAMPF" for element in body["elements"]) + assert body["vocabularies"]["Stroke"]["F"] == "freestyle" + + +def test_parse_returns_the_document(client: TestClient, fixtures: Path) -> None: + response = client.post("/parse", files=_upload(fixtures / "results.dsv8")) + assert response.status_code == 200 + body = response.json() + assert body["clean"] is True + assert body["filename"] == "results.dsv8" + assert body["source"]["encoding"] == "utf-8" + assert len(body["document"]["individual_results"]) == 2 + + +def test_parse_reports_diagnostics_without_failing(client: TestClient, fixtures: Path) -> None: + body = client.post("/parse", files=_upload(fixtures / "broken.dsv7")).json() + assert body["clean"] is False + assert any(entry["severity"] == "error" for entry in body["diagnostics"]) + # A partially readable file is a normal outcome, not an HTTP error. + assert body["document"]["events"] + + +def test_parse_exclude_none_shrinks_the_payload(client: TestClient, fixtures: Path) -> None: + files = _upload(fixtures / "definition.dsv7") + full = client.post("/parse", files=files).content + files = _upload(fixtures / "definition.dsv7") + pruned = client.post("/parse?exclude_none=true", files=files).content + assert len(pruned) < len(full) + + +def test_check_omits_the_document(client: TestClient, fixtures: Path) -> None: + body = client.post("/check", files=_upload(fixtures / "definition.dsv7")).json() + assert body["file_type"] == "definition" + assert body["version"] == 7 + assert body["elements"]["events"] == 4 + assert "document" not in body + + +def test_oversized_upload_is_rejected(client: TestClient) -> None: + payload = b"X" * (MAX_UPLOAD_BYTES + 1) + response = client.post( + "/parse", files={"file": ("huge.dsv8", payload, "application/octet-stream")} + ) + assert response.status_code == 413 + + +def test_openapi_publishes_the_document_schema(client: TestClient) -> None: + schema = client.get("/openapi.json").json() + assert "DsvDocument" in schema["components"]["schemas"] diff --git a/tests/integration/test_cli.py b/tests/integration/test_cli.py new file mode 100644 index 0000000..2d1e999 --- /dev/null +++ b/tests/integration/test_cli.py @@ -0,0 +1,104 @@ +"""The CLI, driven end to end through its argument vector.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from dsv_parser.cli import main + +pytestmark = pytest.mark.integration + + +def test_parse_writes_the_document_as_json(fixtures: Path, tmp_path: Path, capsys) -> None: + output = tmp_path / "out.json" + code = main(["parse", str(fixtures / "definition.dsv7"), "-o", str(output)]) + assert code == 0 + payload = json.loads(output.read_text(encoding="utf-8")) + assert payload["file_type"] == "definition" + assert len(payload["events"]) == 4 + + +def test_parse_writes_to_stdout_by_default(fixtures: Path, capsys) -> None: + assert main(["parse", str(fixtures / "definition.dsv7"), "--compact", "-q"]) == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["version"] == 7 + + +def test_parse_summary_reports_counts(fixtures: Path, capsys) -> None: + assert main(["parse", str(fixtures / "results.dsv8"), "--summary", "-q"]) == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["elements"]["individual_results"] == 2 + assert payload["errors"] == 0 + + +def test_parse_diagnostics_envelope_wraps_both(fixtures: Path, capsys) -> None: + assert main(["parse", str(fixtures / "broken.dsv7"), "--diagnostics", "-q"]) == 0 + payload = json.loads(capsys.readouterr().out) + assert {"document", "diagnostics"} == set(payload) + assert payload["diagnostics"] + + +def test_parse_strict_exits_non_zero_on_data_loss(fixtures: Path) -> None: + assert main(["parse", str(fixtures / "broken.dsv7"), "--strict", "-q"]) == 1 + assert main(["parse", str(fixtures / "definition.dsv7"), "--strict", "-q"]) == 0 + + +def test_parse_exclude_none_drops_empty_fields(fixtures: Path, capsys) -> None: + assert main(["parse", str(fixtures / "definition.dsv7"), "--exclude-none", "-q"]) == 0 + payload = json.loads(capsys.readouterr().out) + assert "venue" in payload + assert "individual_results" not in payload or payload["individual_results"] == [] + + +def test_parse_reads_stdin(monkeypatch, fixtures: Path, capsys) -> None: + class _Stdin: + buffer = (fixtures / "definition.dsv7").open("rb") + + monkeypatch.setattr("sys.stdin", _Stdin()) + assert main(["parse", "-", "--summary", "-q"]) == 0 + assert json.loads(capsys.readouterr().out)["file_type"] == "definition" + + +def test_check_reports_and_exits_on_errors(fixtures: Path, capsys) -> None: + assert main(["check", str(fixtures / "broken.dsv7")]) == 1 + out = capsys.readouterr().out + assert "error:" in out + assert "error(s)" in out + + +def test_check_passes_a_clean_file(fixtures: Path, capsys) -> None: + assert main(["check", str(fixtures / "results.dsv8")]) == 0 + assert "0 error(s)" in capsys.readouterr().out + + +def test_spec_prints_the_element_table(capsys) -> None: + assert main(["spec"]) == 0 + assert "WETTKAMPF" in capsys.readouterr().out + + +def test_spec_json_is_machine_readable(tmp_path: Path) -> None: + output = tmp_path / "spec.json" + assert main(["spec", "--json", "-o", str(output)]) == 0 + payload = json.loads(output.read_text(encoding="utf-8")) + assert payload["elements"] + + +def test_spec_text_to_file(tmp_path: Path) -> None: + output = tmp_path / "spec.txt" + assert main(["spec", "-o", str(output)]) == 0 + assert "WETTKAMPF" in output.read_text(encoding="utf-8") + + +def test_no_command_prints_help(capsys) -> None: + assert main([]) == 2 + assert "usage" in capsys.readouterr().out.lower() + + +def test_diagnostics_go_to_stderr(fixtures: Path, capsys) -> None: + main(["parse", str(fixtures / "broken.dsv7"), "--summary"]) + captured = capsys.readouterr() + assert "error:" in captured.err + assert "error:" not in captured.out diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/test_decoding.py b/tests/unit/test_decoding.py new file mode 100644 index 0000000..e2a3b8d --- /dev/null +++ b/tests/unit/test_decoding.py @@ -0,0 +1,89 @@ +"""Bytes to source text: ZIP unwrapping, encoding sniffing, BOM stripping.""" + +from __future__ import annotations + +import io +import zipfile + +from dsv_parser.core.decoding import decode +from dsv_parser.core.diagnostics import Diagnostics + +PLAIN = "FORMAT: Wettkampfdefinitionsliste;7;\nDATEIENDE\n" + + +def _zip(payload: bytes, name: str = "meet.DSV8") -> bytes: + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as archive: + archive.writestr(name, payload) + return buffer.getvalue() + + +def test_decode_plain_utf8_reports_utf8() -> None: + result = decode(PLAIN.encode("utf-8"), Diagnostics()) + assert result.text == PLAIN + assert result.encoding == "utf-8" + assert result.zipped is False + + +def test_decode_strips_utf8_bom() -> None: + result = decode(b"\xef\xbb\xbf" + PLAIN.encode("utf-8"), Diagnostics()) + assert result.text.startswith("FORMAT") + assert result.encoding == "utf-8-sig" + + +def test_decode_falls_back_to_cp1252_with_warning() -> None: + diagnostics = Diagnostics() + result = decode("VERANSTALTUNG: Grüße;\n".encode("cp1252"), diagnostics) + assert "Grüße" in result.text + assert result.encoding == "cp1252" + assert any("cp1252" in entry.message for entry in diagnostics.warnings) + + +def test_decode_unwraps_zip_archive() -> None: + diagnostics = Diagnostics() + result = decode(_zip(PLAIN.encode("utf-8")), diagnostics) + assert result.text == PLAIN + assert result.zipped is True + assert result.member == "meet.DSV8" + assert diagnostics.clean + + +def test_decode_empty_archive_reports_error() -> None: + diagnostics = Diagnostics() + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w"): + pass + result = decode(buffer.getvalue(), diagnostics) + assert result.text == "" + assert not diagnostics.clean + + +def test_decode_corrupt_archive_reports_error() -> None: + diagnostics = Diagnostics() + result = decode(b"PK\x03\x04garbage", diagnostics) + assert result.text == "" + assert not diagnostics.clean + + +def test_decode_encrypted_archive_reports_error() -> None: + # zipfile raises RuntimeError (not BadZipFile) for an encrypted entry; it + # must become a diagnostic, not an exception escaping parse_bytes. + diagnostics = Diagnostics() + encrypted = bytearray(_zip(PLAIN.encode("utf-8"))) + # Set the encryption bit in the central directory record, which is where + # zipfile takes the entry's flags from. + encrypted[encrypted.find(b"PK\x01\x02") + 8] |= 1 + result = decode(bytes(encrypted), diagnostics) + assert result.text == "" + assert any("could not be read" in entry.message for entry in diagnostics.errors) + + +def test_decode_multi_member_archive_warns_and_reads_first() -> None: + diagnostics = Diagnostics() + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as archive: + archive.writestr("a.DSV8", PLAIN) + archive.writestr("b.DSV8", "FORMAT: x;7;\n") + result = decode(buffer.getvalue(), diagnostics) + assert result.text == PLAIN + assert any("2 entries" in entry.message for entry in diagnostics.warnings) diff --git a/tests/unit/test_lexer.py b/tests/unit/test_lexer.py new file mode 100644 index 0000000..a2e79c3 --- /dev/null +++ b/tests/unit/test_lexer.py @@ -0,0 +1,70 @@ +"""Source text to element lines.""" + +from __future__ import annotations + +from dsv_parser.core.diagnostics import Diagnostics +from dsv_parser.core.lexer import tokenize + + +def test_tokenize_splits_element_and_attributes() -> None: + lines = tokenize("WETTKAMPF: 1;E;1;\n", Diagnostics()) + assert len(lines) == 1 + assert lines[0].element == "WETTKAMPF" + assert lines[0].attributes == ("1", "E", "1") + assert lines[0].line == 1 + + +def test_tokenize_consumes_only_the_terminating_semicolon() -> None: + # "A;B;" is two attributes; "A;B;;" genuinely has an empty third one. + assert tokenize("X: A;B;\n", Diagnostics())[0].attributes == ("A", "B") + assert tokenize("X: A;B;;\n", Diagnostics())[0].attributes == ("A", "B", "") + + +def test_tokenize_strips_trailing_inline_comment() -> None: + # The regression this module exists for: an inline comment must not become + # an attribute, or it silently turns into a value the moment the element + # gains one more attribute in a later format version. + line = tokenize("ABSCHNITT: 2;16.05.2026;08:45;J; (* relativ *)\n", Diagnostics())[0] + assert line.attributes == ("2", "16.05.2026", "08:45", "J") + + +def test_tokenize_strips_comment_between_attributes() -> None: + line = tokenize("X: A;(* mitten drin *)B;\n", Diagnostics())[0] + assert line.attributes == ("A", "B") + + +def test_tokenize_skips_blank_and_comment_only_lines() -> None: + assert tokenize("\n(* header *)\n \n", Diagnostics()) == [] + + +def test_tokenize_recognises_dateiende_without_colon() -> None: + lines = tokenize("DATEIENDE\n", Diagnostics()) + assert lines[0].element == "DATEIENDE" + assert lines[0].attributes == () + + +def test_tokenize_upper_cases_the_element() -> None: + assert tokenize("wettkampf: 1;\n", Diagnostics())[0].element == "WETTKAMPF" + + +def test_tokenize_warns_on_a_line_that_is_neither() -> None: + diagnostics = Diagnostics() + assert tokenize("nonsense\n", diagnostics) == [] + assert len(diagnostics.warnings) == 1 + assert diagnostics.warnings[0].line == 1 + + +def test_tokenize_reports_one_based_line_numbers() -> None: + lines = tokenize("(* c *)\n\nX: A;\n", Diagnostics()) + assert lines[0].line == 3 + + +def test_tokenize_keeps_unicode_line_separators_inside_attributes() -> None: + # str.splitlines would break on U+2028/U+0085 (a web-form paste, or byte 0x85 + # through the latin-1 fallback) and cut the element line in two. + diagnostics = Diagnostics() + lines = tokenize("X: Cup\u2028Halle;N\x85ame;\nY: B;\n", diagnostics) + assert [line.element for line in lines] == ["X", "Y"] + assert lines[0].attributes == ("Cup\u2028Halle", "N\x85ame") + assert lines[1].line == 2 + assert diagnostics.clean diff --git a/tests/unit/test_naming.py b/tests/unit/test_naming.py new file mode 100644 index 0000000..c8d9f17 --- /dev/null +++ b/tests/unit/test_naming.py @@ -0,0 +1,65 @@ +"""The file-naming convention of chapter 2 of the standard.""" + +from __future__ import annotations + +import datetime as dt + +import pytest + +from dsv_parser.model.enums import FileType +from dsv_parser.naming import CITY_MAX_LENGTH, file_name, normalize + +DATE = dt.date(2001, 12, 16) + + +def test_meet_results_name_follows_the_specification_example() -> None: + assert ( + file_name(DATE, "Berlin", FileType.MEET_RESULTS, version=7) == "2001-12-16-Berlin-Pr.DSV7" + ) + + +@pytest.mark.parametrize( + ("city", "expected"), + [("Münster", "Muenster"), ("Frankfurt am Main", "Frankfur")], +) +def test_city_is_transliterated_and_truncated(city: str, expected: str) -> None: + # Both examples are taken verbatim from the standard. + assert normalize(city, CITY_MAX_LENGTH) == expected + + +def test_definition_list_uses_the_wk_suffix() -> None: + assert file_name(DATE, "Berlin", FileType.DEFINITION) == "2001-12-16-Berlin-Wk.DSV8" + + +def test_club_scoped_kinds_carry_the_club_name() -> None: + assert ( + file_name(DATE, "Duisburg", FileType.CLUB_ENTRIES, club_name="SV Hansa Adorf", version=7) + == "2001-12-16-Duisburg-SVHansaAdorf-Me.DSV7" + ) + assert ( + file_name(DATE, "Duisburg", FileType.CLUB_RESULTS, club_name="SV Hansa Adorf", version=7) + == "2001-12-16-Duisburg-SVHansaAdorf-Pr.DSV7" + ) + + +def test_club_name_is_truncated_to_sixteen() -> None: + # "Schwimmverein Musterstadt 1899" → "SchwimmvereinMusterstadt1899" → 16 chars. + assert ( + file_name(DATE, "Berlin", FileType.CLUB_ENTRIES, club_name="Schwimmverein Musterstadt 1899") + == "2001-12-16-Berlin-SchwimmvereinMus-Me.DSV8" + ) + + +def test_zip_variant_appends_z() -> None: + assert file_name(DATE, "Berlin", FileType.MEET_RESULTS, zipped=True).endswith(".DSV8z") + + +def test_sequence_discriminates_same_day_files() -> None: + assert file_name(DATE, "Berlin", FileType.DEFINITION, sequence=2) == ( + "2001-12-16-Berlin2-Wk.DSV8" + ) + + +def test_unusable_name_falls_back() -> None: + assert normalize("---", 8) == "Unbekannt" + assert normalize(None, 8) == "Unbekannt" diff --git a/tests/unit/test_parser.py b/tests/unit/test_parser.py new file mode 100644 index 0000000..24ae908 --- /dev/null +++ b/tests/unit/test_parser.py @@ -0,0 +1,323 @@ +"""End-to-end reading of a whole file, against the synthetic fixtures.""" + +from __future__ import annotations + +import datetime as dt +import io +import zipfile + +from dsv_parser import parse_bytes, parse_file, parse_text +from dsv_parser.model.enums import ( + Course, + EntryFeeType, + Exercise, + FileType, + Gender, + JudgePosition, + ResultStatus, + Round, + Stroke, + TimingSystem, +) + + +def test_parses_a_definition_list_cleanly(definition_bytes: bytes) -> None: + result = parse_bytes(definition_bytes) + document = result.document + + assert result.clean, [d.render() for d in result.diagnostics.errors] + assert document.file_type is FileType.DEFINITION + assert document.version == 7 + assert document.meet is not None + assert document.meet.name == "1. Synthetisches Sportfest" + assert document.meet.course is Course.SCM_25 + assert document.meet.timing is TimingSystem.AUTOMATIC + + +def test_definition_list_populates_the_scalar_header(definition_bytes: bytes) -> None: + document = parse_bytes(definition_bytes).document + + assert document.organizer_name == "SV Musterstadt" + assert document.announcement_url == "https://example.org/ausschreibung" + assert document.remarks == "Keine Nachmeldungen." + assert document.entry_deadline == dt.datetime(2026, 5, 1, 18, 0) + assert document.bank_account is not None + assert document.bank_account.bic == "MUSTDEFF" + + +def test_definition_list_reads_the_programme(definition_bytes: bytes) -> None: + document = parse_bytes(definition_bytes).document + + assert [section.number for section in document.sections] == [1, 2] + assert document.sections[0].admission_time == dt.time(7, 30) + assert document.sections[0].judges_meeting_time == dt.time(8, 15) + assert document.sections[0].start_time == dt.time(8, 45) + assert document.sections[1].relative_timing is True + + assert len(document.events) == 4 + assert document.events[0].distance == 100 + assert document.events[0].stroke is Stroke.BUTTERFLY + assert document.events[0].round is Round.TIMED_FINAL + assert document.events[3].qualification_event_number == 3 + assert document.events[3].qualification_round is Round.PRELIM + + assert [fee.fee_type for fee in document.entry_fees] == [ + EntryFeeType.FLAT_RATE, + EntryFeeType.PER_INDIVIDUAL_START, + ] + assert document.entry_fees[0].amount_cents == 1500 + + +def test_parses_a_result_list_cleanly(results_bytes: bytes) -> None: + result = parse_bytes(results_bytes) + document = result.document + + assert result.clean, [d.render() for d in result.diagnostics.errors] + assert document.file_type is FileType.MEET_RESULTS + assert document.version == 8 + + assert len(document.individual_results) == 2 + first = document.individual_results[0] + assert first.name == "Musterfrau, Mia" + assert first.time_millis == 72_340 + assert first.place == 1 + assert first.status is None # empty attribute — a regularly ranked swim + + second = document.individual_results[1] + assert second.status is ResultStatus.DISQUALIFIED + assert second.time_millis is None # 00:00:00,00 is "no time" + assert second.remark == "Fehlstart" + + +def test_result_list_reads_relays_and_officials(results_bytes: bytes) -> None: + document = parse_bytes(results_bytes).document + + assert [j.position for j in document.judge_assignments] == [ + JudgePosition.REFEREE, + JudgePosition.STARTER, + ] + assert len(document.relay_results) == 1 + assert document.relay_results[0].time_millis == 130_000 + assert document.relay_takeoffs[0].negative is True + assert document.relay_swimmers[0].name == "Musterfrau, Mia" + assert document.splits[0].time_millis == 34_120 + assert document.reactions[0].negative is False + + +def test_meet_protocol_clubs_carry_no_direct_debit_flag(results_bytes: bytes) -> None: + # The Lastschrift flag is a Format 8 addition to the Vereinsmeldeliste only; + # a Wettkampfergebnisliste's VEREIN has four attributes in every version. + document = parse_bytes(results_bytes).document + assert [club.direct_debit_approved for club in document.clubs] == [None, None] + + +def test_entry_list_shifts_the_wettkampf_attributes(entries_bytes: bytes) -> None: + # The Vereinsmeldeliste omits the Bestenliste attribute, so the two + # qualification attributes move one position forward. Getting this wrong + # silently mis-assigns values rather than failing. + result = parse_bytes(entries_bytes) + document = result.document + + assert result.clean, [d.render() for d in result.diagnostics.errors] + assert document.file_type is FileType.CLUB_ENTRIES + assert document.events[0].best_list_category is None + assert document.events[0].gender is not None + + +def test_entry_list_uses_the_short_staffelperson_shape(entries_bytes: bytes) -> None: + document = parse_bytes(entries_bytes).document + leg = document.relay_swimmers[0] + assert leg.swimmer_local_id == 1 + assert leg.leg_number == 1 + assert leg.name is None + + +def test_entry_list_reads_coach_and_contact(entries_bytes: bytes) -> None: + document = parse_bytes(entries_bytes).document + assert document.contact_person is not None + assert document.contact_person.email == "tom@example.org" + assert document.coaches[0].name == "Trainer, Tom" + assert document.swimmers[0].coach_number == 1 + + +def test_broken_file_still_yields_a_document(broken_bytes: bytes) -> None: + result = parse_bytes(broken_bytes) + + assert not result.clean + # Every bad value is isolated: the element survives with the rest intact. + assert result.document.sections[0].number is None + assert result.document.sections[0].date is None + assert result.document.sections[0].judges_meeting_time == dt.time(8, 15) + assert result.document.events[0].number == 1 + assert result.document.events[0].round is None + assert result.document.events[0].stroke is None + + messages = [d.message for d in result.diagnostics.entries] + assert any("unknown element UNBEKANNT" in m for m in messages) + assert any("not an element line" in m for m in messages) + assert any("DATEIENDE" in m for m in messages) + + +def test_missing_format_is_an_error() -> None: + result = parse_text("VERANSTALTUNG: Ohne Kopf;Ort;25;HANDZEIT;\nDATEIENDE\n") + assert not result.clean + assert any("FORMAT" in d.message for d in result.diagnostics.errors) + # The header is unreadable, so every layout keeps all of its attributes and + # the rest of the file is still recovered. + assert result.document.meet is not None + assert result.document.meet.city == "Ort" + + +def test_empty_file_is_an_error() -> None: + result = parse_text("") + assert not result.clean + assert result.document.element_counts() == {} + + +def test_element_after_dateiende_is_ignored() -> None: + result = parse_text("FORMAT: Wettkampfergebnisliste;7;\nDATEIENDE\nVEREIN: Zu spaet;1;2;GER;\n") + assert result.document.clubs == [] + assert any("after DATEIENDE" in d.message for d in result.diagnostics.warnings) + + +def test_duplicate_singular_element_warns_and_keeps_the_last() -> None: + result = parse_text( + "FORMAT: Wettkampfergebnisliste;7;\n" + "VERANSTALTUNG: Erste;A;25;HANDZEIT;\n" + "VERANSTALTUNG: Zweite;B;50;HANDZEIT;\n" + "DATEIENDE\n" + ) + assert result.document.meet is not None + assert result.document.meet.name == "Zweite" + assert any("more than once" in d.message for d in result.diagnostics.warnings) + + +def test_surplus_attributes_warn_about_an_unmodelled_version() -> None: + result = parse_text("FORMAT: Wettkampfergebnisliste;7;\nVEREIN: A;1;2;GER;J;X;Y;\nDATEIENDE\n") + assert any("beyond the" in d.message for d in result.diagnostics.warnings) + + +def test_format_5_is_read_with_a_warning() -> None: + result = parse_text("FORMAT: Wettkampfdefinitionsliste;5;\nDATEIENDE\n") + assert result.document.version == 5 + assert any("Format 5" in d.message for d in result.diagnostics.warnings) + + +def test_unknown_format_version_warns() -> None: + result = parse_text("FORMAT: Wettkampfdefinitionsliste;9;\nDATEIENDE\n") + assert any("outside the supported range" in d.message for d in result.diagnostics.warnings) + + +def test_future_format_version_is_read_with_the_newest_layout() -> None: + # An unclamped version 9 would fail every versions= marker and collapse the + # layouts to the bare Format-5 skeleton — a Format 8 VEREIN's fifth + # attribute would be dropped as surplus instead of bound. + result = parse_text( + "FORMAT: Vereinsmeldeliste;9;\nVEREIN: SV Musterstadt;1234;17;GER;J;\nDATEIENDE\n" + ) + assert result.document.version == 9 + assert result.document.clubs[0].direct_debit_approved is True + assert not any("beyond the" in d.message for d in result.diagnostics.warnings) + + +def test_duplicate_format_warns_and_keeps_the_first() -> None: + # A second FORMAT would contradict every layout decision already made; it + # must not slip past the duplicate-singular warning just because the main + # loop skips FORMAT lines. + result = parse_text( + "FORMAT: Wettkampfdefinitionsliste;7;\nFORMAT: Vereinsmeldeliste;6;\nDATEIENDE\n" + ) + assert result.document.file_type is FileType.DEFINITION + assert result.document.version == 7 + assert any("FORMAT occurs more than once" in d.message for d in result.diagnostics.warnings) + + +def test_unknown_list_kind_is_an_error_but_parsing_continues() -> None: + result = parse_text("FORMAT: Phantasieliste;7;\nVERANSTALTUNG: X;Y;25;HANDZEIT;\nDATEIENDE\n") + assert result.document.file_type is None + assert not result.clean + assert result.document.meet is not None + + +def test_unknown_list_kind_reads_multi_variant_elements_with_the_widest_layout() -> None: + # STAFFELPERSON is 4 attributes in a Vereinsmeldeliste and 12 in a result + # list. The FORMAT diagnostic promises the most permissive reading, so the + # 12-attribute variant must win over declaration order. + result = parse_text( + "FORMAT: Phantasieliste;7;\n" + "STAFFELPERSON: 901;1;E;Musterfrau, Mia;111111;1;W;2010;16;GER;;;\n" + "DATEIENDE\n" + ) + leg = result.document.relay_swimmers[0] + assert leg.name == "Musterfrau, Mia" + assert not any("beyond the" in d.message for d in result.diagnostics.warnings) + + +def test_parse_file_reads_from_disk(fixtures) -> None: + result = parse_file(fixtures / "definition.dsv7") + assert result.document.file_type is FileType.DEFINITION + + +def test_parse_bytes_unwraps_a_zipped_file(definition_bytes: bytes) -> None: + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as archive: + archive.writestr("meet.DSV8", definition_bytes) + result = parse_bytes(buffer.getvalue()) + assert result.source.zipped is True + assert result.document.file_type is FileType.DEFINITION + + +def test_element_counts_skips_empty_blocks(definition_bytes: bytes) -> None: + counts = parse_bytes(definition_bytes).document.element_counts() + assert counts["events"] == 4 + assert "individual_results" not in counts + + +def test_diagnostic_render_includes_the_location() -> None: + result = parse_text("FORMAT: Wettkampfergebnisliste;7;\nVEREIN: A;x;2;GER;\nDATEIENDE\n") + rendered = [d.render() for d in result.diagnostics.errors] + assert any("line 2" in text and "VEREIN" in text for text in rendered) + + +# --- version deltas, read end to end --------------------------------------- + + +def test_format_6_result_list_has_no_nationalities(fixtures) -> None: + result = parse_file(fixtures / "results.dsv6") + document = result.document + + assert result.clean, [d.render() for d in result.diagnostics.errors] + assert document.version == 6 + swim = document.individual_results[0] + assert swim.time_millis == 72_340 + assert swim.club_dsv_id == 1234 + assert swim.nationality_1 is None + assert document.relay_swimmers[0].age_class == 16 + assert document.relay_swimmers[0].nationality_1 is None + + +def test_format_8_entry_list_reads_every_format_8_addition(fixtures) -> None: + result = parse_file(fixtures / "entries.dsv8") + document = result.document + + assert result.clean, [d.render() for d in result.diagnostics.errors] + assert document.clubs[0].direct_debit_approved is True + assert document.coaches[0].gender is Gender.MALE + assert document.judge_nominations[0].gender is Gender.FEMALE + assert document.events[0].exercise is Exercise.KICKS_PRONE + assert document.swimmers[0].gender is Gender.DIVERSE + assert document.handicaps[0].start_class == "S9" + + +def test_format_5_warns_that_its_layouts_are_derived() -> None: + result = parse_text( + "FORMAT: Wettkampfdefinitionsliste;5;\n" + "ABSCHNITT: 1;16.05.2026;07:30;08:15;08:45;\n" + "DATEIENDE\n" + ) + # Format 5 has no "Relative Angabe" attribute, so the element is one shorter + # and the five attributes present must still land in the right fields. + section = result.document.sections[0] + assert section.start_time == dt.time(8, 45) + assert section.relative_timing is None + assert any("Format 5" in d.message for d in result.diagnostics.warnings) diff --git a/tests/unit/test_spec.py b/tests/unit/test_spec.py new file mode 100644 index 0000000..6cf4897 --- /dev/null +++ b/tests/unit/test_spec.py @@ -0,0 +1,188 @@ +"""The element table itself — the invariants the reader relies on.""" + +from __future__ import annotations + +import pytest + +from dsv_parser.model.document import DsvDocument +from dsv_parser.model.enums import DsvEnum, FileType +from dsv_parser.spec import ELEMENTS, REGISTRY +from dsv_parser.spec.fields import Kind +from dsv_parser.spec.render import describe_registry, render_text + + +def test_every_attribute_exists_on_its_model() -> None: + for spec in ELEMENTS: + fields = set(spec.model.model_fields) + for attribute in spec.attributes: + assert attribute.name in fields, f"{spec.element}.{attribute.name}" + + +def test_every_target_exists_on_the_document() -> None: + # FORMAT is the one element with no single target: the parser reads it up + # front and spreads its two attributes over file_type and version. + fields = DsvDocument.model_fields + for spec in ELEMENTS: + if spec.target is None: + assert spec.element == "FORMAT" + continue + assert spec.target in fields, f"{spec.element} → {spec.target}" + + +def test_repeated_elements_target_a_list_field() -> None: + for spec in ELEMENTS: + if spec.target is None: + continue + annotation = str(DsvDocument.model_fields[spec.target].annotation) + assert spec.repeated == annotation.startswith("list["), spec.element + + +def test_enum_attributes_declare_a_vocabulary() -> None: + for spec in ELEMENTS: + for attribute in spec.attributes: + has_enum = attribute.enum is not None + assert (attribute.kind is Kind.ENUM) == has_enum, f"{spec.element}.{attribute.name}" + + +def test_vocabulary_codes_are_unique_within_an_enum() -> None: + for spec in ELEMENTS: + for attribute in spec.attributes: + if attribute.enum is None: + continue + codes = [m.code.casefold() for m in attribute.enum if m.code is not None] + assert len(codes) == len(set(codes)), attribute.enum.__name__ + + +def test_unwrap_names_a_field_of_its_model() -> None: + for spec in ELEMENTS: + if spec.unwrap is not None: + assert spec.unwrap in spec.model.model_fields, spec.element + + +def test_lookup_prefers_the_more_specific_variant() -> None: + entries = REGISTRY.lookup("STAFFELPERSON", 7, FileType.CLUB_ENTRIES) + results = REGISTRY.lookup("STAFFELPERSON", 7, FileType.MEET_RESULTS) + assert entries is not None and results is not None + assert len(entries.attributes) == 4 + assert len(results.attributes) > 4 + + +def test_lookup_unknown_element_returns_none() -> None: + assert REGISTRY.lookup("GIBTESNICHT", 7, None) is None + assert REGISTRY.knows("GIBTESNICHT") is False + assert REGISTRY.knows("WETTKAMPF") is True + + +@pytest.mark.parametrize( + ("file_type", "expected"), + [(FileType.CLUB_ENTRIES, 10), (FileType.DEFINITION, 11), (FileType.MEET_RESULTS, 11)], +) +def test_wettkampf_drops_the_best_list_attribute_in_entry_lists( + file_type: FileType, expected: int +) -> None: + spec = REGISTRY.lookup("WETTKAMPF", 7, file_type) + assert spec is not None + assert len(spec.active(7, file_type)) == expected + + +# --- the version deltas, one test per line of the official change logs ----- + + +def test_nationalities_arrive_with_format_7() -> None: + # Verified against real protocols: PNERGEBNIS carries 16 attributes in a + # Format 6 file and 19 in a Format 7 one. + spec = REGISTRY.lookup("PNERGEBNIS", 6, FileType.MEET_RESULTS) + assert spec is not None + assert len(spec.active(6, FileType.MEET_RESULTS)) == 16 + assert len(spec.active(7, FileType.MEET_RESULTS)) == 19 + + +def test_handicap_arrives_with_format_7() -> None: + assert REGISTRY.lookup("HANDICAP", 6, FileType.CLUB_ENTRIES) is None + assert REGISTRY.lookup("HANDICAP", 7, FileType.CLUB_ENTRIES) is not None + + +def test_format_5_lacks_the_format_6_additions() -> None: + assert REGISTRY.lookup("NACHWEIS", 5, FileType.DEFINITION) is None + assert REGISTRY.lookup("PNREAKTION", 5, FileType.MEET_RESULTS) is None + assert REGISTRY.lookup("STABLOESE", 5, FileType.MEET_RESULTS) is None + section = REGISTRY.lookup("ABSCHNITT", 5, FileType.DEFINITION) + assert section is not None + assert [a.name for a in section.active(5, FileType.DEFINITION)][-1] == "start_time" + + +def test_format_8_additions_are_absent_from_format_7() -> None: + bank = REGISTRY.lookup("BANKVERBINDUNG", 7, FileType.DEFINITION) + coach = REGISTRY.lookup("TRAINER", 7, FileType.CLUB_ENTRIES) + judge = REGISTRY.lookup("KARIMELDUNG", 7, FileType.CLUB_ENTRIES) + assert bank is not None and coach is not None and judge is not None + assert len(bank.active(7, FileType.DEFINITION)) == 3 + assert len(bank.active(8, FileType.DEFINITION)) == 4 + assert len(coach.active(7, FileType.CLUB_ENTRIES)) == 2 + assert len(coach.active(8, FileType.CLUB_ENTRIES)) == 3 + assert len(judge.active(7, FileType.CLUB_ENTRIES)) == 3 + assert len(judge.active(8, FileType.CLUB_ENTRIES)) == 4 + + +def test_club_lastschrift_flag_is_format_8_and_entry_lists_only() -> None: + spec = REGISTRY.lookup("VEREIN", 8, FileType.CLUB_ENTRIES) + assert spec is not None + assert len(spec.active(8, FileType.CLUB_ENTRIES)) == 5 + assert len(spec.active(8, FileType.MEET_RESULTS)) == 4 + assert len(spec.active(7, FileType.CLUB_ENTRIES)) == 4 + + +@pytest.mark.parametrize( + ("element", "allowed"), + [ + ("PNMELDUNG", FileType.CLUB_ENTRIES), + ("PERSON", FileType.CLUB_RESULTS), + ("STMELDUNG", FileType.CLUB_ENTRIES), + ("STAFFEL", FileType.CLUB_RESULTS), + ("PNERGEBNIS", FileType.MEET_RESULTS), + ("PERSONENERGEBNIS", FileType.CLUB_RESULTS), + ("STERGEBNIS", FileType.MEET_RESULTS), + ("STAFFELERGEBNIS", FileType.CLUB_RESULTS), + ("VERANSTALTUNGSORT", FileType.DEFINITION), + ("ANSPRECHPARTNER", FileType.CLUB_ENTRIES), + ], +) +def test_single_list_kind_elements_are_scoped(element: str, allowed: FileType) -> None: + for file_type in FileType: + found = REGISTRY.lookup(element, 8, file_type) + assert (found is not None) == (file_type is allowed), f"{element} in {file_type}" + + +def test_abschnitt_is_shorter_outside_the_definition_list() -> None: + spec = REGISTRY.lookup("ABSCHNITT", 7, None) + assert spec is not None + assert len(spec.active(7, FileType.DEFINITION)) == 6 + assert len(spec.active(7, FileType.MEET_RESULTS)) == 4 + + +def test_lastschrift_exists_only_in_format_8() -> None: + assert REGISTRY.lookup("LASTSCHRIFT", 8, FileType.DEFINITION) is not None + assert REGISTRY.lookup("LASTSCHRIFT", 7, FileType.DEFINITION) is None + + +def test_render_text_covers_every_element() -> None: + rendered = render_text() + for spec in ELEMENTS: + assert spec.element in rendered + + +def test_describe_registry_is_json_serialisable() -> None: + described = describe_registry() + assert {"elements", "vocabularies"} == set(described) + assert len(described["elements"]) == len(ELEMENTS) + assert "Stroke" in described["vocabularies"] + + +def test_enum_from_code_is_case_insensitive_and_trims() -> None: + class Sample(DsvEnum): + A = ("alpha", "AL") + + assert Sample.from_code(" al ") is Sample.A + assert Sample.from_code("") is None + assert Sample.from_code(None) is None + assert Sample.from_code("nope") is None diff --git a/tests/unit/test_values.py b/tests/unit/test_values.py new file mode 100644 index 0000000..fee6355 --- /dev/null +++ b/tests/unit/test_values.py @@ -0,0 +1,167 @@ +"""The primitive DSV attribute types.""" + +from __future__ import annotations + +import datetime as dt + +import pytest + +from dsv_parser.core.values import ( + DsvValueError, + format_amount_cents, + format_clock, + format_date, + format_swim_time, + parse_amount_cents, + parse_clock, + parse_date, + parse_flag, + parse_integer, + parse_sign, + parse_swim_time, +) + + +@pytest.mark.parametrize( + ("raw", "expected"), + [ + ("00:01:12,34", 72_340), + ("01:00:00,00", 3_600_000), + ("00:00:00,01", 10), + ("00:00:34.12", 34_120), + (" 00:00:05,00 ", 5_000), + ], +) +def test_parse_swim_time_valid_literal_returns_millis(raw: str, expected: int) -> None: + assert parse_swim_time(raw) == expected + + +def test_parse_swim_time_all_zero_returns_none() -> None: + assert parse_swim_time("00:00:00,00") is None + + +def test_parse_swim_time_blank_returns_none() -> None: + assert parse_swim_time(" ") is None + + +@pytest.mark.parametrize("raw", ["00:00:00", "1:2", "abc", "00:00:xx,00", "00:00:00;00"]) +def test_parse_swim_time_malformed_raises(raw: str) -> None: + with pytest.raises(DsvValueError): + parse_swim_time(raw) + + +@pytest.mark.parametrize( + "raw", ["00:00:25,5", "00:00:25,345", "00:-1:30,00", "00:99:99,00", "00:00:01,-50"] +) +def test_parse_swim_time_lax_component_raises_instead_of_guessing(raw: str) -> None: + # Each of these used to parse to a silently wrong number of milliseconds: + # a one-digit fraction read as hundredths, negative or >59 minutes/seconds + # folded into the total. Wrong-but-plausible times corrupt rankings, so they + # must be a diagnostic, not a value. + with pytest.raises(DsvValueError): + parse_swim_time(raw) + + +def test_format_swim_time_round_trip_preserves_value() -> None: + assert format_swim_time(parse_swim_time("00:01:12,34")) == "00:01:12,34" + + +def test_format_swim_time_none_renders_placeholder() -> None: + assert format_swim_time(None) == "00:00:00,00" + + +def test_parse_date_valid_literal_returns_date() -> None: + assert parse_date("16.05.2026") == dt.date(2026, 5, 16) + + +@pytest.mark.parametrize("raw", ["32.13.2026", "2026-05-16", "16.05", "x.y.z"]) +def test_parse_date_malformed_raises(raw: str) -> None: + with pytest.raises(DsvValueError): + parse_date(raw) + + +def test_parse_date_huge_year_raises_dsv_error_not_overflow() -> None: + # dt.date raises OverflowError (not ValueError) beyond the C-long range; it + # must still surface as DsvValueError so the reader never raises on content. + with pytest.raises(DsvValueError): + parse_date("01.01.99999999999999999999") + + +def test_format_date_round_trip_preserves_value() -> None: + assert format_date(parse_date("01.02.2026")) == "01.02.2026" + + +def test_format_date_none_returns_empty() -> None: + assert format_date(None) == "" + + +def test_parse_clock_valid_literal_returns_time() -> None: + assert parse_clock("08:45") == dt.time(8, 45) + + +@pytest.mark.parametrize("raw", ["25:99", "8", "08:45:00", "99999999999999999999:00"]) +def test_parse_clock_malformed_raises(raw: str) -> None: + with pytest.raises(DsvValueError): + parse_clock(raw) + + +def test_format_clock_round_trip_preserves_value() -> None: + assert format_clock(parse_clock("08:45")) == "08:45" + assert format_clock(None) == "" + + +@pytest.mark.parametrize( + ("raw", "expected"), + [("15,00", 1500), ("3,5", 350), ("12", 1200), ("12.50", 1250), ("-2,50", -250)], +) +def test_parse_amount_cents_valid_literal_returns_cents(raw: str, expected: int) -> None: + assert parse_amount_cents(raw) == expected + + +@pytest.mark.parametrize("raw", ["1,2,3", "abc", ",50"]) +def test_parse_amount_cents_malformed_raises(raw: str) -> None: + with pytest.raises(DsvValueError): + parse_amount_cents(raw) + + +def test_format_amount_cents_round_trip_preserves_value() -> None: + assert format_amount_cents(parse_amount_cents("15,00")) == "15,00" + assert format_amount_cents(None) == "" + + +def test_format_amount_cents_negative_round_trips() -> None: + # Floor division on negative cents used to render -550 as "-6,50". + assert format_amount_cents(parse_amount_cents("-2,50")) == "-2,50" + assert format_amount_cents(-550) == "-5,50" + assert format_amount_cents(-50) == "-0,50" + + +def test_parse_integer_blank_returns_none() -> None: + assert parse_integer("") is None + + +def test_parse_integer_not_a_number_raises() -> None: + with pytest.raises(DsvValueError): + parse_integer("eins") + + +def test_parse_flag_tri_state_distinguishes_absent_from_no() -> None: + assert parse_flag("J") is True + assert parse_flag("n") is False + assert parse_flag("") is None + + +def test_parse_flag_other_value_raises() -> None: + with pytest.raises(DsvValueError): + parse_flag("Y") + + +def test_parse_sign_defaults_to_positive() -> None: + assert parse_sign("") is False + assert parse_sign("+") is False + assert parse_sign("-") is True + + +def test_parse_sign_other_value_raises() -> None: + with pytest.raises(DsvValueError): + parse_sign("*") diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..bb6192d --- /dev/null +++ b/uv.lock @@ -0,0 +1,1526 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version < '3.15'", +] + +[[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.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, +] + +[[package]] +name = "ast-serialize" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e1/a9/11851c3e02a3fea2ddc9932d1fdc7d2edaeecc0d2e11bc5f2a7fde2b0934/ast_serialize-0.8.0.tar.gz", hash = "sha256:6c37c43e4004dfb42d321ddedc569dc17ff4259296f3af577c9ea46a809bc010", size = 845638, upload-time = "2026-08-07T11:29:02.152Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/16/6e520b57cd8c75914b38c670ad4593d13c22911e4306cc7165dab8b0789b/ast_serialize-0.8.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:3d822605fa7bb326ef868d25fafced7fc660fa46d9b90c02ea86d5e2f5d325f7", size = 863924, upload-time = "2026-08-07T11:27:34.579Z" }, + { url = "https://files.pythonhosted.org/packages/03/e1/48802de9b22a2bcad42ec80601a17e3f69172fe4f590e6311bcc2b323aeb/ast_serialize-0.8.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:2efa40b068197d5efb62655b43baadb842ed71c4958cccd3e8b86a35726f0119", size = 1177662, upload-time = "2026-08-07T11:27:36.196Z" }, + { url = "https://files.pythonhosted.org/packages/38/d4/323438db76bded3a1f3523a3167b8325916b2ddceb2107a330c6ec9fcf4d/ast_serialize-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:db1b957291bca08c7e72f43a12357b2948e20775d970e3fc3dac0aa3160ab725", size = 1167072, upload-time = "2026-08-07T11:27:37.646Z" }, + { url = "https://files.pythonhosted.org/packages/77/82/53c5400b54144b56de8ed7f957fd1ccd97e42482009292ab46121d15f8dd/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdc0d5b18ff8fb364e87923e47c0a91d0d69dbcaeaa274591f7fd26892cc3a3a", size = 1225497, upload-time = "2026-08-07T11:27:39.225Z" }, + { url = "https://files.pythonhosted.org/packages/44/5f/36c07327a8b91303fbf1382c7c3e8a2902072dbe1b9546138a5288e75ff0/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9da7330f3e235bf7da89b8d39205c6350fc0c08a85379743f2df9fff87d6d980", size = 1227101, upload-time = "2026-08-07T11:27:40.799Z" }, + { url = "https://files.pythonhosted.org/packages/9d/48/5adf5c67addc7ddb328122208c6d375a84cf154984f412b4087330a157bd/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f3186969ee66a9863b00acc6523ace44c56974eecb348a7ea4b228d9f0b80e19", size = 1424001, upload-time = "2026-08-07T11:27:42.708Z" }, + { url = "https://files.pythonhosted.org/packages/38/a1/70074dd3869d2b0e934f91891d8d6b734361cd3b80f85ca7ece2e668ecdd/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40a57b73731be45da4fa41430c4d5dc94a24b3a4faba7b9e069978c0402064ea", size = 1245545, upload-time = "2026-08-07T11:27:44.4Z" }, + { url = "https://files.pythonhosted.org/packages/e3/be/53b9c0a8a6399950c2e3546bdfab96d2b299d5b114b47eb94fd3c49c4054/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5075b9da3ef807eda752502446dfecea3b381c4900b7e27a5d5f4f899eb39951", size = 1248961, upload-time = "2026-08-07T11:27:45.781Z" }, + { url = "https://files.pythonhosted.org/packages/eb/13/3651d3812548a2bda15e26e5dd51aadb48cf682d0865370255fcf0e367dd/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:293cc1c5bfa741f8e3fbe8175b9c07beee487c9a6fdbb25a5acad9f1df2d30a9", size = 1243877, upload-time = "2026-08-07T11:27:47.325Z" }, + { url = "https://files.pythonhosted.org/packages/21/a0/521f0bf000f675e9312a4aae2c8ba7a992405d072a85c485e08fd59433b9/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0910c3442a75216dde0f102d854ba2aaa71d2482e0ee213630b9bf29584fba3", size = 1293903, upload-time = "2026-08-07T11:27:49.264Z" }, + { url = "https://files.pythonhosted.org/packages/b1/7e/402fc902568aa2ee65865a3e151f000db0153da8ce6b1be4c9c349025f8d/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:43dd6d596879bb1cb8a12cc9dae7bb10090a39a35883026c24f82488a195619a", size = 1401070, upload-time = "2026-08-07T11:27:50.947Z" }, + { url = "https://files.pythonhosted.org/packages/ff/7c/97d4b66c057f1706fc8be6dd532cc77c988794357c8f4ffdb6adabb39562/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8c9d537f59e936392cfd3597789d1390304dd659efc3c486ce7f40fb6b8a9f53", size = 1502602, upload-time = "2026-08-07T11:27:52.364Z" }, + { url = "https://files.pythonhosted.org/packages/89/6f/72cc3b71562001bba46e898ccfbf1844f7939b3e28912736206102f2e5a8/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:f0190a33d7f97c65e9069f7a7f40499eea6b5cbe260c558378109caf20ce934b", size = 1495848, upload-time = "2026-08-07T11:27:53.803Z" }, + { url = "https://files.pythonhosted.org/packages/a0/53/d6f629d1e49308b2f363dae028baa213ec222c9106fa1f7f0d1f7b41499a/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:77308ae6c5cf5264cc0f01a7c556ec77a9e68eb1f61b093534d698139fdc3b14", size = 1556556, upload-time = "2026-08-07T11:27:55.342Z" }, + { url = "https://files.pythonhosted.org/packages/ee/22/340f35dd8dfc6d412d53dc20699ca014b8d228db923e8ed4759c512b162c/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8d53a23f27e1ed3a36b2d26fd2a1a6228c8e85a1ed62ff7cdb44bd610769f20a", size = 1417822, upload-time = "2026-08-07T11:27:56.712Z" }, + { url = "https://files.pythonhosted.org/packages/11/29/6dde5c13fbebc051d3a6df4ec0a6fd1d5359333cc1193f7f609f3410b4d8/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ffa5e7cb08f96fed9121f77b224151e41caf88feab9d652bb46c78202b6fbeda", size = 1445153, upload-time = "2026-08-07T11:27:58.275Z" }, + { url = "https://files.pythonhosted.org/packages/62/c5/f473a8ed030f7a0ca24b9849cca184677a50c053867a7b808c2e1289bbd3/ast_serialize-0.8.0-cp314-cp314t-win32.whl", hash = "sha256:fa70ed4dea0bb18b30a1789c77baa701d0ef30c474f2ccabdea61e25623a8827", size = 1063711, upload-time = "2026-08-07T11:27:59.793Z" }, + { url = "https://files.pythonhosted.org/packages/23/63/39e171fcd38ca057c2e1979d5ee81ac7a3502784abe3d83df7454f7a0978/ast_serialize-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d8b3c8eee4c1baef9d4e84d2a59a805501617127be42615cb48970b15b0892b6", size = 1103740, upload-time = "2026-08-07T11:28:01.405Z" }, + { url = "https://files.pythonhosted.org/packages/21/1c/d00762b399e7726d68d0a088cc946e3a4c60f1c6176f557608f672f627f3/ast_serialize-0.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:ac4f0a83c55a9b782f79ad55a5247b7db123c1db405959791c2ef886e9710c9f", size = 1076021, upload-time = "2026-08-07T11:28:02.947Z" }, + { url = "https://files.pythonhosted.org/packages/4c/11/911210c3c78923273a9211a2b6cfc4c8aa723b30dab3e1c8d19afb983b40/ast_serialize-0.8.0-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:86b8a1e6d90467345356098b040150e82fbc26d24a7a202224b13dc1f6264ca0", size = 1177715, upload-time = "2026-08-07T11:28:04.654Z" }, + { url = "https://files.pythonhosted.org/packages/77/89/6282881c8587606638db153cbe21e1e0c4d1f3970dee1aa0610a1c62a026/ast_serialize-0.8.0-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:39e92ff8e8cb45947fe9007174b2950e1fb098e6abd00266a13cd3bcf6675068", size = 1169347, upload-time = "2026-08-07T11:28:06.1Z" }, + { url = "https://files.pythonhosted.org/packages/97/78/a9f846a03a340ff3728c915f23338ca742742f3292700559cdb3ad999b1e/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c85d8d18db5b2dfcb3b7e38a4d600ca35504c0ed8a6f75cd1c811e4ffe248a15", size = 1225916, upload-time = "2026-08-07T11:28:07.654Z" }, + { url = "https://files.pythonhosted.org/packages/c0/15/aba6ef8a988a6eceb6f0359589aac509e29ae2dba67fd9bfd5af0c3f13e7/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9830ff7e764f74d9eefb01170c61a9f0fd2c027dac5fcb72e064decd57d56371", size = 1227135, upload-time = "2026-08-07T11:28:09.504Z" }, + { url = "https://files.pythonhosted.org/packages/94/29/3f63d696ea7c5b8abadcecc3505be51bd900daaccc522ed8322fa5b05a93/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6479d9722a4cd21b578f5478074c41e6169f04811996ec881655560f703a5bba", size = 1425040, upload-time = "2026-08-07T11:28:11.044Z" }, + { url = "https://files.pythonhosted.org/packages/e2/5d/0aac338604ff59df5774d4304307898982252f325ff7cafe31d52fedcb65/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a63bed264e818cd83eec11feed0f50aa162542b91132ef58afebc857182763a5", size = 1246278, upload-time = "2026-08-07T11:28:12.519Z" }, + { url = "https://files.pythonhosted.org/packages/23/ca/9f1ef795bb724719532bd86dbec11e5b66857d3fbe9b6772baec0191a6ed/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d187197d234aa45d6cfa2b096be5f666e8cc2e7eb3722d0ab8926293cf5720c", size = 1250029, upload-time = "2026-08-07T11:28:13.896Z" }, + { url = "https://files.pythonhosted.org/packages/dc/25/5e061372d2ed953b9ba3b9c4f73de3b8e9234cda3f6c088db4686801d0e1/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:2d39a56282cfcc0d8eeea37267c754be59c98d48505c23b1dae5c6011f3813dd", size = 1243575, upload-time = "2026-08-07T11:28:15.37Z" }, + { url = "https://files.pythonhosted.org/packages/a8/c1/ae7da218053120635a4ca802366c69f707203641af95372eeb83f70dfd52/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f7cc5f10386994c0f4844f1e6d6a97127e9b478660eb6dec2b257644f0acab64", size = 1294396, upload-time = "2026-08-07T11:28:16.813Z" }, + { url = "https://files.pythonhosted.org/packages/2e/89/271d1f49c5269fcddcc789ea3f25be401f6723fc1138aeda539f4d05516d/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:6102f2f985c2e542be85cd857678ec9356fefa792b93cadfadd31139f5696f27", size = 1401987, upload-time = "2026-08-07T11:28:18.333Z" }, + { url = "https://files.pythonhosted.org/packages/55/be/4e7d77fcf571ac7cb5cf7115a20c36642bd7d29473b45dfaaefeb9618f90/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:3a8660fe66667b76a6e9dccd1d33e66b229fde3b308db991c041609226c005b6", size = 1502904, upload-time = "2026-08-07T11:28:20.039Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ae/ed1de2db7e019d4236fbc164ffa5ef9a6022a300a342bbf142d21b7c141e/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:e7266307e5fba39836edb79def8608887af48820508bff3c5f2941e1e04d1534", size = 1496967, upload-time = "2026-08-07T11:28:21.734Z" }, + { url = "https://files.pythonhosted.org/packages/92/89/5fea507fae5c5f18b7dc7f95e5c00956574b8c717b8fd2049c504fab0b18/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:4ca7e6fd1ad845d1cc649dc2ecd499db2f8f46af5bf8da7b70dd858774cc038b", size = 1559041, upload-time = "2026-08-07T11:28:23.194Z" }, + { url = "https://files.pythonhosted.org/packages/42/71/478d69df21b64e064554a68134c94be304270316ca676a94e63c389a636a/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:2880350b13d3eae69a0d70bc1fb6c9bfaca4dbd0e20ba8cd1aa483080b56ff06", size = 1417367, upload-time = "2026-08-07T11:28:24.601Z" }, + { url = "https://files.pythonhosted.org/packages/5e/2d/8962dc8d5b3a9dc27b36f9db199afa25264c741505469d9ec10ffbfd2ba7/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:ab0f9a59f7d63d0d441b56b9a818b273705264352d5115cfee12e940e816d958", size = 1446178, upload-time = "2026-08-07T11:28:26.152Z" }, + { url = "https://files.pythonhosted.org/packages/4f/22/14d2ad4fd1d1bcd0dc687ca268e0630069f45162496260c0efb70ee0ea72/ast_serialize-0.8.0-cp315-abi3.abi3t-win32.whl", hash = "sha256:0485a25ef519c62e749ee3c1ad8070e591b380d67226349eb5a70b228dc1ac4a", size = 1063811, upload-time = "2026-08-07T11:28:27.864Z" }, + { url = "https://files.pythonhosted.org/packages/18/1d/84a327c0202a41aa5fdba3ade33904d6d8f3b9e6806fa83568d835395850/ast_serialize-0.8.0-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:bd84d60bca7079e741be4ac5dbe237751a59d7f6f9f0126b11880d63822cbe16", size = 1105518, upload-time = "2026-08-07T11:28:29.691Z" }, + { url = "https://files.pythonhosted.org/packages/8c/92/74556dec52fde85a2ad84ed159991b916241043788609c15d8b77e14570b/ast_serialize-0.8.0-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:057769b5921336eb2d9124f2a731b42ed05ffdac559b840dbdf6f3937cf153dc", size = 1076319, upload-time = "2026-08-07T11:28:31.282Z" }, + { url = "https://files.pythonhosted.org/packages/d1/5d/c650b1f2cc1e75193358da95a080261422e8cd10b66d7370b1688c9915c5/ast_serialize-0.8.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:a02cbed7d8bfdcdee88edaac12bd50d53d9953aaa2e1852ef078625be5f1c0b5", size = 852914, upload-time = "2026-08-07T11:28:32.929Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e3/6142e920fec6ef7bccabd8c24ed8ed99f8bdc6cb8b065e1df7c6a3b2d667/ast_serialize-0.8.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:e1bd223df0f6c96b396975fa604cb33bce53d9b4a0185490be4c4a289f7c9c87", size = 1184007, upload-time = "2026-08-07T11:28:34.654Z" }, + { url = "https://files.pythonhosted.org/packages/a6/e9/6e8be8df02b35d85e2b8809f7f1cfa290bdf5882b55127a539d049482db0/ast_serialize-0.8.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ddd3b61f45c132da66c5476b281891e08c1fd87fbdabe8a6973e1622efc85f06", size = 1177588, upload-time = "2026-08-07T11:28:36.318Z" }, + { url = "https://files.pythonhosted.org/packages/8c/80/7e0fd2e2e2aba257820db4a8657c4c356844d36b914b20a4af294bcfb902/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f9caa63fad8241257ae401b5ff0a64026c6adb36b8e86cbe8782d9ea505daf6", size = 1234575, upload-time = "2026-08-07T11:28:37.772Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6a/3bae0af06f9b1bae3001c44d64215f5b567877e7aae9ffd45db11c3a7647/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3926fa117b5e65019853a2969966d11c7175af377a3425991f3fe73784412405", size = 1236015, upload-time = "2026-08-07T11:28:39.14Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c4/ce2d41a1bc22508e82618901f7e10f2a5e2f9556553fea90624daf9875e2/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:485f1113af805e9e170b95ef993ca3fbd4f89c04bab25c58b4fc632d854801ab", size = 1432808, upload-time = "2026-08-07T11:28:40.664Z" }, + { url = "https://files.pythonhosted.org/packages/1a/90/f5058f209756dd70e958b7538aaa82d25d24944baf9ec8ae6f27b06fcacc/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3ccebbed24f1281062d5852353c72c47502955926cfcb8345ffb3a44d87ff3d3", size = 1256251, upload-time = "2026-08-07T11:28:42.223Z" }, + { url = "https://files.pythonhosted.org/packages/bf/32/7f77ea87fa0836daab706ed5cb7f903bb25fa26a77439011aee626af11d8/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:252f883290d1cdb728eb7fe1d9a7221b88af5a329aae0bc91ddee4dafb820331", size = 1258574, upload-time = "2026-08-07T11:28:43.751Z" }, + { url = "https://files.pythonhosted.org/packages/eb/5a/75b82ad2725b5e8e8c742732f9e76c6738a292d0709e1f60d10a973730b4/ast_serialize-0.8.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:96abc072ad29db8d02194afd47d68987322622787daceae82398d7b69f3ba2e6", size = 1254075, upload-time = "2026-08-07T11:28:45.28Z" }, + { url = "https://files.pythonhosted.org/packages/4e/54/8c20ed4eea805516a3fd23dd4a721ce28c64f50f0e4b359969f60a8c97a6/ast_serialize-0.8.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9118ad3e369727060b2696fc4078f250ecffca4248ba87f537f55cea9f9dce06", size = 1301018, upload-time = "2026-08-07T11:28:46.851Z" }, + { url = "https://files.pythonhosted.org/packages/cb/5b/9f14430f12fe830b656fb38f8e2e05ee13b02a88967660bef46af0ab22a8/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f359df4bd921918af8bebd142a376c77511d7151cc8ba852760b587b5a4a54f3", size = 1409951, upload-time = "2026-08-07T11:28:48.312Z" }, + { url = "https://files.pythonhosted.org/packages/2d/3d/084882eca93c842bd4262591a071ec7f825340644035e51501208cc5a8d4/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:e94f9121d13fa36cbf21314783c77d05ae3a0868decd18cf5233fdcc6de49ac8", size = 1509544, upload-time = "2026-08-07T11:28:49.847Z" }, + { url = "https://files.pythonhosted.org/packages/ce/73/ea84852096c2036c61cc0b2f97b90242207419f534dc671060ee1c8e05cb/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:54f95b486018d262bcb387a9afd96f0da74508b442762b80c769454a6fbb3ee3", size = 1505671, upload-time = "2026-08-07T11:28:51.239Z" }, + { url = "https://files.pythonhosted.org/packages/cb/88/287b9a5300c1f2f651d259f670931b63110adc265b7613c885b44c5bc53d/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c38b915511e32bc718c49dbce98ff9af36bac0ad6a604f58000cd5e3aecdba7", size = 1563685, upload-time = "2026-08-07T11:28:53.112Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f3/1bc3a79afcf0c2a8d2c37182d0d659d1545a9d7f7f6dc9cf3e63d6c17135/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:9a2ef9cf12f2de4f1028c42c1dd7d775255e0fb3e5bb48896c97e35ef52366fe", size = 1427977, upload-time = "2026-08-07T11:28:54.418Z" }, + { url = "https://files.pythonhosted.org/packages/5c/cd/440c798957e14e31776bfeb024d8fafe0bb1d5b89c51c2f067e69938f7b0/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6f18048fe9f6dd266bd577cdec48bdcecb74faaa01fe941324435483b013ed2a", size = 1454335, upload-time = "2026-08-07T11:28:55.968Z" }, + { url = "https://files.pythonhosted.org/packages/4f/4a/587eb36dcc240a54c8660f599464516b469ecad96f0dbdb6bccbedb50745/ast_serialize-0.8.0-cp39-abi3-win32.whl", hash = "sha256:31883542dd6c94d178f5db3d32fbd69c5eb88b3a7c018e7ac8cc0c45195ddbed", size = 1068858, upload-time = "2026-08-07T11:28:57.541Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a4/3e887bbd92164e183cb6e412c6a3e9198ddd446d7fe405958293ef5ef49c/ast_serialize-0.8.0-cp39-abi3-win_amd64.whl", hash = "sha256:861794565b06337005c1447ef23103a3d5a627d08bdc827870d00d0b28ef5f51", size = 1111839, upload-time = "2026-08-07T11:28:59Z" }, + { url = "https://files.pythonhosted.org/packages/25/6c/b400476d3ceba681ab929787edc9554f6d88fcc69435eb681b00fc0457a5/ast_serialize-0.8.0-cp39-abi3-win_arm64.whl", hash = "sha256:b2a5978662fd4db463dfb4b974d2b10ac6430b98f5333aabc7051909df3561d0", size = 1083655, upload-time = "2026-08-07T11:29:00.349Z" }, +] + +[[package]] +name = "boolean-py" +version = "5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/cf/85379f13b76f3a69bca86b60237978af17d6aa0bc5998978c3b8cf05abb2/boolean_py-5.0.tar.gz", hash = "sha256:60cbc4bad079753721d32649545505362c754e121570ada4658b852a3a318d95", size = 37047, upload-time = "2025-04-03T10:39:49.734Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/ca/78d423b324b8d77900030fa59c4aa9054261ef0925631cd2501dd015b7b7/boolean_py-5.0-py3-none-any.whl", hash = "sha256:ef28a70bd43115208441b53a045d1549e2f0ec6e3d08a9d142cbc41c1938e8d9", size = 26577, upload-time = "2025-04-03T10:39:48.449Z" }, +] + +[[package]] +name = "cachecontrol" +version = "0.14.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "msgpack" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2d/f6/c972b32d80760fb79d6b9eeb0b3010a46b89c0b23cf6329417ff7886cd22/cachecontrol-0.14.4.tar.gz", hash = "sha256:e6220afafa4c22a47dd0badb319f84475d79108100d04e26e8542ef7d3ab05a1", size = 16150, upload-time = "2025-11-14T04:32:13.138Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/79/c45f2d53efe6ada1110cf6f9fca095e4ff47a0454444aefdde6ac4789179/cachecontrol-0.14.4-py3-none-any.whl", hash = "sha256:b7ac014ff72ee199b5f8af1de29d60239954f223e948196fa3d84adaffc71d2b", size = 22247, upload-time = "2025-11-14T04:32:11.733Z" }, +] + +[package.optional-dependencies] +filecache = [ + { name = "filelock" }, +] + +[[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 = "charset-normalizer" +version = "3.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/3f/143b048436775b0f76ac3eec145c019e8173ccc2885c8f20319b996d5e83/charset_normalizer-3.5.1.tar.gz", hash = "sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3", size = 171764, upload-time = "2026-08-15T08:20:44.807Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/27/78873dc8b6a56357517b74b6bb9568b80450e7bb4f6ef7e3fa9d22aa0bd7/charset_normalizer-3.5.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:5b6d1386bf0096d26d3a863dc0a487a5b4eb9aa93cf5ba69683d29dde6b9d60f", size = 344456, upload-time = "2026-08-15T08:17:10.072Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4c/be49ada26b1f0232d57aa89bbebf997a5cc2332a5616b6eca26ff680044d/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4582c27e8c889d64811987b5967fbd3ae0c823fe1fd933b543d55ac20bb475fa", size = 238530, upload-time = "2026-08-15T08:17:11.563Z" }, + { url = "https://files.pythonhosted.org/packages/76/84/6f1290fa07ae6978d3960caa3eb1b8019bf9284ab7c2297b00c099ef4250/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1d1c7a53a6c2103925cdd6d7229f8c567379f211c869793df679f2e9f738c369", size = 230200, upload-time = "2026-08-15T08:17:12.919Z" }, + { url = "https://files.pythonhosted.org/packages/e7/a0/47b18adeed31c8f16ba9700f32c1b18594cfa09f47eb672a488c273c22bf/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e6621fb2a4988d6e53eedc455e5903e2679f3967b8acb3d639f1b63c14a2e893", size = 262222, upload-time = "2026-08-15T08:17:14.571Z" }, + { url = "https://files.pythonhosted.org/packages/38/fe/341861ac118dae06f3ec0eb487488af52128f2ef2faf0b11003944d22259/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7c0c10730342b0c9b35dd1d619beb8214e520bd96a1f870f452680b238aab3e0", size = 258951, upload-time = "2026-08-15T08:17:16.158Z" }, + { url = "https://files.pythonhosted.org/packages/6f/89/bb5108dc6c3651dca963f2b0a3ba19bbcb370c94e1b6d3e0e844a58e6dca/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9af956078716df40d985fb0dfeb2c2120c5ca92ba4ff4b388acfd01cdc14d08", size = 248801, upload-time = "2026-08-15T08:17:17.683Z" }, + { url = "https://files.pythonhosted.org/packages/b1/ba/ef83ae3aca816393decfa3530976f38a79812d707b80b580ac33b83f9877/charset_normalizer-3.5.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9f8405c2c758532c74fed975dbee57be1f31a6e865c031870c79a6ed3212ada", size = 244070, upload-time = "2026-08-15T08:17:19.191Z" }, + { url = "https://files.pythonhosted.org/packages/f6/0b/c5292a2462d69b7378ea89793bbb5b2b6fcf6f7dd6d1667f9619094ad553/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:96fef3e886d6a9874b14f27fc193fbdc69d5d8035783d86aa4e1cea594e695f9", size = 240110, upload-time = "2026-08-15T08:17:20.547Z" }, + { url = "https://files.pythonhosted.org/packages/46/22/111e5be3b740d5c2a5bfcedb3d237b6591e5c2e82ae9d6ffcb121fe0909c/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5d8531a6569d025f68e2321e7638fb7978f23db58e5f69f56913837aae03816e", size = 232836, upload-time = "2026-08-15T08:17:21.895Z" }, + { url = "https://files.pythonhosted.org/packages/f9/d2/d2aad6fe0dbb44b194bf3becb60f5a0ac48446ade999a47fe7bb41eb09a7/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:aae2ee51122d3ae968a3837d97dc24a0aeebb0dea23694422cd172bd30017cd6", size = 262712, upload-time = "2026-08-15T08:17:23.727Z" }, + { url = "https://files.pythonhosted.org/packages/35/5a/337e4663a5eae6de99db940ee8066d4145caafb61327db62deda15313cce/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7235dc28fc6dd9d832ac7c7bce95367dedb85929f17368a0c2bee1e080b9acbf", size = 242977, upload-time = "2026-08-15T08:17:25.157Z" }, + { url = "https://files.pythonhosted.org/packages/ca/85/f82f8a92e31c7519410e2e1afdc630f28ec47490ce2c09a11c1a43cbb459/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4abdc5f9ad448c1ecbfae2974b820535d6bc6e7eef63babbab3d81cf46968c71", size = 260207, upload-time = "2026-08-15T08:17:26.602Z" }, + { url = "https://files.pythonhosted.org/packages/b7/52/643d11ffd60e9ac2fd1fb87e167a19285b9eefeff4a40e63c87cbfbeab36/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ba501e667c17d8411f98e67a022d9604ef179aff0e459b7e292c796837c13573", size = 250562, upload-time = "2026-08-15T08:17:27.971Z" }, + { url = "https://files.pythonhosted.org/packages/62/16/46556278c2168d12df9da7fede5dc6fc70e60301b26a82bbeec238c9cfe3/charset_normalizer-3.5.1-cp312-cp312-win32.whl", hash = "sha256:cfa1c0cc3a8f9f53f1243a5a99ac36fd003880199383b37672e86ddda9cb07e2", size = 178507, upload-time = "2026-08-15T08:17:29.277Z" }, + { url = "https://files.pythonhosted.org/packages/9d/7a/4c6c298171e6b3e745633180ff59350fc0ca0db1ffd28df1e369e0579f71/charset_normalizer-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:3617ac3cfd8b9888f145ad89dd6e692285834b0201c6074a5eeaad3fd4d668c2", size = 200551, upload-time = "2026-08-15T08:17:30.668Z" }, + { url = "https://files.pythonhosted.org/packages/cd/d7/eb95a042f0dd22e304b0b6472b154f3546a1a039a9ee89ccb2a7f61591fc/charset_normalizer-3.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:88e85ab89cb822c1e635f51d6d32e488f94e002e70e2f492bdb8b945543f345a", size = 180700, upload-time = "2026-08-15T08:17:32.028Z" }, + { url = "https://files.pythonhosted.org/packages/bc/61/2cb6ad133dbbb449fa2d37ccae973232f4827e799af258d15e589a3d1e9e/charset_normalizer-3.5.1-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:4f298bdadb8f0b9e5672877f647d1be9373ef5320c9e2f049795e26cad28b6a9", size = 211584, upload-time = "2026-08-15T08:17:33.597Z" }, + { url = "https://files.pythonhosted.org/packages/18/57/a305c968be1ca13f3dd1b32f445877e97addf55d80b65c7cb35fac82b777/charset_normalizer-3.5.1-cp313-cp313-android_24_x86_64.whl", hash = "sha256:88ca277405c2d3b71c4e1c2ee0e7966e807bcba86a69d11e19ba199d18ae4491", size = 223359, upload-time = "2026-08-15T08:17:35.022Z" }, + { url = "https://files.pythonhosted.org/packages/09/0a/d3646670292ce8d8f8cc11ac067d44885e697a5591f57a9221128da5e7b3/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9362dd90aa7dab48c0054a21187791ccf05473f7dba5d92b8033ae62164675e7", size = 194464, upload-time = "2026-08-15T08:17:36.452Z" }, + { url = "https://files.pythonhosted.org/packages/de/93/d51ec556e01042fed6f993ea859311bc7917b466684182fbbceb6ca24762/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:977cdbd483a9cff38179bea4fd754289a6f2195c7abd414aba85410b3e66cc5e", size = 197676, upload-time = "2026-08-15T08:17:37.819Z" }, + { url = "https://files.pythonhosted.org/packages/a4/a0/562247944386f7d4ef94467e84876600cc1e0f1b93239aaa9213d2bc3cbd/charset_normalizer-3.5.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e90251c0c7bdd54a100a0dce3c07b7e637278c93af29dbf78ebb89a58c4bac7d", size = 340473, upload-time = "2026-08-15T08:17:39.303Z" }, + { url = "https://files.pythonhosted.org/packages/31/e7/1d994be1b93d41e9502b8b0460eaa88a1dd8df335df415db87d6c3e91ab2/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94d78ecec2605a8d0398b0f365d5f12a63248438516f5dac536a5eff7337df4a", size = 240156, upload-time = "2026-08-15T08:17:40.66Z" }, + { url = "https://files.pythonhosted.org/packages/09/53/27923ce5cc6cbccb832037b27dca98882d9c53e9b69e866bbbef4aae7fc8/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d59b75732e9b6f27388e10c14b0259cc5f2e48c78627d185e6a177b58ad3cffe", size = 228246, upload-time = "2026-08-15T08:17:42.003Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/5a97e84d63af1d55c07439cb80e56d99a8efb4295700eb4e18c0d1615d2c/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0d929fc574b4d6fd9e7c0f5c2ede8716a41911923aa7fa5fce38e0818aa4a1ac", size = 263660, upload-time = "2026-08-15T08:17:43.627Z" }, + { url = "https://files.pythonhosted.org/packages/7a/c2/071575791dcc88316c0a9a65ce38897a82e4cfe4a325f0f7fe1b1ac47bcf/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:394fea06235c8543390050ed5f529187074b029fb027213f6c46ac11ab5d950e", size = 260354, upload-time = "2026-08-15T08:17:45.094Z" }, + { url = "https://files.pythonhosted.org/packages/fb/af/63240b0c0248c075c2535a1f1bd992821d8251b9f173abc13329661d09e4/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62b55f6722735a6c472f88361cde6640608773d9443cebdbb51abf436a1fcdd3", size = 250638, upload-time = "2026-08-15T08:17:46.496Z" }, + { url = "https://files.pythonhosted.org/packages/4d/66/70dfad64f15be09c15ccfee81330a7e515895dbe296dd23114e9a231268a/charset_normalizer-3.5.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa48b1b63d639f9483e0633e092f5851e2348c352f1f9bb6c8182f87884ef876", size = 244583, upload-time = "2026-08-15T08:17:47.963Z" }, + { url = "https://files.pythonhosted.org/packages/c0/24/ef36367d38b9ddd4bccbf72888c342e8de1f5ae506fa0b2dcf970e2732a1/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c71fb0d56c920c269cd3e2e3fe7c610e3f1fdb21a6ce60efa6430ff63676cea6", size = 242038, upload-time = "2026-08-15T08:17:49.481Z" }, + { url = "https://files.pythonhosted.org/packages/db/ab/55e683ba0fff2e43adafc10daa3001eac90fdaa419a97227d5a7067eedde/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:485a0d363cafefcd2538a73c7c838daa2035f09b2c9f9b5e3133f80c6aeb84c2", size = 233677, upload-time = "2026-08-15T08:17:50.845Z" }, + { url = "https://files.pythonhosted.org/packages/bd/67/0f40eaf8d1b6e7cf15e82382a2965efaca787fc1c2794b7021d37aaf5036/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0ea61a470e070686aa30892fed79e297d2c8d0ab46b8bcdf027d38c51da591", size = 264491, upload-time = "2026-08-15T08:17:52.61Z" }, + { url = "https://files.pythonhosted.org/packages/5c/64/12b4c2a11ee8df4fcc518c78b0d93e3a92bd3d5253d1617ce74ff0e8c7ef/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:90b7481fb62fbe172c558bc6fd1c4c98d82004a54a7551f20e11ac9bf0b8708c", size = 245196, upload-time = "2026-08-15T08:17:54.023Z" }, + { url = "https://files.pythonhosted.org/packages/37/2e/651d910af6d0fba325eee1cda37ec5443462ed25360e666c144166eb6091/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:35fe081843b35aad20ffeccec3eeffbe637b15d14f3fb22cc1b59cd8ec17e93c", size = 261660, upload-time = "2026-08-15T08:17:55.491Z" }, + { url = "https://files.pythonhosted.org/packages/90/c6/b09e05e6db7f64338e0dc067c79577b1138da86c1e38369096851d96be88/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fd0350afdc3aabd5576f60ea109228bd5538139713c7b094c5cd27c73a98bc6f", size = 252618, upload-time = "2026-08-15T08:17:57.025Z" }, + { url = "https://files.pythonhosted.org/packages/76/4e/362d4f9fdcdf5556fb2aa3ce7d4a58ebce03ed1ff03aa1d9aca8d02f13f3/charset_normalizer-3.5.1-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:9d9a0dc7cbe9bec24c3f767c9122c41fe5a1bc43f47cd099d00d393e09769de4", size = 140362, upload-time = "2026-08-15T08:17:58.425Z" }, + { url = "https://files.pythonhosted.org/packages/b4/d4/703be739b26acce318bd29eb3b25b7209e1b1f527f9eae3d1f1f01fdde2b/charset_normalizer-3.5.1-cp313-cp313-win32.whl", hash = "sha256:d63600d620ad0064c3a748b950ac5ea38a80190e5498532efefa4b7b3f1da1f3", size = 177755, upload-time = "2026-08-15T08:18:00.037Z" }, + { url = "https://files.pythonhosted.org/packages/8a/33/56d97ade41c8db611e727168c52ae46c9224c362ec28d4b65d7e9869e8da/charset_normalizer-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:aea996a6aba25260827c9ea511d1addfde2da9eb686ac961838509086188b7e6", size = 199295, upload-time = "2026-08-15T08:18:01.506Z" }, + { url = "https://files.pythonhosted.org/packages/5b/75/5b20dd1e6573a01a08158fe104104fa2c8abf941745596954185726cd46c/charset_normalizer-3.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:fd0a274c0e5f9a21565cd9d3dd749b61f96b7aa1e20a93aa1ba4029518f2e5c0", size = 179856, upload-time = "2026-08-15T08:18:02.929Z" }, + { url = "https://files.pythonhosted.org/packages/29/cd/2b812ce5e888f1ce69a5350281e58aab07ae64a958ecae8912f30865718e/charset_normalizer-3.5.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:774d157f112367ff4abd29019f38f023c24e00e56edc7829c20e358a5a913ad8", size = 212318, upload-time = "2026-08-15T08:18:04.403Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4a/a6ee107430768a5334e6d63f31f148a04a1a491ef161a1ac9415a73f2fa8/charset_normalizer-3.5.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:26422d45fd13551cf564c58932f7d72b4f58b93b0fcf18c35ba6be12b46bb102", size = 224897, upload-time = "2026-08-15T08:18:05.997Z" }, + { url = "https://files.pythonhosted.org/packages/c3/d9/35ae3f64f29d0179c35c3baefe575904df2913dde519129c7f75995a2b1d/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:09a7bba9f739468c8e78c36a75c33768e53cb1959fc638f510454c14683f00d5", size = 194848, upload-time = "2026-08-15T08:18:07.397Z" }, + { url = "https://files.pythonhosted.org/packages/74/76/f2fc7380f056cc273a53af37f50d08ad54b2c59f61078f31432edcf1c2bd/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c9548dc78002099910abaebc0a72ac58b7d30931869e0351c09b507dff4ece3", size = 198163, upload-time = "2026-08-15T08:18:08.989Z" }, + { url = "https://files.pythonhosted.org/packages/e9/40/095ce62fa078483cccc1fa2b36e6bc9580b85422a20ee9f925341c50e44f/charset_normalizer-3.5.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c428c6c31eb5f4277d7f8eccaf767fbd548ddd5ce3c8b4f4cbbfab3d96b5904c", size = 341823, upload-time = "2026-08-15T08:18:10.458Z" }, + { url = "https://files.pythonhosted.org/packages/f1/5a/0e58b1c04a1596e0256f407274a92d5fb2ee21324409d1fab1da48a65b5b/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f06b7eae9dbe77fe1d644ca244dad508de8d302870a43f3c559b521270938a0", size = 242458, upload-time = "2026-08-15T08:18:11.989Z" }, + { url = "https://files.pythonhosted.org/packages/22/95/b4618ce912e6db0b1aae89ba788e38e8a7eba0f3025cc66e8c0699f977b2/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b7430cf5728e68f6c462254009a6ef4086e1bea43cf2f57aa9c55fb4f50ff96", size = 226717, upload-time = "2026-08-15T08:18:13.401Z" }, + { url = "https://files.pythonhosted.org/packages/8a/76/c681192bbda3d55356db5dadd64381d5202b37c6b598fcda5282e88b5d3d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab743e9bc90c1f73552ec33e10e3331315acd2c397b36065b591b0181de533cc", size = 266111, upload-time = "2026-08-15T08:18:14.961Z" }, + { url = "https://files.pythonhosted.org/packages/88/be/55127bfca72c0cff6c022488d140d7c5b04c771e3b72e9bdb4836d54979d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6f7deae3feb4edfa2efaf7c574fe88cbf055038a6abdb40188e4fff66d5699f", size = 263128, upload-time = "2026-08-15T08:18:16.515Z" }, + { url = "https://files.pythonhosted.org/packages/e0/91/39c3af510b0aa32bbda03374259200f28430febfd1bf5e511fe765282ce5/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15f024313246a4ed976c60f440bb8d257815513a681d212ff74fd46f7d715a90", size = 251240, upload-time = "2026-08-15T08:18:18.127Z" }, + { url = "https://files.pythonhosted.org/packages/1c/a5/cbe418bbc6ecdfc3e05a0116002897c4b403a5e838d697e64c78e9f0190d/charset_normalizer-3.5.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:823f82903d189af463d7df250ef1f7f696f3cee08cc8d91deb565e8d425f6506", size = 245282, upload-time = "2026-08-15T08:18:19.625Z" }, + { url = "https://files.pythonhosted.org/packages/cc/a4/689bb42e8e7cd492f3cb64907c6bc00ad247ec9a3628cd3f8eed126e8ae1/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:01e93745f7f219b703b60ba7afead36cfc4242782be5af484673fc500df12da5", size = 244597, upload-time = "2026-08-15T08:18:21.121Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ce/9962938e179cf9f699d3f1e7b3114b5d7642dee6a893745229f9dd04f274/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:329fc3ccb63ad22d867d84c2adea759a64079a37ba4a343433b02c7a2816871e", size = 231376, upload-time = "2026-08-15T08:18:22.57Z" }, + { url = "https://files.pythonhosted.org/packages/85/54/46000450ada53bd9eac5429a2c8c54cd2d9b39c0c255f229aea9af0948a5/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:bb57753e36e4855b8ca375069482250a6246372331a3e4f3407eaebb007443f5", size = 266715, upload-time = "2026-08-15T08:18:24.235Z" }, + { url = "https://files.pythonhosted.org/packages/3d/bb/618749d70f792b44252a777bf89bfb86823b9bbc1ea13fe8ce759b07f38a/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fce8cbd4997efeb450bd298b54f755dcdff18d496f7a5ddbb4867c6d7c88fdc3", size = 245848, upload-time = "2026-08-15T08:18:25.726Z" }, + { url = "https://files.pythonhosted.org/packages/7e/3f/ffb64458527c7668031d5eb095d978de561958dc9f5b53f8e488a533e603/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6c9cdde8becb25a7fde49924511aa2644d6f8081cc8df8e9452724303348d8e3", size = 264521, upload-time = "2026-08-15T08:18:27.193Z" }, + { url = "https://files.pythonhosted.org/packages/4f/ab/74a55fd803916a35ac461daf002708191aac19b546b80dc8cabfedc63d98/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9ac4444d8d4fd4c4bd08bf451ed3167aa9e7ec6cdb41b648794f1d1103652e36", size = 253054, upload-time = "2026-08-15T08:18:28.568Z" }, + { url = "https://files.pythonhosted.org/packages/a0/2a/6a9034b7d3c60b17499afb482df5878bf9fa20b50cc3887d5ef017a833db/charset_normalizer-3.5.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f03ac127268b43ef4fe9e6ab6794a6794b49485a0cc0c1db79876d2f33f75bc7", size = 140580, upload-time = "2026-08-15T08:18:30.214Z" }, + { url = "https://files.pythonhosted.org/packages/f3/46/1d362e1a00d035d66b9869e1281eee115907f7e390a16a07824ab5737360/charset_normalizer-3.5.1-cp314-cp314-win32.whl", hash = "sha256:1f5883d77fd409a261abb5dc8ccbe335720d798b1de4abb3b1d47ccbbc76b53b", size = 180325, upload-time = "2026-08-15T08:18:31.877Z" }, + { url = "https://files.pythonhosted.org/packages/7a/7c/4938c329b6a9d446f6a59aa2092ff7118f274209b5ed0e26893d1d30a63c/charset_normalizer-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:c658c50ac0c98cd755a2dd50b7977d3bca7df401dcc47fbdfa87db53ef7d4e8b", size = 204175, upload-time = "2026-08-15T08:18:33.466Z" }, + { url = "https://files.pythonhosted.org/packages/ac/33/eeb384dbd8dec570661354592f4f2e1b2fcc92585624d146a000caf53841/charset_normalizer-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:4bea7f8ebe90bbd7f0e4a2de42ca6924ba23e3e76418c408ff82f1d46fabd687", size = 184123, upload-time = "2026-08-15T08:18:34.913Z" }, + { url = "https://files.pythonhosted.org/packages/1c/6c/c73fa9d5a85f6ab05395de61c5f6984e0a9ff40bb5ff888d46dff02526c6/charset_normalizer-3.5.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:fbc597639158fd7c14d55e808718848319540f51b0e6746e3eefa59723a4a348", size = 381682, upload-time = "2026-08-15T08:18:36.349Z" }, + { url = "https://files.pythonhosted.org/packages/30/c7/63565f860921457feba93bae6c86fb7746deb4cffeed2f375cb845318146/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e71c909f353863b2b89c83de2ebed71ea6d0df8a6ef65a128193c5e650766bef", size = 240826, upload-time = "2026-08-15T08:18:37.887Z" }, + { url = "https://files.pythonhosted.org/packages/06/ae/7ae8807410dfa33f8e6f1715740adeaafa8a816cc4cb33508f54b1f7c896/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7ac76cf9afd34929d76eb7fcb63be476a4853d8a96f0dcf2d0db68a0cbdf9885", size = 227861, upload-time = "2026-08-15T08:18:39.315Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a3/887c1642f0da26000b0e0652d91071113c0e72cea33952e225cf589f49a9/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3a370082ce34d0612f421e15fe011c53bb1feff21a26d06ad4fb244dab5a375", size = 260758, upload-time = "2026-08-15T08:18:40.88Z" }, + { url = "https://files.pythonhosted.org/packages/3e/11/e6f5b9a3d0e55b0ef7505cd3765cdd48f22db89994c947b316f52f801fd8/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:256dd4d85d9e4dc595e2bc983c980e73f62ddeb3165c58b4c3dfe78c5c8548c1", size = 259950, upload-time = "2026-08-15T08:18:42.351Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ee/e4e10a94d51cd1ee638aa7e00b65399e6b2a4e8376ab6d2eac9f95586671/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:58d4aa13a59c969dbfdf9e6a9560e242cbfd9e8a8f50c2747714df1a423adf65", size = 249329, upload-time = "2026-08-15T08:18:43.914Z" }, + { url = "https://files.pythonhosted.org/packages/c4/25/d5f4198819e6059735a84e8d0bfb72dc33976da67b97adcd3fb5a5e07ec6/charset_normalizer-3.5.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0c6dfb5ca6723eeed15aa8e564a014d69fcb8812f94eef11fe3631e0508199f5", size = 243137, upload-time = "2026-08-15T08:18:45.368Z" }, + { url = "https://files.pythonhosted.org/packages/a5/e9/e925ca7569cf9fb9701fd82503fee73eea5268fdb856bdd64947092d3daa/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c010f5581d9c612804cc59fcf7b524b707fbcb72828551237ab545bb5c7034af", size = 242820, upload-time = "2026-08-15T08:18:46.842Z" }, + { url = "https://files.pythonhosted.org/packages/34/17/672c251a888ed2aebcdd2fe830ad0104e25ff83c43f5c4f9c15e9fc6853c/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:52ec005752a56ae79547a05c0139ca2501a0c866390b6115008456b9f0e7cde1", size = 230504, upload-time = "2026-08-15T08:18:48.353Z" }, + { url = "https://files.pythonhosted.org/packages/3f/fc/f6a85abebd42ce4da2f1db0aa56cc6a0df1995e318b3875d14401b8381d1/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2bced4061f000f7187254a02ad3433ae17eaf991747ceea2f478422590a5bba9", size = 263087, upload-time = "2026-08-15T08:18:49.859Z" }, + { url = "https://files.pythonhosted.org/packages/98/66/7c42677e739ba66746b297e2046918d793078094dc239e1e72768cffccc6/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9eea3ab2597a5e65fe65296e2d6a84570845a6b55532d90333d740d48bbc850a", size = 243269, upload-time = "2026-08-15T08:18:51.601Z" }, + { url = "https://files.pythonhosted.org/packages/de/d8/a50b79237f417af10f8c2a501ce8d1ca87829a22e69117891ca4ba20a69e/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:496846868fea80e479324862fa877f02411f2fd0f83b79ccee2607aa68b2a032", size = 258766, upload-time = "2026-08-15T08:18:53.23Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1d/0fc91aeaeb3c83b748f532399ce67cf84604b48297405d740000f7a9e786/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:85d5855daafc240cc045c026d7a15fd198a09b0fc8ff6f5ecbb5297b509cb11e", size = 250814, upload-time = "2026-08-15T08:18:54.768Z" }, + { url = "https://files.pythonhosted.org/packages/ae/10/3d8c777cf9024615295aa1b808324ad5b4a77855869c00824bad74ffaf8a/charset_normalizer-3.5.1-cp314-cp314t-win32.whl", hash = "sha256:58d3e12c88e0950bca850ae1f7c256055c097639c2edb9eb123af9807d8b15e4", size = 191074, upload-time = "2026-08-15T08:18:56.305Z" }, + { url = "https://files.pythonhosted.org/packages/4d/81/ae557d3c44d1a1d688696d60563413a0866a91b7ebc50f20df838be3d8c8/charset_normalizer-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:acaf604462bf330b0d07e7a07c1d6e4adac79e5fb13e9c5140590542cafacc00", size = 216476, upload-time = "2026-08-15T08:18:57.889Z" }, + { url = "https://files.pythonhosted.org/packages/27/e9/61c01fb8b804692569c036b3fc50495814502dcf13a60649c6055390b02c/charset_normalizer-3.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:fdb8a068947befafba9952162645dc2fecaeb400e64584829ed5e9b2fbe21a7f", size = 194115, upload-time = "2026-08-15T08:18:59.418Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4e/8544831ef59d8f27ce92c80871380fdacc8076a8a56ed62f82e54f991333/charset_normalizer-3.5.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:9085f87b0e38a2b92b8923059b4e8789fe40d9279712d15dcc670048d77079af", size = 342048, upload-time = "2026-08-15T08:19:01.054Z" }, + { url = "https://files.pythonhosted.org/packages/7f/a6/e3b46852424246065355644f4fb6dbccc0239a42a2eee27ecfc8957f0bcd/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2679de311c7946dde5d3b6f44941844133ff5c7cb86099c0061ab1e8901c20a8", size = 242997, upload-time = "2026-08-15T08:19:02.492Z" }, + { url = "https://files.pythonhosted.org/packages/03/3b/0cc9a26777334ab2f2e3089b948bbf4e4fe72ea70b897715ef6415043ec8/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:baf3775a2635e5a11fbd5e4e64ee69c7e86875d224a5c72aca4c141064589a90", size = 237014, upload-time = "2026-08-15T08:19:03.943Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c2/027335f0aa337a2a2e121bac1ad88c4f02ba6053ea0926802784f3db11af/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ac8c94b6539074e0f40899301273ac8402b9b3e01c7b7ba269ff30340aaaf20", size = 266174, upload-time = "2026-08-15T08:19:05.598Z" }, + { url = "https://files.pythonhosted.org/packages/86/d3/e367787febe4e74769dec0f406f2c3c8d1b955fce5aee1fd0f94e8367a45/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fe532b3c966d1fb794e0698e4589d0444017ae77fc0b31edea13c0e35bcc449", size = 263361, upload-time = "2026-08-15T08:19:07.251Z" }, + { url = "https://files.pythonhosted.org/packages/af/3d/391b193eb9f3e84b02f9314088c386debdc0debee843535aaea2e2c6715d/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5c84bec0ab5ae0c64bfe73a7d2adcb5ce73b467523fc27fd6a28ab2aa6cbe35a", size = 252143, upload-time = "2026-08-15T08:19:08.816Z" }, + { url = "https://files.pythonhosted.org/packages/2e/57/de221f1745a90d418199761967e2776bfe2c275a1194220985e8c1d37833/charset_normalizer-3.5.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:854066be00447fa8de2ccbbe893e2ffc4b123ef16d897af794c1e18bd4a714b0", size = 252086, upload-time = "2026-08-15T08:19:10.255Z" }, + { url = "https://files.pythonhosted.org/packages/c8/e3/d119f86a01f9331e8186175f24873b1d74a7ee9e2e4b4d68f9947dae5afd/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:21b82d8082f6f5e7f456ef0bd16323d08de1266efbfeb476e64b2a91d1471a4e", size = 245231, upload-time = "2026-08-15T08:19:11.807Z" }, + { url = "https://files.pythonhosted.org/packages/26/de/d8e48c135ae480879539cdb179c8d3b50c7879497d75dd899b5763b69cee/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:838648accb3a7fd9803fd45c87bce8509648eb0c11bc34e216141300977244f2", size = 241546, upload-time = "2026-08-15T08:19:13.416Z" }, + { url = "https://files.pythonhosted.org/packages/67/c4/217755fd1abc50d326c252922cd642002758095a81ff45010337b8b3ef65/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:195ce897c6153c0700078142cf8efe3e6454ca4cf4357499e4078dfd83396626", size = 267033, upload-time = "2026-08-15T08:19:14.981Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d7/34d8e404e358d2adcc5a228c2134643af00104c8fb0bf525f3688d756f05/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:978eab16f55b4ab2c2a745be9a0a840bf8f09a7f227d9c76eb30214d078865a5", size = 252045, upload-time = "2026-08-15T08:19:16.618Z" }, + { url = "https://files.pythonhosted.org/packages/5e/fa/40414471acf0aa0692ca77305aa00e434fcd8288f0941c93c30e9a5f8f2f/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:cc0329df4caaceb950d2f580b5ac716a377f7059624a0bafaeaf8a218c6ed774", size = 264866, upload-time = "2026-08-15T08:19:18.101Z" }, + { url = "https://files.pythonhosted.org/packages/32/90/fcc850bae791abd2e0c041847f13e270aa08692a79f3e00de6d2dce1cb50/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:687c9ca3035544b113bea2055e180af96fb63c0c476e22a9180f51925186e7b7", size = 253932, upload-time = "2026-08-15T08:19:19.734Z" }, + { url = "https://files.pythonhosted.org/packages/af/af/53afe99068b3c10b4cbae592a52ef72a7c92c0188440e83ee3a078fd8f75/charset_normalizer-3.5.1-cp315-cp315-win32.whl", hash = "sha256:706bfd38730a5ac7a365793269a00f4e988178cec121391f4248d84ad8c972e9", size = 180320, upload-time = "2026-08-15T08:19:21.37Z" }, + { url = "https://files.pythonhosted.org/packages/c9/bc/f46a132041b29e4a8779ed712d3df1bf112e94ca8de58b66d7ec2c0cf8b9/charset_normalizer-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:92caef967d287a407085d61176fce4012b1dd62daed4eb6d5ceb26d3d2538712", size = 204174, upload-time = "2026-08-15T08:19:23.088Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5d/9ed554480eda8e447b673648628fdc29574d23dbad01fe11837adedd1cae/charset_normalizer-3.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:5fc45d653ea8c9a20479167e11d4a0f8cb2fa3470737ab6f9c827532313187b7", size = 184126, upload-time = "2026-08-15T08:19:24.471Z" }, + { url = "https://files.pythonhosted.org/packages/3b/32/9b8929bf384061ee1fe5d9c27c6f9776d3d824039ad4e14c88ec00c7808e/charset_normalizer-3.5.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:59171c6e45bf07d0d5cab3b0bf81d945035530f6873398b3b531c31184d46663", size = 381441, upload-time = "2026-08-15T08:19:26.038Z" }, + { url = "https://files.pythonhosted.org/packages/96/10/e9aa7923d3ddac652c99a1c5f7be494e737e151566a44abe018daf757f2c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9dbdd9205662134957cf0c324f639bdc5031c0ca056e2369e238db75187c0f11", size = 241742, upload-time = "2026-08-15T08:19:27.532Z" }, + { url = "https://files.pythonhosted.org/packages/28/53/a2d249ebddf47b889a100c0bdcb61a2f9dbb8bc24ef325cc062e4f476877/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e4b018dc5a0eee4676e38fe84a47a427816c590b93b55d9025274ec4d6ffc2dc", size = 235298, upload-time = "2026-08-15T08:19:29.274Z" }, + { url = "https://files.pythonhosted.org/packages/7d/07/469f78af590f7d5cd48e20d8dbfa3d66deeff9ba37768c04d886b5afd45c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced3fdd71aaa83ce593746c2edb42b7a59cb4c19c8b5c407781c72e493aae55a", size = 262500, upload-time = "2026-08-15T08:19:30.955Z" }, + { url = "https://files.pythonhosted.org/packages/55/66/3bb56a47f7dcba014055b1a1d33c6f08bbe9c1e74dba154cfa25f90ae885/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:19a3dd5aa73cef1c99687c4fc57db016a9c17104ae1185da88ba566a5d3bebe4", size = 258888, upload-time = "2026-08-15T08:19:32.458Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c1/2adc2800903fb013210349313b710a5376856578d9e33e6b9a1d8b36714a/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc5d36d96478aa9c60654bd932525bf32964c62a7281eafdf16d85003a8d6004", size = 250243, upload-time = "2026-08-15T08:19:33.94Z" }, + { url = "https://files.pythonhosted.org/packages/95/b5/a18d0dd1157ab655cc2cb14a545f4a4784bbad70ab3502412e36097502d9/charset_normalizer-3.5.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04368edf83514385ffc3e1cfd4546e595f4f1272dd23ba437a93a9cc3741d47b", size = 249871, upload-time = "2026-08-15T08:19:35.413Z" }, + { url = "https://files.pythonhosted.org/packages/ad/c3/525f508cd1e58d0450ac55ed40ac75bc3a97482c59def5278456a5fbf03c/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:9b5db6052055d34d41230fb78d7c439c23dc536a9896f6cb039e8dd92cfc1263", size = 243580, upload-time = "2026-08-15T08:19:36.886Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c1/49a91fe7e97c8140094ca5c64161ab623a70d9f636bf834eace14048acb5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:252d099029bcbea642f2a06c4ed5046bdf8b5a8150b64afa5e027e88b106e5ee", size = 239807, upload-time = "2026-08-15T08:19:38.392Z" }, + { url = "https://files.pythonhosted.org/packages/d3/58/56a48c296601274c4689b864a8e2dfb209b81dfcb39472753ce95eea662b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:6199d5606e2bbf2b096cf64d03f8b6790c91081d5ac866b8e7bb6422738cc60c", size = 264083, upload-time = "2026-08-15T08:19:39.856Z" }, + { url = "https://files.pythonhosted.org/packages/10/4c/dc48409274a1817ff349711d26c62aa0c597df865d4d69ef79160c859193/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:77efcff2b23071c349402ac1066667a3d011f62398d81408c9b88ad991747c9e", size = 250317, upload-time = "2026-08-15T08:19:41.53Z" }, + { url = "https://files.pythonhosted.org/packages/81/58/d325912115caec62d6bdd77bbab5e0b7da5d234a9f20affdffcbcb530d0b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:a5cbd90ecf0fc62e64726917ad083b73001f0563657a87ec3c0b504e277dc90d", size = 258173, upload-time = "2026-08-15T08:19:43.07Z" }, + { url = "https://files.pythonhosted.org/packages/34/f7/b13b1ccae2c8ec63980d13be1890eb73f8aeabbfce02a24aabc0908788f5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:4d26f14f041e83dd8edfd61f4cd4fa7285d31798b5bf1f28e70c367ba6c41d61", size = 251960, upload-time = "2026-08-15T08:19:44.587Z" }, + { url = "https://files.pythonhosted.org/packages/1e/25/ed3f9919c5aef8cc818be1f972f565f7610d7b2076b8ebb98839516ffc3c/charset_normalizer-3.5.1-cp315-cp315t-win32.whl", hash = "sha256:ac13b004224fb341e1e25a1ed5e19d32f57cdb2a403e01f003b46f051a550f6f", size = 191186, upload-time = "2026-08-15T08:19:46.293Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/43c2b3e9d8267092b913eb8b0603f0f71993c395632886bd37a7223f96cf/charset_normalizer-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:35aea775dc2bd5f54cd84a1cd2696cc3207c479cb9cf0bd346f0d343e4300ddb", size = 215947, upload-time = "2026-08-15T08:19:47.853Z" }, + { url = "https://files.pythonhosted.org/packages/a8/76/9aad3e9c8865e5e0efa9a7f6f81c37a67635a985145ecd44528a81e088ee/charset_normalizer-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:fb78f6e7fcd8ad785d28cd577168bc1aaee827b25bb8755638f694794ea98f0a", size = 193909, upload-time = "2026-08-15T08:19:49.383Z" }, + { url = "https://files.pythonhosted.org/packages/5b/97/fb4e82231aba271ffd775a1b4993b0defc4e3059f286ae41d9433409fe85/charset_normalizer-3.5.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:41876ee62a3dddf48ff1121ad8f0798032aa03f2fd35f21f34a4cab14f18d8d2", size = 331467, upload-time = "2026-08-15T08:19:50.959Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2f/fe3f187327aac18e2d54e9d2b08e15d27bf9b642d9e51c219f130fc34d1a/charset_normalizer-3.5.1-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6dac12ff6b846103483683f60c5f8fee205121adc58ffd87e90a90a3af69e99", size = 253057, upload-time = "2026-08-15T08:19:52.654Z" }, + { url = "https://files.pythonhosted.org/packages/d7/c7/9e48cee5c161fe24da823b61bf381921d77cb994a0a4de148e95018c1984/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cee5dd7c6fb5dd52a0fe2a740f9bc6e3593f5f8b1788bde49de02086f30182b2", size = 240930, upload-time = "2026-08-15T08:19:54.163Z" }, + { url = "https://files.pythonhosted.org/packages/49/e0/716601f3cc69be7b198951150c75ead1ece33c3c8036ff6ffa46029659a0/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:343fb4f2821043bd87095f7b08a1a181febc8e36ac64212143bbfd0a0e1bc235", size = 230822, upload-time = "2026-08-15T08:19:55.807Z" }, + { url = "https://files.pythonhosted.org/packages/d3/05/71bfc5caa0abcc45aea1f6a4d50ac68e59605ddc7666fe8494f4cd229665/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ae4a097991662cd4fff0ddc74e0fe7874f82e00042fa0ea00855645ed0c79598", size = 260037, upload-time = "2026-08-15T08:19:57.312Z" }, + { url = "https://files.pythonhosted.org/packages/c3/92/de7e32ed05341e7a9c4c877c318418197b7f2d66a3b68d561bf2ac57ca3e/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b599739b93b2cbeded49645ae3c8d1405c29ddfbceac1545c87a3f9580a9e96", size = 255097, upload-time = "2026-08-15T08:19:59.056Z" }, + { url = "https://files.pythonhosted.org/packages/f5/7b/ade0a122600319dfa0b1000ab0f9731c94a817904cf3c5de408c73a4ede7/charset_normalizer-3.5.1-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b39b69b347e5e47a3b5b8cfc005c68c1ba347474e3960236c4944a8ecd174962", size = 250166, upload-time = "2026-08-15T08:20:00.612Z" }, + { url = "https://files.pythonhosted.org/packages/75/9c/019fbb9f4834491a160951349b1a3714439376f66e5f7cf18b4f18f0c7aa/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a2028475ba855475b8b4d3cfeb4994269c967aea8b9892dfba907f4263a863a3", size = 241821, upload-time = "2026-08-15T08:20:02.321Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b8/11d4840bfc99330cc7fbcc2681ee5a044553a6e77655508d8f9b2bff7b34/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:36047af20e17097c3bb9476c2b7655f2f7aa51322c0ba58c07695bedf755a950", size = 232529, upload-time = "2026-08-15T08:20:04.008Z" }, + { url = "https://files.pythonhosted.org/packages/18/96/2b3a21492d9f65171ac75d872f5018260013d00bfa0ff70ec9f179148cbd/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c4fb141a727957c93edfe5c32a26ceb6b5f6461d67146e2d39f51e16170bea8", size = 260348, upload-time = "2026-08-15T08:20:05.877Z" }, + { url = "https://files.pythonhosted.org/packages/d6/aa/a69a2028e8bd052476c245460ab19d7de595de084dd968f2d75cd50c3e25/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:2f293479cce755c75f1697e87c409b7ae4c555c7dfecb6e988ad13abba943031", size = 247234, upload-time = "2026-08-15T08:20:07.487Z" }, + { url = "https://files.pythonhosted.org/packages/35/8a/3d130aeabcaf3d2466af76b7b141c08d9e89c9016ab4b7cdd0f7dc2d1c62/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:3588e376b3ea2eea84976f67273d679f229e24c66dce7b82ae45aef04ff6e072", size = 256917, upload-time = "2026-08-15T08:20:09.142Z" }, + { url = "https://files.pythonhosted.org/packages/80/c2/a7379b840292d0c1ab9fbd17d1f3967aa81794dc95bc74be8999d7fedcf7/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e199fb99720074809a7720f1c0b4d919eea8b87e88713e0f8f602f7bef543d9d", size = 254846, upload-time = "2026-08-15T08:20:10.727Z" }, + { url = "https://files.pythonhosted.org/packages/01/65/d43b714731bb2f40d4053dfa00ecfc1c5a301f8e3316c5db3a09af59fe94/charset_normalizer-3.5.1-cp37-abi3-win32.whl", hash = "sha256:dd732602a7009217f658d5863d12d79d373a4de0eebc111094bcdd3bb8e0a6cc", size = 174216, upload-time = "2026-08-15T08:20:12.334Z" }, + { url = "https://files.pythonhosted.org/packages/35/4f/b911ed898b26a09789eba9c9200c999aff6c61b4bafaf4838e56d1a1e1a3/charset_normalizer-3.5.1-cp37-abi3-win_amd64.whl", hash = "sha256:70055ff39b97c99e7ae40ea3e393fb62aa2e44dbd9b29f8d14f42fb0025c3959", size = 199764, upload-time = "2026-08-15T08:20:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/f0/a7/920baf467bfd9bf689f3b318340f37aee4572a71f162bd8db51da55ba4fa/charset_normalizer-3.5.1-cp37-abi3-win_arm64.whl", hash = "sha256:87e4f41d375c0b9be2fb5251aee4b8a689169e134535aed81bf085c3b647451e", size = 287318, upload-time = "2026-08-15T08:20:15.551Z" }, + { url = "https://files.pythonhosted.org/packages/cc/61/d01fc49b8dea277640b55a9e15960dbca9fdc8c9fde18e572d39c59f4019/charset_normalizer-3.5.1-py3-none-any.whl", hash = "sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6", size = 68658, upload-time = "2026-08-15T08:20:43.306Z" }, +] + +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, +] + +[[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.15.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/c3/4f2195f512fb172aa425a8803a874b2baa9ba7f80ff7b6080998761fc701/coverage-7.15.4.tar.gz", hash = "sha256:0548198fff07ccf4faf469520bce1c2eceb1ce3e62891921138dec10907f9d00", size = 936952, upload-time = "2026-08-06T13:50:24.442Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/48/bc8d4ba7b37551a767bd863f15b3f80182b271c2f55975356f5f7dbe94c2/coverage-7.15.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d4fedd1f7f428f9fe83b1ead5e7cc87a43427be31aadafbac3ac0636dc7abb22", size = 222543, upload-time = "2026-08-06T13:47:37.562Z" }, + { url = "https://files.pythonhosted.org/packages/20/dd/88d6f83f1fffc974a3691a34a97951c5b12df7512a6782c5963883cbc058/coverage-7.15.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:37e2f0cdf58e2e1fed4e4d5a8f8786ae2f7eb80b478016876667dc4a01d60a97", size = 222905, upload-time = "2026-08-06T13:47:38.927Z" }, + { url = "https://files.pythonhosted.org/packages/bd/5c/54ee0d4748585bb0acab9891cd8d92f2d3593165b4e59fc9de113bfb3140/coverage-7.15.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fb55d0e70bb15f2e81477613627286581414693d74ac7963c93a790dd453ca9d", size = 254407, upload-time = "2026-08-06T13:47:40.488Z" }, + { url = "https://files.pythonhosted.org/packages/8c/3f/f0642a372f494bd0d7dad3b497083b910194a5f1c88be2c94fef707c3b59/coverage-7.15.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:899b9da30f3c6c336566e3707495bb23e8302d39d862f01fa78c48b99b9437e2", size = 257145, upload-time = "2026-08-06T13:47:41.931Z" }, + { url = "https://files.pythonhosted.org/packages/71/17/8b46d0ed68251016002ec972c8fc0119961a765d0984cafb8bf317c43758/coverage-7.15.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d15715e8c46552827e5e4f30a35575a2dbcad14454cf3284c54483946bd16931", size = 258257, upload-time = "2026-08-06T13:47:43.527Z" }, + { url = "https://files.pythonhosted.org/packages/30/b8/8498a0e72d0adbe15477dd07463d2b3bb2c9f6a4815e8589e50939e2c3ae/coverage-7.15.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:002a438859f7b430bc99afeaf01a6d187dad1d0dc907b64cdeffc632a5db8fd8", size = 260517, upload-time = "2026-08-06T13:47:45.121Z" }, + { url = "https://files.pythonhosted.org/packages/41/e1/7dce19c3bdb1e3dd63e769508216500edad81bd5f69a26d724e32aceaf78/coverage-7.15.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e4193a04b518f7968f3099755f5509ee7cccc6dc2b92a6b14841934d22e222c9", size = 254785, upload-time = "2026-08-06T13:47:46.541Z" }, + { url = "https://files.pythonhosted.org/packages/dd/b1/e1494703c675a2561723cd9b89f45c9168782c31280c611b1f767851e57c/coverage-7.15.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e98dcc55d572b38e69d117da7e8e8efb8500f1f5eaf81ecd460a63220790b839", size = 256176, upload-time = "2026-08-06T13:47:48.155Z" }, + { url = "https://files.pythonhosted.org/packages/73/76/a5629d270fb638a43a4b10466f51e2f49d532c1aa4da2913cbbb150bbe0a/coverage-7.15.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:af6c538498ce66c10d3fd541c2a8d5b03da5850355add34e6cba564210cb9e72", size = 254321, upload-time = "2026-08-06T13:47:49.757Z" }, + { url = "https://files.pythonhosted.org/packages/ff/4f/9c44447218435d5766b911534f9d798144a5560f85e9a54ebe5f3f5d19f9/coverage-7.15.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1d10025d96ea89fc2f73714dbc4cbd433fe012c1ac9e23f895d7728b238b6e52", size = 258390, upload-time = "2026-08-06T13:47:51.248Z" }, + { url = "https://files.pythonhosted.org/packages/de/36/c1e127616fb3fa18a9ff71e76c417f2fd7424332a4870015ac224ef4c039/coverage-7.15.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d802e1947603162ded419bff83ac7489820355d2b856dfb09206574e3a37ac0c", size = 253894, upload-time = "2026-08-06T13:47:52.816Z" }, + { url = "https://files.pythonhosted.org/packages/e9/b9/fdb92c8ae7a8bb9b850cc253b7b3b9c8526f68130002048b5671cd510d09/coverage-7.15.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c2de40895718f91951b86712b4c5b694acaf9a0a49be13874896f599a1eed3f4", size = 255763, upload-time = "2026-08-06T13:47:54.296Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/a7d51b2587c7bdb76e71b0896d2565bf7d60436b5122fc83e511adb1f7cd/coverage-7.15.4-cp312-cp312-win32.whl", hash = "sha256:5c3431b2161279b7db5c2a1aa58ae02e5cb8c3c42d93a5094be3f5537bd5b11b", size = 224597, upload-time = "2026-08-06T13:47:56.074Z" }, + { url = "https://files.pythonhosted.org/packages/49/b9/5c5f80cc55f5acaaca6dee677626bfcec8c87204a7809b438b08e84f4571/coverage-7.15.4-cp312-cp312-win_amd64.whl", hash = "sha256:6befeab5fb2b51c958ca4ac6c5d141a1e8240f4f76e46350f1911963deda49cd", size = 225135, upload-time = "2026-08-06T13:47:57.52Z" }, + { url = "https://files.pythonhosted.org/packages/47/e4/2a4561f89ff6bf7c925c287d0f2cce8bdf139c3a33735c87e3203401cf94/coverage-7.15.4-cp312-cp312-win_arm64.whl", hash = "sha256:67bc345491ab55b837277d76f5775d057e8c7f1ac44d890d8c2c82adde258c6f", size = 224515, upload-time = "2026-08-06T13:47:58.977Z" }, + { url = "https://files.pythonhosted.org/packages/f1/84/651a9310859673aaa3b3203f1aa1641ca60fcf2494683e1c9474c7172780/coverage-7.15.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c705b28feb2775dc82a25f1d473a370bc37ff93f5177f4e29ce2425f560f6921", size = 222565, upload-time = "2026-08-06T13:48:00.796Z" }, + { url = "https://files.pythonhosted.org/packages/82/f9/4dcf700137e8af550670f4d74d1b63828ce93e1e2b05e5f10710eb2ea987/coverage-7.15.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3ff205ab5e3ecc670f6a4dd19d9cbf12ede53dd41cfc1e15716ec961ea6d314e", size = 222936, upload-time = "2026-08-06T13:48:02.391Z" }, + { url = "https://files.pythonhosted.org/packages/07/4a/612ff1e780b3fbfd637486f542f84adc5503873d8b5d279dec1ffeef9414/coverage-7.15.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5172326e861a38b48b48befca15e0f477a26b283337a33a739c8fed229934e36", size = 253926, upload-time = "2026-08-06T13:48:04.382Z" }, + { url = "https://files.pythonhosted.org/packages/b0/04/d1cff1c2ead4708a6a79c01d3736b6a25bd38a36678398f72a8dd33dfad9/coverage-7.15.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:12b59c90084e3234fb11184886bf4a40f4f16a8c8f867be2e087b81f8e8868d4", size = 256523, upload-time = "2026-08-06T13:48:05.996Z" }, + { url = "https://files.pythonhosted.org/packages/b9/80/d34e13fb4b293cbdb9665838cf5522077b8ad14ef947550631a4bced36a5/coverage-7.15.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:349062d66f00b40fa2c1c222438bad25fabf755631b5d82937fe985c8008615c", size = 257759, upload-time = "2026-08-06T13:48:08.036Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e7/2c5fe7636fdb0732fe0f09f308a5b066864078b7fc61f6678e8478554f2e/coverage-7.15.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4256ced708e598e05209bc1a8ab4074e04a51dba4c62fb45926a229af675ace7", size = 259890, upload-time = "2026-08-06T13:48:09.834Z" }, + { url = "https://files.pythonhosted.org/packages/92/28/9689f0858dfff59c2ea688938ab9fa2925631235df67126a42b6c5c70ae1/coverage-7.15.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d80f974b20782d9612c8b4c9beeca867074c7cf4079d1419843fa25a26428b25", size = 254121, upload-time = "2026-08-06T13:48:11.459Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e2/785077c230c157243eb5aa9a26c3be260ecd02001bead54a3cada3df8e03/coverage-7.15.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2e179f19bfe1d31f8eeeaa12990194d761c4f62f0759661000bca6cd8729f40b", size = 255891, upload-time = "2026-08-06T13:48:13.209Z" }, + { url = "https://files.pythonhosted.org/packages/d4/90/e20371b17b40f912f21305c2db2f30efa3de306f7320fc916804872c85a4/coverage-7.15.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8bc16bb47b7679670eceff71d78bfb7d6e5b143f6c2cd117487ec7c75e0d4b78", size = 253859, upload-time = "2026-08-06T13:48:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/05/49/25371987ee459a5f67c0427fb75c74f9358e65f2c71fe75bf41c1b6c5fcb/coverage-7.15.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1cd685005cd2c4200adfc14cf39a603b9320efab3f18a8f7f156d20c9cc3345f", size = 258011, upload-time = "2026-08-06T13:48:16.464Z" }, + { url = "https://files.pythonhosted.org/packages/30/6e/32e67467f6154bf4f1c4f63b05acc5097cba4237d45bbeeea446b52e8ac1/coverage-7.15.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:337399ad2c93b3acd2a937627dae8b3e86b66707cd3d3e856347999aadf1ef8d", size = 253676, upload-time = "2026-08-06T13:48:18.493Z" }, + { url = "https://files.pythonhosted.org/packages/03/c1/8b24192e89286399765155251f99ee9f070a9d637109018ac23d99b99f6f/coverage-7.15.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:96e257121228ec5cd2bb919276e94ac11074471bc37d68dbae0e8308cce15fff", size = 255453, upload-time = "2026-08-06T13:48:20.057Z" }, + { url = "https://files.pythonhosted.org/packages/16/6f/8b41ebdf67c87854e17c035336a90f1cfbad0c14c2a584301be6ff148718/coverage-7.15.4-cp313-cp313-win32.whl", hash = "sha256:c65a9e0dfc6143491879da4e13b5e30f8be192055de508d737fb14601edbd22c", size = 224605, upload-time = "2026-08-06T13:48:21.655Z" }, + { url = "https://files.pythonhosted.org/packages/e0/e2/2946c7f0b42b152ecb21ff1bdad72e3d301e790c0c487e4a86e8c9f69347/coverage-7.15.4-cp313-cp313-win_amd64.whl", hash = "sha256:2ff8f5e9b8f7a94f0c11c45631eee103dbcb7d63274edd12c56efe1be690b3b4", size = 225148, upload-time = "2026-08-06T13:48:23.376Z" }, + { url = "https://files.pythonhosted.org/packages/9e/83/3f4a69957f48ae7a0aba76c34743f88963d607b19e03f3f8e66f91cae0f9/coverage-7.15.4-cp313-cp313-win_arm64.whl", hash = "sha256:6e0a8a5083b096487d6cfced94cdd514d8f5db6f113610fb36c0620edb1028cf", size = 224536, upload-time = "2026-08-06T13:48:25.117Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ac/748cf29eeb2d6be34a3176ce26a4f49e38085ee08e8935f05f6f26ed7e0f/coverage-7.15.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:770e9325ab5ea6d56f77e59b29ecfe0ac20b57a82a601876f90494a4dda0386f", size = 222608, upload-time = "2026-08-06T13:48:26.806Z" }, + { url = "https://files.pythonhosted.org/packages/0b/02/1abbf5c984677b0aa439cdacaccbf38d248939d8ef8fe1cc7a50d73edb77/coverage-7.15.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d12b33a3a50a1676b7784dc8d00a0c6d66a9f2add4b85a041c19b6a7e53ef23c", size = 222940, upload-time = "2026-08-06T13:48:28.432Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e1/ff8f9f53d9fcf586125b55d0b1f04ec1c14955fee41e83d5814bee141bb5/coverage-7.15.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5669c8378ebde86f5def7a25d29586631b58acc27ffde04399f678f3dfc6e082", size = 253985, upload-time = "2026-08-06T13:48:29.995Z" }, + { url = "https://files.pythonhosted.org/packages/a1/26/595759762e514e81be1d7d01ed03444303bcd152226a6529998d253f9201/coverage-7.15.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ff97a14362eef486483ed44042ca2027ea257df6ff768e62358ee0c9776925ac", size = 256492, upload-time = "2026-08-06T13:48:31.634Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/b79aabac54d482be23b5fcdd4f4662bff24a78edc4ee29201726929936d5/coverage-7.15.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a325e815318638aed1655d9c06e6d7c2d3d46c09231ce988070428a8762d734", size = 257837, upload-time = "2026-08-06T13:48:33.186Z" }, + { url = "https://files.pythonhosted.org/packages/09/0f/bf7f297885a5bf6fd71e5782404e0ff059ca09e8711ceb3a08544abde45a/coverage-7.15.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:474223409d88eb20d2d6a0d37ea60e8647a65a90cc008dc1f0410af5f64f1e0d", size = 260152, upload-time = "2026-08-06T13:48:34.75Z" }, + { url = "https://files.pythonhosted.org/packages/fd/f1/296744e854ff8368542343457414380465e9ceefb9192342feb9d3bc461d/coverage-7.15.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7f2f62ae3cd189dd2e13aece758c57b3eecbd27be070dbd4cbd10936049e5dbf", size = 253978, upload-time = "2026-08-06T13:48:36.434Z" }, + { url = "https://files.pythonhosted.org/packages/55/b0/bbdb2e9057493e66220a2e149ca2d301ba0e3a58a83bd6b90de9826d16f3/coverage-7.15.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:39ece820e29e0a2ba34b3ecb3be83c27e997eed8926f2ba6fe7ce7a0bda5843b", size = 255846, upload-time = "2026-08-06T13:48:38.317Z" }, + { url = "https://files.pythonhosted.org/packages/96/e4/38015b2b6d21258713bd17e76b59d033b191efb5703589cffd037dfbca20/coverage-7.15.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f21b56dcace11dfe013014201f577dcd592b2a9b72182d930361b47cf6f73f25", size = 253808, upload-time = "2026-08-06T13:48:39.993Z" }, + { url = "https://files.pythonhosted.org/packages/0b/64/0d515c1e60ee6fbfd1a0e79c07cd87d388a233b7adc37758735677203808/coverage-7.15.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:93a3a0b662abcc10c73a47cbc72cd60f63618d6989fb2d1286e50eacd974f303", size = 258081, upload-time = "2026-08-06T13:48:41.971Z" }, + { url = "https://files.pythonhosted.org/packages/91/71/04d9e7a3642146c6351338aef4ef85ab11dbbb54744c13245caba1aad1c0/coverage-7.15.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:141fae2cabf5569b782c10afc4c850ce10f618c13f8db54765cba99cc839da1f", size = 253624, upload-time = "2026-08-06T13:48:43.731Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a7/6c28b74c81ebff66987b0e2522ba5cffa3e90b0c33cb6a2eb264d4ee8cf1/coverage-7.15.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:81294c7e6ab30c5f74c0353b11b2fd6320e72d9bee6ac73b357caa8b916323a5", size = 255280, upload-time = "2026-08-06T13:48:45.58Z" }, + { url = "https://files.pythonhosted.org/packages/52/af/bc19996a7014b98d7bbb0f0939453c67074af65784a3aa16a789a07381fa/coverage-7.15.4-cp314-cp314-win32.whl", hash = "sha256:7bbd7d6418e0dab31a206af5203bd43ae36edb8e7fba1940b055d3e9249290d7", size = 224768, upload-time = "2026-08-06T13:48:47.525Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/219484e476d6e101ba0a444852579e05f5b75c37c611a42ed1190f73ef62/coverage-7.15.4-cp314-cp314-win_amd64.whl", hash = "sha256:f0204ed122758782970526057093f448051a39db9d810d4e344bb87a3546f425", size = 225259, upload-time = "2026-08-06T13:48:49.513Z" }, + { url = "https://files.pythonhosted.org/packages/b7/66/fa77daf4e383e5f776dac62c2409b6af81910ae6fe326bd5170dba74cc63/coverage-7.15.4-cp314-cp314-win_arm64.whl", hash = "sha256:9e71e7bc71c686a123347ae47a0de33a175e797a85bb57b791492adf4eec8ed8", size = 224684, upload-time = "2026-08-06T13:48:51.235Z" }, + { url = "https://files.pythonhosted.org/packages/58/5b/f03bf0ce362bbf3f785fa5219620d00778d4ac6fc9e407734828e9c672f6/coverage-7.15.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7c922735321eef3f87c280a3d39afff6b646723a2880b862cda4ac7a093b8aa8", size = 223338, upload-time = "2026-08-06T13:48:52.896Z" }, + { url = "https://files.pythonhosted.org/packages/0f/76/e77d0ae22501831cc9f92193e8a957a5caa1dd177f90a6d1d9b106242d92/coverage-7.15.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f41c17c4668a655ce96d090d8d5ffdc24ef64b5a02f9753884d08483e8a4a41a", size = 223609, upload-time = "2026-08-06T13:48:54.688Z" }, + { url = "https://files.pythonhosted.org/packages/82/1a/b1f089da8d38ac612fa2dd6dc7f4a1a7657d12f3e261d2996edd3a838d0b/coverage-7.15.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:46822e9b6ff1c6a72b518c162c44a8f45a61a1d609c51084bf5b16c023c5037b", size = 264970, upload-time = "2026-08-06T13:48:56.403Z" }, + { url = "https://files.pythonhosted.org/packages/bf/31/e66d98d6e9c7fcc88470f1e234eaf6b1950dc0dfbf797f7282c1c861da24/coverage-7.15.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3d6f4955b73b5445271379a59e3792b0d978f42d4a01e0cf7a67d9c33a3bb0a5", size = 267088, upload-time = "2026-08-06T13:48:58.41Z" }, + { url = "https://files.pythonhosted.org/packages/59/a1/ae94eb2c541add426378408379f233591e069040b1e2cdb33df9498a0682/coverage-7.15.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3fc9e047706fb4a9abb54f719d3aa643e80e5bb3818182c40aee01ac0f0247ba", size = 269508, upload-time = "2026-08-06T13:49:00.42Z" }, + { url = "https://files.pythonhosted.org/packages/9c/c7/88a10694a1c6a213569766aba9f25847b28155d4ac731b13226db216356d/coverage-7.15.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05e491d4f3165d62d4f5c8fd48dfeabf2ae8f42cbbd484319af33ea851b78982", size = 270629, upload-time = "2026-08-06T13:49:02.234Z" }, + { url = "https://files.pythonhosted.org/packages/b3/34/d8b8232e5e55169933b59aabcef2fedfa4b9d8897361bb80fcbda146505f/coverage-7.15.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:226c66e80ec0598d3b9b4874123df167ccca342aca8714f77cac6829688ee09c", size = 264043, upload-time = "2026-08-06T13:49:04.102Z" }, + { url = "https://files.pythonhosted.org/packages/7e/35/58b009dbf8c471c7224716478b9fed4a7e1af15320e1ed41660978504663/coverage-7.15.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac41cc14bebda0dbfb0628036b7f75706935c95bcc07fefe9a0f93614aa60a57", size = 266963, upload-time = "2026-08-06T13:49:05.821Z" }, + { url = "https://files.pythonhosted.org/packages/62/aa/57fbda1b42c892968273c56b6ee9dc0f1310850859230a507bc7873b1f65/coverage-7.15.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8af623e5cd92080acddd02b38f2f406a2c3a0893c38950b211890361448fbf26", size = 264569, upload-time = "2026-08-06T13:49:07.706Z" }, + { url = "https://files.pythonhosted.org/packages/98/8a/360e6e7f24d477b7e889703af0afa878d15b6d4d8d2a822b2835c169a879/coverage-7.15.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:07545711d4f0f32852a18f18ad11f76f0109909d09e78b9008b4cfc67e829429", size = 268299, upload-time = "2026-08-06T13:49:09.587Z" }, + { url = "https://files.pythonhosted.org/packages/4e/89/6f701261aee21b6b5fa8f7872229406dc917e125069448292223bf213606/coverage-7.15.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a0865421cfdc53654b342d515e5a233187590882d20b95752150e53f65460017", size = 263413, upload-time = "2026-08-06T13:49:11.604Z" }, + { url = "https://files.pythonhosted.org/packages/3f/0f/6f04036edc260ed425af83e834f627fad48941ce97b50bfe6edd8b6fa623/coverage-7.15.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:460115e32ee40566476db5048f9bec1e842c127ad8e6f8be745aad3ac9cbc839", size = 265725, upload-time = "2026-08-06T13:49:13.38Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ce/d19b5d4d5c49a7bfb925fd74310fee7d28bc99520ac3367ccbc54e662518/coverage-7.15.4-cp314-cp314t-win32.whl", hash = "sha256:cbde877ef9dd7baf272b9bfef2b8a25edd45d9170fc326951dd20eb480335e85", size = 225079, upload-time = "2026-08-06T13:49:15.265Z" }, + { url = "https://files.pythonhosted.org/packages/26/bb/7aa1b3b173faee0679037ca950bbbe1247273656697994d8d13f80f8d4b4/coverage-7.15.4-cp314-cp314t-win_amd64.whl", hash = "sha256:3da9e92d1c551fd7563833e9ade686efb0c4b7363ab7681a94283958c950bf5e", size = 225911, upload-time = "2026-08-06T13:49:17.279Z" }, + { url = "https://files.pythonhosted.org/packages/81/1c/4ea9e47426d80038d9222db3c4534cb6021a74b237d3ff97ffd33b6600dd/coverage-7.15.4-cp314-cp314t-win_arm64.whl", hash = "sha256:3a54f5a0d85050c73a38f6793090ee83974531e67fe5e57a1da9bee11398aa5e", size = 225219, upload-time = "2026-08-06T13:49:19.293Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c4/dc5d2ac8f9142e7ec7de66e7bf0591db29d78955a040bd915870d9c0e657/coverage-7.15.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:2c9872e4d9dc5d3cf616bf4b382f5a00359305a5be666a3dd0b5cdb4e49597f9", size = 222604, upload-time = "2026-08-06T13:49:21.279Z" }, + { url = "https://files.pythonhosted.org/packages/70/39/33e63df81fe2ee100897451841c821467635923e58e37c6bd4b46dd8106c/coverage-7.15.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:e101dbb4b9b72f0cddd8cdc8c9c5b47f456766f5e0ac82dbfb75e5c55409b78a", size = 222944, upload-time = "2026-08-06T13:49:23.187Z" }, + { url = "https://files.pythonhosted.org/packages/99/1f/ef3ffb5557febc75a0d97aa459d0266d7d741110265121cc6d8539343d44/coverage-7.15.4-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7d1abebdb047729e852b9c77a00497dfbeb11eb3a117e037d7dbc3ac8e5f5c54", size = 254050, upload-time = "2026-08-06T13:49:25.008Z" }, + { url = "https://files.pythonhosted.org/packages/6f/f5/1f0f6f77698c3601ca0ae7431e34b24c62ca2f06fecb23b73ed1f651d2be/coverage-7.15.4-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d28a4a899354d0ea6214cc59b4fa19eefbce1b9ff1688ab579acf49e894bd3fb", size = 256967, upload-time = "2026-08-06T13:49:26.896Z" }, + { url = "https://files.pythonhosted.org/packages/03/7a/2ed9bed79925f4367c83c77f66a89e5ca7229c288d2d19ad5f36d1ca0070/coverage-7.15.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ffb3c2aacea411cc7e1d27712490c11108e2de1d39019ae32915493a59a8b9ed", size = 258587, upload-time = "2026-08-06T13:49:28.692Z" }, + { url = "https://files.pythonhosted.org/packages/45/8c/fa34044f71b7cc4ecb6da9c2408770959b0591fa9b5fb6fb6bca38f94298/coverage-7.15.4-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a9447978a92f405d301123cfd39ff49895490efb769a758fe2734c7f631bf8ce", size = 260785, upload-time = "2026-08-06T13:49:30.472Z" }, + { url = "https://files.pythonhosted.org/packages/4f/54/d5727ce36b4524a7394ab9f5f1df378e1f23affcdab01037dc8655185cc7/coverage-7.15.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:050467a7983b8e2fe7dd41a78bb30c3e7f8c0b8cafda14b1c46f8b5e3cf2dd3c", size = 254545, upload-time = "2026-08-06T13:49:32.271Z" }, + { url = "https://files.pythonhosted.org/packages/dc/e6/6e3783e576719590194bdffb6dd6d85490801785b7c331e35a245d8cb8b5/coverage-7.15.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:d003b7a5708ddad5c206c79607a6b92abb6fc13c57d99d8a4468cc03a2941ced", size = 256682, upload-time = "2026-08-06T13:49:34.089Z" }, + { url = "https://files.pythonhosted.org/packages/dc/f2/bacdbde18b69ed2de424fcf64d9fb0a4913753d4f0eca8bae9daad69f4bd/coverage-7.15.4-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c38efe30fd74e5c19e9433f11fb1f5dc9c6522770971b7c6145bbaa413dc8800", size = 254560, upload-time = "2026-08-06T13:49:36.052Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a3/1fb927196e3477c1b48831169ab58ba08f451ba87ae311ff1de68b26a616/coverage-7.15.4-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:1f4f826d70f772ab8b0c052329580d7fe8b8abd191e4ce0c8f81aec6614665d3", size = 258792, upload-time = "2026-08-06T13:49:38.01Z" }, + { url = "https://files.pythonhosted.org/packages/41/58/30d4c149c69053de0edfe325614c1d28d508f62b1783e0e4a234d2e49136/coverage-7.15.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:4a4bf917c9953f57c957be31c1cd504e3bd2f34d4a352b9d391a3025336f6768", size = 253968, upload-time = "2026-08-06T13:49:39.934Z" }, + { url = "https://files.pythonhosted.org/packages/89/e4/77f639371b918aad30dda4051f95404b43578f7f2e2f87ba73e02ed1ff37/coverage-7.15.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:1c9bf40ebef178a45192c75c4964760bb261b0e6ad725da5fc4c93f674f19753", size = 255893, upload-time = "2026-08-06T13:49:41.825Z" }, + { url = "https://files.pythonhosted.org/packages/5c/62/13be29b3ddab35f14c87967a4820a05106d2a3eccb4fa4ff550bf30b75e0/coverage-7.15.4-cp315-cp315-win32.whl", hash = "sha256:43619d04c3671792d2c4706ae8bf45e265dc87bbd4078189ef8b847ea1e74be2", size = 224768, upload-time = "2026-08-06T13:49:44.08Z" }, + { url = "https://files.pythonhosted.org/packages/a1/70/af0c6be0f964af6954f6b74bc109b0dbca02824696d2520fb17fe1ab06e3/coverage-7.15.4-cp315-cp315-win_amd64.whl", hash = "sha256:be619439dbcd31a2eab10b32de9fff62c26ed4bab69dc32b8363fdaaa0882809", size = 225242, upload-time = "2026-08-06T13:49:45.899Z" }, + { url = "https://files.pythonhosted.org/packages/4f/2d/f3bd3aab899fc9efc18b53133ee68f5f98574ef480649b23e12962226387/coverage-7.15.4-cp315-cp315-win_arm64.whl", hash = "sha256:def597967dafc2e8d97c9097ea453c464e0bb8ed38f193a43070f10dc623bb6d", size = 224674, upload-time = "2026-08-06T13:49:48.322Z" }, + { url = "https://files.pythonhosted.org/packages/f5/ca/f69251cd63eabc6438321aea22148754cce758a26bde07dd490e3fe7cfc5/coverage-7.15.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c7dbc748ac8a1e3e59a2b28bea47675e6e778081dbbf081bde0d75def2fcbe1d", size = 223333, upload-time = "2026-08-06T13:49:50.293Z" }, + { url = "https://files.pythonhosted.org/packages/a7/a7/037b53b2885b0d8447064432491a4d5a1014cd9f97a594d53acd0c04541a/coverage-7.15.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:2413074a5ecbb61a01a7888fc72db0ca324d13588c5b38bc0dd8564cdcdfea26", size = 223630, upload-time = "2026-08-06T13:49:52.637Z" }, + { url = "https://files.pythonhosted.org/packages/80/4f/152b8a4779ae90da11bb24f7467df8a59f0be48a5c52acb856325ca48289/coverage-7.15.4-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4e6f6f632b7b2f714bf7a1346e8f97b650ee71f3c298aaad42a2ab60f0f07645", size = 264489, upload-time = "2026-08-06T13:49:54.52Z" }, + { url = "https://files.pythonhosted.org/packages/10/2d/84b4b9e0e1dd6528a51920ff7031f35b789382e467a28ec6a5a578cb8812/coverage-7.15.4-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8df457da2249d3c75ca2e5e835d59c725abfe92d27fdff6cd99eed85b51d5e9a", size = 267567, upload-time = "2026-08-06T13:49:56.721Z" }, + { url = "https://files.pythonhosted.org/packages/53/fc/ba01cc25299f9f8a2c8b02d3b28c53f3543d9fbfbe4e74fa2760b48f163e/coverage-7.15.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:050f66a08805acb5b8a23c6d4a517b1ecf82c08e81ed0e4bd727df065e5c6624", size = 270123, upload-time = "2026-08-06T13:49:58.736Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d0/db2647cbf40b14f8c308f94ff7bf89c06d564e59f396906edf50086ec788/coverage-7.15.4-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1587fb771d1ccceef708fdde1e5af8c7ed24b486b61d13a321acb7d8145390aa", size = 271107, upload-time = "2026-08-06T13:50:00.811Z" }, + { url = "https://files.pythonhosted.org/packages/70/ff/4d2d17924552c458bb4f77dd631f0e3bc92fbbdf2d2d916cd4b33bbfd5b1/coverage-7.15.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8b4f1c3a69ca580f3fbd6b2046915f536d7f586874f25c1bb23add2a3c88d50f", size = 264955, upload-time = "2026-08-06T13:50:03.023Z" }, + { url = "https://files.pythonhosted.org/packages/ee/de/dc010c7a3691f396d93bbc26bfcafa1c2a3a351cd520470f15faf5795bd5/coverage-7.15.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:ffb58d7eff5b7f6ecc6fa21d6288ab7f968a212cb67d682c269c09b9eba3b66f", size = 267949, upload-time = "2026-08-06T13:50:05.557Z" }, + { url = "https://files.pythonhosted.org/packages/78/ea/dc96a11375e83c045c2f7c61fb6918277cfe9401db7c0f7b1d111a84b2e5/coverage-7.15.4-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:d9df165544774574ee004b953023d1bebada1894a80b1052a43d798b0f676e67", size = 264421, upload-time = "2026-08-06T13:50:07.612Z" }, + { url = "https://files.pythonhosted.org/packages/c8/86/b77131a0f9503ce461cd577076147d7a9040f0c5dda772686f729e2cc9cb/coverage-7.15.4-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:f9de0a24a4079b53e523b5c5e2c5945ec251ab486652659955187cf255a259bc", size = 269121, upload-time = "2026-08-06T13:50:09.58Z" }, + { url = "https://files.pythonhosted.org/packages/24/24/944bc35007862955e7ebf05754e645419dcf5d7526c52735cfa2715e8ebf/coverage-7.15.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:150089274bdc9f940628552cb92844e0223c987f1902ab8efe9f45a2ec758d88", size = 264565, upload-time = "2026-08-06T13:50:11.722Z" }, + { url = "https://files.pythonhosted.org/packages/c7/cc/a3bb9f93e7e740659163e2ea584f8196ddcd2c456a5dbe15f6c50105fec1/coverage-7.15.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:a58a94fed5da6997d258e8f7668c1e195fbd04a691d781b7558f1e468f9e68bc", size = 266522, upload-time = "2026-08-06T13:50:13.786Z" }, + { url = "https://files.pythonhosted.org/packages/49/dd/e0e40f3560d878d888c580698ff5ad1179f5e1c3ac949684ef66b41a3817/coverage-7.15.4-cp315-cp315t-win32.whl", hash = "sha256:ebd5a6d8466ff30836572f3ba2cae8a5e8f85029b1c6d5e2ed338dc472a5166a", size = 225068, upload-time = "2026-08-06T13:50:15.825Z" }, + { url = "https://files.pythonhosted.org/packages/c6/7e/37732ea80eebc30e976e4cdab15c190bc42d96959a42e38ddf6f8c60468f/coverage-7.15.4-cp315-cp315t-win_amd64.whl", hash = "sha256:288bde2a2d7ab6b6c2d7252fcde8b524387f2d970bdba9658fc6f8bbcaef0f9b", size = 225895, upload-time = "2026-08-06T13:50:17.928Z" }, + { url = "https://files.pythonhosted.org/packages/c6/08/1e00f7923eaaba45fb3d51dd794125fc766304b1df264f3a9c6557bfb30e/coverage-7.15.4-cp315-cp315t-win_arm64.whl", hash = "sha256:68be5e1de60ff13c9095bbec0e5a7fa45b33b101752215b91345ea1f61c4a278", size = 225213, upload-time = "2026-08-06T13:50:19.981Z" }, + { url = "https://files.pythonhosted.org/packages/b4/d9/e70c286c979378f061d8266e279b686ab0b0b688e1fe0af864684f23a77d/coverage-7.15.4-py3-none-any.whl", hash = "sha256:964730a1e9de9c0cf11be6a1a3c79ce419c34882842abd256086ba4698705e84", size = 214332, upload-time = "2026-08-06T13:50:22.192Z" }, +] + +[[package]] +name = "cyclonedx-python-lib" +version = "11.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "license-expression" }, + { name = "packageurl-python" }, + { name = "py-serializable" }, + { name = "sortedcontainers" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/17/40/6509e6cfd7f2f3255501690f46375fdd224949e21fd1e96f4f4c8a9041b1/cyclonedx_python_lib-11.12.0.tar.gz", hash = "sha256:16767c4039de90c04e9f03348f8f0ed4b8ff842eaa7eefcad3a95685f970dacf", size = 1445378, upload-time = "2026-08-13T07:52:18.675Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/f0/b2cb999244f2f4194df63ecff6939228178fa63a33be809c412684ca8db7/cyclonedx_python_lib-11.12.0-py3-none-any.whl", hash = "sha256:0e807521a921a5c3cb8ce1153f8a61d29eedfe76a46aac2796b7c6b573391a54", size = 529453, upload-time = "2026-08-13T07:52:16.836Z" }, +] + +[[package]] +name = "defusedxml" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, +] + +[[package]] +name = "dsv-parser" +version = "0.0.0" +source = { editable = "." } +dependencies = [ + { name = "pydantic" }, +] + +[package.optional-dependencies] +api = [ + { name = "fastapi" }, + { name = "python-multipart" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[package.dev-dependencies] +dev = [ + { name = "httpx" }, + { name = "mypy" }, + { name = "pip-audit" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "fastapi", marker = "extra == 'api'", specifier = ">=0.141.1" }, + { name = "pydantic", specifier = ">=2.13.4" }, + { name = "python-multipart", marker = "extra == 'api'", specifier = ">=0.0.32" }, + { name = "uvicorn", extras = ["standard"], marker = "extra == 'api'", specifier = ">=0.52.0" }, +] +provides-extras = ["api"] + +[package.metadata.requires-dev] +dev = [ + { name = "httpx", specifier = ">=0.28.1" }, + { name = "mypy", specifier = ">=2.3.0" }, + { name = "pip-audit", specifier = ">=2.10.1" }, + { name = "pytest", specifier = ">=9.1.1" }, + { name = "pytest-cov", specifier = ">=7.1.0" }, + { name = "ruff", specifier = ">=0.16.0" }, +] + +[[package]] +name = "fastapi" +version = "0.141.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/02/91e3416a8fdd715abb903a952a6bec7cdd8d14eed55d415fc8595524c319/fastapi-0.141.1.tar.gz", hash = "sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1", size = 425799, upload-time = "2026-07-29T17:18:05.568Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/03/10388a42375ee7e4ac9b94eb2c5c569c8b5795e377e701c9ac3ad63de890/fastapi-0.141.1-py3-none-any.whl", hash = "sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3", size = 131954, upload-time = "2026-07-29T17:18:04.364Z" }, +] + +[[package]] +name = "filelock" +version = "3.32.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/64/a02e6765de08964ed371eca577870593245afc9dfac16d037de7c10d18e6/filelock-3.32.3.tar.gz", hash = "sha256:0ffa185a3540854c95caa7fa76b76cb219d907415e2c5dc9af25fd970563487f", size = 218135, upload-time = "2026-08-13T16:00:05.577Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/8e/50f46a9c0ce8d2861a394c1347caae037ea0431d2f67d7feb151cbc4649a/filelock-3.32.3-py3-none-any.whl", hash = "sha256:7f0ca4bcc0e181c60dbbd8aa9ab5b120ebb99e4e064e83636340056f833a1f09", size = 98901, upload-time = "2026-08-13T16:00:03.974Z" }, +] + +[[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.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15", size = 215237, upload-time = "2026-08-18T05:14:24.27Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" }, +] + +[[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 = "librt" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/36/9b/356320fbae2ac8467e21c5e73e1389c80468e4998c62cc7d3536cc51b614/librt-0.15.0.tar.gz", hash = "sha256:4e66cbe84437497d951b799d3e1551291b6fb3d643820a7014b3655d57a59162", size = 214338, upload-time = "2026-08-07T10:49:42.663Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/39/99c25030e782bdfb7a21be8c05254806a2e4bbb05c8d50c2a2130acbfa05/librt-0.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e87bc679f86a99aa3b26e3c78eeb821a247c9a28eae48eaafcc32c3bf4c3bb9e", size = 151021, upload-time = "2026-08-07T10:47:00.057Z" }, + { url = "https://files.pythonhosted.org/packages/14/43/f4b1bd1b2888798a1409808889a25ea1ba49eaabce7d681ed27734c2df9d/librt-0.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:71599e011ac880e8e45d46047d714871894c7d4ab6f25626f8d4f89da21f368d", size = 155267, upload-time = "2026-08-07T10:47:01.311Z" }, + { url = "https://files.pythonhosted.org/packages/0c/db/3ad9c965c72f1e1d6beeec44ec10a54e17be8ae042fbb4baade16cbadced/librt-0.15.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c802434092b769b1d613ed2e13fac15fbfce1934a74bd10283b03c0fae231cd1", size = 503136, upload-time = "2026-08-07T10:47:02.45Z" }, + { url = "https://files.pythonhosted.org/packages/4b/07/5888a6d76acd62ebce66c61b74d94e9370b9c32929f111e487bb6546f8ed/librt-0.15.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5500eeae393a184d14e1f35645962c27129d20c81afa4069e6ef826ebc2b3aaa", size = 496670, upload-time = "2026-08-07T10:47:03.675Z" }, + { url = "https://files.pythonhosted.org/packages/29/39/ab57cc2f5b276156da02bb7f5a8921bada1cb1993ffec99acf811c602c23/librt-0.15.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6ecfc32dfb46fb7b565bcd6abf9412acf978775a998273d22888a6d7953730dd", size = 513688, upload-time = "2026-08-07T10:47:04.981Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b9/bdbb0b648b5c2befb031f4c6f3b1dd857415e8fb492a25a3c764a6681e6c/librt-0.15.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cc46cfd15022e35084355478c9ac809d90b1152222706ac9a7655ec21df6fa", size = 531904, upload-time = "2026-08-07T10:47:06.211Z" }, + { url = "https://files.pythonhosted.org/packages/93/26/473c2e4b6c104e9e58e27ce95fc8005c8bd4fc36cae4f254371125a92db8/librt-0.15.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d5f51401d102c885b9ca509e62c79b1dbff286e1b9b047fde6f763780789356d", size = 524427, upload-time = "2026-08-07T10:47:07.592Z" }, + { url = "https://files.pythonhosted.org/packages/26/60/03b3abb82b41714671b907bf6989b228e31e6a8af52dec82b5b0728dc250/librt-0.15.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:cc30523e3f1a23fb7511cc659834a0d01a1042bb9de359bc1c131cc4ec6c9656", size = 543155, upload-time = "2026-08-07T10:47:08.866Z" }, + { url = "https://files.pythonhosted.org/packages/f2/0e/9bb1f0a4affbd0a1888f4f79dc03ed2a299d9a2c26c59ab2a97dcbf11903/librt-0.15.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:59fe030d8ae4a57e3fb7756bf35a858de74e04066fc8555c53d0af979132af81", size = 546890, upload-time = "2026-08-07T10:47:10.327Z" }, + { url = "https://files.pythonhosted.org/packages/dc/84/6937a280d461f7de6e031ffb02edc2b7c3c90d49d630565ce8ff27cbc5f2/librt-0.15.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5a6526a2a956bbb1e4ae3568c82e650fc99119c66bb011ea60715744955a2b4d", size = 555163, upload-time = "2026-08-07T10:47:11.798Z" }, + { url = "https://files.pythonhosted.org/packages/bc/95/2a2853c1ee014bf102116e7f897a04beeaeb2461b45b79af98bdfb95f1ef/librt-0.15.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:85ea21ec6730194d67156b0e0b5430ccb1d61f8b8b907e39b37f9812b74a13f0", size = 535812, upload-time = "2026-08-07T10:47:13.279Z" }, + { url = "https://files.pythonhosted.org/packages/c9/4c/cf9601c1b4c5f09280acd5d83abdb2e68527a2be8257136eb42304218622/librt-0.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1e47b8ba865d7ede071a91a7163073bbaeb72541f1ef8a07d512c45c7b5007f2", size = 573688, upload-time = "2026-08-07T10:47:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/47/6d/9ac7cbec46189a7625af4b5acbd25f10d827f4141b2002181848c8418923/librt-0.15.0-cp312-cp312-win32.whl", hash = "sha256:a5207ec414d1c4a2a7231b2086970dc036f94293cdf338190984958a013a42f1", size = 106138, upload-time = "2026-08-07T10:47:15.973Z" }, + { url = "https://files.pythonhosted.org/packages/38/d0/2ae99c83be86ce23f925ac1aeeedc777e97f427c4a8d190c70d0a16e9a87/librt-0.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:73b30cfa976659b3917c8f6153bdb0591c6a9ec6583599fd24a689b690622022", size = 126974, upload-time = "2026-08-07T10:47:17.049Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ef/dd24f9635c730b86b87587967dda7516b1845e8b17684603d31607fed598/librt-0.15.0-cp312-cp312-win_arm64.whl", hash = "sha256:a54cf9e0ef47b96af580849db5471142200568ce1e02cbf416addab551369570", size = 112292, upload-time = "2026-08-07T10:47:18.222Z" }, + { url = "https://files.pythonhosted.org/packages/e7/42/467b53a601b406ccd7b97c1fd54b59cb34f9185ad5ce7e9d5c3c4e8961c8/librt-0.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:db13ca398005abcbe538deda87b686d9bd08b7001cf40c4c06b444960ae10a26", size = 151029, upload-time = "2026-08-07T10:47:19.312Z" }, + { url = "https://files.pythonhosted.org/packages/3e/e6/36c2299b7a94b84fdd01220d8a777a71be5be0925bb0dbdf71c0a06a34d9/librt-0.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:aa1f1995789dca3698bc550aaceb09a51bd5df0a057ff84ff15296cd1975b801", size = 155194, upload-time = "2026-08-07T10:47:20.398Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b6/ed5071f9325845e670bd36012757419767fbf56af77ed483077b9e4db541/librt-0.15.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55456ea87d8df21808446d03817be2f65e20391c1c615d9187440dff28cd08dc", size = 502568, upload-time = "2026-08-07T10:47:21.652Z" }, + { url = "https://files.pythonhosted.org/packages/7f/81/6450c67c3615d87704bcbc21323fafc69c799b06a044c447529f725d4b01/librt-0.15.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5a86a5a08c2235316bdb359d5dbb6ce0abfca7fac06363103e2c5af571d92f95", size = 496153, upload-time = "2026-08-07T10:47:22.925Z" }, + { url = "https://files.pythonhosted.org/packages/e1/d6/5f52b722bc75076954b3bfd49be15ea362df4d580c6fb315d0f617100d30/librt-0.15.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e56b6a368529bed262da40ce13f8fef590db0479819cca84f16a1f01ac356d0b", size = 513336, upload-time = "2026-08-07T10:47:24.213Z" }, + { url = "https://files.pythonhosted.org/packages/8d/e2/c08fd1d36ce63ea5a12b85c5d37f4550b5f86a692167e41e5a74222607ae/librt-0.15.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:234d8d394721fa0d786af15ebf1f3fb7f3ed82fd1cd0cde45c2f247b5d4281d2", size = 531661, upload-time = "2026-08-07T10:47:25.507Z" }, + { url = "https://files.pythonhosted.org/packages/3f/d8/d9482fcbeb177b9eb87bb3899eeb3b42be690313c652f9e146b1d0681fb2/librt-0.15.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d8363d7accb0286ac3a0e633f396e93800dafb8150494505daf9515bbda591f3", size = 524487, upload-time = "2026-08-07T10:47:26.79Z" }, + { url = "https://files.pythonhosted.org/packages/10/cc/075171517b41f861753034fbb151b42cfc83bcc853849f24f5e66fd60ccf/librt-0.15.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0f0ee3644d951f31055ad07d77d92520e84505dd7a432cc4cd501dd70ee06785", size = 543201, upload-time = "2026-08-07T10:47:27.999Z" }, + { url = "https://files.pythonhosted.org/packages/b0/03/42c2330f37eeb475b6affeedd06518f60035f323af3a839335e3fc9fef2d/librt-0.15.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2cfd1a81a648806e6a7717be4cc4d1bb392fa229752bf8444ba365e381e984d6", size = 546467, upload-time = "2026-08-07T10:47:29.396Z" }, + { url = "https://files.pythonhosted.org/packages/57/1e/1ad4c5638f7e64d8560328bd25c54b409a661bdb6ff254b38ff90744288d/librt-0.15.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a6cd22c9da0d866558e46a041f1cc0c2bbb26b61b137b2347fa834c332e1d101", size = 555139, upload-time = "2026-08-07T10:47:30.815Z" }, + { url = "https://files.pythonhosted.org/packages/49/41/39fa7d15db1204cd1cbe6514680fbdc243adf754a0885061308f43afc013/librt-0.15.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6d5225ef8801e4ea5e482fa9b5dfb891dd9ef6f6d870f1f25d449ca2c70ac218", size = 536050, upload-time = "2026-08-07T10:47:32.222Z" }, + { url = "https://files.pythonhosted.org/packages/1e/88/c6dcf0dd8e26dc0c9a499a2abab8646c86dcaf9ecea9524cb46d3686331a/librt-0.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d28a05796b99f749bf8794f17ba9ba1612d0076b802e9cfc62c554634e9ce3b", size = 573700, upload-time = "2026-08-07T10:47:33.527Z" }, + { url = "https://files.pythonhosted.org/packages/1b/9b/ab54c71a7918a7c34fa5327fb61390a77446a07a146fbfb1165250a61035/librt-0.15.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:2067ff438048cead9d223ca5675bae2a25e520a7c3e6c1498bf9c6892d22caab", size = 82194, upload-time = "2026-08-07T10:47:34.835Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b2/4f9a243bb892395f3becb80789ade13771701091f9f07ab8230247953ba8/librt-0.15.0-cp313-cp313-win32.whl", hash = "sha256:1cd3b721f24c206398b9e26da3c3a9c011e6e89d06f318ba8ebefc30f1003890", size = 106231, upload-time = "2026-08-07T10:47:36.251Z" }, + { url = "https://files.pythonhosted.org/packages/bf/af/64aff4885a40b93132382f2c314647d722574605416504379184ef3045ea/librt-0.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:f395a4a9a03ac062dbe9a9f82e0c720502e590a38feee6a757bc82e9c63afbd8", size = 126996, upload-time = "2026-08-07T10:47:37.453Z" }, + { url = "https://files.pythonhosted.org/packages/27/83/335bccf6c7cb9028cb0b54aead27d9ece3f01f83bc6baa2abace5da655c1/librt-0.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:0a15cb554761247d84a3ec0cbdf4078d70725384f0e4662c0fa3b26266eb60ad", size = 112188, upload-time = "2026-08-07T10:47:38.729Z" }, + { url = "https://files.pythonhosted.org/packages/a8/93/949053fb462eecc4a9a5ee770a81f4b40be7b79538b245545d4aebc6b58b/librt-0.15.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f5de7feedc56337a088eb15cd9fafa9938367362221d8cc62c642b7f94821993", size = 149833, upload-time = "2026-08-07T10:47:39.86Z" }, + { url = "https://files.pythonhosted.org/packages/61/ca/8281aa6cd560a3420e4497729f6b704b53be3eeaaef82d5aeadddaf7441f/librt-0.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c0eb900c0e91f4aebe680845242e614f1864edfd44106380d0752ac29522bf8", size = 154088, upload-time = "2026-08-07T10:47:41.065Z" }, + { url = "https://files.pythonhosted.org/packages/dd/02/1a1662dceaba6a086360891448d5ce9a7d3555976cae59a31a39d744b9c7/librt-0.15.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e8c9a650a188e38bac005048cbe6342e81407782944d01934540ab75e417df21", size = 494215, upload-time = "2026-08-07T10:47:42.388Z" }, + { url = "https://files.pythonhosted.org/packages/69/84/99211619dc656370a3740c33d2b0b6d5a3fb1e73689314f6ed477a397dc4/librt-0.15.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:92bfed8deec93df30286b9fe9e3b1dd17329cc076a192b4ee5ec223841d54953", size = 491173, upload-time = "2026-08-07T10:47:43.683Z" }, + { url = "https://files.pythonhosted.org/packages/d4/aa/5448d0b05f4579b635d3899176817ebf561af0e57bacd425b5b1887264c1/librt-0.15.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ec4b19788f835711a2072f9dbe6b03b3bf32ed1f0fb30cf399bdd59d9f0c33fa", size = 505512, upload-time = "2026-08-07T10:47:45.314Z" }, + { url = "https://files.pythonhosted.org/packages/95/82/01940e40b83c43a546c4a3c896cf34ca272a9690899d55914e4827b3dcce/librt-0.15.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d4c7bacb70930f3d0a56f4ecf1be474a1f0d941b01dd73b756f3c256d42cb879", size = 523073, upload-time = "2026-08-07T10:47:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/88/fa/759c0030f3ee371439eb26de34fc745807caf0abb878af7af4b8b7c3dd3d/librt-0.15.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e79f05e4a08b4d880342673312bbc895b56df7765605796f15902eb5367d3ae", size = 515080, upload-time = "2026-08-07T10:47:48.319Z" }, + { url = "https://files.pythonhosted.org/packages/0b/27/894e072228fcb159703c655da69f8cd10dbed489c36e3df7dd032a2483be/librt-0.15.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a417149c0cba4d50b61e992e5a15e69eaf96746609b461cc4ed168aeef6b79dd", size = 534164, upload-time = "2026-08-07T10:47:49.875Z" }, + { url = "https://files.pythonhosted.org/packages/98/a3/0078e91c1f36f8815db17827de15650b9a3fe56c55fbf998c854b34e40d3/librt-0.15.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:da7a94d6a3411f579d72aa3e3bc5fbca7ed4549f3dbd7e5de3aa567333374285", size = 540616, upload-time = "2026-08-07T10:47:51.408Z" }, + { url = "https://files.pythonhosted.org/packages/86/33/81a29b796dd52a45e9ef7974c7732926e8f10f15b8d2be505665979f896d/librt-0.15.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:856f743ae607f2c1380eccb566c0038a9fb3eabf0fc2be2704d76d9f73557239", size = 545890, upload-time = "2026-08-07T10:47:52.818Z" }, + { url = "https://files.pythonhosted.org/packages/05/82/8be1baa1350e5d30cfd70ae79d0a6f4dc5862ef47f7bb2808aabc9bb86e5/librt-0.15.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:779a6e7c894737e5983e7790a9c78c4000c30e23c9aada08081bdbea53b0fa60", size = 523287, upload-time = "2026-08-07T10:47:54.165Z" }, + { url = "https://files.pythonhosted.org/packages/c6/4f/d1be6a01a35c20ef734e0e44113f87d4af756a9354a89dcfbe3b4f8af5e1/librt-0.15.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:96bb17dbe8bab3c0954fbebfc69ed395599de75b6bbc35e3270a878e15d4dd65", size = 565868, upload-time = "2026-08-07T10:47:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/67/88/649cfa33f5825927b160610f670bdab012a64d627eddb94fa795ea4292fd/librt-0.15.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:7220697efaa6e5348fc3d18ee7f8563d4bfecd9872b37ffb915bfc1d08840622", size = 81619, upload-time = "2026-08-07T10:47:56.886Z" }, + { url = "https://files.pythonhosted.org/packages/22/31/8e88a8d5e48fc8d1a817787fb6811dfff6499acd6c8683dd83934aa6ede0/librt-0.15.0-cp314-cp314-win32.whl", hash = "sha256:f54598964d357b1c5ab77cf5d92f21e598fe0e23cdbe9618480807f81b4eba15", size = 100138, upload-time = "2026-08-07T10:47:58.093Z" }, + { url = "https://files.pythonhosted.org/packages/80/92/20fd6c4b6a1b1a564b076d55cd3d427d8428217d7638dc25a654cc4791d4/librt-0.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:3ff5893a2c23d886aa9ce786de5ac6ddc74aeeaf90743682b74d920e117d2e28", size = 121258, upload-time = "2026-08-07T10:47:59.564Z" }, + { url = "https://files.pythonhosted.org/packages/fc/28/6af430b44d9ebb897b865a3c363b6dcace51357be2347cc0f8f869656a86/librt-0.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:3722a099730704c9a3d70c879fc0f51daec25fe5f1555672d97bc595abeafb95", size = 106467, upload-time = "2026-08-07T10:48:01.097Z" }, + { url = "https://files.pythonhosted.org/packages/7e/aa/b42bb798942ced219f6d63b27e07f91237887a8d0bd0921666db79a13790/librt-0.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:38c0c7d4b6fc06c3324b3f9162c8391bfc4fd9dde53afe1033ce7edb48d5a714", size = 159523, upload-time = "2026-08-07T10:48:02.442Z" }, + { url = "https://files.pythonhosted.org/packages/75/03/1b53cd4ef904e73b1d828a5f90143bf94a2967d7cfff0b9ccf93e12aa9b4/librt-0.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8b2fdd7ead3c995c37940a790690660d0ca006c302db26cc51933f6766866fc3", size = 161638, upload-time = "2026-08-07T10:48:03.725Z" }, + { url = "https://files.pythonhosted.org/packages/ac/c4/9f9c9fba097d49e9e694c2b4dc331df31884645ecbc58a93b4b5fc69d2c5/librt-0.15.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2fde98cf1fc4bac144ce23c2c4c017b924ba714509ea9334977b0b27050c837d", size = 701795, upload-time = "2026-08-07T10:48:05.135Z" }, + { url = "https://files.pythonhosted.org/packages/4c/05/0966840bda0380c8ae167b9043c6230202941cc90ea29c48e096964c765e/librt-0.15.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:e3b461183c5fa7681b48560f91515f53a953122fb30c71e07abc67d7ddf58c38", size = 682147, upload-time = "2026-08-07T10:48:06.555Z" }, + { url = "https://files.pythonhosted.org/packages/18/af/1c47ca573c30ea47d195aec26133af522fea1104afaace028d7b32247ea8/librt-0.15.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4bbcc257e3babea20a91715c361b24554ec4e8f51aa578568afc230799fe1a19", size = 696397, upload-time = "2026-08-07T10:48:08.03Z" }, + { url = "https://files.pythonhosted.org/packages/2e/0f/1aed6223d4f9f9d1171a8596ff100ea4c3f7699fea7a4ba657c3e60daa6c/librt-0.15.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b845b8d48088fad0cadc84be4b8fda63203be7e9237b71015b3925443c1f35ab", size = 722542, upload-time = "2026-08-07T10:48:09.569Z" }, + { url = "https://files.pythonhosted.org/packages/c6/22/9e3a929aea456c97d69e6ef3884efea56d4807f97399471cc946baebd8af/librt-0.15.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b30e600e8f337b9bd7f39b86d9fdfedc73cc46e3d0f745931a23a234220bb7e2", size = 729709, upload-time = "2026-08-07T10:48:11.129Z" }, + { url = "https://files.pythonhosted.org/packages/e9/1b/c327ef6018e3a9ca0b8e7c5eddeeb331ba8f9b76c24e126d37d0f6d62faf/librt-0.15.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:64b0c8c35aa4c4ed79896359f3e0b285cbe4e610042106500da4811c322cc108", size = 752891, upload-time = "2026-08-07T10:48:12.558Z" }, + { url = "https://files.pythonhosted.org/packages/d7/d1/d5f1ea02c56930087009e39db9b70660a663e76c730b27b925d786718457/librt-0.15.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0da0d94cb802f32a0524653e7201f2cef72d5f700a5407678f5290483d4fcd08", size = 745301, upload-time = "2026-08-07T10:48:14.55Z" }, + { url = "https://files.pythonhosted.org/packages/d9/3c/5f7c585d15ebb2250c73e7c0ee4e9e47be72c65d520c07ddbcdc62037674/librt-0.15.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4a6369168d371207339b1e50d4532b06a7121586141f82599505a3f315751d47", size = 747921, upload-time = "2026-08-07T10:48:16.453Z" }, + { url = "https://files.pythonhosted.org/packages/7f/52/1443a446486eba966bcbca1696b472e4f210320ec42f490a47f48fbf0fdc/librt-0.15.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c434e072557ade9cbc642d052c89d031efe47d5c9614523619d0d74a02378e81", size = 727561, upload-time = "2026-08-07T10:48:18.089Z" }, + { url = "https://files.pythonhosted.org/packages/79/91/2270a9380f11725cf83ce1925a5e32dd1dde2be9bba597f25c10a38644e7/librt-0.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7eec6a42018bc1d45763b1c162d3d2bf7c3b9a1b0ed30d3e91dcba390efefcc", size = 774417, upload-time = "2026-08-07T10:48:19.611Z" }, + { url = "https://files.pythonhosted.org/packages/9e/3b/f4b1548d4f5b99186737fe27aec238e9823e8d5d23bf4df007c030689dc5/librt-0.15.0-cp314-cp314t-win32.whl", hash = "sha256:6912fa5e635d74529ac7cdb1bdf6ca3af4453da8d1edbe0110ee1cb4ad407ebf", size = 104381, upload-time = "2026-08-07T10:48:21.048Z" }, + { url = "https://files.pythonhosted.org/packages/80/b6/134afad262def1de04c0843c376d02135f1168af43f22e09a52bd8394727/librt-0.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8e11699ed745931c395acd3621b07062e0f840efa6935aad87a64ed0995f0915", size = 127034, upload-time = "2026-08-07T10:48:22.561Z" }, + { url = "https://files.pythonhosted.org/packages/99/5f/1b6846b20572bd699c9e9ec321a5f781845bee477df2aa2a43b28bc40119/librt-0.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:5d2a91724463bfed4f573cd7a9fdc856d2e230d0c0e5a61416a93481dccd8605", size = 110827, upload-time = "2026-08-07T10:48:23.804Z" }, + { url = "https://files.pythonhosted.org/packages/c6/44/4de9f4ddadb009a55c7758eb5736d62534a7daaf27bd71bc50e64b606b06/librt-0.15.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:8443e38dcfcfdbcf5add5118c623efd788d65ac2e25756d6251a54a06a4d0aca", size = 149843, upload-time = "2026-08-07T10:48:25.148Z" }, + { url = "https://files.pythonhosted.org/packages/1f/eb/5d9ab71e30119c44094e0275f38b47dd327aea0f843a080396677029d508/librt-0.15.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:6d15a29033c57490cfe2069097c6fc4049e4e65ffbb749be7dc453b7c4c68965", size = 154510, upload-time = "2026-08-07T10:48:26.485Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9c/8505d1b8f5e8c19587bd03f7429993b3e9ce5c06819d856bfb11d919374c/librt-0.15.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d2c05c729b589e734c09578bf5964be48a911765484840d017bbc84f49d4c4ad", size = 497543, upload-time = "2026-08-07T10:48:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/1d/9a/3a8390775cb095765aded027ac9c63e7c8ea74e731498607544c6505de0e/librt-0.15.0-cp315-cp315-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:fa60887537e1d0cd2d9982269d33a709bf54b195cd2b9364fc0a758022af5bd9", size = 480452, upload-time = "2026-08-07T10:48:29.531Z" }, + { url = "https://files.pythonhosted.org/packages/e7/40/258a4a7117ee915d66de5cd9b8ade65a440993161107ce3a686f1859955c/librt-0.15.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d8bc24219b24c0af375718942ab75e3544b2763085f40f965be4326734ae8328", size = 507768, upload-time = "2026-08-07T10:48:31.007Z" }, + { url = "https://files.pythonhosted.org/packages/6b/c6/2f4dd296c97a0b85b98894519b279408ec9dd602d4f692b1ea0e25dee670/librt-0.15.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:86a21a7bd3fe3a419512ef424cc1c020f6771d0b29cfddff36d1635a855e63f0", size = 525122, upload-time = "2026-08-07T10:48:32.7Z" }, + { url = "https://files.pythonhosted.org/packages/49/dd/29eab42be13b2bf0ea8cb227135a45d44693e30a7e8b92871981ff56b82b/librt-0.15.0-cp315-cp315-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dbab647e88d90b3167b91efe7091e248653688ed4337e4f90907a722c7361bb9", size = 520371, upload-time = "2026-08-07T10:48:34.294Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/4bad71adeca8fe208b775c2a35417fa5a2584c8f4791daaf89a89450fea1/librt-0.15.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:d8edcf6f550e918dca779c069b9e156385c60b406f99fc7641f32c52f7193659", size = 537258, upload-time = "2026-08-07T10:48:35.88Z" }, + { url = "https://files.pythonhosted.org/packages/4c/63/59dba6143fdcc7240c54458b629f3250000a61b8945890fc9efd451b19c5/librt-0.15.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:8b62076030baa2d8b1501a46bf0e19c27a489aa90671c55665bff7887f7660b0", size = 527432, upload-time = "2026-08-07T10:48:37.466Z" }, + { url = "https://files.pythonhosted.org/packages/ec/21/21a24c6a2327d8362580efebe77286bf47b0f4062ec5ea41766e609d3c7d/librt-0.15.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:d00d20d1818e82a07a0ee0aa89a98b17ed7916b92441090b683719cb20a59b6d", size = 548108, upload-time = "2026-08-07T10:48:39.384Z" }, + { url = "https://files.pythonhosted.org/packages/5a/6d/fc68c89a7971418b41f9a873623ff935cb864097544c6a2f8ce491c8ef5d/librt-0.15.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:4e6ee93fc3cf848dcbf0cce2eca73d8e7dcd0cc2b6df3a529d57750b30a4c55c", size = 529681, upload-time = "2026-08-07T10:48:41.392Z" }, + { url = "https://files.pythonhosted.org/packages/65/7e/c2d98766124400d722063a630b0fde38a9fc768705d37eecca15c47dc192/librt-0.15.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:32896a0af72508ea979e0acb4e4c04cbeeae04938167950d535c83c45597167d", size = 567736, upload-time = "2026-08-07T10:48:43.124Z" }, + { url = "https://files.pythonhosted.org/packages/55/6c/f8c34a95e3a515c6e1c192b89511e7253c89a7760c6b500d57ffdb8d2dc8/librt-0.15.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:ec3ba415afaf951f6951b1dd16d3c8e4f540065fc382d7e70b823a79567ca374", size = 81673, upload-time = "2026-08-07T10:48:44.645Z" }, + { url = "https://files.pythonhosted.org/packages/c9/9e/e23fa8e78679ec45728188650b39e8ff476c83b691c96f749217df3b1b7c/librt-0.15.0-cp315-cp315-win32.whl", hash = "sha256:d2813ba2503764f0450680c533d13df7cff9b49df1411062eded5f67db4195b9", size = 100081, upload-time = "2026-08-07T10:48:46.171Z" }, + { url = "https://files.pythonhosted.org/packages/e1/dc/3eb4c5e297343f0620a55532cd7c8d764d3001fa2159212dadf480464827/librt-0.15.0-cp315-cp315-win_amd64.whl", hash = "sha256:b87d67e33afaf265262f2a66db578284b88ee2e6fcd224579cb5c15518677ad8", size = 121228, upload-time = "2026-08-07T10:48:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/97/70/43abce19f04e49762f8ec834c8fafee13cc40fd6b94a72a24e534febfcd0/librt-0.15.0-cp315-cp315-win_arm64.whl", hash = "sha256:713bd7df21170b982e729e46870f31d6b437bd1a9b4648cffb529bd3c2ec5c4b", size = 106487, upload-time = "2026-08-07T10:48:49.095Z" }, + { url = "https://files.pythonhosted.org/packages/de/15/83f2deddb9368b8951ec8c9477269b5b9b8bd9bbf15e57402d0f38817dca/librt-0.15.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:3de789c82752730f94782a5ee518baf9c05edf85733aeaf73bb6e518755cdf54", size = 159448, upload-time = "2026-08-07T10:48:50.649Z" }, + { url = "https://files.pythonhosted.org/packages/06/bf/043097353f9b3c73b583d07f6b8e552795463f4bfc8caf85e42eee50c26a/librt-0.15.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:e0b5deec9a8664eb722c797241970fd4aa1894d25fda36a1ddac0f7407606bd6", size = 161686, upload-time = "2026-08-07T10:48:52.174Z" }, + { url = "https://files.pythonhosted.org/packages/f4/2a/8ae77f9719d42ce71cd708560a3557b38ac3c17a0383e57f87084de45bbe/librt-0.15.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5563302a8359bc2295bb7084d1a8ed1519df96afb30eb2aa4e0bff7b54228988", size = 710668, upload-time = "2026-08-07T10:48:53.782Z" }, + { url = "https://files.pythonhosted.org/packages/61/34/c0436ea134deb9a0d6da80a396a2739a81cb31e0418f7227239e23140898/librt-0.15.0-cp315-cp315t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:22d6263b9d39d7bbb286fa791945646e3218f1be2d693e36fb630f1d0e59cd13", size = 679396, upload-time = "2026-08-07T10:48:55.645Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9f/001e0d99aa9250d5cd5715a9081291a20656083459f9019cda15255329e1/librt-0.15.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:39ffd14646190c454f0d86e0d256b33f00a87a26ab410e619773b841d0e41416", size = 704313, upload-time = "2026-08-07T10:48:57.46Z" }, + { url = "https://files.pythonhosted.org/packages/2d/53/b34fa9d0ff00f136f4d58ebb4c411ff634baed1eb412bb602a2bc8dcafcb/librt-0.15.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c47318cd3a61401452de11282242937e3e057c4fd3dbaf601e269d0928a06c0a", size = 729847, upload-time = "2026-08-07T10:48:59.231Z" }, + { url = "https://files.pythonhosted.org/packages/86/ac/fa4d7a424665040e95baf480a6d523446057684b6758624c85338e8a23b2/librt-0.15.0-cp315-cp315t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a56a1d4f859a82ca5b99fc4b82c9b027b15e3c455c5cd99e7d0719f27bb20b6c", size = 742736, upload-time = "2026-08-07T10:49:01.151Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/e17a9bb5de6fb8c3186ed1a7d68d21618b027ac2d3633e03d3b6109c67ae/librt-0.15.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:077471b3182db4e17c36ae91555f36a4d2c00080b267f749bcad34a478a9a302", size = 763454, upload-time = "2026-08-07T10:49:03.039Z" }, + { url = "https://files.pythonhosted.org/packages/1d/ec/ecd02cd30935b931b9cdbfed6ab5a099c51b280b4e7baa274da80978ed27/librt-0.15.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:411ca4d1b905b860ceba7570dd6717a71dedaddcc4b0f77ece710aa41ee11f8d", size = 743296, upload-time = "2026-08-07T10:49:04.941Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b5/b3c2b8353ce820a4854f78d19321344242f89fa71c975b71132ba9bf242a/librt-0.15.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:1256589e0b0adb31751d685a68bce29d73407ddf4ef05d4188f49d5dcf9566d9", size = 756217, upload-time = "2026-08-07T10:49:06.825Z" }, + { url = "https://files.pythonhosted.org/packages/3c/52/6cc22542ba59146b05cca2a656f9ff8bb67e38e63d12c3b0cc183d837bf1/librt-0.15.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:f42b74a53e5f26a0ba0007411a7455b66c67ce4022a39cc1f56fc4efd65bcbab", size = 741934, upload-time = "2026-08-07T10:49:08.839Z" }, + { url = "https://files.pythonhosted.org/packages/40/32/a04b72b1aa86e3be23b2ecff8c1aad2dcc955bd3956d6d26e7e34267e57a/librt-0.15.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:291bf73caf78b9e88d6fae9bfd693207ff7d832e2fdbe2cf8e746bc13f5f892b", size = 783763, upload-time = "2026-08-07T10:49:10.661Z" }, + { url = "https://files.pythonhosted.org/packages/6c/f0/89eb11dffbe9279ff37144dec786927314502ae0b114f1449dc78c458aab/librt-0.15.0-cp315-cp315t-win32.whl", hash = "sha256:c16d15ee371643ab48dc8248a3e680ebbeca573a13af2c3dd0c985b142d77162", size = 104313, upload-time = "2026-08-07T10:49:12.305Z" }, + { url = "https://files.pythonhosted.org/packages/6d/4a/1f1978c200f563beda63c36adff2d65bbecb81e365e8e69e572f5f70fbc6/librt-0.15.0-cp315-cp315t-win_amd64.whl", hash = "sha256:dbd605739f228912dc49027cb764456b9757750bdc2b6b7773164db7096c6fd1", size = 126889, upload-time = "2026-08-07T10:49:13.881Z" }, + { url = "https://files.pythonhosted.org/packages/38/a6/800800bfed7b1fb10fc3f3d557785c3854e80d3f7a9800d784b176a1fc2d/librt-0.15.0-cp315-cp315t-win_arm64.whl", hash = "sha256:84d244b00604d17df3fc7736c327892d6bba66181254aa4087be807b6c342bdc", size = 110700, upload-time = "2026-08-07T10:49:15.499Z" }, +] + +[[package]] +name = "license-expression" +version = "30.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "boolean-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/71/d89bb0e71b1415453980fd32315f2a037aad9f7f70f695c7cec7035feb13/license_expression-30.4.4.tar.gz", hash = "sha256:73448f0aacd8d0808895bdc4b2c8e01a8d67646e4188f887375398c761f340fd", size = 186402, upload-time = "2025-07-22T11:13:32.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/40/791891d4c0c4dab4c5e187c17261cedc26285fd41541577f900470a45a4d/license_expression-30.4.4-py3-none-any.whl", hash = "sha256:421788fdcadb41f049d2dc934ce666626265aeccefddd25e162a26f23bcbf8a4", size = 120615, upload-time = "2025-07-22T11:13:31.217Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "msgpack" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/31/f9/c0a1c127f9049db9155afc316952ea571720dd01833ff5e4d7e8e6352dbb/msgpack-1.2.1.tar.gz", hash = "sha256:04c721c2c7448767e9e3f2520a475663d8ee0f09c31890f6d2bd70fd636a9647", size = 183960, upload-time = "2026-06-18T16:13:52.594Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/dd/9e8cbd8f5582ca4b590336f2b91ee5662f6a6ca562b565abaf696a0f81ff/msgpack-1.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2ef59c659f289eddf8aa6623823f19fa2f40a4029266889eac7a2505dd210c35", size = 83531, upload-time = "2026-06-18T16:12:58.249Z" }, + { url = "https://files.pythonhosted.org/packages/50/2e/ebdb85a8da151397a2790363676b7ed7c125924fe618e4c6d8befb0cc62c/msgpack-1.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d3567748a5107cb40cdf66a275430c2f87c07777698f4bfd25c35f44d533258c", size = 82657, upload-time = "2026-06-18T16:12:59.396Z" }, + { url = "https://files.pythonhosted.org/packages/26/aa/753ad8b007b464e1d8aa0c8e650b9c5f4f725e658fc5ac8a7635c55b7f6e/msgpack-1.2.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60926b75d00c8e816ef98f3034f484a8bc64242d66839cef4cf7e503142316a0", size = 410634, upload-time = "2026-06-18T16:13:00.383Z" }, + { url = "https://files.pythonhosted.org/packages/6a/fd/6adabd4f6d5e686f97dd02ce7fce3fe4cf672cbac36b8f67ff4040e8ad8b/msgpack-1.2.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:020e881a764b20d8d7ca1a54fc01b8175519d108e3c3f194fddc200bda95951a", size = 419989, upload-time = "2026-06-18T16:13:01.776Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cc/85039b7b0eb168aaad7383a23c97e291a11f08351cb45a606ce865e4e3f1/msgpack-1.2.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4202c74688ca06591f78cb18988228bd4cca2cc75d57b60008372892d2f1e6e6", size = 377544, upload-time = "2026-06-18T16:13:03.637Z" }, + { url = "https://files.pythonhosted.org/packages/ed/bf/35963899493b32030c85fc513b723ae66144ac70c11ebc52e889e16e3d99/msgpack-1.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8b267ce94efb76fbd1b3373511420074ee3187f0f7811bf394531de13294735a", size = 400842, upload-time = "2026-06-18T16:13:05.012Z" }, + { url = "https://files.pythonhosted.org/packages/a6/df/8e2ac970c8f99264cd9997d1c73df5466bc19da3301d7dc5500862a9b089/msgpack-1.2.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e4f1d0f8f98ade9634e01fb704a408f9336c0a8f1117b369f5db83dc7551d8b1", size = 374108, upload-time = "2026-06-18T16:13:06.232Z" }, + { url = "https://files.pythonhosted.org/packages/17/dd/fa8bd265110dfa51c20cb529f9e6d240a16fafe7e645004c6af2d01353ba/msgpack-1.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f02cf17a6ca1abe29b5f980644f7551f94d71f2011509b26d8625ce038f0df64", size = 414939, upload-time = "2026-06-18T16:13:07.478Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b9/8377a5ad8953fc0437c70cc98d9ae29f27fe5ac5109fbec0812085865735/msgpack-1.2.1-cp312-cp312-win32.whl", hash = "sha256:0c0d9802354507bcba62af19c17918e3eb437cc25e6f50657d511b5856a77aac", size = 64504, upload-time = "2026-06-18T16:13:08.822Z" }, + { url = "https://files.pythonhosted.org/packages/57/7f/ce1e377df7e62461fefd9eb23bfb93a4a523f40a517b377b8f844d836828/msgpack-1.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:5c24aa15d5963051e1a5c62b12c50cd705992502b5ec1f3bece6046f33c9fc24", size = 71421, upload-time = "2026-06-18T16:13:09.828Z" }, + { url = "https://files.pythonhosted.org/packages/8f/32/ebfe84c9929f08f188d56c7a2fd913406a9ddad76a634697c1c43b8112e6/msgpack-1.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:4227224aaec8f7fbcbfbd4272319347b2bb4030366502600f8c45588c5187b07", size = 64775, upload-time = "2026-06-18T16:13:11.056Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ac/dcddcab6f6c20ecb387ca5e980371cdb3f87ff69aeca388be97eebc4c074/msgpack-1.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0a70e3cf2804a300d921bb0940426e35f4e489a23adfb77a808892241db0a064", size = 83151, upload-time = "2026-06-18T16:13:12.173Z" }, + { url = "https://files.pythonhosted.org/packages/64/71/fbcfa83a1d6a9c6091942d1cfd070962244664b87427a9a49a6897b1b219/msgpack-1.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:491cc39455ca765fad51fb451bf2915eb2cf41192ab5801ce8d67c1d614fe056", size = 82351, upload-time = "2026-06-18T16:13:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/e3/10/ddf7b06db879e8792d13934ddda09ff20bd2a583fd84c9b59aae9b0e650b/msgpack-1.2.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f310233ef7fb9c14e201c93639fe5f5260b005f56f0b29048e999c30935596cc", size = 407518, upload-time = "2026-06-18T16:13:14.233Z" }, + { url = "https://files.pythonhosted.org/packages/79/d3/36a46a8ed992b781acbc05928bd5bee3c810cb0c3563bf81a7b0c04a1a76/msgpack-1.2.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:787c9bebb5833e8f6fc8abca3c0597683d8d87f56a8842b6b89c75a5f3176e2d", size = 416405, upload-time = "2026-06-18T16:13:15.435Z" }, + { url = "https://files.pythonhosted.org/packages/f9/84/e8e9598b557c0ba6ddae901a73780a4c75ac667dddf59414b1e56a42fb34/msgpack-1.2.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dc871b997a9370d855b7394465f2f350e847a5b806dd38dcc9c989e7d87da155", size = 376257, upload-time = "2026-06-18T16:13:17.022Z" }, + { url = "https://files.pythonhosted.org/packages/40/16/738fe6d875ad7e2a9429c165322a4ec088f4f273cdfae63d96a89c467961/msgpack-1.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:85f57e960d877f2977f6430896191b04a21f8901b3b4baf2e4604329f4db5402", size = 397469, upload-time = "2026-06-18T16:13:18.287Z" }, + { url = "https://files.pythonhosted.org/packages/ca/be/6d5952df75a7f24f35833af764c3a6860780364cb3a0030beb8099e1b2b4/msgpack-1.2.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:1233ee2dd0cefba127583de50ea654677277047d238303521db35def3d7b2e7c", size = 372802, upload-time = "2026-06-18T16:13:19.685Z" }, + { url = "https://files.pythonhosted.org/packages/e1/39/e2ef7dbf0473bcb8dc7c50bf782a892d67414877b63e47fc88eb189ef5e6/msgpack-1.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e3dc2feb0876209d9c38aa56cb1de169bd6c4348f1aa48271f241226590993e6", size = 411273, upload-time = "2026-06-18T16:13:21.028Z" }, + { url = "https://files.pythonhosted.org/packages/ef/c5/133f4512a56e983a93445c836c9d94d88f3bc2e0980ff4b9e577bd8416ce/msgpack-1.2.1-cp313-cp313-win32.whl", hash = "sha256:6d09badf350af2be9d189184e04e64cf54ad93569ab3d96fca58bd3e84aad707", size = 64471, upload-time = "2026-06-18T16:13:22.293Z" }, + { url = "https://files.pythonhosted.org/packages/e2/98/577e10b055096a7dd40732358cabaf7180a20c79ed1dcdbb618e4b9deac7/msgpack-1.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:33f14fba63278b714efe6ad07e50ea5f03d91537aa6a1c5f1ceca4cf44013ca9", size = 71274, upload-time = "2026-06-18T16:13:23.455Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ee/0c0048e7cfbef23c6a94791b8959ab28155232e7956de8a305b5ff588f05/msgpack-1.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:afc5febcd4c99effbc02b528e49d6fd0760b2b7d48c05239e345a5fa6e743d9a", size = 64795, upload-time = "2026-06-18T16:13:24.687Z" }, + { url = "https://files.pythonhosted.org/packages/77/58/cce442852c6b9e1639c7c8ac8fd9143121cb32dab0f308df4d1426a8eb9c/msgpack-1.2.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:05f340e47e7e47d2da8db9b53e1bb1d294369e9ef45a747441309f6650b8351d", size = 83610, upload-time = "2026-06-18T16:13:25.724Z" }, + { url = "https://files.pythonhosted.org/packages/60/5c/15b4c7a0182f75ffa90751958ba36a9c01cafee367d49a3edc10ed140b01/msgpack-1.2.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:810b916696c86ef0deb3b74588480224df4c1b071136c34183e4a2a4284d7ac7", size = 83138, upload-time = "2026-06-18T16:13:26.781Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a6/99e58722feaffc5f2fbcc0c8c0d1451ab9f84097f7af87291b46af2390f4/msgpack-1.2.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ca0dacff965c47afdc3749a8469d7302a8f801d6a28758d55120d75e66ce6889", size = 406090, upload-time = "2026-06-18T16:13:28.072Z" }, + { url = "https://files.pythonhosted.org/packages/19/03/8c63e8cf52958534ef688625965ab04c269a6cadd8caef16758b380a821a/msgpack-1.2.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e2bf9280bceb5efca998435904b5d3e9fdbcc11d90dc9df30aec7973252b720", size = 412106, upload-time = "2026-06-18T16:13:29.427Z" }, + { url = "https://files.pythonhosted.org/packages/63/d2/155d9e71b40e41fd934bc0c48b9b2770f22263e1ac20aad8e29fdca7be3f/msgpack-1.2.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa6c4be5d1c02a42b066ca6ddb71adf36432868fdcdb6ee87e634e86e0674190", size = 374851, upload-time = "2026-06-18T16:13:30.631Z" }, + { url = "https://files.pythonhosted.org/packages/98/48/deaf2326262a8d5ea3295ce9649912ecd3f551ba7ec8e33c665d2ba583f3/msgpack-1.2.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec0e675d59150a6269ddc9139087c722292664a37d071a849c05c473350f1f2d", size = 396168, upload-time = "2026-06-18T16:13:31.977Z" }, + { url = "https://files.pythonhosted.org/packages/10/2a/b4410f906c2ec0008f1608d3ab5143afc3ad3f4e6da0fed3ea2231d0bef4/msgpack-1.2.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:dd3bfe82d53edfe4b7fc9a7ec9761e23a7a5b1dac22264505af428253c29ed24", size = 371959, upload-time = "2026-06-18T16:13:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/59/86/1edc67270099a528fa2093ea60fe191233cd238e4bd30cfacf7db79fc959/msgpack-1.2.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5ad5467fc3f68b5468e06c5f788d712e9f8ffc8b0cd1bcb160c105c1ee92dae7", size = 408457, upload-time = "2026-06-18T16:13:34.567Z" }, + { url = "https://files.pythonhosted.org/packages/82/90/8b630fef07d8c5ab457b71ff2c217910c83d333c7a68472c186e87cc504a/msgpack-1.2.1-cp314-cp314-win32.whl", hash = "sha256:98b58bdb89c46190e4609bb36abe17c6d4105ad13f9c5f8f6f64d320f8ced3fb", size = 65942, upload-time = "2026-06-18T16:13:36.056Z" }, + { url = "https://files.pythonhosted.org/packages/16/f1/467b81e98b24dd3885d7b1857728797b4ffc76a7a7483af4fb321a07de3c/msgpack-1.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:74847557e28ce71bd3c438a447ca90e4b507e997ddbdef8a12a7b283b86c156b", size = 72627, upload-time = "2026-06-18T16:13:37.079Z" }, + { url = "https://files.pythonhosted.org/packages/a7/1d/5d8c4c89985feb6acefb82a09e501c60392261856d2408d20bfe4f0360b1/msgpack-1.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:b50b727bd652bdc37d950336c848ef20ec54a4cafc38dce19b1cd86ad625d0f7", size = 66908, upload-time = "2026-06-18T16:13:38.23Z" }, + { url = "https://files.pythonhosted.org/packages/1b/02/ad2afb678b4de94496cd432b581759b756a92c1192d8c767edd6b132efdc/msgpack-1.2.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8d00f177ca88a77c1cf848d204a38f249751650b601cb6532acc68805d8a8273", size = 86000, upload-time = "2026-06-18T16:13:39.44Z" }, + { url = "https://files.pythonhosted.org/packages/54/74/0b797484013128837f3b1cbb6cea019277c4de4e377dc512b4d9a0f92940/msgpack-1.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5bb9c386f0a329c035ddbab4b72d1028bf9627add8dda41070288563d57ed1b1", size = 86544, upload-time = "2026-06-18T16:13:40.447Z" }, + { url = "https://files.pythonhosted.org/packages/a9/b4/b774d7eb95561739907fec675582f83203cf41c597a418c2589b4bfb8e9d/msgpack-1.2.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20466cca18c49c7292a8984bc15d65857b171e7264bdcb5f96baf8be238791fc", size = 427661, upload-time = "2026-06-18T16:13:41.574Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f9/3243191dc9937e00756c8bc1b0272fed8f23758e43df2a3b46f533e5090f/msgpack-1.2.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:196300e7e5d6e74d50f1607ab9c06c4a1484c383cd22defd727902591f7e8dde", size = 426375, upload-time = "2026-06-18T16:13:42.936Z" }, + { url = "https://files.pythonhosted.org/packages/23/c7/1693111db9944ba4ad4b67a1e788400d78a0b6af7a6523dc7e4e58f8274b/msgpack-1.2.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:575957e79cd51903a4e8495a242442949641e08f1efd5197b43bebd3ea7682b4", size = 380495, upload-time = "2026-06-18T16:13:44.306Z" }, + { url = "https://files.pythonhosted.org/packages/3e/2b/92f86956a0c13e8662f7e2ad630c4eb4db07497b967589bd5245e018b2c1/msgpack-1.2.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8c2ed1e48cc0f460bf3c7780e7137ff21a4e18433451916f2442c1b21036cd7d", size = 410897, upload-time = "2026-06-18T16:13:45.629Z" }, + { url = "https://files.pythonhosted.org/packages/da/ea/1479f72d200313a76fc2f823a79d1e07ed052ab7b8a0280640aa7b95de42/msgpack-1.2.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5f6277e5f783c36786a145e0247fc189a03f35f84b251646e53592d2bc12b355", size = 378519, upload-time = "2026-06-18T16:13:46.998Z" }, + { url = "https://files.pythonhosted.org/packages/f5/4d/fa006060ffa1011d32bfae826fe766fe73e02982183601633b7121058ab3/msgpack-1.2.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f9389552ecf4784886345ead0647e4edc96bee37cbab05b75540f542f766c48c", size = 419815, upload-time = "2026-06-18T16:13:48.205Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/aab6c946570496b78e67804721f3d5e2d62a93081b9b37df77764ef56347/msgpack-1.2.1-cp314-cp314t-win32.whl", hash = "sha256:c1c79a604a2969a868a78b6ebd27a887e00c624f14f66b3038e0590cb23332d1", size = 70914, upload-time = "2026-06-18T16:13:49.385Z" }, + { url = "https://files.pythonhosted.org/packages/13/0a/e608956488a2af014cfe6e3d665e090b8ee42aa14b07f8f95b8880d66b09/msgpack-1.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f12038a35fabd52e56a3547bab42401af49a45caa6dd00b34c44de235bc93ee2", size = 77999, upload-time = "2026-06-18T16:13:50.467Z" }, + { url = "https://files.pythonhosted.org/packages/d2/8a/27e2e57055176e366a46b85d02d68e7a5bcfbdd8474c9706375d965f24d3/msgpack-1.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:0adcf06ffde0777c0e1a9b771a2b1c4226ba1bbf748c8efcc02fcdeca3299107", size = 71160, upload-time = "2026-06-18T16:13:51.498Z" }, +] + +[[package]] +name = "mypy" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ast-serialize" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/6a/878cc1097d4035f82bd516658d0c528d2a9955bc7b363afcbd0b07fea11b/mypy-2.3.1.tar.gz", hash = "sha256:47c1b1207258513a9d93495f69c8be9de73916186f0e52703e8c461b7a623419", size = 3992554, upload-time = "2026-08-15T03:03:38.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/da/d6effc4f808a842d91edc22535dc9e799d2ff6e91449168b7f47a0771f54/mypy-2.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a32bbbb940af990d3be0b8af321c7b6815bb1b3b48142fe7459b9cc5f58959ff", size = 14047547, upload-time = "2026-08-15T03:02:57.707Z" }, + { url = "https://files.pythonhosted.org/packages/e4/e6/478229701dab76f26485fc8ff5d6f241f393da22447400bbc56f6946aebe/mypy-2.3.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff715e45b2231a8e85de1d163d1b42791e4d7aab8f5145f85fee1b710b735aff", size = 14216515, upload-time = "2026-08-15T03:01:26.496Z" }, + { url = "https://files.pythonhosted.org/packages/8d/fe/7c42327a3b21e84681f691982cbfe43f334a3685f3b683b72c376476c4fa/mypy-2.3.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:858fc57d3d91fa728e33e7ad71def60fc6272694607b306cd3292db53ae39080", size = 15307789, upload-time = "2026-08-15T03:03:31.62Z" }, + { url = "https://files.pythonhosted.org/packages/59/f4/7e597edbe01b5a56fa958ce541302dcaabfed979966f1dffedbea0ea0fc2/mypy-2.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:851833db876e7b650f93719c74b7879a08e338979c96054fdfc3bfd90a486355", size = 15548831, upload-time = "2026-08-15T03:03:15.55Z" }, + { url = "https://files.pythonhosted.org/packages/a3/52/cb31e084bc0314a1e384bdd677a4b80e55af04ccac077545e2238b9d320a/mypy-2.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:4c5095a327483591c94e0c8d3ef9e50d4ab1369b541eae007c1f23bc2a41f6bb", size = 11226359, upload-time = "2026-08-15T03:03:29.002Z" }, + { url = "https://files.pythonhosted.org/packages/7a/47/88fcf6217b43fa2da81a8c2611370af18141536a4f0294bbf98b457d456d/mypy-2.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:bbfe022634a2a195406bd469e888d2eaf193b02ba7e607391cd7640374aaae3b", size = 10214707, upload-time = "2026-08-15T03:02:48.807Z" }, + { url = "https://files.pythonhosted.org/packages/de/cf/862010ee800ca9c2bd0c4c0dacf0f092e5411824a09b8f97ad4be8fe250e/mypy-2.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:114dff494000f18bd10d5d95d84b8567b26da60279ecbe838131841df20e635d", size = 13964542, upload-time = "2026-08-15T03:02:21.43Z" }, + { url = "https://files.pythonhosted.org/packages/75/5a/3f3a2107b41e3e92e617e25daaee121413b91e9784bea733131ed4fecc5d/mypy-2.3.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8637731bb5eee3671eb2c3200827aa3564ed8a9309ecee4d1afe77e6d031bdb", size = 14168922, upload-time = "2026-08-15T03:03:00.351Z" }, + { url = "https://files.pythonhosted.org/packages/8b/41/04dc4fe7e63d7820fa4eff272e95157d30cbea921388f3ab3fe77794cd0b/mypy-2.3.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c80fbc405ed8020f5ff3802dc18cf060197bcdd3fbdd6a26ef2fd34dfdd5226", size = 15244791, upload-time = "2026-08-15T03:02:31.089Z" }, + { url = "https://files.pythonhosted.org/packages/96/fc/c3053b26b9054949285aa868cb6af8c10e7591541cacd79c5dcc06a1fcf9/mypy-2.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:84081f538ce27375045c02e3d7f81bd11d853400621ae245d87ce7b6c420ec74", size = 15501627, upload-time = "2026-08-15T03:03:34.128Z" }, + { url = "https://files.pythonhosted.org/packages/70/4e/d77daab008bbc4e5001374d7928f4a260d28f0e6747af444fc4763f7a310/mypy-2.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:e9144ac16fde007096f9563eb2041b4433c2d705c4218edeb79e7e9d01035ee6", size = 11243961, upload-time = "2026-08-15T03:02:11.952Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f8/7eb68c136e4abd30569fe31ef2bfcb7eceae9952cab80017c04cd09f5d0c/mypy-2.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:77ad9529e67dca28e511f5cd5671436584ce91f6d3bac159a353158187b986ac", size = 10213219, upload-time = "2026-08-15T03:02:26.361Z" }, + { url = "https://files.pythonhosted.org/packages/be/c4/42a49d44aeff804edf1b19acce0b49e8bd1a9c57dee9605dd8d980aa43d7/mypy-2.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:192abaedf75da1bc0b1cef104927e70ec49c1ef0031cc4825c7ee10a438ed24d", size = 13986778, upload-time = "2026-08-15T03:01:33.69Z" }, + { url = "https://files.pythonhosted.org/packages/45/13/9331fd2dfed7194d66c5304072894a8be3e51e9deda6863c1eceaa35a43d/mypy-2.3.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf678dffd16efcda2c15cbd30e9ecc0081388e29ea23687a88e686ed92638dc3", size = 14188467, upload-time = "2026-08-15T03:02:40.554Z" }, + { url = "https://files.pythonhosted.org/packages/78/f7/f4a34edab45667c5465855dc585a20e87978ffa8aee711445b7239d120c6/mypy-2.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e036f06b41630f4c8a1d48f9ac6aa26acc65f8be089973f5519da643318f03f", size = 15225538, upload-time = "2026-08-15T03:03:09.761Z" }, + { url = "https://files.pythonhosted.org/packages/40/05/534b3590757bd05794f73e07f6666c2a77b8597ffed795c94ce570096aa0/mypy-2.3.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:71af9c8a894e862b58e92abb08e53b05a384a1e5e5d6dc7cda59126211a53d82", size = 15480805, upload-time = "2026-08-15T03:01:41.134Z" }, + { url = "https://files.pythonhosted.org/packages/55/da/bdfba852e2562f599624af5bb7d29e36b0b4f526f2b8bac85efe0dd1803d/mypy-2.3.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:3c80cd23d85368bdd9f37d5231dfd97d35bcbf5bf41af96ef3a9b078ad1957f9", size = 7761712, upload-time = "2026-08-15T03:02:36.008Z" }, + { url = "https://files.pythonhosted.org/packages/98/31/60fc64a74cdba4f2a5d642d32317993e479163e1ac7d91b695e5d15e2264/mypy-2.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:4956f34d145e145562a0a0bf367f642bbc85c04ec2baf47ae015947c3169a85d", size = 11423968, upload-time = "2026-08-15T03:02:06.931Z" }, + { url = "https://files.pythonhosted.org/packages/a9/23/eb5950b24cd26ba3b78f87707a275568d633c77dae8e61c9661be6055ca6/mypy-2.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:cfb12e360242d23d91f5e978d94f58ea66acf5804c4fb6f2f794a20d4cb1b595", size = 10399323, upload-time = "2026-08-15T03:02:33.671Z" }, + { url = "https://files.pythonhosted.org/packages/82/c7/f80f4e46c0b9a00eb5f78a79d49dda8bdf56a5230f7257fb33e76be04da7/mypy-2.3.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5f1c50bb05b64e2026b52867e8d21106f01313c744a2c4ecc34c90d12e8d6e2", size = 15121308, upload-time = "2026-08-15T03:01:46.053Z" }, + { url = "https://files.pythonhosted.org/packages/5d/74/9b04f17c7074cc5188f02fb63a2ca1d43fedf479e84fe3091c39061a1d7f/mypy-2.3.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:667196b352f4cf304ded4c10f90cfc179263a1acfb3cdcfa984bdfd340d498bc", size = 15536590, upload-time = "2026-08-15T03:01:35.941Z" }, + { url = "https://files.pythonhosted.org/packages/26/04/c837ef6208e567774e2ed1f863f8ba6ec4817b1b6dd426315e5d559b6ec9/mypy-2.3.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9c53e395c12cad2c6d4b67d5da7c6057638a132d85c08b73646b18f802a0045", size = 16791074, upload-time = "2026-08-15T03:01:31.073Z" }, + { url = "https://files.pythonhosted.org/packages/37/68/48730230afa45192d5bd429a6a2ff24a6f8dedda90fdf2b221792b54518f/mypy-2.3.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:18162b128c3f9c703cd35f5537446900b0d21a2549aa7a95d21380d2ef643fb0", size = 17069183, upload-time = "2026-08-15T03:02:28.566Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ea/ca23fc9c20eeda09a15c9cbcf50015d0e73f409f6ead059e42aa69a608ff/mypy-2.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:30c0477d4aab7b7f39c8397dc877f2c96b9fe5588ec379f372c56eb63d599f63", size = 12154679, upload-time = "2026-08-15T03:02:04.809Z" }, + { url = "https://files.pythonhosted.org/packages/3b/67/8d982126034990869466f73b8db80dcb2234a7ac39b4dad093e047a79835/mypy-2.3.1-cp314-cp314t-win_arm64.whl", hash = "sha256:6941ab3619377bc3f32ca02876b07d27f216f5201604b664d3937ea0fdd23bb4", size = 10969159, upload-time = "2026-08-15T03:02:38.152Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f7/41e7f2d8117fbc7a7587286162ffe2f688984b69c46ed63cf5f2e4fc3bae/mypy-2.3.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:6f041a6de52c9217ca125e78ba0a335cb7fd98a1c0580978e49ab2b126f70b57", size = 13990694, upload-time = "2026-08-15T03:03:21.919Z" }, + { url = "https://files.pythonhosted.org/packages/06/85/8f665811a0c8f3bf6fa1d9acd665ec2d97a2bcc453ae68dcd92340941cd6/mypy-2.3.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5159ae60f5dbc3a498af5ba8365505808ac8031bc63f9e00304ad545d40bdd9b", size = 14203518, upload-time = "2026-08-15T03:01:48.455Z" }, + { url = "https://files.pythonhosted.org/packages/2d/82/91b866c8546b120bff83b73a439d90d2d63ef3aff113599e6b8e4d566848/mypy-2.3.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47a8a7a0a7f6f6e63995c0ac36fa0c07b127413fdc81f0439b7f3dccafd33561", size = 15220224, upload-time = "2026-08-15T03:01:23.577Z" }, + { url = "https://files.pythonhosted.org/packages/c8/78/c226c99208ee40de7c768369fa533f933afa003dfdc606ff021450724e91/mypy-2.3.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2329c0501293d4e1f33bc15d04d6304d65a1cdda967ee93a05c1e681a3923133", size = 15501512, upload-time = "2026-08-15T03:02:09.453Z" }, + { url = "https://files.pythonhosted.org/packages/a9/e7/7cfb3f106c393979f4cc37ad6c0586044d50401e3c35b0c003e4f3ba6bc9/mypy-2.3.1-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:bb26deed807bdb0457cf3e3f1cd7c4a1cf9d66864eaf1b4a61e06805d4c6b1f9", size = 7761913, upload-time = "2026-08-15T03:01:55.65Z" }, + { url = "https://files.pythonhosted.org/packages/99/3c/52affefa273b97939a1f474ae4a349c8718635c15b941112dfab4291b0c1/mypy-2.3.1-cp315-cp315-win_amd64.whl", hash = "sha256:375d7013876a8233b2d05be185bfa09f689696cd999ce8b1cfe6acac5c80e8a3", size = 11422533, upload-time = "2026-08-15T03:03:24.101Z" }, + { url = "https://files.pythonhosted.org/packages/2a/b7/75643e70c72a5b346d8a9b1543c967ea8824df2ee3fb7ccba652c272b7bb/mypy-2.3.1-cp315-cp315-win_arm64.whl", hash = "sha256:586b3612214cceabb3c0f588c97e7d1e535393f06a60e912e994f6b3ace97523", size = 10397931, upload-time = "2026-08-15T03:02:55.265Z" }, + { url = "https://files.pythonhosted.org/packages/10/ce/53be21f2d4adfcd26f63f1184a13ed797015ab463853f117e2e11e4d726f/mypy-2.3.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:ef0c6335cda9d807f8193d8ff6204a72bc909fa9882aacbca14f43cdb7188306", size = 15118669, upload-time = "2026-08-15T03:02:51.479Z" }, + { url = "https://files.pythonhosted.org/packages/62/43/20de757cd42989d291a17fad607742c4c74e875ce5cea00e5a5225020ac1/mypy-2.3.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e598c8c66401d26b150872154a286e6d484cf2789c3bb28a7556806298423021", size = 15545627, upload-time = "2026-08-15T03:03:05.132Z" }, + { url = "https://files.pythonhosted.org/packages/7e/fc/092bdf77ad280eaf501422f0f3b966012b528076cc13e41a774861c907d1/mypy-2.3.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eda22fd4efa9dcd39331d1dede9b5b8b8a7fd69af07592e778433da98610d29e", size = 16764157, upload-time = "2026-08-15T03:02:23.958Z" }, + { url = "https://files.pythonhosted.org/packages/94/5c/c94c4d62d909b07f552d0d9356d7acc943825558e602a64822ffa2231536/mypy-2.3.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:2a0ba2e57847849fb0d1fcdabb32786d223095ed8bc121dfe322bcdb3d9c46bc", size = 17073258, upload-time = "2026-08-15T03:02:14.573Z" }, + { url = "https://files.pythonhosted.org/packages/c0/f7/511a88b89e478053c02d22039bb8f3ce4183efe8fd7a4f0a5910a8bb0a32/mypy-2.3.1-cp315-cp315t-win_amd64.whl", hash = "sha256:3f7e865dd51f235f60a2dbcd8728a1c095f5ca28f095d48a725b84cd935735c4", size = 12135505, upload-time = "2026-08-15T03:02:16.714Z" }, + { url = "https://files.pythonhosted.org/packages/71/bf/02573b56964ecb0f7c644f915f53c325ae15c3faec521c5adf11599a32df/mypy-2.3.1-cp315-cp315t-win_arm64.whl", hash = "sha256:8ad80807dc3ab8ea978b1b2b6e4a657194ace1d4ef03e0e731aff1abd517da29", size = 10962647, upload-time = "2026-08-15T03:01:43.712Z" }, + { url = "https://files.pythonhosted.org/packages/8e/41/9675c7a1e78edecfba0b79e587a52594c56e189368261dc7b3a7fffb9527/mypy-2.3.1-py3-none-any.whl", hash = "sha256:6ed5c7e3419083268e5c9258bd1c1ef91af44a9e89374dbcaf37b775716e72eb", size = 2754338, upload-time = "2026-08-15T03:02:53.4Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "packageurl-python" +version = "0.17.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/d6/3b5a4e3cfaef7a53869a26ceb034d1ff5e5c27c814ce77260a96d50ab7bb/packageurl_python-0.17.6.tar.gz", hash = "sha256:1252ce3a102372ca6f86eb968e16f9014c4ba511c5c37d95a7f023e2ca6e5c25", size = 50618, upload-time = "2025-11-24T15:20:17.998Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/2f/c7277b7615a93f51b5fbc1eacfc1b75e8103370e786fd8ce2abf6e5c04ab/packageurl_python-0.17.6-py3-none-any.whl", hash = "sha256:31a85c2717bc41dd818f3c62908685ff9eebcb68588213745b14a6ee9e7df7c9", size = 36776, upload-time = "2025-11-24T15:20:16.962Z" }, +] + +[[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 = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "pip" +version = "26.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/15/4500e320e6b101ec3b719ae85b697d9940b6cda672bc555bd6016fc60c6f/pip-26.2.1.tar.gz", hash = "sha256:f6ad667e89a1fe78046c8f13232b247200f5258d7828f3f7883d660878e0813f", size = 1848877, upload-time = "2026-08-04T22:51:14.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/6e/1736e5b4ae2b778ef2f81c47d797de9f891d4d8acb047a24ca37a60294dd/pip-26.2.1-py3-none-any.whl", hash = "sha256:71138adf1f4ca900cdb7d289c21b7494329f2332b6d85f0e1c42108c0384ed3e", size = 1816632, upload-time = "2026-08-04T22:51:12.472Z" }, +] + +[[package]] +name = "pip-api" +version = "0.0.34" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pip" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/f1/ee85f8c7e82bccf90a3c7aad22863cc6e20057860a1361083cd2adacb92e/pip_api-0.0.34.tar.gz", hash = "sha256:9b75e958f14c5a2614bae415f2adf7eeb54d50a2cfbe7e24fd4826471bac3625", size = 123017, upload-time = "2024-07-09T20:32:30.641Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/f7/ebf5003e1065fd00b4cbef53bf0a65c3d3e1b599b676d5383ccb7a8b88ba/pip_api-0.0.34-py3-none-any.whl", hash = "sha256:8b2d7d7c37f2447373aa2cf8b1f60a2f2b27a84e1e9e0294a3f6ef10eb3ba6bb", size = 120369, upload-time = "2024-07-09T20:32:29.099Z" }, +] + +[[package]] +name = "pip-audit" +version = "2.10.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cachecontrol", extra = ["filecache"] }, + { name = "cyclonedx-python-lib" }, + { name = "packaging" }, + { name = "pip-api" }, + { name = "pip-requirements-parser" }, + { name = "platformdirs" }, + { name = "requests" }, + { name = "rich" }, + { name = "tomli" }, + { name = "tomli-w" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/a4/f21d5f0a0edabcbce31560b73c7c5a6f72ae87af4236fd1069c8f59a353d/pip_audit-2.10.1.tar.gz", hash = "sha256:1eb4565d19ebe5d48996f4b770b4d2b32887e12cb12cfa637f1a064011b55ffc", size = 54275, upload-time = "2026-06-10T22:17:01.744Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/a7/b0c504148114047bd1bc9d97447453c6850ca176bb2f3c0038835994e8b7/pip_audit-2.10.1-py3-none-any.whl", hash = "sha256:99ef3f600a317c1945f1e89e227ef26e1c2d618429b8bd3fa6f4f7c440c4611a", size = 62023, upload-time = "2026-06-10T22:17:00.309Z" }, +] + +[[package]] +name = "pip-requirements-parser" +version = "32.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "pyparsing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/2a/63b574101850e7f7b306ddbdb02cb294380d37948140eecd468fae392b54/pip-requirements-parser-32.0.1.tar.gz", hash = "sha256:b4fa3a7a0be38243123cf9d1f3518da10c51bdb165a2b2985566247f9155a7d3", size = 209359, upload-time = "2022-12-21T15:25:22.732Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/d0/d04f1d1e064ac901439699ee097f58688caadea42498ec9c4b4ad2ef84ab/pip_requirements_parser-32.0.1-py3-none-any.whl", hash = "sha256:4659bc2a667783e7a15d190f6fccf8b2486685b6dba4c19c3876314769c57526", size = 35648, upload-time = "2022-12-21T15:25:21.046Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.11.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b8/d7/e7bfbc86e9f99ff7807e24de7703f032e9c9ba80bb355cf26e0e9bc5a75e/platformdirs-4.11.3.tar.gz", hash = "sha256:66a73d38a849810252df809a3d8bcbda8e26f6c189920e7535ad608a48dbb5ab", size = 33050, upload-time = "2026-08-13T22:43:27.52Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/a9/c34aebedd3a4c9afe5101b1b8713710b3fec18087c8a36c35d2f909861bd/platformdirs-4.11.3-py3-none-any.whl", hash = "sha256:5ed065d443751de711da036041a7a214122efc4a4de393b3f4137ba5576540e7", size = 23491, upload-time = "2026-08-13T22:43:26.121Z" }, +] + +[[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 = "py-serializable" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "defusedxml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/73/21/d250cfca8ff30c2e5a7447bc13861541126ce9bd4426cd5d0c9f08b5547d/py_serializable-2.1.0.tar.gz", hash = "sha256:9d5db56154a867a9b897c0163b33a793c804c80cee984116d02d49e4578fc103", size = 52368, upload-time = "2025-07-21T09:56:48.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/bf/7595e817906a29453ba4d99394e781b6fabe55d21f3c15d240f85dd06bb1/py_serializable-2.1.0-py3-none-any.whl", hash = "sha256:b56d5d686b5a03ba4f4db5e769dc32336e142fc3bd4d68a8c25579ebb0a67304", size = 23045, upload-time = "2025-07-21T09:56:46.848Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +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/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, +] + +[[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 = "pyparsing" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, +] + +[[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-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.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/53/ed9d74092561d4b01a2ef1349d52cdbc135e526c245f366b089cfca6de49/python_dotenv-1.2.3.tar.gz", hash = "sha256:a20a594dabeaa385725aa239d5244871c143ecb356add8a20fcf23773a6c3a35", size = 58945, upload-time = "2026-08-16T16:54:54.067Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780, upload-time = "2026-08-16T16:54:52.473Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, +] + +[[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 = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/00/8f/d8074b1f25e003164087a8bfe79a0f1a3945135764dbb6aaab04103dcaf9/ruff-0.16.4.tar.gz", hash = "sha256:13171aa9d9af2240ee3504e639de73122c67e74036de5ba2e1d01422cd17e3dc", size = 4899731, upload-time = "2026-08-20T17:43:59.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/80/779895ef584e089d22f2c6df0d0e99a65ec2df0805f1fffd439415b8c1f0/ruff-0.16.4-py3-none-linux_armv6l.whl", hash = "sha256:df4075f71ddac40b9934af60c3ec8a53047dd5a5fdc43224e6e4e8e9a27cb6f7", size = 10006909, upload-time = "2026-08-20T17:43:16.888Z" }, + { url = "https://files.pythonhosted.org/packages/a9/e6/f553199b5e8927a05cb5c422d921fd0656b29ab976e91c44802107c6b0da/ruff-0.16.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0c95538517af68004306b0fb3214ff2f2af67a65092aee77cd9eb86db6656604", size = 10240201, upload-time = "2026-08-20T17:43:19.337Z" }, + { url = "https://files.pythonhosted.org/packages/1c/70/4a6dc4bb34da4dee35e30f09bbd1bfbdd26f33b62fb9b8df31f08a199cd2/ruff-0.16.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:963f83df8e69e575b64d67dd447ebbc917db41a14bf38d4593a4183e7aaa8255", size = 9835122, upload-time = "2026-08-20T17:43:21.708Z" }, + { url = "https://files.pythonhosted.org/packages/24/12/c6e22d686372c15bcb7af99831f1a1be96df696491babf4f24e4f942c527/ruff-0.16.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32a5057c7ff3f6e6480a48fccfb3a412a690f48a3d03ac5cf08177d6c2da3ade", size = 9977162, upload-time = "2026-08-20T17:43:24.236Z" }, + { url = "https://files.pythonhosted.org/packages/46/49/72b10ec912f5ab5854992eaf7aa7cd36729b6937d9dc4e0fb41b3bf428ec/ruff-0.16.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b3dce8d9b0c57c265b91885a66a567d8ea1372e8eb4e250fa8e5e3f579e99cff", size = 9829789, upload-time = "2026-08-20T17:43:26.966Z" }, + { url = "https://files.pythonhosted.org/packages/fa/80/0f30e32e7f6ee26edc39075502db9d368d788a44a79b55f763eb4ab03796/ruff-0.16.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7dc651db49283c69f8e72c834eec4fe5573e4c646856aebece0ce385dceb2a80", size = 10527949, upload-time = "2026-08-20T17:43:29.384Z" }, + { url = "https://files.pythonhosted.org/packages/52/3d/86e8ad3542169e56cac3859a343afdb9df2ad54d35a59ce1e67baee83421/ruff-0.16.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3817b87dbcabc92f13b05019257c5b89b5b4d51b5fb20f56fb5235ceb723cd07", size = 11333695, upload-time = "2026-08-20T17:43:31.872Z" }, + { url = "https://files.pythonhosted.org/packages/d0/16/481c29b380c20a0054a8261066665e1b3488e23636c49d0a43e75975b9bb/ruff-0.16.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e9fce1499134b2c8c68e5166f95705a5812062bb93aacc5f9873bb1a27084bc7", size = 10727741, upload-time = "2026-08-20T17:43:34.596Z" }, + { url = "https://files.pythonhosted.org/packages/5e/b6/56bc0b8cf45b54b28b3a5e6381c8945d51b5b18adf659454c32295209a31/ruff-0.16.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2d812e482f5a7e02eee26cd73d2a37ebbdf47d795ea63ba1b89110ae93e9fb3", size = 10286522, upload-time = "2026-08-20T17:43:37.288Z" }, + { url = "https://files.pythonhosted.org/packages/e8/8b/b345b4fb110f2fbe2bd31eabd271e5e8b3b7e4ee6c0e02f2dc6be78db000/ruff-0.16.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:6baaf984aa7976edf93d3b627fe2d1d22ee94bbca05fa6f90fc76d73924e3454", size = 10584182, upload-time = "2026-08-20T17:43:39.984Z" }, + { url = "https://files.pythonhosted.org/packages/29/e5/827b34041c35f58774a9681a4213994c164fc987800f4dddabcf451da0bf/ruff-0.16.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:bdfcf0b28662eb890372d50f92c283bb94e67e7635ed93c7fd533970acff7b2b", size = 10134195, upload-time = "2026-08-20T17:43:42.351Z" }, + { url = "https://files.pythonhosted.org/packages/0f/10/d0bffcdd6729b87afc82ba0ef377173356a7dc8e972f5179968cf2fdf98c/ruff-0.16.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b66b02cb9b04f537643cadf5768e5f98dc461890d530cb67113d71c8c76e605d", size = 9825821, upload-time = "2026-08-20T17:43:44.532Z" }, + { url = "https://files.pythonhosted.org/packages/f5/32/0db2a863b796ca62d83e92a07a3ccf00921b14db02059347576a2fda3d4b/ruff-0.16.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:8528bf9a4b291a60bf02ea453511e8ce6215bd2b982ee80405b66b008b6c30a0", size = 10267658, upload-time = "2026-08-20T17:43:46.989Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a0/fbdeb59e48c6261f523e56c8f12e9c08fbe693786595cc7e3959207a9232/ruff-0.16.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:fbd85d2875fdd67e833213a651f613bbf25303abf6aa822a5121f4531195678d", size = 10697071, upload-time = "2026-08-20T17:43:49.891Z" }, + { url = "https://files.pythonhosted.org/packages/aa/28/0c6dd865859c6d17bc8ccc34cb72b0e02d6c7eb25e8a1e22b5bea681e2c0/ruff-0.16.4-py3-none-win32.whl", hash = "sha256:312769988007aaeb8e189b443ccdd03c0e6374489e053467be6d96518ebff76e", size = 10021687, upload-time = "2026-08-20T17:43:52.281Z" }, + { url = "https://files.pythonhosted.org/packages/a3/03/e724450f621698117f9aa6dd241c94d0274ae96781378dc86745ae29f0e7/ruff-0.16.4-py3-none-win_amd64.whl", hash = "sha256:05d9d27a18c4bcbefada602480ec9e01e0bc949d432e0ced5df77edac195919c", size = 10567657, upload-time = "2026-08-20T17:43:54.78Z" }, + { url = "https://files.pythonhosted.org/packages/0e/fe/da8b9e1347696bb22120b77280ec5ce25d500ca5cb39d5ad6e5c18de19c1/ruff-0.16.4-py3-none-win_arm64.whl", hash = "sha256:a3a61621c9b6f6a89573e938a080e648f1695baa3f58570a3a707bc51ff65a21", size = 10451579, upload-time = "2026-08-20T17:43:57.135Z" }, +] + +[[package]] +name = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, +] + +[[package]] +name = "starlette" +version = "1.6.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/b5/b4/205b0d5241d934e8add0c38aa924c4f9fb7330834ff11e5444db964ec3f9/starlette-1.6.0.tar.gz", hash = "sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b", size = 2716969, upload-time = "2026-08-08T18:27:57.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/cb/6a6a47d5b464bd08695d254f3da6e7986cc70c9fa5d778eda57538edfe56/starlette-1.6.0-py3-none-any.whl", hash = "sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c", size = 75969, upload-time = "2026-08-08T18:27:56.196Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "tomli-w" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/75/241269d1da26b624c0d5e110e8149093c759b7a286138f4efd61a60e75fe/tomli_w-1.2.0.tar.gz", hash = "sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021", size = 7184, upload-time = "2025-01-15T12:07:24.262Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" }, +] + +[[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 = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.52.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f2/0f/3f86e61397dd33bf2ccf28188c40db6a740658aeebbbf6e7dbc101a1f487/uvicorn-0.52.4.tar.gz", hash = "sha256:73acfee47a0b133c5de13d219492d62d8a31e935f4fe6e41a232451a15379f86", size = 100627, upload-time = "2026-08-19T06:27:41.821Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/79/4a20b54ab0491485ccd8c077db2d39187c7f12b3e15485d38a7be37c81b4/uvicorn-0.52.4-py3-none-any.whl", hash = "sha256:f86e41a149d7d05a9969337e3946a9c171c06a5d42680896daaba624aeac8da1", size = 79871, upload-time = "2026-08-19T06:27:40.36Z" }, +] + +[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.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, + { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, + { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, + { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, + { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, + { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, + { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, + { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, + { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, + { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, + { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/2f/e42c992d2afda3108ea1c02acecc991b9f31d05c14adc2a7cee9ee211fc4/watchfiles-1.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26", size = 400115, upload-time = "2026-05-18T04:32:02.06Z" }, + { url = "https://files.pythonhosted.org/packages/5f/8f/6af2ea19065c91d8b0ea3516fdfc8c0d349f407e8e9fbf4e5a17360de8ad/watchfiles-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c", size = 393659, upload-time = "2026-05-18T04:30:50.951Z" }, + { url = "https://files.pythonhosted.org/packages/13/01/b32a967c56fb3e3e5be3db52c3d3b87fa4513aa367d8ed1ad96d42952e5f/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc", size = 453207, upload-time = "2026-05-18T04:31:04.231Z" }, + { url = "https://files.pythonhosted.org/packages/04/98/97557a812180338cb1abd32e1cffcc4588f59b5f23e0cb006b2ba95ba64a/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0", size = 459273, upload-time = "2026-05-18T04:31:50.377Z" }, + { url = "https://files.pythonhosted.org/packages/e8/a8/b4b08dcb7653b8087c6586f7ce649505900e866bbcfe40dc9587af02e686/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c", size = 489927, upload-time = "2026-05-18T04:31:42.485Z" }, + { url = "https://files.pythonhosted.org/packages/50/94/3dceea03545d2e5ddfd839f0ddd5e1cecbf1697b5a428d5ba11cef6af95d/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01", size = 570476, upload-time = "2026-05-18T04:31:03.071Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f2/d39a5450c3532092b91f81d274360e613c2371bc874a89c7a1a3c5e8d138/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8", size = 465650, upload-time = "2026-05-18T04:30:12.701Z" }, + { url = "https://files.pythonhosted.org/packages/22/24/ed72f68cbc1333ca9b9f2200aa048bb6658ae41709bc1caad4310f4bdffd/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5", size = 456398, upload-time = "2026-05-18T04:30:13.784Z" }, + { url = "https://files.pythonhosted.org/packages/0d/64/982ef4a4e5bab5b6e5b6becc8cd5e732f6130a78b855f0abec6439a9a135/watchfiles-1.2.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d", size = 465140, upload-time = "2026-05-18T04:31:52.111Z" }, + { url = "https://files.pythonhosted.org/packages/a0/0c/95282abf4ed680b6096010bcfc30c5fa7a041fc5aa5a2ad17a2cc6c75bba/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c", size = 630259, upload-time = "2026-05-18T04:31:25.676Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/607c1de1530c4bdcf2cf1d1ecc2505ddba5d96bd43ba9f2b0e79876f850f/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906", size = 659859, upload-time = "2026-05-18T04:30:24.333Z" }, + { url = "https://files.pythonhosted.org/packages/fa/08/d9e2e0f9e8e6791d33aefc694ad7eefa7f901f63caff84a81ded38692f9c/watchfiles-1.2.0-cp312-cp312-win32.whl", hash = "sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898", size = 275480, upload-time = "2026-05-18T04:30:31.307Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e6/9d42569c0102645cc8cea5d8c7d8a1e9d4ada2cb7f05f75e554b8aa2202a/watchfiles-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379", size = 288718, upload-time = "2026-05-18T04:32:10.745Z" }, + { url = "https://files.pythonhosted.org/packages/0a/26/88e0dc6ee3898169d7fa22bb6a69cabf2502d2ee25cb8c876d1262d204f8/watchfiles-1.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5", size = 281026, upload-time = "2026-05-18T04:30:22.23Z" }, + { url = "https://files.pythonhosted.org/packages/d1/4d/70a7feced9f87e2ff26dba42667290f41694fc64646c67261fbb8cab5d5c/watchfiles-1.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98", size = 399730, upload-time = "2026-05-18T04:31:38.162Z" }, + { url = "https://files.pythonhosted.org/packages/31/3a/0da302f2307aee316922806ebd5726c542cbd787c938271cf14a074c7daf/watchfiles-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44", size = 392842, upload-time = "2026-05-18T04:30:27.051Z" }, + { url = "https://files.pythonhosted.org/packages/db/ef/d5bdb705c224dbc256aa0c1ec47bf4e61ec52558f2afb44a71a1fe4d7015/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658", size = 452989, upload-time = "2026-05-18T04:31:11.945Z" }, + { url = "https://files.pythonhosted.org/packages/71/29/5495f2c1661949ef7a35e4d71111d129cfe7606414a26887a919d0a55406/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb", size = 458978, upload-time = "2026-05-18T04:30:52.606Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/7f9c07c433811c2fffd93e13fdfb7135de9aab5f2ae41be08960fa0047dc/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f", size = 490248, upload-time = "2026-05-18T04:31:36.003Z" }, + { url = "https://files.pythonhosted.org/packages/3c/11/d93632febc52fbc21be90231bb7c17fd5387f46c9076fd40a5f9c2ae6910/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0", size = 571847, upload-time = "2026-05-18T04:31:10.862Z" }, + { url = "https://files.pythonhosted.org/packages/55/b4/383173e73aabb07ad1d9c7aa859d95437ac46a6d6a1e11005facda0c9d19/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5", size = 465974, upload-time = "2026-05-18T04:30:17.006Z" }, + { url = "https://files.pythonhosted.org/packages/a7/6c/89b1a230a78f57c52dd8893adb1f92f94411721b6ec12596c56d98c74356/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71", size = 454782, upload-time = "2026-05-18T04:30:35.656Z" }, + { url = "https://files.pythonhosted.org/packages/24/62/1732118367cfff0a9fce3bf62ff4bfded09ef5df21d9d446b858b3f70a96/watchfiles-1.2.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3", size = 465182, upload-time = "2026-05-18T04:30:20.846Z" }, + { url = "https://files.pythonhosted.org/packages/28/96/716f7e5f51339bf22963f3345f9f27d7f3b30e2eadc597e257c881dd3c53/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0", size = 629841, upload-time = "2026-05-18T04:31:05.397Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/c40783950fd771ccf66ab3ec2722d188a9af1c7f96c6e811f36e40c6e03f/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427", size = 658028, upload-time = "2026-05-18T04:31:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/71/72/4508db1856d1d87fcbb3b63f4839bab1b5682cb0e8d224d122263c09654a/watchfiles-1.2.0-cp313-cp313-win32.whl", hash = "sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799", size = 275183, upload-time = "2026-05-18T04:30:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/f9/36/14b76ca57652e5cc5fd1c11f32a261292c08a0d19a00351013c2549cbfb2/watchfiles-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9", size = 288059, upload-time = "2026-05-18T04:32:07.937Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8d/0a85e395398d8d20fadfe5c5d32c726eee17a519e78fb356f2cf7531bffe/watchfiles-1.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077", size = 280186, upload-time = "2026-05-18T04:31:54.484Z" }, + { url = "https://files.pythonhosted.org/packages/37/68/36db056f1fdcc5f07302f56e631774d6835bcd6fa3ace402304621d5f9e5/watchfiles-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08", size = 399031, upload-time = "2026-05-18T04:30:44.576Z" }, + { url = "https://files.pythonhosted.org/packages/c1/64/01a9d6f66a82a5c101ce939274106cc72759d62427e153f01edd2b9f87c2/watchfiles-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9", size = 391205, upload-time = "2026-05-18T04:30:25.413Z" }, + { url = "https://files.pythonhosted.org/packages/84/2c/0a44fe058cb4bb7b8ede6b6670698bbb7c0400740e378d00022189b7b31d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4", size = 451892, upload-time = "2026-05-18T04:32:14.005Z" }, + { url = "https://files.pythonhosted.org/packages/67/a1/351e0d56cd35e6488b5c8b4fb11a809a5bc923e8fe8fed9faf8920be0c89/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55", size = 458867, upload-time = "2026-05-18T04:31:22.279Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7d/9d09605187f1b838998624049fcf8bf47b73c1a3b76901fcac1782f62277/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925", size = 490217, upload-time = "2026-05-18T04:31:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/60/5d/a17a16eccb182f04188cd308ec24b1a71a9b5c4e7098269cf35d9fa56d02/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4", size = 571458, upload-time = "2026-05-18T04:32:11.875Z" }, + { url = "https://files.pythonhosted.org/packages/d3/3d/4dd457062083ab1938e5dfd45032eb425cee2ac817287ca8ff4356183e5d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2", size = 464707, upload-time = "2026-05-18T04:30:43.492Z" }, + { url = "https://files.pythonhosted.org/packages/c6/71/ea8c57b128f5383de74d0c7d2d9c57ad7c9a65a930c451bd25d524b295b7/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9", size = 454663, upload-time = "2026-05-18T04:30:16.061Z" }, + { url = "https://files.pythonhosted.org/packages/53/fd/2e812bf938406d7db351f0703ddd3fc6c061cf30d96153a77bc79a943a44/watchfiles-1.2.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa", size = 463537, upload-time = "2026-05-18T04:31:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/86/56/d17a7f1dd1bc3035f1072694a551301272f1739c2d8e319c927cb9e29b38/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44", size = 629194, upload-time = "2026-05-18T04:31:14.141Z" }, + { url = "https://files.pythonhosted.org/packages/be/06/f1ff66bf5cae50aa4062779a0ecd0bbaf15e466195719074078947d9a17d/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72", size = 656194, upload-time = "2026-05-18T04:31:47.14Z" }, + { url = "https://files.pythonhosted.org/packages/e7/54/a9c7ea9a82a4ac65e7004c0a03920b5cdd2f9c3b678757d9cd425aa51d53/watchfiles-1.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4", size = 400205, upload-time = "2026-05-18T04:32:05.153Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5d/c9ab3534374a4a67450696905d6ef16a04405448b8dc52bd752ae50423d4/watchfiles-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281", size = 392508, upload-time = "2026-05-18T04:30:54.849Z" }, + { url = "https://files.pythonhosted.org/packages/26/ca/1ad30103535cf0cecd7b993e8d50edc5351b1820e38f2d22e3df58962feb/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d", size = 452448, upload-time = "2026-05-18T04:30:53.727Z" }, + { url = "https://files.pythonhosted.org/packages/37/a1/ceee2cdf2afbd715fa07758d39c9859513eae411b23196f7fd039e5feedd/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e", size = 459605, upload-time = "2026-05-18T04:30:23.312Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f6/421e30fd1cb3907a84ed92ab3f1983e37ba2dca015e9a894a048418417a2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242", size = 490757, upload-time = "2026-05-18T04:30:47.358Z" }, + { url = "https://files.pythonhosted.org/packages/41/b0/55ed1b97ed08be7bba6f9a541cac15f2a858e1d74d2b07b6da70a82aab00/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add", size = 568672, upload-time = "2026-05-18T04:30:38.915Z" }, + { url = "https://files.pythonhosted.org/packages/d1/cf/d8ae8a80dd7bafab395ea7681c10237311bbf34d37704a8c744e7cf31fc7/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f", size = 464197, upload-time = "2026-05-18T04:30:09.914Z" }, + { url = "https://files.pythonhosted.org/packages/7c/8a/3076c496ca8dafe0e8cd03fcebdfc47be4b1174b4e5b24ff6e396e6b3af2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7", size = 453181, upload-time = "2026-05-18T04:30:14.829Z" }, + { url = "https://files.pythonhosted.org/packages/e5/10/9745e17c98e7b8a86454df0a3c7b5686bd650383f1e9f26e4ebcbd6cc0c0/watchfiles-1.2.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e", size = 465109, upload-time = "2026-05-18T04:30:28.123Z" }, + { url = "https://files.pythonhosted.org/packages/8f/95/8ef4a95481d3e0cb52d62a06fa6e972e81424be2d9698b91a2fecca9904c/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06", size = 630653, upload-time = "2026-05-18T04:31:49.304Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e4/3b3bf36b0f829b50c6ebcb8d031583863c59f923d6a6af3d485e470d0fac/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba", size = 657838, upload-time = "2026-05-18T04:31:06.497Z" }, + { url = "https://files.pythonhosted.org/packages/21/b1/6cbbb50c1f3002ab568777d44aa21206dfb8807a840990c4037523b51812/watchfiles-1.2.0-cp314-cp314-win32.whl", hash = "sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7", size = 275108, upload-time = "2026-05-18T04:30:06.891Z" }, + { url = "https://files.pythonhosted.org/packages/92/45/190ce6db8dcb4536682cf75d3889ff1a27182a58cb519d343cb6d9ea63d8/watchfiles-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103", size = 288441, upload-time = "2026-05-18T04:32:12.901Z" }, + { url = "https://files.pythonhosted.org/packages/74/0d/3eae1c2313ab08378431d907c3f8095ecca00f3eda33111cf4f0f2591799/watchfiles-1.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3", size = 280684, upload-time = "2026-05-18T04:31:26.902Z" }, + { url = "https://files.pythonhosted.org/packages/b1/75/fb64e6c25d6b5ca636d03df34ffb1c6e9873303e76d27967e045f8df088f/watchfiles-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2", size = 398857, upload-time = "2026-05-18T04:32:17.108Z" }, + { url = "https://files.pythonhosted.org/packages/73/4e/9f7adf01754cbf81843722ccfec169d8f26c69778281a302855cecd2ee08/watchfiles-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28", size = 392413, upload-time = "2026-05-18T04:31:07.911Z" }, + { url = "https://files.pythonhosted.org/packages/47/c8/bec626bcc2d69f44b9acb24ce7d60ed7b16b73628eea747fcbd169d8edda/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831", size = 452409, upload-time = "2026-05-18T04:31:20.142Z" }, + { url = "https://files.pythonhosted.org/packages/00/b7/b6362068e81e7c556d155a34c35d40ac3ef42d747b06d7f6e5bf58e359c2/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33", size = 458827, upload-time = "2026-05-18T04:32:06.219Z" }, + { url = "https://files.pythonhosted.org/packages/67/f8/9a813fa42afb1e0b4625e75f0479826644d3ee8dc287e093799bc01f390c/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4", size = 490104, upload-time = "2026-05-18T04:31:56.034Z" }, + { url = "https://files.pythonhosted.org/packages/2f/bf/27dfb6094ca4c9aad21298b5525b6c53cb36121ee454331d05161e58d130/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b", size = 571360, upload-time = "2026-05-18T04:31:57.133Z" }, + { url = "https://files.pythonhosted.org/packages/fb/39/44a096d67270ea93df91d33877dbe91fbda3aa4f8ec2edf799d93eda8736/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666", size = 464644, upload-time = "2026-05-18T04:30:57.33Z" }, + { url = "https://files.pythonhosted.org/packages/0e/80/c7472203bad6268e3ef1ad260739704847898938ad7ea8b63a5131f46b50/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925", size = 454771, upload-time = "2026-05-18T04:30:48.736Z" }, + { url = "https://files.pythonhosted.org/packages/51/cf/3b10b268b4b7f0fc26e9debb5eef1998b515887840f444cd3ec80c688755/watchfiles-1.2.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b", size = 463494, upload-time = "2026-05-18T04:31:33.826Z" }, + { url = "https://files.pythonhosted.org/packages/3d/3e/a4302545cd589262a0dc7d140e86f7688eba3f9c72776c27f7e23b8864c4/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30", size = 629383, upload-time = "2026-05-18T04:31:15.596Z" }, + { url = "https://files.pythonhosted.org/packages/db/99/d5649df0a9a410d45b7c882304d0b790903ac9b6e8f2cfd12114e0c6b9f2/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5", size = 656093, upload-time = "2026-05-18T04:31:58.707Z" }, + { url = "https://files.pythonhosted.org/packages/92/b9/362702539275019a54dd2e94511b31a9b89c5f9e6a21966de7eb692549fc/watchfiles-1.2.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:37a6721cdf3f65dbb13aa9503510ccb4451603ac837e44d265d7992a597e1374", size = 400109, upload-time = "2026-05-18T04:31:16.879Z" }, + { url = "https://files.pythonhosted.org/packages/8f/75/71d5ba62db781e5587bded1d944c675374bc4aa37ff33d5018d98e8b6538/watchfiles-1.2.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2b37d10b5a63bd4d87e18472d80fa525bd670586fae62e5dd580452764879b65", size = 392167, upload-time = "2026-05-18T04:31:28.058Z" }, + { url = "https://files.pythonhosted.org/packages/3c/01/c66dd95d0423fe30d31820e2d1d5bda773764131bbb6ac0cb1cf303ac328/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a105bc2283f67e8fbec74253ec2d94925de92ed72c0393f1206bf326b7b7b69", size = 452372, upload-time = "2026-05-18T04:31:00.836Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/2fe99557e72f85627c6a8eed50d889e8d101623e060a22ad75b875cb932d/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5327989a465505f05cfe06f04fa9d0c2fd5432bb243e10e6f012b1bdca3c8579", size = 459596, upload-time = "2026-05-18T04:31:34.96Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/d4acfa0023367428ed48351b3b9b267893037b6cadae55620c61c24bcfd4/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ecb47f183a8025b2aa18b546725c3657e542112ae9c0613a2af79b4fa8d04ad7", size = 490869, upload-time = "2026-05-18T04:31:59.923Z" }, + { url = "https://files.pythonhosted.org/packages/a4/5f/3164cbdce06c9fb95c4f7b9e2f9760b5e2797af43a9ecc317ef42a23a278/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8520a4ab0e37f770afc34459c4f8f7019e153f9124dc101c15538365875d1ab2", size = 571641, upload-time = "2026-05-18T04:32:00.948Z" }, + { url = "https://files.pythonhosted.org/packages/41/e6/85d3731c55e65cd7690f3f803d24c139588aaf863e4bf2148fe7a7fa1a19/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71cd71740ed2c15211ebb237ced4e39a1cdf6f80566e5fe95428da1626f4fde6", size = 464444, upload-time = "2026-05-18T04:30:34.298Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7d/562641012b8b09872742c3b8adf9629ec479fd78f8d68ae4a0c13da8add6/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f88af53d6ddaf72179ef613ddc905e6f4785f712b49b80b3bef9f3525e6194b4", size = 453593, upload-time = "2026-05-18T04:31:23.464Z" }, + { url = "https://files.pythonhosted.org/packages/56/fe/cb8ef3d6f929d14158fdaaad9925985b7310abc9384dcd4d82dd0016fb59/watchfiles-1.2.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:cee9d5efd929efdac5f7e58f72b3376f676b64050a91c5b99a7094c5b2317488", size = 465096, upload-time = "2026-05-18T04:31:30.384Z" }, + { url = "https://files.pythonhosted.org/packages/25/91/80908e835e100527a9267147b08c0eee1fa6ab0ffec15edc04d1d44885f7/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:b718bf356bbc15e559bd8ef41782b573b8ae0e3f177ab244b440568d7ea02cfb", size = 630638, upload-time = "2026-05-18T04:30:49.89Z" }, + { url = "https://files.pythonhosted.org/packages/46/4b/95ab2f256bb4af3cb2eb23b9317bda984ee6e0f11733a5c004a6c95b06e3/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377", size = 657684, upload-time = "2026-05-18T04:31:32.027Z" }, +] + +[[package]] +name = "websockets" +version = "17.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/96/e01084f83a64bcb3a27994bd0cb0db68ff29d9c6707fae37ec19b18ba990/websockets-17.0.1.tar.gz", hash = "sha256:5baa9bc0dfbae8c507e51c8cf1b6d4628086f7a87bbd3a9952bd5f035451f1cc", size = 183298, upload-time = "2026-07-31T11:31:27.665Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/ff/6199a52d864215750af8668d84b0274775011a90052081f5a9495807a92b/websockets-17.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:10f461191125c63902ea7394ae9e752b1b5785641850c1d365bb30b0f88bc53f", size = 212603, upload-time = "2026-07-31T11:29:30.771Z" }, + { url = "https://files.pythonhosted.org/packages/54/7e/439a962bcada88dcf586da77a1b2385f91e2d2910e9359540934c827156b/websockets-17.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cffc84ddec6da7f447677266fee2a3c40ecc78172f00752aa1150b8a8d65df1d", size = 210286, upload-time = "2026-07-31T11:29:32.157Z" }, + { url = "https://files.pythonhosted.org/packages/d9/82/123660edc759c225626b3b91952c7625f85c77a8362acbc35a4623120f7d/websockets-17.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c23e532c8a2325a1e7486de8763a60dc43e83f01bcaeca07e3ba79652c156db1", size = 210549, upload-time = "2026-07-31T11:29:33.388Z" }, + { url = "https://files.pythonhosted.org/packages/cd/2f/2940e57080cf56f28190287516400126d5a76b52b9a61dc10ba6f6400dbe/websockets-17.0.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c09e097d0e46e3c289bedab9a475ae344b70c30ff5646e46af22b4e6fdc97b21", size = 219874, upload-time = "2026-07-31T11:29:34.608Z" }, + { url = "https://files.pythonhosted.org/packages/42/28/9ec976c16d63cc51c28dfec74b66854048c0b8b6579946e902f91b69e8bf/websockets-17.0.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f47b0815af3948ec6a440b3afa02f05b18cc0939549e91b5c677b5d9c2c8472a", size = 220150, upload-time = "2026-07-31T11:29:35.831Z" }, + { url = "https://files.pythonhosted.org/packages/f4/a4/850c699a16bbc451723856360c59bd997bec075e637154f3fa96e80d5760/websockets-17.0.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8848c207049ad49d318e5f64a3d4d7bb189f8328d0d98e65647788f2a085785c", size = 221389, upload-time = "2026-07-31T11:29:37.189Z" }, + { url = "https://files.pythonhosted.org/packages/47/e1/f60a891c1a4b3420d5052333a84eb7241e1fb4a71866dec1562f5fa30027/websockets-17.0.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2604de7228506b13a44a256a9d223943340c0e725af5d367dc068e192b027761", size = 224169, upload-time = "2026-07-31T11:29:38.61Z" }, + { url = "https://files.pythonhosted.org/packages/26/fb/e2a893be6fae4fddfe50ddc3035a331d3f381103d5467b7900026bdb3a64/websockets-17.0.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:07abc3bd196a48af476a82fd47f3f79a6a3f70937a9f930cef703cfa0c9d83b6", size = 222025, upload-time = "2026-07-31T11:29:39.897Z" }, + { url = "https://files.pythonhosted.org/packages/d9/72/e3144b2d79276fab9798ed7d4aea2f0847434f186800b6f56a1eddcb3114/websockets-17.0.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:769ce7e2acfd9a89f2bed3a9c0da229459516bbc00bd4c9e2ca492c613ae4861", size = 220779, upload-time = "2026-07-31T11:29:41.084Z" }, + { url = "https://files.pythonhosted.org/packages/c0/5e/69c02174fbcf1c40c6adc45d3c316a401558392fe7bab8969ef8c46f1689/websockets-17.0.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:07d78a509c3333f5908c83d7f78144ea68a6c9ec28110f5c54d81d8fcdc262c4", size = 218053, upload-time = "2026-07-31T11:29:42.322Z" }, + { url = "https://files.pythonhosted.org/packages/b3/09/7574778b095b99cfa0856583462f56568df784f9b41485145169b2ec9c64/websockets-17.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ffad64ce7ad3703d652a3fd9af26238377d24ce52c6ad8ff35d26d82f61f493f", size = 220825, upload-time = "2026-07-31T11:29:43.553Z" }, + { url = "https://files.pythonhosted.org/packages/c5/e6/f46571f38765dbc4cbc0d0b47de8db65768006dbbd4340e6f5f51bc1d895/websockets-17.0.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:e95e321d0d763f2b6633512605f6112ebd70d5746f3ce05c941909d4a25233f2", size = 219427, upload-time = "2026-07-31T11:29:44.731Z" }, + { url = "https://files.pythonhosted.org/packages/9d/31/6ff1fee057bd7e9dd5237fc064a749615378d003aa045b5bfc2d12b2f4f7/websockets-17.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cd526c8228e759c1006c4b7c9ac71dc4e925ced1a6a6a5a8e94643709738f63e", size = 220198, upload-time = "2026-07-31T11:29:45.997Z" }, + { url = "https://files.pythonhosted.org/packages/7a/4f/d41847227a44b9ad87c3d5a9fddbfad8b7c4d6032878d8460d9d37c2d44f/websockets-17.0.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8cd3369e42c0246afaf9d669cfc19797e3a49e8c0a639544459c57597108b966", size = 221304, upload-time = "2026-07-31T11:29:47.314Z" }, + { url = "https://files.pythonhosted.org/packages/63/30/21a7e326c6ad2eb526cd5b816383d59cdeb28b8805b65a543c3cfbd8e8ce/websockets-17.0.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:b580794e926cab7ff42ee4371ef14e0b22cb2bb722a607f77769136468f49a3f", size = 218858, upload-time = "2026-07-31T11:29:48.587Z" }, + { url = "https://files.pythonhosted.org/packages/2d/48/55b0331cd5bec9ce29748f79edc00075805450a47011d0c8e3b1c61dbf04/websockets-17.0.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5033ffe6804dd53afafa7d08e8c3eef2d2431f34d58ca30507a8442dd04a033a", size = 219840, upload-time = "2026-07-31T11:29:49.791Z" }, + { url = "https://files.pythonhosted.org/packages/78/6e/2e8bc06e546f49b32a58a2bc2957902d1809ecc37552d3d7ccd6639a126e/websockets-17.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6db9e5bf3649ab506c6ae8a3ac85a00fb1ae3816d75962771b2df8adbc5d40d2", size = 220116, upload-time = "2026-07-31T11:29:51.026Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ad/4bac01fa41aca54307157b9c9f68b066a6bb51fb18716ba618078a67b283/websockets-17.0.1-cp312-cp312-win32.whl", hash = "sha256:bc0bca48ba24c6c866847fd20478a51dd547fa0ad258dab9615c414ec534bbc0", size = 213050, upload-time = "2026-07-31T11:29:52.328Z" }, + { url = "https://files.pythonhosted.org/packages/82/d8/c3a78cccc74a554780e9e76e323d5cde891048627025f0f82623e22dc3df/websockets-17.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:2b3f3020171202b135ca078e20434977c6b2b02af647130d6980c9e39b9462e3", size = 213348, upload-time = "2026-07-31T11:29:53.891Z" }, + { url = "https://files.pythonhosted.org/packages/7b/25/e1b8824bd632c8a5a62d504b61e9e35e470b67e4be0206f5c28f90c7f86d/websockets-17.0.1-cp312-cp312-win_arm64.whl", hash = "sha256:41d6aa06b5ab832aee72fedf47a149535b121ac900b6bb4d3fe14712afac9a79", size = 213276, upload-time = "2026-07-31T11:29:55.299Z" }, + { url = "https://files.pythonhosted.org/packages/ba/a8/79c577bc2f874ee22f6f5ccdab97ba9ce6b96806be3fcc3a6d8490f88a21/websockets-17.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:55b12e47dcee83673a40d07686cfb6f9d6dfc285976ade9463f61d2bef3fad22", size = 212593, upload-time = "2026-07-31T11:29:56.518Z" }, + { url = "https://files.pythonhosted.org/packages/db/99/e1cfaf419bb3b2fcfd6792a846f1d936293132b0b9a56530ced016c83c7b/websockets-17.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c1c118a6b0e25bfc9a6802075d748fa6321714ffbdf3c88d29d9a0e3c7386c75", size = 210280, upload-time = "2026-07-31T11:29:57.768Z" }, + { url = "https://files.pythonhosted.org/packages/a2/ef/cc994494bf7d97e41833f6ff55c24f535e4d527a10370b9631737e9c2f00/websockets-17.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:734d20364dc2cfe03674883cafcf580b6e431c5ce42b476312b9285310230cf9", size = 210538, upload-time = "2026-07-31T11:29:59.021Z" }, + { url = "https://files.pythonhosted.org/packages/87/32/fbf2d132f63ba3e67f675bccf333469786a24e0418969ce1d8e6ff9e6f02/websockets-17.0.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9493314a99e599163c854fb5900ad7f7ea38c5cb9d9103aa30b3c6b8181c01fa", size = 219925, upload-time = "2026-07-31T11:30:00.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/50/64eee3d25a47fe744a9490e0627cc373dca096755db740f91c28bd61cd35/websockets-17.0.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:18ded646ce98cdd3c0235825b3252f1df55765ba49b616bb10282f758667b4d0", size = 220206, upload-time = "2026-07-31T11:30:01.52Z" }, + { url = "https://files.pythonhosted.org/packages/15/56/10ed4bc4dd75f204e3c62bd4898e44a8742a27773c80b188cfa7888aad2d/websockets-17.0.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c1bec5d6a19f5fbe87e4940739cfc65e7bb53d8b353e1029b8037a1653b321bc", size = 221445, upload-time = "2026-07-31T11:30:02.788Z" }, + { url = "https://files.pythonhosted.org/packages/bf/be/bb14328614c068ab09569962fbf218fc00413ce3febc6d2684c764b6f37e/websockets-17.0.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:872273e629ca7e3d35f16a2dc6ede84e1d5c831e616b8277de6e4f83114e7c58", size = 222887, upload-time = "2026-07-31T11:30:03.943Z" }, + { url = "https://files.pythonhosted.org/packages/32/1b/4cb0eec2fee310007104687493175af190019f705940c864f9c523fe9f6f/websockets-17.0.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1df81d174c1561292de9e40b141cafc04f69077272f6c352afe1d743e20810df", size = 222072, upload-time = "2026-07-31T11:30:05.258Z" }, + { url = "https://files.pythonhosted.org/packages/6c/9c/14e6391de777ddb39c439c450deb551406d445e25a5877d6fa25c49d4544/websockets-17.0.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:759adeb5b0c5775b563254ec63b5b79089fc0045b479143a0b1b8c0ebaae1253", size = 220826, upload-time = "2026-07-31T11:30:06.53Z" }, + { url = "https://files.pythonhosted.org/packages/cb/57/96e94e384442247bbed5d3ab67381c7257355c2d66b62c3ad33a17f5d385/websockets-17.0.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1d99db29b5444e3982f1ce2ba8a833508ad44b2f1fbd0bd99e81d825c0b461", size = 218107, upload-time = "2026-07-31T11:30:07.766Z" }, + { url = "https://files.pythonhosted.org/packages/c0/8c/9c9dedd14c3919435df9b35cdee7111268c751252b87652f3a6a4f56e760/websockets-17.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:02ed63bf26dda9fa27df730a41f6664586c4ee05972c8fb667ce1725b3fd13d3", size = 220889, upload-time = "2026-07-31T11:30:09.035Z" }, + { url = "https://files.pythonhosted.org/packages/94/4d/ca73c2ac82c00f50c529784bacb323e42da4816333211bc1543d90c9cf11/websockets-17.0.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:eab6de8a98b9a7772cf686d00b4de439fc7efb8ab05ae106ef227291d06f87c5", size = 219486, upload-time = "2026-07-31T11:30:10.289Z" }, + { url = "https://files.pythonhosted.org/packages/5b/da/fb37ac09dcd7c69dd73bac979ed393df35f78a3c232e293d1ff3bd586d24/websockets-17.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2a855b6dfe21c4d3420be265ae031829ba8ba0be0ea350d9f7c3ef30ae63ebe2", size = 220258, upload-time = "2026-07-31T11:30:11.605Z" }, + { url = "https://files.pythonhosted.org/packages/fc/04/9693f191d968a93f37326a17301a101d49580889c688f466699f89ecdee1/websockets-17.0.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7002d5f9e1c3ddd991cdfdbfee18cc8c8b196b2445022892badacd6cb338bbbc", size = 221358, upload-time = "2026-07-31T11:30:12.858Z" }, + { url = "https://files.pythonhosted.org/packages/cf/29/ad0d85c01db5dcf22898d51648bd2c25af0dd0a4a41c550b11acddeeeba7/websockets-17.0.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c395bda8e7d8f51a02e80261fb57127979e5c472675d9a96b2860619ad47da48", size = 218921, upload-time = "2026-07-31T11:30:14.064Z" }, + { url = "https://files.pythonhosted.org/packages/18/3b/bf8e855e495dcca63f2b8aa019cf2ada3160e1fa66d833c7417f3b1f7f38/websockets-17.0.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:aadc298969ad229d8e3029fc5cc751fdad286696230f9cf014e90ff9cd8e6ea0", size = 219871, upload-time = "2026-07-31T11:30:15.358Z" }, + { url = "https://files.pythonhosted.org/packages/3b/db/c7abd6639a93a40279cd1ddc57e09e1c4f8381c4cfccdb775aa5aac9770a/websockets-17.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f11a398d8170b7ac5000baf7f258dcda579ef3ea744e0cc6a165e0dfbc0d3198", size = 220154, upload-time = "2026-07-31T11:30:16.96Z" }, + { url = "https://files.pythonhosted.org/packages/f6/2a/25a9f8f2e5a6ef34e911d2f55d9f756bdeb92b4c28cfb77b8430bbc73cb1/websockets-17.0.1-cp313-cp313-win32.whl", hash = "sha256:846a4a8b0833e3cad57523d9e3bd50ec8ea05ab9d06c582f82a1340ba096af5f", size = 213038, upload-time = "2026-07-31T11:30:18.434Z" }, + { url = "https://files.pythonhosted.org/packages/81/2f/ea1380f72bb11b64fc5bc7ae0d42de5bbf3e6dc13b965706b2a1d4e17cdf/websockets-17.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:409d93efcaa14f7a99592c5baaef5ec6ca94fba0f5aec1a86f693977c69c9c1c", size = 213348, upload-time = "2026-07-31T11:30:19.693Z" }, + { url = "https://files.pythonhosted.org/packages/e6/c7/b956ed9151c3c74530ebc62d716fbfdbde7507a6acc6423a64f9ecfb6b8a/websockets-17.0.1-cp313-cp313-win_arm64.whl", hash = "sha256:90246fa9e6cb192a778ce6ce024057ec54317a894db7899c922dcdc1f4cbf6a5", size = 213282, upload-time = "2026-07-31T11:30:21.045Z" }, + { url = "https://files.pythonhosted.org/packages/98/dc/cadab608924ac605647031472fb1f8792d7d4ea07565ba1899ec42028e0d/websockets-17.0.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:53b90c00bc6201ab6695c7ff51a04d0e425514c37515e9eeecd2c1b978ac6c0e", size = 212640, upload-time = "2026-07-31T11:30:22.436Z" }, + { url = "https://files.pythonhosted.org/packages/16/7a/b034d13ca181211bbd58bb50835cb196a7784cd505b5a2079d4d03374f9f/websockets-17.0.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:5f33a649bfcb8312524173cc4bbafa7dbb236e18eee9aa31a1d324ca0ddda28c", size = 210332, upload-time = "2026-07-31T11:30:23.608Z" }, + { url = "https://files.pythonhosted.org/packages/2f/4d/943ede39b53744768edf1ed84a3f9401527388228a3d6c1249c02c3d6bd7/websockets-17.0.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cddc675ec31bca65473321f9a9794e488b43b3b8de5d02c8ef4810c5d5792163", size = 210546, upload-time = "2026-07-31T11:30:24.932Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e3/88dc159d2ae66743c669443246243f28d873b0c5e58271b8cc1ca0440334/websockets-17.0.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b3ff0ad440ad52dda64138f16895f66403f40192365e39b1010e889f289746b0", size = 219928, upload-time = "2026-07-31T11:30:26.221Z" }, + { url = "https://files.pythonhosted.org/packages/fe/f2/ff27eaefa15851a5cf7f004ab827a022bf2d6632cb520f89cb100db7e84b/websockets-17.0.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:72d7f2a5aeb4e82daa4ee18f125b4277f427033359be5c745ad709608446cc2c", size = 220279, upload-time = "2026-07-31T11:30:27.49Z" }, + { url = "https://files.pythonhosted.org/packages/19/2e/a5166149f363d2449c1cb2dde6486a245521979509d53b87a09f3e79662b/websockets-17.0.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6fd88365da261c53d3e943fb37e0d0721b9cde119f6b2e3fc84369b6ab234d63", size = 221525, upload-time = "2026-07-31T11:30:28.872Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b5/f46931269b3ff3bde65d27c65ddb22f9bb8ce92ac2c6c4df0910128f6219/websockets-17.0.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ab9f962a5b64a5c3c845d556b7dc4e6fb683f7b67179f8205e814bb2e0213ffe", size = 222897, upload-time = "2026-07-31T11:30:30.164Z" }, + { url = "https://files.pythonhosted.org/packages/42/f4/deccf3439f35df953ec35e13fe07986821c5f1ab5785d69614283bdb9034/websockets-17.0.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8c07f145d0b9e90cbd96035f31fb79199aef4da1872854e36ebeb258e3d57594", size = 222129, upload-time = "2026-07-31T11:30:31.489Z" }, + { url = "https://files.pythonhosted.org/packages/35/a5/e1b57a59da92ade37fd021567a17b518ea8267b28e5530075844cdb525fe/websockets-17.0.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9f7747d3daa41a11f25f7cca5dc988fc51da97b311bed4c9d843860f79779283", size = 220875, upload-time = "2026-07-31T11:30:32.805Z" }, + { url = "https://files.pythonhosted.org/packages/f0/30/e7d0889c790a854156de424575fd67af79ddbaed9ff3157ae863dfd1c1dc/websockets-17.0.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2abb1ba0a5133b7d2ef3c1c9f4b0c1e8a101012dce0b594ab2b2888d9a64820e", size = 218160, upload-time = "2026-07-31T11:30:34.512Z" }, + { url = "https://files.pythonhosted.org/packages/09/2e/43db785d6ed9ae7594fae7b62bbc9cb4dfee2b015e06a1005f1e5ce283b6/websockets-17.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f3fd9a1f87f8f0f3f8e9f9bd0195f7516562d13f5b178db8c5784d1f60b60bed", size = 220951, upload-time = "2026-07-31T11:30:35.806Z" }, + { url = "https://files.pythonhosted.org/packages/eb/f5/4ac3cab3d5e8a830657a822f64a8910e3803229c6783e59c3fd9a3487427/websockets-17.0.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2bc14b481e05e331811108daa1aeb41a5e237a5564ef2f02ec5a356a0f102f78", size = 219460, upload-time = "2026-07-31T11:30:37.273Z" }, + { url = "https://files.pythonhosted.org/packages/03/0e/c3a4020673ffc17c82cf1a467835038a196a555d3b4f2a50f0f063cf8ccc/websockets-17.0.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:57d2ee9b24b404ce75f3814f92073c0ed88106c950148d2427fe8d25ca254d1f", size = 220248, upload-time = "2026-07-31T11:30:38.527Z" }, + { url = "https://files.pythonhosted.org/packages/7d/87/e47a6a278cc1dfade38444c893ce18322943c25d4b780a74450d9d164be1/websockets-17.0.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1b363bfd72a52c0658a3154a4cff219f15a474b35a235057d38853bf151acce7", size = 221421, upload-time = "2026-07-31T11:30:39.879Z" }, + { url = "https://files.pythonhosted.org/packages/da/8f/473d5fc4e3836e375b0233c6ef26777e6e5e3f7bfc84ccd524eae4090ed5/websockets-17.0.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:10b1587c599fa0f2c89154587c80e0fda98ade6c9fa8c0260a2823fb1800b685", size = 218975, upload-time = "2026-07-31T11:30:41.192Z" }, + { url = "https://files.pythonhosted.org/packages/d4/b9/819ec2dcdf69031d7e9cab11247f3a6ff9bbc8c7c53ada1dbcb9055b227b/websockets-17.0.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d7d72843691f50b91127c50688df10cb72ec6f4c4b1d7e2c11ab33b16acf8e51", size = 219925, upload-time = "2026-07-31T11:30:42.524Z" }, + { url = "https://files.pythonhosted.org/packages/85/b9/6c0da301f6118502e079cf92f4e864adf28e56b3f8c0f6085076ced7b876/websockets-17.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:90973a3a00f23afdfd1c9b06fb84289bf0220f247ef8a62501a1967c7af54f7b", size = 220218, upload-time = "2026-07-31T11:30:44.04Z" }, + { url = "https://files.pythonhosted.org/packages/55/f9/cba32dc9dd856565263d6272f594255bd0e2781deb8cd982c026a54760ad/websockets-17.0.1-cp314-cp314-win32.whl", hash = "sha256:599b03beb77633bffc095334338fad79cafc2b01fbd58953838130a9ae967d7b", size = 212626, upload-time = "2026-07-31T11:30:45.579Z" }, + { url = "https://files.pythonhosted.org/packages/fa/95/91cdd8c192287d7ea741f37cf7d64fdc1a14410f06f73805e428a1a590af/websockets-17.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:81ce19c6046ace11da7001781be7317bb1dc389f399af4b2ed962190f76f9add", size = 212969, upload-time = "2026-07-31T11:30:46.983Z" }, + { url = "https://files.pythonhosted.org/packages/8e/fd/8c98a1e431960661c5769ab1a4dd66494e87ab02d791cc79e51e0d9a289f/websockets-17.0.1-cp314-cp314-win_arm64.whl", hash = "sha256:efe0ae052a8d023b87198921e8a7ce1dc7768816bcd2fbc20df171ac73a04891", size = 212850, upload-time = "2026-07-31T11:30:48.304Z" }, + { url = "https://files.pythonhosted.org/packages/13/c1/142f5186ee7dc3beee0426b998a79e223e067b7689afcaa95890d64aa800/websockets-17.0.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ab56439c9f74c52770690c7b2f616b3bf775cb3920453ee355ac765c032d8bbf", size = 212967, upload-time = "2026-07-31T11:30:49.688Z" }, + { url = "https://files.pythonhosted.org/packages/02/de/4b03ed316c9dee180365286c298219809ff247be39beeaaf9958b21167ab/websockets-17.0.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:20a92f78ac8250984ed459faa9ca48c285adbfc0038ddc3fdac6046990a9c9ed", size = 210504, upload-time = "2026-07-31T11:30:50.987Z" }, + { url = "https://files.pythonhosted.org/packages/cd/d8/ad2b3e8f867e1e8cac3077e2f33ffb60b71bd763d6cfc71bd916f113c3bf/websockets-17.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6a434e59962a4fb9016bea327e1d14d6cd67670ecfb8942b4f4a0c24036634ce", size = 210702, upload-time = "2026-07-31T11:30:52.261Z" }, + { url = "https://files.pythonhosted.org/packages/48/18/7a77a82ce9d6f831c07b176da3942f7e71acd0f115f3ecdb1d00a040eb01/websockets-17.0.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2503c7e2a5049a12d5dac917a46d5d52591283a766165b8176bb167560421b38", size = 220290, upload-time = "2026-07-31T11:30:53.582Z" }, + { url = "https://files.pythonhosted.org/packages/f5/af/43c3e3c3ea7ba4693c2181743f3221957df28bededadcbd9fc8a0661bde0/websockets-17.0.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:28012a54510fe8301bb893ef143cec30a2780a2d3bc20b7bbdf4379d7a63945d", size = 220573, upload-time = "2026-07-31T11:30:54.966Z" }, + { url = "https://files.pythonhosted.org/packages/1f/bd/ed48eca15725743ee7e2dc172e15c61de29e85ec98dace7b14257f366836/websockets-17.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22bd00f8bae2bccdb5dbe41e20f58ba44ca9fff0b4b561aaf39099c35da762ed", size = 221747, upload-time = "2026-07-31T11:30:56.762Z" }, + { url = "https://files.pythonhosted.org/packages/99/50/838deb7937a8225c4925dd4a977eafea473fabf444178a99de0bc7e92bb0/websockets-17.0.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e98ec9ec61cce5bc4b8b218322ad090b0994eb060bb04da704c62ef0a3d864e6", size = 223891, upload-time = "2026-07-31T11:30:58.127Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e9/657fb70c6eb6bcd01adfa5d2b06496e9911e1c1a8813d353b8c00f7591cd/websockets-17.0.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8e387adb0c692c6b5571bdeafc8ac9d1901ea30f10309134780b16ecd35e6605", size = 222317, upload-time = "2026-07-31T11:30:59.416Z" }, + { url = "https://files.pythonhosted.org/packages/07/4c/82cb722afa5428fed981331210c4c07600570db01bb1620579f655b5adaf/websockets-17.0.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd1470d2c53fe53269bf5619da7725d30dd9b9693f1689f7a85eab8dea734442", size = 221047, upload-time = "2026-07-31T11:31:00.757Z" }, + { url = "https://files.pythonhosted.org/packages/90/84/bd6d67d6bc65f0de0cb50de55dab42f256a9876a351c4736522eb168fda0/websockets-17.0.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:884af729b8ab50486acd94d9768c2b60914bf39b579ebba0a5cb73bfdfd61fd2", size = 218626, upload-time = "2026-07-31T11:31:02.48Z" }, + { url = "https://files.pythonhosted.org/packages/96/cb/6a372c8553976f0d8f97f5115826ed47e34b3be6b8bf0d0249af249a7416/websockets-17.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a60fa1a25cca1bcc2bf87b8d6be37a741f0a3239fb5e9cfb7a37173b68ffcf87", size = 221299, upload-time = "2026-07-31T11:31:03.795Z" }, + { url = "https://files.pythonhosted.org/packages/bc/31/f966e8472337974f74d788b3ef6c6f3b8b9a5f201efd16a843c91d269fa5/websockets-17.0.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:e8208f2729cba030ff872a92064c97584eeb9502f53d32a05a0f05d5a17ca6c6", size = 219789, upload-time = "2026-07-31T11:31:05.08Z" }, + { url = "https://files.pythonhosted.org/packages/57/34/404e83a6cc7b0efcac810b7041bffd72ff76900e6fd0aa45a26c92fb2ffe/websockets-17.0.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:54cdcaa56f5d3eafd57058f0fa4a3de93a310b43a3c4699f06efc4c0bd054a5a", size = 220678, upload-time = "2026-07-31T11:31:06.635Z" }, + { url = "https://files.pythonhosted.org/packages/e5/70/8946188c2a68d67251859b589a3634918cf7867bf0b891347a5ecaa43d30/websockets-17.0.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4d41c0a1d47a478bc432b3b9068097bee1ce0c5b19327ea6f75c2ab34ab1f2fb", size = 221697, upload-time = "2026-07-31T11:31:08.023Z" }, + { url = "https://files.pythonhosted.org/packages/04/16/ee73fc2083a2938ac6209f4ec804960496835b20a0068dbcfe8424957c04/websockets-17.0.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f991247276797d0c61ab7770bc9791eadc16f683b4d83517f624932adc1a8bab", size = 219390, upload-time = "2026-07-31T11:31:09.378Z" }, + { url = "https://files.pythonhosted.org/packages/94/6a/d5f88033c69932af6cdaa72da62516ade47c257e3bf69f4c0ba5f40e12a2/websockets-17.0.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:733e3cc7171fa1b899edbe725ef9382d0e960657dc1fd933f3281ae910c01dab", size = 220161, upload-time = "2026-07-31T11:31:10.915Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/6183dd2c0370287ecf4afe0bb33aca364208e5e7b0e1a286adcaecc0c78b/websockets-17.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:810cb3fb5fa6e447216f4e82d9a85cb8aed0929ae3538153ddfe8a6e3121a58d", size = 220591, upload-time = "2026-07-31T11:31:12.289Z" }, + { url = "https://files.pythonhosted.org/packages/26/93/70f6516d85b9744f7eac224c4b1b9ef4e84133f80b53be02080cb1c3e663/websockets-17.0.1-cp314-cp314t-win32.whl", hash = "sha256:17ac37716c0244e82c9e384c41653c090b1864c6610224ca3857e7f7b58fce10", size = 212755, upload-time = "2026-07-31T11:31:13.883Z" }, + { url = "https://files.pythonhosted.org/packages/f1/2c/9d9c1da5a7ea9af307b386d25f64d1dead4729644198d2b92e36db5dfd41/websockets-17.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:bb31f42ea095ea826463c770829aa188a86c9a5c976b1467cbbf583c811de833", size = 213094, upload-time = "2026-07-31T11:31:15.367Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/a585e7573e128070605d003b5544729bcd58d9756c7e99d550818ca4b916/websockets-17.0.1-cp314-cp314t-win_arm64.whl", hash = "sha256:dbfae8e75b342e31fc6fd1a8bbb393b7cbb91d6cfd581650300a94381e7b7e2b", size = 213009, upload-time = "2026-07-31T11:31:16.776Z" }, + { url = "https://files.pythonhosted.org/packages/09/ce/3929538b2b9918f5eee623fbf3346893973191f6df93f19bbda097bd7bb7/websockets-17.0.1-py3-none-any.whl", hash = "sha256:c6be9cba65c65cc76dfa3d4619e359ff02a4476c74e179b215236c11a0b32345", size = 206718, upload-time = "2026-07-31T11:31:26.037Z" }, +]