From c44ab8d8bb34baea8f9eb0c7c328fc48635a7ec0 Mon Sep 17 00:00:00 2001 From: Kirill Trofimov Date: Tue, 23 Jun 2026 20:46:17 +0300 Subject: [PATCH] refactor: prepare for release --- .github/workflows/ci.yml | 63 + .github/workflows/docker.yml | 22 +- .gitignore | 10 +- README.md | 333 ++--- Tiltfile | 20 - auth_service/.dockerignore | 27 + auth_service/.python-version | 1 + auth_service/Dockerfile | 40 +- auth_service/admin/__init__.py | 0 auth_service/admin/api.py | 75 + auth_service/admin/routes.py | 22 + auth_service/admin/service.py | 93 ++ auth_service/app.py | 246 ---- auth_service/auth/__init__.py | 0 auth_service/auth/routes.py | 60 + auth_service/auth/service.py | 49 + auth_service/config.py | 105 ++ auth_service/deps.py | 75 + auth_service/infra/__init__.py | 0 auth_service/infra/routes.py | 27 + auth_service/log.py | 95 ++ auth_service/main.py | 97 ++ auth_service/metrics.py | 6 + auth_service/pyproject.toml | 44 + auth_service/redis_store.py | 99 ++ auth_service/requirements.txt | 5 - auth_service/sqlite_store.py | 128 ++ auth_service/static/css/LICENSE.pico.md | 21 + auth_service/static/css/app.css | 79 ++ .../static/css/pico.classless.min.css | 4 + auth_service/static/js/app.js | 328 +++++ auth_service/storage.py | 31 + auth_service/templates/index.html | 218 ++- auth_service/tests/__init__.py | 1 + auth_service/tests/conftest.py | 29 + auth_service/tests/helpers.py | 8 + auth_service/tests/test_admin.py | 205 +++ auth_service/tests/test_allowed_hosts.py | 95 ++ auth_service/tests/test_auth.py | 84 ++ auth_service/tests/test_healthz.py | 15 + auth_service/tests/test_metrics.py | 23 + auth_service/tests/test_throttle.py | 218 +++ auth_service/throttle.py | 199 +++ auth_service/uv.lock | 1251 +++++++++++++++++ auth_service/validators.py | 181 +++ docker-compose.redis.yml | 68 + docker-compose.yml | 49 +- docs/token_creation.png | Bin 0 -> 191519 bytes env.example | 36 +- example/README.md | 64 +- example/caddy/Caddyfile | 6 + example/caddy/docker-compose.redis.yml | 51 + example/caddy/docker-compose.sqlite.yml | 25 + example/docker-compose.yml | 46 - example/traefik/docker-compose.redis.yml | 55 + example/traefik/docker-compose.sqlite.yml | 29 + example/{ => traefik}/dynamic.yml | 2 +- example/{ => traefik}/traefik.yml | 0 k8s/auth-service.yaml | 119 -- k8s/ingress-traefik.yaml | 27 - k8s/test-protected-service.yaml | 56 - k8s/traefik-middleware.yaml | 11 - scripts/README.md | 46 - scripts/apply-secrets.sh | 46 - 64 files changed, 4512 insertions(+), 956 deletions(-) create mode 100644 .github/workflows/ci.yml delete mode 100644 Tiltfile create mode 100644 auth_service/.python-version create mode 100644 auth_service/admin/__init__.py create mode 100644 auth_service/admin/api.py create mode 100644 auth_service/admin/routes.py create mode 100644 auth_service/admin/service.py delete mode 100644 auth_service/app.py create mode 100644 auth_service/auth/__init__.py create mode 100644 auth_service/auth/routes.py create mode 100644 auth_service/auth/service.py create mode 100644 auth_service/config.py create mode 100644 auth_service/deps.py create mode 100644 auth_service/infra/__init__.py create mode 100644 auth_service/infra/routes.py create mode 100644 auth_service/log.py create mode 100644 auth_service/main.py create mode 100644 auth_service/metrics.py create mode 100644 auth_service/pyproject.toml create mode 100644 auth_service/redis_store.py delete mode 100644 auth_service/requirements.txt create mode 100644 auth_service/sqlite_store.py create mode 100644 auth_service/static/css/LICENSE.pico.md create mode 100644 auth_service/static/css/app.css create mode 100644 auth_service/static/css/pico.classless.min.css create mode 100644 auth_service/static/js/app.js create mode 100644 auth_service/storage.py create mode 100644 auth_service/tests/__init__.py create mode 100644 auth_service/tests/conftest.py create mode 100644 auth_service/tests/helpers.py create mode 100644 auth_service/tests/test_admin.py create mode 100644 auth_service/tests/test_allowed_hosts.py create mode 100644 auth_service/tests/test_auth.py create mode 100644 auth_service/tests/test_healthz.py create mode 100644 auth_service/tests/test_metrics.py create mode 100644 auth_service/tests/test_throttle.py create mode 100644 auth_service/throttle.py create mode 100644 auth_service/uv.lock create mode 100644 auth_service/validators.py create mode 100644 docker-compose.redis.yml create mode 100644 docs/token_creation.png create mode 100644 example/caddy/Caddyfile create mode 100644 example/caddy/docker-compose.redis.yml create mode 100644 example/caddy/docker-compose.sqlite.yml delete mode 100644 example/docker-compose.yml create mode 100644 example/traefik/docker-compose.redis.yml create mode 100644 example/traefik/docker-compose.sqlite.yml rename example/{ => traefik}/dynamic.yml (88%) rename example/{ => traefik}/traefik.yml (100%) delete mode 100644 k8s/auth-service.yaml delete mode 100644 k8s/ingress-traefik.yaml delete mode 100644 k8s/test-protected-service.yaml delete mode 100644 k8s/traefik-middleware.yaml delete mode 100644 scripts/README.md delete mode 100755 scripts/apply-secrets.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d06647e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,63 @@ +name: CI + +on: + pull_request: + branches: [main] + push: + branches: [main] + +permissions: + contents: read + +jobs: + lint: + runs-on: ubuntu-latest + defaults: + run: + working-directory: auth_service + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v5 + - run: uv sync --group dev + - name: Check formatting + run: uv run ruff format --check . + - name: Lint + run: uv run ruff check . + + security: + runs-on: ubuntu-latest + defaults: + run: + working-directory: auth_service + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: astral-sh/setup-uv@v5 + - run: uv sync --group dev + - name: Bandit + run: uv run bandit -r . -c pyproject.toml --severity-level high --confidence-level high + - name: pip-audit + run: uv run pip-audit + - name: Gitleaks + uses: gitleaks/gitleaks-action@v2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Trivy FS + uses: aquasecurity/trivy-action@master + with: + scan-type: fs + scan-ref: auth_service + severity: CRITICAL,HIGH + exit-code: 1 + + test: + runs-on: ubuntu-latest + defaults: + run: + working-directory: auth_service + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v5 + - run: uv sync --group dev + - run: uv run pytest diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index cc50fe0..c662077 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -2,16 +2,17 @@ name: Build and Publish Docker Image on: push: - branches: [ "main" ] - tags: [ "v*" ] + branches: ["main"] + tags: ["v*"] workflow_dispatch: {} permissions: contents: read packages: write + security-events: write env: - IMAGE_NAME: acl-auth-service + IMAGE_NAME: wicket REGISTRY: ghcr.io jobs: @@ -52,3 +53,18 @@ jobs: labels: ${{ steps.meta.outputs.labels }} cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:buildcache cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:buildcache,mode=max + + - name: Scan image with Trivy + uses: aquasecurity/trivy-action@master + with: + image-ref: ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:${{ github.sha }} + format: sarif + output: trivy-results.sarif + severity: CRITICAL,HIGH + exit-code: 1 + + - name: Upload SARIF + uses: github/codeql-action/upload-sarif@v3 + if: always() + with: + sarif_file: trivy-results.sarif diff --git a/.gitignore b/.gitignore index 81b5115..5b9d917 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,10 @@ .env -k8s/generated/ \ No newline at end of file +.venv +k8s/generated/ +data/ + + +__pycache__ +.pytest_cache +.ruff_cache +.DS_Store diff --git a/README.md b/README.md index ef44f8e..6815c4d 100644 --- a/README.md +++ b/README.md @@ -1,213 +1,232 @@ -## ACL Proxy Auth Service (Traefik ForwardAuth + FastAPI + Redis) +# wicket -Minimal auth service to protect backends behind Traefik using ForwardAuth. Tokens are hashed and stored in Redis with optional TTLs and rate limiting. Includes a tiny HTML admin UI (protected via Basic Auth). +> Share your self-hosted MCP servers, APIs, and dashboards with a few friends. No identity provider needed. -### Architecture -- Traefik (Ingress) → ForwardAuth → FastAPI auth service → backend service -- Auth flow: - - Client sends `Authorization: Bearer ` - - Traefik calls `GET /auth` on this service - - Service checks Redis: `tokens:` hash with field `hosts` - - If requested host is in token's allowed hosts → 200 OK; else 401/403 +I wanted to share my self-hosted service with 3 friends. And let one of them +into my MCP server deployed on the same machine, but not the other two. -### Repository Layout +I needed: -- `auth_service/app.py` — FastAPI app (`/auth`, `/healthz`, admin UI, hashing, rate limits) -- `auth_service/templates/index.html` — simple token manager UI -- `auth_service/requirements.txt` — pinned dependencies -- `auth_service/Dockerfile` — container for auth service -- `docker-compose.yml` — local stack with Redis + auth -- `k8s/` — K8s manifests for auth service, Redis, and Traefik middleware/ingress +- token-based auth +- simple admin panel +- be able to attach to the different proxies -### Requirements +I looked at what existed: -- Docker / Docker Compose -- Redis (docker-compose provides one) +- **Authelia / Authentik / Zitadel**: full SSO, designed for companies. Overkill. +- **tinyauth**: it does user/session auth with login UI; I want pre-shared bearer tokens for API clients, not browser sessions. +- **OAuth2-proxy**: needs an upstream identity provider I'd have to run too. +- **nginx basic auth**: no per-resource rules, no rotation, no administration. -### Quick Start (Local) -1) Start stack: +So I built this: a FastAPI service that your reverse proxy (Caddy or Traefik) calls via forward-auth. +Create a bearer token in the admin UI, assign it to specific hostnames, hand it to your friend. + +They add `Authorization: Bearer ` to requests. + +![Token manager screenshot](docs/token_creation.png) + +--- + +## Who this is for + +- You self-host a few services: MCP servers, scrapers, pet projects, internal APIs, dashboards +- You want to share them with a small trusted group +- You don't want to run an identity provider +- You don't want to depend on a third-party SaaS + +## Who this is not for + +- You need real SSO, audit logs, or user-attribute policies: use Authelia / Authentik / Zitadel +- You have hundreds of users: the admin UI doesn't have search or grouping yet, so it gets painful fast. + +## What you get + +- Bearer-token auth with **per-host ACL**: one token can grant access to multiple services, or just one +- **Tiny admin UI** for issuing, and revoking tokens +- **Token TTLs** for auto-expiry + +--- + +## Quick start + +```bash +git clone https://github.com/trofkm/wicket +cd wicket +cp .env.example .env # set env variables +``` + +### SQLite ```bash docker compose up --build ``` -2) Open admin UI: +### Redis +```bash +docker compose -f docker-compose.redis.yml up --build ``` -http://localhost:8000/ + + +Open `http://localhost:8000` for the admin UI. Sign in with `ADMIN_USER` / +`ADMIN_PASS`, create a token for your service's hostname, and wire up your +proxy: + + +### Caddy + +```caddyfile +yourmcp.example.com { + forward_auth wicket:8000 { + uri /auth + } + reverse_proxy yourmcp:3002 +} ``` -3) Create token for hosts (comma-separated), e.g. `example.com,subdomain.example.com`. You will be prompted for Basic Auth (set via env). Optionally set TTL seconds. +### Traefik (Docker labels) -4) Test the auth endpoint: +```yaml +services: + yourmcp: + labels: + - "traefik.http.routers.yourmcp.middlewares=wicket-auth@docker" + - "traefik.http.middlewares.wicket-auth.forwardauth.address=http://wicket:8000/auth" + - "traefik.http.middlewares.wicket-auth.forwardauth.trustForwardHeader=true" +``` + + +> See [`example/`](example/) for full-stack Docker Compose setups. + +--- + +## How it works -```bash -curl -H "Authorization: Bearer " \ - -H "X-Forwarded-Host: example.com" \ - http://localhost:8000/auth +``` + Client + │ + │ GET https://yourmcp.example.com/ + │ Authorization: Bearer + ▼ + Reverse proxy + │ + │ forward-auth: GET /auth + │ X-Forwarded-Host: yourmcp.example.com + ▼ + wicket (this service) + │ + │ lookup in storage + │ is the host in this token's allowlist? + ▼ ``` -- OK → `200 OK` with body `OK` -- Wrong/missing token → `401` -- Token without access to host → `403` +The admin UI shows you the raw token only once, on creation. -### API & UI +--- -- `GET /auth` - - Headers: - - `Authorization: Bearer ` (required) - - `X-Forwarded-Host: ` (Traefik sets this; send manually for testing) - - Responses: `200 OK`, `401`, `403` +## API reference -- `GET /healthz` → `ok` when Redis is reachable +### `GET /auth` -- Admin UI - - `GET /` — list tokens and allowed hosts, email, comment, TTL - - `POST /create_token` (form fields: `hosts`, optional `email`, `comment`, `ttl_seconds`) - - `POST /delete_token` (form field `token`) +Your reverse proxy calls this endpoint. Returns the verdict for a single request. -### Security & Data Model +| Header | Notes | +|-------------------------|-----------------------------------------------| +| `Authorization` | `Bearer `, **Required.** | +| `X-Forwarded-Host` | Set by the reverse proxy. **Required.** | -- Stored as `SHA-256(token + PEPPER)`. Raw tokens are never persisted. -- Key: `tokens:` (hash) - - Field: `hosts` → `host1,host2,...` - - Optional TTL is applied per-token. -- Rate limiting: sliding buckets per token hash (`RATE_LIMIT_WINDOW_SEC`, `RATE_LIMIT_MAX`). +Responses: `200 OK` (allow), `400` (missing `X-Forwarded-Host`), `401` (missing/invalid token or host not allowed), `503` (store unavailable). -### Environment Variables +### `GET /healthz` -- `REDIS_HOST` (default: `localhost`) -- `REDIS_PORT` (default: `6379`) -- `REDIS_DB` (default: `0`) -- `REDIS_USERNAME` (optional) -- `REDIS_PASSWORD` (optional; required if Redis secured) -- `REDIS_TLS` (`true|false`, default `false`) -- `REDIS_TLS_SKIP_VERIFY` (`true|false`, default `false`) -- `PEPPER` (required; server-side secret for hashing) -- `ADMIN_USER`, `ADMIN_PASS` (required for admin UI Basic Auth) -- `TOKEN_TTL_SECONDS` (default `0`, no default TTL) -- `RATE_LIMIT_WINDOW_SEC` (default `1`) -- `RATE_LIMIT_MAX` (default `100`) +Always `200 OK`. Liveness probe. -### Docker Image +### `GET /readyz` -Build manually (if needed): +`200 OK` when the storage backend is reachable, otherwise `503`. Readiness probe. -```bash -docker build -t acl-auth-service:local ./auth_service -``` +### `GET /metrics` -Run manually: +Prometheus metrics endpoint. Exposes: -```bash -docker run --rm -p 8000:8000 \ - -e REDIS_HOST=host.docker.internal \ - acl-auth-service:local -``` +| Metric | Type | Description | +|--------|------|-------------| +| `http_requests_total` | counter | Request count by method, handler, and status | +| `http_request_duration_seconds` | histogram | Request latency | +| `wicket_store_health` | gauge | Storage health (1 = ok, 0 = down) | -### Kubernetes (k3s) Deployment +### Admin UI (Basic Auth) -1) Push your image to a registry. Update `k8s/auth-service.yaml` with the image: +- `GET /`: token manager +- `POST /create_token`: fields: `hosts` (required, comma-separated), `email`, `comment`, `ttl_seconds` +- `POST /delete_token`: field: `token` -```yaml -containers: - - name: auth-service - image: ghcr.io/your-org/acl-auth-service:latest -``` +--- -2) Create secrets and configmap: +## Storage backends -```bash -# ConfigMap -kubectl create configmap auth-service-config \ - --from-env-file=.env \ - -n default \ - --dry-run=client -o yaml | kubectl apply -f - - -# App secrets -kubectl create secret generic auth-service-secrets \ - --from-literal=pepper=CHANGE_ME \ - --from-literal=admin_user=admin \ - --from-literal=admin_pass=CHANGE_ME \ - -n default --dry-run=client -o yaml | kubectl apply -f - - -# Redis password -kubectl create secret generic redis-auth \ - --from-literal=password=CHANGE_ME_REDIS \ - -n default --dry-run=client -o yaml | kubectl apply -f - -``` +Supports two storage backends, selected via `STORAGE_BACKEND`: -3) Deploy: +| Backend | Best for | Notes | +|---------|----------|-------| +| `sqlite`| Single instance, simple setup | Easy to use. Feets 99% of usecase. | +| `redis` | Multiple instances, or if you already run Redis. | Use this if you don't like sqlite or you have highload. | -```bash -kubectl apply -f k8s/auth-service.yaml -kubectl apply -f k8s/traefik-middleware.yaml -kubectl apply -f k8s/ingress-traefik.yaml -kubectl rollout restart deploy/auth-service -n default -``` -The middleware forwards auth checks to `http://auth-service.default.svc.cluster.local:8000/auth`. Add the middleware annotation to any Ingress you want protected. +## Environment variables -### Traefik Middleware & Ingress (example) +| Variable | Default | Notes | +|-------------------------------|---------------|--------------------------------------| +| `PEPPER` | *required* | Server-side secret for hashing | +| `ADMIN_USER` | *required* | Admin UI Basic Auth | +| `ADMIN_PASS` | *required* | Admin UI Basic Auth | +| `STORAGE_BACKEND` | `sqlite` | `sqlite` or `redis` | +| `SQLITE_PATH` | `/data/tokens.db` | Path to SQLite file (sqlite backend only) | +| `TOKEN_TTL_SECONDS` | `0` | `0` = no default TTL | +| `AUTH_FAILURE_WINDOW_SECONDS` | `60` | Sliding window for brute-force detection (seconds) | +| `AUTH_MAX_FAILURES` | `10` | Max failed auth attempts per window before 429 | -`k8s/ingress-traefik.yaml` defines: +**Redis backend only**: -- Middleware: +| Variable | Default | Notes | +|-------------------------------|---------------|--------------------------------------| +| `REDIS_HOST` | `localhost` | | +| `REDIS_PORT` | `6379` | | +| `REDIS_DB` | `0` | | +| `REDIS_USERNAME` | *(none)* | Optional | +| `REDIS_PASSWORD` | *(none)* | Optional | +| `REDIS_TLS` | `false` | | +| `REDIS_TLS_SKIP_VERIFY` | `false` | | +| `REDIS_CA_CERTS` | *(none)* | Path to CA bundle for TLS verify | +| `REDIS_SOCKET_TIMEOUT` | `5.0` | Timeout per operation (seconds) | +| `REDIS_SOCKET_CONNECT_TIMEOUT`| `2.0` | Timeout for initial connect (seconds)| +| `REDIS_MAX_CONNECTIONS` | `20` | Connection pool size | +| `REDIS_HEALTH_CHECK_INTERVAL` | `30` | Connection health check (seconds) | +| `REDIS_RETRY_COUNT` | `3` | Retries on errors | +| `REDIS_CLIENT_NAME` | `wicket` | Shows in redis `CLIENT LIST` | -```yaml -apiVersion: traefik.containo.us/v1alpha1 -kind: Middleware -metadata: - name: auth-middleware - namespace: default -spec: - forwardAuth: - address: "http://auth-service.default.svc.cluster.local:8000/auth" - trustForwardHeader: true -``` +--- -- Ingress (example backend service `firecrawl-service`): +## Security notes -```yaml -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: firecrawl - namespace: default - annotations: - kubernetes.io/ingress.class: traefik - traefik.ingress.kubernetes.io/router.middlewares: default-auth-middleware@kubernetescrd -spec: - rules: - - host: firecrawl.example.com - http: - paths: - - path: / - pathType: Prefix - backend: - service: - name: firecrawl-service - port: - number: 80 -``` +- **Front this with HTTPS** Bearer tokens are sensitive. Don't send them over plain HTTP. +- The admin UI uses Basic Auth. For exposed deployments, add an IP allowlist, a NetworkPolicy, mTLS, or put the UI behind a separate route. -### Security Notes +--- -- Use HTTPS (Traefik TLS) to encrypt tokens in transit. -- Tokens are hashed with `PEPPER` and never stored raw. Rotate `PEPPER` by re-issuing tokens. -- Use Redis AOF for persistence; back up AOF/RDB off-cluster. -- Consider managed Redis (Sentinel/Cluster) for HA; test restoration regularly. -- Restrict admin UI further with IP allowlists, NetworkPolicies, or mTLS. +## Troubleshooting -### Troubleshooting +| Symptom | Likely cause | +|---|---| +| `400 missing X-Forwarded-Host` | Reverse proxy isn't setting `X-Forwarded-Host`. Check `trustForwardHeader` or equivalent. | +| `401 missing bearer token` | Reverse proxy isn't forwarding `Authorization` | +| `401 invalid token` | Token isn't in the store (deleted/expired), or the host is not in the token's allowlist | +| `503 store unavailable` | Redis is down, or no write permissions for sqlite | -- `401 missing bearer token` — ensure `Authorization: Bearer ` is present. -- `401 invalid token` — token not found in Redis; create via UI. -- `403 forbidden for host` — host not in token's `hosts` list. -- `503 redis unavailable` — check Redis connection/env vars. +--- -### License +## License MIT diff --git a/Tiltfile b/Tiltfile deleted file mode 100644 index 19943fd..0000000 --- a/Tiltfile +++ /dev/null @@ -1,20 +0,0 @@ -# Apply secrets and configmaps from .env file -local_resource( - 'apply-secrets', - cmd='./scripts/apply-secrets.sh', - deps=['.env'], - labels=['config'] -) - -k8s_yaml( - [ - 'k8s/auth-service.yaml', - 'k8s/traefik-middleware.yaml', - 'k8s/ingress-traefik.yaml' - - ] -) - -docker_build('ghcr.io/trofkm/acl-auth-service:latest', 'auth_service') -k8s_resource('auth-service', port_forwards='8000', resource_deps=['apply-secrets']) -allow_k8s_contexts('default') diff --git a/auth_service/.dockerignore b/auth_service/.dockerignore index 70b916d..50db4c9 100644 --- a/auth_service/.dockerignore +++ b/auth_service/.dockerignore @@ -5,11 +5,38 @@ __pycache__/ # Secrets .env +.env.* +*.pem +*.key # Git .git .gitignore +.gitattributes # Docker Dockerfile .dockerignore +docker-compose*.yml + +# Virtual environments +.venv/ +venv/ +env/ + +# IDE / Editor +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Docs / Misc +*.md +*.log +*.tmp +.DS_Store +.ruff_cache/ +.pytest_cache/ + +tests/ diff --git a/auth_service/.python-version b/auth_service/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/auth_service/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/auth_service/Dockerfile b/auth_service/Dockerfile index d1011c0..f1fbedf 100644 --- a/auth_service/Dockerfile +++ b/auth_service/Dockerfile @@ -1,14 +1,44 @@ -FROM python:3.11-slim +# Build stage +FROM python:3.12-slim-bookworm AS builder + +RUN pip install --no-cache-dir uv==0.11.23 ENV PYTHONDONTWRITEBYTECODE=1 \ - PYTHONUNBUFFERED=1 + PYTHONUNBUFFERED=1 \ + UV_COMPILE_BYTECODE=1 \ + UV_LINK_MODE=copy WORKDIR /app -COPY requirements.txt /app/requirements.txt -RUN pip install --no-cache-dir -r /app/requirements.txt +COPY pyproject.toml uv.lock /app/ +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --frozen --no-install-project --no-dev COPY . /app/ +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --frozen --no-dev + +# Runtime stage +FROM python:3.12-slim-bookworm AS runtime + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app + +COPY --from=builder /app /app/ + +RUN groupadd -r app && useradd -r -g app app && \ + chown -R app:app /app && \ + mkdir -p /data && chown app:app /data + +ENV PATH="/app/.venv/bin:$PATH" + +USER app EXPOSE 8000 -CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"] + +HEALTHCHECK --interval=15s --timeout=3s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/healthz')" || exit 1 + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/auth_service/admin/__init__.py b/auth_service/admin/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/auth_service/admin/api.py b/auth_service/admin/api.py new file mode 100644 index 0000000..426efa7 --- /dev/null +++ b/auth_service/admin/api.py @@ -0,0 +1,75 @@ +from fastapi import APIRouter, Depends, HTTPException, Request + +from admin.service import create_token, delete_token, list_tokens +from config import Config +from deps import admin_guard, get_config, get_store +from validators import ( + validate_comment, + validate_email, + validate_hosts, + validate_token_hash, +) + +router = APIRouter(prefix="/api", dependencies=[Depends(admin_guard)]) + + +@router.post("/tokens", status_code=201) +async def create( + request: Request, + store=Depends(get_store), + cfg: Config = Depends(get_config), +) -> dict: + body = await request.json() + hosts = body.get("hosts", "") + validated_hosts = validate_hosts(hosts) + stored_hosts = ",".join(validated_hosts) + + email = body.get("email") + validated_email = validate_email(email) + + ttl_seconds = body.get("ttl_seconds") + if ttl_seconds is not None: + if not isinstance(ttl_seconds, int): + raise HTTPException(status_code=422, detail="ttl_seconds must be integer") + if ttl_seconds < 0: + raise HTTPException(status_code=422, detail="ttl_seconds must be >= 0") + + comment = body.get("comment") + if comment is not None: + if not isinstance(comment, str): + raise HTTPException(status_code=422, detail="comment must be string") + comment = validate_comment(comment) + + raw_token = await create_token( + store=store, + hosts=stored_hosts, + pepper=cfg.pepper.get_secret_value(), + default_ttl=cfg.token_ttl_seconds, + ttl_seconds=ttl_seconds, + email=validated_email, + comment=comment, + ) + return {"token": raw_token} + + +@router.get("/tokens") +async def list_all( + request: Request, + store=Depends(get_store), + page: int = 1, + size: int = 20, +) -> dict: + if page < 1: + raise HTTPException(status_code=422, detail="page must be >= 1") + if size < 1 or size > 100: + raise HTTPException(status_code=422, detail="size must be 1..100") + return await list_tokens(store, page=page, size=size) + + +@router.delete("/tokens/{token_hash}", status_code=204) +async def delete( + token_hash: str, + store=Depends(get_store), +) -> None: + validate_token_hash(token_hash) + await delete_token(store, token_hash) diff --git a/auth_service/admin/routes.py b/auth_service/admin/routes.py new file mode 100644 index 0000000..62d1bc4 --- /dev/null +++ b/auth_service/admin/routes.py @@ -0,0 +1,22 @@ +from fastapi import APIRouter, Depends, Request +from fastapi.responses import HTMLResponse + +from config import Config +from deps import admin_guard, get_config, templates + +router = APIRouter(dependencies=[Depends(admin_guard)]) + + +@router.get("/", response_class=HTMLResponse) +async def index( + request: Request, + cfg: Config = Depends(get_config), +) -> HTMLResponse: + return templates.TemplateResponse( + request, + "index.html", + { + "request": request, + "default_ttl": cfg.token_ttl_seconds, + }, + ) diff --git a/auth_service/admin/service.py b/auth_service/admin/service.py new file mode 100644 index 0000000..0e24ba2 --- /dev/null +++ b/auth_service/admin/service.py @@ -0,0 +1,93 @@ +import secrets +import time + +from auth.service import hash_token +from log import logger +from storage import TokenStore + + +def _format_tokens(raw_tokens: list[dict]) -> list[dict]: + tokens: list[dict] = [] + for t in raw_tokens: + created_raw = t.get("created_at_raw", "") + created_iso = "" + created_ts = 0 + if created_raw: + try: + created_ts = int(created_raw) + created_iso = time.strftime("%Y-%m-%d %H:%M", time.gmtime(created_ts)) + except Exception: + created_iso = "" + tokens.append( + { + "token": t["token_hash"], + "hosts": t.get("hosts", ""), + "email": t.get("email", ""), + "comment": t.get("comment", ""), + "created_at": created_iso, + "_created_ts": created_ts, + "ttl": t.get("ttl", -1), + } + ) + tokens.sort(key=lambda t: t.get("_created_ts", 0)) + return tokens + + +async def list_tokens(store: TokenStore, page: int = 1, size: int = 20) -> dict: + raw_tokens = await store.list_tokens() + formatted = _format_tokens(raw_tokens) + total = len(formatted) + start = (page - 1) * size + return { + "tokens": formatted[start : start + size], + "page": page, + "size": size, + "total": total, + } + + +async def create_token( + store: TokenStore, + hosts: str, + pepper: str, + default_ttl: int = 0, + ttl_seconds: int | None = None, + email: str | None = None, + comment: str | None = None, +) -> str: + raw_token = secrets.token_urlsafe(32) + token_hash_value = hash_token(raw_token, pepper) + + logger.info( + "token created: hosts=%s email=%s ttl=%s", + hosts, + email or "-", + ttl_seconds or "default", + ) + + parsed_ttl: int = 0 + if ttl_seconds is not None: + parsed_ttl = ttl_seconds + ttl_effective = parsed_ttl if parsed_ttl > 0 else default_ttl + + data = { + "hosts": hosts, + "email": email or "", + "comment": comment or "", + "created_at": str(int(time.time())), + } + await store.set_token(token_hash_value, data, ttl_effective) + + if ttl_effective <= 0: + logger.warning( + "token created with no TTL (hosts=%s, email=%s)", + hosts, + email or "-", + ) + + return raw_token + + +async def delete_token(store: TokenStore, token_hash: str) -> None: + await store.delete_token(token_hash) + logger.info("token deleted: hash=%s...", token_hash[:12]) diff --git a/auth_service/app.py b/auth_service/app.py deleted file mode 100644 index 8c1e22a..0000000 --- a/auth_service/app.py +++ /dev/null @@ -1,246 +0,0 @@ -import hashlib -import os -import secrets -import time -from typing import List, Optional - -import redis -from fastapi import Depends, FastAPI, Form, HTTPException, Request -from fastapi.responses import ( - HTMLResponse, - JSONResponse, - PlainTextResponse, - RedirectResponse, -) -from fastapi.security import HTTPBasic, HTTPBasicCredentials -from fastapi.templating import Jinja2Templates - - -def get_redis_client() -> redis.Redis: - use_tls = os.getenv("REDIS_TLS", "false").lower() in {"1", "true", "yes"} - tls_skip_verify = os.getenv("REDIS_TLS_SKIP_VERIFY", "false").lower() in { - "1", - "true", - "yes", - } - ssl_params = {} - if use_tls: - ssl_params.update( - { - "ssl": True, - "ssl_cert_reqs": None if tls_skip_verify else "required", - } - ) - - password = os.getenv("REDIS_PASSWORD") - username = os.getenv("REDIS_USERNAME") - - return redis.Redis( - host=os.getenv("REDIS_HOST", "localhost"), - port=int(os.getenv("REDIS_PORT", "6379")), - db=int(os.getenv("REDIS_DB", "0")), - username=username, - password=password, - decode_responses=True, - **ssl_params, - ) - - -redis_client = get_redis_client() - -app = FastAPI(title="ACL Proxy Auth Service", version="0.1.0") -templates = Jinja2Templates( - directory=os.path.join(os.path.dirname(__file__), "templates") -) -security = HTTPBasic() - - -def get_env(name: str, default: Optional[str] = None) -> Optional[str]: - value = os.getenv(name) - return value if value is not None else default - - -def hash_token(raw_token: str, pepper: str) -> str: - # Raw tokens are never persisted. - digest = hashlib.sha256((raw_token + pepper).encode("utf-8")).hexdigest() - return digest - - -def admin_guard(credentials: HTTPBasicCredentials = Depends(security)) -> None: - admin_user = get_env("ADMIN_USER", "admin") - admin_pass = get_env("ADMIN_PASS") - if not admin_pass: - # If not configured, deny rather than allow - raise HTTPException(status_code=503, detail="admin auth not configured") - - correct = credentials.username == admin_user and secrets.compare_digest( - credentials.password, admin_pass - ) - if not correct: - raise HTTPException(status_code=401, detail="unauthorized") - - -@app.get("/healthz", response_class=PlainTextResponse) -async def healthz() -> str: - try: - redis_client.ping() - return "ok" - except Exception: - raise HTTPException(status_code=503, detail="redis unavailable") - - -def parse_allowed_hosts(raw_hosts: str) -> List[str]: - if not raw_hosts: - return [] - return [h.strip().lower() for h in raw_hosts.split(",") if h.strip()] - - -@app.get("/auth", response_class=PlainTextResponse) -async def auth(request: Request) -> str: - auth_header = request.headers.get("authorization") or request.headers.get( - "Authorization" - ) - if not auth_header or not auth_header.startswith("Bearer "): - raise HTTPException(status_code=401, detail="missing bearer token") - - token = auth_header.split(" ", 1)[1].strip() - if not token: - raise HTTPException(status_code=401, detail="empty token") - - pepper = get_env("PEPPER", "") - if not pepper: - raise HTTPException(status_code=503, detail="server not initialized") - - token_hash = hash_token(token, pepper) - - # Per-token hash rate limit - window_sec = int(get_env("RATE_LIMIT_WINDOW_SEC", "1")) - max_hits = int(get_env("RATE_LIMIT_MAX", "100")) - now = int(time.time()) - window_bucket = now - (now % window_sec) - rl_key = f"ratelimit:{token_hash}:{window_bucket}" - current = redis_client.incr(rl_key) - if current == 1: - redis_client.expire(rl_key, window_sec + 1) - if current > max_hits: - raise HTTPException(status_code=429, detail="rate limit exceeded") - - token_key = f"tokens:{token_hash}" - token_data = redis_client.hgetall(token_key) - if not token_data: - raise HTTPException(status_code=401, detail="invalid token") - - allowed_hosts = parse_allowed_hosts(token_data.get("hosts", "")) - - # Traefik passes X-Forwarded-Host when trustForwardHeader=true. - requested_host = ( - request.headers.get("X-Forwarded-Host") - or request.headers.get("x-forwarded-host") - or (request.url.hostname or "").lower() - ) - - if not requested_host: - raise HTTPException(status_code=400, detail="cannot determine requested host") - - requested_host = requested_host.lower() - - if allowed_hosts and requested_host not in allowed_hosts: - raise HTTPException(status_code=403, detail="forbidden for host") - - # Empty allowed_hosts = no access. - if not allowed_hosts: - raise HTTPException(status_code=403, detail="no hosts assigned for token") - - # Success tells Traefik to continue the request to the backend service - return "OK" - - -@app.get("/debug/token/{token}") -async def debug_token(token: str, _: None = Depends(admin_guard)) -> JSONResponse: - token_key = f"tokens:{token}" - token_data = redis_client.hgetall(token_key) - return JSONResponse({"exists": bool(token_data), "data": token_data}) - - -# --- Admin UI --- -@app.get("/", response_class=HTMLResponse) -async def index(request: Request, _: None = Depends(admin_guard)) -> HTMLResponse: - tokens = [] - for key in redis_client.scan_iter("tokens:*"): - token_value = key.split(":", 1)[1] - data = redis_client.hgetall(key) - ttl_seconds = redis_client.ttl(key) - created_raw = data.get("created_at", "") - created_iso = "" - created_ts = 0 - if created_raw: - try: - created_ts = int(created_raw) - created_iso = time.strftime("%Y-%m-%d %H:%M", time.gmtime(created_ts)) - except Exception: - created_iso = "" - tokens.append( - { - "token": token_value, - "hosts": data.get("hosts", ""), - "email": data.get("email", ""), - "comment": data.get("comment", ""), - "created_at": created_iso, - "_created_ts": created_ts, - "ttl": ttl_seconds, - } - ) - tokens.sort(key=lambda t: t.get("_created_ts", 0)) - - default_ttl = int(get_env("TOKEN_TTL_SECONDS", "0") or 0) - return templates.TemplateResponse( - "index.html", - {"request": request, "tokens": tokens, "default_ttl": default_ttl}, - ) - - -@app.post("/create_token") -async def create_token( - hosts: str = Form(...), - ttl_seconds: Optional[str] = Form(None), - email: Optional[str] = Form(None), - comment: Optional[str] = Form(None), - _: None = Depends(admin_guard), -) -> JSONResponse: - # Raw token shown only once; never stored. - raw_token = secrets.token_urlsafe(32) - pepper = get_env("PEPPER", "") - if not pepper: - raise HTTPException(status_code=503, detail="server not initialized") - token_hash_value = hash_token(raw_token, pepper) - - key = f"tokens:{token_hash_value}" - redis_client.hset( - key, - mapping={ - "hosts": hosts, - "email": (email or ""), - "comment": (comment or ""), - "created_at": str(int(time.time())), - }, - ) - default_ttl = int(get_env("TOKEN_TTL_SECONDS", "0") or 0) - parsed_ttl: int = 0 - if ttl_seconds is not None and ttl_seconds != "": - try: - parsed_ttl = int(ttl_seconds) - except ValueError: - raise HTTPException(status_code=422, detail="ttl_seconds must be integer") - ttl_effective = parsed_ttl if parsed_ttl > 0 else default_ttl - if ttl_effective and ttl_effective > 0: - redis_client.expire(key, ttl_effective) - - return JSONResponse({"token": raw_token, "hash": token_hash_value}) - - -@app.post("/delete_token") -async def delete_token( - token: str = Form(...), _: None = Depends(admin_guard) -) -> RedirectResponse: - redis_client.delete(f"tokens:{token}") - return RedirectResponse(url="/", status_code=303) diff --git a/auth_service/auth/__init__.py b/auth_service/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/auth_service/auth/routes.py b/auth_service/auth/routes.py new file mode 100644 index 0000000..cbe0c3b --- /dev/null +++ b/auth_service/auth/routes.py @@ -0,0 +1,60 @@ +from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi.responses import PlainTextResponse + +from auth.service import ForbiddenHostError, InvalidTokenError, validate_token +from config import Config +from deps import check_throttle, get_auth_throttle, get_client_ip, get_config, get_store +from log import logger + +router = APIRouter() + + +async def _record_failure(request: Request) -> None: + throttle = get_auth_throttle(request) + client_ip = get_client_ip(request) + await throttle.record_failure(client_ip) + + +@router.api_route( + "/auth", + methods=["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"], + response_class=PlainTextResponse, +) +async def auth( + request: Request, + store=Depends(get_store), + cfg: Config = Depends(get_config), + _: None = Depends(check_throttle), +) -> str: + auth_header = request.headers.get("authorization") + if not auth_header: + await _record_failure(request) + raise HTTPException(status_code=401, detail="missing bearer token") + + host = request.headers.get("X-Forwarded-Host") + + parts = auth_header.split(maxsplit=1) + if len(parts) < 2 or parts[0].lower() != "bearer": + logger.warning("auth: bad auth header format, host=%s", host) + await _record_failure(request) + raise HTTPException(status_code=401, detail="invalid authorization header") + + token = parts[1] + if not token: + logger.warning("auth: empty token, host=%s", host) + await _record_failure(request) + raise HTTPException(status_code=401, detail="empty token") + + if not host: + raise HTTPException( + status_code=400, + detail="missing X-Forwarded-Host header (must be set by reverse proxy)", + ) + + try: + await validate_token(store, token, host, cfg.pepper.get_secret_value()) + except (InvalidTokenError, ForbiddenHostError): + await _record_failure(request) + raise HTTPException(status_code=401, detail="invalid token") + + return "OK" diff --git a/auth_service/auth/service.py b/auth_service/auth/service.py new file mode 100644 index 0000000..634e937 --- /dev/null +++ b/auth_service/auth/service.py @@ -0,0 +1,49 @@ +import hashlib +import hmac + +from log import logger +from storage import TokenStore +from validators import host_matches, parse_allowed_hosts + + +class InvalidTokenError(Exception): + pass + + +class ForbiddenHostError(Exception): + pass + + +def hash_token(raw_token: str, pepper: str) -> str: + digest = hmac.new( + pepper.encode("utf-8"), raw_token.encode("utf-8"), hashlib.sha256 + ).hexdigest() + return digest + + +async def validate_token( + store: TokenStore, raw_token: str, host: str, pepper: str +) -> None: + token_hash = hash_token(raw_token, pepper) + token_data = await store.get_token(token_hash) + + if not token_data: + logger.warning("auth: invalid token, host=%s", host) + raise InvalidTokenError() + + allowed_hosts = parse_allowed_hosts(token_data.get("hosts", "")) + + if not allowed_hosts: + logger.warning("auth: token has no hosts, token_hash=%s...", token_hash[:12]) + raise ForbiddenHostError() + + requested_host = host.lower() + matched = any(host_matches(requested_host, pattern) for pattern in allowed_hosts) + if not matched: + logger.warning( + "auth: forbidden, host=%s not in allowed=%s, token_hash=%s...", + requested_host, + allowed_hosts, + token_hash[:12], + ) + raise ForbiddenHostError() diff --git a/auth_service/config.py b/auth_service/config.py new file mode 100644 index 0000000..d9021e7 --- /dev/null +++ b/auth_service/config.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +from enum import StrEnum +from pathlib import Path + +from pydantic import Field, SecretStr, field_validator, model_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class StorageBackend(StrEnum): + SQLITE = "sqlite" + REDIS = "redis" + + +class Config(BaseSettings): + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + extra="forbid", + frozen=True, + validate_default=True, + ) + + pepper: SecretStr = Field( + ..., + validation_alias="PEPPER", + description="Server-side secret for HMAC token hashing", + ) + admin_user: str = Field( + ..., + min_length=1, + validation_alias="ADMIN_USER", + description="Admin UI basic-auth username", + ) + admin_pass: SecretStr = Field( + ..., + validation_alias="ADMIN_PASS", + description="Admin UI basic-auth password", + ) + + storage_backend: StorageBackend = StorageBackend.SQLITE + sqlite_path: Path = Path("/data/tokens.db") + + redis_host: str = "localhost" + redis_port: int = Field(6379, ge=1, le=65535) + redis_db: int = Field(0, ge=0) + redis_username: str | None = None + redis_password: str | None = None + redis_tls: bool = False + redis_tls_skip_verify: bool = False + redis_ca_certs: str | None = None + redis_socket_timeout: float = Field(5.0, gt=0) + redis_socket_connect_timeout: float = Field(2.0, gt=0) + redis_max_connections: int = Field(20, ge=1) + redis_health_check_interval: int = Field(30, ge=1) + redis_retry_count: int = Field(3, ge=0) + redis_client_name: str = "wicket" + + token_ttl_seconds: int = Field(0, ge=0) + + auth_failure_window_seconds: int = Field(60, ge=1) + auth_max_failures: int = Field(10, ge=1) + + @field_validator( + "redis_username", "redis_password", "redis_ca_certs", mode="before" + ) + @classmethod + def _empty_str_to_none(cls, v: object) -> object: + if isinstance(v, str) and v.strip() == "": + return None + return v + + @field_validator("sqlite_path", mode="before") + @classmethod + def _coerce_path(cls, v: object) -> object: + if isinstance(v, str) and v.strip(): + return Path(v) + return v + + @model_validator(mode="after") + def _check_secrets_not_default(self) -> Config: + if self.pepper.get_secret_value() in ("", "change-me", "change-me-pepper"): + raise ValueError("PEPPER must not be empty or the example placeholder") + if self.admin_pass.get_secret_value() in ("", "admin", "change-me"): + raise ValueError("ADMIN_PASS must not be empty or a trivial default") + if self.admin_user in ("", "admin", "change-me"): + raise ValueError("ADMIN_USER must not be empty or a trivial default") + return self + + @model_validator(mode="after") + def _check_redis_when_needed(self) -> Config: + if self.storage_backend is not StorageBackend.REDIS: + return self + if not self.redis_host.strip(): + raise ValueError("storage_backend=redis but REDIS_HOST is empty") + return self + + @model_validator(mode="after") + def _check_sqlite_parent_is_dir(self) -> Config: + if self.storage_backend is not StorageBackend.SQLITE: + return self + parent = self.sqlite_path.parent + if parent.exists() and not parent.is_dir(): + raise ValueError(f"SQLITE_PATH parent is not a directory: {parent}") + return self diff --git a/auth_service/deps.py b/auth_service/deps.py new file mode 100644 index 0000000..e28b63a --- /dev/null +++ b/auth_service/deps.py @@ -0,0 +1,75 @@ +import secrets + +from fastapi import Depends, HTTPException, Request +from fastapi.security import HTTPBasic, HTTPBasicCredentials +from fastapi.templating import Jinja2Templates + +from config import Config +from log import logger +from storage import TokenStore +from throttle import FailureStore + +templates = Jinja2Templates(directory="templates") +security = HTTPBasic() + + +def get_store(request: Request) -> TokenStore: + return request.app.state.store + + +def get_config(request: Request) -> Config: + return request.app.state.config + + +def get_auth_throttle(request: Request) -> FailureStore: + return request.app.state.auth_throttle + + +def get_admin_throttle(request: Request) -> FailureStore: + return request.app.state.admin_throttle + + +def get_client_ip(request: Request) -> str: + forwarded = request.headers.get("X-Forwarded-For") + if forwarded: + return forwarded.split(",")[0].strip() + if request.client: + return request.client.host + return "unknown" + + +async def check_throttle(request: Request, cfg: Config = Depends(get_config)) -> None: + throttle = get_auth_throttle(request) + client_ip = get_client_ip(request) + if await throttle.is_blocked(client_ip): + logger.warning("auth_throttled", ip=client_ip) + raise HTTPException( + status_code=429, + detail="too many failed auth attempts, retry later", + headers={"Retry-After": str(cfg.auth_failure_window_seconds)}, + ) + + +async def admin_guard( + request: Request, + credentials: HTTPBasicCredentials = Depends(security), + cfg: Config = Depends(get_config), +) -> None: + throttle = get_admin_throttle(request) + client_ip = get_client_ip(request) + if await throttle.is_blocked(client_ip): + raise HTTPException( + status_code=429, + detail="too many failed admin auth attempts, retry later", + headers={"Retry-After": str(cfg.auth_failure_window_seconds)}, + ) + + admin_pass = cfg.admin_pass.get_secret_value() + if not admin_pass: + raise HTTPException(status_code=503, detail="admin auth not configured") + + user_ok = secrets.compare_digest(credentials.username, cfg.admin_user) + pass_ok = secrets.compare_digest(credentials.password, admin_pass) + if not (user_ok and pass_ok): + await throttle.record_failure(client_ip) + raise HTTPException(status_code=401, detail="unauthorized") diff --git a/auth_service/infra/__init__.py b/auth_service/infra/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/auth_service/infra/routes.py b/auth_service/infra/routes.py new file mode 100644 index 0000000..1aa5dfb --- /dev/null +++ b/auth_service/infra/routes.py @@ -0,0 +1,27 @@ +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import PlainTextResponse + +from deps import get_store +from metrics import store_health +from storage import TokenStore + +router = APIRouter() + + +@router.get("/healthz", response_class=PlainTextResponse) +async def healthz() -> str: + return "ok" + + +@router.get("/readyz", response_class=PlainTextResponse) +async def readyz(store: TokenStore = Depends(get_store)) -> str: + try: + healthy = await store.health() + except Exception: + healthy = False + + store_health.set(1 if healthy else 0) + + if healthy: + return "ok" + raise HTTPException(status_code=503, detail="store unavailable") diff --git a/auth_service/log.py b/auth_service/log.py new file mode 100644 index 0000000..a4b15d2 --- /dev/null +++ b/auth_service/log.py @@ -0,0 +1,95 @@ +import logging +import sys +import time +import uuid + +import structlog +from starlette.types import ASGIApp, Receive, Scope, Send + +logger = structlog.get_logger() + + +def configure_logging(json_logs: bool = True, level: str = "INFO") -> None: + shared_processors = [ + structlog.contextvars.merge_contextvars, + structlog.stdlib.add_logger_name, + structlog.stdlib.add_log_level, + structlog.processors.TimeStamper(fmt="iso", utc=True), + structlog.processors.StackInfoRenderer(), + structlog.processors.format_exc_info, + ] + + renderer = ( + structlog.processors.JSONRenderer() + if json_logs + else structlog.dev.ConsoleRenderer(colors=True) + ) + + structlog.configure( + processors=shared_processors + + [ + structlog.stdlib.ProcessorFormatter.wrap_for_formatter, + ], + logger_factory=structlog.stdlib.LoggerFactory(), + wrapper_class=structlog.stdlib.BoundLogger, + cache_logger_on_first_use=True, + ) + + handler = logging.StreamHandler(sys.stdout) + handler.setFormatter( + structlog.stdlib.ProcessorFormatter( + processor=renderer, + foreign_pre_chain=shared_processors, + ) + ) + root = logging.getLogger() + root.handlers = [handler] + root.setLevel(level) + + logging.getLogger("uvicorn.access").handlers = [] + logging.getLogger("uvicorn.access").propagate = False + + +class LoggingMiddleware: + def __init__(self, app: ASGIApp) -> None: + self.app = app + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] != "http": + await self.app(scope, receive, send) + return + + structlog.contextvars.clear_contextvars() + request_id = str(uuid.uuid4()) + structlog.contextvars.bind_contextvars( + request_id=request_id, + method=scope["method"], + path=scope["path"], + ) + + status_code = 500 + errored = False + + async def send_wrapper(message): + nonlocal status_code + if message["type"] == "http.response.start": + status_code = message["status"] + message.setdefault("headers", []).append( + (b"x-request-id", request_id.encode()) + ) + await send(message) + + start = time.perf_counter() + try: + await self.app(scope, receive, send_wrapper) + except Exception: + errored = True + logger.exception("request_failed") + raise + finally: + if not errored: + logger.info( + "request_completed", + status_code=status_code, + duration_ms=round((time.perf_counter() - start) * 1000, 2), + ) diff --git a/auth_service/main.py b/auth_service/main.py new file mode 100644 index 0000000..642a5f1 --- /dev/null +++ b/auth_service/main.py @@ -0,0 +1,97 @@ +import sys +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from fastapi.staticfiles import StaticFiles +from prometheus_fastapi_instrumentator import Instrumentator + +from admin.api import router as api_router +from admin.routes import router as admin_router +from auth.routes import router as auth_router +from config import Config, StorageBackend +from infra.routes import router as infra_router +from log import LoggingMiddleware, configure_logging +from redis_store import RedisTokenStore +from sqlite_store import SQLiteTokenStore +from storage import TokenStore +from throttle import FailureStore, MemoryFailureStore, RedisFailureStore + + +async def init_store(cfg: Config) -> TokenStore: + backend = cfg.storage_backend + if backend == StorageBackend.REDIS: + return RedisTokenStore(cfg) + elif backend == StorageBackend.SQLITE: + store = SQLiteTokenStore(str(cfg.sqlite_path)) + await store.init() + return store + else: + raise ValueError(f"unknown STORAGE_BACKEND: {backend}") + + +async def init_auth_throttle(cfg: Config) -> FailureStore: + backend = cfg.storage_backend + window = cfg.auth_failure_window_seconds + max_fail = cfg.auth_max_failures + if backend == StorageBackend.REDIS: + return RedisFailureStore( + cfg, prefix="auth", window_seconds=window, max_failures=max_fail + ) + else: + return MemoryFailureStore(window_seconds=window, max_failures=max_fail) + + +async def init_admin_throttle(cfg: Config) -> FailureStore: + backend = cfg.storage_backend + window = cfg.auth_failure_window_seconds + max_fail = cfg.auth_max_failures + if backend == StorageBackend.REDIS: + return RedisFailureStore( + cfg, prefix="admin", window_seconds=window, max_failures=max_fail + ) + else: + return MemoryFailureStore(window_seconds=window, max_failures=max_fail) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + configure_logging(json_logs=True, level="INFO") + + try: + cfg = Config() # type: ignore[call-arg] + except Exception as exc: + if hasattr(exc, "errors"): + print("Configuration errors:", file=sys.stderr) + for err in exc.errors(): # type: ignore[union-attr] + loc = " → ".join(str(p) for p in err["loc"]) + print(f" • {loc}: {err['msg']}", file=sys.stderr) + else: + print(f"Configuration error: {exc}", file=sys.stderr) + sys.exit(1) + + app.state.config = cfg + + store = await init_store(cfg) + app.state.store = store + + auth_throttle = await init_auth_throttle(cfg) + app.state.auth_throttle = auth_throttle + admin_throttle = await init_admin_throttle(cfg) + app.state.admin_throttle = admin_throttle + + yield + await auth_throttle.close() + await admin_throttle.close() + await store.close() + + +app = FastAPI(title="Wicket", version="0.1.0", lifespan=lifespan) +app.add_middleware(LoggingMiddleware) + +app.mount("/static", StaticFiles(directory="static"), name="static") +app.include_router(auth_router) +app.include_router(admin_router) +app.include_router(api_router) +app.include_router(infra_router) + +Instrumentator().instrument(app).expose(app, include_in_schema=False) diff --git a/auth_service/metrics.py b/auth_service/metrics.py new file mode 100644 index 0000000..05fca92 --- /dev/null +++ b/auth_service/metrics.py @@ -0,0 +1,6 @@ +from prometheus_client import Gauge + +store_health = Gauge( + "wicket_store_health", + "Storage backend health (1 = reachable, 0 = unreachable)", +) diff --git a/auth_service/pyproject.toml b/auth_service/pyproject.toml new file mode 100644 index 0000000..78d4fe3 --- /dev/null +++ b/auth_service/pyproject.toml @@ -0,0 +1,44 @@ +[project] +name = "wicket" +version = "0.1.0" +requires-python = ">=3.12" +dependencies = [ + "aiosqlite>=0.20.0", + "fastapi>=0.115.0", + "prometheus-fastapi-instrumentator>=7.1.0", + "pydantic-settings>=2.0", + "uvicorn[standard]>=0.30.6", + "redis>=5.0.7", + "jinja2>=3.1.6", + "python-multipart>=0.0.9", + "structlog>=26.1.0", +] + +[dependency-groups] +dev = [ + "bandit[toml]>=1.7.0", + "httpx>=0.28.1", + "pip-audit>=2.7.0", + "pytest>=8.3.4", + "pytest-asyncio>=0.24.0", + "ruff>=0.8.0", +] + +[tool.ruff] +target-version = "py312" +line-length = 88 + +[tool.ruff.lint] +select = ["E", "F", "I", "N", "W"] + +[tool.ruff.format] +quote-style = "double" + +[tool.bandit] +severity = ["high"] +confidence = ["high"] +targets = ["."] +exclude_dirs = [".venv", ".ruff_cache", ".pytest_cache", "__pycache__"] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/auth_service/redis_store.py b/auth_service/redis_store.py new file mode 100644 index 0000000..48641c0 --- /dev/null +++ b/auth_service/redis_store.py @@ -0,0 +1,99 @@ +from redis.asyncio import Redis +from redis.asyncio.retry import Retry +from redis.backoff import ExponentialBackoff +from redis.exceptions import ConnectionError, TimeoutError + +from config import Config + + +class RedisTokenStore: + def __init__(self, cfg: Config) -> None: + ssl_params = {} + if cfg.redis_tls: + ssl_params["ssl"] = True + ssl_params["ssl_cert_reqs"] = ( + None if cfg.redis_tls_skip_verify else "required" + ) + if cfg.redis_tls_skip_verify: + ssl_params["ssl_check_hostname"] = False + if cfg.redis_ca_certs: + ssl_params["ssl_ca_certs"] = cfg.redis_ca_certs + + retry = None + if cfg.redis_retry_count > 0: + retry = Retry( + backoff=ExponentialBackoff(), + retries=cfg.redis_retry_count, + ) + + self._client = Redis( + host=cfg.redis_host, + port=cfg.redis_port, + db=cfg.redis_db, + username=cfg.redis_username, + password=cfg.redis_password, + decode_responses=True, + socket_timeout=cfg.redis_socket_timeout, + socket_connect_timeout=cfg.redis_socket_connect_timeout, + max_connections=cfg.redis_max_connections, + health_check_interval=cfg.redis_health_check_interval, + client_name=cfg.redis_client_name, + retry=retry, + retry_on_error=[ConnectionError, TimeoutError], + **ssl_params, + ) + + async def get_token(self, token_hash: str) -> dict | None: + token_data = await self._client.hgetall(f"tokens:{token_hash}") + if not token_data: + return None + return dict(token_data) + + async def set_token(self, token_hash: str, data: dict, ttl: int = 0) -> None: + key = f"tokens:{token_hash}" + await self._client.hset(key, mapping=data) + if ttl > 0: + await self._client.expire(key, ttl) + + async def delete_token(self, token_hash: str) -> None: + await self._client.delete(f"tokens:{token_hash}") + + async def list_tokens(self) -> list[dict]: + keys = [key async for key in self._client.scan_iter("tokens:*")] + if not keys: + return [] + + pipe = self._client.pipeline(transaction=False) + for key in keys: + pipe.hgetall(key) + pipe.ttl(key) + results = await pipe.execute() + + tokens: list[dict] = [] + for idx, key in enumerate(keys): + token_hash = key.split(":", 1)[1] + data = results[idx * 2] + ttl_seconds = results[idx * 2 + 1] + + tokens.append( + { + "token_hash": token_hash, + "hosts": data.get("hosts", ""), + "email": data.get("email", ""), + "comment": data.get("comment", ""), + "created_at_raw": data.get("created_at", ""), + "ttl": ttl_seconds, + } + ) + + return tokens + + async def health(self) -> bool: + try: + await self._client.ping() + return True + except Exception: + return False + + async def close(self) -> None: + await self._client.close() diff --git a/auth_service/requirements.txt b/auth_service/requirements.txt deleted file mode 100644 index 07960e2..0000000 --- a/auth_service/requirements.txt +++ /dev/null @@ -1,5 +0,0 @@ -fastapi==0.115.0 -uvicorn[standard]==0.30.6 -redis==5.0.7 -jinja2==3.1.4 -python-multipart==0.0.9 diff --git a/auth_service/sqlite_store.py b/auth_service/sqlite_store.py new file mode 100644 index 0000000..35fe02b --- /dev/null +++ b/auth_service/sqlite_store.py @@ -0,0 +1,128 @@ +import time + +import aiosqlite + + +class SQLiteTokenStore: + def __init__(self, db_path: str) -> None: + self._db_path = db_path + self._conn: aiosqlite.Connection | None = None + + async def init(self) -> None: + self._conn = await aiosqlite.connect(self._db_path) + self._conn.row_factory = aiosqlite.Row + await self._conn.execute("PRAGMA journal_mode=WAL") + await self._conn.execute("PRAGMA wal_autocheckpoint=1000") + await self._conn.execute("PRAGMA busy_timeout=5000") + await self._conn.execute("PRAGMA foreign_keys=ON") + await self._conn.execute("""CREATE TABLE IF NOT EXISTS tokens ( + token_hash TEXT PRIMARY KEY, + hosts TEXT NOT NULL, + email TEXT DEFAULT '', + comment TEXT DEFAULT '', + created_at INTEGER NOT NULL, + expires_at INTEGER DEFAULT 0 + )""") + await self._conn.commit() + + async def get_token(self, token_hash: str) -> dict | None: + conn = self._conn + if conn is None: + return None + cursor = await conn.execute( + "SELECT hosts, email, comment, created_at, expires_at FROM tokens " + "WHERE token_hash = ?", + (token_hash,), + ) + row = await cursor.fetchone() + if row is None: + return None + + hosts, email, comment, created_at, expires_at = row + if expires_at > 0 and expires_at < int(time.time()): + await conn.execute("DELETE FROM tokens WHERE token_hash = ?", (token_hash,)) + await conn.commit() + return None + + return { + "hosts": hosts, + "email": email, + "comment": comment, + "created_at": str(created_at), + } + + async def set_token(self, token_hash: str, data: dict, ttl: int = 0) -> None: + conn = self._conn + if conn is None: + return + created_at = int(data.get("created_at", int(time.time()))) + expires_at = int(time.time()) + ttl if ttl > 0 else 0 + await conn.execute( + """INSERT OR REPLACE INTO tokens + (token_hash, hosts, email, comment, created_at, expires_at) + VALUES (?, ?, ?, ?, ?, ?)""", + ( + token_hash, + data.get("hosts", ""), + data.get("email", ""), + data.get("comment", ""), + created_at, + expires_at, + ), + ) + await conn.commit() + + async def delete_token(self, token_hash: str) -> None: + conn = self._conn + if conn is None: + return + await conn.execute("DELETE FROM tokens WHERE token_hash = ?", (token_hash,)) + await conn.commit() + + async def list_tokens(self) -> list[dict]: + conn = self._conn + if conn is None: + return [] + cursor = await conn.execute( + "SELECT token_hash, hosts, email, comment, created_at, expires_at " + "FROM tokens" + ) + rows = await cursor.fetchall() + now = int(time.time()) + tokens: list[dict] = [] + expired: list[str] = [] + for row in rows: + token_hash, hosts, email, comment, created_at, expires_at = row + if expires_at > 0 and expires_at < now: + expired.append(token_hash) + continue + tokens.append( + { + "token_hash": token_hash, + "hosts": hosts, + "email": email, + "comment": comment, + "created_at_raw": str(created_at), + "ttl": max(0, expires_at - now) if expires_at > 0 else -1, + } + ) + if expired: + for h in expired: + await conn.execute("DELETE FROM tokens WHERE token_hash = ?", (h,)) + await conn.commit() + return tokens + + async def health(self) -> bool: + try: + conn = self._conn + if conn is None: + return False + await conn.execute("SELECT 1") + return True + except Exception: + return False + + async def close(self) -> None: + if self._conn is not None: + await self._conn.close() + self._conn = None diff --git a/auth_service/static/css/LICENSE.pico.md b/auth_service/static/css/LICENSE.pico.md new file mode 100644 index 0000000..94af9b1 --- /dev/null +++ b/auth_service/static/css/LICENSE.pico.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019-2024 Pico + +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/auth_service/static/css/app.css b/auth_service/static/css/app.css new file mode 100644 index 0000000..8f34148 --- /dev/null +++ b/auth_service/static/css/app.css @@ -0,0 +1,79 @@ +main { + max-width: 960px; +} + +.ttl-presets { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + margin-bottom: 1rem; +} +.ttl-preset { + font-size: 0.8rem; + padding: 0.3rem 0.75rem; + border-radius: 2rem; + border: 1px solid var(--pico-muted-border-color); + background: transparent; + color: var(--pico-muted-color); + cursor: pointer; + transition: all 0.15s ease; +} +.ttl-preset:hover { + border-color: var(--pico-primary); + color: var(--pico-primary); +} +.ttl-preset.active { + background: var(--pico-primary); + border-color: var(--pico-primary); + color: var(--pico-primary-inverse); +} +table { + font-size: 0.875rem; +} +td:first-child, +td:last-child { + white-space: nowrap; +} +.empty-state { + text-align: center; + padding: 2rem 0; + color: var(--pico-muted-color); +} +.input-error { + --pico-form-element-border-color: var( + --pico-form-element-invalid-border-color + ); + --pico-form-element-focus-color: var( + --pico-form-element-invalid-active-border-color + ); +} +.field-error { + display: block; + margin-top: 0.25rem; + font-size: 0.8rem; + color: var(--pico-form-element-invalid-border-color); +} +button.destructive { + --pico-background-color: var(--pico-form-element-invalid-border-color); + --pico-border-color: var(--pico-form-element-invalid-border-color); + --pico-color: var(--pico-primary-inverse); + --pico-form-element-focus-color: var( + --pico-form-element-invalid-active-border-color + ); +} +button.destructive:hover, +button.destructive:active, +button.destructive:focus { + --pico-background-color: var( + --pico-form-element-invalid-active-border-color + ); + --pico-border-color: var(--pico-form-element-invalid-active-border-color); +} +#token-value { + font-family: var(--pico-font-family-monospace); + font-size: 1rem; + user-select: all; +} +#confirm-token-prefix { + font-family: var(--pico-font-family-monospace); +} diff --git a/auth_service/static/css/pico.classless.min.css b/auth_service/static/css/pico.classless.min.css new file mode 100644 index 0000000..3e19952 --- /dev/null +++ b/auth_service/static/css/pico.classless.min.css @@ -0,0 +1,4 @@ +@charset "UTF-8";/*! + * Pico CSS ✨ v2.1.1 (https://picocss.com) + * Copyright 2019-2025 - Licensed under MIT + */:host,:root{--pico-font-family-emoji:"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--pico-font-family-sans-serif:system-ui,"Segoe UI",Roboto,Oxygen,Ubuntu,Cantarell,Helvetica,Arial,"Helvetica Neue",sans-serif,var(--pico-font-family-emoji);--pico-font-family-monospace:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,"Liberation Mono",monospace,var(--pico-font-family-emoji);--pico-font-family:var(--pico-font-family-sans-serif);--pico-line-height:1.5;--pico-font-weight:400;--pico-font-size:100%;--pico-text-underline-offset:0.1rem;--pico-border-radius:0.25rem;--pico-border-width:0.0625rem;--pico-outline-width:0.125rem;--pico-transition:0.2s ease-in-out;--pico-spacing:1rem;--pico-typography-spacing-vertical:1rem;--pico-block-spacing-vertical:var(--pico-spacing);--pico-block-spacing-horizontal:var(--pico-spacing);--pico-form-element-spacing-vertical:0.75rem;--pico-form-element-spacing-horizontal:1rem;--pico-group-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-group-box-shadow-focus-with-button:0 0 0 var(--pico-outline-width) var(--pico-primary-focus);--pico-group-box-shadow-focus-with-input:0 0 0 0.0625rem var(--pico-form-element-border-color);--pico-modal-overlay-backdrop-filter:blur(0.375rem);--pico-nav-element-spacing-vertical:1rem;--pico-nav-element-spacing-horizontal:0.5rem;--pico-nav-link-spacing-vertical:0.5rem;--pico-nav-link-spacing-horizontal:0.5rem;--pico-nav-breadcrumb-divider:">";--pico-icon-checkbox:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(255, 255, 255)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-minus:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(255, 255, 255)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='5' y1='12' x2='19' y2='12'%3E%3C/line%3E%3C/svg%3E");--pico-icon-chevron:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-date:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='4' width='18' height='18' rx='2' ry='2'%3E%3C/rect%3E%3Cline x1='16' y1='2' x2='16' y2='6'%3E%3C/line%3E%3Cline x1='8' y1='2' x2='8' y2='6'%3E%3C/line%3E%3Cline x1='3' y1='10' x2='21' y2='10'%3E%3C/line%3E%3C/svg%3E");--pico-icon-time:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cpolyline points='12 6 12 12 16 14'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-search:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='11' cy='11' r='8'%3E%3C/circle%3E%3Cline x1='21' y1='21' x2='16.65' y2='16.65'%3E%3C/line%3E%3C/svg%3E");--pico-icon-close:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='18' y1='6' x2='6' y2='18'%3E%3C/line%3E%3Cline x1='6' y1='6' x2='18' y2='18'%3E%3C/line%3E%3C/svg%3E");--pico-icon-loading:url("data:image/svg+xml,%3Csvg fill='none' height='24' width='24' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg' %3E%3Cstyle%3E g %7B animation: rotate 2s linear infinite; transform-origin: center center; %7D circle %7B stroke-dasharray: 75,100; stroke-dashoffset: -5; animation: dash 1.5s ease-in-out infinite; stroke-linecap: round; %7D @keyframes rotate %7B 0%25 %7B transform: rotate(0deg); %7D 100%25 %7B transform: rotate(360deg); %7D %7D @keyframes dash %7B 0%25 %7B stroke-dasharray: 1,100; stroke-dashoffset: 0; %7D 50%25 %7B stroke-dasharray: 44.5,100; stroke-dashoffset: -17.5; %7D 100%25 %7B stroke-dasharray: 44.5,100; stroke-dashoffset: -62; %7D %7D %3C/style%3E%3Cg%3E%3Ccircle cx='12' cy='12' r='10' fill='none' stroke='rgb(136, 145, 164)' stroke-width='4' /%3E%3C/g%3E%3C/svg%3E")}@media (min-width:576px){:host,:root{--pico-font-size:106.25%}}@media (min-width:768px){:host,:root{--pico-font-size:112.5%}}@media (min-width:1024px){:host,:root{--pico-font-size:118.75%}}@media (min-width:1280px){:host,:root{--pico-font-size:125%}}@media (min-width:1536px){:host,:root{--pico-font-size:131.25%}}a{--pico-text-decoration:underline}small{--pico-font-size:0.875em}h1,h2,h3,h4,h5,h6{--pico-font-weight:700}h1{--pico-font-size:2rem;--pico-line-height:1.125;--pico-typography-spacing-top:3rem}h2{--pico-font-size:1.75rem;--pico-line-height:1.15;--pico-typography-spacing-top:2.625rem}h3{--pico-font-size:1.5rem;--pico-line-height:1.175;--pico-typography-spacing-top:2.25rem}h4{--pico-font-size:1.25rem;--pico-line-height:1.2;--pico-typography-spacing-top:1.874rem}h5{--pico-font-size:1.125rem;--pico-line-height:1.225;--pico-typography-spacing-top:1.6875rem}h6{--pico-font-size:1rem;--pico-line-height:1.25;--pico-typography-spacing-top:1.5rem}tfoot td,tfoot th,thead td,thead th{--pico-font-weight:600;--pico-border-width:0.1875rem}code,kbd,pre,samp{--pico-font-family:var(--pico-font-family-monospace)}kbd{--pico-font-weight:bolder}:where(select,textarea),input:not([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]){--pico-outline-width:0.0625rem}[type=search]{--pico-border-radius:5rem}[type=checkbox],[type=radio]{--pico-border-width:0.125rem}[type=checkbox][role=switch]{--pico-border-width:0.1875rem}[role=search]{--pico-border-radius:5rem}[role=group] [role=button],[role=group] [type=button],[role=group] [type=submit],[role=group] button,[role=search] [role=button],[role=search] [type=button],[role=search] [type=submit],[role=search] button{--pico-form-element-spacing-horizontal:2rem}details summary[role=button]::after{filter:brightness(0) invert(1)}[aria-busy=true]:not(input,select,textarea):is(button,[type=submit],[type=button],[type=reset],[role=button])::before{filter:brightness(0) invert(1)}:host(:not([data-theme=dark])),:root:not([data-theme=dark]),[data-theme=light]{color-scheme:light;--pico-background-color:#fff;--pico-color:#373c44;--pico-text-selection-color:rgba(2, 154, 232, 0.25);--pico-muted-color:#646b79;--pico-muted-border-color:rgb(231, 234, 239.5);--pico-primary:#0172ad;--pico-primary-background:#0172ad;--pico-primary-border:var(--pico-primary-background);--pico-primary-underline:rgba(1, 114, 173, 0.5);--pico-primary-hover:#015887;--pico-primary-hover-background:#02659a;--pico-primary-hover-border:var(--pico-primary-hover-background);--pico-primary-hover-underline:var(--pico-primary-hover);--pico-primary-focus:rgba(2, 154, 232, 0.5);--pico-primary-inverse:#fff;--pico-secondary:#5d6b89;--pico-secondary-background:#525f7a;--pico-secondary-border:var(--pico-secondary-background);--pico-secondary-underline:rgba(93, 107, 137, 0.5);--pico-secondary-hover:#48536b;--pico-secondary-hover-background:#48536b;--pico-secondary-hover-border:var(--pico-secondary-hover-background);--pico-secondary-hover-underline:var(--pico-secondary-hover);--pico-secondary-focus:rgba(93, 107, 137, 0.25);--pico-secondary-inverse:#fff;--pico-contrast:#181c25;--pico-contrast-background:#181c25;--pico-contrast-border:var(--pico-contrast-background);--pico-contrast-underline:rgba(24, 28, 37, 0.5);--pico-contrast-hover:#000;--pico-contrast-hover-background:#000;--pico-contrast-hover-border:var(--pico-contrast-hover-background);--pico-contrast-hover-underline:var(--pico-secondary-hover);--pico-contrast-focus:rgba(93, 107, 137, 0.25);--pico-contrast-inverse:#fff;--pico-box-shadow:0.0145rem 0.029rem 0.174rem rgba(129, 145, 181, 0.01698),0.0335rem 0.067rem 0.402rem rgba(129, 145, 181, 0.024),0.0625rem 0.125rem 0.75rem rgba(129, 145, 181, 0.03),0.1125rem 0.225rem 1.35rem rgba(129, 145, 181, 0.036),0.2085rem 0.417rem 2.502rem rgba(129, 145, 181, 0.04302),0.5rem 1rem 6rem rgba(129, 145, 181, 0.06),0 0 0 0.0625rem rgba(129, 145, 181, 0.015);--pico-h1-color:#2d3138;--pico-h2-color:#373c44;--pico-h3-color:#424751;--pico-h4-color:#4d535e;--pico-h5-color:#5c6370;--pico-h6-color:#646b79;--pico-mark-background-color:rgb(252.5, 230.5, 191.5);--pico-mark-color:#0f1114;--pico-ins-color:rgb(28.5, 105.5, 84);--pico-del-color:rgb(136, 56.5, 53);--pico-blockquote-border-color:var(--pico-muted-border-color);--pico-blockquote-footer-color:var(--pico-muted-color);--pico-button-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-button-hover-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-table-border-color:var(--pico-muted-border-color);--pico-table-row-stripped-background-color:rgba(111, 120, 135, 0.0375);--pico-code-background-color:rgb(243, 244.5, 246.75);--pico-code-color:#646b79;--pico-code-kbd-background-color:var(--pico-color);--pico-code-kbd-color:var(--pico-background-color);--pico-form-element-background-color:rgb(251, 251.5, 252.25);--pico-form-element-selected-background-color:#dfe3eb;--pico-form-element-border-color:#cfd5e2;--pico-form-element-color:#23262c;--pico-form-element-placeholder-color:var(--pico-muted-color);--pico-form-element-active-background-color:#fff;--pico-form-element-active-border-color:var(--pico-primary-border);--pico-form-element-focus-color:var(--pico-primary-border);--pico-form-element-disabled-opacity:0.5;--pico-form-element-invalid-border-color:rgb(183.5, 105.5, 106.5);--pico-form-element-invalid-active-border-color:rgb(200.25, 79.25, 72.25);--pico-form-element-invalid-focus-color:var(--pico-form-element-invalid-active-border-color);--pico-form-element-valid-border-color:rgb(76, 154.5, 137.5);--pico-form-element-valid-active-border-color:rgb(39, 152.75, 118.75);--pico-form-element-valid-focus-color:var(--pico-form-element-valid-active-border-color);--pico-switch-background-color:#bfc7d9;--pico-switch-checked-background-color:var(--pico-primary-background);--pico-switch-color:#fff;--pico-switch-thumb-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-range-border-color:#dfe3eb;--pico-range-active-border-color:#bfc7d9;--pico-range-thumb-border-color:var(--pico-background-color);--pico-range-thumb-color:var(--pico-secondary-background);--pico-range-thumb-active-color:var(--pico-primary-background);--pico-accordion-border-color:var(--pico-muted-border-color);--pico-accordion-active-summary-color:var(--pico-primary-hover);--pico-accordion-close-summary-color:var(--pico-color);--pico-accordion-open-summary-color:var(--pico-muted-color);--pico-card-background-color:var(--pico-background-color);--pico-card-border-color:var(--pico-muted-border-color);--pico-card-box-shadow:var(--pico-box-shadow);--pico-card-sectioning-background-color:rgb(251, 251.5, 252.25);--pico-loading-spinner-opacity:0.5;--pico-modal-overlay-background-color:rgba(232, 234, 237, 0.75);--pico-progress-background-color:#dfe3eb;--pico-progress-color:var(--pico-primary-background);--pico-tooltip-background-color:var(--pico-contrast-background);--pico-tooltip-color:var(--pico-contrast-inverse);--pico-icon-valid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(76, 154.5, 137.5)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-invalid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(200.25, 79.25, 72.25)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'%3E%3C/line%3E%3C/svg%3E")}:host(:not([data-theme=dark])) input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]),:root:not([data-theme=dark]) input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]),[data-theme=light] input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]){--pico-form-element-focus-color:var(--pico-primary-focus)}@media only screen and (prefers-color-scheme:dark){:host(:not([data-theme])),:root:not([data-theme]){color-scheme:dark;--pico-background-color:rgb(19, 22.5, 30.5);--pico-color:#c2c7d0;--pico-text-selection-color:rgba(1, 170, 255, 0.1875);--pico-muted-color:#7b8495;--pico-muted-border-color:#202632;--pico-primary:#01aaff;--pico-primary-background:#0172ad;--pico-primary-border:var(--pico-primary-background);--pico-primary-underline:rgba(1, 170, 255, 0.5);--pico-primary-hover:#79c0ff;--pico-primary-hover-background:#017fc0;--pico-primary-hover-border:var(--pico-primary-hover-background);--pico-primary-hover-underline:var(--pico-primary-hover);--pico-primary-focus:rgba(1, 170, 255, 0.375);--pico-primary-inverse:#fff;--pico-secondary:#969eaf;--pico-secondary-background:#525f7a;--pico-secondary-border:var(--pico-secondary-background);--pico-secondary-underline:rgba(150, 158, 175, 0.5);--pico-secondary-hover:#b3b9c5;--pico-secondary-hover-background:#5d6b89;--pico-secondary-hover-border:var(--pico-secondary-hover-background);--pico-secondary-hover-underline:var(--pico-secondary-hover);--pico-secondary-focus:rgba(144, 158, 190, 0.25);--pico-secondary-inverse:#fff;--pico-contrast:#dfe3eb;--pico-contrast-background:#eff1f4;--pico-contrast-border:var(--pico-contrast-background);--pico-contrast-underline:rgba(223, 227, 235, 0.5);--pico-contrast-hover:#fff;--pico-contrast-hover-background:#fff;--pico-contrast-hover-border:var(--pico-contrast-hover-background);--pico-contrast-hover-underline:var(--pico-contrast-hover);--pico-contrast-focus:rgba(207, 213, 226, 0.25);--pico-contrast-inverse:#000;--pico-box-shadow:0.0145rem 0.029rem 0.174rem rgba(7, 8.5, 12, 0.01698),0.0335rem 0.067rem 0.402rem rgba(7, 8.5, 12, 0.024),0.0625rem 0.125rem 0.75rem rgba(7, 8.5, 12, 0.03),0.1125rem 0.225rem 1.35rem rgba(7, 8.5, 12, 0.036),0.2085rem 0.417rem 2.502rem rgba(7, 8.5, 12, 0.04302),0.5rem 1rem 6rem rgba(7, 8.5, 12, 0.06),0 0 0 0.0625rem rgba(7, 8.5, 12, 0.015);--pico-h1-color:#f0f1f3;--pico-h2-color:#e0e3e7;--pico-h3-color:#c2c7d0;--pico-h4-color:#b3b9c5;--pico-h5-color:#a4acba;--pico-h6-color:#8891a4;--pico-mark-background-color:#014063;--pico-mark-color:#fff;--pico-ins-color:#62af9a;--pico-del-color:rgb(205.5, 126, 123);--pico-blockquote-border-color:var(--pico-muted-border-color);--pico-blockquote-footer-color:var(--pico-muted-color);--pico-button-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-button-hover-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-table-border-color:var(--pico-muted-border-color);--pico-table-row-stripped-background-color:rgba(111, 120, 135, 0.0375);--pico-code-background-color:rgb(26, 30.5, 40.25);--pico-code-color:#8891a4;--pico-code-kbd-background-color:var(--pico-color);--pico-code-kbd-color:var(--pico-background-color);--pico-form-element-background-color:rgb(28, 33, 43.5);--pico-form-element-selected-background-color:#2a3140;--pico-form-element-border-color:#2a3140;--pico-form-element-color:#e0e3e7;--pico-form-element-placeholder-color:#8891a4;--pico-form-element-active-background-color:rgb(26, 30.5, 40.25);--pico-form-element-active-border-color:var(--pico-primary-border);--pico-form-element-focus-color:var(--pico-primary-border);--pico-form-element-disabled-opacity:0.5;--pico-form-element-invalid-border-color:rgb(149.5, 74, 80);--pico-form-element-invalid-active-border-color:rgb(183.25, 63.5, 59);--pico-form-element-invalid-focus-color:var(--pico-form-element-invalid-active-border-color);--pico-form-element-valid-border-color:#2a7b6f;--pico-form-element-valid-active-border-color:rgb(22, 137, 105.5);--pico-form-element-valid-focus-color:var(--pico-form-element-valid-active-border-color);--pico-switch-background-color:#333c4e;--pico-switch-checked-background-color:var(--pico-primary-background);--pico-switch-color:#fff;--pico-switch-thumb-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-range-border-color:#202632;--pico-range-active-border-color:#2a3140;--pico-range-thumb-border-color:var(--pico-background-color);--pico-range-thumb-color:var(--pico-secondary-background);--pico-range-thumb-active-color:var(--pico-primary-background);--pico-accordion-border-color:var(--pico-muted-border-color);--pico-accordion-active-summary-color:var(--pico-primary-hover);--pico-accordion-close-summary-color:var(--pico-color);--pico-accordion-open-summary-color:var(--pico-muted-color);--pico-card-background-color:#181c25;--pico-card-border-color:var(--pico-card-background-color);--pico-card-box-shadow:var(--pico-box-shadow);--pico-card-sectioning-background-color:rgb(26, 30.5, 40.25);--pico-loading-spinner-opacity:0.5;--pico-modal-overlay-background-color:rgba(7.5, 8.5, 10, 0.75);--pico-progress-background-color:#202632;--pico-progress-color:var(--pico-primary-background);--pico-tooltip-background-color:var(--pico-contrast-background);--pico-tooltip-color:var(--pico-contrast-inverse);--pico-icon-valid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(42, 123, 111)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-invalid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(149.5, 74, 80)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'%3E%3C/line%3E%3C/svg%3E")}:host(:not([data-theme])) input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]),:root:not([data-theme]) input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]){--pico-form-element-focus-color:var(--pico-primary-focus)}}[data-theme=dark]{color-scheme:dark;--pico-background-color:rgb(19, 22.5, 30.5);--pico-color:#c2c7d0;--pico-text-selection-color:rgba(1, 170, 255, 0.1875);--pico-muted-color:#7b8495;--pico-muted-border-color:#202632;--pico-primary:#01aaff;--pico-primary-background:#0172ad;--pico-primary-border:var(--pico-primary-background);--pico-primary-underline:rgba(1, 170, 255, 0.5);--pico-primary-hover:#79c0ff;--pico-primary-hover-background:#017fc0;--pico-primary-hover-border:var(--pico-primary-hover-background);--pico-primary-hover-underline:var(--pico-primary-hover);--pico-primary-focus:rgba(1, 170, 255, 0.375);--pico-primary-inverse:#fff;--pico-secondary:#969eaf;--pico-secondary-background:#525f7a;--pico-secondary-border:var(--pico-secondary-background);--pico-secondary-underline:rgba(150, 158, 175, 0.5);--pico-secondary-hover:#b3b9c5;--pico-secondary-hover-background:#5d6b89;--pico-secondary-hover-border:var(--pico-secondary-hover-background);--pico-secondary-hover-underline:var(--pico-secondary-hover);--pico-secondary-focus:rgba(144, 158, 190, 0.25);--pico-secondary-inverse:#fff;--pico-contrast:#dfe3eb;--pico-contrast-background:#eff1f4;--pico-contrast-border:var(--pico-contrast-background);--pico-contrast-underline:rgba(223, 227, 235, 0.5);--pico-contrast-hover:#fff;--pico-contrast-hover-background:#fff;--pico-contrast-hover-border:var(--pico-contrast-hover-background);--pico-contrast-hover-underline:var(--pico-contrast-hover);--pico-contrast-focus:rgba(207, 213, 226, 0.25);--pico-contrast-inverse:#000;--pico-box-shadow:0.0145rem 0.029rem 0.174rem rgba(7, 8.5, 12, 0.01698),0.0335rem 0.067rem 0.402rem rgba(7, 8.5, 12, 0.024),0.0625rem 0.125rem 0.75rem rgba(7, 8.5, 12, 0.03),0.1125rem 0.225rem 1.35rem rgba(7, 8.5, 12, 0.036),0.2085rem 0.417rem 2.502rem rgba(7, 8.5, 12, 0.04302),0.5rem 1rem 6rem rgba(7, 8.5, 12, 0.06),0 0 0 0.0625rem rgba(7, 8.5, 12, 0.015);--pico-h1-color:#f0f1f3;--pico-h2-color:#e0e3e7;--pico-h3-color:#c2c7d0;--pico-h4-color:#b3b9c5;--pico-h5-color:#a4acba;--pico-h6-color:#8891a4;--pico-mark-background-color:#014063;--pico-mark-color:#fff;--pico-ins-color:#62af9a;--pico-del-color:rgb(205.5, 126, 123);--pico-blockquote-border-color:var(--pico-muted-border-color);--pico-blockquote-footer-color:var(--pico-muted-color);--pico-button-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-button-hover-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-table-border-color:var(--pico-muted-border-color);--pico-table-row-stripped-background-color:rgba(111, 120, 135, 0.0375);--pico-code-background-color:rgb(26, 30.5, 40.25);--pico-code-color:#8891a4;--pico-code-kbd-background-color:var(--pico-color);--pico-code-kbd-color:var(--pico-background-color);--pico-form-element-background-color:rgb(28, 33, 43.5);--pico-form-element-selected-background-color:#2a3140;--pico-form-element-border-color:#2a3140;--pico-form-element-color:#e0e3e7;--pico-form-element-placeholder-color:#8891a4;--pico-form-element-active-background-color:rgb(26, 30.5, 40.25);--pico-form-element-active-border-color:var(--pico-primary-border);--pico-form-element-focus-color:var(--pico-primary-border);--pico-form-element-disabled-opacity:0.5;--pico-form-element-invalid-border-color:rgb(149.5, 74, 80);--pico-form-element-invalid-active-border-color:rgb(183.25, 63.5, 59);--pico-form-element-invalid-focus-color:var(--pico-form-element-invalid-active-border-color);--pico-form-element-valid-border-color:#2a7b6f;--pico-form-element-valid-active-border-color:rgb(22, 137, 105.5);--pico-form-element-valid-focus-color:var(--pico-form-element-valid-active-border-color);--pico-switch-background-color:#333c4e;--pico-switch-checked-background-color:var(--pico-primary-background);--pico-switch-color:#fff;--pico-switch-thumb-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-range-border-color:#202632;--pico-range-active-border-color:#2a3140;--pico-range-thumb-border-color:var(--pico-background-color);--pico-range-thumb-color:var(--pico-secondary-background);--pico-range-thumb-active-color:var(--pico-primary-background);--pico-accordion-border-color:var(--pico-muted-border-color);--pico-accordion-active-summary-color:var(--pico-primary-hover);--pico-accordion-close-summary-color:var(--pico-color);--pico-accordion-open-summary-color:var(--pico-muted-color);--pico-card-background-color:#181c25;--pico-card-border-color:var(--pico-card-background-color);--pico-card-box-shadow:var(--pico-box-shadow);--pico-card-sectioning-background-color:rgb(26, 30.5, 40.25);--pico-loading-spinner-opacity:0.5;--pico-modal-overlay-background-color:rgba(7.5, 8.5, 10, 0.75);--pico-progress-background-color:#202632;--pico-progress-color:var(--pico-primary-background);--pico-tooltip-background-color:var(--pico-contrast-background);--pico-tooltip-color:var(--pico-contrast-inverse);--pico-icon-valid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(42, 123, 111)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-invalid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(149.5, 74, 80)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'%3E%3C/line%3E%3C/svg%3E")}[data-theme=dark] input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]){--pico-form-element-focus-color:var(--pico-primary-focus)}[type=checkbox],[type=radio],[type=range],progress{accent-color:var(--pico-primary)}*,::after,::before{box-sizing:border-box;background-repeat:no-repeat}::after,::before{text-decoration:inherit;vertical-align:inherit}:where(:host),:where(:root){-webkit-tap-highlight-color:transparent;-webkit-text-size-adjust:100%;-moz-text-size-adjust:100%;text-size-adjust:100%;background-color:var(--pico-background-color);color:var(--pico-color);font-weight:var(--pico-font-weight);font-size:var(--pico-font-size);line-height:var(--pico-line-height);font-family:var(--pico-font-family);text-underline-offset:var(--pico-text-underline-offset);text-rendering:optimizeLegibility;overflow-wrap:break-word;-moz-tab-size:4;-o-tab-size:4;tab-size:4}body{width:100%;margin:0}main{display:block}body>footer,body>header,body>main{width:100%;margin-right:auto;margin-left:auto;padding:var(--pico-block-spacing-vertical) var(--pico-block-spacing-horizontal)}@media (min-width:576px){body>footer,body>header,body>main{max-width:510px;padding-right:0;padding-left:0}}@media (min-width:768px){body>footer,body>header,body>main{max-width:700px}}@media (min-width:1024px){body>footer,body>header,body>main{max-width:950px}}@media (min-width:1280px){body>footer,body>header,body>main{max-width:1200px}}@media (min-width:1536px){body>footer,body>header,body>main{max-width:1450px}}section{margin-bottom:var(--pico-block-spacing-vertical)}b,strong{font-weight:bolder}sub,sup{position:relative;font-size:.75em;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}address,blockquote,dl,ol,p,pre,table,ul{margin-top:0;margin-bottom:var(--pico-typography-spacing-vertical);color:var(--pico-color);font-style:normal;font-weight:var(--pico-font-weight)}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:var(--pico-typography-spacing-vertical);color:var(--pico-color);font-weight:var(--pico-font-weight);font-size:var(--pico-font-size);line-height:var(--pico-line-height);font-family:var(--pico-font-family)}h1{--pico-color:var(--pico-h1-color)}h2{--pico-color:var(--pico-h2-color)}h3{--pico-color:var(--pico-h3-color)}h4{--pico-color:var(--pico-h4-color)}h5{--pico-color:var(--pico-h5-color)}h6{--pico-color:var(--pico-h6-color)}:where(article,address,blockquote,dl,figure,form,ol,p,pre,table,ul)~:is(h1,h2,h3,h4,h5,h6){margin-top:var(--pico-typography-spacing-top)}p{margin-bottom:var(--pico-typography-spacing-vertical)}hgroup{margin-bottom:var(--pico-typography-spacing-vertical)}hgroup>*{margin-top:0;margin-bottom:0}hgroup>:not(:first-child):last-child{--pico-color:var(--pico-muted-color);--pico-font-weight:unset;font-size:1rem}:where(ol,ul) li{margin-bottom:calc(var(--pico-typography-spacing-vertical) * .25)}:where(dl,ol,ul) :where(dl,ol,ul){margin:0;margin-top:calc(var(--pico-typography-spacing-vertical) * .25)}ul li{list-style:square}mark{padding:.125rem .25rem;background-color:var(--pico-mark-background-color);color:var(--pico-mark-color);vertical-align:baseline}blockquote{display:block;margin:var(--pico-typography-spacing-vertical) 0;padding:var(--pico-spacing);border-right:none;border-left:.25rem solid var(--pico-blockquote-border-color);border-inline-start:0.25rem solid var(--pico-blockquote-border-color);border-inline-end:none}blockquote footer{margin-top:calc(var(--pico-typography-spacing-vertical) * .5);color:var(--pico-blockquote-footer-color)}abbr[title]{border-bottom:1px dotted;text-decoration:none;cursor:help}ins{color:var(--pico-ins-color);text-decoration:none}del{color:var(--pico-del-color)}::-moz-selection{background-color:var(--pico-text-selection-color)}::selection{background-color:var(--pico-text-selection-color)}:where(a:not([role=button])),[role=link]{--pico-color:var(--pico-primary);--pico-background-color:transparent;--pico-underline:var(--pico-primary-underline);outline:0;background-color:var(--pico-background-color);color:var(--pico-color);-webkit-text-decoration:var(--pico-text-decoration);text-decoration:var(--pico-text-decoration);text-decoration-color:var(--pico-underline);text-underline-offset:0.125em;transition:background-color var(--pico-transition),color var(--pico-transition),box-shadow var(--pico-transition),-webkit-text-decoration var(--pico-transition);transition:background-color var(--pico-transition),color var(--pico-transition),text-decoration var(--pico-transition),box-shadow var(--pico-transition);transition:background-color var(--pico-transition),color var(--pico-transition),text-decoration var(--pico-transition),box-shadow var(--pico-transition),-webkit-text-decoration var(--pico-transition)}:where(a:not([role=button])):is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[role=link]:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-color:var(--pico-primary-hover);--pico-underline:var(--pico-primary-hover-underline);--pico-text-decoration:underline}:where(a:not([role=button])):focus-visible,[role=link]:focus-visible{box-shadow:0 0 0 var(--pico-outline-width) var(--pico-primary-focus)}a[role=button]{display:inline-block}button{margin:0;overflow:visible;font-family:inherit;text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[role=button],[type=button],[type=file]::file-selector-button,[type=reset],[type=submit],button{--pico-background-color:var(--pico-primary-background);--pico-border-color:var(--pico-primary-border);--pico-color:var(--pico-primary-inverse);--pico-box-shadow:var(--pico-button-box-shadow, 0 0 0 rgba(0, 0, 0, 0));padding:var(--pico-form-element-spacing-vertical) var(--pico-form-element-spacing-horizontal);border:var(--pico-border-width) solid var(--pico-border-color);border-radius:var(--pico-border-radius);outline:0;background-color:var(--pico-background-color);box-shadow:var(--pico-box-shadow);color:var(--pico-color);font-weight:var(--pico-font-weight);font-size:1rem;line-height:var(--pico-line-height);text-align:center;text-decoration:none;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;transition:background-color var(--pico-transition),border-color var(--pico-transition),color var(--pico-transition),box-shadow var(--pico-transition)}[role=button]:is(:hover,:active,:focus),[role=button]:is([aria-current]:not([aria-current=false])),[type=button]:is(:hover,:active,:focus),[type=button]:is([aria-current]:not([aria-current=false])),[type=file]::file-selector-button:is(:hover,:active,:focus),[type=file]::file-selector-button:is([aria-current]:not([aria-current=false])),[type=reset]:is(:hover,:active,:focus),[type=reset]:is([aria-current]:not([aria-current=false])),[type=submit]:is(:hover,:active,:focus),[type=submit]:is([aria-current]:not([aria-current=false])),button:is(:hover,:active,:focus),button:is([aria-current]:not([aria-current=false])){--pico-background-color:var(--pico-primary-hover-background);--pico-border-color:var(--pico-primary-hover-border);--pico-box-shadow:var(--pico-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0));--pico-color:var(--pico-primary-inverse)}[role=button]:focus,[role=button]:is([aria-current]:not([aria-current=false])):focus,[type=button]:focus,[type=button]:is([aria-current]:not([aria-current=false])):focus,[type=file]::file-selector-button:focus,[type=file]::file-selector-button:is([aria-current]:not([aria-current=false])):focus,[type=reset]:focus,[type=reset]:is([aria-current]:not([aria-current=false])):focus,[type=submit]:focus,[type=submit]:is([aria-current]:not([aria-current=false])):focus,button:focus,button:is([aria-current]:not([aria-current=false])):focus{--pico-box-shadow:var(--pico-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)),0 0 0 var(--pico-outline-width) var(--pico-primary-focus)}[type=button],[type=reset],[type=submit]{margin-bottom:var(--pico-spacing)}[type=file]::file-selector-button,[type=reset]{--pico-background-color:var(--pico-secondary-background);--pico-border-color:var(--pico-secondary-border);--pico-color:var(--pico-secondary-inverse);cursor:pointer}[type=file]::file-selector-button:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[type=reset]:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-background-color:var(--pico-secondary-hover-background);--pico-border-color:var(--pico-secondary-hover-border);--pico-color:var(--pico-secondary-inverse)}[type=file]::file-selector-button:focus,[type=reset]:focus{--pico-box-shadow:var(--pico-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)),0 0 0 var(--pico-outline-width) var(--pico-secondary-focus)}:where(button,[type=submit],[type=reset],[type=button],[role=button])[disabled],:where(fieldset[disabled]) :is(button,[type=submit],[type=button],[type=reset],[role=button]){opacity:.5;pointer-events:none}:where(table){width:100%;border-collapse:collapse;border-spacing:0;text-indent:0}td,th{padding:calc(var(--pico-spacing)/ 2) var(--pico-spacing);border-bottom:var(--pico-border-width) solid var(--pico-table-border-color);background-color:var(--pico-background-color);color:var(--pico-color);font-weight:var(--pico-font-weight);text-align:left;text-align:start}tfoot td,tfoot th{border-top:var(--pico-border-width) solid var(--pico-table-border-color);border-bottom:0}table.striped tbody tr:nth-child(odd) td,table.striped tbody tr:nth-child(odd) th{background-color:var(--pico-table-row-stripped-background-color)}:where(audio,canvas,iframe,img,svg,video){vertical-align:middle}audio,video{display:inline-block}audio:not([controls]){display:none;height:0}:where(iframe){border-style:none}img{max-width:100%;height:auto;border-style:none}:where(svg:not([fill])){fill:currentColor}svg:not(:host),svg:not(:root){overflow:hidden}code,kbd,pre,samp{font-size:.875em;font-family:var(--pico-font-family)}pre code,pre samp{font-size:inherit;font-family:inherit}pre{-ms-overflow-style:scrollbar;overflow:auto}code,kbd,pre,samp{border-radius:var(--pico-border-radius);background:var(--pico-code-background-color);color:var(--pico-code-color);font-weight:var(--pico-font-weight);line-height:initial}code,kbd,samp{display:inline-block;padding:.375rem}pre{display:block;margin-bottom:var(--pico-spacing);overflow-x:auto}pre>code,pre>samp{display:block;padding:var(--pico-spacing);background:0 0;line-height:var(--pico-line-height)}kbd{background-color:var(--pico-code-kbd-background-color);color:var(--pico-code-kbd-color);vertical-align:baseline}figure{display:block;margin:0;padding:0}figure figcaption{padding:calc(var(--pico-spacing) * .5) 0;color:var(--pico-muted-color)}hr{height:0;margin:var(--pico-typography-spacing-vertical) 0;border:0;border-top:1px solid var(--pico-muted-border-color);color:inherit}[hidden],template{display:none!important}canvas{display:inline-block}input,optgroup,select,textarea{margin:0;font-size:1rem;line-height:var(--pico-line-height);font-family:inherit;letter-spacing:inherit}input{overflow:visible}select{text-transform:none}legend{max-width:100%;padding:0;color:inherit;white-space:normal}textarea{overflow:auto}[type=checkbox],[type=radio]{padding:0}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}::-moz-focus-inner{padding:0;border-style:none}:-moz-focusring{outline:0}:-moz-ui-invalid{box-shadow:none}::-ms-expand{display:none}[type=file],[type=range]{padding:0;border-width:0}input:not([type=checkbox],[type=radio],[type=range]){height:calc(1rem * var(--pico-line-height) + var(--pico-form-element-spacing-vertical) * 2 + var(--pico-border-width) * 2)}fieldset{width:100%;margin:0;margin-bottom:var(--pico-spacing);padding:0;border:0}fieldset legend,label{display:block;margin-bottom:calc(var(--pico-spacing) * .375);color:var(--pico-color);font-weight:var(--pico-form-label-font-weight,var(--pico-font-weight))}fieldset legend{margin-bottom:calc(var(--pico-spacing) * .5)}button[type=submit],input:not([type=checkbox],[type=radio]),select,textarea{width:100%}input:not([type=checkbox],[type=radio],[type=range],[type=file]),select,textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;padding:var(--pico-form-element-spacing-vertical) var(--pico-form-element-spacing-horizontal)}input,select,textarea{--pico-background-color:var(--pico-form-element-background-color);--pico-border-color:var(--pico-form-element-border-color);--pico-color:var(--pico-form-element-color);--pico-box-shadow:none;border:var(--pico-border-width) solid var(--pico-border-color);border-radius:var(--pico-border-radius);outline:0;background-color:var(--pico-background-color);box-shadow:var(--pico-box-shadow);color:var(--pico-color);font-weight:var(--pico-font-weight);transition:background-color var(--pico-transition),border-color var(--pico-transition),color var(--pico-transition),box-shadow var(--pico-transition)}:where(select,textarea):not([readonly]):is(:active,:focus),input:not([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[readonly]):is(:active,:focus){--pico-background-color:var(--pico-form-element-active-background-color)}:where(select,textarea):not([readonly]):is(:active,:focus),input:not([type=submit],[type=button],[type=reset],[role=switch],[readonly]):is(:active,:focus){--pico-border-color:var(--pico-form-element-active-border-color)}:where(select,textarea):not([readonly]):focus,input:not([type=submit],[type=button],[type=reset],[type=range],[type=file],[readonly]):focus{--pico-box-shadow:0 0 0 var(--pico-outline-width) var(--pico-form-element-focus-color)}:where(fieldset[disabled]) :is(input:not([type=submit],[type=button],[type=reset]),select,textarea),input:not([type=submit],[type=button],[type=reset])[disabled],label[aria-disabled=true],select[disabled],textarea[disabled]{opacity:var(--pico-form-element-disabled-opacity);pointer-events:none}label[aria-disabled=true] input[disabled]{opacity:1}:where(input,select,textarea):not([type=checkbox],[type=radio],[type=date],[type=datetime-local],[type=month],[type=time],[type=week],[type=range])[aria-invalid]{padding-right:calc(var(--pico-form-element-spacing-horizontal) + 1.5rem)!important;padding-left:var(--pico-form-element-spacing-horizontal);padding-inline-start:var(--pico-form-element-spacing-horizontal)!important;padding-inline-end:calc(var(--pico-form-element-spacing-horizontal) + 1.5rem)!important;background-position:center right .75rem;background-size:1rem auto;background-repeat:no-repeat}:where(input,select,textarea):not([type=checkbox],[type=radio],[type=date],[type=datetime-local],[type=month],[type=time],[type=week],[type=range])[aria-invalid=false]:not(select){background-image:var(--pico-icon-valid)}:where(input,select,textarea):not([type=checkbox],[type=radio],[type=date],[type=datetime-local],[type=month],[type=time],[type=week],[type=range])[aria-invalid=true]:not(select){background-image:var(--pico-icon-invalid)}:where(input,select,textarea)[aria-invalid=false]{--pico-border-color:var(--pico-form-element-valid-border-color)}:where(input,select,textarea)[aria-invalid=false]:is(:active,:focus){--pico-border-color:var(--pico-form-element-valid-active-border-color)!important}:where(input,select,textarea)[aria-invalid=false]:is(:active,:focus):not([type=checkbox],[type=radio]){--pico-box-shadow:0 0 0 var(--pico-outline-width) var(--pico-form-element-valid-focus-color)!important}:where(input,select,textarea)[aria-invalid=true]{--pico-border-color:var(--pico-form-element-invalid-border-color)}:where(input,select,textarea)[aria-invalid=true]:is(:active,:focus){--pico-border-color:var(--pico-form-element-invalid-active-border-color)!important}:where(input,select,textarea)[aria-invalid=true]:is(:active,:focus):not([type=checkbox],[type=radio]){--pico-box-shadow:0 0 0 var(--pico-outline-width) var(--pico-form-element-invalid-focus-color)!important}[dir=rtl] :where(input,select,textarea):not([type=checkbox],[type=radio]):is([aria-invalid],[aria-invalid=true],[aria-invalid=false]){background-position:center left .75rem}input::-webkit-input-placeholder,input::placeholder,select:invalid,textarea::-webkit-input-placeholder,textarea::placeholder{color:var(--pico-form-element-placeholder-color);opacity:1}input:not([type=checkbox],[type=radio]),select,textarea{margin-bottom:var(--pico-spacing)}select::-ms-expand{border:0;background-color:transparent}select:not([multiple],[size]){padding-right:calc(var(--pico-form-element-spacing-horizontal) + 1.5rem);padding-left:var(--pico-form-element-spacing-horizontal);padding-inline-start:var(--pico-form-element-spacing-horizontal);padding-inline-end:calc(var(--pico-form-element-spacing-horizontal) + 1.5rem);background-image:var(--pico-icon-chevron);background-position:center right .75rem;background-size:1rem auto;background-repeat:no-repeat}select[multiple] option:checked{background:var(--pico-form-element-selected-background-color);color:var(--pico-form-element-color)}[dir=rtl] select:not([multiple],[size]){background-position:center left .75rem}textarea{display:block;resize:vertical}textarea[aria-invalid]{--pico-icon-height:calc(1rem * var(--pico-line-height) + var(--pico-form-element-spacing-vertical) * 2 + var(--pico-border-width) * 2);background-position:top right .75rem!important;background-size:1rem var(--pico-icon-height)!important}:where(input,select,textarea,fieldset)+small{display:block;width:100%;margin-top:calc(var(--pico-spacing) * -.75);margin-bottom:var(--pico-spacing);color:var(--pico-muted-color)}:where(input,select,textarea,fieldset)[aria-invalid=false]+small{color:var(--pico-ins-color)}:where(input,select,textarea,fieldset)[aria-invalid=true]+small{color:var(--pico-del-color)}label>:where(input,select,textarea){margin-top:calc(var(--pico-spacing) * .25)}label:has([type=checkbox],[type=radio]){width:-moz-fit-content;width:fit-content;cursor:pointer}[type=checkbox],[type=radio]{-webkit-appearance:none;-moz-appearance:none;appearance:none;width:1.25em;height:1.25em;margin-top:-.125em;margin-inline-end:.5em;border-width:var(--pico-border-width);vertical-align:middle;cursor:pointer}[type=checkbox]::-ms-check,[type=radio]::-ms-check{display:none}[type=checkbox]:checked,[type=checkbox]:checked:active,[type=checkbox]:checked:focus,[type=radio]:checked,[type=radio]:checked:active,[type=radio]:checked:focus{--pico-background-color:var(--pico-primary-background);--pico-border-color:var(--pico-primary-border);background-image:var(--pico-icon-checkbox);background-position:center;background-size:.75em auto;background-repeat:no-repeat}[type=checkbox]~label,[type=radio]~label{display:inline-block;margin-bottom:0;cursor:pointer}[type=checkbox]~label:not(:last-of-type),[type=radio]~label:not(:last-of-type){margin-inline-end:1em}[type=checkbox]:indeterminate{--pico-background-color:var(--pico-primary-background);--pico-border-color:var(--pico-primary-border);background-image:var(--pico-icon-minus);background-position:center;background-size:.75em auto;background-repeat:no-repeat}[type=radio]{border-radius:50%}[type=radio]:checked,[type=radio]:checked:active,[type=radio]:checked:focus{--pico-background-color:var(--pico-primary-inverse);border-width:.35em;background-image:none}[type=checkbox][role=switch]{--pico-background-color:var(--pico-switch-background-color);--pico-color:var(--pico-switch-color);width:2.25em;height:1.25em;border:var(--pico-border-width) solid var(--pico-border-color);border-radius:1.25em;background-color:var(--pico-background-color);line-height:1.25em}[type=checkbox][role=switch]:not([aria-invalid]){--pico-border-color:var(--pico-switch-background-color)}[type=checkbox][role=switch]:before{display:block;aspect-ratio:1;height:100%;border-radius:50%;background-color:var(--pico-color);box-shadow:var(--pico-switch-thumb-box-shadow);content:"";transition:margin .1s ease-in-out}[type=checkbox][role=switch]:focus{--pico-background-color:var(--pico-switch-background-color);--pico-border-color:var(--pico-switch-background-color)}[type=checkbox][role=switch]:checked{--pico-background-color:var(--pico-switch-checked-background-color);--pico-border-color:var(--pico-switch-checked-background-color);background-image:none}[type=checkbox][role=switch]:checked::before{margin-inline-start:calc(2.25em - 1.25em)}[type=checkbox][role=switch][disabled]{--pico-background-color:var(--pico-border-color)}[type=checkbox][aria-invalid=false]:checked,[type=checkbox][aria-invalid=false]:checked:active,[type=checkbox][aria-invalid=false]:checked:focus,[type=checkbox][role=switch][aria-invalid=false]:checked,[type=checkbox][role=switch][aria-invalid=false]:checked:active,[type=checkbox][role=switch][aria-invalid=false]:checked:focus{--pico-background-color:var(--pico-form-element-valid-border-color)}[type=checkbox]:checked:active[aria-invalid=true],[type=checkbox]:checked:focus[aria-invalid=true],[type=checkbox]:checked[aria-invalid=true],[type=checkbox][role=switch]:checked:active[aria-invalid=true],[type=checkbox][role=switch]:checked:focus[aria-invalid=true],[type=checkbox][role=switch]:checked[aria-invalid=true]{--pico-background-color:var(--pico-form-element-invalid-border-color)}[type=checkbox][aria-invalid=false]:checked,[type=checkbox][aria-invalid=false]:checked:active,[type=checkbox][aria-invalid=false]:checked:focus,[type=checkbox][role=switch][aria-invalid=false]:checked,[type=checkbox][role=switch][aria-invalid=false]:checked:active,[type=checkbox][role=switch][aria-invalid=false]:checked:focus,[type=radio][aria-invalid=false]:checked,[type=radio][aria-invalid=false]:checked:active,[type=radio][aria-invalid=false]:checked:focus{--pico-border-color:var(--pico-form-element-valid-border-color)}[type=checkbox]:checked:active[aria-invalid=true],[type=checkbox]:checked:focus[aria-invalid=true],[type=checkbox]:checked[aria-invalid=true],[type=checkbox][role=switch]:checked:active[aria-invalid=true],[type=checkbox][role=switch]:checked:focus[aria-invalid=true],[type=checkbox][role=switch]:checked[aria-invalid=true],[type=radio]:checked:active[aria-invalid=true],[type=radio]:checked:focus[aria-invalid=true],[type=radio]:checked[aria-invalid=true]{--pico-border-color:var(--pico-form-element-invalid-border-color)}[type=color]::-webkit-color-swatch-wrapper{padding:0}[type=color]::-moz-focus-inner{padding:0}[type=color]::-webkit-color-swatch{border:0;border-radius:calc(var(--pico-border-radius) * .5)}[type=color]::-moz-color-swatch{border:0;border-radius:calc(var(--pico-border-radius) * .5)}input:not([type=checkbox],[type=radio],[type=range],[type=file]):is([type=date],[type=datetime-local],[type=month],[type=time],[type=week]){--pico-icon-position:0.75rem;--pico-icon-width:1rem;padding-right:calc(var(--pico-icon-width) + var(--pico-icon-position));background-image:var(--pico-icon-date);background-position:center right var(--pico-icon-position);background-size:var(--pico-icon-width) auto;background-repeat:no-repeat}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=time]{background-image:var(--pico-icon-time)}[type=date]::-webkit-calendar-picker-indicator,[type=datetime-local]::-webkit-calendar-picker-indicator,[type=month]::-webkit-calendar-picker-indicator,[type=time]::-webkit-calendar-picker-indicator,[type=week]::-webkit-calendar-picker-indicator{width:var(--pico-icon-width);margin-right:calc(var(--pico-icon-width) * -1);margin-left:var(--pico-icon-position);opacity:0}@-moz-document url-prefix(){[type=date],[type=datetime-local],[type=month],[type=time],[type=week]{padding-right:var(--pico-form-element-spacing-horizontal)!important;background-image:none!important}}[dir=rtl] :is([type=date],[type=datetime-local],[type=month],[type=time],[type=week]){text-align:right}[type=file]{--pico-color:var(--pico-muted-color);margin-left:calc(var(--pico-outline-width) * -1);padding:calc(var(--pico-form-element-spacing-vertical) * .5) 0;padding-left:var(--pico-outline-width);border:0;border-radius:0;background:0 0}[type=file]::file-selector-button{margin-right:calc(var(--pico-spacing)/ 2);padding:calc(var(--pico-form-element-spacing-vertical) * .5) var(--pico-form-element-spacing-horizontal)}[type=file]:is(:hover,:active,:focus)::file-selector-button{--pico-background-color:var(--pico-secondary-hover-background);--pico-border-color:var(--pico-secondary-hover-border)}[type=file]:focus::file-selector-button{--pico-box-shadow:var(--pico-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)),0 0 0 var(--pico-outline-width) var(--pico-secondary-focus)}[type=range]{-webkit-appearance:none;-moz-appearance:none;appearance:none;width:100%;height:1.25rem;background:0 0}[type=range]::-webkit-slider-runnable-track{width:100%;height:.375rem;border-radius:var(--pico-border-radius);background-color:var(--pico-range-border-color);-webkit-transition:background-color var(--pico-transition),box-shadow var(--pico-transition);transition:background-color var(--pico-transition),box-shadow var(--pico-transition)}[type=range]::-moz-range-track{width:100%;height:.375rem;border-radius:var(--pico-border-radius);background-color:var(--pico-range-border-color);-moz-transition:background-color var(--pico-transition),box-shadow var(--pico-transition);transition:background-color var(--pico-transition),box-shadow var(--pico-transition)}[type=range]::-ms-track{width:100%;height:.375rem;border-radius:var(--pico-border-radius);background-color:var(--pico-range-border-color);-ms-transition:background-color var(--pico-transition),box-shadow var(--pico-transition);transition:background-color var(--pico-transition),box-shadow var(--pico-transition)}[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:1.25rem;height:1.25rem;margin-top:-.4375rem;border:2px solid var(--pico-range-thumb-border-color);border-radius:50%;background-color:var(--pico-range-thumb-color);cursor:pointer;-webkit-transition:background-color var(--pico-transition),transform var(--pico-transition);transition:background-color var(--pico-transition),transform var(--pico-transition)}[type=range]::-moz-range-thumb{-webkit-appearance:none;width:1.25rem;height:1.25rem;margin-top:-.4375rem;border:2px solid var(--pico-range-thumb-border-color);border-radius:50%;background-color:var(--pico-range-thumb-color);cursor:pointer;-moz-transition:background-color var(--pico-transition),transform var(--pico-transition);transition:background-color var(--pico-transition),transform var(--pico-transition)}[type=range]::-ms-thumb{-webkit-appearance:none;width:1.25rem;height:1.25rem;margin-top:-.4375rem;border:2px solid var(--pico-range-thumb-border-color);border-radius:50%;background-color:var(--pico-range-thumb-color);cursor:pointer;-ms-transition:background-color var(--pico-transition),transform var(--pico-transition);transition:background-color var(--pico-transition),transform var(--pico-transition)}[type=range]:active,[type=range]:focus-within{--pico-range-border-color:var(--pico-range-active-border-color);--pico-range-thumb-color:var(--pico-range-thumb-active-color)}[type=range]:active::-webkit-slider-thumb{transform:scale(1.25)}[type=range]:active::-moz-range-thumb{transform:scale(1.25)}[type=range]:active::-ms-thumb{transform:scale(1.25)}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=search]{padding-inline-start:calc(var(--pico-form-element-spacing-horizontal) + 1.75rem);background-image:var(--pico-icon-search);background-position:center left calc(var(--pico-form-element-spacing-horizontal) + .125rem);background-size:1rem auto;background-repeat:no-repeat}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=search][aria-invalid]{padding-inline-start:calc(var(--pico-form-element-spacing-horizontal) + 1.75rem)!important;background-position:center left 1.125rem,center right .75rem}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=search][aria-invalid=false]{background-image:var(--pico-icon-search),var(--pico-icon-valid)}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=search][aria-invalid=true]{background-image:var(--pico-icon-search),var(--pico-icon-invalid)}[dir=rtl] :where(input):not([type=checkbox],[type=radio],[type=range],[type=file])[type=search]{background-position:center right 1.125rem}[dir=rtl] :where(input):not([type=checkbox],[type=radio],[type=range],[type=file])[type=search][aria-invalid]{background-position:center right 1.125rem,center left .75rem}details{display:block;margin-bottom:var(--pico-spacing)}details summary{line-height:1rem;list-style-type:none;cursor:pointer;transition:color var(--pico-transition)}details summary:not([role]){color:var(--pico-accordion-close-summary-color)}details summary::-webkit-details-marker{display:none}details summary::marker{display:none}details summary::-moz-list-bullet{list-style-type:none}details summary::after{display:block;width:1rem;height:1rem;margin-inline-start:calc(var(--pico-spacing,1rem) * .5);float:right;transform:rotate(-90deg);background-image:var(--pico-icon-chevron);background-position:right center;background-size:1rem auto;background-repeat:no-repeat;content:"";transition:transform var(--pico-transition)}details summary:focus{outline:0}details summary:focus:not([role]){color:var(--pico-accordion-active-summary-color)}details summary:focus-visible:not([role]){outline:var(--pico-outline-width) solid var(--pico-primary-focus);outline-offset:calc(var(--pico-spacing,1rem) * 0.5);color:var(--pico-primary)}details summary[role=button]{width:100%;text-align:left}details summary[role=button]::after{height:calc(1rem * var(--pico-line-height,1.5))}details[open]>summary{margin-bottom:var(--pico-spacing)}details[open]>summary:not([role]):not(:focus){color:var(--pico-accordion-open-summary-color)}details[open]>summary::after{transform:rotate(0)}[dir=rtl] details summary{text-align:right}[dir=rtl] details summary::after{float:left;background-position:left center}article{margin-bottom:var(--pico-block-spacing-vertical);padding:var(--pico-block-spacing-vertical) var(--pico-block-spacing-horizontal);border-radius:var(--pico-border-radius);background:var(--pico-card-background-color);box-shadow:var(--pico-card-box-shadow)}article>footer,article>header{margin-right:calc(var(--pico-block-spacing-horizontal) * -1);margin-left:calc(var(--pico-block-spacing-horizontal) * -1);padding:calc(var(--pico-block-spacing-vertical) * .66) var(--pico-block-spacing-horizontal);background-color:var(--pico-card-sectioning-background-color)}article>header{margin-top:calc(var(--pico-block-spacing-vertical) * -1);margin-bottom:var(--pico-block-spacing-vertical);border-bottom:var(--pico-border-width) solid var(--pico-card-border-color);border-top-right-radius:var(--pico-border-radius);border-top-left-radius:var(--pico-border-radius)}article>footer{margin-top:var(--pico-block-spacing-vertical);margin-bottom:calc(var(--pico-block-spacing-vertical) * -1);border-top:var(--pico-border-width) solid var(--pico-card-border-color);border-bottom-right-radius:var(--pico-border-radius);border-bottom-left-radius:var(--pico-border-radius)}[role=group],[role=search]{display:inline-flex;position:relative;width:100%;margin-bottom:var(--pico-spacing);border-radius:var(--pico-border-radius);box-shadow:var(--pico-group-box-shadow,0 0 0 transparent);vertical-align:middle;transition:box-shadow var(--pico-transition)}[role=group] input:not([type=checkbox],[type=radio]),[role=group] select,[role=group]>*,[role=search] input:not([type=checkbox],[type=radio]),[role=search] select,[role=search]>*{position:relative;flex:1 1 auto;margin-bottom:0}[role=group] input:not([type=checkbox],[type=radio]):not(:first-child),[role=group] select:not(:first-child),[role=group]>:not(:first-child),[role=search] input:not([type=checkbox],[type=radio]):not(:first-child),[role=search] select:not(:first-child),[role=search]>:not(:first-child){margin-left:0;border-top-left-radius:0;border-bottom-left-radius:0}[role=group] input:not([type=checkbox],[type=radio]):not(:last-child),[role=group] select:not(:last-child),[role=group]>:not(:last-child),[role=search] input:not([type=checkbox],[type=radio]):not(:last-child),[role=search] select:not(:last-child),[role=search]>:not(:last-child){border-top-right-radius:0;border-bottom-right-radius:0}[role=group] input:not([type=checkbox],[type=radio]):focus,[role=group] select:focus,[role=group]>:focus,[role=search] input:not([type=checkbox],[type=radio]):focus,[role=search] select:focus,[role=search]>:focus{z-index:2}[role=group] [role=button]:not(:first-child),[role=group] [type=button]:not(:first-child),[role=group] [type=reset]:not(:first-child),[role=group] [type=submit]:not(:first-child),[role=group] button:not(:first-child),[role=group] input:not([type=checkbox],[type=radio]):not(:first-child),[role=group] select:not(:first-child),[role=search] [role=button]:not(:first-child),[role=search] [type=button]:not(:first-child),[role=search] [type=reset]:not(:first-child),[role=search] [type=submit]:not(:first-child),[role=search] button:not(:first-child),[role=search] input:not([type=checkbox],[type=radio]):not(:first-child),[role=search] select:not(:first-child){margin-left:calc(var(--pico-border-width) * -1)}[role=group] [role=button],[role=group] [type=button],[role=group] [type=reset],[role=group] [type=submit],[role=group] button,[role=search] [role=button],[role=search] [type=button],[role=search] [type=reset],[role=search] [type=submit],[role=search] button{width:auto}@supports selector(:has(*)){[role=group]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus),[role=search]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus){--pico-group-box-shadow:var(--pico-group-box-shadow-focus-with-button)}[role=group]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus) input:not([type=checkbox],[type=radio]),[role=group]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus) select,[role=search]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus) input:not([type=checkbox],[type=radio]),[role=search]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus) select{border-color:transparent}[role=group]:has(input:not([type=submit],[type=button]):focus,select:focus),[role=search]:has(input:not([type=submit],[type=button]):focus,select:focus){--pico-group-box-shadow:var(--pico-group-box-shadow-focus-with-input)}[role=group]:has(input:not([type=submit],[type=button]):focus,select:focus) [role=button],[role=group]:has(input:not([type=submit],[type=button]):focus,select:focus) [type=button],[role=group]:has(input:not([type=submit],[type=button]):focus,select:focus) [type=submit],[role=group]:has(input:not([type=submit],[type=button]):focus,select:focus) button,[role=search]:has(input:not([type=submit],[type=button]):focus,select:focus) [role=button],[role=search]:has(input:not([type=submit],[type=button]):focus,select:focus) [type=button],[role=search]:has(input:not([type=submit],[type=button]):focus,select:focus) [type=submit],[role=search]:has(input:not([type=submit],[type=button]):focus,select:focus) button{--pico-button-box-shadow:0 0 0 var(--pico-border-width) var(--pico-primary-border);--pico-button-hover-box-shadow:0 0 0 var(--pico-border-width) var(--pico-primary-hover-border)}[role=group] [role=button]:focus,[role=group] [type=button]:focus,[role=group] [type=reset]:focus,[role=group] [type=submit]:focus,[role=group] button:focus,[role=search] [role=button]:focus,[role=search] [type=button]:focus,[role=search] [type=reset]:focus,[role=search] [type=submit]:focus,[role=search] button:focus{box-shadow:none}}[role=search]>:first-child{border-top-left-radius:5rem;border-bottom-left-radius:5rem}[role=search]>:last-child{border-top-right-radius:5rem;border-bottom-right-radius:5rem}[aria-busy=true]:not(input,select,textarea,html,form){white-space:nowrap}[aria-busy=true]:not(input,select,textarea,html,form)::before{display:inline-block;width:1em;height:1em;background-image:var(--pico-icon-loading);background-size:1em auto;background-repeat:no-repeat;content:"";vertical-align:-.125em}[aria-busy=true]:not(input,select,textarea,html,form):not(:empty)::before{margin-inline-end:calc(var(--pico-spacing) * .5)}[aria-busy=true]:not(input,select,textarea,html,form):empty{text-align:center}[role=button][aria-busy=true],[type=button][aria-busy=true],[type=reset][aria-busy=true],[type=submit][aria-busy=true],a[aria-busy=true],button[aria-busy=true]{pointer-events:none}:host,:root{--pico-scrollbar-width:0px}dialog{display:flex;z-index:999;position:fixed;top:0;right:0;bottom:0;left:0;align-items:center;justify-content:center;width:inherit;min-width:100%;height:inherit;min-height:100%;padding:0;border:0;-webkit-backdrop-filter:var(--pico-modal-overlay-backdrop-filter);backdrop-filter:var(--pico-modal-overlay-backdrop-filter);background-color:var(--pico-modal-overlay-background-color);color:var(--pico-color)}dialog>article{width:100%;max-height:calc(100vh - var(--pico-spacing) * 2);margin:var(--pico-spacing);overflow:auto}@media (min-width:576px){dialog>article{max-width:510px}}@media (min-width:768px){dialog>article{max-width:700px}}dialog>article>header>*{margin-bottom:0}dialog>article>header :is(a,button)[rel=prev]{margin:0;margin-left:var(--pico-spacing);padding:0;float:right}dialog>article>footer{text-align:right}dialog>article>footer [role=button],dialog>article>footer button{margin-bottom:0}dialog>article>footer [role=button]:not(:first-of-type),dialog>article>footer button:not(:first-of-type){margin-left:calc(var(--pico-spacing) * .5)}dialog>article :is(a,button)[rel=prev]{display:block;width:1rem;height:1rem;margin-top:calc(var(--pico-spacing) * -1);margin-bottom:var(--pico-spacing);margin-left:auto;border:none;background-image:var(--pico-icon-close);background-position:center;background-size:auto 1rem;background-repeat:no-repeat;background-color:transparent;opacity:.5;transition:opacity var(--pico-transition)}dialog>article :is(a,button)[rel=prev]:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){opacity:1}dialog:not([open]),dialog[open=false]{display:none}:where(nav li)::before{float:left;content:"​"}nav,nav ul{display:flex}nav{justify-content:space-between;overflow:visible}nav ol,nav ul{align-items:center;margin-bottom:0;padding:0;list-style:none}nav ol:first-of-type,nav ul:first-of-type{margin-left:calc(var(--pico-nav-element-spacing-horizontal) * -1)}nav ol:last-of-type,nav ul:last-of-type{margin-right:calc(var(--pico-nav-element-spacing-horizontal) * -1)}nav li{display:inline-block;margin:0;padding:var(--pico-nav-element-spacing-vertical) var(--pico-nav-element-spacing-horizontal)}nav li :where(a,[role=link]){display:inline-block;margin:calc(var(--pico-nav-link-spacing-vertical) * -1) calc(var(--pico-nav-link-spacing-horizontal) * -1);padding:var(--pico-nav-link-spacing-vertical) var(--pico-nav-link-spacing-horizontal);border-radius:var(--pico-border-radius)}nav li :where(a,[role=link]):not(:hover){text-decoration:none}nav li [role=button],nav li [type=button],nav li button,nav li input:not([type=checkbox],[type=radio],[type=range],[type=file]),nav li select{height:auto;margin-right:inherit;margin-bottom:0;margin-left:inherit;padding:calc(var(--pico-nav-link-spacing-vertical) - var(--pico-border-width) * 2) var(--pico-nav-link-spacing-horizontal)}nav[aria-label=breadcrumb]{align-items:center;justify-content:start}nav[aria-label=breadcrumb] ul li:not(:first-child){margin-inline-start:var(--pico-nav-link-spacing-horizontal)}nav[aria-label=breadcrumb] ul li a{margin:calc(var(--pico-nav-link-spacing-vertical) * -1) 0;margin-inline-start:calc(var(--pico-nav-link-spacing-horizontal) * -1)}nav[aria-label=breadcrumb] ul li:not(:last-child)::after{display:inline-block;position:absolute;width:calc(var(--pico-nav-link-spacing-horizontal) * 4);margin:0 calc(var(--pico-nav-link-spacing-horizontal) * -1);content:var(--pico-nav-breadcrumb-divider);color:var(--pico-muted-color);text-align:center;text-decoration:none;white-space:nowrap}nav[aria-label=breadcrumb] a[aria-current]:not([aria-current=false]){background-color:transparent;color:inherit;text-decoration:none;pointer-events:none}aside li,aside nav,aside ol,aside ul{display:block}aside li{padding:calc(var(--pico-nav-element-spacing-vertical) * .5) var(--pico-nav-element-spacing-horizontal)}aside li a{display:block}aside li [role=button]{margin:inherit}[dir=rtl] nav[aria-label=breadcrumb] ul li:not(:last-child) ::after{content:"\\"}progress{display:inline-block;vertical-align:baseline}progress{-webkit-appearance:none;-moz-appearance:none;display:inline-block;appearance:none;width:100%;height:.5rem;margin-bottom:calc(var(--pico-spacing) * .5);overflow:hidden;border:0;border-radius:var(--pico-border-radius);background-color:var(--pico-progress-background-color);color:var(--pico-progress-color)}progress::-webkit-progress-bar{border-radius:var(--pico-border-radius);background:0 0}progress[value]::-webkit-progress-value{background-color:var(--pico-progress-color);-webkit-transition:inline-size var(--pico-transition);transition:inline-size var(--pico-transition)}progress::-moz-progress-bar{background-color:var(--pico-progress-color)}@media (prefers-reduced-motion:no-preference){progress:indeterminate{background:var(--pico-progress-background-color) linear-gradient(to right,var(--pico-progress-color) 30%,var(--pico-progress-background-color) 30%) top left/150% 150% no-repeat;animation:progress-indeterminate 1s linear infinite}progress:indeterminate[value]::-webkit-progress-value{background-color:transparent}progress:indeterminate::-moz-progress-bar{background-color:transparent}}@media (prefers-reduced-motion:no-preference){[dir=rtl] progress:indeterminate{animation-direction:reverse}}@keyframes progress-indeterminate{0%{background-position:200% 0}100%{background-position:-200% 0}}[data-tooltip]{position:relative}[data-tooltip]:not(a,button,input,[role=button]){border-bottom:1px dotted;text-decoration:none;cursor:help}[data-tooltip]::after,[data-tooltip]::before,[data-tooltip][data-placement=top]::after,[data-tooltip][data-placement=top]::before{display:block;z-index:99;position:absolute;bottom:100%;left:50%;padding:.25rem .5rem;overflow:hidden;transform:translate(-50%,-.25rem);border-radius:var(--pico-border-radius);background:var(--pico-tooltip-background-color);content:attr(data-tooltip);color:var(--pico-tooltip-color);font-style:normal;font-weight:var(--pico-font-weight);font-size:.875rem;text-decoration:none;text-overflow:ellipsis;white-space:nowrap;opacity:0;pointer-events:none}[data-tooltip]::after,[data-tooltip][data-placement=top]::after{padding:0;transform:translate(-50%,0);border-top:.3rem solid;border-right:.3rem solid transparent;border-left:.3rem solid transparent;border-radius:0;background-color:transparent;content:"";color:var(--pico-tooltip-background-color)}[data-tooltip][data-placement=bottom]::after,[data-tooltip][data-placement=bottom]::before{top:100%;bottom:auto;transform:translate(-50%,.25rem)}[data-tooltip][data-placement=bottom]:after{transform:translate(-50%,-.3rem);border:.3rem solid transparent;border-bottom:.3rem solid}[data-tooltip][data-placement=left]::after,[data-tooltip][data-placement=left]::before{top:50%;right:100%;bottom:auto;left:auto;transform:translate(-.25rem,-50%)}[data-tooltip][data-placement=left]:after{transform:translate(.3rem,-50%);border:.3rem solid transparent;border-left:.3rem solid}[data-tooltip][data-placement=right]::after,[data-tooltip][data-placement=right]::before{top:50%;right:auto;bottom:auto;left:100%;transform:translate(.25rem,-50%)}[data-tooltip][data-placement=right]:after{transform:translate(-.3rem,-50%);border:.3rem solid transparent;border-right:.3rem solid}[data-tooltip]:focus::after,[data-tooltip]:focus::before,[data-tooltip]:hover::after,[data-tooltip]:hover::before{opacity:1}@media (hover:hover) and (pointer:fine){[data-tooltip]:focus::after,[data-tooltip]:focus::before,[data-tooltip]:hover::after,[data-tooltip]:hover::before{--pico-tooltip-slide-to:translate(-50%, -0.25rem);transform:translate(-50%,.75rem);animation-duration:.2s;animation-fill-mode:forwards;animation-name:tooltip-slide;opacity:0}[data-tooltip]:focus::after,[data-tooltip]:hover::after{--pico-tooltip-caret-slide-to:translate(-50%, 0rem);transform:translate(-50%,-.25rem);animation-name:tooltip-caret-slide}[data-tooltip][data-placement=bottom]:focus::after,[data-tooltip][data-placement=bottom]:focus::before,[data-tooltip][data-placement=bottom]:hover::after,[data-tooltip][data-placement=bottom]:hover::before{--pico-tooltip-slide-to:translate(-50%, 0.25rem);transform:translate(-50%,-.75rem);animation-name:tooltip-slide}[data-tooltip][data-placement=bottom]:focus::after,[data-tooltip][data-placement=bottom]:hover::after{--pico-tooltip-caret-slide-to:translate(-50%, -0.3rem);transform:translate(-50%,-.5rem);animation-name:tooltip-caret-slide}[data-tooltip][data-placement=left]:focus::after,[data-tooltip][data-placement=left]:focus::before,[data-tooltip][data-placement=left]:hover::after,[data-tooltip][data-placement=left]:hover::before{--pico-tooltip-slide-to:translate(-0.25rem, -50%);transform:translate(.75rem,-50%);animation-name:tooltip-slide}[data-tooltip][data-placement=left]:focus::after,[data-tooltip][data-placement=left]:hover::after{--pico-tooltip-caret-slide-to:translate(0.3rem, -50%);transform:translate(.05rem,-50%);animation-name:tooltip-caret-slide}[data-tooltip][data-placement=right]:focus::after,[data-tooltip][data-placement=right]:focus::before,[data-tooltip][data-placement=right]:hover::after,[data-tooltip][data-placement=right]:hover::before{--pico-tooltip-slide-to:translate(0.25rem, -50%);transform:translate(-.75rem,-50%);animation-name:tooltip-slide}[data-tooltip][data-placement=right]:focus::after,[data-tooltip][data-placement=right]:hover::after{--pico-tooltip-caret-slide-to:translate(-0.3rem, -50%);transform:translate(-.05rem,-50%);animation-name:tooltip-caret-slide}}@keyframes tooltip-slide{to{transform:var(--pico-tooltip-slide-to);opacity:1}}@keyframes tooltip-caret-slide{50%{opacity:0}to{transform:var(--pico-tooltip-caret-slide-to);opacity:1}}[aria-controls]{cursor:pointer}[aria-disabled=true],[disabled]{cursor:not-allowed}[aria-hidden=false][hidden]{display:initial}[aria-hidden=false][hidden]:not(:focus){clip:rect(0,0,0,0);position:absolute}[tabindex],a,area,button,input,label,select,summary,textarea{-ms-touch-action:manipulation}[dir=rtl]{direction:rtl}@media (prefers-reduced-motion:reduce){:not([aria-busy=true]),:not([aria-busy=true])::after,:not([aria-busy=true])::before{background-attachment:initial!important;animation-duration:1ms!important;animation-delay:-1ms!important;animation-iteration-count:1!important;scroll-behavior:auto!important;transition-delay:0s!important;transition-duration:0s!important}} \ No newline at end of file diff --git a/auth_service/static/js/app.js b/auth_service/static/js/app.js new file mode 100644 index 0000000..c66f008 --- /dev/null +++ b/auth_service/static/js/app.js @@ -0,0 +1,328 @@ +(function () { + "use strict"; + + const createForm = document.getElementById("create-form"); + const emailInput = document.getElementById("email-input"); + const emailError = document.getElementById("email-error"); + const ttlInput = document.getElementById("ttl-input"); + const tokenDialog = document.getElementById("token-dialog"); + const tokenInput = document.getElementById("token-value"); + const copyBtn = document.getElementById("copy-btn"); + const doneBtn = document.getElementById("done-btn"); + const confirmDialog = document.getElementById("confirm-dialog"); + const confirmCancel = document.getElementById("confirm-cancel"); + const confirmDelete = document.getElementById("confirm-delete"); + const confirmPrefix = document.getElementById("confirm-token-prefix"); + const confirmHosts = document.getElementById("confirm-token-hosts"); + const tbody = document.getElementById("token-table-body"); + const tokenCount = document.getElementById("token-count"); + const pagination = document.getElementById("pagination"); + + let pendingTokenHash = null; + let pendingPrefix = null; + let pendingHosts = null; + let currentPage = 1; + const pageSize = 20; + + function formatTtl(ttl) { + if (!ttl || ttl <= 0) return "\u2014"; + if (ttl >= 86400) return Math.round(ttl / 86400) + "d"; + if (ttl >= 3600) return Math.round(ttl / 3600) + "h"; + if (ttl >= 60) return Math.round(ttl / 60) + "m"; + return ttl + "s"; + } + + function escapeHtml(str) { + const div = document.createElement("div"); + div.appendChild(document.createTextNode(str)); + return div.innerHTML; + } + + function escapeAttr(str) { + return str + .replace(/&/g, "&") + .replace(/"/g, """) + .replace(/'/g, "'") + .replace(//g, ">"); + } + + function renderPagination(page, total, size) { + const pages = Math.ceil(total / size); + if (pages <= 1) { + pagination.innerHTML = ""; + return; + } + let html = ""; + if (page > 1) { + html += + ''; + } + html += " Page " + page + " of " + pages + " "; + if (page < pages) { + html += + ''; + } + pagination.innerHTML = html; + } + + function renderTable(data) { + tokenCount.textContent = data.total ? "(" + data.total + ")" : ""; + if (!data.tokens || data.tokens.length === 0) { + tbody.innerHTML = + 'No tokens yet. Create your first one above.'; + renderPagination(1, 0, pageSize); + return; + } + let html = ""; + for (let i = 0; i < data.tokens.length; i++) { + const t = data.tokens[i]; + html += + "" + + "" + + escapeHtml(t.token.substring(0, 8)) + + "..." + + "" + + escapeHtml(t.hosts) + + "" + + "" + + (t.email ? escapeHtml(t.email) : "\u2014") + + "" + + "" + + (t.comment ? escapeHtml(t.comment) : "\u2014") + + "" + + "" + + (t.created_at ? escapeHtml(t.created_at) : "\u2014") + + "" + + "" + + formatTtl(t.ttl) + + "" + + '' + + ""; + } + tbody.innerHTML = html; + renderPagination(data.page, data.total, data.size); + } + + async function refreshTable(page) { + page = page || currentPage; + currentPage = page; + try { + const resp = await fetch( + "/api/tokens?page=" + page + "&size=" + pageSize, + { + headers: { Accept: "application/json" }, + }, + ); + if (!resp.ok) throw new Error("Failed to fetch tokens"); + const data = await resp.json(); + renderTable(data); + return data; + } catch (err) { + console.error("refreshTable:", err); + tbody.innerHTML = + 'Failed to load tokens. Please try again.'; + tokenCount.textContent = ""; + pagination.innerHTML = ""; + return null; + } + } + + pagination.addEventListener("click", function (e) { + const btn = e.target.closest("button[data-page]"); + if (btn) refreshTable(parseInt(btn.getAttribute("data-page"), 10)); + }); + + function activatePreset(ttl) { + ttlInput.value = ttl; + document.querySelectorAll(".ttl-preset").forEach(function (b) { + b.classList.toggle("active", b.getAttribute("data-ttl") === ttl); + }); + } + + document.querySelectorAll(".ttl-preset").forEach(function (btn) { + btn.addEventListener("click", function () { + activatePreset(this.getAttribute("data-ttl")); + }); + }); + + const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s.]+$/; + + function validateEmailField() { + const raw = emailInput.value.trim(); + if (!raw) { + emailInput.classList.remove("input-error"); + emailError.textContent = ""; + return true; + } + if (!EMAIL_RE.test(raw)) { + emailInput.classList.add("input-error"); + emailError.textContent = "Invalid email address"; + return false; + } + emailInput.classList.remove("input-error"); + emailError.textContent = ""; + return true; + } + + emailInput.addEventListener("input", validateEmailField); + + createForm.addEventListener("submit", async function (e) { + e.preventDefault(); + if (!validateEmailField()) { + emailInput.focus(); + return; + } + const submitBtn = createForm.querySelector('button[type="submit"]'); + submitBtn.disabled = true; + submitBtn.setAttribute("aria-busy", "true"); + + const fd = new FormData(createForm); + const body = { hosts: fd.get("hosts") || "" }; + const ttlRaw = fd.get("ttl_seconds"); + if (ttlRaw) body.ttl_seconds = parseInt(ttlRaw, 10); + const emailVal = fd.get("email"); + if (emailVal) body.email = emailVal; + const commentVal = fd.get("comment"); + if (commentVal) body.comment = commentVal; + + try { + const resp = await fetch("/api/tokens", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!resp.ok) { + let detail = "Failed to create token"; + try { + const err = await resp.json(); + detail = err.detail || detail; + } catch (_) {} + throw new Error(detail); + } + const data = await resp.json(); + tokenInput.value = data.token; + tokenDialog.showModal(); + tokenInput.select(); + } catch (err) { + alert("Error: " + err.message); + } finally { + submitBtn.disabled = false; + submitBtn.removeAttribute("aria-busy"); + } + }); + + copyBtn.addEventListener("click", async function () { + const showCopied = function () { + copyBtn.textContent = "Copied \u2713"; + copyBtn.disabled = true; + setTimeout(function () { + copyBtn.textContent = "Copy to clipboard"; + copyBtn.disabled = false; + }, 1500); + }; + if (navigator.clipboard && window.isSecureContext) { + try { + await navigator.clipboard.writeText(tokenInput.value); + showCopied(); + return; + } catch (_) {} + } + tokenInput.focus(); + tokenInput.select(); + try { + if (document.execCommand("copy")) { + showCopied(); + return; + } + } catch (_) {} + alert( + "Could not copy automatically. Press Ctrl+C (or Cmd+C) to copy the selected token.", + ); + }); + + doneBtn.addEventListener("click", function () { + tokenDialog.close(); + }); + + tokenDialog.addEventListener("close", function () { + createForm.reset(); + document.querySelectorAll(".ttl-preset").forEach(function (b) { + b.classList.remove("active"); + }); + refreshTable(1); + }); + + tbody.addEventListener("click", function (e) { + const btn = e.target.closest(".delete-btn"); + if (!btn) return; + pendingTokenHash = btn.getAttribute("data-token-hash"); + pendingPrefix = btn.getAttribute("data-prefix"); + pendingHosts = btn.getAttribute("data-hosts"); + confirmPrefix.textContent = pendingPrefix + "..."; + confirmHosts.textContent = pendingHosts; + confirmDialog.showModal(); + }); + + confirmCancel.addEventListener("click", function () { + confirmDialog.close(); + }); + + confirmDelete.addEventListener("click", async function () { + if (!pendingTokenHash) { + confirmDialog.close(); + return; + } + confirmDialog.close(); + try { + const resp = await fetch("/api/tokens/" + pendingTokenHash, { + method: "DELETE", + headers: { Accept: "application/json" }, + }); + if (!resp.ok) { + let detail = "Failed to delete token"; + try { + const err = await resp.json(); + detail = err.detail || detail; + } catch (_) {} + throw new Error(detail); + } + const data = await refreshTable(currentPage); + if ( + data && + data.tokens.length === 0 && + data.total > 0 && + currentPage > 1 + ) { + await refreshTable(currentPage - 1); + } + } catch (err) { + alert("Error: " + err.message); + } finally { + pendingTokenHash = null; + pendingPrefix = null; + pendingHosts = null; + } + }); + + confirmDialog.addEventListener("close", function () { + if ( + document.activeElement && + typeof document.activeElement.blur === "function" + ) { + document.activeElement.blur(); + } + }); + + refreshTable(1); +})(); diff --git a/auth_service/storage.py b/auth_service/storage.py new file mode 100644 index 0000000..6f97fc4 --- /dev/null +++ b/auth_service/storage.py @@ -0,0 +1,31 @@ +from typing import Protocol + + +class TokenStore(Protocol): + async def get_token(self, token_hash: str) -> dict | None: + """Return token metadata dict or None if not found/expired.""" + ... + + async def set_token(self, token_hash: str, data: dict, ttl: int = 0) -> None: + """Store token metadata. + + data keys: hosts, email, comment, created_at. + If ttl > 0, the token expires after ttl seconds. + """ + ... + + async def delete_token(self, token_hash: str) -> None: + """Remove token from store.""" + ... + + async def list_tokens(self) -> list[dict]: + """Return all active tokens. + + Each dict: token_hash, hosts, email, comment, created_at_raw, ttl. + ttl is -1 for no expiry, >0 for seconds remaining. + """ + ... + + async def health(self) -> bool: + """Check if store is reachable. Returns True if healthy.""" + ... diff --git a/auth_service/templates/index.html b/auth_service/templates/index.html index acae492..954936d 100644 --- a/auth_service/templates/index.html +++ b/auth_service/templates/index.html @@ -1,82 +1,38 @@ - + - Token Manager - + Wicket · Token Manager + + -

