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" },
+]