Token Manager

-
-
+
+

Token Manager

+ + +
+ + + + + +
-
-
+ + +

Current Tokens

+ + + + + + + + + + + + + +
TokenHostsEmailCommentCreatedTTLActions
+ + + + +
+
+

Token created

+
+

+ Save this token now. For security reasons it will not + be shown again. + If you lose it, you’ll need to issue a new one. +

+ +
+ + +
+
+
+ + +
+
+

Delete token

+
+

+ You're about to delete token + + for . +

+

This action cannot be undone.

+
+ + +
+
+
-

Current Tokens

- - - - - - - - - - - - - - {% for t in tokens %} - - - - - - - - - - {% endfor %} - -
TokenHostsEmailCommentCreatedTTL (s)Actions
{{ t.token[:8] }}...{{ t.hosts }}{{ t.email }}{{ t.comment }} - {% if t.created_at %}{{ t.created_at }}{% else %}—{% - endif %} - - {% if t.ttl and t.ttl > 0 %}{{ t.ttl }}{% else %}—{% - endif %} - -
- - -
-
+ diff --git a/auth_service/tests/__init__.py b/auth_service/tests/__init__.py new file mode 100644 index 0000000..1eca1b6 --- /dev/null +++ b/auth_service/tests/__init__.py @@ -0,0 +1 @@ +# wicket tests diff --git a/auth_service/tests/conftest.py b/auth_service/tests/conftest.py new file mode 100644 index 0000000..0ac7740 --- /dev/null +++ b/auth_service/tests/conftest.py @@ -0,0 +1,29 @@ +import os +from unittest.mock import AsyncMock + +import pytest +from fastapi.testclient import TestClient + +os.environ.setdefault("PEPPER", "test-pepper") +os.environ.setdefault("ADMIN_USER", "test-admin") +os.environ.setdefault("ADMIN_PASS", "test-admin") +os.environ.setdefault("AUTH_MAX_FAILURES", "999999") +os.environ.setdefault("SQLITE_PATH", "/tmp/wicket-test.db") + +from main import app # noqa: E402 + + +@pytest.fixture +def client(): + with TestClient(app) as c: + yield c + + +@pytest.fixture +def mock_store(client): + mock = AsyncMock() + mock.health.return_value = True + mock.get_token.return_value = None + mock.list_tokens.return_value = [] + app.state.store = mock + yield mock diff --git a/auth_service/tests/helpers.py b/auth_service/tests/helpers.py new file mode 100644 index 0000000..1fbc4f6 --- /dev/null +++ b/auth_service/tests/helpers.py @@ -0,0 +1,8 @@ +import base64 + + +def basic_auth_headers( + username: str = "test-admin", password: str = "test-admin" +) -> dict: + encoded = base64.b64encode(f"{username}:{password}".encode()).decode() + return {"Authorization": f"Basic {encoded}"} diff --git a/auth_service/tests/test_admin.py b/auth_service/tests/test_admin.py new file mode 100644 index 0000000..575a01d --- /dev/null +++ b/auth_service/tests/test_admin.py @@ -0,0 +1,205 @@ +import pytest + +from tests.helpers import basic_auth_headers + +_VALID_HASH = "a" * 64 + + +class TestCreateToken: + def test_valid(self, client, mock_store): + resp = client.post( + "/api/tokens", + json={"hosts": "example.com"}, + headers=basic_auth_headers(), + ) + assert resp.status_code == 201 + data = resp.json() + assert "token" in data + assert len(data["token"]) > 0 + mock_store.set_token.assert_called_once() + args = mock_store.set_token.call_args[0] + assert len(args[0]) == 64 + assert args[1]["hosts"] == "example.com" + assert args[2] == 0 + + def test_with_ttl(self, client, mock_store): + resp = client.post( + "/api/tokens", + json={"hosts": "example.com", "ttl_seconds": 3600}, + headers=basic_auth_headers(), + ) + assert resp.status_code == 201 + mock_store.set_token.assert_called_once() + assert mock_store.set_token.call_args[0][2] == 3600 + + def test_with_email_and_comment(self, client, mock_store): + resp = client.post( + "/api/tokens", + json={ + "hosts": "example.com", + "email": "friend@example.com", + "comment": "for Bob", + }, + headers=basic_auth_headers(), + ) + assert resp.status_code == 201 + data = mock_store.set_token.call_args[0][1] + assert data["email"] == "friend@example.com" + assert data["comment"] == "for Bob" + + @pytest.mark.parametrize("ttl_input", ["not-a-number", -1, -3600]) + def test_invalid_ttl(self, client, mock_store, ttl_input): + resp = client.post( + "/api/tokens", + json={"hosts": "example.com", "ttl_seconds": ttl_input}, + headers=basic_auth_headers(), + ) + assert resp.status_code == 422 + + @pytest.mark.parametrize( + "hosts_input", + [ + "example.com", + "example.com,other.com", + " EXAMPLE.com , other.COM ", + "*.example.com", + "example.com:8080", + "192.168.1.1:3000", + "*.example.com:443", + "*", + "*:8080", + "[::1]:9090", + "api.example.com,*.other.com:3000", + ], + ) + def test_valid_hosts(self, client, mock_store, hosts_input): + resp = client.post( + "/api/tokens", + json={"hosts": hosts_input}, + headers=basic_auth_headers(), + ) + assert resp.status_code == 201 + + @pytest.mark.parametrize( + "hosts_input", + [ + " , ,,", + "!!!bad!!!.com", + "-bad.com", + "bad-.com", + "", + " ", + "*.*.example.com", + "example.com:99999", + "example.com:0", + ":8080", + "foo.*.example.com", + ], + ) + def test_invalid_hosts(self, client, mock_store, hosts_input): + resp = client.post( + "/api/tokens", + json={"hosts": hosts_input}, + headers=basic_auth_headers(), + ) + assert resp.status_code == 422 + + def test_hosts_too_long(self, client, mock_store): + resp = client.post( + "/api/tokens", + json={"hosts": "x" * 4097}, + headers=basic_auth_headers(), + ) + assert resp.status_code == 422 + + def test_comment_too_long(self, client, mock_store): + resp = client.post( + "/api/tokens", + json={"hosts": "example.com", "comment": "x" * 257}, + headers=basic_auth_headers(), + ) + assert resp.status_code == 422 + + +class TestDeleteToken: + def test_valid(self, client, mock_store): + resp = client.delete( + f"/api/tokens/{_VALID_HASH}", + headers=basic_auth_headers(), + ) + assert resp.status_code == 204 + mock_store.delete_token.assert_called_once_with(_VALID_HASH) + + def test_invalid_hash_format(self, client, mock_store): + resp = client.delete( + "/api/tokens/not-hex!!", + headers=basic_auth_headers(), + ) + assert resp.status_code == 422 + mock_store.delete_token.assert_not_called() + + +class TestListTokens: + def test_empty_list(self, client, mock_store): + mock_store.list_tokens.return_value = [] + resp = client.get("/api/tokens", headers=basic_auth_headers()) + assert resp.status_code == 200 + data = resp.json() + assert data["tokens"] == [] + assert data["total"] == 0 + + def test_pagination(self, client, mock_store): + raw = [ + { + "token_hash": f"{'a' * 64}", + "hosts": "host1.com", + "email": "", + "comment": "", + "created_at_raw": "1000", + "ttl": -1, + }, + { + "token_hash": f"{'b' * 64}", + "hosts": "host2.com", + "email": "", + "comment": "", + "created_at_raw": "2000", + "ttl": -1, + }, + { + "token_hash": f"{'c' * 64}", + "hosts": "host3.com", + "email": "", + "comment": "", + "created_at_raw": "3000", + "ttl": -1, + }, + ] + mock_store.list_tokens.return_value = raw + + resp = client.get("/api/tokens?page=1&size=2", headers=basic_auth_headers()) + assert resp.status_code == 200 + data = resp.json() + assert len(data["tokens"]) == 2 + assert data["total"] == 3 + assert data["page"] == 1 + + resp = client.get("/api/tokens?page=2&size=2", headers=basic_auth_headers()) + data = resp.json() + assert len(data["tokens"]) == 1 + assert data["page"] == 2 + + def test_default_page_size(self, client, mock_store): + mock_store.list_tokens.return_value = [] + resp = client.get("/api/tokens", headers=basic_auth_headers()) + data = resp.json() + assert data["page"] == 1 + assert data["size"] == 20 + + def test_invalid_page(self, client, mock_store): + resp = client.get("/api/tokens?page=0", headers=basic_auth_headers()) + assert resp.status_code == 422 + + def test_invalid_size(self, client, mock_store): + resp = client.get("/api/tokens?size=200", headers=basic_auth_headers()) + assert resp.status_code == 422 diff --git a/auth_service/tests/test_allowed_hosts.py b/auth_service/tests/test_allowed_hosts.py new file mode 100644 index 0000000..f9d2681 --- /dev/null +++ b/auth_service/tests/test_allowed_hosts.py @@ -0,0 +1,95 @@ +import pytest + +from validators import host_matches, parse_allowed_hosts + + +class TestParseAllowedHosts: + def test_empty_string(self): + assert parse_allowed_hosts("") == [] + + def test_trailing_comma(self): + assert parse_allowed_hosts("a.com,") == ["a.com"] + + def test_combo(self): + assert parse_allowed_hosts(" Example.COM , Other.COM ") == [ + "example.com", + "other.com", + ] + + +class TestHostMatches: + def test_exact_match(self): + assert host_matches("example.com", "example.com") is True + + def test_exact_case_insensitive(self): + assert host_matches("EXAMPLE.com", "example.com") is True + + def test_exact_mismatch(self): + assert host_matches("evil.com", "example.com") is False + + def test_exact_with_port_match(self): + assert host_matches("example.com:8080", "example.com:8080") is True + + def test_exact_with_port_mismatch(self): + assert host_matches("example.com:9090", "example.com:8080") is False + + def test_pattern_no_port_request_has_port(self): + assert host_matches("example.com:8080", "example.com") is False + + def test_pattern_has_port_request_no_port(self): + assert host_matches("example.com", "example.com:8080") is False + + @pytest.mark.parametrize("subdomain", ["api", "cdn"]) + def test_wildcard_matches_single_label(self, subdomain): + assert host_matches(f"{subdomain}.example.com", "*.example.com") is True + + def test_wildcard_no_match_bare_domain(self): + assert host_matches("example.com", "*.example.com") is False + + def test_wildcard_no_match_multi_label(self): + assert host_matches("x.y.example.com", "*.example.com") is False + + def test_wildcard_no_match_different_suffix(self): + assert host_matches("api.other.com", "*.example.com") is False + + def test_wildcard_no_match_partial_suffix(self): + assert host_matches("api.otherexample.com", "*.example.com") is False + + def test_wildcard_case_insensitive(self): + assert host_matches("API.Example.COM", "*.example.com") is True + + def test_wildcard_with_port_match(self): + assert host_matches("api.example.com:8080", "*.example.com:8080") is True + + def test_wildcard_with_port_mismatch(self): + assert host_matches("api.example.com:9090", "*.example.com:8080") is False + + def test_wildcard_pattern_no_port_request_has_port(self): + assert host_matches("api.example.com:8080", "*.example.com") is False + + def test_wildcard_pattern_has_port_request_no_port(self): + assert host_matches("api.example.com", "*.example.com:8080") is False + + def test_match_all_any_hostname(self): + assert host_matches("anything.example.com", "*") is True + + def test_match_all_any_with_port(self): + assert host_matches("anything.example.com:1234", "*") is True + + def test_match_all_with_port_match(self): + assert host_matches("anything.example.com:8080", "*:8080") is True + + def test_match_all_with_port_mismatch(self): + assert host_matches("anything.example.com:9090", "*:8080") is False + + def test_ipv4_match(self): + assert host_matches("192.168.1.1", "192.168.1.1") is True + + def test_ipv4_with_port_match(self): + assert host_matches("192.168.1.1:3000", "192.168.1.1:3000") is True + + def test_ipv4_port_mismatch(self): + assert host_matches("192.168.1.1:3000", "192.168.1.1:4000") is False + + def test_empty_requested_host(self): + assert host_matches("", "example.com") is False diff --git a/auth_service/tests/test_auth.py b/auth_service/tests/test_auth.py new file mode 100644 index 0000000..89a68c6 --- /dev/null +++ b/auth_service/tests/test_auth.py @@ -0,0 +1,84 @@ +class TestAuthEndpoint: + def test_no_auth_header(self, client, mock_store): + resp = client.get("/auth", headers={"X-Forwarded-Host": "example.com"}) + assert resp.status_code == 401 + + def test_not_bearer(self, client, mock_store): + resp = client.get( + "/auth", + headers={ + "Authorization": "Basic xyz", + "X-Forwarded-Host": "example.com", + }, + ) + assert resp.status_code == 401 + + def test_empty_token(self, client, mock_store): + resp = client.get( + "/auth", + headers={ + "Authorization": "Bearer ", + "X-Forwarded-Host": "example.com", + }, + ) + assert resp.status_code == 401 + + def test_missing_x_forwarded_host(self, client, mock_store): + resp = client.get("/auth", headers={"Authorization": "Bearer some-token"}) + assert resp.status_code == 400 + assert "X-Forwarded-Host" in resp.json()["detail"] + + def test_invalid_token(self, client, mock_store): + mock_store.get_token.return_value = None + resp = client.get( + "/auth", + headers={ + "Authorization": "Bearer garbage", + "X-Forwarded-Host": "example.com", + }, + ) + assert resp.status_code == 401 + + def test_valid_token_wrong_host(self, client, mock_store): + mock_store.get_token.return_value = {"hosts": "allowed.com"} + resp = client.get( + "/auth", + headers={ + "Authorization": "Bearer my-token", + "X-Forwarded-Host": "evil.com", + }, + ) + assert resp.status_code == 401 + + def test_valid_token_correct_host(self, client, mock_store): + mock_store.get_token.return_value = {"hosts": "example.com"} + resp = client.get( + "/auth", + headers={ + "Authorization": "Bearer my-token", + "X-Forwarded-Host": "example.com", + }, + ) + assert resp.status_code == 200 + + def test_token_with_no_hosts(self, client, mock_store): + mock_store.get_token.return_value = {"hosts": ""} + resp = client.get( + "/auth", + headers={ + "Authorization": "Bearer my-token", + "X-Forwarded-Host": "example.com", + }, + ) + assert resp.status_code == 401 + + def test_post_method_accepted(self, client, mock_store): + mock_store.get_token.return_value = {"hosts": "example.com"} + resp = client.post( + "/auth", + headers={ + "Authorization": "Bearer my-token", + "X-Forwarded-Host": "example.com", + }, + ) + assert resp.status_code == 200 diff --git a/auth_service/tests/test_healthz.py b/auth_service/tests/test_healthz.py new file mode 100644 index 0000000..b097f82 --- /dev/null +++ b/auth_service/tests/test_healthz.py @@ -0,0 +1,15 @@ +class TestHealthz: + def test_healthz_ok(self, client, mock_store): + resp = client.get("/healthz") + assert resp.status_code == 200 + assert resp.text == "ok" + + def test_readyz_ok(self, client, mock_store): + resp = client.get("/readyz") + assert resp.status_code == 200 + assert resp.text == "ok" + + def test_readyz_store_down(self, client, mock_store): + mock_store.health.side_effect = Exception("boom") + resp = client.get("/readyz") + assert resp.status_code == 503 diff --git a/auth_service/tests/test_metrics.py b/auth_service/tests/test_metrics.py new file mode 100644 index 0000000..0bad285 --- /dev/null +++ b/auth_service/tests/test_metrics.py @@ -0,0 +1,23 @@ +from metrics import store_health + + +class TestMetricsEndpoint: + def test_metrics_returns_200(self, client, mock_store): + resp = client.get("/metrics") + assert resp.status_code == 200 + assert resp.headers["content-type"].startswith("text/plain") + + +class TestStoreHealthGauge: + def test_healthy_store_sets_one(self, client, mock_store): + mock_store.health.return_value = True + client.get("/readyz") + resp = client.get("/metrics") + assert "wicket_store_health 1.0" in resp.text + + def test_unhealthy_store_sets_zero(self, client, mock_store): + store_health.set(1) + mock_store.health.side_effect = Exception("boom") + client.get("/readyz") + resp = client.get("/metrics") + assert "wicket_store_health 0.0" in resp.text diff --git a/auth_service/tests/test_throttle.py b/auth_service/tests/test_throttle.py new file mode 100644 index 0000000..928756e --- /dev/null +++ b/auth_service/tests/test_throttle.py @@ -0,0 +1,218 @@ +import asyncio +import time + +import pytest + +from main import app +from tests.helpers import basic_auth_headers +from throttle import MemoryFailureStore + + +def _run(coro): + return asyncio.run(coro) + + +class TestMemoryFailureStore: + def test_not_blocked_with_no_history(self): + store = MemoryFailureStore(window_seconds=60, max_failures=10) + assert not _run(store.is_blocked("1.2.3.4")) + + def test_not_blocked_below_threshold(self): + store = MemoryFailureStore(window_seconds=60, max_failures=10) + for _ in range(9): + _run(store.record_failure("1.2.3.4")) + assert not _run(store.is_blocked("1.2.3.4")) + + def test_blocked_at_threshold(self): + store = MemoryFailureStore(window_seconds=60, max_failures=3) + for _ in range(3): + _run(store.record_failure("1.2.3.4")) + assert _run(store.is_blocked("1.2.3.4")) + + def test_window_expires(self): + store = MemoryFailureStore(window_seconds=1, max_failures=3) + for _ in range(3): + _run(store.record_failure("1.2.3.4")) + assert _run(store.is_blocked("1.2.3.4")) + + time.sleep(1.1) + assert not _run(store.is_blocked("1.2.3.4")) + + def test_window_trimming_on_is_blocked(self): + store = MemoryFailureStore(window_seconds=60, max_failures=5) + store._buckets["1.2.3.4"].append(time.time() - 120) + _run(store.record_failure("1.2.3.4")) + assert not _run(store.is_blocked("1.2.3.4")) + + def test_per_ip_isolation(self): + store = MemoryFailureStore(window_seconds=60, max_failures=3) + for _ in range(3): + _run(store.record_failure("1.2.3.4")) + assert _run(store.is_blocked("1.2.3.4")) + assert not _run(store.is_blocked("5.6.7.8")) + + def test_prune_removes_stale_ips(self): + store = MemoryFailureStore( + window_seconds=60, max_failures=10, prune_age_seconds=0.01 + ) + store._buckets["1.2.3.4"].append(time.time() - 10) + _run(store.record_failure("5.6.7.8")) + assert "1.2.3.4" not in store._buckets + + def test_close_clears_buckets(self): + store = MemoryFailureStore() + _run(store.record_failure("1.2.3.4")) + assert len(store._buckets) > 0 + _run(store.close()) + assert len(store._buckets) == 0 + + +@pytest.fixture +def strict_throttle(client): + app.state.auth_throttle = MemoryFailureStore(window_seconds=60, max_failures=3) + app.state.admin_throttle = MemoryFailureStore(window_seconds=60, max_failures=3) + + +class TestAuthThrottle: + def test_200_not_penalised(self, client, mock_store, strict_throttle): + mock_store.get_token.return_value = {"hosts": "example.com"} + headers = { + "Authorization": "Bearer my-token", + "X-Forwarded-Host": "example.com", + } + for _ in range(100): + resp = client.get("/auth", headers=headers) + assert resp.status_code == 200 + + def test_429_after_too_many_failures(self, client, mock_store, strict_throttle): + mock_store.get_token.return_value = None + headers = { + "Authorization": "Bearer garbage", + "X-Forwarded-Host": "example.com", + } + + for i in range(3): + resp = client.get("/auth", headers=headers) + assert resp.status_code == 401, f"request {i + 1} should be 401" + + resp = client.get("/auth", headers=headers) + assert resp.status_code == 429 + assert resp.headers["retry-after"] == "60" + assert "too many failed" in resp.json()["detail"] + + def test_throttle_per_ip(self, client, mock_store, strict_throttle): + mock_store.get_token.return_value = None + + headers_a = { + "Authorization": "Bearer garbage", + "X-Forwarded-Host": "example.com", + "X-Forwarded-For": "1.2.3.4", + } + for _ in range(3): + client.get("/auth", headers=headers_a) + assert client.get("/auth", headers=headers_a).status_code == 429 + + headers_b = { + "Authorization": "Bearer garbage", + "X-Forwarded-Host": "example.com", + "X-Forwarded-For": "5.6.7.8", + } + resp = client.get("/auth", headers=headers_b) + assert resp.status_code == 401 + + def test_401_counts_as_failure(self, client, mock_store, strict_throttle): + mock_store.get_token.return_value = {"hosts": "allowed.com"} + headers = { + "Authorization": "Bearer my-token", + "X-Forwarded-Host": "evil.com", + } + + for _ in range(3): + resp = client.get("/auth", headers=headers) + assert resp.status_code == 401 + + resp = client.get("/auth", headers=headers) + assert resp.status_code == 429 + + def test_mixed_failures_count_together(self, client, mock_store, strict_throttle): + headers = { + "X-Forwarded-Host": "example.com", + } + + headers["Authorization"] = "" + client.get("/auth", headers=headers) + client.get("/auth", headers=headers) + + mock_store.get_token.return_value = {"hosts": "allowed.com"} + headers["Authorization"] = "Bearer my-token" + resp = client.get("/auth", headers=headers) + assert resp.status_code == 401 + + headers["Authorization"] = "" + resp = client.get("/auth", headers=headers) + assert resp.status_code == 429 + + +class TestAdminThrottle: + def test_admin_200_not_penalised(self, client, mock_store, strict_throttle): + for _ in range(100): + resp = client.get("/", headers=basic_auth_headers()) + assert resp.status_code == 200 + + def test_admin_429_after_too_many_failures( + self, client, mock_store, strict_throttle + ): + bad = basic_auth_headers(password="wrong-pass") + for i in range(3): + resp = client.get("/", headers=bad) + assert resp.status_code == 401, f"request {i + 1} should be 401" + + resp = client.get("/", headers=bad) + assert resp.status_code == 429 + assert resp.headers["retry-after"] == "60" + assert "too many failed" in resp.json()["detail"] + + def test_admin_throttle_per_ip(self, client, mock_store, strict_throttle): + bad = basic_auth_headers(password="wrong-pass") + + headers_a = {**bad, "X-Forwarded-For": "1.2.3.4"} + for _ in range(3): + client.get("/", headers=headers_a) + assert client.get("/", headers=headers_a).status_code == 429 + + headers_b = {**bad, "X-Forwarded-For": "5.6.7.8"} + resp = client.get("/", headers=headers_b) + assert resp.status_code == 401 + + +class TestCrossContamination: + def test_admin_failures_do_not_block_auth( + self, client, mock_store, strict_throttle + ): + bad_admin = basic_auth_headers(password="wrong-pass") + for _ in range(3): + client.get("/", headers=bad_admin) + assert client.get("/", headers=bad_admin).status_code == 429 + + mock_store.get_token.return_value = {"hosts": "example.com"} + auth_headers = { + "Authorization": "Bearer my-token", + "X-Forwarded-Host": "example.com", + } + resp = client.get("/auth", headers=auth_headers) + assert resp.status_code == 200 + + def test_auth_failures_do_not_block_admin( + self, client, mock_store, strict_throttle + ): + mock_store.get_token.return_value = None + bad_auth = { + "Authorization": "Bearer garbage", + "X-Forwarded-Host": "example.com", + } + for _ in range(3): + client.get("/auth", headers=bad_auth) + assert client.get("/auth", headers=bad_auth).status_code == 429 + + resp = client.get("/", headers=basic_auth_headers()) + assert resp.status_code == 200 diff --git a/auth_service/throttle.py b/auth_service/throttle.py new file mode 100644 index 0000000..ab8d11c --- /dev/null +++ b/auth_service/throttle.py @@ -0,0 +1,199 @@ +"""Brute-force throttling for /auth and admin endpoints. + +Tracks failed auth attempts per client IP with a sliding window. +Successful auths are never penalised — only 401 responses count. + +Architecture mirrors storage.py: Protocol + two implementations. +- MemoryFailureStore: in-memory deque-based window, for single-instance (sqlite) deploys +- RedisFailureStore: Redis sorted-set with TTL, for multi-instance deploys +""" + +import time +from collections import defaultdict, deque +from typing import Protocol, runtime_checkable + +from redis.asyncio import Redis +from redis.asyncio.retry import Retry +from redis.backoff import ExponentialBackoff +from redis.exceptions import ConnectionError as RedisConnectionError +from redis.exceptions import TimeoutError as RedisTimeoutError + +from config import Config +from log import logger + + +@runtime_checkable +class FailureStore(Protocol): + """Tracks auth failures per client IP using a sliding window. + + Implementations must be concurrency-safe: MemoryFailureStore relies on + asyncio's single-threaded event loop; RedisFailureStore uses atomic + Redis commands. + """ + + async def is_blocked(self, ip: str) -> bool: + """Return True if this IP has exceeded the failure threshold.""" + ... + + async def record_failure(self, ip: str) -> None: + """Record a failed auth attempt for this IP.""" + ... + + async def close(self) -> None: + """Release resources (connection pools, etc.).""" + ... + + +class MemoryFailureStore: + """In-memory sliding-window failure tracker. + + Each IP maps to a deque of failure timestamps. Window trimming + happens on every ``is_blocked()`` call — old timestamps are popped + from the left in O(1). Stale IPs are garbage-collected lazily + during ``record_failure()``. + + ``deque`` is the idiomatic Python choice for a sliding window + (over ``list``) because ``popleft()`` is O(1), while ``list.pop(0)`` + shifts the entire array. + """ + + def __init__( + self, + window_seconds: int = 60, + max_failures: int = 10, + prune_age_seconds: float = 300.0, + ) -> None: + self._window = window_seconds + self._max = max_failures + self._prune_age = prune_age_seconds + self._buckets: dict[str, deque[float]] = defaultdict(deque) + + async def is_blocked(self, ip: str) -> bool: + now = time.time() + bucket = self._buckets.get(ip) + if bucket is None: + return False + + cutoff = now - self._window + while bucket and bucket[0] < cutoff: + bucket.popleft() + + if not bucket: + del self._buckets[ip] + return False + + return len(bucket) >= self._max + + async def record_failure(self, ip: str) -> None: + now = time.time() + bucket = self._buckets[ip] + bucket.append(now) + + recent = sum(1 for ts in bucket if ts > now - self._window) + if recent >= self._max // 2: + logger.warning( + "throttle_failure_recorded", + ip=ip, + recent_failures=recent, + max_failures=self._max, + ) + + self._prune(now) + + def _prune(self, now: float) -> None: + cutoff = now - self._prune_age + stale = [ + ip + for ip, bucket in self._buckets.items() + if not bucket or bucket[-1] < cutoff + ] + for ip in stale: + del self._buckets[ip] + + async def close(self) -> None: + self._buckets.clear() + + +class RedisFailureStore: + """Redis-backed failure tracker using sorted sets. + + Key layout:: + + throttle:{prefix}:{ip} → ZSET {timestamp: timestamp, ...} TTL = window × 2 + + - ``ZREMRANGEBYSCORE`` prunes expired entries before counting. + - ``EXPIRE`` ensures the key auto-deletes after twice the window + (so an IP that goes quiet doesn't leak keys). + - All Redis commands are atomic — safe for multi-replica deploys. + """ + + def __init__( + self, + cfg: Config, + prefix: str = "auth", + window_seconds: int = 60, + max_failures: int = 10, + ) -> None: + self._prefix = prefix + self._window = window_seconds + self._max = max_failures + + ssl_params: dict = {} + if cfg.redis_tls: + ssl_params["ssl"] = True + ssl_params["ssl_cert_reqs"] = ( + None if cfg.redis_tls_skip_verify else "required" + ) + if cfg.redis_tls_skip_verify: + ssl_params["ssl_check_hostname"] = False + if cfg.redis_ca_certs: + ssl_params["ssl_ca_certs"] = cfg.redis_ca_certs + + retry = None + if cfg.redis_retry_count > 0: + retry = Retry( + backoff=ExponentialBackoff(), + retries=cfg.redis_retry_count, + ) + + self._client = Redis( + host=cfg.redis_host, + port=cfg.redis_port, + db=cfg.redis_db, + username=cfg.redis_username, + password=cfg.redis_password, + decode_responses=True, + socket_timeout=cfg.redis_socket_timeout, + socket_connect_timeout=cfg.redis_socket_connect_timeout, + max_connections=cfg.redis_max_connections, + health_check_interval=cfg.redis_health_check_interval, + client_name=f"{cfg.redis_client_name}-{prefix}-throttle", + retry=retry, + retry_on_error=[RedisConnectionError, RedisTimeoutError], + **ssl_params, + ) + + async def is_blocked(self, ip: str) -> bool: + try: + key = f"throttle:{self._prefix}:{ip}" + now = time.time() + cutoff = now - self._window + + await self._client.zremrangebyscore(key, "-inf", cutoff) + count = await self._client.zcard(key) + return count >= self._max + except Exception: + logger.warning("throttle_redis_error", ip=ip, exc_info=True) + return False + + async def record_failure(self, ip: str) -> None: + try: + key = f"throttle:{self._prefix}:{ip}" + now = time.time() + await self._client.zadd(key, {str(now): now}) + await self._client.expire(key, self._window * 2) + except Exception: + logger.warning("throttle_redis_error", ip=ip, exc_info=True) + + async def close(self) -> None: + await self._client.close() diff --git a/auth_service/uv.lock b/auth_service/uv.lock new file mode 100644 index 0000000..691008b --- /dev/null +++ b/auth_service/uv.lock @@ -0,0 +1,1251 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "wicket" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "aiosqlite" }, + { name = "fastapi" }, + { name = "jinja2" }, + { name = "prometheus-fastapi-instrumentator" }, + { name = "pydantic-settings" }, + { name = "python-multipart" }, + { name = "redis" }, + { name = "structlog" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[package.dev-dependencies] +dev = [ + { name = "bandit" }, + { name = "httpx" }, + { name = "pip-audit" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "aiosqlite", specifier = ">=0.20.0" }, + { name = "fastapi", specifier = ">=0.115.0" }, + { name = "jinja2", specifier = ">=3.1.6" }, + { name = "prometheus-fastapi-instrumentator", specifier = ">=7.1.0" }, + { name = "pydantic-settings", specifier = ">=2.0" }, + { name = "python-multipart", specifier = ">=0.0.9" }, + { name = "redis", specifier = ">=5.0.7" }, + { name = "structlog", specifier = ">=26.1.0" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.30.6" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "bandit", extras = ["toml"], specifier = ">=1.7.0" }, + { name = "httpx", specifier = ">=0.28.1" }, + { name = "pip-audit", specifier = ">=2.7.0" }, + { name = "pytest", specifier = ">=8.3.4" }, + { name = "pytest-asyncio", specifier = ">=0.24.0" }, + { name = "ruff", specifier = ">=0.8.0" }, +] + +[[package]] +name = "aiosqlite" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650", size = 14821, upload-time = "2025-12-23T19:25:43.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.14.0" +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/1c/b5/001890774a9552aff22502b8da382593109ce0c95314abaebbb116567545/anyio-4.14.0.tar.gz", hash = "sha256:b47c1f9ccf73e67021df785332508f99379c68fa7d0684e8e3492cb1d4b23f89", size = 253586, upload-time = "2026-06-15T22:00:49.021Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/16/9826f089383c593cdfc4a6e5aca94d9e91ae1692c57af82c3b2aa5e810f7/anyio-4.14.0-py3-none-any.whl", hash = "sha256:dd9b7a2a9799ed6552fde617b2c5df02b7fdd7d88392fc48101e51bae46164d9", size = 123506, upload-time = "2026-06-15T22:00:47.595Z" }, +] + +[[package]] +name = "bandit" +version = "1.9.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "pyyaml" }, + { name = "rich" }, + { name = "stevedore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/aa/c3/0cb80dfe0f3076e5da7e4c5ad8e57bac6ac357ff4a6406205501cade4965/bandit-1.9.4.tar.gz", hash = "sha256:b589e5de2afe70bd4d53fa0c1da6199f4085af666fde00e8a034f152a52cd628", size = 4242677, upload-time = "2026-02-25T06:44:15.503Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/a4/a26d5b25671d27e03afb5401a0be5899d94ff8fab6a698b1ac5be3ec29ef/bandit-1.9.4-py3-none-any.whl", hash = "sha256:f89ffa663767f5a0585ea075f01020207e966a9c0f2b9ef56a57c7963a3f6f8e", size = 134741, upload-time = "2026-02-25T06:44:13.694Z" }, +] + +[[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.6.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + +[[package]] +name = "click" +version = "8.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" }, +] + +[[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 = "cyclonedx-python-lib" +version = "11.11.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/75/c9/5d0ccdd19bc7d8ab803b90695c1706aa2ea8529685d18e682dc2524d2630/cyclonedx_python_lib-11.11.0.tar.gz", hash = "sha256:4b3194db72b613717f2912447e67ab618c75ff7dcac6c4af3c0e9e1ac617c102", size = 1442983, upload-time = "2026-06-17T11:57:49.055Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/f3/56ccb2884aaa3db5622368e5191a3384b15f35392aa93df8b2f508c660d2/cyclonedx_python_lib-11.11.0-py3-none-any.whl", hash = "sha256:3049fc83e06a059b5c5907a527625a8ed5073caab10607ed4c9e5503b590fd44", size = 528689, upload-time = "2026-06-17T11:57:47.358Z" }, +] + +[[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 = "fastapi" +version = "0.138.0" +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/5b/58/ff455d9fe47c60abadb34b9e05a304b1f05f5ab8000ac01565156b6f5e43/fastapi-0.138.0.tar.gz", hash = "sha256:d445a4877636ad191e7053e08c9bf98cb921a6756776848400bb773d1740c061", size = 419240, upload-time = "2026-06-20T01:18:05.259Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/ff/8496d9847a5fedae775eb49460722d3efaa80487854273e9647ae876218c/fastapi-0.138.0-py3-none-any.whl", hash = "sha256:b6f54fd1bd72c80b0f899f172c61a600f6f7af9b43d4d772a018f35624048cb0", size = 126779, upload-time = "2026-06-20T01:18:03.483Z" }, +] + +[[package]] +name = "filelock" +version = "3.29.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/dc/be6cbe99670cd6e4ad387123647cb08e0c32975e223f82551e914c5568a6/filelock-3.29.4.tar.gz", hash = "sha256:10cdb3656fc44541cdf30652a93fb10ec6b05325620eb316bd26893e4201538a", size = 63028, upload-time = "2026-06-13T16:12:00.744Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/37/a065dc3bd6e49423a6532c642ca7378d3f467b1ef44c2800c937af7f9739/filelock-3.29.4-py3-none-any.whl", hash = "sha256:dac1648087d5115554850d113e7dd8c83ab2d38e3435dde2d4f163847e57b767", size = 42757, upload-time = "2026-06-13T16:11:59.582Z" }, +] + +[[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.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[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 = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[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 = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[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 = "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.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pip" +version = "26.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/91/47e7d486260f618783899587af63ccf7980fb60245c3e63dd4571c6b57ad/pip-26.1.2.tar.gz", hash = "sha256:f49cd134c61cf2fd75e0ce2676db03e4054504a5a4986d00f8299ae632dc4605", size = 1840799, upload-time = "2026-05-31T17:33:58.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/95/6b5cb3461ea5673ba0995989746db58eb18b91b54dbf331e72f569540946/pip-26.1.2-py3-none-any.whl", hash = "sha256:382ff9f685ee3bc25864f820aa50505825f10f5458ffff07e30a6d96e5715cab", size = 1813144, upload-time = "2026-05-31T17:33:56.772Z" }, +] + +[[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.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" }, +] + +[[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 = "prometheus-client" +version = "0.25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/fb/d9aa83ffe43ce1f19e557c0971d04b90561b0cfd50762aafb01968285553/prometheus_client-0.25.0.tar.gz", hash = "sha256:5e373b75c31afb3c86f1a52fa1ad470c9aace18082d39ec0d2f918d11cc9ba28", size = 86035, upload-time = "2026-04-09T19:53:42.359Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/9b/d4b1e644385499c8346fa9b622a3f030dce14cd6ef8a1871c221a17a67e7/prometheus_client-0.25.0-py3-none-any.whl", hash = "sha256:d5aec89e349a6ec230805d0df882f3807f74fd6c1a2fa86864e3c2279059fed1", size = 64154, upload-time = "2026-04-09T19:53:41.324Z" }, +] + +[[package]] +name = "prometheus-fastapi-instrumentator" +version = "8.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "prometheus-client" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1b/e9/2065686d1dfa62296fdc158b6e8fd25b0cb3dca09b0632cabeb5ae81fe4d/prometheus_fastapi_instrumentator-8.0.2.tar.gz", hash = "sha256:3c252e748151768a7aefd66824a04a870144f71de48a67aed211749a9ca2a548", size = 21342, upload-time = "2026-06-23T09:39:31.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/c7/fa2b3f469a2e6001b829e0d6bc8680755349aa1329a87bf48731e9d5d30a/prometheus_fastapi_instrumentator-8.0.2-py3-none-any.whl", hash = "sha256:746002ec1e2c58b93f61444e1d104de959a9463a6a3f1c8909ac3757e16c3866", size = 20549, upload-time = "2026-06-23T09:39:32.616Z" }, +] + +[[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 = "pydantic-settings" +version = "2.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f", size = 237700, upload-time = "2026-06-19T13:44:56.324Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", size = 61715, upload-time = "2026-06-19T13:44:55.02Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[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-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[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 = "redis" +version = "8.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/ae/ed461cca5780b5fc8b9fe8ca0ed98d89508645fb9d880c24cc42c087678f/redis-8.0.0.tar.gz", hash = "sha256:a00c5355432051ac14e593b8b197fc76c887ee12d55a0984f69328a1115fdc49", size = 5101591, upload-time = "2026-05-28T12:45:13.5Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/e3/b519734372d305bd547534a9f32e4ce9f98552af753dce72cf3483a0ff0b/redis-8.0.0-py3-none-any.whl", hash = "sha256:c938c18338585009f0bc310f4c7e4e4b4d37639356c4ac072cedf3af570c8dc7", size = 499870, upload-time = "2026-05-28T12:45:11.697Z" }, +] + +[[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.15.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/74/98/1295ad5a5aa9bc85bdcdfa5d82fe7b49c61af5657df4f227637ff9de0da6/ruff-0.15.18.tar.gz", hash = "sha256:2698a964c70e8bf402dcb99c8810472d270d141e7aa8c4e13599fd52033a2f33", size = 4761437, upload-time = "2026-06-18T18:25:39.224Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/d0/686e984941269621e2be72612d5c1e461f8f7b38415a2a7d7a81c8ae6715/ruff-0.15.18-py3-none-linux_armv6l.whl", hash = "sha256:8b6850172348c8381b8b3084c5915a4393c2373b9b54cd5b5e1ea15812bc10df", size = 10887308, upload-time = "2026-06-18T18:25:03.062Z" }, + { url = "https://files.pythonhosted.org/packages/ed/21/bc4123e3f5515ee99f8ce1eb93a14a0628fe4d1678663cd08f933ac16931/ruff-0.15.18-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3fccc153a85417dcd976883160cacce486997b0a0058dd18f54b8aaaac7d1ce2", size = 11281305, upload-time = "2026-06-18T18:25:30.026Z" }, + { url = "https://files.pythonhosted.org/packages/51/93/4769464c25cf7ab2acb3c7dda9cad3d867eb41c59565b3e2a9d17249c90c/ruff-0.15.18-py3-none-macosx_11_0_arm64.whl", hash = "sha256:08d4c86a68f2c3ec2c9d56380a71fb4a4f65373055cbb8caabd645e9102f38d4", size = 10641215, upload-time = "2026-06-18T18:25:15.802Z" }, + { url = "https://files.pythonhosted.org/packages/6c/42/56926d17120db2c208d76bf60a1a019644dd9e91dc27f0f95c9caddb1366/ruff-0.15.18-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:37e5108745c2c0705da916d7d4de533ddf547051ef45f62888c31bae73f66318", size = 10957224, upload-time = "2026-06-18T18:25:36.955Z" }, + { url = "https://files.pythonhosted.org/packages/22/4f/d43fab8d8189afde803103022d000a8ef9f230616d436d52a8b2b8d63b50/ruff-0.15.18-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56949a6ce8b3abde54c0bcb22cebfe57e8771cadc84b407ae8b8eaf67ebdcd43", size = 10699024, upload-time = "2026-06-18T18:25:05.707Z" }, + { url = "https://files.pythonhosted.org/packages/63/42/1e3e4c68bd408b9768cf3e439acbe2c78245225faef253f7028a0cdb63e0/ruff-0.15.18-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:01a754cd6a1b630d3f97e33eb452cf7a98040482318e870f8bc52a5a30e62657", size = 11491458, upload-time = "2026-06-18T18:25:20.275Z" }, + { url = "https://files.pythonhosted.org/packages/20/77/47a3484bea8521e14a203d98c389c5c97846675e4f02734672da4a69b52a/ruff-0.15.18-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6ba7a07e03a44dbf10bb086ee06705b173625014ec99f73a7e6836a5e5590a0c", size = 12383752, upload-time = "2026-06-18T18:25:22.535Z" }, + { url = "https://files.pythonhosted.org/packages/0a/ca/054159590787023d83b658a1a1819c4c8910114e7015069340b71c0961cb/ruff-0.15.18-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5a2c40a41a4cadbcf5897b548ab29dfe248b20c540961c0247d98a3973c70403", size = 11577923, upload-time = "2026-06-18T18:25:10.702Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ff/d353d6b7bbd73cc0ec37f4463d7540e45e894338abdd9964eee0de332708/ruff-0.15.18-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5f0480ce690cbb6c4db6e5d08f19fce98e10ba131a8b60c1bcdac42771e3ae2d", size = 11583925, upload-time = "2026-06-18T18:25:32.391Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4a/891f89b9c296ed3e5f3ece1a5629badc989d9a8fdaa30431aaf4774bc1c2/ruff-0.15.18-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2330215f1f393fa8733f55edce04fcf94c36a2c460fcde31f78cc84e4951e9b1", size = 11582834, upload-time = "2026-06-18T18:25:27.309Z" }, + { url = "https://files.pythonhosted.org/packages/32/a3/ed9e370154bf85de360b93c03026157f02d4943b2d01ff4945f4429f8e8a/ruff-0.15.18-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a6aa6a3d979e48ae617578183674bf264fbe7d0114a796a26bd678d67963c7ff", size = 10927328, upload-time = "2026-06-18T18:25:34.676Z" }, + { url = "https://files.pythonhosted.org/packages/f5/d1/5cf5909329fedb5d39d555ee818ba5cf4638e1a301b89785d34f2905bfcb/ruff-0.15.18-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a81beadbbff2c9c245561ae3f77b16709d87f35eec650d0501679239d3449b22", size = 10693187, upload-time = "2026-06-18T18:25:08.245Z" }, + { url = "https://files.pythonhosted.org/packages/fd/44/ff6c635cf2c4f4e7b618b6640da057376baa36014695487d88aed4794268/ruff-0.15.18-py3-none-musllinux_1_2_i686.whl", hash = "sha256:2186d9e940ae332ab293623a75b5f4fe49565f449954d50a72a046683aa6b809", size = 11208721, upload-time = "2026-06-18T18:25:41.327Z" }, + { url = "https://files.pythonhosted.org/packages/88/d9/5baa2a30861adfb7022cf33c1e35b2fc18085b08c16f83eff4c7b99a5f48/ruff-0.15.18-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5c2abf140438032bc77b2284a6c9944ecd8a19e5f1c7b52b1b8e4a0a80d19a7a", size = 11678599, upload-time = "2026-06-18T18:25:13.607Z" }, + { url = "https://files.pythonhosted.org/packages/c3/1a/0725a7cfdc32ff769efb96ee782bec882e16448c5d9e3be947ec4c04ce27/ruff-0.15.18-py3-none-win32.whl", hash = "sha256:02299e6e9fa5b297a3f6d5d10d7bcd655c925b028bb8b9d4588214549c6b9ec4", size = 10901903, upload-time = "2026-06-18T18:25:24.755Z" }, + { url = "https://files.pythonhosted.org/packages/f3/51/805d9f6fb7970505c3504794a5ec350f605361b807fef4dcf214ebd35e72/ruff-0.15.18-py3-none-win_amd64.whl", hash = "sha256:dac80dc8d26b2257dbefabed62f5d255c3937b4ccb122da1fc634794fa3578b3", size = 12041189, upload-time = "2026-06-18T18:25:17.915Z" }, + { url = "https://files.pythonhosted.org/packages/29/4c/67bb45e41609eb4726f1bfeb59e083cf91d14c696d4bd14c234a980be93d/ruff-0.15.18-py3-none-win_arm64.whl", hash = "sha256:b2c9257fcbd4a3e5b977a1904e6facca016bafe2edc17df24db67cfaee03b4e4", size = 11329958, upload-time = "2026-06-18T18:25:43.686Z" }, +] + +[[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.3.1" +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/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, +] + +[[package]] +name = "stevedore" +version = "5.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e9/88/35e4d27d9177d7df76d060e0a18f69c6c5794c96960c94042e20a12c8ba2/stevedore-5.8.0.tar.gz", hash = "sha256:b49867b32ca3016e94100e68dbf26e72aa7b8708d0a3f73c08aeb220370ac715", size = 514710, upload-time = "2026-05-18T09:15:27.731Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/ac/19f9941c74add59d17694930ec8105d5eddeee4ce56dd8632b765ca16d6c/stevedore-5.8.0-py3-none-any.whl", hash = "sha256:88eede9e66ca80e34085b9174e2327da2c61ac91f24f70e41c3ad76e4bb4872b", size = 54553, upload-time = "2026-05-18T09:15:25.82Z" }, +] + +[[package]] +name = "structlog" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/89/b4a0bcfdf4f71a3dea31379f095929613d7e4528a0996bca6aa964cd0dca/structlog-26.1.0.tar.gz", hash = "sha256:f63a716cbd1b1291cf7661de7794b455acfa4c43c5bcf1630e6ad5ddc1adb3b7", size = 1459881, upload-time = "2026-06-06T07:33:39.348Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/18/489c97b834dfff9cf2fc2507cede4bcd4b11e67f84bc462acd1992496f86/structlog-26.1.0-py3-none-any.whl", hash = "sha256:e081a26d6c373e6d201eca24eede26d8ffab07f88f477822e679183428d3d91e", size = 73764, upload-time = "2026-06-06T07:33:38.046Z" }, +] + +[[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.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[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.49.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/1f/fa18009dea8469069cca78a4e877a008ab78f08b064bfc9ab891579077ff/uvicorn-0.49.0.tar.gz", hash = "sha256:ebf4271aa580d9de97f93192d4595176df6e91f9aae919ca73e4fc07df1e66a3", size = 91284, upload-time = "2026-06-03T22:01:30.448Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/fa/e1388bbcf24ef3274f45c0c1c7b501fd14971037c1b6ee23610553307497/uvicorn-0.49.0-py3-none-any.whl", hash = "sha256:ba3d14c3ee7e41c6c654c46c9eb489d33213cdd30aa1696eab1374337c13f68f", size = 71376, upload-time = "2026-06-03T22:01:29.037Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { 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 = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, + { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, + { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, + { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, + { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, + { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +] diff --git a/auth_service/validators.py b/auth_service/validators.py new file mode 100644 index 0000000..6f83f73 --- /dev/null +++ b/auth_service/validators.py @@ -0,0 +1,181 @@ +import ipaddress +import re +from typing import List, Tuple + +from fastapi import HTTPException + +_HOSTNAME_RE = re.compile(r"^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?$") +_PORT_RE = re.compile(r":(\d{1,5})$") +_TOKEN_HASH_RE = re.compile(r"^[a-f0-9]{64}$") +_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s.]+$") + +_MAX_HOSTS_LENGTH = 4096 +_MAX_COMMENT_LENGTH = 256 +_MAX_EMAIL_LENGTH = 254 + + +def _split_port(entry: str) -> Tuple[str, int | None]: + m = _PORT_RE.search(entry) + if m: + port = int(m.group(1)) + if port < 1 or port > 65535: + raise ValueError(f"invalid port: {port}") + return entry[: m.start()], port + return entry, None + + +def _is_valid_hostname_labels(entry: str) -> bool: + if not entry: + return False + labels = entry.split(".") + return all(_HOSTNAME_RE.match(label) for label in labels) + + +def _is_valid_wildcard_hostname(entry: str) -> bool: + if not entry.startswith("*."): + return False + rest = entry[2:] + if not rest: + return False + return _is_valid_hostname_labels(rest) + + +def _is_valid_ipv4(entry: str) -> bool: + try: + ipaddress.IPv4Address(entry) + return True + except (ipaddress.AddressValueError, ValueError): + return False + + +def _validate_host_part(host_part: str) -> None: + if host_part == "*": + return + if _is_valid_wildcard_hostname(host_part): + return + if _is_valid_hostname_labels(host_part): + return + if _is_valid_ipv4(host_part): + return + if host_part.startswith("[") and host_part.endswith("]"): + try: + ipaddress.IPv6Address(host_part[1:-1]) + return + except (ipaddress.AddressValueError, ValueError): + pass + raise ValueError(f"invalid host: {host_part}") + + +def validate_hosts(raw_hosts: str) -> List[str]: + if not raw_hosts or not raw_hosts.strip(): + raise HTTPException(status_code=422, detail="hosts must not be empty") + if len(raw_hosts) > _MAX_HOSTS_LENGTH: + raise HTTPException( + status_code=422, + detail=f"hosts must be <= {_MAX_HOSTS_LENGTH} characters", + ) + + hosts: List[str] = [] + for part in raw_hosts.split(","): + stripped = part.strip() + if not stripped: + continue + entry = stripped.lower() + + try: + host_part, port = _split_port(entry) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) + + if port is not None and host_part == "": + raise HTTPException( + status_code=422, + detail=f"invalid host entry: '{stripped}'", + ) + + try: + _validate_host_part(host_part) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) + + if port is not None: + hosts.append(f"{host_part}:{port}") + else: + hosts.append(host_part) + + if not hosts: + raise HTTPException(status_code=422, detail="hosts must not be empty") + return hosts + + +def parse_allowed_hosts(raw_hosts: str) -> List[str]: + if not raw_hosts: + return [] + return [h.strip().lower() for h in raw_hosts.split(",") if h.strip()] + + +def host_matches(requested_host: str, pattern: str) -> bool: + req = requested_host.lower() + pat = pattern.lower() + + req_host, req_port = _split_port(req) + pat_host, pat_port = _split_port(pat) + + if pat_host == "*": + if pat_port is None: + return True + return pat_port == req_port + + if pat_port is not None and pat_port != req_port: + return False + if pat_port is None and req_port is not None: + return False + + if pat_host.startswith("*."): + suffix = pat_host[1:] + if not req_host.endswith(suffix): + return False + prefix = req_host[: -len(suffix)] + if not prefix or "." in prefix: + return False + return True + + return req_host == pat_host + + +def validate_email(email: str | None) -> str | None: + if email is None: + return None + stripped = email.strip() + if not stripped: + return None + if len(stripped) > _MAX_EMAIL_LENGTH: + raise HTTPException( + status_code=422, + detail=f"email must be <= {_MAX_EMAIL_LENGTH} characters", + ) + if not _EMAIL_RE.match(stripped): + raise HTTPException(status_code=422, detail="invalid email address") + return stripped.lower() + + +def validate_token_hash(token_hash: str) -> None: + if not _TOKEN_HASH_RE.match(token_hash): + raise HTTPException( + status_code=422, + detail="invalid token hash format (expected 64 hex chars)", + ) + + +def validate_comment(comment: str | None) -> str | None: + if comment is None: + return None + stripped = comment.strip() + if not stripped: + return None + if len(stripped) > _MAX_COMMENT_LENGTH: + raise HTTPException( + status_code=422, + detail=f"comment must be <= {_MAX_COMMENT_LENGTH} characters", + ) + return stripped diff --git a/docker-compose.redis.yml b/docker-compose.redis.yml new file mode 100644 index 0000000..e9a461e --- /dev/null +++ b/docker-compose.redis.yml @@ -0,0 +1,68 @@ +services: + redis: + image: redis:7-alpine + command: + [ + "redis-server", + "--appendonly", + "yes", + "--requirepass", + "${REDIS_PASSWORD}", + ] + environment: + REDIS_PASSWORD: ${REDIS_PASSWORD} + expose: + - "6379" + volumes: + - redis-data:/data + restart: unless-stopped + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + healthcheck: + test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"] + interval: 5s + timeout: 3s + retries: 5 + start_period: 10s + deploy: + resources: + limits: + memory: 256M + cpus: "0.5" + + wicket: + build: + context: auth_service + dockerfile: Dockerfile + init: true + environment: + STORAGE_BACKEND: redis + PEPPER: ${PEPPER} + ADMIN_USER: ${ADMIN_USER} + ADMIN_PASS: ${ADMIN_PASS} + TOKEN_TTL_SECONDS: ${TOKEN_TTL_SECONDS:-0} + REDIS_HOST: redis + REDIS_PORT: 6379 + REDIS_PASSWORD: ${REDIS_PASSWORD} + ports: + - "127.0.0.1:8000:8000" + restart: unless-stopped + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + deploy: + resources: + limits: + memory: 512M + cpus: "1.0" + depends_on: + redis: + condition: service_healthy + +volumes: + redis-data: {} diff --git a/docker-compose.yml b/docker-compose.yml index dc97438..2bdb0ba 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,33 +1,28 @@ -version: "3.8" services: - redis: - image: redis:7-alpine - command: ["redis-server", "--appendonly", "yes", "--requirepass", "${REDIS_PASSWORD}"] - environment: - - REDIS_PASSWORD=${REDIS_PASSWORD} - ports: - - "6379:6379" - volumes: - - redis-data:/data - - auth-service: + wicket: build: context: auth_service dockerfile: Dockerfile + init: true environment: - - REDIS_HOST=redis - - REDIS_PORT=6379 - - REDIS_PASSWORD=${REDIS_PASSWORD} - - PEPPER=${PEPPER} - - ADMIN_USER=${ADMIN_USER} - - ADMIN_PASS=${ADMIN_PASS} - - TOKEN_TTL_SECONDS=${TOKEN_TTL_SECONDS} - - RATE_LIMIT_WINDOW_SEC=${RATE_LIMIT_WINDOW_SEC} - - RATE_LIMIT_MAX=${RATE_LIMIT_MAX} + STORAGE_BACKEND: sqlite + SQLITE_PATH: /data/tokens.db + PEPPER: ${PEPPER} + ADMIN_USER: ${ADMIN_USER} + ADMIN_PASS: ${ADMIN_PASS} + TOKEN_TTL_SECONDS: ${TOKEN_TTL_SECONDS:-0} ports: - - "8000:8000" - depends_on: - - redis - -volumes: - redis-data: {} + - "127.0.0.1:8000:8000" + volumes: + - ./data:/data + restart: unless-stopped + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + deploy: + resources: + limits: + memory: 512M + cpus: "1.0" diff --git a/docs/token_creation.png b/docs/token_creation.png new file mode 100644 index 0000000000000000000000000000000000000000..f7d0ad754d350eb29a1e8ee77d0146800ed8621d GIT binary patch literal 191519 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

~|`t7nq-yPb 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 Ct)G1W literal 0 HcmV?d00001 diff --git a/env.example b/env.example index 081c78a..fbcc772 100644 --- a/env.example +++ b/env.example @@ -1,14 +1,34 @@ # Copy to .env in the repo root and adjust values -# Redis -REDIS_PASSWORD=admin +# STORAGE_BACKEND=sqlite +# SQLITE_PATH=/data/tokens.db -# Auth service secrets +# Redis connection +# REDIS_HOST=localhost +# REDIS_PORT=6379 +# REDIS_DB=0 +# REDIS_USERNAME= +# REDIS_PASSWORD= + +# Redis TLS +# REDIS_TLS=false +# REDIS_TLS_SKIP_VERIFY=false +# REDIS_CA_CERTS= + +# REDIS_SOCKET_TIMEOUT=5.0 +# REDIS_SOCKET_CONNECT_TIMEOUT=2.0 +# REDIS_MAX_CONNECTIONS=20 +# REDIS_HEALTH_CHECK_INTERVAL=30 +# REDIS_RETRY_COUNT=3 +# REDIS_CLIENT_NAME=wicket + +# Auth secrets PEPPER=change-me-pepper -ADMIN_USER=admin +ADMIN_USER=change-me ADMIN_PASS=admin -# Optional defaults -TOKEN_TTL_SECONDS=36000000 -RATE_LIMIT_WINDOW_SEC=1 -RATE_LIMIT_MAX=100 +# Token defaults +# TOKEN_TTL_SECONDS=0 + +# AUTH_FAILURE_WINDOW_SECONDS=60 +# AUTH_MAX_FAILURES=10 diff --git a/example/README.md b/example/README.md index 70f7b84..e728522 100644 --- a/example/README.md +++ b/example/README.md @@ -1,11 +1,33 @@ -# ACL Proxy test bench +# Wicket test bench -Runs Redis, Auth Service, Traefik (ForwardAuth), and a whoami backend. +This test bench runs the auth service, a reverse proxy with ForwardAuth, and a whoami backend. + +Two proxy variants: **Traefik** and **Caddy**. Each supports two storage backends. ## Start +Traefik + SQLite: + +```bash +docker compose -f example/traefik/docker-compose.sqlite.yml up --build +``` + +Traefik + Redis: + ```bash -docker compose -f example/docker-compose.yml up --build +docker compose -f example/traefik/docker-compose.redis.yml up --build +``` + +Caddy + SQLite: + +```bash +docker compose -f example/caddy/docker-compose.sqlite.yml up --build +``` + +Caddy + Redis: + +```bash +docker compose -f example/caddy/docker-compose.redis.yml up --build ``` ## Ports @@ -13,20 +35,25 @@ docker compose -f example/docker-compose.yml up --build | Port | Purpose | |------|---------| | 8000 | Auth service admin UI | -| 8080 | Traefik entrypoint | +| 8080 | Proxy entrypoint | + +Traefik only: + +| Port | Purpose | +|------|---------| | 8081 | Traefik dashboard | ## Test Flow ### 1. Create a token -Open http://localhost:8000, log in with `admin / admin`. +Open http://localhost:8000, log in with `test-admin / test-admin`. Enter `whoami.localhost` in **Hosts**, click **Create Token**. Copy the `token` value from the response. -### 2. Request without token → 401 +### 2. Request without token: 401 ```bash curl -s -o /dev/null -w "%{http_code}\n" \ @@ -34,7 +61,7 @@ curl -s -o /dev/null -w "%{http_code}\n" \ http://localhost:8080/ ``` -### 3. Request with token → 200 +### 3. Request with token: 200 ```bash curl -H "Host: whoami.localhost" \ @@ -44,7 +71,10 @@ curl -H "Host: whoami.localhost" \ Returns whoami output: IP, headers, `X-Forwarded-Host`. -### 4. Token for a different host → 403 +### 4. Token for a different host: not routed + +The proxy only routes requests with `Host: whoami.localhost`. A request with a different host +does not reach the auth service. ```bash curl -s -o /dev/null -w "%{http_code}\n" \ @@ -53,21 +83,13 @@ curl -s -o /dev/null -w "%{http_code}\n" \ http://localhost:8080/ ``` -### 5. Rate limit → 429 - -```bash -for i in $(seq 1 110); do - curl -s -o /dev/null -w "%{http_code}\n" \ - -H "Host: whoami.localhost" \ - -H "Authorization: Bearer " \ - http://localhost:8080/ -done -``` - -First 100 requests → `200`, then `429`. +Traefik returns `404`, Caddy returns `200` with empty body. ## Stop ```bash -docker compose -f example/docker-compose.yml down -v +docker compose -f example/traefik/docker-compose.sqlite.yml down -v +docker compose -f example/traefik/docker-compose.redis.yml down -v +docker compose -f example/caddy/docker-compose.sqlite.yml down -v +docker compose -f example/caddy/docker-compose.redis.yml down -v ``` diff --git a/example/caddy/Caddyfile b/example/caddy/Caddyfile new file mode 100644 index 0000000..13b2aab --- /dev/null +++ b/example/caddy/Caddyfile @@ -0,0 +1,6 @@ +http://whoami.localhost { + forward_auth wicket:8000 { + uri /auth + } + reverse_proxy whoami:80 +} diff --git a/example/caddy/docker-compose.redis.yml b/example/caddy/docker-compose.redis.yml new file mode 100644 index 0000000..c096a9c --- /dev/null +++ b/example/caddy/docker-compose.redis.yml @@ -0,0 +1,51 @@ +services: + redis: + image: redis:7-alpine + command: + [ + "redis-server", + "--appendonly", + "yes", + "--requirepass", + "${REDIS_PASSWORD:-test}", + ] + expose: + - "6379" + restart: unless-stopped + healthcheck: + test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD:-test}", "ping"] + interval: 5s + timeout: 3s + retries: 5 + start_period: 10s + + wicket: + build: + context: ../../auth_service + dockerfile: Dockerfile + init: true + environment: + STORAGE_BACKEND: redis + PEPPER: ${PEPPER:-test-pepper} + ADMIN_USER: ${ADMIN_USER:-test-admin} + ADMIN_PASS: ${ADMIN_PASS:-test-admin} + TOKEN_TTL_SECONDS: ${TOKEN_TTL_SECONDS:-0} + REDIS_HOST: redis + REDIS_PORT: 6379 + REDIS_PASSWORD: ${REDIS_PASSWORD:-test} + ports: + - "8000:8000" + restart: unless-stopped + depends_on: + redis: + condition: service_healthy + + caddy: + image: caddy:2 + ports: + - "8080:80" + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + + whoami: + image: traefik/whoami:latest diff --git a/example/caddy/docker-compose.sqlite.yml b/example/caddy/docker-compose.sqlite.yml new file mode 100644 index 0000000..bf264d7 --- /dev/null +++ b/example/caddy/docker-compose.sqlite.yml @@ -0,0 +1,25 @@ +services: + wicket: + build: + context: ../../auth_service + dockerfile: Dockerfile + init: true + environment: + STORAGE_BACKEND: sqlite + PEPPER: ${PEPPER:-test-pepper} + ADMIN_USER: ${ADMIN_USER:-test-admin} + ADMIN_PASS: ${ADMIN_PASS:-test-admin} + TOKEN_TTL_SECONDS: ${TOKEN_TTL_SECONDS:-0} + ports: + - "8000:8000" + restart: unless-stopped + + caddy: + image: caddy:2 + ports: + - "8080:80" + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + + whoami: + image: traefik/whoami:latest diff --git a/example/docker-compose.yml b/example/docker-compose.yml deleted file mode 100644 index 61a0f23..0000000 --- a/example/docker-compose.yml +++ /dev/null @@ -1,46 +0,0 @@ -services: - redis: - image: redis:7-alpine - command: - [ - "redis-server", - "--appendonly", - "yes", - "--requirepass", - "${REDIS_PASSWORD:-test}", - ] - ports: - - "6379:6379" - - auth-service: - build: - context: ../auth_service - dockerfile: Dockerfile - environment: - - REDIS_HOST=redis - - REDIS_PORT=6379 - - REDIS_PASSWORD=${REDIS_PASSWORD:-test} - - PEPPER=${PEPPER:-test-pepper} - - ADMIN_USER=${ADMIN_USER:-admin} - - ADMIN_PASS=${ADMIN_PASS:-admin} - - TOKEN_TTL_SECONDS=${TOKEN_TTL_SECONDS:-0} - - RATE_LIMIT_WINDOW_SEC=${RATE_LIMIT_WINDOW_SEC:-1} - - RATE_LIMIT_MAX=${RATE_LIMIT_MAX:-100} - ports: - - "8000:8000" - depends_on: - - redis - - traefik: - image: traefik:v3.3 - command: - - "--configfile=/etc/traefik/traefik.yml" - ports: - - "8080:80" - - "8081:8080" - volumes: - - ./traefik.yml:/etc/traefik/traefik.yml:ro - - ./dynamic.yml:/etc/traefik/dynamic.yml:ro - - whoami: - image: traefik/whoami:latest diff --git a/example/traefik/docker-compose.redis.yml b/example/traefik/docker-compose.redis.yml new file mode 100644 index 0000000..9c09207 --- /dev/null +++ b/example/traefik/docker-compose.redis.yml @@ -0,0 +1,55 @@ +services: + redis: + image: redis:7-alpine + command: + [ + "redis-server", + "--appendonly", + "yes", + "--requirepass", + "${REDIS_PASSWORD:-test}", + ] + expose: + - "6379" + restart: unless-stopped + healthcheck: + test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD:-test}", "ping"] + interval: 5s + timeout: 3s + retries: 5 + start_period: 10s + + wicket: + build: + context: ../../auth_service + dockerfile: Dockerfile + init: true + environment: + STORAGE_BACKEND: redis + PEPPER: ${PEPPER:-test-pepper} + ADMIN_USER: ${ADMIN_USER:-test-admin} + ADMIN_PASS: ${ADMIN_PASS:-test-admin} + TOKEN_TTL_SECONDS: ${TOKEN_TTL_SECONDS:-0} + REDIS_HOST: redis + REDIS_PORT: 6379 + REDIS_PASSWORD: ${REDIS_PASSWORD:-test} + ports: + - "8000:8000" + restart: unless-stopped + depends_on: + redis: + condition: service_healthy + + traefik: + image: traefik:v3.3 + command: + - "--configfile=/etc/traefik/traefik.yml" + ports: + - "8080:80" + - "8081:8080" + volumes: + - ./traefik.yml:/etc/traefik/traefik.yml:ro + - ./dynamic.yml:/etc/traefik/dynamic.yml:ro + + whoami: + image: traefik/whoami:latest diff --git a/example/traefik/docker-compose.sqlite.yml b/example/traefik/docker-compose.sqlite.yml new file mode 100644 index 0000000..0c7ca33 --- /dev/null +++ b/example/traefik/docker-compose.sqlite.yml @@ -0,0 +1,29 @@ +services: + wicket: + build: + context: ../../auth_service + dockerfile: Dockerfile + init: true + environment: + STORAGE_BACKEND: sqlite + PEPPER: ${PEPPER:-test-pepper} + ADMIN_USER: ${ADMIN_USER:-test-admin} + ADMIN_PASS: ${ADMIN_PASS:-test-admin} + TOKEN_TTL_SECONDS: ${TOKEN_TTL_SECONDS:-0} + ports: + - "8000:8000" + restart: unless-stopped + + traefik: + image: traefik:v3.3 + command: + - "--configfile=/etc/traefik/traefik.yml" + ports: + - "8080:80" + - "8081:8080" + volumes: + - ./traefik.yml:/etc/traefik/traefik.yml:ro + - ./dynamic.yml:/etc/traefik/dynamic.yml:ro + + whoami: + image: traefik/whoami:latest diff --git a/example/dynamic.yml b/example/traefik/dynamic.yml similarity index 88% rename from example/dynamic.yml rename to example/traefik/dynamic.yml index 8f83d03..fa0bb5f 100644 --- a/example/dynamic.yml +++ b/example/traefik/dynamic.yml @@ -3,7 +3,7 @@ http: middlewares: auth: forwardAuth: - address: "http://auth-service:8000/auth" + address: "http://wicket:8000/auth" trustForwardHeader: true routers: diff --git a/example/traefik.yml b/example/traefik/traefik.yml similarity index 100% rename from example/traefik.yml rename to example/traefik/traefik.yml diff --git a/k8s/auth-service.yaml b/k8s/auth-service.yaml deleted file mode 100644 index 42899b9..0000000 --- a/k8s/auth-service.yaml +++ /dev/null @@ -1,119 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: auth-service - namespace: default -spec: - replicas: 1 - selector: - matchLabels: - app: auth-service - template: - metadata: - labels: - app: auth-service - spec: - imagePullSecrets: - - name: ghcr-creds - containers: - - name: auth-service - image: ghcr.io/trofkm/acl-auth-service:latest - imagePullPolicy: IfNotPresent - envFrom: - - configMapRef: - name: auth-service-config - - secretRef: - name: auth-service-secrets - env: - - name: REDIS_HOST - value: "redis.default.svc.cluster.local" - - name: REDIS_PORT - value: "6379" - - name: REDIS_PASSWORD - valueFrom: - secretKeyRef: - name: redis-auth - key: password - - name: PEPPER - valueFrom: - secretKeyRef: - name: auth-service-secrets - key: pepper - - name: ADMIN_USER - valueFrom: - secretKeyRef: - name: auth-service-secrets - key: admin_user - - name: ADMIN_PASS - valueFrom: - secretKeyRef: - name: auth-service-secrets - key: admin_pass - - name: TOKEN_TTL_SECONDS - value: "0" - - name: RATE_LIMIT_WINDOW_SEC - value: "1" - - name: RATE_LIMIT_MAX - value: "20" - ports: - - containerPort: 8000 ---- -apiVersion: v1 -kind: Service -metadata: - name: auth-service - namespace: default -spec: - selector: - app: auth-service - ports: - - name: http - port: 8000 - targetPort: 8000 ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: redis - namespace: default -spec: - replicas: 1 - selector: - matchLabels: - app: redis - template: - metadata: - labels: - app: redis - spec: - containers: - - name: redis - image: redis:7-alpine - args: ["--appendonly", "yes", "--requirepass", "$(REDIS_PASSWORD)"] - env: - - name: REDIS_PASSWORD - valueFrom: - secretKeyRef: - name: redis-auth - key: password - ports: - - containerPort: 6379 - volumeMounts: - - name: redis-data - mountPath: /data - volumes: - - name: redis-data - emptyDir: {} ---- -apiVersion: v1 -kind: Service -metadata: - name: redis - namespace: default -spec: - selector: - app: redis - ports: - - name: redis - port: 6379 - targetPort: 6379 diff --git a/k8s/ingress-traefik.yaml b/k8s/ingress-traefik.yaml deleted file mode 100644 index 9d61618..0000000 --- a/k8s/ingress-traefik.yaml +++ /dev/null @@ -1,27 +0,0 @@ ---- -# Admin UI is not exposed externally. Use port-forward: -# kubectl port-forward svc/auth-service 8000:8000 -# Then visit http://localhost:8000 ---- -# Example: Protected service with auth middleware -# Uncomment and modify for your services -# apiVersion: networking.k8s.io/v1 -# kind: Ingress -# metadata: -# name: my-protected-service -# namespace: default -# annotations: -# traefik.ingress.kubernetes.io/router.middlewares: default-auth-middleware@kubernetescrd -# spec: -# ingressClassName: traefik -# rules: -# - host: myapp.example.com -# http: -# paths: -# - path: / -# pathType: Prefix -# backend: -# service: -# name: my-service -# port: -# number: 80 diff --git a/k8s/test-protected-service.yaml b/k8s/test-protected-service.yaml deleted file mode 100644 index 1ec5320..0000000 --- a/k8s/test-protected-service.yaml +++ /dev/null @@ -1,56 +0,0 @@ ---- -# Test service for auth middleware -apiVersion: apps/v1 -kind: Deployment -metadata: - name: test-nginx - namespace: default -spec: - replicas: 1 - selector: - matchLabels: - app: test-nginx - template: - metadata: - labels: - app: test-nginx - spec: - containers: - - name: nginx - image: nginx:alpine - ports: - - containerPort: 80 ---- -apiVersion: v1 -kind: Service -metadata: - name: test-nginx - namespace: default -spec: - selector: - app: test-nginx - ports: - - port: 80 - targetPort: 80 ---- -# Protected by auth middleware -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: test-protected - namespace: default - annotations: - traefik.ingress.kubernetes.io/router.middlewares: default-auth-middleware@kubernetescrd -spec: - ingressClassName: traefik - rules: - - host: test.example.com - http: - paths: - - path: / - pathType: Prefix - backend: - service: - name: test-nginx - port: - number: 80 diff --git a/k8s/traefik-middleware.yaml b/k8s/traefik-middleware.yaml deleted file mode 100644 index 9dd1352..0000000 --- a/k8s/traefik-middleware.yaml +++ /dev/null @@ -1,11 +0,0 @@ -apiVersion: traefik.io/v1alpha1 -kind: Middleware -metadata: - name: auth-middleware - namespace: default -spec: - forwardAuth: - address: "http://auth-service.default.svc.cluster.local:8000/auth" - trustForwardHeader: true - authResponseHeaders: - - "X-Forwarded-User" diff --git a/scripts/README.md b/scripts/README.md deleted file mode 100644 index 717799d..0000000 --- a/scripts/README.md +++ /dev/null @@ -1,46 +0,0 @@ -# Scripts - -## apply-secrets.sh - -Creates and updates Kubernetes secrets and configmaps from `.env`. - -### Usage - -```bash -# Make sure you have a .env file in the repo root -cp ../env.example ../.env -# Edit .env with your actual values - -# Run the script manually -./apply-secrets.sh -``` - -### What it creates - -1. **ConfigMap: auth-service-config** - - TOKEN_TTL_SECONDS - - RATE_LIMIT_WINDOW_SEC - - RATE_LIMIT_MAX - -2. **Secret: auth-service-secrets** - - pepper (encryption pepper) - - admin_user (basic auth username) - - admin_pass (basic auth password) - -3. **Secret: redis-auth** - - password (Redis password) - -### Integration with Tilt - -Tilt runs this script when you `tilt up`: -- On first startup -- When `.env` changes -- Before auth-service deploys (secrets must exist first) - -### Requirements - -- kubectl configured and connected to your cluster -- .env file in the repository root -- Kubernetes namespace 'default' (or modify the script) - - diff --git a/scripts/apply-secrets.sh b/scripts/apply-secrets.sh deleted file mode 100755 index d5eab11..0000000 --- a/scripts/apply-secrets.sh +++ /dev/null @@ -1,46 +0,0 @@ -#!/bin/bash -set -e - -# Create K8s secrets and configmaps from .env - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" -ENV_FILE="$REPO_ROOT/.env" - -if [ ! -f "$ENV_FILE" ]; then - echo "Error: .env file not found at $ENV_FILE" - echo "Please copy env.example to .env and configure your secrets" - exit 1 -fi - -echo "Loading environment variables from $ENV_FILE" -source "$ENV_FILE" - -echo "Creating Kubernetes secrets and configmaps..." - -# ConfigMap -echo "→ Creating auth-service-config ConfigMap..." -kubectl create configmap auth-service-config \ - --from-literal=TOKEN_TTL_SECONDS="${TOKEN_TTL_SECONDS:-36000000}" \ - --from-literal=RATE_LIMIT_WINDOW_SEC="${RATE_LIMIT_WINDOW_SEC:-1}" \ - --from-literal=RATE_LIMIT_MAX="${RATE_LIMIT_MAX:-20}" \ - -n default \ - --dry-run=client -o yaml | kubectl apply -f - - -# Create auth-service secrets -echo "→ Creating auth-service-secrets Secret..." -kubectl create secret generic auth-service-secrets \ - --from-literal=pepper="${PEPPER:-change-me-pepper}" \ - --from-literal=admin_user="${ADMIN_USER:-admin}" \ - --from-literal=admin_pass="${ADMIN_PASS:-admin}" \ - -n default \ - --dry-run=client -o yaml | kubectl apply -f - - -# Create Redis secret -echo "→ Creating redis-auth Secret..." -kubectl create secret generic redis-auth \ - --from-literal=password="${REDIS_PASSWORD:-admin}" \ - -n default \ - --dry-run=client -o yaml | kubectl apply -f - - -echo "✓ Successfully created/updated all secrets and configmaps"