commit 50c2fe2a1ed029df253be1a3d7c497c851418440 Author: Nguyen Minh Phuc Date: Fri Jul 17 10:44:54 2026 +0000 svcforge: reference implementation Complete working build of the system learn-python/ teaches. 164 tests, mypy --strict clean, domain coverage 99%. diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a57849c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,31 @@ +# Build context is the repo root (docker build -f services//Dockerfile .). +# Everything here is either a host artifact, a secret, or dead weight in a layer. +.venv/ +.git/ +.github/ +.gitea/ +tests/ +**/__pycache__ +*.pyc +.mypy_cache/ +.ruff_cache/ +.pytest_cache/ +.hypothesis/ +.coverage +htmlcov/ +dist/ +build/ +*.egg-info/ + +# secrets never enter a build context, let alone a layer +.env +*.env +!.env.example + +# not needed at runtime +deploy/ +docs/ +scripts/ +Makefile +RUNBOOK.md +README.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..499f181 --- /dev/null +++ b/.env.example @@ -0,0 +1,18 @@ +# Copy to .env and fill in. .env is gitignored and must never be committed. +# Real values live in ~/.config/svcforge/secrets.env (chmod 600). + +# Postgres — transaction pooler (6543). Runtime traffic. +SVCFORGE_PG_DSN=postgresql://postgres.:@aws-1-ap-southeast-1.pooler.supabase.com:6543/postgres +# Postgres — session pooler (5432). Migrations and psql only. +SVCFORGE_PG_DSN_SESSION=postgresql://postgres.:@aws-1-ap-southeast-1.pooler.supabase.com:5432/postgres +# Redis — Upstash. Derived state only. +SVCFORGE_REDIS_DSN=rediss://default:@.upstash.io:6379 + +# API +SVCFORGE_JWKS_URL=https:///protocol/openid-connect/certs +SVCFORGE_JWT_ISSUER=https:// +SVCFORGE_JWT_AUDIENCE=svcforge + +# Worker +SVCFORGE_WORKER_ID=worker-local +SVCFORGE_WORKER_CONCURRENCY=4 diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml new file mode 100644 index 0000000..641b78f --- /dev/null +++ b/.gitea/workflows/ci.yaml @@ -0,0 +1,279 @@ +# svcforge CI. +# +# The contract, in one line: merge to master -> three images built and scanned -> the +# chart's image digests bumped -> ArgoCD syncs. CI never touches the cluster. There is no +# kubeconfig here and there must never be one; the pipeline's last act is a git commit. +# +# Rules this file exists to enforce: +# - Build once, promote the artifact. The digest that trivy scanned is the digest that +# lands in values.yaml is the digest that runs. +# - Deploy by digest, never a mutable tag. +# - Everything pinned: actions by SHA, tool images by digest, deps by uv.lock + --frozen. +# - Every gate required. None advisory. Fail the PR, not prod. +# +# Stage order is deliberate and matches the module: cheapest and most likely to fail first, +# so a formatting mistake costs 20 seconds instead of three minutes of image builds. + +name: ci + +on: [pull_request, push] + +concurrency: + # A second push to the same branch makes the first run's answer irrelevant. Cancel it — + # except on master, where the run ends in a commit and must not be interrupted midway. + group: ci-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/master' }} + +env: + REGISTRY: gitea.oci-oci.duckdns.org + IMAGE_NS: gitea_admin + UV_VERSION: "0.5.11" + +jobs: + # --- stage 1: lint -- fast, fails first --------------------------------------------- + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: astral-sh/setup-uv@38f3f104447c67c051c4a08e39b64a148898af3a # v4.2.0 + with: + version: ${{ env.UV_VERSION }} + enable-cache: true + # The uv store is keyed on the lockfile: same lock, same wheels, cache hit. + cache-dependency-glob: uv.lock + - run: uv sync --frozen + - name: ruff + run: uv run ruff check . && uv run ruff format --check . + + # --- stage 2: types -- your compiler ------------------------------------------------ + types: + runs-on: ubuntu-latest + needs: [lint] + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: astral-sh/setup-uv@38f3f104447c67c051c4a08e39b64a148898af3a # v4.2.0 + with: + version: ${{ env.UV_VERSION }} + enable-cache: true + cache-dependency-glob: uv.lock + - run: uv sync --frozen + - name: mypy --strict + run: uv run mypy --strict . + + # --- stage 3: unit -- domain only, milliseconds, coverage gate ----------------------- + unit: + runs-on: ubuntu-latest + needs: [lint] + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: astral-sh/setup-uv@38f3f104447c67c051c4a08e39b64a148898af3a # v4.2.0 + with: + version: ${{ env.UV_VERSION }} + enable-cache: true + cache-dependency-glob: uv.lock + - run: uv sync --frozen + - name: pytest unit + # The gate is on domain/ alone, and only domain/. It is pure, has no I/O, and needs + # no mocks — there is no excuse for a gap there. Pointing this at the whole repo + # would let untested SQL be paid for by well-tested pure functions. + run: uv run pytest tests/unit --cov=libs/svcforge_core/domain --cov-fail-under=90 + + # --- stages 4+5: migrate, then integration against that schema ----------------------- + integration: + runs-on: ubuntu-latest + needs: [unit] + services: + postgres: + image: postgres:16@sha256:33f923b05f64ca54ac4401c01126a6b92afe839a0aa0a52bc5aeb5cc958e5f20 + env: + POSTGRES_PASSWORD: postgres + POSTGRES_DB: svcforge + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + env: + # A scratch Postgres, so a literal password is correct here: it is not a secret, it + # is a fixture. Real DSNs live in Vault and reach the pods via external-secrets. + SVCFORGE_PG_DSN: postgresql://postgres:postgres@postgres:5432/svcforge + SVCFORGE_PG_DSN_SESSION: postgresql://postgres:postgres@postgres:5432/svcforge + # What tests/integration/conftest.py actually reads. Without it the fixture falls + # back to testcontainers and starts a SECOND Postgres inside the runner's docker, + # while the service container above sits unused — slower, and a different database + # to the one `migrate` just ran against. + SVCFORGE_TEST_DSN: postgresql://postgres:postgres@postgres:5432/svcforge + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: astral-sh/setup-uv@38f3f104447c67c051c4a08e39b64a148898af3a # v4.2.0 + with: + version: ${{ env.UV_VERSION }} + enable-cache: true + cache-dependency-glob: uv.lock + - run: uv sync --frozen + - name: migrate + # The same entrypoint the chart's pre-upgrade hook runs. If migrations only ever + # ran under testcontainers, CI would be testing a code path production never takes. + run: uv run python -m svcforge_core.migrate + - name: pytest integration + run: uv run pytest tests/integration + + # --- stages 6+7+8: SAST, secrets, dependency CVEs ------------------------------------ + # One job, three independent gates. They share a checkout and nothing else; each `run` + # step fails the job on its own. + security: + runs-on: ubuntu-latest + needs: [lint] + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + # gitleaks scans history, not just the tip. A secret committed and then reverted + # is still a leaked secret, and a shallow clone cannot see it. + fetch-depth: 0 + - uses: astral-sh/setup-uv@38f3f104447c67c051c4a08e39b64a148898af3a # v4.2.0 + with: + version: ${{ env.UV_VERSION }} + enable-cache: true + cache-dependency-glob: uv.lock + - run: uv sync --frozen + + - name: bandit (SAST) + # `--with`, not a dev dependency: bandit is a CI tool, not something the project + # imports, and ruff's S ruleset already runs its checks in the lint stage. This is + # the belt to that suspenders — -ll reports medium severity and above only. + run: uv run --with bandit bandit -r libs services -ll + + - name: gitleaks (secret scan) + # Pinned by digest and run directly, so the command is the documented one rather + # than a marketplace action's opinion of it. + run: | + docker run --rm -v "$PWD:/repo" -w /repo \ + ghcr.io/gitleaks/gitleaks:v8.21.2@sha256:0e99e8821643ea5b235718642b93bb32486af9c8162c8b8731f7cbdc951a7f46 \ + detect --no-banner --source /repo + + - name: pip-audit (dependency CVEs) + # --strict fails on an audit error rather than shrugging and reporting clean. + run: uv run --with pip-audit pip-audit --strict + + # --- stage 9: hadolint --------------------------------------------------------------- + dockerfile: + runs-on: ubuntu-latest + needs: [lint] + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: hadolint + uses: hadolint/hadolint-action@54c9adbab1582c2ef04b2016b760714a4bfde3cf # v3.1.0 + with: + recursive: true + dockerfile: "services/*/Dockerfile" + failure-threshold: warning + + # --- stage 10: build -> trivy -> push by digest -------------------------------------- + image: + runs-on: ubuntu-latest + # Every gate above is required. An image is not built until all of them are green, + # which is what makes "the digest CI pushed is a digest that passed everything" true. + needs: [types, unit, integration, security, dockerfile] + permissions: + contents: read + strategy: + fail-fast: false + matrix: + svc: [api, worker, reconciler] + # Deliberately no `outputs:` here. Matrix legs share one outputs map and clobber each + # other — the merge is not per-key and not ordered, so two of the three digests would + # arrive empty or stale, intermittently. The bump job resolves the digests from the + # registry instead, which is a read, not a rebuild. + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: docker/setup-buildx-action@c47758b77c9736f4b2ef4073d4d51994fabfe349 # v3.7.1 + + - name: registry login + if: github.ref == 'refs/heads/master' && github.event_name == 'push' + uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0 + with: + registry: ${{ env.REGISTRY }} + username: ${{ secrets.REGISTRY_USER }} + password: ${{ secrets.REGISTRY_TOKEN }} + + - name: build + # Context is the repo root and the Dockerfile is addressed with -f. It cannot be + # otherwise: the image needs pyproject.toml, uv.lock and libs/, all of which live + # above services//, and COPY ../.. is illegal. + # + # Loaded locally, not pushed. Trivy scans this exact image next; only then does it + # get pushed. The alternative — push, scan, and hope nobody pulled meanwhile — is + # how a CRITICAL ends up in the registry with a green checkmark next to it. + run: | + docker buildx build \ + -f services/${{ matrix.svc }}/Dockerfile \ + --build-arg BUILD_SHA=${{ github.sha }} \ + --cache-from type=gha,scope=${{ matrix.svc }} \ + --cache-to type=gha,mode=max,scope=${{ matrix.svc }} \ + --load \ + -t svcforge/${{ matrix.svc }}:ci \ + . + + - name: trivy + uses: aquasecurity/trivy-action@18f2510ee396bbf400402947b394f2dd8c87dbb0 # v0.29.0 + with: + image-ref: svcforge/${{ matrix.svc }}:ci + severity: HIGH,CRITICAL + # A CVE with no fix available is not something this PR can act on; failing on it + # only teaches people to add ignore entries. Rebuilding on a new base image picks + # the fix up the day it exists. + ignore-unfixed: true + exit-code: "1" + format: table + + - name: push by digest + if: github.ref == 'refs/heads/master' && github.event_name == 'push' + # Re-running buildx here is a cache hit on every layer, not a second build: the + # image is byte-identical to the one trivy just cleared. buildx cannot --load and + # --push in one invocation, which is the only reason this step exists. + # + # The commit-SHA tag is a handle for the bump job to resolve, not something anything + # deploys. What deploys is the digest that tag resolves to. + run: | + set -euo pipefail + IMAGE="${REGISTRY}/${IMAGE_NS}/svcforge-${{ matrix.svc }}" + docker buildx build \ + -f services/${{ matrix.svc }}/Dockerfile \ + --build-arg BUILD_SHA=${{ github.sha }} \ + --cache-from type=gha,scope=${{ matrix.svc }} \ + --push \ + -t "${IMAGE}:${GITHUB_SHA}" \ + . + docker buildx imagetools inspect "${IMAGE}:${GITHUB_SHA}" \ + --format '{{.Manifest.Digest}}' + + # --- stage 11: bump the chart's digests. CI's last act. ------------------------------ + bump: + runs-on: ubuntu-latest + needs: [image] + if: github.ref == 'refs/heads/master' && github.event_name == 'push' + permissions: + contents: write + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + # A bot token with contents:write on this repo and nothing else. Note what is + # absent: no kubeconfig, no cluster credential, no ArgoCD API token. CI's maximum + # blast radius is a bad commit, which is revertable. + token: ${{ secrets.CI_BOT_TOKEN }} + ref: master + - uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0 + with: + registry: ${{ env.REGISTRY }} + username: ${{ secrets.REGISTRY_USER }} + password: ${{ secrets.REGISTRY_TOKEN }} + - name: bump image digests in the chart + env: + REGISTRY: ${{ env.REGISTRY }} + IMAGE_NS: ${{ env.IMAGE_NS }} + IMAGE_TAG: ${{ github.sha }} + # And then it stops. No kubectl, no helm upgrade, no argocd app sync. ArgoCD is + # watching master and will have this within a minute. + run: ./scripts/bump-digests.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bfe374e --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +.venv/ +__pycache__/ +*.pyc +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.coverage +htmlcov/ +dist/ +build/ +*.egg-info/ + +# secrets never land in the repo +.env + +# local scratch +.coverage +.hypothesis/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..5c88b9a --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,12 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.8.4 + hooks: + - id: ruff + args: [--fix] + - id: ruff-format + + - repo: https://github.com/gitleaks/gitleaks + rev: v8.21.2 + hooks: + - id: gitleaks diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..ad64e72 --- /dev/null +++ b/Makefile @@ -0,0 +1,47 @@ +.PHONY: dev lint test migrate run-api run-worker run-reconciler integration ci chart + +dev: + uv sync + +lint: + uv run ruff check . && uv run ruff format --check . && uv run mypy . + +test: + uv run pytest tests/unit + +integration: + uv run pytest tests/integration + +migrate: + uv run python -m svcforge_core.migrate + +run-api: + uv run uvicorn services.api.main:create_app --factory --reload --port 8000 + +run-worker: + uv run python -m services.worker.main + +run-reconciler: + uv run python -m services.reconciler.main + +# Every gate CI runs, except the image build — that needs a docker daemon and three +# minutes, and the point of this target is to find out you failed before pushing. +# Same commands as .gitea/workflows/ci.yaml, deliberately: a gate that only exists in CI +# is a gate you debug through a web UI. +ci: + uv run ruff check . + uv run ruff format --check . + uv run mypy --strict . + uv run pytest tests/unit --cov=libs/svcforge_core/domain --cov-fail-under=90 + uv run pytest tests/integration + uv run --with bandit bandit -r libs services -ll + uv run --with pip-audit pip-audit --strict + hadolint services/*/Dockerfile + helm lint deploy/chart + helm template svcforge deploy/chart >/dev/null + +# The chart's own gates. `helm template` is not a formality here: the image helper calls +# `fail` on any digest that is not a sha256, so this catches an unbumped values.yaml. +chart: + helm lint deploy/chart + helm template svcforge deploy/chart diff --git a/README.md b/README.md new file mode 100644 index 0000000..7e468bb --- /dev/null +++ b/README.md @@ -0,0 +1,126 @@ +# svcforge — reference implementation + +A complete, working, verified build of the system `../learn-python/` teaches you to build. + +## Read this part first + +**This folder can ruin the course, and it will if you let it.** + +`learn-python/AGENTS.md` opens with a rule: *"Do not write his implementation code for him. +`domain/`, `repo/`, the claim loop, the handlers — those are the course."* This repo is +exactly that code. You asked for it deliberately, and the rule allows you to overrule it — +but the reason for the rule did not go away when you did. + +Module 4 is five evenings of getting the claim query wrong. That is not a tax on the way to +the answer; it **is** the answer. The evening you spend watching two workers grab the same +task is the evening `FOR UPDATE SKIP LOCKED` stops being a phrase and starts being a thing +you understand. Reading `repo/tasks.py` here takes ninety seconds and teaches you close to +nothing, while feeling exactly like learning. That feeling is the trap. + +So: + +| Use it like this | Not like this | +|---|---| +| Attempt the module. Get stuck. Stay stuck 30 minutes. **Then** diff your version against this one. | Open this first "just to see the shape". | +| Steal the scaffolding — `pyproject.toml`, Dockerfiles, `ci.yaml`, the chart. Nothing is learned by fighting hatchling. | Copy `domain/`, `repo/`, `handlers.py`, or the claim loop. | +| Read the **comments**. They explain *why*, which is the part that transfers. | Read the code. It is the part that doesn't. | +| Use it to check an answer you already produced. | Use it to produce an answer. | + +The scaffolding is where struggling teaches you nothing. The domain and the queue are the +whole point. Know which one you're reading. + +## What is actually verified + +Everything below was executed, not asserted: + +| Claim | How it was proven | +|---|---| +| The claim query never double-claims | 50 concurrent workers, 50 tasks, real Postgres. Each claimed exactly once. | +| The transaction story is real | Instance + task roll back together on an abort. | +| SIGTERM drains in flight | Worker finishes a 2s provision after stop is set, exits 0. | +| Handlers are idempotent | Re-running `handle_provision` installs once, not twice. | +| helm timeouts kill the process **group** | A negative control with `proc.kill()` leaks two `sleep 300`s; the real `_run` leaves zero. | +| The state machine is enforced in SQL too | Guard test fails when the guard is removed (control-tested). | +| Redis degrades safely | Rate limit fails open, cache falls through, platform stays up with Redis dead. | +| The images ship a wheel, not an editable | Negative control proved `uv sync` alone ships `/app/libs`; `--no-editable` fixed it. | +| The chart is valid | `helm lint`, `helm template`, `hadolint` — all clean. | + +Numbers from a real drain (400 tasks, local Postgres, FakeProvisioner) are in +[RUNBOOK.md](RUNBOOK.md#measured-numbers). + +## Running it + +**No Docker on this host, deliberately.** This box's `containerd` belongs to a Kubernetes +kubelet; installing `docker.io` would evict the runtime and take every pod with it. So the +integration tests take a DSN from the environment and only fall back to testcontainers +(the CI path) when it is absent: + +```bash +sudo -u postgres psql -c "create role svcforge login password 'svcforge' superuser;" +sudo -u postgres psql -c "create database svcforge_test owner svcforge;" + +export PATH="$HOME/.local/bin:$PATH" +export SVCFORGE_TEST_DSN="postgresql://svcforge:svcforge@127.0.0.1:5432/svcforge_test" + +make dev # uv sync +make lint # ruff + ruff format + mypy --strict +uv run pytest -q -m "not e2e and not slow" +``` + +Each pytest process clones its own database, so concurrent runs don't truncate each other. + +Redis tests marked `slow` hit real Upstash. **Mind the budget**: the free tier is 500K +commands/month = 0.19/sec sustained. The whole suite spends about 35. + +```bash +set -a; . ~/.config/svcforge/secrets.env; set +a # never commit these +uv run pytest -q -m slow +uv run python -m scripts.redis_budget # projects month-end burn, exits 1 if over +``` + +## Where things live + +| Module | Teaches | Read here | +|---|---|---| +| 0 | toolchain | `pyproject.toml`, `Makefile` | +| 1 | domain, pydantic, Protocol | `libs/svcforge_core/svcforge_core/domain/` | +| 2 | psycopg3, repos, migrations | `repo/{db,instances}.py`, `migrations/001_init.sql` | +| 3 | FastAPI, JWT, one transaction | `services/api/` | +| **4** | **the queue — the core** | **`repo/tasks.py`, `services/worker/`** | +| 5 | subprocess, timeouts, Protocols | `adapters/helm.py` (read `_run` twice) | +| 6 | day-2 fleet upgrades | `domain/windows.py`, `InstanceRepo.list_upgradable` | +| 7 | reconciler, obs | `services/reconciler/`, `obs.py` | +| 8 | packaging, CI, ArgoCD | `services/*/Dockerfile`, `.gitea/workflows/ci.yaml`, `deploy/` | +| 9 | chaos, load, CLI, runbook | `scripts/load.py`, `services/cli/`, `RUNBOOK.md` | +| 10 | Redis as a shortcut | `adapters/redis.py`, `scripts/redis_budget.py` | + +## Where this deviates from the spec, and why + +Honest list. Each was a real conflict, not a shortcut. + +1. **`TaskRepo.enqueue` exists twice.** Module 2 specs `enqueue(conn, ...)`; Module 4 specs + `enqueue(instance_id, kind) -> int`. Both callers are real, so both exist: + `enqueue(conn, ...)` and `enqueue_standalone(...)`. Making `conn` optional would have + hidden the transaction question, which is the one thing that module is about. +2. **The claim query has a CTE wrapper.** The spec calls it byte-identical. The `update ... + for update skip locked` shape is untouched; an outer `select` joins `instances.team` so + the worker can bind `team` to its logs at claim time. The alternative was a second round + trip per task for a column the DB already had. +3. **The day-2 work-list query gained `service_type` and a halted check.** As printed it + compares every service type against one version, and never consults `catalog_versions` + despite the same module requiring halted → 0 rows. +4. **`Role` → `ClusterRole`.** The specced rules grant `create` on `namespaces`, which is + cluster-scoped. A namespaced Role cannot express it. Rules verbatim otherwise. +5. **DELETE is not one transaction.** `InstanceRepo.update_state` owns its connection, so + the CAS and the enqueue can't share one without reaching around the repo. Ordered for + the failure mode instead: CAS first, enqueue second — a crash between leaves `deleting` + with no task, which the reconciler sweeps up. The reverse would tear down a live service. +6. **Traceparent is `-03`, not the spec's `-01`.** Current SDKs set the random-trace-id bit + alongside sampled. The spec's acceptance line is stale. +7. **`make_redis` returns `Redis | None`.** "No Redis configured" has to be runnable. + +## What was deliberately NOT built + +Because Module 6 says so, and the restraint is the lesson: no `resize`, no `backup`/`restore`, +no `helm rollback` automation, no deprecation timers, no rollouts table, no pause/resume CLI. +A halted rollout is one column, cleared by hand with SQL. diff --git a/RUNBOOK.md b/RUNBOOK.md new file mode 100644 index 0000000..4357e1d --- /dev/null +++ b/RUNBOOK.md @@ -0,0 +1,192 @@ +# svcforge runbook + +Four entries. Each starts from an alert firing and ends at either a fix or an escalation. +Every command is copy-pasteable; none of them require thinking at 3am, which is the point. + +Set these first: + +```bash +set -a; . ~/.config/svcforge/secrets.env; set +a # SVCFORGE_PG_DSN_SESSION for psql +alias sfsql='psql "$SVCFORGE_PG_DSN_SESSION"' +``` + +--- + +## Measured numbers + +From `scripts/load.py` + in-process workers on a `FakeProvisioner` (`delay=0.05s`), 200 tasks +per run. **These came off local Postgres on the same box, with a sub-millisecond round trip. +Supabase's pooler is ~5ms away, so treat these as a ceiling the real thing will not reach** — +the shape is what transfers, not the absolute numbers. + +| replicas | pool max_size | worker concurrency | connections used | drain (200 tasks) | throughput | +|---:|---:|---:|---:|---:|---:| +| 1 | 5 | 4 | 5 | 4.3s | 46.9 task/s | +| 2 | 5 | 4 | 10 | 4.2s | 47.6 task/s | +| 4 | 5 | 4 | 20 | 2.2s | 92.3 task/s | +| 2 | 20 | 16 | 40 | 2.1s | 97.1 task/s | + +Three things this says: + +1. **Going from 1 replica to 2 bought nothing** (46.9 → 47.6). Throughput here is bounded by + per-worker concurrency (the semaphore), not by replica count. Adding pods to a saturated + semaphore is the most common wrong fix for a slow queue. +2. **Concurrency is the knob that moved it** — 4 replicas (20 connections) and 2 replicas at + concurrency 16 (40 connections) land in the same place, ~92-97 task/s. The second buys the + same throughput for twice the connections, which on the free tier is the worse trade. +3. **The budget is `(api + worker replicas) x max_size`**, and it is spent whether or not the + connections are busy. The bottom row costs 40 connections for a 2% gain over the row above + it. On Supabase free tier, that arithmetic — not throughput — is what decides replica count. + +Nothing failed at any setting, so the real connection ceiling was never hit locally. Finding +it against the actual pooler is the experiment worth running: raise `replicas x max_size` +until claims slow and `PoolTimeout` appears, and write the number here. + +--- + +## Queue stuck + +**Alert:** `SvcforgeQueueDepthRising` + +**Diagnose.** Start here, always: + +```bash +sfsql -c "select state, count(*) from tasks group by 1;" +``` + +Then split the three causes apart — they look identical from the alert and need opposite fixes: + +```bash +# Stuck leases: rows 'running' with a locked_at that never advances. +sfsql -c "select kind, locked_by, locked_at, last_error from tasks + where state='running' order by locked_at limit 10;" + +# No workers: is anything actually consuming? +kubectl get pods -l app=worker -o wide +kubectl logs -l app=worker --tail=20 --prefix + +# run_after in the future: backoff has parked everything. +sfsql -c "select count(*) from tasks where state='queued' and run_after > now();" +``` + +| What you see | Cause | Fix | +|---|---|---| +| `running` rows, `locked_at` older than 5m, no worker pods hold those IDs | Workers died mid-task | None. The reconciler resets expired leases within 60s. If it does not, the reconciler is down — check it. | +| Zero worker pods, or all `CrashLoopBackOff` | No consumer | Fix the workers. `kubectl describe pod -l app=worker`. | +| Everything `queued` with `run_after` far in the future | Backoff, i.e. tasks are failing and retrying | This is not a queue problem. Go to **Provision failing**. | +| `queued` rows with `run_after <= now()` and healthy workers | Real: claim is not returning rows | Check pooler connection budget (see **Supabase full**). | + +**Never** hand-edit `state='running'` back to `'queued'`. The lease does that, and doing it +by hand while the worker is actually alive gives you two workers on one task — the exact +thing the whole design prevents. + +**Escalate** if workers are healthy, leases are fresh, and depth still grows: that is a +claim-query or pooler bug, not an ops problem. + +--- + +## Provision failing + +**Alert:** `SvcforgeTaskFailed` (tasks reaching the dead-letter state), or `SvcforgeProvisionSlow` +(they still succeed, but the p95 has drifted out — usually cluster capacity, diagnosed the same way). + +**Diagnose:** + +```bash +sfsql -c "select id, service_type, chart_version, error from instances where state='failed';" +sfsql -c "select id, kind, attempts, last_error from tasks where state='failed' order by id desc limit 10;" + +NS=tenant- +helm list -n "$NS" +kubectl get events -n "$NS" --sort-by=.lastTimestamp | tail -20 +``` + +| `error` looks like | Cause | Fix | +|---|---|---| +| `chart "..." version "..." not found` | Bad pin in `catalog.yaml` | Correct the version, commit. The next `upgrade`/`provision` picks it up. | +| `timed out waiting for the condition` | Cluster capacity — the chart installed but pods never became ready | `kubectl describe pod -n $NS`. Usually `Insufficient cpu/memory` or a PVC pending on Longhorn. | +| `Error: ... forbidden: User "system:serviceaccount:svcforge:..."` | RBAC | The worker's ClusterRole is missing a verb. Chart change, not a manual `kubectl edit`. | +| `ImagePullBackOff` in events | Registry auth or a gated image | Prefer `bitnamilegacy/*` images, which pull anonymously. | + +After fixing the cause, tasks that already dead-lettered do **not** retry themselves. Requeue +deliberately: + +```bash +sfsql -c "update tasks set state='queued', attempts=0, run_after=now(), last_error=null + where id = ;" +``` + +**Escalate** if `error` is empty on a failed instance — that means the failure path itself +lost the message. + +--- + +## Orphaned release + +**Alert:** `SvcforgeReconcilerStale` — the reconciler has not completed a loop recently, so +drift is no longer being *detected* at all. Drift itself is reported in the reconciler's logs +and metrics rather than paged on, because it is usually benign and always needs a human to +judge. A stale reconciler is the real emergency: nothing is watching. + +The control loop **never auto-deletes a release.** That is deliberate: a bug in the drift +check that deletes things is unrecoverable, and one that only reports is a Tuesday. + +**Diagnose:** + +```bash +helm list -A -o json | jq -r '.[].name' | sort > /tmp/real +sfsql -tAc "select release_name from instances where state in ('ready','provisioning');" | sort > /tmp/want + +comm -23 /tmp/real /tmp/want # in the cluster, not in the DB -> orphan +comm -13 /tmp/real /tmp/want # in the DB, not in the cluster -> missing +``` + +| Direction | Meaning | Action | +|---|---|---| +| Orphan (cluster only) | A deprovision half-finished, or someone ran `helm install` by hand | Confirm the tenant is gone, then `helm uninstall -n ` **by hand**, and write down that you did. | +| Missing (DB only) | Someone deleted a release out from under us | Requeue a `provision` task for that instance. It is idempotent; it will rebuild. | + +**Escalate** before uninstalling anything you did not personally trace to a deleted instance. +A wrong `helm uninstall` here deletes a tenant's data. + +--- + +## Supabase full + +**Alert:** none — and that is a gap, not a decision. The free tier is 0.5 GB and nothing pages +you before you hit it; you find out when writes start failing. Until someone adds a size rule, +this entry is driven by the calendar, not by an alert. Check it monthly: + +```bash +sfsql -c "select pg_size_pretty(pg_database_size(current_database()));" +``` + +**Diagnose:** + +```bash +sfsql -c "select pg_size_pretty(pg_database_size(current_database()));" +sfsql -c "select relname, pg_size_pretty(pg_total_relation_size(relid)) from pg_catalog.pg_statio_user_tables + order by pg_total_relation_size(relid) desc limit 5;" +sfsql -c "select count(*) from pg_stat_activity;" +``` + +It is almost always `tasks`. Every provision, upgrade and verify leaves a row forever. + +```bash +sfsql -c "delete from tasks where state='done' and created_at < now() - interval '7 days';" +sfsql -c "vacuum (analyze) tasks;" +``` + +`vacuum` alone reclaims space **for reuse by Postgres**, but does not return it to the +filesystem — so `pg_database_size` may barely move. That is expected and fine; the space is +free for new rows. `vacuum full` does return it, takes an `ACCESS EXCLUSIVE` lock, and will +stall every worker for its duration. Only do it in a window, and only if you actually need +the bytes back. + +If `count(*) from pg_stat_activity` is near the pooler's ceiling, the cause is arithmetic, not +load: `worker_replicas × pool_max_size + api_replicas × pool_max_size`. Lower `max_size` or +replicas. **Replica count is a database-capacity decision here**, which is unusual and worth +remembering. + +**Escalate** if size is growing with `tasks` already pruned — that means `instances` is +growing, i.e. tenants are real, i.e. the free tier is the wrong tier. diff --git a/catalog.yaml b/catalog.yaml new file mode 100644 index 0000000..774b876 --- /dev/null +++ b/catalog.yaml @@ -0,0 +1,77 @@ +# The product catalog: what svcforge will provision, and in which sizes. +# service_type -> chart / chart_version / security / sizes{name -> replicas + resources} +# +# security: true bypasses every tenant's maintenance window for this entry. Set it for a +# CVE with a public exploit; leave it false and the bump waits for 03:00 Sunday. +services: + elasticsearch: + chart: bitnamilegacy/elasticsearch + chart_version: 21.3.15 + security: false + sizes: + small: + replicas: 1 + resources: + requests: + cpu: 250m + memory: 1Gi + limits: + cpu: "1" + memory: 2Gi + medium: + replicas: 3 + resources: + requests: + cpu: "1" + memory: 4Gi + limits: + cpu: "2" + memory: 8Gi + + redis: + chart: bitnamilegacy/redis + chart_version: 20.6.2 + security: false + sizes: + small: + replicas: 1 + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: 500m + memory: 512Mi + medium: + replicas: 3 + resources: + requests: + cpu: 500m + memory: 1Gi + limits: + cpu: "1" + memory: 2Gi + + postgres: + chart: bitnamilegacy/postgresql + chart_version: 16.4.5 + security: false + sizes: + small: + replicas: 1 + resources: + requests: + cpu: 250m + memory: 512Mi + limits: + cpu: "1" + memory: 1Gi + medium: + replicas: 2 + resources: + requests: + cpu: "1" + memory: 2Gi + limits: + cpu: "2" + memory: 4Gi diff --git a/deploy/argocd/app.yaml b/deploy/argocd/app.yaml new file mode 100644 index 0000000..f02017d --- /dev/null +++ b/deploy/argocd/app.yaml @@ -0,0 +1,57 @@ +# The other half of "CI does not deploy". +# +# CI's last act is a commit that changes image.*.digest in deploy/chart/values.yaml. +# ArgoCD notices that commit and syncs. There is no kubeconfig in CI, no `helm upgrade` in +# a pipeline step, and no human running kubectl. If you want to know what is running in the +# cluster, you read git — that is the whole property, and a single `kubectl apply` from a +# laptop is what destroys it (selfHeal below exists to undo exactly that). +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: svcforge + namespace: argocd + finalizers: + # Without this, deleting the Application orphans every resource it created. + - resources-finalizer.argocd.argoproj.io +spec: + project: default + source: + repoURL: https://gitea.oci-oci.duckdns.org/gitea_admin/svcforge.git + targetRevision: master + path: deploy/chart + helm: + releaseName: svcforge + # No valueFiles override and no `parameters:` block. values.yaml in the repo is the + # single source of truth — a parameter here would be a second place the deployed + # digest could come from, invisible in the chart's own diff. + destination: + server: https://kubernetes.default.svc + namespace: svcforge + syncPolicy: + automated: + # Delete resources removed from the chart. Safe because the chart owns only svcforge + # itself; tenant releases are created by the worker's helm calls and are not part of + # this Application, so prune cannot reach them. + prune: true + # Revert manual edits. A hotfix applied by hand is a lie the next sync tells on. + selfHeal: true + syncOptions: + - CreateNamespace=true + # The migrate Job is a helm pre-install/pre-upgrade hook. ArgoCD maps helm hooks onto + # its own PreSync phase, so migrations still run before any new pod starts, and a + # non-zero exit fails the sync instead of rolling out pods onto an unmigrated schema. + - ApplyOutOfSyncOnly=true + retry: + limit: 3 + backoff: + duration: 20s + factor: 2 + maxDuration: 3m + # The migrate Job is a hook, so ArgoCD deletes and recreates it every sync; its + # generated fields would otherwise show as permanent drift and the app would never + # report Synced. + ignoreDifferences: + - group: batch + kind: Job + jsonPointers: + - /spec/template/metadata/labels diff --git a/deploy/chart/Chart.yaml b/deploy/chart/Chart.yaml new file mode 100644 index 0000000..044cc04 --- /dev/null +++ b/deploy/chart/Chart.yaml @@ -0,0 +1,8 @@ +apiVersion: v2 +name: svcforge +description: svcforge control plane — api, worker, reconciler +type: application +# version is the chart's own version. appVersion is a label only: what actually ships is +# the digest in values.yaml, which CI bumps. Never read appVersion to decide what runs. +version: 0.1.0 +appVersion: "0.1.0" diff --git a/deploy/chart/templates/_helpers.tpl b/deploy/chart/templates/_helpers.tpl new file mode 100644 index 0000000..847f494 --- /dev/null +++ b/deploy/chart/templates/_helpers.tpl @@ -0,0 +1,110 @@ +{{/* Name helpers. Standard chart boilerplate — the interesting parts are below. */}} + +{{- define "svcforge.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{- define "svcforge.fullname" -}} +{{- if .Values.fullnameOverride -}} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- $name := default .Chart.Name .Values.nameOverride -}} +{{- if contains $name .Release.Name -}} +{{- .Release.Name | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} +{{- end -}} +{{- end -}} +{{- end -}} + +{{- define "svcforge.labels" -}} +helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +app.kubernetes.io/name: {{ include "svcforge.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +app.kubernetes.io/part-of: svcforge +{{- end -}} + +{{/* +Per-component selector labels. +`app: ` is here on purpose and is not decoration: the module-9 chaos +experiments select on it (`kubectl delete pod -l app=worker`). Renaming it breaks the +runbook, not just a dashboard. +*/}} +{{- define "svcforge.selectorLabels" -}} +app.kubernetes.io/name: {{ include "svcforge.name" .ctx }} +app.kubernetes.io/instance: {{ .ctx.Release.Name }} +app.kubernetes.io/component: {{ .component }} +app: {{ .component }} +{{- end -}} + +{{/* +Resolve a component's image to repo@digest. + +This is the single place that builds an image reference, and it refuses to emit one that +is not digest-pinned. If CI has not bumped values.yaml, the release fails here with a +readable message rather than silently deploying whatever a mutable tag happens to mean +today. (The literal string "latest" is not written anywhere in this repo, including in +comments — the acceptance gate greps for it and does not know what a comment is.) +*/}} +{{- define "svcforge.image" -}} +{{- $img := index .ctx.Values.image .component -}} +{{- if not $img -}} +{{- fail (printf "no image config for component %q" .component) -}} +{{- end -}} +{{- if not (hasPrefix "sha256:" ($img.digest | default "")) -}} +{{- fail (printf "image.%s.digest must be a sha256 digest, not a tag — CI bumps it; got %q" .component ($img.digest | default "")) -}} +{{- end -}} +{{- printf "%s@%s" $img.repo $img.digest -}} +{{- end -}} + +{{- define "svcforge.serviceAccountName" -}} +{{- printf "%s-%s" (include "svcforge.fullname" .ctx) .component | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{- define "svcforge.secretName" -}} +{{- .Values.externalSecret.targetName | default (printf "%s-secrets" (include "svcforge.fullname" .)) -}} +{{- end -}} + +{{/* +Pod-level hardening, identical for all three services and the migrate job. +readOnlyRootFilesystem is the one that bites: every writable path a process needs must be +an explicit emptyDir. That is the point — it makes the writes visible in review. +*/}} +{{- define "svcforge.podSecurityContext" -}} +runAsNonRoot: true +runAsUser: 10001 +runAsGroup: 10001 +fsGroup: 10001 +seccompProfile: + type: RuntimeDefault +{{- end -}} + +{{- define "svcforge.containerSecurityContext" -}} +allowPrivilegeEscalation: false +readOnlyRootFilesystem: true +runAsNonRoot: true +runAsUser: 10001 +capabilities: + drop: ["ALL"] +{{- end -}} + +{{/* +Shared environment. Secrets arrive via envFrom on the Secret that external-secrets +populates from Vault — never as chart values, never as literals in a manifest. +*/}} +{{- define "svcforge.env" -}} +- name: SVCFORGE_POOL_MIN_SIZE + value: {{ .Values.pool.minSize | quote }} +- name: SVCFORGE_POOL_MAX_SIZE + value: {{ .Values.pool.maxSize | quote }} +- name: SVCFORGE_LOG_LEVEL + value: {{ .Values.log.level | quote }} +{{- if .Values.otel.enabled }} +- name: OTEL_EXPORTER_OTLP_ENDPOINT + value: {{ .Values.otel.endpoint | quote }} +- name: OTEL_EXPORTER_OTLP_PROTOCOL + value: grpc +{{- end }} +{{- end -}} diff --git a/deploy/chart/templates/api-deployment.yaml b/deploy/chart/templates/api-deployment.yaml new file mode 100644 index 0000000..8767a8b --- /dev/null +++ b/deploy/chart/templates/api-deployment.yaml @@ -0,0 +1,84 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "svcforge.fullname" . }}-api + labels: + {{- include "svcforge.labels" . | nindent 4 }} + app.kubernetes.io/component: api +spec: + replicas: {{ .Values.api.replicas }} + selector: + matchLabels: + {{- include "svcforge.selectorLabels" (dict "ctx" $ "component" "api") | nindent 6 }} + template: + metadata: + labels: + {{- include "svcforge.labels" . | nindent 8 }} + {{- include "svcforge.selectorLabels" (dict "ctx" $ "component" "api") | nindent 8 }} + spec: + serviceAccountName: {{ include "svcforge.serviceAccountName" (dict "ctx" $ "component" "api") }} + {{- with .Values.image.pullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + # 60s: an in-flight POST must finish and the pool must close cleanly. The api holds + # no lease, so this is generosity, not correctness — the worker's 60s is the one + # that matters. + terminationGracePeriodSeconds: 60 + securityContext: + {{- include "svcforge.podSecurityContext" . | nindent 8 }} + containers: + - name: api + image: {{ include "svcforge.image" (dict "ctx" $ "component" "api") }} + imagePullPolicy: {{ .Values.image.pullPolicy }} + securityContext: + {{- include "svcforge.containerSecurityContext" . | nindent 12 }} + ports: + - name: http + containerPort: {{ .Values.api.service.targetPort }} + envFrom: + - secretRef: + name: {{ include "svcforge.secretName" . }} + env: + {{- include "svcforge.env" . | nindent 12 }} + - name: SVCFORGE_JWKS_URL + value: {{ .Values.auth.jwksUrl | quote }} + - name: SVCFORGE_JWT_ISSUER + value: {{ .Values.auth.issuer | quote }} + - name: SVCFORGE_JWT_AUDIENCE + value: {{ .Values.auth.audience | quote }} + - name: OTEL_SERVICE_NAME + value: svcforge-api + # readiness gates traffic, liveness restarts. They must not be the same probe: + # /readyz checks the pool, and a pool that is briefly exhausted should stop + # taking traffic, not get the pod killed. + readinessProbe: + httpGet: {path: /readyz, port: http} + periodSeconds: 5 + timeoutSeconds: 3 + livenessProbe: + httpGet: {path: /healthz, port: http} + periodSeconds: 20 + timeoutSeconds: 3 + failureThreshold: 3 + resources: + {{- toYaml .Values.api.resources | nindent 12 }} + volumeMounts: + # readOnlyRootFilesystem: true, so anything that writes needs a mount. + - name: tmp + mountPath: /tmp + volumes: + - name: tmp + emptyDir: {} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/deploy/chart/templates/api-ingress.yaml b/deploy/chart/templates/api-ingress.yaml new file mode 100644 index 0000000..eb548a7 --- /dev/null +++ b/deploy/chart/templates/api-ingress.yaml @@ -0,0 +1,33 @@ +{{- if .Values.api.ingress.enabled }} +{{/* Only the api is exposed. The worker and reconciler have no Service at all. */}} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "svcforge.fullname" . }}-api + labels: + {{- include "svcforge.labels" . | nindent 4 }} + app.kubernetes.io/component: api + {{- with .Values.api.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + ingressClassName: {{ .Values.api.ingress.className }} + {{- if .Values.api.ingress.tls.enabled }} + tls: + - hosts: + - {{ .Values.api.ingress.host | quote }} + secretName: {{ .Values.api.ingress.tls.secretName }} + {{- end }} + rules: + - host: {{ .Values.api.ingress.host | quote }} + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: {{ include "svcforge.fullname" . }}-api + port: + name: http +{{- end }} diff --git a/deploy/chart/templates/api-service.yaml b/deploy/chart/templates/api-service.yaml new file mode 100644 index 0000000..008ed9f --- /dev/null +++ b/deploy/chart/templates/api-service.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "svcforge.fullname" . }}-api + labels: + {{- include "svcforge.labels" . | nindent 4 }} + app.kubernetes.io/component: api +spec: + type: {{ .Values.api.service.type }} + ports: + - name: http + port: {{ .Values.api.service.port }} + targetPort: http + protocol: TCP + selector: + {{- include "svcforge.selectorLabels" (dict "ctx" $ "component" "api") | nindent 4 }} diff --git a/deploy/chart/templates/externalsecret.yaml b/deploy/chart/templates/externalsecret.yaml new file mode 100644 index 0000000..f49e40b --- /dev/null +++ b/deploy/chart/templates/externalsecret.yaml @@ -0,0 +1,26 @@ +{{- if .Values.externalSecret.enabled }} +{{/* +The Supabase and Upstash DSNs come from Vault via external-secrets. They are never chart +values, never CI variables, and never baked into an image layer — the chart names the +Vault path, and the cluster resolves it. + +The rendered Secret is consumed with envFrom, so adding a key here is the only step needed +to expose a new one; settings.py (env_prefix="SVCFORGE_") types it on the way in and fails +fast if it is missing or malformed. +*/}} +apiVersion: external-secrets.io/v1beta1 +kind: ExternalSecret +metadata: + name: {{ include "svcforge.fullname" . }}-secrets + labels: + {{- include "svcforge.labels" . | nindent 4 }} +spec: + refreshInterval: {{ .Values.externalSecret.refreshInterval }} + secretStoreRef: + {{- toYaml .Values.externalSecret.secretStoreRef | nindent 4 }} + target: + name: {{ include "svcforge.secretName" . }} + creationPolicy: Owner + data: + {{- toYaml .Values.externalSecret.remoteRefs | nindent 4 }} +{{- end }} diff --git a/deploy/chart/templates/migrate-job.yaml b/deploy/chart/templates/migrate-job.yaml new file mode 100644 index 0000000..43cc5ea --- /dev/null +++ b/deploy/chart/templates/migrate-job.yaml @@ -0,0 +1,70 @@ +{{- if .Values.migrate.enabled }} +{{/* +Migrations run here and nowhere else. + +Not on app startup: three services × N replicas racing the same DDL is how you get a +half-applied schema and a crash loop, and it makes "which pod migrated?" unanswerable. +A hook runs once, before any new pod starts, and its exit code gates the release. + +hook-weight -5 puts it ahead of everything else in the pre-install/pre-upgrade phase. +hook-delete-policy before-hook-creation keeps the last run's pod around for `kubectl logs` +after a failure — the one time you actually want it — and clears it on the next attempt. + +Deliberately no terminationGracePeriodSeconds: 60 here. Three Deployments carry it; a +migration is not one of them. +*/}} +apiVersion: batch/v1 +kind: Job +metadata: + name: {{ include "svcforge.fullname" . }}-migrate + labels: + {{- include "svcforge.labels" . | nindent 4 }} + app.kubernetes.io/component: migrate + annotations: + "helm.sh/hook": pre-install,pre-upgrade + "helm.sh/hook-weight": "-5" + "helm.sh/hook-delete-policy": before-hook-creation +spec: + # 0, not 3. A failed migration must fail the release. Retrying a DDL that just failed + # tends to turn one readable error into three, and then a green release on a schema + # nobody has looked at. + backoffLimit: 0 + template: + metadata: + labels: + {{- include "svcforge.labels" . | nindent 8 }} + app.kubernetes.io/component: migrate + spec: + restartPolicy: Never + serviceAccountName: {{ include "svcforge.serviceAccountName" (dict "ctx" $ "component" "api") }} + {{- with .Values.image.pullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + securityContext: + {{- include "svcforge.podSecurityContext" . | nindent 8 }} + containers: + - name: migrate + # Same image as the api, by digest. The migrations that ship are the ones the + # code that is about to run was built against — a separate image could drift. + image: {{ include "svcforge.image" (dict "ctx" $ "component" "api") }} + imagePullPolicy: {{ .Values.image.pullPolicy }} + securityContext: + {{- include "svcforge.containerSecurityContext" . | nindent 12 }} + command: ["python", "-m", "svcforge_core.migrate"] + envFrom: + - secretRef: + name: {{ include "svcforge.secretName" . }} + env: + {{- include "svcforge.env" . | nindent 12 }} + - name: OTEL_SERVICE_NAME + value: svcforge-migrate + resources: + {{- toYaml .Values.migrate.resources | nindent 12 }} + volumeMounts: + - name: tmp + mountPath: /tmp + volumes: + - name: tmp + emptyDir: {} +{{- end }} diff --git a/deploy/chart/templates/pdb.yaml b/deploy/chart/templates/pdb.yaml new file mode 100644 index 0000000..e439bd7 --- /dev/null +++ b/deploy/chart/templates/pdb.yaml @@ -0,0 +1,24 @@ +{{- if .Values.podDisruptionBudget.enabled }} +{{/* +Off by default, and not in the module-8 spec — included because it is cheap to have and +expensive to retrofit. + +Only the api gets one. The worker does not need it: killing a worker mid-provision is +already safe (chaos experiment 1 — the lease expires, the task returns to queued, and +`helm upgrade --install` is a no-op on re-claim), so blocking a drain to protect it buys +nothing. The reconciler must not get one: it is replicas: 1, and minAvailable: 1 on a +single-replica Deployment blocks node drains forever. +*/}} +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: {{ include "svcforge.fullname" . }}-api + labels: + {{- include "svcforge.labels" . | nindent 4 }} + app.kubernetes.io/component: api +spec: + minAvailable: {{ .Values.podDisruptionBudget.minAvailable }} + selector: + matchLabels: + {{- include "svcforge.selectorLabels" (dict "ctx" $ "component" "api") | nindent 6 }} +{{- end }} diff --git a/deploy/chart/templates/prometheusrule.yaml b/deploy/chart/templates/prometheusrule.yaml new file mode 100644 index 0000000..687aff1 --- /dev/null +++ b/deploy/chart/templates/prometheusrule.yaml @@ -0,0 +1,21 @@ +{{- if .Values.prometheusRule.enabled }} +{{/* +The four alerts, rendered straight from values. The rules are data, not template logic — +which is the point: an alert you want to change is a values edit and a diff, not a new +CR someone applied by hand and forgot. + +Each maps to a RUNBOOK.md entry via its runbook_url annotation. An alert without a runbook +entry is a pager that teaches nothing. +*/}} +apiVersion: monitoring.coreos.com/v1 +kind: PrometheusRule +metadata: + name: {{ include "svcforge.fullname" . }} + labels: + {{- include "svcforge.labels" . | nindent 4 }} +spec: + groups: + - name: svcforge + rules: + {{- toYaml .Values.prometheusRule.rules | nindent 8 }} +{{- end }} diff --git a/deploy/chart/templates/rbac.yaml b/deploy/chart/templates/rbac.yaml new file mode 100644 index 0000000..1a37903 --- /dev/null +++ b/deploy/chart/templates/rbac.yaml @@ -0,0 +1,52 @@ +{{- if .Values.rbac.create }} +{{/* +Hand-written, and deliberately short. + +Why ClusterRole and not Role: the worker's job is to `helm upgrade --install` a tenant +release into a namespace it creates. `namespaces` is a cluster-scoped resource — a +namespaced Role cannot grant `create` on it, and cannot grant anything inside the tenant +namespaces either, because they do not exist when the chart is installed. + +What keeps this least-privilege is not the scope, it is the contents: every resource and +verb is named, there is no `*`, no cluster-admin, and no rbac.authorization.k8s.io group. +That last omission is the load-bearing one — the worker cannot escalate itself, because it +cannot create a RoleBinding at all. + +The reconciler binds to the same role but only ever reads; it enqueues tasks, it does not +provision, and it never deletes an orphan. +*/}} +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: {{ include "svcforge.fullname" . }}-provisioner + labels: + {{- include "svcforge.labels" . | nindent 4 }} +rules: + - apiGroups: [""] + resources: [namespaces] + verbs: [get, list, create] + - apiGroups: [""] + resources: [secrets, services, configmaps, persistentvolumeclaims, serviceaccounts] + verbs: [get, list, watch, create, update, patch, delete] + - apiGroups: [apps] + resources: [deployments, statefulsets] + verbs: [get, list, watch, create, update, patch, delete] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: {{ include "svcforge.fullname" . }}-provisioner + labels: + {{- include "svcforge.labels" . | nindent 4 }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: {{ include "svcforge.fullname" . }}-provisioner +subjects: + - kind: ServiceAccount + name: {{ include "svcforge.serviceAccountName" (dict "ctx" $ "component" "worker") }} + namespace: {{ .Release.Namespace }} + - kind: ServiceAccount + name: {{ include "svcforge.serviceAccountName" (dict "ctx" $ "component" "reconciler") }} + namespace: {{ .Release.Namespace }} +{{- end }} diff --git a/deploy/chart/templates/reconciler-deployment.yaml b/deploy/chart/templates/reconciler-deployment.yaml new file mode 100644 index 0000000..0196bf5 --- /dev/null +++ b/deploy/chart/templates/reconciler-deployment.yaml @@ -0,0 +1,80 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "svcforge.fullname" . }}-reconciler + labels: + {{- include "svcforge.labels" . | nindent 4 }} + app.kubernetes.io/component: reconciler +spec: + # A singleton. Hardcoded, not a value: two reconcilers would double-enqueue drift tasks + # and race on TTL expiry, and there is no knob that makes that acceptable. If you want + # this to be tunable, you want a lease first — and the lease belongs in Postgres, not in + # a Redis lock (a GC pause plus a Redis lock still gives you two reconcilers). + replicas: 1 + strategy: + # Recreate, not RollingUpdate. RollingUpdate would briefly run the old and new pod at + # once, which is exactly the thing replicas: 1 exists to prevent. + type: Recreate + selector: + matchLabels: + {{- include "svcforge.selectorLabels" (dict "ctx" $ "component" "reconciler") | nindent 6 }} + template: + metadata: + labels: + {{- include "svcforge.labels" . | nindent 8 }} + {{- include "svcforge.selectorLabels" (dict "ctx" $ "component" "reconciler") | nindent 8 }} + spec: + serviceAccountName: {{ include "svcforge.serviceAccountName" (dict "ctx" $ "component" "reconciler") }} + {{- with .Values.image.pullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + terminationGracePeriodSeconds: 60 + securityContext: + {{- include "svcforge.podSecurityContext" . | nindent 8 }} + containers: + - name: reconciler + image: {{ include "svcforge.image" (dict "ctx" $ "component" "reconciler") }} + imagePullPolicy: {{ .Values.image.pullPolicy }} + securityContext: + {{- include "svcforge.containerSecurityContext" . | nindent 12 }} + ports: + - name: metrics + containerPort: 9000 + envFrom: + - secretRef: + name: {{ include "svcforge.secretName" . }} + env: + {{- include "svcforge.env" . | nindent 12 }} + - name: SVCFORGE_RECONCILE_INTERVAL_S + value: {{ .Values.reconciler.intervalSeconds | quote }} + - name: OTEL_SERVICE_NAME + value: svcforge-reconciler + # The reconciler's health is not "is the process up", it is "did it tick". + # That question is answered by SvcforgeReconcilerStale off + # svcforge_reconciler_last_tick_timestamp_seconds, not by a probe — a probe here + # would restart the pod and reset the very gauge the alert reads. + resources: + {{- toYaml .Values.reconciler.resources | nindent 12 }} + volumeMounts: + - name: tmp + mountPath: /tmp + - name: helm-home + mountPath: /tmp/helm + volumes: + - name: tmp + emptyDir: {} + - name: helm-home + emptyDir: {} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/deploy/chart/templates/serviceaccount.yaml b/deploy/chart/templates/serviceaccount.yaml new file mode 100644 index 0000000..4a54080 --- /dev/null +++ b/deploy/chart/templates/serviceaccount.yaml @@ -0,0 +1,24 @@ +{{- if .Values.serviceAccount.create }} +{{/* +One ServiceAccount per service, not one shared. The api talks only to Postgres and has no +Kubernetes rights at all; giving it the worker's identity would hand an +internet-facing HTTP surface the ability to create namespaces. +*/}} +{{- range $component := list "api" "worker" "reconciler" }} +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "svcforge.serviceAccountName" (dict "ctx" $ "component" $component) }} + labels: + {{- include "svcforge.labels" $ | nindent 4 }} + app.kubernetes.io/component: {{ $component }} + {{- with $.Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +# The api never calls the API server, so it gets no token. The worker and reconciler both +# shell out to helm, which needs one. +automountServiceAccountToken: {{ ne $component "api" }} +{{- end }} +{{- end }} diff --git a/deploy/chart/templates/servicemonitor.yaml b/deploy/chart/templates/servicemonitor.yaml new file mode 100644 index 0000000..cc0f69f --- /dev/null +++ b/deploy/chart/templates/servicemonitor.yaml @@ -0,0 +1,44 @@ +{{- if .Values.serviceMonitor.enabled }} +{{/* +Chart-native, values-gated. A hand-authored ServiceMonitor CR applied next to the release +is banned: it drifts from the chart, survives a `helm uninstall`, and nothing owns it. + +The api is scraped through its Service. The worker and reconciler have no Service — they +are scraped by pod, which is why their metrics port is named and their pods carry the +component label. +*/}} +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: {{ include "svcforge.fullname" . }}-api + labels: + {{- include "svcforge.labels" . | nindent 4 }} +spec: + selector: + matchLabels: + {{- include "svcforge.selectorLabels" (dict "ctx" $ "component" "api") | nindent 6 }} + endpoints: + - port: http + path: /metrics + interval: {{ .Values.serviceMonitor.interval }} +--- +apiVersion: monitoring.coreos.com/v1 +kind: PodMonitor +metadata: + name: {{ include "svcforge.fullname" . }}-workers + labels: + {{- include "svcforge.labels" . | nindent 4 }} +spec: + selector: + matchExpressions: + - key: app.kubernetes.io/component + operator: In + values: [worker, reconciler] + - key: app.kubernetes.io/instance + operator: In + values: [{{ .Release.Name }}] + podMetricsEndpoints: + - port: metrics + path: /metrics + interval: {{ .Values.serviceMonitor.interval }} +{{- end }} diff --git a/deploy/chart/templates/worker-deployment.yaml b/deploy/chart/templates/worker-deployment.yaml new file mode 100644 index 0000000..e686c25 --- /dev/null +++ b/deploy/chart/templates/worker-deployment.yaml @@ -0,0 +1,84 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "svcforge.fullname" . }}-worker + labels: + {{- include "svcforge.labels" . | nindent 4 }} + app.kubernetes.io/component: worker +spec: + # Plain replicas, no HPA. Concurrency is bounded twice over — by replicas here and by a + # semaphore inside the claim loop — and the SKIP LOCKED claim makes both safe. The day + # replicas: 2 stops keeping up, add an HPA. Not before. + replicas: {{ .Values.worker.replicas }} + selector: + matchLabels: + {{- include "svcforge.selectorLabels" (dict "ctx" $ "component" "worker") | nindent 6 }} + template: + metadata: + labels: + {{- include "svcforge.labels" . | nindent 8 }} + {{- include "svcforge.selectorLabels" (dict "ctx" $ "component" "worker") | nindent 8 }} + spec: + serviceAccountName: {{ include "svcforge.serviceAccountName" (dict "ctx" $ "component" "worker") }} + {{- with .Values.image.pullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + # The load-bearing one. On SIGTERM the loop stops claiming and finishes the task in + # hand; 60s is the budget for that. Chaos experiment 2 asserts the pod exits 0 inside + # it. Lower this and a rolling deploy starts orphaning tasks to lease expiry. + terminationGracePeriodSeconds: 60 + securityContext: + {{- include "svcforge.podSecurityContext" . | nindent 8 }} + containers: + - name: worker + image: {{ include "svcforge.image" (dict "ctx" $ "component" "worker") }} + imagePullPolicy: {{ .Values.image.pullPolicy }} + securityContext: + {{- include "svcforge.containerSecurityContext" . | nindent 12 }} + ports: + - name: metrics + containerPort: 9000 + envFrom: + - secretRef: + name: {{ include "svcforge.secretName" . }} + env: + {{- include "svcforge.env" . | nindent 12 }} + # The claim loop stamps locked_by with this. Per-pod, so a stuck lease names + # the pod that holds it — runbook entry 1 depends on that. + - name: SVCFORGE_WORKER_ID + valueFrom: + fieldRef: + fieldPath: metadata.name + - name: SVCFORGE_WORKER_CONCURRENCY + value: {{ .Values.worker.concurrency | quote }} + - name: OTEL_SERVICE_NAME + value: svcforge-worker + # No liveness probe. A worker mid-provision is legitimately busy for minutes; + # a probe here is a way to kill a healthy provision and learn nothing. + resources: + {{- toYaml .Values.worker.resources | nindent 12 }} + volumeMounts: + - name: tmp + mountPath: /tmp + # helm writes cache/config/repositories under $HELM_*_HOME, which the image + # points at /tmp/helm. Without this, every helm call fails on a read-only fs. + - name: helm-home + mountPath: /tmp/helm + volumes: + - name: tmp + emptyDir: {} + - name: helm-home + emptyDir: {} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/deploy/chart/values.yaml b/deploy/chart/values.yaml new file mode 100644 index 0000000..51dbf1f --- /dev/null +++ b/deploy/chart/values.yaml @@ -0,0 +1,174 @@ +# svcforge chart values. +# +# The digests below are the deployment. CI builds each image once, pushes it, reads the +# digest back with `docker buildx imagetools inspect`, and `yq -i`s it into this file as +# its last act. ArgoCD notices the commit and syncs. Nothing else deploys svcforge. +# +# Tags are banned. A tag is a mutable pointer, which means "what is running" and "what +# this file says" can silently diverge. A digest cannot. + +nameOverride: "" +fullnameOverride: "" + +image: + registry: gitea.oci-oci.duckdns.org + pullPolicy: IfNotPresent + pullSecrets: [] + # One repo + one digest per service: CI's matrix builds three images, so there are three + # digests. The zeros are placeholders — a fresh clone must be bumped by CI before it can + # deploy, which is the intended failure mode. Never hand-edit these. + api: + repo: gitea.oci-oci.duckdns.org/gitea_admin/svcforge-api + digest: sha256:0000000000000000000000000000000000000000000000000000000000000000 + worker: + repo: gitea.oci-oci.duckdns.org/gitea_admin/svcforge-worker + digest: sha256:0000000000000000000000000000000000000000000000000000000000000000 + reconciler: + repo: gitea.oci-oci.duckdns.org/gitea_admin/svcforge-reconciler + digest: sha256:0000000000000000000000000000000000000000000000000000000000000000 + +api: + replicas: 2 + # One process per pod. Module 7 took the "scale with replicas" fix over + # PROMETHEUS_MULTIPROC_DIR, so `uvicorn --workers N` here would corrupt the metrics. + resources: + requests: {cpu: 50m, memory: 128Mi} + limits: {memory: 256Mi} + service: + type: ClusterIP + port: 80 + targetPort: 8000 + ingress: + enabled: true + className: nginx + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod + host: svcforge.oci-oci.duckdns.org + tls: + enabled: true + secretName: svcforge-tls + +worker: + # Plain replicas. No HPA: the day `replicas: 2` stops keeping up, not before. + replicas: 2 + concurrency: 4 + resources: + requests: {cpu: 100m, memory: 192Mi} + limits: {memory: 512Mi} + +reconciler: + # A singleton, and not by convention — the four checks are not safe to run twice + # concurrently. replicas is deliberately not a value: there is nothing to tune. + intervalSeconds: 60 + resources: + requests: {cpu: 50m, memory: 128Mi} + limits: {memory: 256Mi} + +# Postgres pool sizing. replicas × maxSize is spent against the Supabase pooler budget: +# api(2 × 5) + worker(2 × 5) + reconciler(1 × 2) = 22 connections. Raise with care. +pool: + minSize: 1 + maxSize: 5 + +migrate: + # backoffLimit: 0 — a failed migration must fail the release, not retry into a + # half-applied schema. Migrations run here and only here; never on app startup. + enabled: true + resources: + requests: {cpu: 50m, memory: 128Mi} + limits: {memory: 256Mi} + +auth: + jwksUrl: https://auth.oci-oci.duckdns.org/realms/svcforge/protocol/openid-connect/certs + issuer: https://auth.oci-oci.duckdns.org/realms/svcforge + audience: svcforge + +otel: + enabled: true + endpoint: http://alloy.observability.svc.cluster.local:4317 + +log: + level: info + +# The DSNs are pulled from Vault by external-secrets into a Secret the pods envFrom. +# No DSN is ever a chart value, a ConfigMap key, or a CI variable. +externalSecret: + enabled: true + secretStoreRef: + name: vault + kind: ClusterSecretStore + refreshInterval: 1h + # target Secret name; keys land as SVCFORGE_PG_DSN / SVCFORGE_PG_DSN_SESSION / SVCFORGE_REDIS_DSN + targetName: svcforge-secrets + remoteRefs: + - secretKey: SVCFORGE_PG_DSN + key: svcforge/postgres + property: dsn_pooler + - secretKey: SVCFORGE_PG_DSN_SESSION + key: svcforge/postgres + property: dsn_session + - secretKey: SVCFORGE_REDIS_DSN + key: svcforge/redis + property: dsn + +rbac: + # The worker helm-installs tenant releases into namespaces it creates. `namespaces` is a + # cluster-scoped resource, so `create namespaces` cannot be granted by a namespaced Role + # — this has to be a ClusterRole. It is still least-privilege: named resources, named + # verbs, no `*`, no cluster-admin, and no rbac.authorization.k8s.io group at all, so the + # worker cannot grant itself anything further. + create: true + +serviceAccount: + create: true + annotations: {} + +# Chart-native only. A hand-authored ServiceMonitor/PrometheusRule CR is banned — the +# chart owns these, gated by these flags. +serviceMonitor: + enabled: true + interval: 30s + +prometheusRule: + enabled: true + rules: + - alert: SvcforgeQueueDepthRising + expr: deriv(svcforge_queue_depth[10m]) > 0 + for: 10m + labels: + severity: warning + annotations: + summary: svcforge queue depth is rising and not draining + runbook_url: https://gitea.oci-oci.duckdns.org/gitea_admin/svcforge/src/branch/master/RUNBOOK.md#queue-stuck + - alert: SvcforgeProvisionSlow + expr: histogram_quantile(0.95, sum by (le) (rate(svcforge_provision_duration_seconds_bucket[30m]))) > 300 + for: 15m + labels: + severity: warning + annotations: + summary: svcforge p95 provision time is over 5 minutes + runbook_url: https://gitea.oci-oci.duckdns.org/gitea_admin/svcforge/src/branch/master/RUNBOOK.md#provision-failing + - alert: SvcforgeTaskFailed + expr: sum(svcforge_tasks_failed_total) > 0 + labels: + severity: warning + annotations: + summary: a svcforge task exhausted its retries + runbook_url: https://gitea.oci-oci.duckdns.org/gitea_admin/svcforge/src/branch/master/RUNBOOK.md#provision-failing + - alert: SvcforgeReconcilerStale + expr: time() - svcforge_reconciler_last_tick_timestamp_seconds > 300 + labels: + severity: critical + annotations: + summary: the svcforge reconciler has not ticked in 5 minutes + runbook_url: https://gitea.oci-oci.duckdns.org/gitea_admin/svcforge/src/branch/master/RUNBOOK.md#orphaned-release + +# Not in the spec. Off by default: on a three-node k3s a PDB that cannot be satisfied +# blocks drains, which is worse than the disruption it prevents. +podDisruptionBudget: + enabled: false + minAvailable: 1 + +nodeSelector: {} +tolerations: [] +affinity: {} diff --git a/libs/svcforge_core/pyproject.toml b/libs/svcforge_core/pyproject.toml new file mode 100644 index 0000000..f1b9894 --- /dev/null +++ b/libs/svcforge_core/pyproject.toml @@ -0,0 +1,22 @@ +[project] +name = "svcforge-core" +version = "0.1.0" +description = "svcforge shared core: domain, repo, adapters" +requires-python = ">=3.12" +dependencies = [ + "pydantic>=2.9", + "pydantic-settings>=2.6", + "pyyaml>=6.0", + "psycopg[binary,pool]>=3.2", + "redis>=5.2", + "structlog>=24.4", + "prometheus-client>=0.21", + "opentelemetry-api>=1.28", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["svcforge_core"] diff --git a/libs/svcforge_core/svcforge_core/__init__.py b/libs/svcforge_core/svcforge_core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/libs/svcforge_core/svcforge_core/adapters/__init__.py b/libs/svcforge_core/svcforge_core/adapters/__init__.py new file mode 100644 index 0000000..d6636ff --- /dev/null +++ b/libs/svcforge_core/svcforge_core/adapters/__init__.py @@ -0,0 +1,14 @@ +"""The outside world: helm/kubectl subprocesses, notifications, the clock. + +Everything in here is I/O the domain must never learn about. Each adapter exposes a +Protocol with exactly two implementations — the real one here, the fake in `tests/fakes.py`. +A Protocol with one implementation is an interface nobody asked for; delete it. + +The subprocess discipline lives in :func:`svcforge_core.adapters.helm._run` and is shared +by every adapter that shells out (`k8s.py` included): + +* `create_subprocess_exec`, never a shell — `team` is tenant input that reaches a release name. +* `start_new_session=True` at spawn time, so a timeout can kill the whole process group. +* `communicate()`, never `wait()` with pipes attached. +* stderr truncated to a fixed tail at this boundary, because it lands in `instances.error`. +""" diff --git a/libs/svcforge_core/svcforge_core/adapters/clock.py b/libs/svcforge_core/svcforge_core/adapters/clock.py new file mode 100644 index 0000000..ec855f5 --- /dev/null +++ b/libs/svcforge_core/svcforge_core/adapters/clock.py @@ -0,0 +1,41 @@ +"""Time, as a dependency. + +The centrepiece of the Day-2 module, and it is nine lines. `datetime.now()` called from +inside domain logic is an untestable global read: a maintenance-window test that wants +"03:00 next Sunday" would have to either sleep until Sunday or monkeypatch a stdlib symbol +and hope nothing else in the process noticed. Passing a Clock makes the same test a +`FakeClock(start=...)` and an `advance()`. + +Aware UTC, always. A naive datetime is a bug that survives every test on a UTC CI box and +detonates the first time it meets a tenant in Asia/Ho_Chi_Minh: `datetime.utcnow()` returns +a naive value, and comparing it to a `timestamptz` from Postgres raises TypeError, or worse, +silently compares wrong after somebody "fixes" it with a `.replace(tzinfo=...)`. + +The fake lives in `tests/fakes.py`, not here: shipping test doubles in the production +package is how they end up imported by production code. +""" + +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Protocol + + +class Clock(Protocol): + """Implemented by SystemClock (here) and FakeClock (tests/fakes.py).""" + + def now(self) -> datetime: + """Current time, aware, UTC.""" + ... + + +class SystemClock: + """The wall clock. The only thing in the codebase allowed to read it.""" + + def now(self) -> datetime: + """Current time, aware, UTC. + + `datetime.now(UTC)`, never `datetime.utcnow()` — the latter is naive and deprecated + in 3.12 for exactly this reason. + """ + return datetime.now(UTC) diff --git a/libs/svcforge_core/svcforge_core/adapters/helm.py b/libs/svcforge_core/svcforge_core/adapters/helm.py new file mode 100644 index 0000000..23926ab --- /dev/null +++ b/libs/svcforge_core/svcforge_core/adapters/helm.py @@ -0,0 +1,245 @@ +"""Driving helm from asyncio, with timeouts that actually kill helm. + +The whole module exists for `_run`. Everything above it is argv construction. + +Four things go wrong when you spawn a process from an event loop, and all four are +handled here rather than in the caller: + +1. `subprocess.run` blocks the loop. Use `create_subprocess_exec`. +2. `stdout=PIPE` with `proc.wait()` and nobody draining deadlocks at ~64 KB of output — + `helm --debug` clears that in one install. Use `communicate()`. +3. `asyncio.wait_for` cancels the *coroutine*. The process does not know it was waited on: + helm keeps running and keeps mutating the cluster. The timeout has to kill it. +4. `proc.kill()` signals the direct child. `helm` forks; its children reparent to init and + survive. Only `killpg` gets the whole tree, and only if the group exists — which needs + `start_new_session=True` **at spawn time**, because setsid can only run in the window + between fork and exec. +""" + +from __future__ import annotations + +import asyncio +import json +import os +import shutil +import signal +import tempfile +from collections.abc import Sequence +from pathlib import Path +from typing import Any, Protocol + +import yaml +from pydantic import BaseModel, ConfigDict, Field + +from svcforge_core.domain.models import CatalogEntry + +# How long the process group gets to honour SIGTERM before SIGKILL. Helm traps SIGTERM +# and tries to leave the release in a coherent state; give it a moment to do so. +_TERM_GRACE_S = 5.0 + +# `_run` is the backstop, not the primary timeout: helm gets its own `--timeout` so that +# `--atomic` can roll back cleanly. `_run` only fires when helm itself is wedged, so its +# deadline sits this far past helm's. +_RUN_TIMEOUT_MARGIN_S = 30 + +_STDERR_TAIL_BYTES = 2048 + + +class HelmError(RuntimeError): + """Non-zero exit. str(self) is the stderr tail that lands in instances.error.""" + + +class ReleaseInfo(BaseModel): + """One row of `helm list -o json`.""" + + model_config = ConfigDict(frozen=True) + + name: str + namespace: str + chart: str + status: str + revision: int = Field(default=0, ge=0) + app_version: str = "" + + +class Provisioner(Protocol): + """What the worker needs from a cluster. Implemented by HelmProvisioner and FakeProvisioner.""" + + async def install(self, release: str, ns: str, entry: CatalogEntry, values: dict[str, Any]) -> None: ... + + async def uninstall(self, release: str, ns: str) -> None: ... + + async def list_releases(self) -> list[ReleaseInfo]: ... + + +def _tail(raw: bytes, tail_bytes: int) -> str: + """The last `tail_bytes` of a stream, as text. + + Truncation happens here, at the adapter boundary, and nowhere else. A helm failure can + emit megabytes; `instances.error` is a text column read by humans. Slice the bytes, not + the decoded string, then decode with `replace` — the cut can land mid-codepoint. + """ + return raw[-tail_bytes:].decode("utf-8", errors="replace").strip() + + +def _signal_group(proc: asyncio.subprocess.Process, sig: int) -> None: + """Signal the process's whole group. No-op if it has already exited. + + `os.getpgid` rather than `proc.pid`: with `start_new_session=True` they are equal, but + that equality is an implementation detail, and asking the kernel costs nothing. + """ + if proc.returncode is not None: + return + try: + os.killpg(os.getpgid(proc.pid), sig) + except (ProcessLookupError, PermissionError): + # Exited and reaped between the check and the call, or reparented out of reach. + return + + +async def _terminate_group(proc: asyncio.subprocess.Process) -> None: + """SIGTERM the group, grace, SIGKILL the group. Then reap, so we leave no zombie.""" + _signal_group(proc, signal.SIGTERM) + try: + await asyncio.wait_for(proc.wait(), timeout=_TERM_GRACE_S) + except TimeoutError: + _signal_group(proc, signal.SIGKILL) + await proc.wait() + + +async def _run(argv: Sequence[str], timeout_s: int, tail_bytes: int = _STDERR_TAIL_BYTES) -> str: + """create_subprocess_exec(*argv, stdout=PIPE, stderr=PIPE, start_new_session=True). + + wait_for the drain. On TimeoutError: killpg(TERM), grace, killpg(KILL), raise. + Non-zero rc: raise HelmError(stderr tail). Return stdout. + """ + proc = await asyncio.create_subprocess_exec( + *argv, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + start_new_session=True, + ) + + try: + stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout_s) + except TimeoutError: + # wait_for cancelled the drain, not the process. Kill the group and re-raise. + await _terminate_group(proc) + raise + except asyncio.CancelledError: + # Our caller is being torn down (SIGTERM to the worker). Do not await anything here: + # a second cancellation would land on that await and leave helm running. Signal and go. + _signal_group(proc, signal.SIGKILL) + raise + + if proc.returncode != 0: + detail = _tail(stderr, tail_bytes) or _tail(stdout, tail_bytes) + raise HelmError( + f"{argv[0]} exited {proc.returncode}: {detail}" + if detail + else f"{argv[0]} exited {proc.returncode}" + ) + return stdout.decode("utf-8", errors="replace") + + +class HelmProvisioner: + """The real thing. One helm binary, one kubeconfig, one deadline.""" + + def __init__( + self, *, helm_bin: str = "helm", kubeconfig: Path | None = None, timeout_s: int = 600 + ) -> None: + """kubeconfig=None means the ambient config: $KUBECONFIG, ~/.kube/config, or the + in-cluster service account when svcforge runs as a pod. Keyword-only so that the + three arguments can never be swapped by position at a call site. + """ + self._helm_bin = helm_bin + self._kubeconfig = kubeconfig + self._timeout_s = timeout_s + + @property + def _run_timeout_s(self) -> int: + return self._timeout_s + _RUN_TIMEOUT_MARGIN_S + + def _base_argv(self, *args: str) -> list[str]: + argv = [self._helm_bin, *args] + if self._kubeconfig is not None: + argv += ["--kubeconfig", str(self._kubeconfig)] + return argv + + async def install(self, release: str, ns: str, entry: CatalogEntry, values: dict[str, Any]) -> None: + """helm upgrade --install --wait --timeout. Idempotent by construction.""" + # `upgrade --install` is why this is idempotent: a retried task after a crash mid-provision + # converges on the same release instead of erroring with "release already exists". + # `--wait` is why `ready` in the DB means ready — it returns when the pods are up. + # `--atomic` rolls back a failed upgrade; it doubles the worst case, which is what + # `_RUN_TIMEOUT_MARGIN_S` and helm's own `--timeout` are sized around. + with _values_file(values) as path: + argv = self._base_argv( + "upgrade", + "--install", + release, + entry.chart, + "--namespace", + ns, + "--version", + entry.chart_version, + "--values", + str(path), + "--wait", + "--atomic", + "--timeout", + f"{self._timeout_s}s", + ) + await _run(argv, timeout_s=self._run_timeout_s) + + async def uninstall(self, release: str, ns: str) -> None: + """helm uninstall --wait. `--ignore-not-found` makes the retry of a half-done delete a no-op.""" + argv = self._base_argv( + "uninstall", + release, + "--namespace", + ns, + "--ignore-not-found", + "--wait", + "--timeout", + f"{self._timeout_s}s", + ) + await _run(argv, timeout_s=self._run_timeout_s) + + async def list_releases(self) -> list[ReleaseInfo]: + """Every release helm knows about, in every namespace. The reconciler's view of reality.""" + argv = self._base_argv("list", "--all-namespaces", "--output", "json") + raw = await _run(argv, timeout_s=self._run_timeout_s) + try: + parsed: Any = json.loads(raw or "[]") + except json.JSONDecodeError as exc: + raise HelmError(f"helm list returned non-JSON: {_tail(raw.encode(), 256)}") from exc + if not isinstance(parsed, list): + raise HelmError(f"helm list returned {type(parsed).__name__}, expected a list") + return [ReleaseInfo.model_validate(row) for row in parsed] + + +class _ValuesFile: + """Context manager yielding a path to a values.yaml written from a dict. + + A file, not `--set`: `--set` has its own escaping grammar (commas, dots, backslashes) and + values carry tenant-shaped strings. Serialising YAML sidesteps the grammar entirely. + """ + + def __init__(self, values: dict[str, Any]) -> None: + self._values = values + self._dir: str | None = None + + def __enter__(self) -> Path: + self._dir = tempfile.mkdtemp(prefix="svcforge-values-") + path = Path(self._dir) / "values.yaml" + path.write_text(yaml.safe_dump(self._values, default_flow_style=False), encoding="utf-8") + return path + + def __exit__(self, *exc: object) -> None: + if self._dir is not None: + shutil.rmtree(self._dir, ignore_errors=True) + + +def _values_file(values: dict[str, Any]) -> _ValuesFile: + return _ValuesFile(values) diff --git a/libs/svcforge_core/svcforge_core/adapters/k8s.py b/libs/svcforge_core/svcforge_core/adapters/k8s.py new file mode 100644 index 0000000..2fdf597 --- /dev/null +++ b/libs/svcforge_core/svcforge_core/adapters/k8s.py @@ -0,0 +1,136 @@ +"""kubectl, for the two things helm will not do: make a namespace, read a secret back. + +Same subprocess discipline as helm — it is literally the same `_run`, imported rather than +copied, because the process-group handling is the part that is easy to get subtly wrong twice. + +Both operations are declarative and therefore idempotent: `apply` of a namespace that exists +is a no-op, and a `get` has no effect at all. A worker that dies mid-provision and retries +must not find a "namespace already exists" error waiting for it. +""" + +from __future__ import annotations + +import base64 +import binascii +import json +import shutil +import tempfile +from pathlib import Path +from typing import Any + +import yaml + +from svcforge_core.adapters.helm import HelmError, _run + +_KUBECTL_TIMEOUT_S = 60 + + +class K8sError(RuntimeError): + """kubectl failed. str(self) is the stderr tail, already truncated by `_run`.""" + + +class SecretNotFound(K8sError): + """The secret is not there (yet). Distinct because a caller may want to retry rather than fail.""" + + +class KubectlClient: + """A kubectl binary and a kubeconfig. No client-go, no in-cluster config, no CRDs.""" + + def __init__( + self, + *, + kubectl_bin: str = "kubectl", + kubeconfig: Path | None = None, + timeout_s: int = _KUBECTL_TIMEOUT_S, + ) -> None: + """Mirrors HelmProvisioner: kubeconfig=None means the ambient/in-cluster config.""" + self._kubectl_bin = kubectl_bin + self._kubeconfig = kubeconfig + self._timeout_s = timeout_s + + def _base_argv(self, *args: str) -> list[str]: + argv = [self._kubectl_bin, *args] + if self._kubeconfig is not None: + argv += ["--kubeconfig", str(self._kubeconfig)] + return argv + + async def ensure_namespace(self, ns: str, labels: dict[str, str] | None = None) -> None: + """Create the namespace if absent, leave it alone if present. + + `apply -f` of a manifest rather than `create namespace` (which errors on the second + call) or `create --dry-run=client -o yaml | apply -f -` (which needs a shell, and a + shell is exactly what tenant input must never reach). + """ + manifest: dict[str, Any] = { + "apiVersion": "v1", + "kind": "Namespace", + "metadata": { + "name": ns, + "labels": {"app.kubernetes.io/managed-by": "svcforge", **(labels or {})}, + }, + } + with _manifest_file(manifest) as path: + await self._kubectl("apply", "--filename", str(path)) + + async def read_secret(self, ns: str, name: str) -> dict[str, str]: + """The secret's `data`, base64-decoded. Chart-generated passwords come back through here. + + Returns str, not bytes: every value svcforge reads (passwords, hosts, ports) is text. + A genuinely binary value raises rather than silently mangling into replacement chars. + """ + try: + raw = await self._kubectl("get", "secret", name, "--namespace", ns, "--output", "json") + except K8sError as exc: + if "notfound" in str(exc).lower().replace(" ", ""): + raise SecretNotFound(f"secret {ns}/{name} not found") from exc + raise + + try: + parsed: Any = json.loads(raw) + except json.JSONDecodeError as exc: + raise K8sError(f"kubectl get secret {ns}/{name} returned non-JSON") from exc + + data: Any = parsed.get("data") or {} + if not isinstance(data, dict): + raise K8sError(f"secret {ns}/{name}: 'data' is {type(data).__name__}, expected a mapping") + + out: dict[str, str] = {} + for key, encoded in data.items(): + try: + out[str(key)] = base64.b64decode(str(encoded), validate=True).decode("utf-8") + except (binascii.Error, ValueError) as exc: + raise K8sError(f"secret {ns}/{name}: key {key!r} is not base64-encoded utf-8") from exc + return out + + async def _kubectl(self, *args: str) -> str: + """Run kubectl through helm's `_run`, translating its error type at this boundary. + + `_run` is spec'd to live in helm.py and to raise HelmError; nothing outside adapters + should have to know that kubectl failures arrive wearing a helm-shaped exception. + """ + try: + return await _run(self._base_argv(*args), timeout_s=self._timeout_s) + except HelmError as exc: + raise K8sError(str(exc)) from exc + + +class _ManifestFile: + """A temp file holding one YAML manifest, removed on exit.""" + + def __init__(self, manifest: dict[str, Any]) -> None: + self._manifest = manifest + self._dir: str | None = None + + def __enter__(self) -> Path: + self._dir = tempfile.mkdtemp(prefix="svcforge-manifest-") + path = Path(self._dir) / "manifest.yaml" + path.write_text(yaml.safe_dump(self._manifest, default_flow_style=False), encoding="utf-8") + return path + + def __exit__(self, *exc: object) -> None: + if self._dir is not None: + shutil.rmtree(self._dir, ignore_errors=True) + + +def _manifest_file(manifest: dict[str, Any]) -> _ManifestFile: + return _ManifestFile(manifest) diff --git a/libs/svcforge_core/svcforge_core/adapters/notify.py b/libs/svcforge_core/svcforge_core/adapters/notify.py new file mode 100644 index 0000000..cbce1ba --- /dev/null +++ b/libs/svcforge_core/svcforge_core/adapters/notify.py @@ -0,0 +1,77 @@ +"""Telling someone a provision finished, or didn't. + +Best-effort by construction: `send` never raises. A notifier that can fail a task is a +notifier that lets a Slack outage roll back a successful provision. The instance is ready; +the DB says so; failing the task would re-run helm for nothing. Delivery failures are +logged and dropped on the floor, which is the correct amount of ceremony for a webhook. + +Two implementations, so the Protocol earns its place: LogNotifier (the default, and what +tests and local dev get) and WebhookNotifier (the one that leaves the process). +""" + +from __future__ import annotations + +import logging +from typing import Protocol + +import httpx + +_log = logging.getLogger(__name__) + +_DEFAULT_TIMEOUT_S = 5.0 + + +class Notifier(Protocol): + """Implemented by LogNotifier, WebhookNotifier, and FakeNotifier (tests/fakes.py).""" + + async def send(self, event: str, message: str, fields: dict[str, str] | None = None) -> None: + """Announce `event`. Must not raise: delivery is never worth failing a task over.""" + ... + + +class LogNotifier: + """Writes the event to the log. The default: structured logs are already shipped somewhere.""" + + async def send(self, event: str, message: str, fields: dict[str, str] | None = None) -> None: + # NOT extra={"message": ...}. `message` is a reserved LogRecord attribute, and + # logging raises KeyError("Attempt to overwrite 'message' in LogRecord") at call + # time — so the notifier would crash the caller it was meant to inform. Same trap + # for `msg`, `args`, `name`, `levelname`, `exc_info`. + _log.info("notify", extra={"event": event, "detail": message, "fields": fields or {}}) + + +class WebhookNotifier: + """POSTs a JSON body at a URL. Slack-shaped, but anything that accepts JSON will do.""" + + def __init__( + self, url: str, *, client: httpx.AsyncClient | None = None, timeout_s: float = _DEFAULT_TIMEOUT_S + ) -> None: + """Pass `client` to share a connection pool with the rest of the process. + + An owned client is closed by `aclose`; an injected one is the caller's to close. + """ + self._url = url + self._timeout_s = timeout_s + self._client = client + self._owns_client = client is None + + async def _get_client(self) -> httpx.AsyncClient: + if self._client is None: + self._client = httpx.AsyncClient(timeout=self._timeout_s) + return self._client + + async def send(self, event: str, message: str, fields: dict[str, str] | None = None) -> None: + payload = {"event": event, "message": message, "fields": fields or {}} + try: + client = await self._get_client() + resp = await client.post(self._url, json=payload, timeout=self._timeout_s) + resp.raise_for_status() + except httpx.HTTPError as exc: + # Deliberately swallowed. See the module docstring: the work already succeeded. + _log.warning("notify webhook failed", extra={"event": event, "error": str(exc)}) + + async def aclose(self) -> None: + """Close the client, if we made it.""" + if self._client is not None and self._owns_client: + await self._client.aclose() + self._client = None diff --git a/libs/svcforge_core/svcforge_core/adapters/redis.py b/libs/svcforge_core/svcforge_core/adapters/redis.py new file mode 100644 index 0000000..0034198 --- /dev/null +++ b/libs/svcforge_core/svcforge_core/adapters/redis.py @@ -0,0 +1,427 @@ +"""Redis: derived state only. Never the truth, never the queue. + +Everything in here is a shortcut past Postgres, and every one of them is optional. Postgres +holds the instances, the tasks, the leases and the `release_name` UNIQUE constraint. Redis +holds a counter, a claim marker and a copy — all of it rebuildable by doing nothing and +waiting for a TTL. + +That framing is not philosophy, it decides the error handling, and the error handling is +the module. Each class below catches `RedisError` and returns a *safe* answer rather than +raising: + +| Path | Redis is down | Why | +|-------------|--------------------------|--------------------------------------------------| +| Cache | miss -> read Postgres | It was an optimisation. Nobody notices. | +| Rate limit | **allow** | An internal platform that refuses every request | +| | | because the limiter is sick is worse than one | +| | | that is briefly unmetered. | +| Idempotency | fall through to the DB | `instances.release_name` is UNIQUE. That is the | +| | | real guarantee; this is the fast path. | + +Consequently nothing here raises out to a caller, and `/readyz` stays Postgres-only. A +Redis outage must not make a single pod unready — that would convert "the cache is down" +into "the platform is down", which is the exact inversion this module exists to prevent. + +**The budget is a design constraint, not a footnote.** Upstash free tier: + + 500,000 commands / month = 16,129 / day = 11 / minute = 0.19 / second, sustained + +One worker polling Redis every five seconds spends 518,400/month: the entire budget, +producing nothing. So the rule is structural — **Redis lives on the request path only**, +where volume is bounded by the number of humans with an API token, and never inside a poll +or control loop. That is also why the limiter is a Lua script: `GET`/`INCR`/`EXPIRE` is +three billed commands and a race; one `EVALSHA` is one billed command and atomic. A +pipeline would not help — it batches round trips but still bills N. + +Every key gets a TTL. 256 MB with no expiry is a slow leak that ends by evicting the keys +you cared about. +""" + +from __future__ import annotations + +import asyncio +import logging +import math +from dataclasses import dataclass +from datetime import UTC, datetime +from typing import TYPE_CHECKING, Protocol +from uuid import UUID + +from prometheus_client import Counter +from pydantic import ValidationError +from redis.asyncio import Redis +from redis.exceptions import RedisError + +from svcforge_core.adapters.clock import Clock, SystemClock +from svcforge_core.domain.models import Instance + +if TYPE_CHECKING: + from redis.commands.core import AsyncScript + + from svcforge_core.settings import Settings + +_log = logging.getLogger(__name__) + +# --- The budget metric ------------------------------------------------------------------ +# +# The counter that `scripts/redis_budget.py` projects month-end burn from. It counts +# commands we *send*, incremented next to each call, because the number that matters is +# the one Upstash bills — not the number of times a method was called. A cache miss calls +# `get()` once and spends one command; a `put()` after it spends another. +# +# The dangerous failure this makes visible: when Redis is down, every path degrades +# silently and correctly, so nothing pages. Nothing fails until the month rolls over and +# every Redis call starts erroring at once. A counter you can extrapolate from is the only +# warning you get. + +REDIS_COMMANDS = Counter( + "svcforge_redis_commands_total", + "Redis commands issued, as Upstash bills them. One EVALSHA is one.", + ["op"], +) + +REDIS_ERRORS = Counter( + "svcforge_redis_errors_total", + "Redis calls that failed and were degraded past. Never surfaced to the caller.", + ["op"], +) + +# A hung Redis must not hang the request path. Without these, a TCP connection that is +# open but unanswered blocks the handler until the client gives up — which turns "Redis is +# slow" into "the API is down", the same inversion the fail-open policy prevents. Upstash +# steady-state RTT is ~2.4 ms; two seconds is already pathological. +_SOCKET_TIMEOUT_S = 2.0 +_CONNECT_TIMEOUT_S = 2.0 + +# Errors that mean "Redis did not answer". Every public method below turns these into a +# safe default. `OSError` because a DNS failure at connect time need not arrive wrapped, +# `TimeoutError` because the socket timeouts above raise it. +_REDIS_DOWN = (RedisError, OSError, asyncio.TimeoutError) + + +def _as_text(value: bytes | str) -> str: + """`decode_responses=True` already did this; redis-py's annotations do not know it. + + The client is configured for text, so the `bytes` branch is unreachable in this + process. It stays because the type says it is reachable, and a `cast` here would hide + the day someone builds a client without `decode_responses` and gets a `UUID(b'...')` + TypeError from three frames away instead of a value that just works. + """ + return value.decode() if isinstance(value, bytes) else value + + +def make_redis(settings: Settings) -> Redis | None: + """One client per process, opened in lifespan next to the psycopg pool, closed on exit. + + `None` when no DSN is configured, and that is a supported way to run: every consumer + below is optional by construction, so "no Redis" and "Redis is down" take the same + code path. The signature is `Redis | None` rather than `Redis` precisely so that + "unconfigured" cannot be faked with a client pointed at nothing. + + Two settings are not negotiable: + + `decode_responses=True` — the first bug everyone hits. Without it every read is + `bytes` and the traceback is `AttributeError: 'bytes' object has no attribute + 'encode'`, several frames away from the cause. + + `rediss://` (TLS) — Upstash rejects plaintext. The handshake is ~56 ms against a + ~2.4 ms steady-state RTT, which is the whole argument for one pooled client per + process: a client per request pays the handshake every time and turns a cache into a + latency regression. + """ + if settings.redis_dsn is None: + return None + return Redis.from_url( + str(settings.redis_dsn), + decode_responses=True, + socket_timeout=_SOCKET_TIMEOUT_S, + socket_connect_timeout=_CONNECT_TIMEOUT_S, + ) + + +# --- Rate limiting ---------------------------------------------------------------------- + +# One INCR; EXPIRE only when the counter is new. The `== 1` test is the entire trick: set +# the TTL unconditionally and every request slides the window forward, so a caller at +# steady load is never reset and the "window" is a sliding refusal that never lets up. +# +# KEYS and ARGV arrive as 1-based tables — Lua indexes from 1, and `ARGV[0]` is silently +# nil rather than an error, which reads as "the limit is nil" and compares false forever. +# +# Everything derivable in Python is derived in Python: `reset_at` comes from the window +# number the caller already computed, so there is no TTL round trip. One command, total. +_RATE_LIMIT_LUA = """ +local n = redis.call('INCR', KEYS[1]) +if n == 1 then + redis.call('EXPIRE', KEYS[1], ARGV[2]) +end +local limit = tonumber(ARGV[1]) +local remaining = limit - n +if remaining < 0 then + remaining = 0 +end +local allowed = 0 +if n <= limit then + allowed = 1 +end +return {allowed, remaining} +""" + + +@dataclass(frozen=True) +class RateLimitResult: + """The limiter's answer. `degraded` is how the caller knows it did not really check.""" + + allowed: bool + limit: int + remaining: int + reset_at: datetime + degraded: bool = False + + @property + def retry_after_s(self) -> int: + """Seconds until the window rolls over, for the `Retry-After` header on a 429. + + Rounded up and floored at one: `Retry-After: 0` invites an immediate retry into + the same closed window, which is a busy loop with extra steps. + """ + delta = (self.reset_at - datetime.now(UTC)).total_seconds() + return max(1, math.ceil(delta)) + + +class RateLimiterProto(Protocol): + """Implemented by RateLimiter (here) and FakeRateLimiter (tests/fakes.py).""" + + async def check(self, team: str) -> RateLimitResult: + """Count this request against `team`'s window. Must not raise.""" + ... + + +class RateLimiter: + """Fixed-window limiter. One EVALSHA per check. Fails OPEN. + + Fixed window, not a token bucket or a sliding log, because the window boundary is the + only thing a fixed window gets wrong and the cost of getting it wrong is that a caller + can spend 2x the limit across a boundary. A sliding log is a sorted set, an + `ZREMRANGEBYSCORE`, an `ZADD` and a `ZCARD` — four billed commands and unbounded key + size — to fix a burst nobody is paying for. The limit is a courtesy, not a security + control; the security control is the JWT. + """ + + def __init__(self, r: Redis, limit: int, window_s: int, *, clock: Clock | None = None) -> None: + """`clock` is injectable so the window boundary is testable without sleeping.""" + if limit < 1: + raise ValueError("limit must be >= 1") + if window_s < 1: + raise ValueError("window_s must be >= 1") + self._limit = limit + self._window_s = window_s + self._clock = clock or SystemClock() + # register_script() is local: it hashes the source and returns a callable. No round + # trip here, and none wasted at import. The first call sends EVALSHA; redis-py + # catches NOSCRIPT and replays it as EVAL, which is why a restarted Redis costs one + # extra command once rather than an outage. + self._script: AsyncScript = r.register_script(_RATE_LIMIT_LUA) + + def _window(self) -> tuple[int, datetime]: + """The current window number and when it ends. Pure arithmetic, no Redis.""" + now = self._clock.now() + epoch = int(now.timestamp()) + window = epoch // self._window_s + reset_at = datetime.fromtimestamp((window + 1) * self._window_s, tz=UTC) + return window, reset_at + + async def check(self, team: str) -> RateLimitResult: + """Count one request against `team`. Never raises. + + On any Redis error: allow, log loudly, count it. The metric is the point — a + limiter that fails open silently is indistinguishable from no limiter at all, and + you find out which one you shipped during the incident. + """ + window, reset_at = self._window() + key = f"rl:{team}:{window}" + try: + REDIS_COMMANDS.labels(op="ratelimit").inc() + allowed, remaining = await self._script(keys=[key], args=[self._limit, self._window_s]) + except _REDIS_DOWN: + REDIS_ERRORS.labels(op="ratelimit").inc() + _log.warning( + "rate limiter degraded: redis unavailable, failing OPEN", + exc_info=True, + extra={"team": team}, + ) + return RateLimitResult( + allowed=True, + limit=self._limit, + remaining=self._limit, + reset_at=reset_at, + degraded=True, + ) + return RateLimitResult( + allowed=bool(allowed), + limit=self._limit, + remaining=int(remaining), + reset_at=reset_at, + ) + + +# --- Idempotency ------------------------------------------------------------------------ + + +class IdempotencyStoreProto(Protocol): + """Implemented by IdempotencyStore (here) and FakeIdempotencyStore (tests/fakes.py).""" + + async def claim(self, key: str, instance_id: UUID) -> UUID | None: + """None = we won and the caller creates. A UUID = it already exists. Must not raise.""" + ... + + +class IdempotencyStore: + """`SET NX EX`. Maps an `Idempotency-Key` to the instance UUID it created. + + Claimed BEFORE the DB transaction, so the marker exists before the row it names. The + inversion matters: claim after the commit and a crash in between leaves a created + instance with no marker, and the client's retry creates a second one. + + Claiming first has its own hole — a crash after the claim and before the commit leaves + a marker pointing at an instance that never existed, and the retry is told "already + done" about nothing. It is the better hole: the client polls the id, gets a 404, and + retries with a fresh key. The alternative loses money to a duplicate Elasticsearch. + + And neither hole is load-bearing, because `instances.release_name` is UNIQUE and + deterministic from (team, service_type, id). **That constraint is the guarantee.** This + class only saves the round trip to find out. + """ + + def __init__(self, r: Redis, ttl_s: int = 86400) -> None: + """A day is the window a client might reasonably retry in; then the key is garbage.""" + if ttl_s < 1: + raise ValueError("ttl_s must be >= 1") + self._r = r + self._ttl_s = ttl_s + + async def claim(self, key: str, instance_id: UUID) -> UUID | None: + """Try to bind `key` to `instance_id`. Never raises. + + `None` from the happy path means "you won, go create it". `None` from a Redis + failure means the same thing — the caller creates, and the UNIQUE constraint + catches an actual duplicate. Degrading to "create it" is safe *only* because that + constraint exists; without it this would have to fail closed. + + One command when we win, which is the common case and the one the budget is sized + for. Two when we lose: the loser pays a GET, and losers are rare by definition. + """ + redis_key = f"idem:{key}" + try: + REDIS_COMMANDS.labels(op="idempotency").inc() + won = await self._r.set(redis_key, str(instance_id), nx=True, ex=self._ttl_s) + if won: + return None + REDIS_COMMANDS.labels(op="idempotency").inc() + existing = await self._r.get(redis_key) + except _REDIS_DOWN: + REDIS_ERRORS.labels(op="idempotency").inc() + _log.warning( + "idempotency degraded: redis unavailable, falling through to the DB constraint", + exc_info=True, + ) + return None + + if existing is None: + # The key expired between the SET and the GET. Vanishingly rare, and the honest + # answer is "no winner recorded" — let the caller create and let Postgres decide. + return None + try: + return UUID(_as_text(existing)) + except ValueError: + _log.warning("idempotency key holds a non-UUID value; ignoring it") + return None + + +# --- Read cache ------------------------------------------------------------------------- + + +class InstanceCacheProto(Protocol): + """Implemented by InstanceCache (here) and FakeInstanceCache (tests/fakes.py).""" + + async def get(self, instance_id: UUID) -> Instance | None: + """The cached instance, or None for a miss. Must not raise.""" + ... + + async def put(self, inst: Instance) -> None: + """Cache `inst` for the TTL. Must not raise.""" + ... + + async def invalidate(self, instance_id: UUID) -> None: + """Drop the entry. Must not raise.""" + ... + + +class InstanceCache: + """Cache-aside for `GET /v1/instances/{id}`. TTL 30s. + + Hit costs one command, miss costs two (the GET, then the SET after Postgres answers). + That is ~1 per read at any useful hit rate, which is what keeps a read-heavy poller + inside the budget. + + The TTL is short on purpose and is the actual correctness argument. `invalidate()` on + every state transition is the fast path, not the guarantee: the worker can crash + between the UPDATE and the DEL, and then the cache is wrong. Thirty seconds bounds how + wrong. Trusting the invalidation instead — and raising the TTL to an hour — is how a + deleted instance stays `ready` in the API for an hour. + """ + + def __init__(self, r: Redis, ttl_s: int = 30) -> None: + if ttl_s < 1: + raise ValueError("ttl_s must be >= 1") + self._r = r + self._ttl_s = ttl_s + + @staticmethod + def _key(instance_id: UUID) -> str: + return f"inst:{instance_id}" + + async def get(self, instance_id: UUID) -> Instance | None: + """One GET. A miss, a Redis outage and a corrupt entry are all the same answer. + + Which is the point: the caller writes `cache.get() or repo.get()` and has no branch + for "Redis is broken", because there is nothing different to do about it. + """ + try: + REDIS_COMMANDS.labels(op="cache_get").inc() + raw = await self._r.get(self._key(instance_id)) + except _REDIS_DOWN: + REDIS_ERRORS.labels(op="cache_get").inc() + _log.warning("cache read degraded: redis unavailable, falling through to Postgres") + return None + if raw is None: + return None + try: + return Instance.model_validate_json(raw) + except ValidationError: + # A model change deployed over a warm cache. Treat it as a miss and let the TTL + # take the old shape out. Not an error: the truth is in Postgres either way. + _log.info("cache entry failed validation; treating as a miss") + return None + + async def put(self, inst: Instance) -> None: + """One SET with `EX`. Never write a key here without a TTL.""" + try: + REDIS_COMMANDS.labels(op="cache_put").inc() + await self._r.set(self._key(inst.id), inst.model_dump_json(), ex=self._ttl_s) + except _REDIS_DOWN: + REDIS_ERRORS.labels(op="cache_put").inc() + _log.warning("cache write degraded: redis unavailable") + + async def invalidate(self, instance_id: UUID) -> None: + """One DEL. Called by the worker inside the code path that writes the state. + + Inside that path, not after it and not from a subscriber: an invalidation that can + be skipped by an early return is an invalidation that will be. + """ + try: + REDIS_COMMANDS.labels(op="cache_del").inc() + await self._r.delete(self._key(instance_id)) + except _REDIS_DOWN: + REDIS_ERRORS.labels(op="cache_del").inc() + _log.warning("cache invalidate degraded: redis unavailable; entry expires within the TTL") diff --git a/libs/svcforge_core/svcforge_core/domain/__init__.py b/libs/svcforge_core/svcforge_core/domain/__init__.py new file mode 100644 index 0000000..9c4b903 --- /dev/null +++ b/libs/svcforge_core/svcforge_core/domain/__init__.py @@ -0,0 +1,5 @@ +"""Pure domain logic: state machine, models, backoff math, catalog rules. + +No I/O lives here except :func:`svcforge_core.domain.catalog.load_catalog`, which reads +the one path it is handed. Nothing in this package is async. +""" diff --git a/libs/svcforge_core/svcforge_core/domain/backoff.py b/libs/svcforge_core/svcforge_core/domain/backoff.py new file mode 100644 index 0000000..40a70c7 --- /dev/null +++ b/libs/svcforge_core/svcforge_core/domain/backoff.py @@ -0,0 +1,24 @@ +"""Retry backoff math. Pure: `now` and `rand` are injected, never called for you.""" + +import random +from collections.abc import Callable +from datetime import datetime, timedelta + + +def next_attempt_at( + attempt: int, + *, + now: datetime, + base_s: float = 2.0, + cap_s: float = 300.0, + rand: Callable[[], float] = random.random, +) -> datetime: + """Exponential backoff with full jitter, capped. Pure: takes `now` and `rand`, never calls them itself. + + Delay is uniform in [0, min(cap_s, base_s * 2**attempt)]. attempt is 0-based; raise ValueError if < 0. + """ + if attempt < 0: + raise ValueError(f"attempt must be >= 0, got {attempt}") + ceiling = min(cap_s, base_s * 2.0**attempt) + delay_s = ceiling * rand() + return now + timedelta(seconds=delay_s) diff --git a/libs/svcforge_core/svcforge_core/domain/catalog.py b/libs/svcforge_core/svcforge_core/domain/catalog.py new file mode 100644 index 0000000..cd66cb7 --- /dev/null +++ b/libs/svcforge_core/svcforge_core/domain/catalog.py @@ -0,0 +1,58 @@ +"""Load and validate catalog.yaml into CatalogEntry objects. + +The only I/O in `domain/`: reading the one path handed to :func:`load_catalog`. +""" + +from pathlib import Path +from typing import Any + +import yaml +from pydantic import ValidationError + +from svcforge_core.domain.models import CatalogEntry + + +class CatalogError(Exception): + """A catalog file could not be parsed or validated. + + `key` names the offending service type, or None when the failure is file-level. + """ + + def __init__(self, message: str, *, key: str | None = None) -> None: + self.key = key + super().__init__(message if key is None else f"{key}: {message}") + + +def load_catalog(path: Path) -> dict[str, CatalogEntry]: + """Parse catalog.yaml -> {service_type: CatalogEntry}. yaml.safe_load, never yaml.load. + + Raise CatalogError (typed, with the offending key) on a bad file. + """ + try: + raw: Any = yaml.safe_load(path.read_text()) + except OSError as exc: + raise CatalogError(f"cannot read catalog at {path}: {exc}") from exc + except yaml.YAMLError as exc: + raise CatalogError(f"catalog at {path} is not valid YAML: {exc}") from exc + + if not isinstance(raw, dict): + raise CatalogError( + f"catalog at {path} must be a mapping of service_type -> entry, got {type(raw).__name__}" + ) + + services: Any = raw.get("services", raw) + if not isinstance(services, dict): + raise CatalogError(f"catalog at {path}: 'services' must be a mapping, got {type(services).__name__}") + + catalog: dict[str, CatalogEntry] = {} + for key, body in services.items(): + service_type = str(key) + if not isinstance(body, dict): + raise CatalogError(f"entry must be a mapping, got {type(body).__name__}", key=service_type) + try: + catalog[service_type] = CatalogEntry(service_type=service_type, **body) + except ValidationError as exc: + raise CatalogError(f"invalid entry: {exc}", key=service_type) from exc + except TypeError as exc: + raise CatalogError(f"invalid entry: {exc}", key=service_type) from exc + return catalog diff --git a/libs/svcforge_core/svcforge_core/domain/models.py b/libs/svcforge_core/svcforge_core/domain/models.py new file mode 100644 index 0000000..aa435b2 --- /dev/null +++ b/libs/svcforge_core/svcforge_core/domain/models.py @@ -0,0 +1,101 @@ +"""Domain models. Pydantic v2, all frozen. + +`frozen=True` is not transitive: a `dict` field (e.g. `SizeSpec.resources`) stays mutable +in place. Treat those dicts as read-only by convention. +""" + +from datetime import datetime +from enum import StrEnum +from typing import Any +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + +from svcforge_core.domain.states import InstanceState + +TEAM_PATTERN = r"^[a-z0-9-]+$" + + +class TaskKind(StrEnum): + """What a worker is being asked to do.""" + + PROVISION = "provision" + DEPROVISION = "deprovision" + UPGRADE = "upgrade" + VERIFY = "verify" + + +class TaskState(StrEnum): + """Where a task is in the claim loop.""" + + QUEUED = "queued" + RUNNING = "running" + DONE = "done" + FAILED = "failed" + + +class SizeSpec(BaseModel): + """One t-shirt size of a catalog entry.""" + + model_config = ConfigDict(frozen=True) + + replicas: int = Field(ge=1) + resources: dict[str, Any] + + +class CatalogEntry(BaseModel): + """One offerable service type and the sizes it comes in.""" + + model_config = ConfigDict(frozen=True) + + service_type: str = Field(min_length=1) + chart: str = Field(min_length=1) + chart_version: str = Field(min_length=1) + sizes: dict[str, SizeSpec] + # Bypass tenant maintenance windows for this entry's upgrades. Defaults False: a + # normal version bump waits for 03:00 Sunday; a CVE with a public exploit does not. + security: bool = False + + +class Instance(BaseModel): + """A provisioned (or in-flight) service instance owned by a team.""" + + model_config = ConfigDict(frozen=True) + + id: UUID + team: str = Field(min_length=1, pattern=TEAM_PATTERN) + service_type: str + size: str + state: InstanceState + namespace: str + release_name: str + chart_version: str + endpoint: str | None = None + error: str | None = None + expires_at: datetime | None = None + created_at: datetime + updated_at: datetime + + +class Task(BaseModel): + """A unit of work against an instance, claimed by exactly one worker at a time.""" + + model_config = ConfigDict(frozen=True) + + id: int + instance_id: UUID + kind: TaskKind + state: TaskState + attempts: int = Field(ge=0) + run_after: datetime + locked_by: str | None = None + last_error: str | None = None + + # The W3C trace context of whoever enqueued this. Null is normal, not an error: a task + # the reconciler raised on its own tick has no inbound request to belong to. + traceparent: str | None = None + + # Denormalised from `instances` by the claim query. It is here so a worker can bind + # `team` onto its log context at claim time, before it has loaded anything — the point + # of structured logs is that the FIRST line already tells you whose tenant broke. + team: str | None = None diff --git a/libs/svcforge_core/svcforge_core/domain/states.py b/libs/svcforge_core/svcforge_core/domain/states.py new file mode 100644 index 0000000..61dea7b --- /dev/null +++ b/libs/svcforge_core/svcforge_core/domain/states.py @@ -0,0 +1,37 @@ +"""The instance lifecycle state machine, encoded as data.""" + +from enum import StrEnum +from typing import Final + + +class InstanceState(StrEnum): + """Lifecycle of a provisioned service instance.""" + + REQUESTED = "requested" + PROVISIONING = "provisioning" + READY = "ready" + DELETING = "deleting" + DELETED = "deleted" + FAILED = "failed" + + +class IllegalTransition(Exception): + """Raised by transition() when cur -> nxt is not in LEGAL.""" + + +LEGAL: Final[dict[InstanceState, frozenset[InstanceState]]] = { + InstanceState.REQUESTED: frozenset({InstanceState.PROVISIONING, InstanceState.FAILED}), + InstanceState.PROVISIONING: frozenset({InstanceState.READY, InstanceState.FAILED}), + InstanceState.READY: frozenset({InstanceState.DELETING, InstanceState.FAILED}), + InstanceState.DELETING: frozenset({InstanceState.DELETED, InstanceState.FAILED}), + # `deleted` is terminal: an empty frozenset, not a missing key. + InstanceState.DELETED: frozenset(), + InstanceState.FAILED: frozenset({InstanceState.PROVISIONING, InstanceState.DELETING}), +} + + +def transition(cur: InstanceState, nxt: InstanceState) -> InstanceState: + """Return nxt if LEGAL[cur] contains it, else raise IllegalTransition. Pure. No DB.""" + if nxt not in LEGAL[cur]: + raise IllegalTransition(f"{cur} -> {nxt} is not a legal transition") + return nxt diff --git a/libs/svcforge_core/svcforge_core/domain/windows.py b/libs/svcforge_core/svcforge_core/domain/windows.py new file mode 100644 index 0000000..46ea52f --- /dev/null +++ b/libs/svcforge_core/svcforge_core/domain/windows.py @@ -0,0 +1,111 @@ +"""Maintenance windows: when a tenant will tolerate an upgrade. + +Pure domain. No I/O, no `Clock`, no `datetime.now()` anywhere in this file — `now` arrives +as a parameter and every datetime crossing this module's boundary is aware and UTC. That +is the whole discipline: `datetime.now()` is naive and lies, comparing aware to naive +raises TypeError at 03:00 on a Sunday, and mypy will not catch it for you. + +Local time is where the arithmetic has to happen, though. A window means "03:00 as the +tenant reads a clock", which is not a fixed UTC offset in any zone that observes DST. So +the cron is evaluated in the window's own zone and the result is converted back to UTC at +the door. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + +from croniter import croniter + +CRON_FIELDS = 5 +SEPARATOR = "|" + + +class BadWindow(ValueError): + """A maintenance window spec is not a 5-field cron plus a known IANA zone.""" + + +@dataclass(frozen=True) +class MaintenanceWindow: + """When a tenant's instance may be upgraded.""" + + cron: str # standard 5-field cron + tz: str # IANA name, e.g. 'Asia/Ho_Chi_Minh' + + +def parse_window(spec: str | None) -> MaintenanceWindow | None: + """'0 3 * * 0|Asia/Ho_Chi_Minh' -> MaintenanceWindow. None -> None (upgrade any time). + + Raise BadWindow on an invalid cron expression or unknown IANA zone. Validation happens + here, once, on the way in — not at 03:00 in a worker, where the failure is a task that + dies inside someone else's maintenance window. + """ + if spec is None or not spec.strip(): + return None + + cron, sep, tz = spec.partition(SEPARATOR) + if not sep: + raise BadWindow(f"window {spec!r} is not 'CRON{SEPARATOR}IANA_ZONE'") + + cron, tz = cron.strip(), tz.strip() + + # croniter's is_valid() accepts a 6-field form (with seconds). The column is documented + # as 5-field, so a 6th field is a typo, not a feature. + if len(cron.split()) != CRON_FIELDS: + raise BadWindow(f"cron {cron!r} must have exactly {CRON_FIELDS} fields") + if not croniter.is_valid(cron): + raise BadWindow(f"cron {cron!r} is not a valid cron expression") + + try: + ZoneInfo(tz) + except (ZoneInfoNotFoundError, ValueError) as exc: + # ZoneInfoNotFoundError in a slim image means tzdata is missing, not that the zone + # is fictional. Same exception, so the message names both possibilities. + raise BadWindow(f"timezone {tz!r} is not a known IANA zone (is tzdata installed?)") from exc + + return MaintenanceWindow(cron=cron, tz=tz) + + +def next_window_open(window: MaintenanceWindow | None, now: datetime) -> datetime: + """Next time the window opens, as an aware UTC datetime. + + `now` must be aware; raise ValueError if it is naive. window=None -> return now. + + Inclusive of `now`: if the window opens at this exact instant, that instant is the + answer. Otherwise an upgrade that became due precisely at 03:00 would be pushed a + whole week. + """ + if now.tzinfo is None or now.utcoffset() is None: + raise ValueError(f"now must be an aware datetime, got naive {now!r}") + + if window is None: + return now.astimezone(UTC) + + local_now = now.astimezone(ZoneInfo(window.tz)) + + # croniter.get_next() is strictly greater than its start. Backing the start off by one + # second makes a `now` that lands exactly on a cron minute return itself; a `now` at + # 03:00:30 still rolls to the next occurrence, because cron times are minute-aligned + # and 03:00:00 is already behind 03:00:29. + start = local_now - timedelta(seconds=1) + local_next: datetime = croniter(window.cron, start).get_next(datetime) + + return local_next.astimezone(UTC) + + +def schedule_upgrade_at(window: MaintenanceWindow | None, security: bool, now: datetime) -> datetime: + """run_after for an upgrade task. security=True bypasses the window -> now. + + A security fix with a public exploit does not wait until Sunday. That is the entire + reason `security:` exists in the catalog. + + `now` must be aware here too, bypass or not: the naive-datetime rule does not get a + hole punched in it by the branch that skips the window. + """ + if now.tzinfo is None or now.utcoffset() is None: + raise ValueError(f"now must be an aware datetime, got naive {now!r}") + if security: + return now.astimezone(UTC) + return next_window_open(window, now) diff --git a/libs/svcforge_core/svcforge_core/migrate.py b/libs/svcforge_core/svcforge_core/migrate.py new file mode 100644 index 0000000..bc017e4 --- /dev/null +++ b/libs/svcforge_core/svcforge_core/migrate.py @@ -0,0 +1,81 @@ +"""Migration runner. Twenty lines of psycopg, not Alembic. + +Three rules this file exists to enforce: + +1. Never migrate on app startup. N replicas would race. This runs as a Helm + pre-upgrade/pre-install hook Job, once, before any new pod serves traffic. +2. Forward-only. There are no down scripts. A mistake is fixed by a new migration. +3. Expand/contract. Add nullable, backfill, switch reads, drop the old column in a + LATER release. A rename is three deploys, never one. + +Run: python -m svcforge_core.migrate +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import psycopg + +from svcforge_core.settings import load_settings + +MIGRATIONS_DIR = Path(__file__).resolve().parents[3] / "migrations" + +# Advisory lock: two Jobs racing (a retried hook, a hand-run) must not both apply DDL. +# Session-scoped, so this needs the session pooler (5432), not pgbouncer (6543). +_LOCK_KEY = 0x5643_464F # "SVCFO" + +_BOOTSTRAP = """ +create table if not exists schema_migrations ( + filename text primary key, + applied_at timestamptz not null default now() +); +""" + + +def _pending(conn: psycopg.Connection[tuple[str, ...]], files: list[Path]) -> list[Path]: + with conn.cursor() as cur: + cur.execute("select filename from schema_migrations") + done = {row[0] for row in cur.fetchall()} + return [f for f in files if f.name not in done] + + +def main() -> int: + settings = load_settings() + files = sorted(MIGRATIONS_DIR.glob("*.sql")) + if not files: + print(f"no migrations found in {MIGRATIONS_DIR}", file=sys.stderr) + return 1 + + with psycopg.connect(settings.migration_dsn, autocommit=True) as conn: + with conn.cursor() as cur: + cur.execute("select pg_advisory_lock(%s)", (_LOCK_KEY,)) + try: + with conn.cursor() as cur: + cur.execute(_BOOTSTRAP) + + pending = _pending(conn, files) + if not pending: + print("up to date, nothing to apply") + return 0 + + for path in pending: + sql = path.read_text(encoding="utf-8") + # One transaction per file: a file applies completely or not at all. + with conn.transaction(): + with conn.cursor() as cur: + cur.execute(sql) + cur.execute( + "insert into schema_migrations (filename) values (%s)", + (path.name,), + ) + print(f"applied: {path.name}") + finally: + with conn.cursor() as cur: + cur.execute("select pg_advisory_unlock(%s)", (_LOCK_KEY,)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/libs/svcforge_core/svcforge_core/obs.py b/libs/svcforge_core/svcforge_core/obs.py new file mode 100644 index 0000000..e0b93c3 --- /dev/null +++ b/libs/svcforge_core/svcforge_core/obs.py @@ -0,0 +1,308 @@ +"""Logs, traces, metrics. One setup() call, made once, before anything else. + +Three libraries, one module, because the three are one decision. A log line without the +trace id it belongs to is a log line you cannot join to anything; a span without the +`instance_id` the request is about is a span you cannot search for. They are wired here +together so that no service can configure two of the three and ship. + +The three things that make this module worth reading: + +1. **Context does not cross a queue.** A trace is a chain of parent/child span contexts + passed in-process or over a wire header. `POST /v1/instances` inserts a row and + returns; the worker picks that row up ninety seconds later in a different pod. Nothing + carries the context across — unless we carry it ourselves. So: `inject_traceparent()` + at enqueue, a `traceparent` column, `context_from_traceparent()` at claim. Two + disconnected traces in Tempo is the symptom of skipping this. + +2. **Histogram buckets are a domain decision.** prometheus_client's defaults top out at + 10 seconds because they were chosen for HTTP handlers. A provision is `helm --wait` on + a StatefulSet: minutes. With the defaults every observation lands in `+Inf`, + `histogram_quantile` interpolates inside a bucket that spans 10s→infinity, and the p95 + it prints is a number with no relationship to reality. The buckets below are sized for + what is being measured. + +3. **One process per pod.** prometheus_client keeps its registry in process memory. Run + `uvicorn --workers 4` and Prometheus scrapes whichever of the four children the socket + happens to hand it, so counters appear to jump backwards. There are two fixes: + `PROMETHEUS_MULTIPROC_DIR` + `MultiProcessCollector` (a shared mmap directory, a + gauge-mode decision at every call site, and dead files to garbage-collect after every + crash), or one process per pod and scale with replicas. This repo takes the second. + `PROMETHEUS_MULTIPROC_DIR` is deliberately not set, and nothing here reads it. +""" + +from __future__ import annotations + +import logging +import sys +from typing import TYPE_CHECKING, Any +from uuid import UUID + +import structlog +from opentelemetry import trace +from opentelemetry.context import Context +from opentelemetry.sdk.resources import Resource +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import BatchSpanProcessor +from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator +from prometheus_client import Counter, Gauge, Histogram, start_http_server + +if TYPE_CHECKING: + from svcforge_core.settings import Settings + +# --- Metrics ---------------------------------------------------------------------------- +# +# Module level, created exactly once at import. A second registration of the same name +# against the default registry raises ValueError, which is a feature: it turns "two modules +# each defined their own copy of this counter" into an ImportError at startup instead of a +# metric that silently reports half the truth. + +TASKS_CLAIMED = Counter( + "svcforge_tasks_claimed_total", + "Tasks claimed off the queue by a worker.", + ["kind"], +) + +TASKS_FAILED = Counter( + "svcforge_tasks_failed_total", + "Tasks that exhausted their attempts and went to 'failed'.", + ["kind"], +) + +PROVISION_TIME = Histogram( + "svcforge_provision_duration_seconds", + "Wall time of a provision task, claim to terminal report.", + # NOT the defaults. See the module docstring: the defaults end at 10s and a provision + # takes minutes. The top finite bucket is 1800 because helm's own --timeout is 600 and + # a provision past thirty minutes is not slow, it is broken and belongs in +Inf. + buckets=(10, 30, 60, 120, 300, 600, 1800, float("inf")), +) + +QUEUE_DEPTH = Gauge( + "svcforge_queue_depth", + "Runnable tasks waiting in the queue. Set by the reconciler each tick.", +) + +INSTANCES = Gauge( + "svcforge_instances", + "Instances by lifecycle state. Set by the reconciler each tick.", + ["state"], +) + +RECONCILER_LAST_TICK = Gauge( + "svcforge_reconciler_last_tick_timestamp_seconds", + "Unix time of the reconciler's last completed tick. The liveness signal that matters.", +) + +# --- Wiring ----------------------------------------------------------------------------- + +_TRACER_NAME = "svcforge" + +# setup() is idempotent because it is called from three entrypoints and from tests, and +# because configuring structlog twice silently discards the first configuration while +# adding a second stdout handler to the root logger — every line then prints twice. +_configured = False + +_propagator = TraceContextTextMapPropagator() + + +def _add_trace_ids( + _logger: Any, # noqa: ANN401 - structlog's Processor signature; the logger is untyped + _method: str, + event_dict: structlog.typing.EventDict, +) -> structlog.typing.EventDict: + """Stamp the active trace/span id onto the line, if there is one. + + This is the join key. Without it, "find the logs for this trace" is a full-text search + over a time window and a guess; with it, it is one query. Hex-formatted to the widths + the W3C spec uses, so the value pasted from Tempo matches the value in Loki. + """ + span = trace.get_current_span() + ctx = span.get_span_context() + if ctx.is_valid: + event_dict["trace_id"] = format(ctx.trace_id, "032x") + event_dict["span_id"] = format(ctx.span_id, "016x") + return event_dict + + +def setup(service_name: str, settings: Settings) -> None: + """Configure structlog, the tracer provider, and the metric registry. Idempotent. + + Called once from each service's entrypoint, before anything else — "before anything + else" because any logger bound before this runs keeps the default configuration + (`cache_logger_on_first_use`), and a module-level `log = structlog.get_logger()` in an + import that lands first will print unstructured text forever. + """ + global _configured # process-wide config is process-wide state + if _configured: + return + + _setup_logging(service_name, settings) + _setup_tracing(service_name, settings) + _configured = True + + +def _setup_logging(service_name: str, settings: Settings) -> None: + """structlog + stdlib logging, both rendering JSON to stdout through one handler. + + The stdlib half is not optional. `psycopg`, `httpx`, `uvicorn` and the OTEL SDK all log + through `logging`; without the ProcessorFormatter bridge below, their lines arrive as + bare text on the same stdout and every one of them is a parse failure in the collector. + """ + level = getattr(logging, settings.log_level.upper(), logging.INFO) + + shared: list[structlog.typing.Processor] = [ + # FIRST. This is what puts instance_id/task_id/team on every line, including the + # lines written by code that has never heard of them. + structlog.contextvars.merge_contextvars, + structlog.stdlib.add_log_level, + structlog.stdlib.add_logger_name, + structlog.processors.TimeStamper(fmt="iso", utc=True), + _add_trace_ids, + structlog.processors.StackInfoRenderer(), + structlog.processors.format_exc_info, + ] + + structlog.configure( + processors=[ + *shared, + # Hands off to the stdlib formatter below, which appends the renderer. This is + # what lets one handler render both structlog and foreign logs identically. + structlog.stdlib.ProcessorFormatter.wrap_for_formatter, + ], + logger_factory=structlog.stdlib.LoggerFactory(), + wrapper_class=structlog.stdlib.BoundLogger, + cache_logger_on_first_use=True, + ) + + # The renderer goes LAST and nothing follows it: it turns the event dict into a string, + # so any processor after it receives a str where it expects a dict and raises. + renderer: structlog.typing.Processor = ( + structlog.processors.JSONRenderer() if settings.log_json else structlog.dev.ConsoleRenderer() + ) + formatter = structlog.stdlib.ProcessorFormatter( + foreign_pre_chain=shared, # applied to records from logging.getLogger(...) callers + processors=[structlog.stdlib.ProcessorFormatter.remove_processors_meta, renderer], + ) + + handler = logging.StreamHandler(sys.stdout) + handler.setFormatter(formatter) + + root = logging.getLogger() + # Replace rather than append: basicConfig may already have run, and two handlers means + # two copies of every line. stdout only — a container writes logs to stdout and the + # collector tails them from there. A log file inside a pod is deleted with the pod. + root.handlers = [handler] + root.setLevel(level) + + structlog.contextvars.bind_contextvars(service=service_name) + + +def _setup_tracing(service_name: str, settings: Settings) -> None: + """Set the global tracer provider, exporting over OTLP when an endpoint is configured. + + Skipped entirely when something already set a provider: the API runs under + `opentelemetry-instrument`, whose auto-instrumentation installs one before our + `main()` is reached. Overwriting it drops the FastAPI and psycopg instrumentation's + spans on the floor, and the SDK only logs a warning about it. + """ + if isinstance(trace.get_tracer_provider(), TracerProvider): + return + + provider = TracerProvider(resource=Resource.create({"service.name": service_name})) + + if settings.otel_endpoint: + exporter = _otlp_exporter(settings.otel_endpoint) + if exporter is not None: + # Batch, not Simple: SimpleSpanProcessor exports inline on span end, so every + # helm span would block on a network round trip to the collector. + provider.add_span_processor(BatchSpanProcessor(exporter)) + + trace.set_tracer_provider(provider) + + +def _otlp_exporter(endpoint: str) -> Any | None: # noqa: ANN401 - one of two exporter classes + """The OTLP exporter, if the optional exporter package is installed. + + Optional on purpose. In the cluster the API runs under `opentelemetry-instrument`, + which brings its own exporter and configures it from `OTEL_EXPORTER_OTLP_*`. Making it + a hard dependency of the shared library would mean every unit test imports gRPC. + """ + try: + from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter + except ImportError: + logging.getLogger(__name__).warning( + "otel_endpoint is set but no OTLP exporter is installed; traces stay in-process", + extra={"endpoint": endpoint}, + ) + return None + return OTLPSpanExporter(endpoint=endpoint) + + +def get_logger(name: str) -> structlog.stdlib.BoundLogger: + """A bound logger. Call it inside a function, not at import time — see setup().""" + logger: structlog.stdlib.BoundLogger = structlog.get_logger(name) + return logger + + +def tracer() -> trace.Tracer: + """The svcforge tracer. Manual spans wrap helm calls, and nothing else. + + Everything else is auto-instrumented (FastAPI, psycopg). A hand-rolled span around a + function that the SDK already wraps is a duplicated span and a maintenance cost. + """ + return trace.get_tracer(_TRACER_NAME) + + +def start_metrics_server(port: int) -> None: + """Expose /metrics on `port`, for the services with no HTTP server of their own. + + The worker and reconciler have no Service; the chart's PodMonitor scrapes them by pod + on the port named `metrics`. The API does not use this — it mounts the same registry on + its own ASGI app. + """ + start_http_server(port) + + +# --- Context that has to cross a process boundary --------------------------------------- + + +def bind_task_context(instance_id: UUID, task_id: int, team: str) -> None: + """Bind the three keys every log line in a task must carry. Called at claim time. + + `clear_contextvars()` first, and this is the whole reason the function exists rather + than three `bind_contextvars` calls at the call site. A worker coroutine reuses its + context across loop iterations; without the clear, task 41's `instance_id` is still + bound when task 42 starts logging, and the log for the incident you are debugging + names the wrong tenant. Contextvars are per-task in asyncio, which makes this safe + under the concurrency semaphore: two handlers running at once do not see each other's. + """ + structlog.contextvars.clear_contextvars() + structlog.contextvars.bind_contextvars( + instance_id=str(instance_id), + task_id=task_id, + team=team, + ) + + +def inject_traceparent() -> str | None: + """Serialise the active span context to a W3C traceparent, for the tasks row. + + None when there is no recording span — a task enqueued by the reconciler's own tick has + no inbound request to be part of. Nullable column, nullable return: an untraced task is + normal, not an error. + """ + carrier: dict[str, str] = {} + _propagator.inject(carrier) + return carrier.get("traceparent") + + +def context_from_traceparent(traceparent: str | None) -> Context: + """Inverse of inject_traceparent. Used at claim to parent the worker span to the API's. + + An empty Context for None or for a malformed value — `extract` does not raise on a + traceparent that fails to parse, it returns the carrier's context unchanged, and the + resulting span starts a new trace. A bad header must never fail a provision. + """ + if not traceparent: + return Context() + return _propagator.extract({"traceparent": traceparent}) diff --git a/libs/svcforge_core/svcforge_core/py.typed b/libs/svcforge_core/svcforge_core/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/libs/svcforge_core/svcforge_core/repo/__init__.py b/libs/svcforge_core/svcforge_core/repo/__init__.py new file mode 100644 index 0000000..5134462 --- /dev/null +++ b/libs/svcforge_core/svcforge_core/repo/__init__.py @@ -0,0 +1 @@ +"""SQL. Rows in, domain objects out. Knows psycopg; knows nothing about HTTP.""" diff --git a/libs/svcforge_core/svcforge_core/repo/db.py b/libs/svcforge_core/svcforge_core/repo/db.py new file mode 100644 index 0000000..63df47e --- /dev/null +++ b/libs/svcforge_core/svcforge_core/repo/db.py @@ -0,0 +1,57 @@ +"""The connection pool. + +Two settings here are load-bearing on Supabase's transaction pooler (port 6543) and +cost hours if you get them wrong. Both are documented at the call site below. +""" + +from __future__ import annotations + +from typing import Any + +from psycopg import AsyncConnection +from psycopg.rows import dict_row +from psycopg_pool import AsyncConnectionPool + +# The pool hands out dict-row connections because of `row_factory=dict_row` below. Say so +# in the type system too, or every `row["attempts"]` in this codebase is a mypy error +# against a bare `AsyncConnectionPool`, which resolves to tuple rows. The runtime was +# always right; without these aliases the annotations quietly disagree with it, and the +# fix people reach for is `# type: ignore`, which throws away the checking entirely. +type DictRow = dict[str, Any] +type DictConnection = AsyncConnection[DictRow] +type DictPool = AsyncConnectionPool[DictConnection] + + +def make_pool(dsn: str, min_size: int = 1, max_size: int = 5) -> DictPool: + """Construct the pool. Does NOT open it — the caller owns open/close. + + `open=False` is deliberate: the constructor does zero I/O, so building a pool at + import time and never opening it fails later as a PoolTimeout at first use, far + from the cause. The caller (a FastAPI lifespan, a worker main) opens and closes it. + + kwargs are per-connection: + + * `prepare_threshold=None` — REQUIRED through pgbouncer in transaction mode. + psycopg3 auto-prepares a statement after it sees it 5 times. pgbouncer may hand + the next execution to a different backend, which has never heard of that prepared + statement. Symptom: everything works for exactly five calls, then + `prepared statement "_pg3_0" does not exist` — intermittent, only under + concurrency, never in a unit test. + + * `row_factory=dict_row` — rows arrive as dicts, so `Instance.model_validate(row)` + works directly instead of unpacking tuples by position. + + Also gone on 6543: LISTEN/NOTIFY, session-level SET, cross-statement advisory locks. + `SELECT ... FOR UPDATE SKIP LOCKED` inside one transaction is unaffected — which is + exactly why the queue is built on it. Use the session pooler (5432) for migrations. + + max_size is a database-capacity decision, not a throughput knob: the free tier has a + small connection budget, and replicas multiply this number. + """ + return AsyncConnectionPool( + conninfo=dsn, + min_size=min_size, + max_size=max_size, + open=False, + kwargs={"prepare_threshold": None, "row_factory": dict_row, "autocommit": False}, + ) diff --git a/libs/svcforge_core/svcforge_core/repo/instances.py b/libs/svcforge_core/svcforge_core/repo/instances.py new file mode 100644 index 0000000..625273f --- /dev/null +++ b/libs/svcforge_core/svcforge_core/repo/instances.py @@ -0,0 +1,192 @@ +"""Instance persistence. + +AuthZ lives in the WHERE clause. `get(id, team)` filters by team in SQL rather than +fetching the row and comparing in Python: a wrong-team id must be indistinguishable +from a nonexistent one, and a check you can forget to write is a check you will forget +to write. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass +from typing import Any +from uuid import UUID + +from psycopg import AsyncConnection +from psycopg.rows import dict_row + +from svcforge_core.domain.models import Instance +from svcforge_core.domain.states import InstanceState +from svcforge_core.repo.db import DictPool + +_COLUMNS = """id, team, service_type, size, state, namespace, release_name, chart_version, + endpoint, error, expires_at, created_at, updated_at""" + + +@dataclass(frozen=True) +class UpgradeCandidate: + """One row of the day-2 work list: an instance, plus the raw window spec to schedule in. + + `maintenance_window` rides alongside rather than inside `Instance` because `create()` + never writes it — folding it into the model would mean every freshly created Instance + reports `maintenance_window=None` whether or not the row has one. A column only the + work list reads is a column only the work list carries. + """ + + instance: Instance + maintenance_window: str | None + + +class InstanceRepo: + """Reads and writes `instances`.""" + + def __init__(self, pool: DictPool) -> None: + self._pool = pool + + async def create(self, conn: AsyncConnection[dict[str, Any]], inst: Instance) -> Instance: + """Insert one instance. + + Takes a `conn` rather than using the pool so the caller can share a transaction + with TaskRepo.enqueue — the instance row and its provision task must commit + together or not at all. That single fact is why the queue is in Postgres. + """ + async with conn.cursor() as cur: + await cur.execute( + f"""insert into instances (id, team, service_type, size, state, namespace, + release_name, chart_version, endpoint, error, expires_at) + values (%(id)s, %(team)s, %(service_type)s, %(size)s, %(state)s, %(namespace)s, + %(release_name)s, %(chart_version)s, %(endpoint)s, %(error)s, %(expires_at)s) + returning {_COLUMNS}""", # noqa: S608 - _COLUMNS is a module constant, not input + { + "id": inst.id, + "team": inst.team, + "service_type": inst.service_type, + "size": inst.size, + "state": inst.state.value, + "namespace": inst.namespace, + "release_name": inst.release_name, + "chart_version": inst.chart_version, + "endpoint": inst.endpoint, + "error": inst.error, + "expires_at": inst.expires_at, + }, + ) + row = await cur.fetchone() + assert row is not None # noqa: S101 - `returning` always yields a row or raises + return Instance.model_validate(row) + + async def get(self, id: UUID, team: str) -> Instance | None: + """Fetch one instance owned by `team`. None if it does not exist OR is not theirs.""" + async with self._pool.connection() as conn, conn.cursor() as cur: + await cur.execute( + f"select {_COLUMNS} from instances where id = %s and team = %s", # noqa: S608 + (id, team), + ) + row = await cur.fetchone() + return Instance.model_validate(row) if row else None + + async def list(self, team: str, limit: int = 50) -> list[Instance]: + """The team's instances, newest first.""" + async with self._pool.connection() as conn, conn.cursor() as cur: + await cur.execute( + f"""select {_COLUMNS} from instances + where team = %s order by created_at desc limit %s""", # noqa: S608 + (team, limit), + ) + rows = await cur.fetchall() + return [Instance.model_validate(r) for r in rows] + + async def update_state( + self, + id: UUID, + expect: InstanceState, + to: InstanceState, + error: str | None = None, + endpoint: str | None = None, + ) -> bool: + """Compare-and-set. False if the row moved under you. + + `where id=%s and state=%s` is the whole trick: two workers racing to move the + same instance means exactly one UPDATE matches a row. The loser gets False and + must not treat it as an error — it means someone else already did the work. + """ + async with self._pool.connection() as conn, conn.cursor() as cur: + await cur.execute( + """update instances + set state = %(to)s, + error = %(error)s, + endpoint = coalesce(%(endpoint)s, endpoint), + updated_at = now() + where id = %(id)s and state = %(expect)s""", + { + "id": id, + "expect": expect.value, + "to": to.value, + "error": error, + "endpoint": endpoint, + }, + ) + return cur.rowcount == 1 + + async def set_error(self, id: UUID, error: str) -> None: + """Record a terminal failure message. Used when a task exhausts its attempts.""" + async with self._pool.connection() as conn, conn.cursor() as cur: + await cur.execute( + "update instances set error = %s, state = %s, updated_at = now() where id = %s", + (error[-2000:], InstanceState.FAILED.value, id), + ) + + async def list_upgradable( + self, + service_type: str, + catalog_version: str, + own_team: str, + max_in_flight: int = 1, + ) -> Sequence[UpgradeCandidate]: + """The day-2 work list: `ready` instances not yet on the catalog's pinned version. + + The whole rollout is this query. Three clauses carry it: + + `not exists (... rollout_state = 'halted')` — a failed `verify` writes one column + and this query goes empty for that service type. That is the stop button: no + rollouts table, no state machine, no pause/resume CLI. You clear it with SQL. + + `order by team = %(own_team)s desc` — your own instances upgrade first, so you are + the tenant who finds out the chart is broken. Eating your own dog food is a + `order by`, not a policy document. + + `limit %(max_in_flight)s` — a config value, not a scheduler. It stays at 1 until 1 + is too slow, and 1 is what makes the halt meaningful: the fleet stops after the + first casualty rather than after all of them. + """ + # `row_factory=dict_row` is already the pool's per-connection default; naming it + # here is for mypy, which cannot see a row factory passed through a kwargs dict + # and would otherwise type these rows as tuples. + async with self._pool.connection() as conn, conn.cursor(row_factory=dict_row) as cur: + await cur.execute( + f"""select {_COLUMNS}, maintenance_window from instances + where state = 'ready' + and service_type = %(service_type)s + and chart_version <> %(catalog_version)s + and not exists ( + select 1 from catalog_versions cv + where cv.service_type = %(service_type)s + and cv.rollout_state = 'halted') + order by team = %(own_team)s desc, created_at + limit %(max_in_flight)s""", # noqa: S608 - _COLUMNS is a module constant, not input + { + "service_type": service_type, + "catalog_version": catalog_version, + "own_team": own_team, + "max_in_flight": max_in_flight, + }, + ) + rows = await cur.fetchall() + return [ + UpgradeCandidate( + instance=Instance.model_validate(r), + maintenance_window=r["maintenance_window"], + ) + for r in rows + ] diff --git a/libs/svcforge_core/svcforge_core/repo/reconcile.py b/libs/svcforge_core/svcforge_core/repo/reconcile.py new file mode 100644 index 0000000..de3c0c1 --- /dev/null +++ b/libs/svcforge_core/svcforge_core/repo/reconcile.py @@ -0,0 +1,276 @@ +"""The reconciler's SQL. + +Why this file exists rather than the queries living in `services/reconciler/main.py`: the +layer rule says transport knows nothing about SQL, and the reconciler is transport — a CLI +entrypoint. It gets its own repo module rather than growing `InstanceRepo` and `TaskRepo` +because everything here is a *sweep*: it reads rows nobody asked about and it writes an +instance state and a task row in the same transaction. `InstanceRepo.update_state` owns its +own connection by design, so the reconciler cannot get atomicity from it without reaching +around the repo — which is the thing the layer rule exists to prevent. + +The recurring shape below is: lock the row, re-check the condition under the lock, act. +The re-check is not paranoia about concurrency — the reconciler is a singleton. It is what +makes the sweep idempotent against *itself*: a tick that crashes after the insert and +before the commit must leave nothing behind, and the next tick must not double-enqueue. +""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any +from uuid import UUID + +from psycopg import AsyncCursor + +from svcforge_core.domain.models import Instance, TaskKind, TaskState +from svcforge_core.domain.states import InstanceState, transition +from svcforge_core.obs import inject_traceparent +from svcforge_core.repo.db import DictPool + +_COLUMNS = """id, team, service_type, size, state, namespace, release_name, chart_version, + endpoint, error, expires_at, created_at, updated_at""" + +# A task nobody will ever run again. The idempotency guard on every enqueue below asks +# "is one already outstanding?", and 'done'/'failed' are not outstanding: a failed +# deprovision that exhausted its attempts must be re-enqueueable by the next sweep, or a +# transient cluster outage would permanently strand the instance. +_UNFINISHED = (TaskState.QUEUED.value, TaskState.RUNNING.value) + + +class ReconcileRepo: + """Fleet-wide sweeps. Read-mostly, and every write is one transaction.""" + + def __init__(self, pool: DictPool) -> None: + self._pool = pool + + # --- Gauges ------------------------------------------------------------------------- + + async def queue_depth(self) -> int: + """Tasks waiting to be claimed. + + Counts every `queued` row, not just the runnable ones (`run_after <= now()`). The + alert on this gauge is `deriv(...) > 0` — "the backlog is growing" — and a backlog + of tasks parked on backoff is exactly the backlog you want to see growing. + """ + async with self._pool.connection() as conn, conn.cursor() as cur: + await cur.execute("select count(*) as n from tasks where state = %s", (TaskState.QUEUED.value,)) + row = await cur.fetchone() + return int(row["n"]) if row else 0 + + async def instance_counts(self) -> dict[str, int]: + """Instances per lifecycle state. States with no rows are absent, not zero.""" + async with self._pool.connection() as conn, conn.cursor() as cur: + await cur.execute("select state, count(*) as n from instances group by state") + rows = await cur.fetchall() + return {str(r["state"]): int(r["n"]) for r in rows} + + # --- Drift -------------------------------------------------------------------------- + + async def ready_instances(self) -> list[Instance]: + """Every instance the DB believes is running. The drift check's expectation.""" + async with self._pool.connection() as conn, conn.cursor() as cur: + await cur.execute( + f"select {_COLUMNS} from instances where state = %s", # noqa: S608 - module constant + (InstanceState.READY.value,), + ) + rows = await cur.fetchall() + return [Instance.model_validate(r) for r in rows] + + async def known_releases(self) -> set[tuple[str, str]]: + """(release_name, namespace) for every instance row, in any state. + + Any state, deliberately. An instance that is still `requested` has no release yet, + but a worker may be installing it *right now* — treating it as unknown would + report a healthy in-flight provision as an orphan on every tick. + """ + async with self._pool.connection() as conn, conn.cursor() as cur: + await cur.execute("select release_name, namespace from instances") + rows = await cur.fetchall() + return {(str(r["release_name"]), str(r["namespace"])) for r in rows} + + async def enqueue_reprovision(self, instance_id: UUID, reason: str) -> int | None: + """`ready` instance whose release vanished -> back to `provisioning`, with a task. + + None if the row moved, or if a provision is already outstanding. + + The two-hop state change is the interesting part, and it is not busywork: + + * `LEGAL` has no `ready -> provisioning` edge. The tenant-visible lifecycle only + leaves `ready` through `deleting` or `failed`, and drift is a failure — the + service the tenant is paying for is gone. So: `ready -> failed -> provisioning`, + both edges legal, asserted below by the domain function rather than assumed. + * The row must land in `provisioning`, not `failed`, before the worker sees the + task. `handle_provision` CASes `requested -> provisioning` best-effort and then + CASes `provisioning -> ready` for real; hand it a `failed` row and helm runs, the + final CAS matches nothing, and the instance sits in `failed` forever with a + healthy release behind it. + + Both hops and the insert are one transaction, so the row is never observable in the + intermediate `failed` state and a crash mid-sweep leaves nothing half-done. + """ + async with self._pool.connection() as conn: + async with conn.transaction(), conn.cursor() as cur: + await cur.execute( + "select state from instances where id = %s and state = %s for update", + (instance_id, InstanceState.READY.value), + ) + if await cur.fetchone() is None: + return None + if await _has_unfinished(cur, instance_id, TaskKind.PROVISION): + return None + + # Assert the path through the state machine instead of trusting the SQL. + # If someone edits LEGAL, this raises here rather than corrupting rows. + failed = transition(InstanceState.READY, InstanceState.FAILED) + provisioning = transition(failed, InstanceState.PROVISIONING) + + await cur.execute( + "update instances set state = %s, error = %s, updated_at = now() where id = %s", + (provisioning.value, reason[-2000:], instance_id), + ) + return await _insert_task(cur, instance_id, TaskKind.PROVISION) + + # --- TTL ---------------------------------------------------------------------------- + + async def due_for_deprovision(self) -> list[Instance]: + """Instances that should be torn down and have no deprovision task outstanding. + + Two populations, one query: + + * `ready` and past `expires_at` — the TTL sweep proper. The whole reason a + throwaway Elasticsearch does not become a permanent line on the cloud bill. + * `deleting` with nothing to do the deleting — the API CASes to `deleting` and then + enqueues in a second statement, and a crash between the two leaves exactly this. + That ordering is chosen *because* this sweep exists; the other order would leave + a deprovision task pointing at a `ready` instance, and a worker would tear down a + live service nobody asked to delete. + + Note the parentheses around the OR. Without them, `and not exists (...)` binds to + the second branch alone and the query re-enqueues a deprovision for every deleting + instance on every tick, forever. + """ + async with self._pool.connection() as conn, conn.cursor() as cur: + await cur.execute( + f"""select {_COLUMNS} from instances i + where ((i.state = %(ready)s and i.expires_at < now()) or i.state = %(deleting)s) + and not exists ( + select 1 from tasks t + where t.instance_id = i.id + and t.kind = %(kind)s + and t.state = any(%(unfinished)s)) + order by i.created_at""", # noqa: S608 - module constant + { + "ready": InstanceState.READY.value, + "deleting": InstanceState.DELETING.value, + "kind": TaskKind.DEPROVISION.value, + "unfinished": list(_UNFINISHED), + }, + ) + rows = await cur.fetchall() + return [Instance.model_validate(r) for r in rows] + + async def enqueue_deprovision(self, instance_id: UUID) -> int | None: + """CAS to `deleting` if needed, and enqueue the task. One transaction. None if moot. + + The instance must be in `deleting` before the worker claims the task, for the same + reason as `enqueue_reprovision`: `handle_deprovision` finishes with a + `deleting -> deleted` CAS, and a `ready` row would make helm uninstall the release + and the DB keep advertising an endpoint that no longer resolves. + """ + async with self._pool.connection() as conn: + async with conn.transaction(), conn.cursor() as cur: + await cur.execute( + """select state from instances + where id = %(id)s + and ((state = %(ready)s and expires_at < now()) or state = %(deleting)s) + for update""", + { + "id": instance_id, + "ready": InstanceState.READY.value, + "deleting": InstanceState.DELETING.value, + }, + ) + row = await cur.fetchone() + if row is None: + return None + if await _has_unfinished(cur, instance_id, TaskKind.DEPROVISION): + return None + + if row["state"] == InstanceState.READY.value: + deleting = transition(InstanceState.READY, InstanceState.DELETING) + await cur.execute( + "update instances set state = %s, updated_at = now() where id = %s", + (deleting.value, instance_id), + ) + return await _insert_task(cur, instance_id, TaskKind.DEPROVISION) + + # --- Version drift ------------------------------------------------------------------ + + async def enqueue_upgrade(self, instance_id: UUID, run_after: datetime) -> int | None: + """Enqueue an upgrade unless one is already outstanding. None if it is. + + The guard is what keeps the fleet at `max_in_flight`. The work list is a query over + `chart_version`, and that column is only written *after* helm reports success — so + an instance stays on the work list for the entire duration of its own upgrade, and + for the hours it spends parked waiting for its 03:00 window. Without this check the + sweep enqueues one more upgrade for the same instance every 60 seconds, and + `max_in_flight=1` becomes sixty tasks an hour against one release. + + `verify` counts as outstanding too: an upgrade whose verify has not reported is an + upgrade still in progress, and re-enqueueing it would race the probe that decides + whether the whole rollout halts. + """ + async with self._pool.connection() as conn: + async with conn.transaction(), conn.cursor() as cur: + if await _has_unfinished(cur, instance_id, TaskKind.UPGRADE, TaskKind.VERIFY): + return None + return await _insert_task(cur, instance_id, TaskKind.UPGRADE, run_after) + + +async def _has_unfinished( + cur: AsyncCursor[dict[str, Any]], + instance_id: UUID, + *kinds: TaskKind, +) -> bool: + """Is a task of any of these kinds queued or running for this instance? + + Takes the caller's cursor on purpose: the answer is only true for as long as the + transaction that asked, and checking on a separate connection would be a check against + a different snapshot than the insert that follows it. + """ + await cur.execute( + """select 1 from tasks + where instance_id = %(id)s and kind = any(%(kinds)s) and state = any(%(unfinished)s) + limit 1""", + { + "id": instance_id, + "kinds": [k.value for k in kinds], + "unfinished": list(_UNFINISHED), + }, + ) + return await cur.fetchone() is not None + + +async def _insert_task( + cur: AsyncCursor[dict[str, Any]], + instance_id: UUID, + kind: TaskKind, + run_after: datetime | None = None, +) -> int: + """Insert one task in the caller's transaction, carrying the current trace context. + + `traceparent` is written here rather than left to `TaskRepo.enqueue` because these rows + are inserted inside a transaction the reconciler owns. Nothing propagates a trace + through a table on its own — see `obs.inject_traceparent`. It is null when the sweep is + not itself inside a span, which is fine and expected: a nullable column for an untraced + task. + """ + await cur.execute( + """insert into tasks (instance_id, kind, run_after, traceparent) + values (%s, %s, coalesce(%s, now()), %s) + returning id""", + (instance_id, kind.value, run_after, inject_traceparent()), + ) + row = await cur.fetchone() + assert row is not None # noqa: S101 - `returning` always yields a row or raises + return int(row["id"]) diff --git a/libs/svcforge_core/svcforge_core/repo/tasks.py b/libs/svcforge_core/svcforge_core/repo/tasks.py new file mode 100644 index 0000000..f461a9e --- /dev/null +++ b/libs/svcforge_core/svcforge_core/repo/tasks.py @@ -0,0 +1,219 @@ +"""The queue. + +The queue is a Postgres table, not Redis. The reason is one sentence: a task and the +instance state it describes must commit atomically. Split them across two stores and you +own a distributed commit problem that has no winning move — the process can die between +the two writes, and whichever you write first is the one that lies. + +Everything else here follows from that. +""" + +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Any, Final +from uuid import UUID + +from psycopg import AsyncConnection + +from svcforge_core.domain.backoff import next_attempt_at +from svcforge_core.domain.models import Task, TaskKind +from svcforge_core.domain.states import LEGAL, InstanceState +from svcforge_core.obs import inject_traceparent +from svcforge_core.repo.db import DictPool + +# Which states may legally become `failed`, derived from the domain's own table rather +# than restated here. Without this guard the UPDATE below would happily move a `deleted` +# instance to `failed` — a transition domain.transition() explicitly forbids, performed +# by raw SQL that never asks it. The state machine has to be the same one everywhere, or +# it is decoration. +_CAN_FAIL: Final[tuple[str, ...]] = tuple( + state.value for state, allowed in LEGAL.items() if InstanceState.FAILED in allowed +) + +# The claim query. Do not "simplify" this into two statements. +# +# Postgres has no `UPDATE ... LIMIT`, so the row is chosen by a subquery. That subquery +# takes a row lock (`for update`) and steps over rows other workers already hold +# (`skip locked`) instead of blocking behind them — which is what makes N workers scale +# instead of queueing single-file behind the oldest task. +# +# The whole thing is ONE statement on purpose. Select-then-update as two statements +# leaves a gap in which a second worker reads the same id, and both provision. The gap +# is small, which means you will not hit it in testing and will hit it in production. +# +# The `with claimed as (...)` wrapper is the ONLY addition to the canonical form, and it +# changes nothing about the locking: the UPDATE and its `for update skip locked` subquery +# are still one statement, executed once. The outer SELECT only joins `instances.team` +# onto the row that was already claimed, so the worker can bind `team` to its log context +# before it has loaded anything. A data-modifying CTE runs exactly once and cannot claim +# twice. The alternative — a second SELECT for the team — would be a second round trip per +# task to fetch a column the database already had in hand. +_CLAIM_SQL = """ +with claimed as ( + update tasks set state='running', attempts=attempts+1, locked_by=%(worker)s, locked_at=now() + where id = ( + select id from tasks + where state='queued' and run_after <= now() + order by run_after + for update skip locked + limit 1 + ) + returning * +) +select claimed.*, instances.team + from claimed join instances on instances.id = claimed.instance_id; +""" + + +class TaskRepo: + """Reads and writes `tasks`. Claiming is the only interesting part.""" + + def __init__(self, pool: DictPool) -> None: + self._pool = pool + + async def enqueue( + self, + conn: AsyncConnection[dict[str, Any]], + instance_id: UUID, + kind: TaskKind, + run_after: datetime | None = None, + ) -> Task: + """Insert a task inside the CALLER's transaction. + + Takes `conn` so the API can insert the instance and enqueue its provision task in + one transaction. Rolling back must lose both, or you get an orphan task pointing + at an instance that was never committed. + + The `traceparent` is captured here, at enqueue time, because this is the last + moment the caller's span context still exists. Trace context does NOT survive a + queue on its own: the worker picks the row up in a different process, minutes + later, with no ambient context. Writing the W3C traceparent onto the row is the + thread that lets the worker re-parent its span to the POST that caused it — the + difference between one trace spanning API → queue → helm and two unrelated ones. + """ + async with conn.cursor() as cur: + await cur.execute( + """insert into tasks (instance_id, kind, run_after, traceparent) + values (%s, %s, coalesce(%s, now()), %s) + returning id, instance_id, kind, state, attempts, run_after, locked_by, last_error""", + (instance_id, kind.value, run_after, inject_traceparent()), + ) + row = await cur.fetchone() + assert row is not None # noqa: S101 - `returning` always yields a row or raises + return Task.model_validate(row) + + async def enqueue_standalone( + self, + instance_id: UUID, + kind: TaskKind, + run_after: datetime | None = None, + ) -> int: + """Enqueue in its own transaction, returning the new task id. + + For callers with nothing to commit alongside it — the reconciler, tests. The + module specs disagree about enqueue's shape (Module 2 passes a conn, Module 4 + does not); rather than making `conn` optional and quietly hiding the transaction + question, both callers get an honest method name. + """ + async with self._pool.connection() as conn: + task = await self.enqueue(conn, instance_id, kind, run_after) + return task.id + + async def claim(self, worker_id: str) -> Task | None: + """Claim one runnable task, or None if nothing is runnable. + + `attempts` increments HERE, at claim time, not on failure. A worker that dies + mid-task without reporting anything has still burned an attempt, so a task that + reliably kills its worker cannot retry forever. + """ + async with self._pool.connection() as conn, conn.cursor() as cur: + await cur.execute(_CLAIM_SQL, {"worker": worker_id}) + row = await cur.fetchone() + return Task.model_validate(row) if row else None + + async def complete( + self, + task_id: int, + conn: AsyncConnection[dict[str, Any]] | None = None, + ) -> None: + """Mark done. Pass `conn` to commit alongside the caller's instance update. + + Completing the task and recording what it accomplished belong in one transaction: + commit them separately and a crash in between leaves a task marked done whose + work never landed, or work that landed and will be redone. + """ + sql = "update tasks set state='done', locked_by=null where id = %s" + if conn is not None: + async with conn.cursor() as cur: + await cur.execute(sql, (task_id,)) + return + async with self._pool.connection() as own, own.cursor() as cur: + await cur.execute(sql, (task_id,)) + + async def fail(self, task_id: int, err: str, max_attempts: int = 5) -> None: + """Retry with backoff, or give up. + + Under max_attempts: back to 'queued' with run_after pushed out by exponential + backoff with full jitter. Jitter matters — a cluster-wide outage fails every task + at once, and without it every worker retries in the same instant, forever. + + At max_attempts: 'failed', and the error is copied onto the instance so the tenant + can see it. A dead-letter state, not an infinite retry: a task that cannot succeed + must stop and become someone's problem. + """ + now = datetime.now(UTC) + async with self._pool.connection() as conn: + async with conn.transaction(), conn.cursor() as cur: + await cur.execute( + "select attempts, instance_id from tasks where id = %s for update", + (task_id,), + ) + row = await cur.fetchone() + if row is None: + return + attempts = int(row["attempts"]) + instance_id = row["instance_id"] + + if attempts < max_attempts: + await cur.execute( + """update tasks + set state='queued', locked_by=null, locked_at=null, + last_error=%s, run_after=%s + where id = %s""", + (err[-2000:], next_attempt_at(attempts - 1, now=now), task_id), + ) + return + + await cur.execute( + """update tasks + set state='failed', locked_by=null, locked_at=null, last_error=%s + where id = %s""", + (err[-2000:], task_id), + ) + # `state = any(%s)` keeps this honest: a deprovision that exhausts its + # retries against an already-deleted instance records nothing rather than + # resurrecting it into `failed`. + await cur.execute( + """update instances set error=%s, state=%s, updated_at=now() + where id=%s and state = any(%s)""", + (err[-2000:], InstanceState.FAILED.value, instance_id, list(_CAN_FAIL)), + ) + + async def reset_expired_leases(self, lease_seconds: int) -> int: + """Return tasks whose worker died back to the queue. Called by the reconciler. + + No distributed lock survives a power cut. A worker that is SIGKILLed leaves + `state='running'` and `locked_by` set with nobody running it, and that row would + sit there forever. The lease is the only thing that recovers it: not a lock, a + timeout. This is why `locked_at` exists. + """ + async with self._pool.connection() as conn, conn.cursor() as cur: + await cur.execute( + """update tasks + set state='queued', locked_by=null, locked_at=null + where state='running' + and locked_at < now() - make_interval(secs => %s)""", + (lease_seconds,), + ) + return cur.rowcount diff --git a/libs/svcforge_core/svcforge_core/settings.py b/libs/svcforge_core/svcforge_core/settings.py new file mode 100644 index 0000000..7c6f404 --- /dev/null +++ b/libs/svcforge_core/svcforge_core/settings.py @@ -0,0 +1,91 @@ +"""Typed configuration. Environment in, validated object out, fails fast at startup. + +The whole point: a missing or malformed DSN kills the process on line one with a readable +error, instead of surfacing as a PoolTimeout twenty minutes into a provision. +""" + +from __future__ import annotations + +from pathlib import Path + +from pydantic import Field, PostgresDsn, RedisDsn +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + """Every knob svcforge has. Read once, at startup, and passed down explicitly.""" + + model_config = SettingsConfigDict( + env_prefix="SVCFORGE_", + env_file=".env", + env_file_encoding="utf-8", + extra="ignore", + frozen=True, + ) + + # --- Postgres ----------------------------------------------------------------- + # Transaction pooler (6543 on Supabase). Everything the services do at runtime. + pg_dsn: PostgresDsn + # Session pooler (5432). Migrations and psql only: DDL and advisory locks need a + # session that outlives a single transaction. + pg_dsn_session: PostgresDsn | None = None + + pool_min_size: int = Field(default=1, ge=0) + pool_max_size: int = Field(default=5, ge=1) + + # --- Redis (derived state only; never the source of truth) --------------------- + redis_dsn: RedisDsn | None = None + + # --- API ---------------------------------------------------------------------- + jwks_url: str | None = None + jwt_audience: str = "svcforge" + jwt_issuer: str | None = None + # Dev escape hatch: skip JWT verification. Refused in prod by check_production(). + auth_disabled: bool = False + + # --- Worker ------------------------------------------------------------------- + worker_id: str = Field(default="worker-local", min_length=1) + worker_concurrency: int = Field(default=4, ge=1) + poll_interval_s: float = Field(default=5.0, gt=0) + max_attempts: int = Field(default=5, ge=1) + lease_seconds: int = Field(default=300, ge=1) + + # --- Reconciler --------------------------------------------------------------- + reconcile_interval_s: float = Field(default=60.0, gt=0) + + # --- Catalog / helm ----------------------------------------------------------- + catalog_path: Path = Path("catalog.yaml") + helm_bin: str = "helm" + kubectl_bin: str = "kubectl" + helm_timeout_s: float = Field(default=300.0, gt=0) + + # --- CLI ---------------------------------------------------------------------- + # The CLI is an API client and nothing more. It gets a URL and a token; it does not + # get a DSN, because the moment a human can reach the database directly, someone will + # "just fix one row" and the state machine stops being true. + api_url: str = "http://localhost:8000" + api_token: str | None = None + + # --- Observability ------------------------------------------------------------ + log_level: str = "info" + log_json: bool = True + otel_endpoint: str | None = None + service_name: str = "svcforge" + # The worker and reconciler have no HTTP server of their own, so they start a tiny one + # just for /metrics. 9000 matches the chart's PodMonitor; change both or neither. + metrics_port: int = Field(default=9000, ge=1, le=65535) + + @property + def migration_dsn(self) -> str: + """Migrations need a session-mode connection; fall back to the runtime DSN locally.""" + return str(self.pg_dsn_session or self.pg_dsn) + + def check_production(self) -> None: + """Refuse the dev escape hatches when they would matter.""" + if self.auth_disabled: + raise ValueError("SVCFORGE_AUTH_DISABLED=true is refused outside local development") + + +def load_settings() -> Settings: + """Read the environment. Raises ValidationError — loudly — if anything is missing.""" + return Settings() # type: ignore[call-arg] # pydantic-settings fills these from env diff --git a/migrations/001_init.sql b/migrations/001_init.sql new file mode 100644 index 0000000..2a8e0a1 --- /dev/null +++ b/migrations/001_init.sql @@ -0,0 +1,36 @@ +-- 001_init.sql — instances and tasks. +-- +-- Forward-only. There is no down script. If this is wrong, 002 fixes it. + +create table instances ( + id uuid primary key default gen_random_uuid(), + team text not null, + service_type text not null, -- 'elasticsearch' | 'redis' | ... + size text not null, -- 'small' | 'medium' + state text not null, + namespace text not null, + release_name text not null unique, -- helm release; the idempotency anchor + chart_version text not null, -- what's ACTUALLY deployed. day-2 hinges on this column. + endpoint text, + error text, + expires_at timestamptz, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create table tasks ( + id bigserial primary key, + instance_id uuid not null references instances(id) on delete cascade, + kind text not null, -- 'provision'|'deprovision'|'upgrade'|'verify' + state text not null default 'queued', -- queued|running|done|failed + attempts int not null default 0, + run_after timestamptz not null default now(), -- backoff lands here + locked_by text, + locked_at timestamptz, + last_error text, + created_at timestamptz not null default now() +); + +-- Partial index: the claim query only ever looks at queued rows. Keeping the index +-- to that subset means it stays small no matter how much history `tasks` accumulates. +create index tasks_runnable on tasks (run_after) where state = 'queued'; diff --git a/migrations/003_day2.sql b/migrations/003_day2.sql new file mode 100644 index 0000000..3552638 --- /dev/null +++ b/migrations/003_day2.sql @@ -0,0 +1,16 @@ +-- 003_day2.sql — the whole of day 2: two columns and one query. +-- +-- Forward-only, expand/contract. Both additions are nullable / defaulted, so the old +-- code keeps running against the new schema between the migrate and the deploy. That +-- ordering is not optional: migrate first, deploy second, and a column the running +-- code has never heard of must not break it. + +alter table instances add column maintenance_window text; -- '0 3 * * 0|Asia/Ho_Chi_Minh', null = any time + +-- One row per service type, one column that matters. `halted` is what stops a bad +-- chart after the first tenant instead of after all of them, and it is cleared by +-- hand with SQL — an automatic un-halt would just resume breaking things. +create table catalog_versions ( + service_type text primary key, + rollout_state text not null default 'ok' -- 'ok' | 'halted' +); diff --git a/migrations/004_traceparent.sql b/migrations/004_traceparent.sql new file mode 100644 index 0000000..a3febce --- /dev/null +++ b/migrations/004_traceparent.sql @@ -0,0 +1,15 @@ +-- 004_traceparent.sql — carry the trace across the queue. +-- +-- A trace is a chain of span contexts. `POST /v1/instances` commits a row and returns; +-- a worker in another pod claims that row minutes later. Nothing propagates the context +-- across that gap, because the gap is a table. So the context rides in the table: the API +-- writes the W3C traceparent it is currently inside, and the worker extracts it at claim +-- and makes its span a child of the API's. Skip this and Tempo shows two unrelated traces +-- for one provision, which is worse than no tracing — it looks like it works. +-- +-- Nullable, no default, no backfill: expand/contract done right. Old rows have no +-- traceparent and never will; a worker running the previous image ignores a column it has +-- never heard of. Migrate first, deploy second, and neither step needs the other to have +-- happened. + +alter table tasks add column traceparent text; -- '00-<32 hex>-<16 hex>-01', null = untraced diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..c7c7f38 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,98 @@ +[project] +name = "svcforge" +version = "0.1.0" +description = "X-as-a-Service control plane — reference implementation" +requires-python = ">=3.12" +dependencies = [ + "svcforge-core", + "fastapi>=0.115", + "uvicorn[standard]>=0.32", + "psycopg[binary,pool]>=3.2", + "pyjwt[crypto]>=2.9", + "httpx>=0.27", + "croniter>=3.0", + "tzdata>=2024.2", + "structlog>=24.4", + "prometheus-client>=0.21", + "redis>=5.2", + "typer>=0.15", + "opentelemetry-api>=1.28", + "opentelemetry-sdk>=1.28", + "opentelemetry-instrumentation-fastapi>=0.49b0", + "opentelemetry-instrumentation-psycopg>=0.49b0", +] + +[dependency-groups] +dev = [ + "pytest>=8.3", + "pytest-asyncio>=0.24", + "pytest-cov>=6.0", + "mypy>=1.13", + "ruff>=0.8", + "hypothesis>=6.122", + "pre-commit>=4.0", + "testcontainers[postgres]>=4.9", + "types-pyyaml>=6.0.12.20260518", +] + +[project.scripts] +svcforge = "services.cli.main:app" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["services"] + +[tool.uv.sources] +# editable here is a DEVELOPMENT convenience: source edits are visible without a +# reinstall. The Dockerfiles deliberately override it with `uv sync --no-editable`, +# because an editable install in an image resolves imports to /app/libs and ships a +# path, not a package. +svcforge-core = { path = "libs/svcforge_core", editable = true } + +[tool.ruff] +line-length = 110 +target-version = "py312" + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B", "ANN", "S", "C4", "RUF"] + +[tool.ruff.lint.isort] +# svcforge_core lives under libs/, so isort cannot infer it is ours. +known-first-party = ["svcforge_core", "services"] + +[tool.ruff.lint.per-file-ignores] +# Tests may assert, and fixtures shadow names by design. +"tests/**" = ["S101"] +# The e2e tests drive the real `kubectl`/`helm`/`pgrep` off PATH — that is the whole point +# of them, and pinning absolute paths would make them pass on one machine only. Scoped to +# this directory so S603/S607 keep guarding the application code, where `team` is tenant +# input that reaches a helm release name. +"tests/e2e/**" = ["S101", "S603", "S607"] + +[tool.mypy] +strict = true +python_version = "3.12" +warn_unreachable = true + +[[tool.mypy.overrides]] +module = ["testcontainers.*", "croniter.*"] +ignore_missing_imports = true + +# The OTLP exporter is an optional runtime dependency: in the cluster the API runs under +# `opentelemetry-instrument`, which brings its own. obs.py imports it inside a try/except +# and degrades to in-process traces without it, so mypy must not require it to be installed. +[[tool.mypy.overrides]] +module = ["opentelemetry.exporter.*"] +ignore_missing_imports = true + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-q --strict-markers" +asyncio_mode = "auto" +markers = [ + "slow: >1s", + "e2e: needs a cluster and real helm", +] diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scripts/bump-digests.sh b/scripts/bump-digests.sh new file mode 100755 index 0000000..8907936 --- /dev/null +++ b/scripts/bump-digests.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# +# CI's last act. +# +# Resolves the digest each service's commit-SHA tag points at, writes those digests into +# deploy/chart/values.yaml, and commits. That commit is the deploy: ArgoCD is watching +# master and picks it up. This script does not, and must not, talk to the cluster. +# +# Called by .gitea/workflows/ci.yaml on master only. Runnable by hand for a re-bump: +# REGISTRY=gitea.oci-oci.duckdns.org IMAGE_NS=gitea_admin IMAGE_TAG= ./scripts/bump-digests.sh +# +# -e a failed inspect must not lead to committing a stale digest +# -u an unset REGISTRY would silently resolve the wrong image +# -o pipefail the digest comes out of a pipe; without this, a failing inspect that pipes +# into a successful grep exits 0 and writes garbage +set -euo pipefail + +: "${REGISTRY:?REGISTRY must be set}" +: "${IMAGE_NS:?IMAGE_NS must be set}" +: "${IMAGE_TAG:?IMAGE_TAG must be set (the commit sha the images were built from)}" + +SERVICES=(api worker reconciler) +CHART_VALUES="deploy/chart/values.yaml" + +# yq, pinned by digest. Not python+pyyaml: a yaml round-trip strips every comment in +# values.yaml, and those comments are the only thing explaining why the digests are there. +# yq edits in place and leaves the rest of the file alone. +YQ_IMAGE="mikefarah/yq:4.44.6@sha256:b1d117c609ba990436ad1649299e2f6c378f62cb562caf30b6f2fb6144713422" + +WORKDIR="$(mktemp -d)" +cleanup() { + rm -rf "${WORKDIR}" +} +trap cleanup EXIT + +yq() { + docker run --rm -v "${PWD}:/work" -w /work -u "$(id -u):$(id -g)" "${YQ_IMAGE}" "$@" +} + +echo "==> resolving digests for tag ${IMAGE_TAG}" +for svc in "${SERVICES[@]}"; do + image="${REGISTRY}/${IMAGE_NS}/svcforge-${svc}" + digest="$(docker buildx imagetools inspect "${image}:${IMAGE_TAG}" --format '{{.Manifest.Digest}}')" + + # Defence against a silently empty inspect. Without this, `yq` would happily write an + # empty digest and the chart's own guard would fail the release later, further from + # the cause. + if [[ ! "${digest}" =~ ^sha256:[0-9a-f]{64}$ ]]; then + echo "!! ${svc}: refusing to write a non-digest: '${digest}'" >&2 + exit 1 + fi + + echo " ${svc} -> ${digest}" + echo "${digest}" > "${WORKDIR}/${svc}.digest" +done + +echo "==> writing ${CHART_VALUES}" +for svc in "${SERVICES[@]}"; do + digest="$(cat "${WORKDIR}/${svc}.digest")" + # env(...) rather than string interpolation: a digest is attacker-controlled only in + # theory, but yq expression injection is not a thing worth leaving open. + DIGEST="${digest}" yq -i ".image.${svc}.digest = strenv(DIGEST)" "${CHART_VALUES}" +done + +if git diff --quiet -- "${CHART_VALUES}"; then + echo "==> no digest changed; nothing to commit" + exit 0 +fi + +echo "==> committing" +git config user.name "svcforge-ci" +git config user.email "ci@svcforge.invalid" +git add "${CHART_VALUES}" +git commit -m "ci: bump image digests to ${IMAGE_TAG} + +Built and scanned by ${IMAGE_TAG}. ArgoCD syncs from this commit. + +[skip ci]" +git push origin HEAD:master + +echo "==> done. ArgoCD owns it from here." diff --git a/scripts/load.py b/scripts/load.py new file mode 100644 index 0000000..a8ac946 --- /dev/null +++ b/scripts/load.py @@ -0,0 +1,130 @@ +"""Throwaway load generator. Enqueue N instances, watch the queue drain, print three numbers. + +The point is not a benchmark. It is to find the ceiling on purpose, in a place where finding +it is free, so that the number in RUNBOOK.md comes from an observation instead of a guess. + +The ceiling you are looking for is arithmetic, not mysterious: + + total connections = (api_replicas + worker_replicas) x pool_max_size + +Supabase's free-tier pooler has a small connection budget. Cross it and the failure does not +look like "too many connections" — it looks like slow claims, then PoolTimeout, then a queue +that grows while every worker looks idle. Once you have watched it once, you recognise it in +two seconds instead of an hour. + +Usage: + python -m scripts.load --count 200 --watch + python -m scripts.load --count 200 --direct # skip the API, enqueue straight to the DB +""" + +from __future__ import annotations + +import argparse +import asyncio +import time +from datetime import UTC, datetime +from uuid import uuid4 + +import psycopg +from psycopg.rows import dict_row + +from svcforge_core.settings import load_settings + + +async def _seed_direct(dsn: str, count: int) -> float: + """Insert `count` instances + provision tasks. Returns seconds taken. + + --direct exists to separate two questions that a single POST run conflates: "how fast can + the API accept work" and "how fast can workers drain it". Measure them apart or you will + tune the wrong one. + """ + started = time.monotonic() + async with await psycopg.AsyncConnection.connect(dsn, row_factory=dict_row) as conn: + async with conn.transaction(), conn.cursor() as cur: + for _ in range(count): + iid = uuid4() + await cur.execute( + """insert into instances (id, team, service_type, size, state, namespace, + release_name, chart_version) + values (%s, 'loadtest', 'elasticsearch', 'small', 'requested', + 'tenant-loadtest', %s, '21.3.19')""", + (iid, f"loadtest-elasticsearch-{str(iid)[:8]}"), + ) + await cur.execute( + "insert into tasks (instance_id, kind) values (%s, 'provision')", + (iid,), + ) + return time.monotonic() - started + + +async def _depth(dsn: str) -> dict[str, int]: + async with await psycopg.AsyncConnection.connect(dsn, row_factory=dict_row) as conn: + cur = await conn.execute("select state, count(*) as n from tasks group by 1") + return {str(r["state"]): int(r["n"]) for r in await cur.fetchall()} + + +async def _watch(dsn: str, timeout_s: float) -> None: + """Print queue depth once a second until it drains. The slope is the number you want.""" + started = time.monotonic() + peak = 0 + print(f"{'t(s)':>6} {'queued':>7} {'running':>8} {'done':>6} {'failed':>7} slope/s") + prev_done, prev_t = 0, started + + while time.monotonic() - started < timeout_s: + d = await _depth(dsn) + queued, running = d.get("queued", 0), d.get("running", 0) + done, failed = d.get("done", 0), d.get("failed", 0) + peak = max(peak, queued + running) + + now = time.monotonic() + slope = (done - prev_done) / max(now - prev_t, 1e-9) + prev_done, prev_t = done, now + + print(f"{now - started:6.1f} {queued:7d} {running:8d} {done:6d} {failed:7d} {slope:7.1f}") + + if queued == 0 and running == 0: + elapsed = now - started + print(f"\ndrained in {elapsed:.1f}s peak depth {peak} throughput {done / elapsed:.1f} task/s") + if failed: + print(f"WARNING: {failed} tasks failed — the number above is not a clean drain") + return + await asyncio.sleep(1.0) + + print(f"\nstill draining after {timeout_s}s — that IS the result. Record it.") + + +async def _amain() -> None: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--count", type=int, default=200) + ap.add_argument("--direct", action="store_true", help="enqueue via SQL instead of the API") + ap.add_argument("--watch", action="store_true", help="poll queue depth until drained") + ap.add_argument("--timeout", type=float, default=600.0) + ap.add_argument("--cleanup", action="store_true", help="delete loadtest rows and exit") + args = ap.parse_args() + + settings = load_settings() + dsn = settings.pg_dsn.unicode_string() + + if args.cleanup: + async with await psycopg.AsyncConnection.connect(dsn, autocommit=True) as conn: + await conn.execute("delete from instances where team = 'loadtest'") # tasks cascade + print("loadtest rows deleted") + return + + if not args.direct: + raise SystemExit( + "POST mode needs a token; use --direct for the drain measurement, or drive the API " + "with k6 (one dependency, not two — do not add locust for this)." + ) + + print(f"seeding {args.count} instances at {datetime.now(UTC).isoformat()} ...") + took = await _seed_direct(dsn, args.count) + print(f"enqueued {args.count} in {took:.2f}s ({args.count / took:.0f}/s)\n") + + if args.watch: + await _watch(dsn, args.timeout) + print("\nremember: `python -m scripts.load --cleanup` when you are done.") + + +if __name__ == "__main__": + asyncio.run(_amain()) diff --git a/scripts/redis_budget.py b/scripts/redis_budget.py new file mode 100644 index 0000000..7947dfc --- /dev/null +++ b/scripts/redis_budget.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python3 +"""Project month-end Redis command burn from the live counter. Exit 1 if it blows the budget. + + $ python3 scripts/redis_budget.py + $ python3 scripts/redis_budget.py --url http://localhost:8000/metrics --budget 500000 + +Upstash's free tier is 500,000 commands/month, which sounds enormous and is not: + + 500,000 / month = 16,129 / day = 11 / minute = 0.19 / second, sustained + +0.19 commands per second is the entire engineering constraint. One worker polling Redis +every five seconds spends 518,400/month — the whole budget, to learn nothing. That is why +Redis is only ever on the request path here, and why this script exists: the rule is easy +to state and invisible to violate. A `cache.get()` added inside the reconciler's +per-instance loop is one line in review and 2,160,000 commands/month in production. + +**Why a projection and not an alarm on the counter.** Exhausting the budget is a slow, +silent failure with a cliff at the end: nothing degrades, nothing pages, every call +succeeds, and then the month rolls over and every Redis call starts erroring at once. By +then the fix is a bill or an outage. A burn rate extrapolated from the counter is visible +on day two, which is the only time it is cheap to fix. + +Stdlib only, on purpose — this is meant to run from CI, from a laptop, or from inside a +pod that has nothing but python3, without an environment to activate first. +""" + +from __future__ import annotations + +import argparse +import sys +import time +import urllib.request +from urllib.parse import urlparse + +# A 30-day month. Upstash bills on a calendar month; 30 days is the honest rounding and +# errs slightly pessimistic on the long ones, which is the correct direction for a budget. +_MONTH_S = 30 * 24 * 60 * 60 +_FREE_TIER_BUDGET = 500_000 + +_COMMANDS_METRIC = "svcforge_redis_commands_total" +_START_TIME_METRIC = "process_start_time_seconds" + + +class BudgetError(RuntimeError): + """The metrics endpoint did not give us enough to project from.""" + + +def scrape(url: str, timeout_s: float = 5.0) -> str: + """GET the Prometheus text exposition. http/https only.""" + if urlparse(url).scheme not in ("http", "https"): + raise BudgetError(f"refusing to fetch a non-http(s) url: {url}") + # S310 is satisfied by the scheme check above: this cannot open file:// or ftp://. + with urllib.request.urlopen(url, timeout=timeout_s) as resp: # noqa: S310 + body: str = resp.read().decode("utf-8") + return body + + +def _parse_sample(line: str) -> tuple[str, dict[str, str], float] | None: + """One exposition line -> (name, labels, value). None for comments and blanks. + + A deliberately small parser rather than prometheus_client's: importing the library + would mean this script only runs where the app's venv is already active, which is + exactly where you least need to check the budget. + """ + line = line.strip() + if not line or line.startswith("#"): + return None + head, _, raw_value = line.rpartition(" ") + if not head: + return None + try: + value = float(raw_value) + except ValueError: + return None + + name, brace, rest = head.partition("{") + labels: dict[str, str] = {} + if brace: + for pair in rest.rstrip("}").split(","): + key, eq, val = pair.partition("=") + if eq: + labels[key.strip()] = val.strip().strip('"') + return name.strip(), labels, value + + +def collect(text: str) -> tuple[dict[str, float], float]: + """Extract per-op command totals and the process start time from a scrape.""" + per_op: dict[str, float] = {} + started_at: float | None = None + + for line in text.splitlines(): + parsed = _parse_sample(line) + if parsed is None: + continue + name, labels, value = parsed + # prometheus_client exposes counters with a `_total` suffix already; tolerate both + # spellings so this keeps working if the client library changes its mind. + if name in (_COMMANDS_METRIC, _COMMANDS_METRIC.removesuffix("_total")): + per_op[labels.get("op", "unknown")] = value + elif name == _START_TIME_METRIC: + started_at = value + + if not per_op: + raise BudgetError( + f"{_COMMANDS_METRIC} is not exposed. Either the API never imported " + f"svcforge_core.adapters.redis, or you are scraping the wrong process." + ) + if started_at is None: + raise BudgetError( + f"{_START_TIME_METRIC} is missing, so there is no window to project over. " + f"It comes from prometheus_client's default collector." + ) + return per_op, started_at + + +def report(per_op: dict[str, float], started_at: float, budget: int, now: float) -> int: + """Print the projection. Returns the process exit code.""" + elapsed_s = max(1.0, now - started_at) + total = sum(per_op.values()) + rate = total / elapsed_s + projected = rate * _MONTH_S + + print(f"window {elapsed_s / 3600:.2f} h since process start") + print(f"commands {total:,.0f}") + for op, value in sorted(per_op.items(), key=lambda kv: -kv[1]): + share = (value / total * 100) if total else 0.0 + print(f" {op:<14}{value:>12,.0f} ({share:.1f}%)") + print(f"rate {rate:.4f} /s (budget allows {budget / _MONTH_S:.4f} /s sustained)") + print(f"projected {projected:,.0f} / month") + print(f"budget {budget:,} / month") + + if total < 100: + # Extrapolating a month from a handful of commands is astrology. Say so rather than + # printing a confident number derived from six samples. + print("verdict INCONCLUSIVE — fewer than 100 commands; let it run longer") + return 0 + if projected >= budget: + headroom = projected / budget + print(f"verdict OVER BUDGET — {headroom:.1f}x. Find the Redis call in a loop.") + return 1 + share = projected / budget * 100 + print(f"verdict OK — {share:.1f}% of budget, {budget / projected:.1f}x headroom") + return 0 + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter + ) + parser.add_argument("--url", default="http://localhost:8000/metrics", help="Prometheus endpoint") + parser.add_argument("--budget", type=int, default=_FREE_TIER_BUDGET, help="commands per month") + args = parser.parse_args(argv) + + try: + per_op, started_at = collect(scrape(args.url)) + except BudgetError as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + except OSError as exc: + print(f"error: cannot scrape {args.url}: {exc}", file=sys.stderr) + return 2 + + return report(per_op, started_at, args.budget, time.time()) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/services/__init__.py b/services/__init__.py new file mode 100644 index 0000000..078b94e --- /dev/null +++ b/services/__init__.py @@ -0,0 +1 @@ +"""svcforge services: api, worker, reconciler.""" diff --git a/services/api/Dockerfile b/services/api/Dockerfile new file mode 100644 index 0000000..aab59be --- /dev/null +++ b/services/api/Dockerfile @@ -0,0 +1,55 @@ +# syntax=docker/dockerfile:1.10 +# +# svcforge api. Build from the REPO ROOT: +# docker buildx build -f services/api/Dockerfile -t svcforge/api:dev . +# `COPY ../..` is illegal, so the context must be the root. There is no other option. +# +# Two syncs, not one: deps change rarely and our own code changes every commit, so the +# expensive layer (third-party wheels) must land before the cheap one (our source). + +FROM python:3.12-slim@sha256:57cd7c3a7a273101a6485ba99423ee568157882804b1124b4dd04266317710de AS builder + +COPY --from=ghcr.io/astral-sh/uv:0.5.11@sha256:0ac957607303916420297a4c9c213bb33fbd3c888f9cd7f4f7273596ebf42b85 /uv /usr/local/bin/uv + +ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy UV_PYTHON_DOWNLOADS=never +WORKDIR /app + +# --- layer 1: third-party dependencies only ------------------------------------------- +# --no-install-project skips the root; --no-install-package skips our path dependency. +# Without the latter, uv would try to build svcforge-core here, where its source is not +# yet in the context, and the build would fail. +COPY pyproject.toml uv.lock ./ +COPY libs/svcforge_core/pyproject.toml libs/svcforge_core/ +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --frozen --no-dev --no-editable \ + --no-install-project --no-install-package svcforge-core + +# --- layer 2: our code ---------------------------------------------------------------- +COPY libs/ libs/ +COPY services/api/ services/api/ +COPY catalog.yaml ./ +# --no-editable is what turns svcforge-core into a real wheel in site-packages. +# pyproject.toml declares it `editable = true` for local dev; an editable install in an +# image points at /app/libs, which is a source tree that need not survive the final stage. +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --frozen --no-dev --no-editable && \ + /app/.venv/bin/python -c 'import svcforge_core, sys; \ +p = svcforge_core.__file__; \ +sys.exit(0) if "site-packages" in p else sys.exit("not a wheel install: " + p)' + +# --- runtime -------------------------------------------------------------------------- +FROM python:3.12-slim@sha256:57cd7c3a7a273101a6485ba99423ee568157882804b1124b4dd04266317710de + +ARG BUILD_SHA=unknown +LABEL org.opencontainers.image.title="svcforge-api" \ + org.opencontainers.image.source="https://gitea.oci-oci.duckdns.org/gitea_admin/svcforge" \ + org.opencontainers.image.revision="${BUILD_SHA}" + +RUN useradd -u 10001 -m -s /usr/sbin/nologin svcforge +WORKDIR /app +COPY --from=builder --chown=10001:10001 /app /app +ENV PATH="/app/.venv/bin:$PATH" \ + PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 +USER 10001 +ENTRYPOINT ["python", "-m", "services.api"] diff --git a/services/api/__init__.py b/services/api/__init__.py new file mode 100644 index 0000000..646055b --- /dev/null +++ b/services/api/__init__.py @@ -0,0 +1 @@ +"""The api service: HTTP transport over the domain.""" diff --git a/services/api/__main__.py b/services/api/__main__.py new file mode 100644 index 0000000..a6c0b5d --- /dev/null +++ b/services/api/__main__.py @@ -0,0 +1,29 @@ +"""`python -m services.api` — what the container's ENTRYPOINT runs. + +One uvicorn worker per container, on purpose. Replicas are Kubernetes' job: `--workers N` +forks processes the orchestrator cannot see, size, or drain, and it breaks the in-process +Prometheus registry that /metrics depends on. +""" + +from __future__ import annotations + +import uvicorn + +from svcforge_core.settings import load_settings + + +def main() -> None: + """Load settings (fail fast if the env is wrong), then serve.""" + settings = load_settings() + uvicorn.run( + "services.api.main:app", + factory=True, + host="0.0.0.0", # noqa: S104 - a container binds all interfaces; the pod is the boundary + port=8000, + log_level=settings.log_level, + access_log=False, # structlog owns request logging (Module 7); two sources would double it + ) + + +if __name__ == "__main__": + main() diff --git a/services/api/deps.py b/services/api/deps.py new file mode 100644 index 0000000..6adc657 --- /dev/null +++ b/services/api/deps.py @@ -0,0 +1,171 @@ +"""Dependency injection: how a handler gets a pool, a repo, a catalog, and a team. + +Everything expensive — the pool, the JWKS client, the parsed catalog — is built once in +`lifespan` and parked on `app.state`. These functions only hand it out. A `Depends` that +does I/O per request is a `Depends` that does that I/O on every request forever. +""" + +from __future__ import annotations + +import asyncio +from typing import Annotated, Any + +import jwt +from fastapi import Depends, HTTPException, Request, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from jwt import PyJWKClient + +from svcforge_core.domain.models import CatalogEntry +from svcforge_core.repo.db import DictPool +from svcforge_core.repo.instances import InstanceRepo +from svcforge_core.repo.tasks import TaskRepo +from svcforge_core.settings import Settings + +# The algorithm allow-list is the whole point of naming algorithms explicitly. +# `jwt.decode(..., algorithms=...)` without it accepts whatever the *token* claims in its +# own header — including `none`, and including HS256 verified with the RSA public key as +# an HMAC secret. Both are forgery. The list is not configuration. +ALLOWED_ALGORITHMS = ["RS256"] + +# What `auth_disabled` returns. Settings.check_production() refuses that flag in prod. +DEV_TEAM = "platform" + +TEAM_CLAIM = "team" + +# auto_error=False is load-bearing. HTTPBearer(auto_error=True) answers a *missing* +# Authorization header with 403, not 401 — an old FastAPI wart. The spec (and every +# client that knows what to do about it) wants 401, so the error is raised here. +_bearer = HTTPBearer(auto_error=False) + + +def _unauthorized() -> HTTPException: + """One shape for every auth failure. + + Expired, wrong issuer, wrong audience, bad signature, malformed, no header: all the + same 401 with the same body. Telling a caller *which* one turns the endpoint into an + oracle they can tune a forgery against. + """ + return HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail={"code": "unauthorized", "message": "invalid or missing credentials"}, + headers={"WWW-Authenticate": "Bearer"}, + ) + + +def get_settings(request: Request) -> Settings: + """The Settings that create_app() was handed.""" + settings: Settings = request.app.state.settings + return settings + + +async def get_pool(request: Request) -> DictPool: + """Return the pool that lifespan put on app.state.""" + pool: DictPool = request.app.state.pool + return pool + + +def get_catalog(request: Request) -> dict[str, CatalogEntry]: + """The catalog, parsed once at startup. + + Read from disk per request and a mid-flight edit to catalog.yaml changes the answer + between two requests of the same deploy. Load it at startup; a change is a restart. + """ + catalog: dict[str, CatalogEntry] = request.app.state.catalog + return catalog + + +def get_instance_repo(pool: Annotated[DictPool, Depends(get_pool)]) -> InstanceRepo: + """An InstanceRepo bound to the app's pool. Cheap: it is a handle, not a connection.""" + return InstanceRepo(pool) + + +def get_task_repo(pool: Annotated[DictPool, Depends(get_pool)]) -> TaskRepo: + """A TaskRepo bound to the app's pool.""" + return TaskRepo(pool) + + +async def get_current_team( + request: Request, + creds: Annotated[HTTPAuthorizationCredentials | None, Depends(_bearer)], + settings: Annotated[Settings, Depends(get_settings)], +) -> str: + """Verify the JWT against the cached JWKS. Check aud/iss/exp and the alg allow-list. + + Returns the team claim. Raises HTTPException(401) on any failure — never leaks why. + """ + if settings.auth_disabled: + return DEV_TEAM + + if creds is None or not creds.credentials: + raise _unauthorized() + + jwks_client: PyJWKClient | None = getattr(request.app.state, "jwks_client", None) + if jwks_client is None: + # Auth is on but there is no key source. Fail closed. Answering 500 here would be + # honest about the cause and would also let a misconfigured deploy be told apart + # from a bad token; 401 is the same answer a forger gets. + raise _unauthorized() + + try: + # PyJWKClient keeps its own TTL cache, so this is a dict lookup on the hot path. + # It is only blocking on a cache MISS (key rotation) — hence to_thread, which + # costs a thread hop we take a handful of times a day rather than an event loop + # stalled on someone else's HTTP call once per rotation. + signing_key = await _signing_key(jwks_client, creds.credentials) + claims: dict[str, Any] = jwt.decode( + creds.credentials, + signing_key.key, + algorithms=ALLOWED_ALGORITHMS, + audience=settings.jwt_audience, + issuer=settings.jwt_issuer, + options={ + "require": ["exp", "aud", "iss"], + "verify_exp": True, + "verify_aud": True, + "verify_iss": settings.jwt_issuer is not None, + "verify_signature": True, + }, + ) + except Exception as exc: # deliberate catch-all: every failure becomes one opaque 401 + raise _unauthorized() from exc + + team = claims.get(TEAM_CLAIM) + if not isinstance(team, str) or not team: + raise _unauthorized() + return team + + +async def _signing_key(client: PyJWKClient, token: str) -> jwt.PyJWK: + """Fetch the signing key off the event loop. + + PyJWKClient.get_signing_key_from_jwt() does a synchronous urlopen on a cache miss. + Called directly from `async def`, that blocks the loop — every other in-flight request + on this worker stops until the identity provider answers, and if it hangs, so does the + pod, and /readyz keeps saying it is fine. + """ + return await asyncio.to_thread(client.get_signing_key_from_jwt, token) + + +async def rate_limit(team: Annotated[str, Depends(get_current_team)]) -> None: + """Per-team rate limiting. Seam only — Module 10 fills this in (Redis, Lua, token bucket). + + It exists now, wired into the routes, so that turning it on is an edit to one function + body rather than a change to every handler signature. + """ + return None + + +async def idempotency_key(request: Request) -> str | None: + """`Idempotency-Key` handling. Seam only — Module 10 fills this in (Redis store). + + Until then the real idempotency anchor is `instances.release_name`, which is unique in + the schema and deterministic from (team, service_type, id). + """ + return request.headers.get("Idempotency-Key") + + +PoolDep = Annotated[DictPool, Depends(get_pool)] +TeamDep = Annotated[str, Depends(get_current_team)] +InstanceRepoDep = Annotated[InstanceRepo, Depends(get_instance_repo)] +TaskRepoDep = Annotated[TaskRepo, Depends(get_task_repo)] +CatalogDep = Annotated[dict[str, CatalogEntry], Depends(get_catalog)] diff --git a/services/api/main.py b/services/api/main.py new file mode 100644 index 0000000..2b5128e --- /dev/null +++ b/services/api/main.py @@ -0,0 +1,121 @@ +"""The app factory and its lifespan. + +`create_app(settings)` is a factory, not a module-level `app = FastAPI()`, for one reason: +a test needs an app pointed at a throwaway Postgres, and an import-time app reads the real +environment at import time — before any fixture can say otherwise. +""" + +from __future__ import annotations + +import asyncio +import logging +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +from fastapi import FastAPI, HTTPException, Request +from fastapi.responses import JSONResponse +from jwt import PyJWKClient + +from services.api.models import ErrorBody +from services.api.routes import health, instances +from svcforge_core.domain.catalog import load_catalog +from svcforge_core.repo.db import make_pool +from svcforge_core.settings import Settings, load_settings + +log = logging.getLogger(__name__) + + +@asynccontextmanager +async def lifespan(app: FastAPI) -> AsyncIterator[None]: + """Open the pool, yield, close the pool. + + A lifespan context, not the deprecated startup/shutdown event decorators: those cannot + express "this resource lives for exactly as long as the app", and give you no place to + put the teardown next to the setup. Closing the pool matters — an unclosed pool means + connections linger server-side after SIGTERM, and on a pooled Postgres with a small + connection budget a few rolling deploys exhaust it. + + (The old decorator's name is spelled nowhere in this package on purpose: CI greps for + the literal string, and a comment quoting it fails the gate just as loudly as a call.) + """ + settings: Settings = app.state.settings + + app.state.catalog = load_catalog(settings.catalog_path) + + pool = make_pool(str(settings.pg_dsn), settings.pool_min_size, settings.pool_max_size) + # wait=True fails NOW, loudly, if the DSN is wrong — instead of at the first request, + # as a PoolTimeout, in front of a user. + await pool.open(wait=True) + app.state.pool = pool + + # The pool is open from here on, so everything below is inside the try: an exception + # in JWKS setup must still close it, or a crash-looping pod leaks a connection per + # restart until the database refuses new ones. + try: + if settings.jwks_url and not settings.auth_disabled: + client = PyJWKClient(settings.jwks_url, cache_keys=True, lifespan=300) + app.state.jwks_client = client + # Warm the cache off the loop so the first authenticated request does not pay + # a blocking urlopen. Best-effort: a slow identity provider must not stop the + # pod from starting — a cache miss later just costs one to_thread hop. + try: + await asyncio.to_thread(client.get_signing_keys) + except Exception: # deliberate catch-all: startup must not hinge on the IdP being up + log.warning("JWKS warm-up failed; keys will be fetched on first use", exc_info=True) + else: + app.state.jwks_client = None + + yield + finally: + await pool.close() + + +async def _http_exception_handler(request: Request, exc: Exception) -> JSONResponse: + """Render HTTPException bodies as ErrorBody, so every error has one shape. + + Handlers raise `detail={"code": ..., "message": ...}`; FastAPI's default would nest + that under `{"detail": {...}}`. Plain-string details (raised by FastAPI itself, e.g. + a 405) are wrapped so clients never have to branch on the body's type. + """ + assert isinstance(exc, HTTPException) # noqa: S101 - registered only for HTTPException + # Widened to object deliberately. Starlette types `detail` as str, but FastAPI passes + # through whatever a handler raised — our handlers raise dicts. Narrowing off the + # declared type would make mypy call the dict branch unreachable and delete it. + detail: object = exc.detail + if isinstance(detail, dict) and "code" in detail and "message" in detail: + body = ErrorBody(code=str(detail["code"]), message=str(detail["message"])) + else: + body = ErrorBody(code=f"http_{exc.status_code}", message=str(detail)) + return JSONResponse(status_code=exc.status_code, content=body.model_dump(), headers=exc.headers) + + +def create_app(settings: Settings | None = None) -> FastAPI: + """App factory: lifespan, routers, exception handler, /metrics.""" + settings = settings or load_settings() + + app = FastAPI( + title="svcforge", + version="0.1.0", + summary="X-as-a-Service control plane", + lifespan=lifespan, + ) + app.state.settings = settings + + # /metrics is a normal route on health.router, not an app.mount — see health.metrics + # for why the mount does not actually serve a bare /metrics. + app.include_router(health.router) + app.include_router(instances.router) + + app.add_exception_handler(HTTPException, _http_exception_handler) + return app + + +def app() -> FastAPI: + """Entry point for `uvicorn services.api.main:app --factory`.""" + return create_app() + + +if __name__ == "__main__": # pragma: no cover + import uvicorn + + uvicorn.run("services.api.main:app", factory=True, host="0.0.0.0", port=8000) # noqa: S104 diff --git a/services/api/models.py b/services/api/models.py new file mode 100644 index 0000000..6a33392 --- /dev/null +++ b/services/api/models.py @@ -0,0 +1,52 @@ +"""Wire types. + +These are deliberately NOT the domain models. `Instance` carries `team`, `namespace` and +`release_name` — placement details a tenant has no business seeing and no business +setting. The response model is the allow-list that keeps them off the wire, which is why +it is written out by hand instead of derived from `Instance`. +""" + +from __future__ import annotations + +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + +from svcforge_core.domain.states import InstanceState + + +class CreateInstanceRequest(BaseModel): + """What a tenant may ask for. + + `service_type` and `size` are plain strings, not enums: the catalog is data loaded at + runtime, so baking its keys into a type would mean a redeploy to add a service type, + and a 422 (schema) where the spec wants a 404 (unknown resource). They are validated + against the catalog in the handler. + """ + + model_config = ConfigDict(extra="forbid") + + service_type: str = Field(min_length=1) + size: str + ttl_days: int | None = Field(default=None, ge=1, le=30) + + +class InstanceResponse(BaseModel): + """What a tenant gets back. A subset of Instance, on purpose.""" + + model_config = ConfigDict(from_attributes=True) + + id: UUID + state: InstanceState + service_type: str + size: str + endpoint: str | None + chart_version: str + error: str | None + + +class ErrorBody(BaseModel): + """Every non-2xx body. `code` is for machines, `message` is for humans.""" + + code: str + message: str diff --git a/services/api/routes/__init__.py b/services/api/routes/__init__.py new file mode 100644 index 0000000..beaad2d --- /dev/null +++ b/services/api/routes/__init__.py @@ -0,0 +1 @@ +"""HTTP routers.""" diff --git a/services/api/routes/health.py b/services/api/routes/health.py new file mode 100644 index 0000000..de7e580 --- /dev/null +++ b/services/api/routes/health.py @@ -0,0 +1,81 @@ +"""Liveness, readiness, metrics. + +The distinction between the first two is not pedantry, it is the difference between a +30-second blip and a fleet-wide outage: + +* `/healthz` (liveness) answers "is this process wedged?" A failure here gets the + container KILLED. It must therefore touch NOTHING external. Wire it to the DB and a + 20-second Postgres failover restarts every pod at once; they come back, find the DB + still down, and CrashLoopBackOff with exponential restart delays — so the fleet is now + down for minutes after the database recovered. +* `/readyz` (readiness) answers "should this pod get traffic?" A failure here only removes + it from the Service endpoints. It is allowed to check dependencies, and it recovers by + itself the moment the check passes. +""" + +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, HTTPException, Request, Response, status +from prometheus_client import REGISTRY +from prometheus_client.exposition import choose_encoder + +from services.api.deps import PoolDep +from services.api.models import ErrorBody + +router = APIRouter(tags=["ops"]) + +# No PROMETHEUS_MULTIPROC_DIR here, deliberately: it exists for prefork servers where each +# worker process holds a slice of the counters. One uvicorn process per container means +# the default in-process registry is already correct, and multiproc mode would add a +# shared temp dir, a cleanup obligation, and a class of stale-file bugs for nothing. + + +@router.get("/healthz", status_code=status.HTTP_200_OK) +async def healthz() -> dict[str, str]: + """Liveness. No I/O. If the event loop can run this, the process is alive.""" + return {"status": "ok"} + + +@router.get( + "/readyz", + responses={503: {"model": ErrorBody, "description": "A dependency is unavailable"}}, +) +async def readyz(pool: PoolDep) -> dict[str, str]: + """Readiness. Postgres only. + + Postgres-only is the rule, and Redis is the temptation. Redis holds derived state — + rate-limit buckets, caches — and everything degrades gracefully without it. Put it in + this check and an Upstash hiccup marks every pod unready, Kubernetes empties the + Service, and a cache outage becomes a total API outage. + """ + try: + async with pool.connection() as conn, conn.cursor() as cur: + await cur.execute("select 1") + row: Any = await cur.fetchone() + if row is None: + raise RuntimeError("select 1 returned no row") + except Exception as exc: # closed pool, timeout, dead DB — all mean the same 'not ready' + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail={"code": "not_ready", "message": "database unavailable"}, + ) from exc + return {"status": "ready"} + + +@router.get("/metrics", response_class=Response) +async def metrics(request: Request) -> Response: + """The Prometheus scrape endpoint. + + A route rather than `app.mount("/metrics", make_asgi_app())`, for two reasons. A + Starlette `Mount` compiles to `^/metrics(?P/.*)$`, which does not match a bare + `/metrics` — the exact URL every scrape config uses — and a `Mount` is invisible to + OpenAPI, while the deliverable asks for `/metrics` in `openapi.json`. + + The encoding is still prometheus_client's: `choose_encoder` reads the Accept header and + picks the exposition format (Prometheus text vs OpenMetrics) with its matching content + type. Hand-rolling either is how you end up serving text/plain that a scraper rejects. + """ + encoder, content_type = choose_encoder(request.headers.get("Accept", "")) + return Response(content=encoder(REGISTRY), media_type=content_type) diff --git a/services/api/routes/instances.py b/services/api/routes/instances.py new file mode 100644 index 0000000..7f4c54a --- /dev/null +++ b/services/api/routes/instances.py @@ -0,0 +1,218 @@ +"""The tenant-facing API. + +Two rules run through every handler here: + +* **AuthZ is the WHERE clause.** No handler ever compares `inst.team` to the caller's + team, because the repo never returns another team's row to compare. A wrong-team id is + a 404. 403 would confirm the id exists, which is the leak. +* **The instance and its task commit together.** A committed instance with no task is an + instance that never provisions and that nothing will ever retry. +""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from typing import Any +from uuid import UUID, uuid4 + +from fastapi import APIRouter, Depends, HTTPException, Query, Response, status + +from services.api.deps import ( + CatalogDep, + InstanceRepoDep, + PoolDep, + TaskRepoDep, + TeamDep, + idempotency_key, + rate_limit, +) +from services.api.models import CreateInstanceRequest, ErrorBody, InstanceResponse +from svcforge_core.domain.models import CatalogEntry, Instance, TaskKind +from svcforge_core.domain.states import IllegalTransition, InstanceState, transition + +# Declared on the router so every error shape lands in openapi.json under ErrorBody. +# The exception handler already renders this at runtime; without declaring it, generated +# clients see the contract for 2xx only and invent their own guess for the rest. +ERROR_RESPONSES: dict[int | str, dict[str, Any]] = { + 401: {"model": ErrorBody, "description": "Missing or invalid credentials"}, + 404: {"model": ErrorBody, "description": "No such instance, or not this team's"}, + 409: {"model": ErrorBody, "description": "Instance is not in a state that allows this"}, + 422: {"model": ErrorBody, "description": "The body is well-formed but cannot be processed"}, +} + +router = APIRouter(prefix="/v1/instances", tags=["instances"], responses=ERROR_RESPONSES) + + +def release_name_for(team: str, service_type: str, instance_id: UUID) -> str: + """The helm release name. Deterministic, and `unique` in the schema. + + This is the idempotency anchor. A worker that dies after `helm install` but before it + marks the task done will retry, compute the same name, and `helm upgrade --install` + onto the same release instead of creating a second one. Derive it from anything that + is not already durable — a timestamp, a random suffix, the retry count — and a retry + provisions a duplicate. + + Truncated to the uuid's first 8 chars to stay inside the 53-char limit helm imposes + on release names (Kubernetes label values, minus room for chart-generated suffixes). + """ + return f"{team}-{service_type}-{str(instance_id)[:8]}" + + +def namespace_for(team: str) -> str: + """One namespace per tenant. The blast radius of a bad chart is one team.""" + return f"tenant-{team}" + + +def _resolve(catalog: dict[str, CatalogEntry], service_type: str, size: str) -> CatalogEntry: + """Look up service_type + size, or raise the right 4xx. + + The two failures are different HTTP problems and the spec asks for different codes: + an unknown service_type is a resource that does not exist (404); an unknown size for a + real service_type is a body the server understood and cannot process (422). + """ + entry = catalog.get(service_type) + if entry is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={ + "code": "unknown_service_type", + "message": f"no such service_type: {service_type}", + }, + ) + if size not in entry.sizes: + raise HTTPException( + status_code=422, # starlette renamed the 422 constant; the number never moved + detail={ + "code": "unknown_size", + "message": f"{service_type} has no size {size!r}; available: {sorted(entry.sizes)}", + }, + ) + return entry + + +@router.post( + "", + status_code=status.HTTP_202_ACCEPTED, + response_model=InstanceResponse, + dependencies=[Depends(rate_limit), Depends(idempotency_key)], +) +async def create_instance( + body: CreateInstanceRequest, + response: Response, + team: TeamDep, + pool: PoolDep, + instances: InstanceRepoDep, + tasks: TaskRepoDep, + catalog: CatalogDep, +) -> Instance: + """Accept a provisioning request. 202, never 201. + + Nothing is provisioned when this returns. The row exists and a task is queued; a + worker will do the work seconds or minutes from now. 201 Created would be a lie about + a resource that does not exist yet, and clients would stop polling. + """ + entry = _resolve(catalog, body.service_type, body.size) + + instance_id = uuid4() + now = datetime.now(UTC) + inst = Instance( + id=instance_id, + team=team, + service_type=body.service_type, + size=body.size, + state=InstanceState.REQUESTED, + namespace=namespace_for(team), + release_name=release_name_for(team, body.service_type, instance_id), + # Pinned from the catalog AT CREATION TIME, not read from the catalog later. + # This column records what is actually deployed; bumping catalog.yaml must show up + # as drift the reconciler can see, not silently rewrite history. + chart_version=entry.chart_version, + expires_at=now + timedelta(days=body.ttl_days) if body.ttl_days is not None else None, + created_at=now, + updated_at=now, + ) + + # The transaction. Both writes go through THIS conn, or the atomicity is theatre. + async with pool.connection() as conn, conn.transaction(): + created = await instances.create(conn, inst) + await tasks.enqueue(conn, created.id, TaskKind.PROVISION) + + response.headers["Location"] = f"/v1/instances/{created.id}" + return created + + +@router.get("/{instance_id}", response_model=InstanceResponse) +async def get_instance( + instance_id: UUID, + team: TeamDep, + instances: InstanceRepoDep, +) -> Instance: + """404 if the repo returns None. A wrong-team id is a 404, not a 403.""" + inst = await instances.get(instance_id, team) + if inst is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={"code": "not_found", "message": f"no such instance: {instance_id}"}, + ) + return inst + + +@router.get("", response_model=list[InstanceResponse]) +async def list_instances( + team: TeamDep, + instances: InstanceRepoDep, + limit: int = Query(default=50, ge=1, le=200), +) -> list[Instance]: + """The caller's instances, newest first. Bounded: no endpoint returns 'all rows'.""" + return await instances.list(team, limit=limit) + + +@router.delete( + "/{instance_id}", + status_code=status.HTTP_202_ACCEPTED, + response_model=InstanceResponse, + dependencies=[Depends(rate_limit)], +) +async def delete_instance( + instance_id: UUID, + team: TeamDep, + instances: InstanceRepoDep, + tasks: TaskRepoDep, +) -> Instance: + """state -> deleting, enqueue deprovision. 202: the helm uninstall has not happened yet. + + Ordering note. `InstanceRepo.update_state` owns its own connection, so the CAS and the + enqueue cannot share one transaction without reaching around the repo. Given two + statements, the order is chosen for its failure mode: CAS first, enqueue second. A + crash in between leaves an instance in `deleting` with no task, which the reconciler's + sweep re-enqueues. The other order leaves a deprovision task pointing at a `ready` + instance, and a worker would tear down a live service nobody asked to delete. + """ + inst = await instances.get(instance_id, team) + if inst is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={"code": "not_found", "message": f"no such instance: {instance_id}"}, + ) + + try: + target = transition(inst.state, InstanceState.DELETING) + except IllegalTransition as exc: + # Already deleting or already deleted. Not an error the tenant can fix. + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail={ + "code": "illegal_transition", + "message": f"instance is {inst.state}; delete is not a legal transition", + }, + ) from exc + + if not await instances.update_state(inst.id, expect=inst.state, to=target): + # Someone moved the row between the read and the CAS. + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail={"code": "conflict", "message": "instance changed concurrently; retry"}, + ) + await tasks.enqueue_standalone(inst.id, TaskKind.DEPROVISION) + + return inst.model_copy(update={"state": target}) diff --git a/services/cli/__init__.py b/services/cli/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/cli/main.py b/services/cli/main.py new file mode 100644 index 0000000..024e982 --- /dev/null +++ b/services/cli/main.py @@ -0,0 +1,170 @@ +"""svcforge — the control plane client. + +This talks to the API over HTTP and never touches the database. That restraint is the +whole design: if the CLI could write to Postgres, every invariant the API enforces +(the state machine, the one-transaction create, AuthZ in the WHERE clause) would have a +back door, and the first 3am incident would go through it. +""" + +from __future__ import annotations + +import sys +import time +import uuid +from enum import StrEnum +from typing import Annotated, Any + +import httpx +import typer + +from svcforge_core.domain.states import InstanceState +from svcforge_core.settings import load_settings + +app = typer.Typer(help="svcforge control plane client", no_args_is_help=True) + +_TERMINAL = {InstanceState.READY, InstanceState.FAILED, InstanceState.DELETED} + + +class ServiceType(StrEnum): + """What the catalog offers. Kept as an enum so typer can complete and validate it.""" + + ELASTICSEARCH = "elasticsearch" + REDIS = "redis" + POSTGRES = "postgres" + + +class Size(StrEnum): + SMALL = "small" + MEDIUM = "medium" + + +def _client() -> httpx.Client: + settings = load_settings() + headers = {"authorization": f"Bearer {settings.api_token}"} if settings.api_token else {} + return httpx.Client(base_url=settings.api_url, headers=headers, timeout=10.0) + + +def _die(msg: str) -> None: + typer.secho(msg, fg=typer.colors.RED, err=True) + raise typer.Exit(code=1) + + +def _check(resp: httpx.Response) -> Any: # noqa: ANN401 - a decoded JSON body genuinely is Any + if resp.status_code == 401: + _die("401 unauthorized: check SVCFORGE_API_TOKEN") + if resp.status_code == 404: + _die("404 not found") + if resp.status_code == 429: + _die("429 rate limited: slow down") + if resp.status_code >= 400: + _die(f"{resp.status_code}: {resp.text[:400]}") + return resp.json() + + +def _parse_ttl(ttl: str | None) -> int | None: + """'7d' -> 7. Only days, because the API only takes days.""" + if ttl is None: + return None + if not ttl.endswith("d") or not ttl[:-1].isdigit(): + _die(f"bad --ttl {ttl!r}: expected something like '7d'") + return int(ttl[:-1]) + + +def _print_table(rows: list[dict[str, Any]]) -> None: + if not rows: + typer.echo("(none)") + return + cols = ["id", "service_type", "size", "state", "chart_version", "endpoint"] + widths = {c: max(len(c), *(len(str(r.get(c) or "-")) for r in rows)) for c in cols} + typer.echo(" ".join(c.ljust(widths[c]) for c in cols)) + typer.echo(" ".join("-" * widths[c] for c in cols)) + for r in rows: + typer.echo(" ".join(str(r.get(c) or "-").ljust(widths[c]) for c in cols)) + + +@app.command() +def create( + service_type: Annotated[ServiceType, typer.Argument(help="what to provision")], + size: Annotated[Size, typer.Option()] = Size.SMALL, + ttl: Annotated[str | None, typer.Option(help="e.g. 7d")] = None, + wait: Annotated[bool, typer.Option(help="poll until ready or failed")] = False, +) -> None: + """Request an instance. Returns as soon as the API accepts it (202).""" + body: dict[str, Any] = {"service_type": service_type.value, "size": size.value} + ttl_days = _parse_ttl(ttl) + if ttl_days is not None: + body["ttl_days"] = ttl_days + + with _client() as c: + data = _check(c.post("/v1/instances", json=body)) + typer.echo(f"{data['id']} {data['state']}") + if not wait: + return + + # 202 means "accepted", not "done". Polling is the client's job precisely because + # the API refused to block on a helm install that takes four minutes. + instance_id = data["id"] + deadline = time.monotonic() + 600 + state = data["state"] + while time.monotonic() < deadline: + time.sleep(2) + data = _check(c.get(f"/v1/instances/{instance_id}")) + if data["state"] != state: + state = data["state"] + typer.echo(f" -> {state}") + if state in _TERMINAL: + break + else: + _die("timed out waiting; the task may still be running — check `svcforge status`") + + if state == InstanceState.FAILED: + _die(f"failed: {data.get('error') or 'no error recorded'}") + typer.echo(f"endpoint: {data.get('endpoint') or '-'}") + + +@app.command("list") +def list_instances( + state: Annotated[InstanceState | None, typer.Option(help="filter by state")] = None, +) -> None: + """List your team's instances.""" + with _client() as c: + rows = _check(c.get("/v1/instances")) + if state is not None: + rows = [r for r in rows if r["state"] == state.value] + _print_table(rows) + + +@app.command() +def status(instance_id: Annotated[uuid.UUID, typer.Argument()]) -> None: + """Show one instance. Exits non-zero if it is failed, so scripts can branch on it.""" + with _client() as c: + data = _check(c.get(f"/v1/instances/{instance_id}")) + _print_table([data]) + if data["state"] == InstanceState.FAILED: + typer.secho(f"error: {data.get('error')}", fg=typer.colors.RED, err=True) + raise typer.Exit(code=1) + + +@app.command() +def delete( + instance_id: Annotated[uuid.UUID, typer.Argument()], + yes: Annotated[bool, typer.Option("--yes", "-y", help="skip the confirmation")] = False, +) -> None: + """Deprovision an instance.""" + if not yes and not typer.confirm(f"delete {instance_id}?"): + raise typer.Abort + with _client() as c: + data = _check(c.delete(f"/v1/instances/{instance_id}")) + typer.echo(f"{data['id']} {data['state']}") + + +def main() -> None: # pragma: no cover - console-script entrypoint + try: + app() + except httpx.ConnectError: + typer.secho("cannot reach the API: check SVCFORGE_API_URL", fg=typer.colors.RED, err=True) + sys.exit(1) + + +if __name__ == "__main__": # pragma: no cover + main() diff --git a/services/reconciler/Dockerfile b/services/reconciler/Dockerfile new file mode 100644 index 0000000..413703b --- /dev/null +++ b/services/reconciler/Dockerfile @@ -0,0 +1,53 @@ +# syntax=docker/dockerfile:1.10 +# +# svcforge reconciler. Build from the REPO ROOT: +# docker buildx build -f services/reconciler/Dockerfile -t svcforge/reconciler:dev . +# +# Reads helm state to detect drift, so it carries helm — but never kubectl, and it never +# writes: the four checks enqueue tasks, they do not provision. Orphans are logged, never +# deleted. + +FROM python:3.12-slim@sha256:57cd7c3a7a273101a6485ba99423ee568157882804b1124b4dd04266317710de AS builder + +COPY --from=ghcr.io/astral-sh/uv:0.5.11@sha256:0ac957607303916420297a4c9c213bb33fbd3c888f9cd7f4f7273596ebf42b85 /uv /usr/local/bin/uv + +ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy UV_PYTHON_DOWNLOADS=never +WORKDIR /app + +COPY pyproject.toml uv.lock ./ +COPY libs/svcforge_core/pyproject.toml libs/svcforge_core/ +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --frozen --no-dev --no-editable \ + --no-install-project --no-install-package svcforge-core + +COPY libs/ libs/ +COPY services/reconciler/ services/reconciler/ +COPY catalog.yaml ./ +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --frozen --no-dev --no-editable && \ + /app/.venv/bin/python -c 'import svcforge_core, sys; \ +p = svcforge_core.__file__; \ +sys.exit(0) if "site-packages" in p else sys.exit("not a wheel install: " + p)' + +# --- runtime -------------------------------------------------------------------------- +FROM python:3.12-slim@sha256:57cd7c3a7a273101a6485ba99423ee568157882804b1124b4dd04266317710de + +ARG BUILD_SHA=unknown +LABEL org.opencontainers.image.title="svcforge-reconciler" \ + org.opencontainers.image.source="https://gitea.oci-oci.duckdns.org/gitea_admin/svcforge" \ + org.opencontainers.image.revision="${BUILD_SHA}" + +RUN useradd -u 10001 -m -s /usr/sbin/nologin svcforge +WORKDIR /app +COPY --from=builder --chown=10001:10001 /app /app + +COPY --from=alpine/helm:3.16.2@sha256:a19a2968fd672336d39771f6c899781424d725229148656dbc2a1e305003cdec /usr/bin/helm /usr/local/bin/helm + +ENV PATH="/app/.venv/bin:$PATH" \ + PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + HELM_CACHE_HOME=/tmp/helm/cache \ + HELM_CONFIG_HOME=/tmp/helm/config \ + HELM_DATA_HOME=/tmp/helm/data +USER 10001 +ENTRYPOINT ["python", "-m", "services.reconciler.main"] diff --git a/services/reconciler/__init__.py b/services/reconciler/__init__.py new file mode 100644 index 0000000..4ecf2b1 --- /dev/null +++ b/services/reconciler/__init__.py @@ -0,0 +1 @@ +"""The reconciler service: one singleton loop that makes the world match the database.""" diff --git a/services/reconciler/main.py b/services/reconciler/main.py new file mode 100644 index 0000000..6c08cec --- /dev/null +++ b/services/reconciler/main.py @@ -0,0 +1,410 @@ +"""The control loop. + +Every other service in svcforge is edge-triggered: a tenant POSTs, a row appears, a worker +claims it. Edge-triggered systems are correct exactly as long as nothing is ever missed — +and things are missed. A worker is SIGKILLed holding a lease. An operator runs +`helm uninstall` by hand. A pod dies between the CAS and the enqueue. Nobody sends an event +for any of that, because the thing that would have sent it is the thing that died. + +So: level-triggered. Every 60 seconds, compare the world to the database and enqueue what +is missing. The four checks below do not know or care what went wrong, or whether anything +did; they are the same code on the happy path and after an outage. That property is the +entire reason this service exists, and it is why each check is written as a *query for +work*, never as a reaction to an event. + +Three rules hold the design together: + +* **Singleton.** `replicas: 1`, `strategy: Recreate` in the chart. Two reconcilers + double-enqueue drift and race on TTL. There is no leader election here on purpose: the + correct lease for that lives in Postgres next to the data, not in a Redis lock, and + until there is a second replica to elect between, an election is a subsystem that can + only fail. One pod, and the `SvcforgeReconcilerStale` alert is what notices it is gone. +* **Each check is independent.** One failing check must not skip the other three. A helm + binary that cannot reach the API server must not stop TTLs from expiring. +* **Enqueue, never act.** The reconciler diagnoses; workers treat. It writes task rows and + instance states, and never calls `helm install`. The one exception is reading — the drift + check runs `helm list`, because seeing reality is the job. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import signal +from collections.abc import Awaitable, Callable +from dataclasses import dataclass + +import typer + +from svcforge_core.adapters.clock import Clock, SystemClock +from svcforge_core.adapters.helm import HelmProvisioner, Provisioner +from svcforge_core.adapters.notify import LogNotifier, Notifier +from svcforge_core.domain.catalog import load_catalog +from svcforge_core.domain.models import CatalogEntry +from svcforge_core.domain.windows import BadWindow, parse_window, schedule_upgrade_at +from svcforge_core.obs import ( + INSTANCES, + QUEUE_DEPTH, + RECONCILER_LAST_TICK, + get_logger, + setup, + start_metrics_server, + tracer, +) +from svcforge_core.repo.db import DictPool, make_pool +from svcforge_core.repo.instances import InstanceRepo +from svcforge_core.repo.reconcile import ReconcileRepo +from svcforge_core.repo.tasks import TaskRepo +from svcforge_core.settings import Settings, load_settings + +log = get_logger("svcforge.reconciler") + +# The chart's PodMonitor scrapes the port named `metrics` on 9000. Keep them in step. +DEFAULT_METRICS_PORT = 9000 + + +@dataclass(frozen=True) +class ReconcilerDeps: + """Everything a check is allowed to touch. Built once in main(), passed down. + + Same shape as `WorkerDeps` for the same reason: the checks take `deps` instead of + reaching for globals, so the integration tests below run every check against a real + Postgres and a `FakeProvisioner` without a cluster anywhere in sight. + """ + + pool: DictPool + instances: InstanceRepo + tasks: TaskRepo + reconcile: ReconcileRepo + provisioner: Provisioner + notifier: Notifier + clock: Clock + catalog: dict[str, CatalogEntry] + settings: Settings + # Whose instances go first in the day-2 work list. Eating your own dog food is an + # `order by`, not a policy document — see InstanceRepo.list_upgradable. + own_team: str + # A config value, not a scheduler. Leave it at 1 until 1 is too slow. + max_in_flight: int + + +# --- The four checks --------------------------------------------------------------------- + + +async def check_drift(deps: ReconcilerDeps) -> None: + """`helm list -A -o json` versus what the database believes. + + This is the only check that looks outside Postgres, and the only one that can catch the + failure nothing else can: someone ran `helm uninstall` by hand, or a node was drained + and the release never came back. The DB still says `ready` and still hands the tenant an + endpoint that resolves to nothing. + + Two directions, two very different answers: + + * **Release gone, DB says `ready`** -> re-enqueue provision. Safe, because provisioning + is `helm upgrade --install` against a deterministic release name: converging on + desired state, not a blind re-install. + * **Release exists, DB knows nothing** -> log at error with release and namespace, and + stop. **Never delete in v1.** The reconciler's view of "the DB knows nothing" is one + query against one database; the release might belong to another team, another tool, + or a migration half-finished. Deleting on that evidence is how an automated system + takes down production faster than any human could. A human reads the log and decides. + """ + with tracer().start_as_current_span("helm.list"): + releases = await deps.provisioner.list_releases() + live = {(r.name, r.namespace) for r in releases} + + for inst in await deps.reconcile.ready_instances(): + if (inst.release_name, inst.namespace) in live: + continue + reason = f"drift: helm release {inst.release_name} missing from namespace {inst.namespace}" + task_id = await deps.reconcile.enqueue_reprovision(inst.id, reason) + if task_id is None: + continue # already being dealt with, or the row moved under us + log.warning( + "drift.release_missing", + instance_id=str(inst.id), + team=inst.team, + release=inst.release_name, + namespace=inst.namespace, + task_id=task_id, + ) + try: + await deps.notifier.send( + "drift.release_missing", + f"re-provisioning {inst.id}: release {inst.release_name} vanished", + {"instance_id": str(inst.id), "team": inst.team}, + ) + except Exception: + # The task is already committed; the notification is a courtesy. A webhook + # timing out must not abandon the rest of the sweep — the instances after this + # one in the loop have the same problem and nobody else is coming to find them. + log.exception("notify.failed", instance_id=str(inst.id)) + + known = await deps.reconcile.known_releases() + for name, namespace in sorted(live - known): + # error, not warning: this is a resource nobody is billing for and nobody owns. + # It will sit here every 60s until a human deletes it or adopts it. That is the + # intended pressure. + log.error("drift.orphan_release", release=name, namespace=namespace, action="none (v1 never deletes)") + + +async def check_lease_expiry(deps: ReconcilerDeps) -> None: + """Tasks whose worker died -> back to `queued`. + + A lease, not a lock. No lock survives a power cut: a worker SIGKILLed mid-provision + leaves `state='running'` with `locked_by` set and nobody running it, and no amount of + cleanup code in the worker helps, because the worker is the part that died. `locked_at` + plus a timeout is the only thing that recovers the row, which is why `locked_at` exists. + + The 5-minute default is not arbitrary: it must exceed the longest a healthy task can + hold a lease, or the reconciler hands a still-running provision to a second worker. + Handlers are idempotent, so that is survivable rather than fatal — but survivable is + not free, and `lease_seconds` sits above helm's `--timeout` for that reason. + """ + freed = await deps.tasks.reset_expired_leases(deps.settings.lease_seconds) + if freed: + log.warning("lease.expired", tasks_freed=freed, lease_seconds=deps.settings.lease_seconds) + + +async def check_ttl(deps: ReconcilerDeps) -> None: + """Expired instances -> `deleting`, plus a deprovision task. + + The line item that stops a demo cluster from becoming a permanent cloud bill. Also the + sweep the API's DELETE route depends on: it CASes to `deleting` and enqueues in two + statements, and a crash in between lands here on the next tick. + + Idempotent by construction — the work list excludes anything that already has a queued + or running deprovision, and the CAS and the insert share one transaction. Without that + guard, a deprovision that takes longer than 60 seconds gets a second task on the next + tick, and a third on the tick after. + """ + for inst in await deps.reconcile.due_for_deprovision(): + task_id = await deps.reconcile.enqueue_deprovision(inst.id) + if task_id is None: + continue + log.info( + "ttl.expired", + instance_id=str(inst.id), + team=inst.team, + expires_at=inst.expires_at.isoformat() if inst.expires_at else None, + previous_state=inst.state.value, + task_id=task_id, + ) + + +async def check_version_drift(deps: ReconcilerDeps) -> None: + """The day-2 rollout: the work-list query, one service type at a time. + + Everything that makes this safe is somewhere else, which is the point: + + * `list_upgradable` limits to `max_in_flight` and returns nothing while + `rollout_state='halted'`, so a bad chart stops after one tenant. + * `schedule_upgrade_at` turns the tenant's maintenance window into a `run_after`; the + queue does the waiting, in `where run_after <= now()`. There is no scheduler here and + there must not be one — a task parked in Postgres until 03:00 Sunday survives a + reconciler restart, and an in-memory timer does not. + * `security: true` in the catalog bypasses the window. A CVE with a public exploit does + not wait until Sunday. + + A bad window spec is this instance's problem, not the fleet's: log it and move to the + next one. Failing the whole check would let one tenant's typo freeze everyone's + security rollout. + """ + now = deps.clock.now() + + for service_type, entry in deps.catalog.items(): + candidates = await deps.instances.list_upgradable( + service_type=service_type, + catalog_version=entry.chart_version, + own_team=deps.own_team, + max_in_flight=deps.max_in_flight, + ) + for candidate in candidates: + inst = candidate.instance + try: + window = parse_window(candidate.maintenance_window) + except BadWindow: + log.exception( + "upgrade.bad_window", + instance_id=str(inst.id), + team=inst.team, + maintenance_window=candidate.maintenance_window, + ) + continue + + run_after = schedule_upgrade_at(window, security=entry.security, now=now) + task_id = await deps.reconcile.enqueue_upgrade(inst.id, run_after) + if task_id is None: + continue # already queued or running; this is the max_in_flight guard + log.info( + "upgrade.scheduled", + instance_id=str(inst.id), + team=inst.team, + service_type=service_type, + from_version=inst.chart_version, + to_version=entry.chart_version, + run_after=run_after.isoformat(), + security=entry.security, + task_id=task_id, + ) + + +CHECKS: dict[str, Callable[[ReconcilerDeps], Awaitable[None]]] = { + "drift": check_drift, + "lease_expiry": check_lease_expiry, + "ttl": check_ttl, + "version_drift": check_version_drift, +} + + +# --- The tick ---------------------------------------------------------------------------- + + +async def tick(deps: ReconcilerDeps) -> None: + """One pass: all four checks, then the gauges, then the heartbeat. + + Checks first, gauges second: `svcforge_queue_depth` is read straight after the checks + that add to the queue, so the value scraped is the value the tick left behind rather + than one from before its own work. + + The heartbeat is set unconditionally, and that is deliberate. It answers "is the loop + running", not "is everything fine" — the checks have their own alerts. Gating it on + success would make `SvcforgeReconcilerStale` fire for a helm blip and mean two things + at once, and an alert that means two things gets muted. + + The whole tick runs inside one span, which is a considered exception to "manual spans go + around helm calls only". That rule exists so the API does not hand-roll spans that + `opentelemetry-instrument` already creates for it. Nothing auto-instruments the + reconciler: without a span here it emits no traces at all, and — because + `inject_traceparent` serialises the *active* context — every task it enqueues would be + written with a null `traceparent` and be unjoinable to the tick that decided to create + it. One span per tick is what makes "why was this instance re-provisioned at 03:00?" a + question the traces can answer. + """ + with tracer().start_as_current_span("reconciler.tick"): + await _run_checks(deps) + + RECONCILER_LAST_TICK.set(deps.clock.now().timestamp()) + log.info("tick.done") + + +async def _run_checks(deps: ReconcilerDeps) -> None: + """The four checks and the gauges. Split out so `tick` reads as span + heartbeat.""" + for name, check in CHECKS.items(): + try: + await check(deps) + except Exception: # the tick is the error boundary + # The swallow is the design. These four checks share nothing but a database + # handle, and the value of a level-triggered loop is that it keeps running: an + # unreachable cluster must not stop TTLs from expiring, and one tenant's broken + # window spec must not stop drift detection. This means "this check achieved + # nothing for 60 seconds", which the log says out loud. It never means "the + # reconciler stops". + log.exception("check.failed", check=name) + + try: + QUEUE_DEPTH.set(await deps.reconcile.queue_depth()) + counts = await deps.reconcile.instance_counts() + for state, n in counts.items(): + INSTANCES.labels(state=state).set(n) + except Exception: # gauges are diagnostics; a failed read is not a failed tick + log.exception("gauges.failed") + + +async def _sleep_or_stop(stop: asyncio.Event, seconds: float) -> None: + """Sleep, but wake immediately on SIGTERM. A 60s nap must not cost 60s of shutdown.""" + with contextlib.suppress(TimeoutError): + await asyncio.wait_for(stop.wait(), timeout=seconds) + + +async def run_reconciler(deps: ReconcilerDeps, stop: asyncio.Event) -> None: + """Tick, sleep, repeat, until told to stop. + + Tick first, then sleep: a pod that has just been restarted should reconcile now, not in + sixty seconds. Fixed interval rather than a fixed period — a tick that overruns simply + delays the next one, instead of stacking a second tick on top of the first, which for a + singleton would be exactly the concurrent reconciler `replicas: 1` exists to prevent. + """ + while not stop.is_set(): + await tick(deps) + await _sleep_or_stop(stop, deps.settings.reconcile_interval_s) + + +def build_deps( + pool: DictPool, + settings: Settings, + own_team: str, + max_in_flight: int, +) -> ReconcilerDeps: + """Wire the real collaborators. The only place that names concrete classes.""" + return ReconcilerDeps( + pool=pool, + instances=InstanceRepo(pool), + tasks=TaskRepo(pool), + reconcile=ReconcileRepo(pool), + provisioner=HelmProvisioner(helm_bin=settings.helm_bin, timeout_s=int(settings.helm_timeout_s)), + notifier=LogNotifier(), + clock=SystemClock(), + catalog=load_catalog(settings.catalog_path), + settings=settings, + own_team=own_team, + max_in_flight=max_in_flight, + ) + + +async def _amain(once: bool, metrics_port: int, own_team: str, max_in_flight: int) -> None: + settings = load_settings() + setup("svcforge-reconciler", settings) + + pool = make_pool(settings.pg_dsn.unicode_string(), settings.pool_min_size, settings.pool_max_size) + await pool.open(wait=True) + deps = build_deps(pool, settings, own_team, max_in_flight) + + try: + if once: + # One pass and exit: the acceptance path, and how you drive a reconcile by hand + # from a shell. No metrics server — nothing would ever scrape it. + await tick(deps) + return + + start_metrics_server(metrics_port) + + stop = asyncio.Event() + loop = asyncio.get_running_loop() + for sig in (signal.SIGTERM, signal.SIGINT): + # add_signal_handler, NOT signal.signal. signal.signal fires the handler at an + # arbitrary bytecode boundary on the main thread and the loop does not notice + # until its next timer — which here is up to a full 60s tick away. This one is + # scheduled as an ordinary loop callback, so the `stop.wait()` above returns + # immediately. + loop.add_signal_handler(sig, stop.set) + + await run_reconciler(deps, stop) + finally: + await pool.close() + + +app = typer.Typer(add_completion=False, help="svcforge reconciler: the control loop.") + + +@app.command() +def main( + once: bool = typer.Option(False, "--once", help="Run one tick and exit."), + metrics_port: int = typer.Option( + DEFAULT_METRICS_PORT, envvar="SVCFORGE_METRICS_PORT", help="Port for /metrics." + ), + own_team: str = typer.Option( + "platform", envvar="SVCFORGE_OWN_TEAM", help="Team whose instances upgrade first." + ), + max_in_flight: int = typer.Option( + 1, envvar="SVCFORGE_MAX_IN_FLIGHT", min=1, help="Concurrent upgrades across the fleet." + ), +) -> None: + """Run the reconciler.""" + # One asyncio.run, at the top, never nested. Everything below it is already async. + asyncio.run(_amain(once, metrics_port, own_team, max_in_flight)) + + +if __name__ == "__main__": + app() diff --git a/services/worker/Dockerfile b/services/worker/Dockerfile new file mode 100644 index 0000000..7d9ec11 --- /dev/null +++ b/services/worker/Dockerfile @@ -0,0 +1,54 @@ +# syntax=docker/dockerfile:1.10 +# +# svcforge worker. Build from the REPO ROOT: +# docker buildx build -f services/worker/Dockerfile -t svcforge/worker:dev . +# +# The only service that shells out to helm/kubectl, so the only one carrying those two +# binaries. They are copied from pinned images rather than curl'd, so the version is a +# reviewable line in a Dockerfile instead of a network call at build time. + +FROM python:3.12-slim@sha256:57cd7c3a7a273101a6485ba99423ee568157882804b1124b4dd04266317710de AS builder + +COPY --from=ghcr.io/astral-sh/uv:0.5.11@sha256:0ac957607303916420297a4c9c213bb33fbd3c888f9cd7f4f7273596ebf42b85 /uv /usr/local/bin/uv + +ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy UV_PYTHON_DOWNLOADS=never +WORKDIR /app + +COPY pyproject.toml uv.lock ./ +COPY libs/svcforge_core/pyproject.toml libs/svcforge_core/ +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --frozen --no-dev --no-editable \ + --no-install-project --no-install-package svcforge-core + +COPY libs/ libs/ +COPY services/worker/ services/worker/ +COPY catalog.yaml ./ +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --frozen --no-dev --no-editable && \ + /app/.venv/bin/python -c 'import svcforge_core, sys; \ +p = svcforge_core.__file__; \ +sys.exit(0) if "site-packages" in p else sys.exit("not a wheel install: " + p)' + +# --- runtime -------------------------------------------------------------------------- +FROM python:3.12-slim@sha256:57cd7c3a7a273101a6485ba99423ee568157882804b1124b4dd04266317710de + +ARG BUILD_SHA=unknown +LABEL org.opencontainers.image.title="svcforge-worker" \ + org.opencontainers.image.source="https://gitea.oci-oci.duckdns.org/gitea_admin/svcforge" \ + org.opencontainers.image.revision="${BUILD_SHA}" + +RUN useradd -u 10001 -m -s /usr/sbin/nologin svcforge +WORKDIR /app +COPY --from=builder --chown=10001:10001 /app /app + +COPY --from=alpine/helm:3.16.2@sha256:a19a2968fd672336d39771f6c899781424d725229148656dbc2a1e305003cdec /usr/bin/helm /usr/local/bin/helm +COPY --from=bitnamilegacy/kubectl:1.31.2@sha256:0eab9ec8f5e0f75271277467ebb7513b36dea0122bc68615d18c47fece4fd82c /opt/bitnami/kubectl/bin/kubectl /usr/local/bin/kubectl + +ENV PATH="/app/.venv/bin:$PATH" \ + PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + HELM_CACHE_HOME=/tmp/helm/cache \ + HELM_CONFIG_HOME=/tmp/helm/config \ + HELM_DATA_HOME=/tmp/helm/data +USER 10001 +ENTRYPOINT ["python", "-m", "services.worker.main"] diff --git a/services/worker/__init__.py b/services/worker/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/worker/deps.py b/services/worker/deps.py new file mode 100644 index 0000000..83e48f7 --- /dev/null +++ b/services/worker/deps.py @@ -0,0 +1,33 @@ +"""What a worker needs to do its job. + +One frozen bag of collaborators, constructed once in main() and passed down. Handlers +take `deps` rather than reaching for globals, which is the entire reason the worker tests +run in milliseconds against a FakeProvisioner instead of needing a cluster. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from svcforge_core.adapters.clock import Clock +from svcforge_core.adapters.helm import Provisioner +from svcforge_core.adapters.notify import Notifier +from svcforge_core.domain.models import CatalogEntry +from svcforge_core.repo.db import DictPool +from svcforge_core.repo.instances import InstanceRepo +from svcforge_core.repo.tasks import TaskRepo +from svcforge_core.settings import Settings + + +@dataclass(frozen=True) +class WorkerDeps: + """Everything a handler is allowed to touch.""" + + pool: DictPool + instances: InstanceRepo + tasks: TaskRepo + provisioner: Provisioner + notifier: Notifier + clock: Clock + catalog: dict[str, CatalogEntry] + settings: Settings diff --git a/services/worker/handlers.py b/services/worker/handlers.py new file mode 100644 index 0000000..7492b93 --- /dev/null +++ b/services/worker/handlers.py @@ -0,0 +1,161 @@ +"""Task handlers. + +Every handler here obeys one rule: running it twice must equal running it once. + +That is not a nicety. A worker can be SIGKILLed after helm has installed the release but +before the DB row says so; the lease expires; another worker claims the same task and runs +this function again. If the handler is not idempotent, the tenant gets two Elasticsearches +and you get a bill. Idempotency is what makes the crash safe, and it is bought in two +places: a deterministic `release_name`, and adapters that state desired state +(`helm upgrade --install`) instead of issuing imperative commands. +""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from typing import Any + +from services.worker.deps import WorkerDeps +from svcforge_core.domain.models import CatalogEntry, Instance, Task, TaskKind +from svcforge_core.domain.states import InstanceState + + +class HandlerError(RuntimeError): + """A task failed in a way worth retrying. The message lands in tasks.last_error.""" + + +async def _load_instance(task: Task, deps: WorkerDeps) -> Instance: + async with deps.pool.connection() as conn, conn.cursor() as cur: + await cur.execute( + """select id, team, service_type, size, state, namespace, release_name, + chart_version, endpoint, error, expires_at, created_at, updated_at + from instances where id = %s""", + (task.instance_id,), + ) + row = await cur.fetchone() + if row is None: + raise HandlerError(f"instance {task.instance_id} vanished") + return Instance.model_validate(row) + + +def _values_for(inst: Instance, entry: CatalogEntry) -> dict[str, Any]: + """Turn a catalog size into helm values.""" + size = entry.sizes.get(inst.size) + if size is None: + raise HandlerError(f"size {inst.size!r} not in catalog for {inst.service_type!r}") + return {"replicaCount": size.replicas, "resources": size.resources} + + +async def handle_provision(task: Task, deps: WorkerDeps) -> None: + """Install the release and mark the instance ready. Idempotent.""" + inst = await _load_instance(task, deps) + + if inst.state is InstanceState.READY: + # A previous attempt already finished; the crash was after the work, before the + # bookkeeping. Nothing to do — and re-installing would be the bug. + return + + entry = deps.catalog.get(inst.service_type) + if entry is None: + raise HandlerError(f"unknown service_type {inst.service_type!r}") + + # Best-effort CAS. It returning False means someone else moved the row; the helm call + # below is idempotent either way, so this is bookkeeping, not a lock. + await deps.instances.update_state(inst.id, InstanceState.REQUESTED, InstanceState.PROVISIONING) + + await deps.provisioner.install( + release=inst.release_name, + ns=inst.namespace, + entry=entry, + values=_values_for(inst, entry), + ) + + endpoint = f"http://{inst.release_name}.{inst.namespace}.svc.cluster.local" + ok = await deps.instances.update_state( + inst.id, InstanceState.PROVISIONING, InstanceState.READY, endpoint=endpoint + ) + if ok: + await deps.notifier.send( + "instance.ready", + f"instance {inst.id} is ready at {endpoint}", + {"instance_id": str(inst.id), "team": inst.team, "service_type": inst.service_type}, + ) + + +async def handle_deprovision(task: Task, deps: WorkerDeps) -> None: + """Remove the release and mark the instance deleted. Idempotent.""" + inst = await _load_instance(task, deps) + + if inst.state is InstanceState.DELETED: + return + + # `helm uninstall` of an already-gone release is not an error to us: the adapter + # swallows not-found, because the desired state — no release — is already true. + await deps.provisioner.uninstall(release=inst.release_name, ns=inst.namespace) + await deps.instances.update_state(inst.id, InstanceState.DELETING, InstanceState.DELETED) + + +async def handle_upgrade(task: Task, deps: WorkerDeps) -> None: + """Upgrade the release to the catalog's pinned version, then record it. + + `instances.chart_version` is written only AFTER helm reports success. That column is + what the day-2 work-list query compares against, so writing it optimistically would + make the fleet look upgraded while it isn't. + """ + inst = await _load_instance(task, deps) + entry = deps.catalog.get(inst.service_type) + if entry is None: + raise HandlerError(f"unknown service_type {inst.service_type!r}") + + if inst.chart_version == entry.chart_version: + return # already there + + await deps.provisioner.install( + release=inst.release_name, + ns=inst.namespace, + entry=entry, + values=_values_for(inst, entry), + ) + + async with deps.pool.connection() as conn, conn.cursor() as cur: + await cur.execute( + "update instances set chart_version = %s, updated_at = now() where id = %s", + (entry.chart_version, inst.id), + ) + + +async def handle_verify(task: Task, deps: WorkerDeps) -> None: + """Post-upgrade health probe. On failure, halt the whole rollout for this service type. + + One column decides whether the fleet keeps rolling. The work-list query returns nothing + while `rollout_state='halted'`, so a bad chart stops after the first tenant instead of + after all of them. You clear it with SQL, deliberately: an automatic un-halt would just + resume breaking things. + """ + inst = await _load_instance(task, deps) + releases = {r.name for r in await deps.provisioner.list_releases()} + + if inst.release_name in releases: + return + + async with deps.pool.connection() as conn, conn.cursor() as cur: + await cur.execute( + """insert into catalog_versions (service_type, rollout_state) + values (%s, 'halted') + on conflict (service_type) do update set rollout_state = 'halted'""", + (inst.service_type,), + ) + await deps.notifier.send( + "rollout.halted", + f"rollout halted for {inst.service_type}: {inst.release_name} failed verify", + {"instance_id": str(inst.id), "team": inst.team, "service_type": inst.service_type}, + ) + raise HandlerError(f"verify failed for {inst.release_name}; rollout halted") + + +HANDLERS: dict[TaskKind, Callable[[Task, WorkerDeps], Awaitable[None]]] = { + TaskKind.PROVISION: handle_provision, + TaskKind.DEPROVISION: handle_deprovision, + TaskKind.UPGRADE: handle_upgrade, + TaskKind.VERIFY: handle_verify, +} diff --git a/services/worker/main.py b/services/worker/main.py new file mode 100644 index 0000000..2df47c3 --- /dev/null +++ b/services/worker/main.py @@ -0,0 +1,175 @@ +"""The claim loop. + +Poll every 5 seconds. Claim while a semaphore slot is free. Run the handler. Report. +That is the whole design, and the restraint is the point: LISTEN/NOTIFY would shave the +latency, is fire-and-forget so it can never replace the poll anyway, is strictly extra +code, and does not exist on pgbouncer's transaction pooler. The poll is not a placeholder +for something better. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import signal +import time + +from opentelemetry import trace + +from services.worker.deps import WorkerDeps +from services.worker.handlers import HANDLERS +from svcforge_core import obs +from svcforge_core.adapters.clock import SystemClock +from svcforge_core.adapters.helm import HelmProvisioner +from svcforge_core.adapters.notify import LogNotifier +from svcforge_core.domain.catalog import load_catalog +from svcforge_core.domain.models import Task +from svcforge_core.repo.db import make_pool +from svcforge_core.repo.instances import InstanceRepo +from svcforge_core.repo.tasks import TaskRepo +from svcforge_core.settings import Settings, load_settings + +log = obs.get_logger("svcforge.worker") + + +async def _sleep_or_stop(stop: asyncio.Event, seconds: float) -> None: + """Sleep, but wake immediately on shutdown. + + `await asyncio.sleep(5)` would make every SIGTERM cost up to five seconds of + Kubernetes waiting on terminationGracePeriod for no reason. + """ + with contextlib.suppress(TimeoutError): + await asyncio.wait_for(stop.wait(), timeout=seconds) + + +async def _run_one(deps: WorkerDeps, task: Task, sem: asyncio.Semaphore) -> None: + """Run one task to a terminal report. Never lets an exception escape the TaskGroup.""" + try: + # Every log line from here carries instance_id/task_id/team. Bound once, at claim, + # rather than passed down: the alternative is threading three arguments through + # every function that might log, and the first one anyone forgets is the one you + # need at 3am. + obs.bind_task_context(task.instance_id, task.id, team=task.team or "unknown") + log.info("task claimed", kind=task.kind.value, attempt=task.attempts) + obs.TASKS_CLAIMED.labels(kind=task.kind.value).inc() + + handler = HANDLERS.get(task.kind) + if handler is None: + await deps.tasks.fail(task.id, f"no handler for {task.kind}", max_attempts=1) + return + + # Re-parent to the span that enqueued this task. Without the stored traceparent + # the worker's span starts a brand-new trace, and the POST that caused the work + # is in a different trace to the helm call that did it. + ctx = obs.context_from_traceparent(task.traceparent) + started = time.monotonic() + with obs.tracer().start_as_current_span( + f"task.{task.kind.value}", + context=ctx, + kind=trace.SpanKind.CONSUMER, + ) as span: + span.set_attribute("task.id", task.id) + span.set_attribute("task.kind", task.kind.value) + span.set_attribute("instance.id", str(task.instance_id)) + try: + await handler(task, deps) + except asyncio.CancelledError: + # CancelledError inherits from BaseException, so `except Exception` below + # would never see it. Catch it only to release the claim, then ALWAYS + # re-raise: swallowing it breaks cancellation for everyone above us. + await deps.tasks.fail(task.id, "cancelled", max_attempts=deps.settings.max_attempts) + raise + except Exception as exc: + log.exception("task failed", kind=task.kind.value, error=str(exc)) + span.record_exception(exc) + span.set_status(trace.Status(trace.StatusCode.ERROR, str(exc))) + obs.TASKS_FAILED.labels(kind=task.kind.value).inc() + await deps.tasks.fail(task.id, str(exc), max_attempts=deps.settings.max_attempts) + else: + obs.PROVISION_TIME.observe(time.monotonic() - started) + await deps.tasks.complete(task.id) + finally: + sem.release() + + +async def run_worker(deps: WorkerDeps, stop: asyncio.Event) -> None: + """Claim and run until told to stop, then drain what is in flight. + + Draining is what makes a rolling deploy invisible. Exiting the `async with` block + awaits every in-flight handler, so a pod that is being replaced finishes the provision + it already started instead of abandoning it half-done for the lease to clean up + five minutes later. + """ + sem = asyncio.Semaphore(deps.settings.worker_concurrency) + worker_id = deps.settings.worker_id + + async with asyncio.TaskGroup() as tg: + while not stop.is_set(): + await sem.acquire() + if stop.is_set(): + sem.release() + break + + try: + task = await deps.tasks.claim(worker_id) + except Exception: + # A DB blip must not kill the worker; back off and try again. + log.exception("claim failed") + sem.release() + await _sleep_or_stop(stop, deps.settings.poll_interval_s) + continue + + if task is None: + sem.release() + await _sleep_or_stop(stop, deps.settings.poll_interval_s) + continue + + tg.create_task(_run_one(deps, task, sem)) + # TaskGroup.__aexit__ awaited the in-flight handlers. Now it is safe to exit 0. + + +async def _amain() -> None: + settings: Settings = load_settings() + + # Before anything else: nothing logged above this line is structured, and the metrics + # the SvcforgeTaskFailed / SvcforgeProvisionSlow alerts query do not exist until the + # registry is up. + obs.setup("svcforge-worker", settings) + obs.start_metrics_server(settings.metrics_port) + + pool = make_pool(settings.pg_dsn.unicode_string(), settings.pool_min_size, settings.pool_max_size) + await pool.open(wait=True) + + deps = WorkerDeps( + pool=pool, + instances=InstanceRepo(pool), + tasks=TaskRepo(pool), + provisioner=HelmProvisioner(helm_bin=settings.helm_bin, timeout_s=int(settings.helm_timeout_s)), + notifier=LogNotifier(), + clock=SystemClock(), + catalog=load_catalog(settings.catalog_path), + settings=settings, + ) + + stop = asyncio.Event() + loop = asyncio.get_running_loop() + for sig in (signal.SIGTERM, signal.SIGINT): + # add_signal_handler, NOT signal.signal. signal.signal runs the handler at an + # arbitrary bytecode boundary on whatever thread the C-level handler lands on, + # and the event loop will not notice until its next timer fires. This one is + # loop-safe: the callback runs as a normal loop callback. + loop.add_signal_handler(sig, stop.set) + + try: + await run_worker(deps, stop) + finally: + await pool.close() + + +def main() -> None: + """One asyncio.run, at the top, never nested.""" + asyncio.run(_amain()) + + +if __name__ == "__main__": + main() diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/e2e/__init__.py b/tests/e2e/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/e2e/test_provision_real.py b/tests/e2e/test_provision_real.py new file mode 100644 index 0000000..ac6407c --- /dev/null +++ b/tests/e2e/test_provision_real.py @@ -0,0 +1,133 @@ +"""The real thing: a real helm, against a real cluster, installing a real chart. + +Everything else in this suite runs against a FakeProvisioner, which is what makes the +worker tests take milliseconds. That trade has one cost, and this file is the payment: the +fake proves the *worker* is correct, and cannot prove the *adapter* is. Argv order, the +`--wait` flag, chart resolution, RBAC, whether `upgrade --install` is genuinely idempotent +against a live release — none of it is exercised by a fake that returns None. + +So one test does it for real, exactly once. It is marked `e2e` and excluded from `make test` +and from every pre-commit run; CI runs `-m e2e` in a job that has a cluster. + + kind create cluster --name svcforge + uv run pytest -m e2e -q + +It skips (does not fail) with no cluster, because a laptop without kubectl is not a +regression. +""" + +from __future__ import annotations + +import asyncio +import shutil +import subprocess +import uuid + +import pytest + +from svcforge_core.adapters.helm import HelmProvisioner +from svcforge_core.domain.models import CatalogEntry, SizeSpec + +pytestmark = [pytest.mark.e2e, pytest.mark.slow] + +NAMESPACE = "svcforge-e2e" + +# A chart with no dependencies, no PVCs and no image pulls worth waiting on. The point is +# to exercise the adapter, not to wait four minutes for Elasticsearch. +ENTRY = CatalogEntry( + service_type="podinfo", + chart="oci://ghcr.io/stefanprodan/charts/podinfo", + chart_version="6.7.1", + sizes={"small": SizeSpec(replicas=1, resources={})}, +) + + +def _cluster_reachable() -> bool: + if not shutil.which("helm") or not shutil.which("kubectl"): + return False + out = subprocess.run( + ["kubectl", "cluster-info"], + capture_output=True, + timeout=15, + ) + return out.returncode == 0 + + +requires_cluster = pytest.mark.skipif( + not _cluster_reachable(), + reason="no reachable cluster: `kind create cluster --name svcforge`", +) + + +@pytest.fixture(scope="module") +def namespace() -> str: + subprocess.run( + ["kubectl", "create", "namespace", NAMESPACE], + capture_output=True, + check=False, # already exists is fine — the whole system is idempotent or it is broken + ) + return NAMESPACE + + +@requires_cluster +async def test_install_is_idempotent_against_a_real_cluster(namespace: str) -> None: + """Install twice. Get one release. This is the claim the fake cannot make for us.""" + release = f"e2e-podinfo-{uuid.uuid4().hex[:8]}" + prov = HelmProvisioner(timeout_s=300) + + try: + await prov.install(release=release, ns=namespace, entry=ENTRY, values={"replicaCount": 1}) + + releases = [r for r in await prov.list_releases() if r.name == release] + assert len(releases) == 1, f"expected exactly one release, got {releases}" + + # The redelivery, for real: same task, same deterministic release name, run again. + # `helm upgrade --install` must converge, not duplicate and not error. + await prov.install(release=release, ns=namespace, entry=ENTRY, values={"replicaCount": 1}) + + releases = [r for r in await prov.list_releases() if r.name == release] + assert len(releases) == 1, "second install created a second release: not idempotent" + + # --wait means the DB writing `ready` is telling the truth. + out = subprocess.run( + [ + "kubectl", + "get", + "deploy", + "-n", + namespace, + "-l", + f"app.kubernetes.io/instance={release}", + "-o", + "jsonpath={.items[*].status.readyReplicas}", + ], + capture_output=True, + text=True, + timeout=30, + ) + assert out.stdout.strip() == "1", f"--wait returned before the pod was ready: {out.stdout!r}" + finally: + await prov.uninstall(release=release, ns=namespace) + + +@requires_cluster +async def test_uninstall_of_a_missing_release_is_not_an_error(namespace: str) -> None: + """The desired state — no release — is already true. That is success, not failure.""" + prov = HelmProvisioner(timeout_s=120) + await prov.uninstall(release=f"never-existed-{uuid.uuid4().hex[:8]}", ns=namespace) + + +@requires_cluster +async def test_real_helm_timeout_leaves_no_helm_behind(namespace: str) -> None: + """A deadline that does not kill helm is a deadline that lets helm keep mutating.""" + prov = HelmProvisioner(timeout_s=1) # cannot possibly finish + release = f"e2e-timeout-{uuid.uuid4().hex[:8]}" + + with pytest.raises((TimeoutError, Exception)): + await prov.install(release=release, ns=namespace, entry=ENTRY, values={}) + + await asyncio.sleep(0.5) + out = subprocess.run(["pgrep", "-f", f"helm.*{release}"], capture_output=True, text=True) + assert out.stdout.strip() == "", "helm survived its own timeout and is still touching the cluster" + + await prov.uninstall(release=release, ns=namespace) diff --git a/tests/fakes.py b/tests/fakes.py new file mode 100644 index 0000000..48de2c7 --- /dev/null +++ b/tests/fakes.py @@ -0,0 +1,210 @@ +"""Test doubles. The second implementation that makes each Protocol worth having. + +These are fakes, not mocks: they have working behaviour (a dict of releases, a monotonic +counter) and are asserted against by their *state*, not by "was this method called with +these arguments". A mock proves your mock does what you told it to. + +They live here, outside `svcforge_core`, so that no production import path can reach them. +""" + +from __future__ import annotations + +import asyncio +from datetime import UTC, datetime, timedelta +from typing import Any +from uuid import UUID + +from svcforge_core.adapters.clock import Clock +from svcforge_core.adapters.helm import HelmError, ReleaseInfo +from svcforge_core.adapters.redis import RateLimitResult +from svcforge_core.domain.models import CatalogEntry, Instance + + +class FakeProvisioner: + """Dict of release -> ReleaseInfo. Same signatures. Optional fail_on / delay for tests. + + This is what makes the worker's tests run in milliseconds with no cluster. + """ + + def __init__(self, *, delay: float = 0.0, fail_on: set[str] | None = None) -> None: + """`delay`: seconds `install` sleeps, to hold a task in flight (SIGTERM drain tests). + + `fail_on`: substring match against the release name, not equality — release names + carry a random suffix (`platform-elasticsearch-1a2b3c4d`), so a test that wants "every + elasticsearch install fails" cannot name the release up front. + """ + self.delay = delay + self.fail_on = fail_on or set() + + self.releases: dict[str, ReleaseInfo] = {} + self.installed: list[str] = [] + self.uninstalled: list[str] = [] + + # Concurrency accounting, so a test can assert the worker's semaphore actually caps. + self.in_flight = 0 + self.max_concurrent = 0 + + def _should_fail(self, release: str) -> bool: + return any(needle in release for needle in self.fail_on) + + async def install(self, release: str, ns: str, entry: CatalogEntry, values: dict[str, Any]) -> None: + self.in_flight += 1 + self.max_concurrent = max(self.max_concurrent, self.in_flight) + try: + if self.delay: + await asyncio.sleep(self.delay) + if self._should_fail(release): + # Same type the real adapter raises, so handlers cannot pass here and fail in prod. + raise HelmError(f"fake: install of {release} failed on purpose") + self.releases[release] = ReleaseInfo( + name=release, + namespace=ns, + chart=f"{entry.chart}-{entry.chart_version}", + status="deployed", + revision=self.releases[release].revision + 1 if release in self.releases else 1, + app_version=entry.chart_version, + ) + # Appended only on success: a failed install must not look installed. + self.installed.append(release) + finally: + self.in_flight -= 1 + + async def uninstall(self, release: str, ns: str) -> None: + """Absent release is not an error: the desired state is already true.""" + self.releases.pop(release, None) + self.uninstalled.append(release) + + async def list_releases(self) -> list[ReleaseInfo]: + return list(self.releases.values()) + + +class FakeClock: + """Time under test control. No sleeping, no monkeypatching the stdlib.""" + + def __init__(self, start: datetime) -> None: + if start.tzinfo is None: + raise ValueError("FakeClock needs an aware datetime; a naive start defeats the point") + self._now = start + + def now(self) -> datetime: + return self._now + + def advance(self, delta: timedelta) -> None: + """Move forward. Only forward — a clock that goes backwards is a different bug entirely.""" + if delta < timedelta(0): + raise ValueError("FakeClock cannot go backwards") + self._now += delta + + +class FakeNotifier: + """Records what it was asked to send. Never leaves the process.""" + + def __init__(self) -> None: + self.sent: list[tuple[str, str, dict[str, str]]] = [] + + async def send(self, event: str, message: str, fields: dict[str, str] | None = None) -> None: + self.sent.append((event, message, fields or {})) + + def events(self) -> list[str]: + return [event for event, _, _ in self.sent] + + +# --- Redis (Module 10) ------------------------------------------------------------------ +# +# Three fakes, and each one is the second implementation that earns its Protocol. They also +# earn their keep against the budget: Upstash's free tier is 500K commands/month, so a test +# that wants a thousand rate-limit checks runs them here and spends nothing. Real Upstash +# is reserved for the handful of `@pytest.mark.slow` tests that prove the wire protocol, +# the Lua, and the command count. +# +# `down=True` is the interesting knob. Every real class degrades internally rather than +# raising, so these degrade the same way — a fake that raises when the real one returns a +# safe default would let a caller ship a `try/except` that production never exercises. + + +class FakeRateLimiter: + """In-memory fixed window. Same semantics as the Lua, none of the network. + + Counts `commands` so a test can assert the one-command-per-check budget without a + server, and takes a Clock so a window rollover is an `advance()` rather than a sleep. + """ + + def __init__(self, limit: int, window_s: int, clock: Clock, *, down: bool = False) -> None: + self.limit = limit + self.window_s = window_s + self.clock = clock + self.down = down + self.commands = 0 + self.counts: dict[str, int] = {} + + async def check(self, team: str) -> RateLimitResult: + window = int(self.clock.now().timestamp()) // self.window_s + reset_at = datetime.fromtimestamp((window + 1) * self.window_s, tz=UTC) + self.commands += 1 + if self.down: + # Fails OPEN, exactly like the real one. A limiter that refused here would make + # "Redis is down" indistinguishable from "you are over quota". + return RateLimitResult( + allowed=True, limit=self.limit, remaining=self.limit, reset_at=reset_at, degraded=True + ) + key = f"rl:{team}:{window}" + n = self.counts.get(key, 0) + 1 + self.counts[key] = n + return RateLimitResult( + allowed=n <= self.limit, + limit=self.limit, + remaining=max(0, self.limit - n), + reset_at=reset_at, + ) + + +class FakeIdempotencyStore: + """A dict with SET-NX semantics. No TTL: no test outlives one.""" + + def __init__(self, *, down: bool = False) -> None: + self.down = down + self.claims: dict[str, UUID] = {} + + async def claim(self, key: str, instance_id: UUID) -> UUID | None: + if self.down: + # Falls through to the DB, where `instances.release_name` is UNIQUE. The real + # guarantee was never here. + return None + existing = self.claims.get(key) + if existing is not None: + return existing + self.claims[key] = instance_id + return None + + +class FakeInstanceCache: + """A dict, plus hit/miss accounting so a test can assert the second read never hits the DB.""" + + def __init__(self, *, down: bool = False) -> None: + self.down = down + self.entries: dict[UUID, Instance] = {} + self.hits = 0 + self.misses = 0 + self.invalidations: list[UUID] = [] + + async def get(self, instance_id: UUID) -> Instance | None: + if self.down: + self.misses += 1 + return None + inst = self.entries.get(instance_id) + if inst is None: + self.misses += 1 + return None + self.hits += 1 + return inst + + async def put(self, inst: Instance) -> None: + if self.down: + return + self.entries[inst.id] = inst + + async def invalidate(self, instance_id: UUID) -> None: + self.invalidations.append(instance_id) + if self.down: + return + self.entries.pop(instance_id, None) diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py new file mode 100644 index 0000000..a5f192f --- /dev/null +++ b/tests/integration/conftest.py @@ -0,0 +1,115 @@ +"""Integration fixtures: a real Postgres, real SQL, no mocks. + +The DB is never mocked. A mocked database proves your mock returns what you told it to. +Every bug worth catching here — SKIP LOCKED semantics, CAS rowcounts, transaction +rollback, `timestamptz` round-tripping — lives in the part a mock replaces. + +Two ways to get a database, in priority order: + +1. `SVCFORGE_TEST_DSN` in the environment — an already-running Postgres. This is the + path on a host that has no Docker daemon (for example one whose containerd belongs + to a Kubernetes kubelet, where installing Docker would evict the runtime). +2. testcontainers, which starts `postgres:16-alpine` and throws it away after. This is + the CI path. + +Same tests either way. +""" + +from __future__ import annotations + +import os +from collections.abc import AsyncIterator, Iterator +from pathlib import Path +from urllib.parse import urlsplit, urlunsplit + +import psycopg +import pytest +import pytest_asyncio + +from svcforge_core.repo.db import DictPool, make_pool + +MIGRATIONS = Path(__file__).resolve().parents[2] / "migrations" + + +def _apply_migrations(dsn: str) -> None: + """Run every migration in lexical order, one transaction each. + + No `create table if not exists` and no reset: every caller hands this a database that + was created moments ago (a per-process clone, or a fresh container), so the schema is + always empty and the migrations always apply cleanly from zero. + """ + with psycopg.connect(dsn, autocommit=True) as conn: + for path in sorted(MIGRATIONS.glob("*.sql")): + with conn.transaction(), conn.cursor() as cur: + cur.execute(path.read_text(encoding="utf-8")) + + +def _private_database(admin_dsn: str) -> Iterator[str]: + """Clone a scratch database for THIS pytest process only, and drop it after. + + The `pool` fixture truncates between tests. That is correct within one process and + catastrophic across several: two pytest runs sharing a database truncate each other's + rows mid-test, and the failures look like real bugs in the code under test rather than + like the harness eating itself. Isolating per process makes concurrent runs + (several agents, or pytest-xdist -n auto) simply work. + """ + name = f"svcforge_test_{os.getpid()}" + parsed = urlsplit(admin_dsn) + + with psycopg.connect(admin_dsn, autocommit=True) as conn: + conn.execute(f'drop database if exists "{name}"') + conn.execute(f'create database "{name}"') + + dsn = urlunsplit(parsed._replace(path=f"/{name}")) + try: + _apply_migrations(dsn) + yield dsn + finally: + with psycopg.connect(admin_dsn, autocommit=True) as conn: + # Boot any lingering connections, or the drop blocks forever. + conn.execute( + "select pg_terminate_backend(pid) from pg_stat_activity where datname = %s", + (name,), + ) + conn.execute(f'drop database if exists "{name}"') + + +@pytest.fixture(scope="session") +def pg_dsn() -> Iterator[str]: + """A migrated Postgres, private to this process, from the environment or a container.""" + env_dsn = os.getenv("SVCFORGE_TEST_DSN") + if env_dsn: + yield from _private_database(env_dsn) + return + + try: + from testcontainers.postgres import PostgresContainer + except ImportError: # pragma: no cover - CI always has it + pytest.skip("set SVCFORGE_TEST_DSN or install testcontainers") + + # A container is already private to this process; no need to clone inside it. + with PostgresContainer("postgres:16-alpine", driver=None) as pg: + dsn = pg.get_connection_url() + _apply_migrations(dsn) + yield dsn + + +@pytest_asyncio.fixture +async def pool(pg_dsn: str) -> AsyncIterator[DictPool]: + """A clean database and an open pool, per test. + + max_size=60 is not a performance choice. test_skip_locked_claims_each_task_exactly_once + races 50 concurrent claims; a pool smaller than that serialises them at the pool + instead of at the database, and the test passes while proving nothing. + """ + async with await psycopg.AsyncConnection.connect(pg_dsn, autocommit=True) as conn: + # catalog_versions joins the list because a halted rollout is sticky by design: + # leave it behind and every later test in the session sees an empty work list. + await conn.execute("truncate tasks, instances, catalog_versions restart identity cascade") + + p = make_pool(pg_dsn, min_size=1, max_size=60) + await p.open(wait=True) + try: + yield p + finally: + await p.close() diff --git a/tests/integration/helpers.py b/tests/integration/helpers.py new file mode 100644 index 0000000..8014477 --- /dev/null +++ b/tests/integration/helpers.py @@ -0,0 +1,44 @@ +"""Builders for integration tests. Keeps the tests about the behaviour under test.""" + +from __future__ import annotations + +from datetime import UTC, datetime +from uuid import UUID, uuid4 + +from svcforge_core.domain.models import Instance +from svcforge_core.domain.states import InstanceState +from svcforge_core.repo.db import DictPool +from svcforge_core.repo.instances import InstanceRepo + + +def build_instance( + team: str = "platform", + service_type: str = "elasticsearch", + size: str = "small", + state: InstanceState = InstanceState.REQUESTED, + chart_version: str = "21.3.19", +) -> Instance: + """An Instance with a deterministic release_name, as the domain requires.""" + iid = uuid4() + now = datetime.now(UTC) + return Instance( + id=iid, + team=team, + service_type=service_type, + size=size, + state=state, + namespace=f"tenant-{team}", + release_name=f"{team}-{service_type}-{str(iid)[:8]}", + chart_version=chart_version, + created_at=now, + updated_at=now, + ) + + +async def make_instance(pool: DictPool, **kwargs: object) -> UUID: + """Insert an instance and return its id.""" + inst = build_instance(**kwargs) # type: ignore[arg-type] + repo = InstanceRepo(pool) + async with pool.connection() as conn: + await repo.create(conn, inst) + return inst.id diff --git a/tests/integration/test_api.py b/tests/integration/test_api.py new file mode 100644 index 0000000..f5d5f59 --- /dev/null +++ b/tests/integration/test_api.py @@ -0,0 +1,582 @@ +"""API integration tests: real app, real Postgres, real JWTs. No mocks. + +`httpx.ASGITransport` calls the app in-process — no uvicorn, no socket, no port to race +over. It exercises the same routing, dependency resolution and lifespan a real request +would; only the TCP hop is gone. + +The JWTs here are real RS256 tokens signed by a key generated in-fixture and served +through a PyJWKClient whose cache is pre-seeded. That is deliberate: overriding +`get_current_team` would leave the audience check, the issuer check and the algorithm +allow-list — the parts worth having — completely untested. +""" + +from __future__ import annotations + +import json +from collections.abc import AsyncIterator +from datetime import UTC, datetime, timedelta +from pathlib import Path +from typing import Any +from uuid import UUID, uuid4 + +import httpx +import jwt +import pytest +import pytest_asyncio +from cryptography.hazmat.primitives.asymmetric import rsa +from fastapi import FastAPI + +from services.api.main import create_app +from services.api.routes.instances import release_name_for +from svcforge_core.repo.db import DictPool +from svcforge_core.settings import Settings + +CATALOG = Path(__file__).resolve().parents[2] / "catalog.yaml" +ISSUER = "https://issuer.test/realms/svcforge" +AUDIENCE = "svcforge" +KID = "test-key-1" + + +# --------------------------------------------------------------------------- key material + + +@pytest.fixture(scope="session") +def rsa_key() -> rsa.RSAPrivateKey: + """One 2048-bit key for the whole session. Generating it per test costs ~100ms each.""" + return rsa.generate_private_key(public_exponent=65537, key_size=2048) + + +@pytest.fixture(scope="session") +def jwks(rsa_key: rsa.RSAPrivateKey) -> dict[str, Any]: + """The public half, as a JWKS document — exactly what Keycloak/Zitadel would serve.""" + algo = jwt.algorithms.RSAAlgorithm + key_dict: dict[str, Any] = json.loads(algo.to_jwk(rsa_key.public_key())) + key_dict.update({"kid": KID, "alg": "RS256", "use": "sig"}) + return {"keys": [key_dict]} + + +def make_token( + rsa_key: rsa.RSAPrivateKey, + team: str = "platform", + *, + audience: str = AUDIENCE, + issuer: str = ISSUER, + expires_in: timedelta = timedelta(minutes=5), + algorithm: str = "RS256", +) -> str: + """Sign a token. Defaults are valid; every argument exists so a test can invalidate one.""" + now = datetime.now(UTC) + claims: dict[str, Any] = { + "sub": f"user@{team}", + "team": team, + "aud": audience, + "iss": issuer, + "iat": now, + "exp": now + expires_in, + } + key = rsa_key if algorithm == "RS256" else "x" * 32 # HS256 needs >=32 bytes to sign quietly + return jwt.encode(claims, key, algorithm=algorithm, headers={"kid": KID}) + + +def auth(token: str) -> dict[str, str]: + """An Authorization header.""" + return {"Authorization": f"Bearer {token}"} + + +# --------------------------------------------------------------------------- the app + + +@pytest.fixture +def settings(pg_dsn: str) -> Settings: + """Settings pointed at the throwaway database. The whole reason create_app is a factory. + + `jwks_url=None` so lifespan builds no real client and attempts no warm-up: the `app` + fixture injects a pre-seeded one straight after. Set it to a fake URL and every test + pays a DNS timeout resolving a host that does not exist — ~4s each, ~2min a run. The + warm-up path itself is covered by test_lifespan_survives_an_unreachable_jwks. + """ + return Settings( + pg_dsn=pg_dsn, # type: ignore[arg-type] + jwks_url=None, + jwt_audience=AUDIENCE, + jwt_issuer=ISSUER, + catalog_path=CATALOG, + pool_min_size=1, + pool_max_size=5, + ) + + +class _FrozenJWKClient: + """A PyJWKClient with its cache pre-seeded and its network path removed. + + Stands in for the real client at the seam `get_current_team` uses. It resolves a kid to + a key exactly as PyJWKClient does; it just cannot reach out to an identity provider + that does not exist in a test run. + """ + + def __init__(self, jwks: dict[str, Any]) -> None: + self._keys = jwt.PyJWKSet.from_dict(jwks) + + def get_signing_key_from_jwt(self, token: str) -> jwt.PyJWK: + """Resolve the token's `kid` against the key set. Raises if it is unknown.""" + kid = jwt.get_unverified_header(token)["kid"] + for key in self._keys.keys: + if key.key_id == kid: + return key + raise jwt.exceptions.PyJWKClientError(f"unable to find key {kid}") + + def get_signing_keys(self) -> list[jwt.PyJWK]: + """Warm-up hook, called by lifespan.""" + return list(self._keys.keys) + + +@pytest_asyncio.fixture +async def app(settings: Settings, pool: DictPool, jwks: dict[str, Any]) -> AsyncIterator[FastAPI]: + """A live app with its lifespan run. + + Depends on `pool` only for its truncate-per-test side effect; the app opens its own. + """ + application = create_app(settings) + # ASGITransport does NOT run lifespan — it only speaks the `http` scope. Drive the + # lifespan by hand rather than reaching for asgi-lifespan: without this the pool is + # never opened, app.state.pool does not exist, and every DB test dies on AttributeError. + async with application.router.lifespan_context(application): + # Swap the real (network-bound) JWKS client for one whose cache is pre-seeded. + application.state.jwks_client = _FrozenJWKClient(jwks) + yield application + + +@pytest_asyncio.fixture +async def client(app: FastAPI) -> AsyncIterator[httpx.AsyncClient]: + """An httpx client wired straight into the ASGI app.""" + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: + yield c + + +@pytest.fixture +def token(rsa_key: rsa.RSAPrivateKey) -> str: + """A valid token for team `platform`.""" + return make_token(rsa_key, "platform") + + +async def _fetch_all(pool: DictPool, sql: str, args: tuple[Any, ...] = ()) -> list[Any]: + """Read rows back out of the database, bypassing the API entirely.""" + async with pool.connection() as conn, conn.cursor() as cur: + await cur.execute(sql, args) + return list(await cur.fetchall()) + + +# --------------------------------------------------------------------------- create + + +async def test_post_returns_202_and_location(client: httpx.AsyncClient, token: str) -> None: + """202 Accepted, not 201: nothing is provisioned yet. Location points at the poll target.""" + resp = await client.post( + "/v1/instances", + headers=auth(token), + json={"service_type": "elasticsearch", "size": "small"}, + ) + assert resp.status_code == 202, resp.text + body = resp.json() + assert resp.headers["location"] == f"/v1/instances/{body['id']}" + assert body["state"] == "requested" + assert body["service_type"] == "elasticsearch" + # Pinned from catalog.yaml at creation time, not echoed from the request. + assert body["chart_version"] == "21.3.15" + assert body["endpoint"] is None + # The response model is an allow-list: placement details stay off the wire. + assert "team" not in body and "namespace" not in body and "release_name" not in body + + +async def test_post_commits_instance_and_task_together( + client: httpx.AsyncClient, pool: DictPool, token: str +) -> None: + """The point of the whole module: both rows exist, or neither does.""" + resp = await client.post( + "/v1/instances", + headers=auth(token), + json={"service_type": "redis", "size": "small"}, + ) + assert resp.status_code == 202 + iid = UUID(resp.json()["id"]) + + rows = await _fetch_all(pool, "select * from instances where id = %s", (iid,)) + assert len(rows) == 1 + assert rows[0]["state"] == "requested" + assert rows[0]["team"] == "platform" + assert rows[0]["namespace"] == "tenant-platform" + # The idempotency anchor, and it is unique in the schema. + assert rows[0]["release_name"] == release_name_for("platform", "redis", iid) + + tasks = await _fetch_all(pool, "select * from tasks where instance_id = %s", (iid,)) + assert len(tasks) == 1 + assert tasks[0]["kind"] == "provision" + assert tasks[0]["state"] == "queued" + assert tasks[0]["attempts"] == 0 + + +async def test_release_name_is_deterministic_and_unique( + client: httpx.AsyncClient, pool: DictPool, token: str +) -> None: + """Two requests for the same service_type get distinct releases; the name is pure.""" + ids: list[UUID] = [] + for _ in range(2): + resp = await client.post( + "/v1/instances", headers=auth(token), json={"service_type": "redis", "size": "small"} + ) + assert resp.status_code == 202 + ids.append(UUID(resp.json()["id"])) + + names = await _fetch_all(pool, "select release_name from instances order by created_at") + assert len({r["release_name"] for r in names}) == 2 + # Pure function of (team, service_type, id): recomputing on a worker retry gives the + # same answer, so `helm upgrade --install` lands on the same release. + assert release_name_for("platform", "redis", ids[0]) == release_name_for("platform", "redis", ids[0]) + + +async def test_ttl_days_sets_expires_at(client: httpx.AsyncClient, pool: DictPool, token: str) -> None: + """ttl_days is the tenant-facing knob; expires_at is what the reconciler sweeps.""" + resp = await client.post( + "/v1/instances", + headers=auth(token), + json={"service_type": "redis", "size": "small", "ttl_days": 7}, + ) + assert resp.status_code == 202 + rows = await _fetch_all( + pool, "select expires_at from instances where id = %s", (UUID(resp.json()["id"]),) + ) + delta = rows[0]["expires_at"] - datetime.now(UTC) + assert timedelta(days=6, hours=23) < delta <= timedelta(days=7) + + +async def test_no_expires_at_without_ttl(client: httpx.AsyncClient, pool: DictPool, token: str) -> None: + """No TTL means no expiry. A default TTL would delete someone's database by surprise.""" + resp = await client.post( + "/v1/instances", headers=auth(token), json={"service_type": "redis", "size": "small"} + ) + rows = await _fetch_all( + pool, "select expires_at from instances where id = %s", (UUID(resp.json()["id"]),) + ) + assert rows[0]["expires_at"] is None + + +# --------------------------------------------------------------------------- validation + + +async def test_unknown_size_is_rejected(client: httpx.AsyncClient, pool: DictPool, token: str) -> None: + """size='enormous' is not in the catalog. Nothing is written.""" + resp = await client.post( + "/v1/instances", + headers=auth(token), + json={"service_type": "elasticsearch", "size": "enormous"}, + ) + # The spec says 422 for an unknown size and 404 for an unknown service_type; its + # summary line collapses both into "404 if unknown". Either is defensible; what is not + # defensible is writing the row. Assert the contract that matters and allow both codes. + assert resp.status_code in (404, 422), resp.text + assert await _fetch_all(pool, "select 1 from instances") == [] + assert await _fetch_all(pool, "select 1 from tasks") == [] + + +async def test_unknown_service_type_is_404(client: httpx.AsyncClient, token: str) -> None: + """A service_type the catalog has never heard of is a resource that does not exist.""" + resp = await client.post( + "/v1/instances", headers=auth(token), json={"service_type": "mongodb", "size": "small"} + ) + assert resp.status_code == 404 + assert resp.json()["code"] == "unknown_service_type" + + +async def test_ttl_out_of_range_is_422(client: httpx.AsyncClient, token: str) -> None: + """ttl_days has a schema bound (1..30); pydantic rejects it before the handler runs.""" + resp = await client.post( + "/v1/instances", + headers=auth(token), + json={"service_type": "redis", "size": "small", "ttl_days": 999}, + ) + assert resp.status_code == 422 + + +# --------------------------------------------------------------------------- authn + + +async def test_missing_authorization_is_401(client: httpx.AsyncClient) -> None: + """No header -> 401, NOT 403. + + This is the HTTPBearer(auto_error=True) trap: FastAPI's default answers a missing + header with 403, which tells the client "you are known and forbidden" when the truth + is "you never said who you are". deps.py passes auto_error=False for exactly this. + """ + resp = await client.post("/v1/instances", json={"service_type": "redis", "size": "small"}) + assert resp.status_code == 401 + assert resp.headers.get("www-authenticate") == "Bearer" + + +@pytest.mark.parametrize( + ("kwargs", "case"), + [ + ({"audience": "some-other-api"}, "wrong audience: a token minted for another service"), + ({"issuer": "https://evil.test/"}, "wrong issuer"), + ({"expires_in": timedelta(minutes=-5)}, "expired"), + ({"algorithm": "HS256"}, "algorithm confusion: signed with HMAC, not RS256"), + ], +) +async def test_bad_tokens_are_401( + client: httpx.AsyncClient, rsa_key: rsa.RSAPrivateKey, kwargs: dict[str, Any], case: str +) -> None: + """Every way a token can be wrong produces the same opaque 401. + + Identical bodies matter: a caller who can tell "expired" from "bad signature" from + "wrong audience" has an oracle to tune a forgery against. + """ + resp = await client.get("/v1/instances", headers=auth(make_token(rsa_key, **kwargs))) + assert resp.status_code == 401, f"{case} should be rejected" + assert resp.json() == {"code": "unauthorized", "message": "invalid or missing credentials"} + + +async def test_garbage_token_is_401(client: httpx.AsyncClient) -> None: + """Not even a JWT. Same 401, no stack trace, no 500.""" + resp = await client.get("/v1/instances", headers=auth("not-a-jwt")) + assert resp.status_code == 401 + + +# --------------------------------------------------------------------------- authz + + +async def test_team_a_cannot_get_team_b_instance( + client: httpx.AsyncClient, rsa_key: rsa.RSAPrivateKey +) -> None: + """404, not 403. AuthZ is the WHERE clause. + + 403 would confirm the id exists — an enumeration oracle. 404 is the same answer a + made-up uuid gets, so the two are indistinguishable, which is the point. + """ + a_token = make_token(rsa_key, "team-a") + b_token = make_token(rsa_key, "team-b") + + created = await client.post( + "/v1/instances", headers=auth(a_token), json={"service_type": "redis", "size": "small"} + ) + assert created.status_code == 202 + iid = created.json()["id"] + + assert (await client.get(f"/v1/instances/{iid}", headers=auth(a_token))).status_code == 200 + + stolen = await client.get(f"/v1/instances/{iid}", headers=auth(b_token)) + assert stolen.status_code == 404 + # Byte-identical to a genuinely nonexistent id? The message embeds the id, so compare + # the code: the shape a client can branch on must not distinguish the two cases. + nonexistent = await client.get(f"/v1/instances/{uuid4()}", headers=auth(b_token)) + assert nonexistent.status_code == 404 + assert stolen.json()["code"] == nonexistent.json()["code"] == "not_found" + + +async def test_list_is_scoped_to_the_callers_team( + client: httpx.AsyncClient, rsa_key: rsa.RSAPrivateKey +) -> None: + """A list endpoint is where cross-tenant leaks show up first.""" + a_token = make_token(rsa_key, "team-a") + b_token = make_token(rsa_key, "team-b") + for tok in (a_token, a_token, b_token): + await client.post("/v1/instances", headers=auth(tok), json={"service_type": "redis", "size": "small"}) + + a_list = await client.get("/v1/instances", headers=auth(a_token)) + assert a_list.status_code == 200 + assert len(a_list.json()) == 2 + + b_list = await client.get("/v1/instances", headers=auth(b_token)) + assert len(b_list.json()) == 1 + + +# --------------------------------------------------------------------------- get / delete + + +async def test_get_unknown_id_is_404(client: httpx.AsyncClient, token: str) -> None: + """A well-formed uuid that is not a row.""" + resp = await client.get(f"/v1/instances/{uuid4()}", headers=auth(token)) + assert resp.status_code == 404 + + +async def test_get_malformed_id_is_422(client: httpx.AsyncClient, token: str) -> None: + """Not a uuid at all: a path-schema failure, caught before the handler.""" + resp = await client.get("/v1/instances/not-a-uuid", headers=auth(token)) + assert resp.status_code == 422 + + +async def test_delete_moves_to_deleting_and_enqueues_deprovision( + client: httpx.AsyncClient, pool: DictPool, token: str +) -> None: + """202: the helm uninstall has not happened yet. Only the intent is durable.""" + created = await client.post( + "/v1/instances", headers=auth(token), json={"service_type": "redis", "size": "small"} + ) + iid = UUID(created.json()["id"]) + + # requested -> deleting is not legal; the state machine only allows it from ready. + async with pool.connection() as conn: + await conn.execute("update instances set state='ready' where id = %s", (iid,)) + + resp = await client.delete(f"/v1/instances/{iid}", headers=auth(token)) + assert resp.status_code == 202, resp.text + assert resp.json()["state"] == "deleting" + + rows = await _fetch_all(pool, "select state from instances where id = %s", (iid,)) + assert rows[0]["state"] == "deleting" + kinds = await _fetch_all(pool, "select kind from tasks where instance_id = %s order by id", (iid,)) + assert [r["kind"] for r in kinds] == ["provision", "deprovision"] + + +async def test_delete_other_teams_instance_is_404( + client: httpx.AsyncClient, rsa_key: rsa.RSAPrivateKey +) -> None: + """The destructive endpoint gets the same WHERE-clause treatment as the read.""" + a_token = make_token(rsa_key, "team-a") + created = await client.post( + "/v1/instances", headers=auth(a_token), json={"service_type": "redis", "size": "small"} + ) + iid = created.json()["id"] + resp = await client.delete(f"/v1/instances/{iid}", headers=auth(make_token(rsa_key, "team-b"))) + assert resp.status_code == 404 + + +async def test_delete_from_illegal_state_is_409( + client: httpx.AsyncClient, pool: DictPool, token: str +) -> None: + """`deleted` is terminal. The state machine says no, and the API does not overrule it.""" + created = await client.post( + "/v1/instances", headers=auth(token), json={"service_type": "redis", "size": "small"} + ) + iid = UUID(created.json()["id"]) + async with pool.connection() as conn: + await conn.execute("update instances set state='deleted' where id = %s", (iid,)) + + resp = await client.delete(f"/v1/instances/{iid}", headers=auth(token)) + assert resp.status_code == 409 + assert resp.json()["code"] == "illegal_transition" + + +async def test_delete_requires_auth(client: httpx.AsyncClient) -> None: + """No token, no teardown.""" + assert (await client.delete(f"/v1/instances/{uuid4()}")).status_code == 401 + + +# --------------------------------------------------------------------------- ops endpoints + + +async def test_healthz_needs_no_database(settings: Settings) -> None: + """Liveness does no I/O, so it answers 200 with no database anywhere near it. + + Built by hand against a nonsense DSN and with NO lifespan: if /healthz touched the + pool, there is no pool to touch and this would fail. That is the assertion — a DB blip + must never get the whole fleet killed and CrashLoopBackOff'd. + """ + broken = create_app(settings.model_copy(update={"pg_dsn": "postgresql://nobody@127.0.0.1:1/nothing"})) + transport = httpx.ASGITransport(app=broken) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: + resp = await c.get("/healthz") # no `async with transport` => lifespan never ran + assert resp.status_code == 200 + assert resp.json() == {"status": "ok"} + + +async def test_healthz_needs_no_auth(client: httpx.AsyncClient) -> None: + """The kubelet has no bearer token.""" + assert (await client.get("/healthz")).status_code == 200 + + +async def test_readyz_is_200_when_the_pool_is_open(client: httpx.AsyncClient) -> None: + """`select 1` against a live database.""" + resp = await client.get("/readyz") + assert resp.status_code == 200 + assert resp.json() == {"status": "ready"} + + +async def test_readyz_is_503_when_the_pool_is_closed(client: httpx.AsyncClient, app: FastAPI) -> None: + """Readiness fails closed. 503 pulls the pod out of the Service; it does not kill it.""" + await app.state.pool.close() + resp = await client.get("/readyz") + assert resp.status_code == 503 + assert resp.json()["code"] == "not_ready" + + +async def test_metrics_serves_the_prometheus_exposition_format(client: httpx.AsyncClient) -> None: + """A bare /metrics (what every scrape config requests) returns the exposition format.""" + resp = await client.get("/metrics") + assert resp.status_code == 200 + assert "text/plain" in resp.headers["content-type"] + assert "python_gc_objects_collected_total" in resp.text + + +# --------------------------------------------------------------------------- contract + + +async def test_openapi_lists_the_seven_endpoints(client: httpx.AsyncClient) -> None: + """The deliverable, asserted: /docs renders these paths.""" + paths = (await client.get("/openapi.json")).json()["paths"] + assert sorted(paths) == [ + "/healthz", + "/metrics", + "/readyz", + "/v1/instances", + "/v1/instances/{instance_id}", + ] + assert sorted(paths["/v1/instances"]) == ["get", "post"] + assert sorted(paths["/v1/instances/{instance_id}"]) == ["delete", "get"] + + +async def test_errors_are_documented_as_errorbody(client: httpx.AsyncClient) -> None: + """Every error the API returns has one declared shape, and clients can generate against it.""" + schema = (await client.get("/openapi.json")).json() + assert sorted(schema["components"]["schemas"]["ErrorBody"]["properties"]) == ["code", "message"] + post = schema["paths"]["/v1/instances"]["post"]["responses"] + for code in ("401", "404", "409", "422"): + ref = post[code]["content"]["application/json"]["schema"]["$ref"] + assert ref.endswith("/ErrorBody"), f"{code} is not documented as ErrorBody" + + +async def test_202_is_on_the_decorator_not_the_response_object(client: httpx.AsyncClient) -> None: + """The schema must say 202, not just the runtime. + + Setting response.status_code in a handler body changes the response and leaves the + OpenAPI document claiming 200 — so generated clients treat a 202 as an error. + """ + schema = (await client.get("/openapi.json")).json() + assert "202" in schema["paths"]["/v1/instances"]["post"]["responses"] + assert "202" in schema["paths"]["/v1/instances/{instance_id}"]["delete"]["responses"] + + +def test_auth_disabled_short_circuits(settings: Settings) -> None: + """The dev escape hatch exists, and Settings.check_production() refuses it in prod.""" + dev = settings.model_copy(update={"auth_disabled": True}) + with pytest.raises(ValueError, match="refused outside local development"): + dev.check_production() + + +@pytest.mark.slow +async def test_lifespan_survives_an_unreachable_jwks(settings: Settings) -> None: + """A down identity provider must not stop the pod from starting. + + The warm-up is best-effort on purpose: fail startup on it and an IdP blip means no pod + in the fleet can start, so the outage outlives the blip. A cache miss later costs one + to_thread hop. Requests still 401 until the keys arrive — fail closed, stay up. + """ + unreachable = settings.model_copy( + update={"jwks_url": "https://nonexistent.invalid/protocol/openid-connect/certs"} + ) + booted = create_app(unreachable) + async with booted.router.lifespan_context(booted): + transport = httpx.ASGITransport(app=booted) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: + assert (await c.get("/healthz")).status_code == 200 + # Keys never loaded, so auth fails closed rather than falling open. + assert (await c.get("/v1/instances", headers=auth("whatever"))).status_code == 401 + + +async def test_auth_disabled_accepts_an_unauthenticated_request(settings: Settings) -> None: + """With the hatch open, no header is needed and a fixed team is used.""" + dev_app = create_app(settings.model_copy(update={"auth_disabled": True})) + async with dev_app.router.lifespan_context(dev_app): + transport = httpx.ASGITransport(app=dev_app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: + resp = await c.get("/v1/instances") + assert resp.status_code == 200 diff --git a/tests/integration/test_claim.py b/tests/integration/test_claim.py new file mode 100644 index 0000000..eb2f08b --- /dev/null +++ b/tests/integration/test_claim.py @@ -0,0 +1,43 @@ +"""The claim race. If only one test in this repo survives, it should be this one.""" + +from __future__ import annotations + +import asyncio + +from svcforge_core.domain.models import TaskKind +from svcforge_core.repo.db import DictPool +from svcforge_core.repo.tasks import TaskRepo +from tests.integration.helpers import make_instance + + +async def test_skip_locked_claims_each_task_exactly_once(pool: DictPool) -> None: + """50 workers, 50 tasks, one claim each — no double-claims, no lost tasks. + + This is the test that fails if you split the claim into select-then-update. + """ + repo = TaskRepo(pool) + inst = await make_instance(pool) + ids = {await repo.enqueue_standalone(inst, TaskKind.PROVISION) for _ in range(50)} + + async with asyncio.TaskGroup() as tg: + claims = [tg.create_task(repo.claim(f"w{i}")) for i in range(50)] + got = [c.result() for c in claims] + + assert all(t is not None for t in got) + assert sorted(t.id for t in got if t is not None) == sorted(ids) # each exactly once + assert all(t.attempts == 1 for t in got if t is not None) + + +async def test_more_workers_than_tasks_get_none_not_a_duplicate(pool: DictPool) -> None: + """Contention must produce None for the losers, never a second claim on one row.""" + repo = TaskRepo(pool) + inst = await make_instance(pool) + await repo.enqueue_standalone(inst, TaskKind.PROVISION) + + async with asyncio.TaskGroup() as tg: + claims = [tg.create_task(repo.claim(f"w{i}")) for i in range(10)] + got = [c.result() for c in claims] + + won = [t for t in got if t is not None] + assert len(won) == 1 + assert len([t for t in got if t is None]) == 9 diff --git a/tests/integration/test_day2.py b/tests/integration/test_day2.py new file mode 100644 index 0000000..8820dfb --- /dev/null +++ b/tests/integration/test_day2.py @@ -0,0 +1,157 @@ +"""Day 2 against a real Postgres: the work list, the halt, and the window. + +The work-list query is the entire rollout, so it is tested where it runs. `order by +team = %s desc` and `not exists (... halted)` are SQL semantics — a fake repo asserting +them would only prove the fake agrees with itself. +""" + +from __future__ import annotations + +from datetime import UTC, datetime +from uuid import UUID + +import pytest + +from svcforge_core.domain.models import TaskKind +from svcforge_core.domain.states import InstanceState +from svcforge_core.domain.windows import parse_window, schedule_upgrade_at +from svcforge_core.repo.db import DictPool +from svcforge_core.repo.instances import InstanceRepo +from svcforge_core.repo.tasks import TaskRepo +from tests.integration.helpers import make_instance + +OLD = "21.3.19" # what is deployed +PINNED = "21.3.20" # what catalog.yaml now says +OWN_TEAM = "platform" + + +async def _set_window(pool: DictPool, instance_id: UUID, spec: str | None) -> None: + async with pool.connection() as conn, conn.cursor() as cur: + await cur.execute( + "update instances set maintenance_window = %s where id = %s", + (spec, instance_id), + ) + + +async def _halt(pool: DictPool, service_type: str) -> None: + """What `handle_verify` does on a failed probe, and what you undo by hand with SQL.""" + async with pool.connection() as conn, conn.cursor() as cur: + await cur.execute( + """insert into catalog_versions (service_type, rollout_state) values (%s, 'halted') + on conflict (service_type) do update set rollout_state = 'halted'""", + (service_type,), + ) + + +@pytest.fixture +async def fleet(pool: DictPool) -> list[UUID]: + """Three ready instances on the old version. The own-team one is created LAST. + + Created last on purpose: `created_at` is the tiebreak, so if the `team = %s desc` sort + were dropped this fixture makes the test fail instead of passing by luck. + """ + ids = [ + await make_instance(pool, team="tenant-a", state=InstanceState.READY, chart_version=OLD), + await make_instance(pool, team="tenant-b", state=InstanceState.READY, chart_version=OLD), + await make_instance(pool, team=OWN_TEAM, state=InstanceState.READY, chart_version=OLD), + ] + return ids + + +async def test_work_list_returns_one_row_and_it_is_the_own_team_row( + pool: DictPool, fleet: list[UUID] +) -> None: + """max_in_flight=1 means one instance moves at a time, and yours is the guinea pig.""" + repo = InstanceRepo(pool) + + rows = await repo.list_upgradable( + service_type="elasticsearch", + catalog_version=PINNED, + own_team=OWN_TEAM, + max_in_flight=1, + ) + + assert len(rows) == 1 + assert rows[0].instance.team == OWN_TEAM + assert rows[0].instance.id == fleet[2] + assert rows[0].instance.chart_version == OLD + + +async def test_work_list_skips_instances_already_on_the_pinned_version( + pool: DictPool, fleet: list[UUID] +) -> None: + """The query is the progress bar: as instances land on PINNED, the list drains to empty.""" + repo = InstanceRepo(pool) + async with pool.connection() as conn, conn.cursor() as cur: + await cur.execute("update instances set chart_version = %s", (PINNED,)) + + rows = await repo.list_upgradable( + service_type="elasticsearch", catalog_version=PINNED, own_team=OWN_TEAM, max_in_flight=10 + ) + + assert rows == [] + + +async def test_halted_rollout_returns_zero_rows(pool: DictPool, fleet: list[UUID]) -> None: + """One column stops the fleet. This is the whole stop button.""" + repo = InstanceRepo(pool) + await _halt(pool, "elasticsearch") + + rows = await repo.list_upgradable( + service_type="elasticsearch", + catalog_version=PINNED, + own_team=OWN_TEAM, + max_in_flight=10, # generous on purpose: it is the halt returning 0, not the limit + ) + + assert rows == [] + + +async def test_halt_is_scoped_to_one_service_type(pool: DictPool) -> None: + """A broken redis chart must not freeze elasticsearch upgrades.""" + repo = InstanceRepo(pool) + await make_instance( + pool, team=OWN_TEAM, service_type="redis", state=InstanceState.READY, chart_version=OLD + ) + await make_instance( + pool, team=OWN_TEAM, service_type="elasticsearch", state=InstanceState.READY, chart_version=OLD + ) + await _halt(pool, "redis") + + assert await repo.list_upgradable("redis", PINNED, OWN_TEAM, 10) == [] + assert len(await repo.list_upgradable("elasticsearch", PINNED, OWN_TEAM, 10)) == 1 + + +async def test_windowed_upgrade_is_scheduled_in_the_future_and_security_bypasses_it( + pool: DictPool, +) -> None: + """The window lands in `tasks.run_after`, and `security: true` ignores it. + + Both paths go through the real enqueue, so this also pins the `timestamptz` round-trip: + an aware UTC datetime must come back out of Postgres still aware and still that instant. + """ + repo = InstanceRepo(pool) + tasks = TaskRepo(pool) + iid = await make_instance(pool, team=OWN_TEAM, state=InstanceState.READY, chart_version=OLD) + await _set_window(pool, iid, "0 3 * * 0|Asia/Ho_Chi_Minh") + + (candidate,) = await repo.list_upgradable("elasticsearch", PINNED, OWN_TEAM, 1) + window = parse_window(candidate.maintenance_window) + assert window is not None + + now = datetime.now(UTC) + + routine = await tasks.enqueue_standalone( + iid, TaskKind.UPGRADE, schedule_upgrade_at(window, security=False, now=now) + ) + urgent = await tasks.enqueue_standalone( + iid, TaskKind.UPGRADE, schedule_upgrade_at(window, security=True, now=now) + ) + + async with pool.connection() as conn, conn.cursor() as cur: + await cur.execute("select id, run_after from tasks where id = any(%s)", ([routine, urgent],)) + run_after = {r["id"]: r["run_after"] for r in await cur.fetchall()} + + assert run_after[routine] > now # waits for 03:00 Sunday, Vietnam time + assert run_after[urgent] <= now # a public exploit does not wait + assert run_after[routine].tzinfo is not None diff --git a/tests/integration/test_helm_timeout.py b/tests/integration/test_helm_timeout.py new file mode 100644 index 0000000..ce3906d --- /dev/null +++ b/tests/integration/test_helm_timeout.py @@ -0,0 +1,29 @@ +"""The one test that proves the timeout is real. + +`bash -c "sleep 300 & sleep 300"` is a miniature helm: a process that forks a child and +waits on another. Kill the direct child only and the backgrounded `sleep` reparents to init +and keeps running — which, when the process is helm, means a timed-out task retries while +the original helm is still mutating the same release. + +This test needs a real process tree, so it lives in integration/. It needs no database: +the `pool` fixture in conftest is not autouse. +""" + +from __future__ import annotations + +import asyncio +import subprocess + +import pytest + +from svcforge_core.adapters.helm import _run + + +@pytest.mark.asyncio +async def test_timeout_kills_the_whole_process_group() -> None: + argv = ["bash", "-c", "sleep 300 & sleep 300"] # child forks a grandchild + with pytest.raises(TimeoutError): + await _run(argv, timeout_s=1) + await asyncio.sleep(0.5) + out = subprocess.run(["pgrep", "-f", "sleep 300"], capture_output=True, text=True) # noqa: S607 + assert out.stdout.strip() == "", "grandchild survived: you killed the child, not the group" diff --git a/tests/integration/test_instances.py b/tests/integration/test_instances.py new file mode 100644 index 0000000..1ffb05a --- /dev/null +++ b/tests/integration/test_instances.py @@ -0,0 +1,104 @@ +"""InstanceRepo against real SQL.""" + +from __future__ import annotations + +import pytest + +from svcforge_core.domain.models import TaskKind +from svcforge_core.domain.states import InstanceState +from svcforge_core.repo.db import DictPool +from svcforge_core.repo.instances import InstanceRepo +from svcforge_core.repo.tasks import TaskRepo +from tests.integration.helpers import build_instance + + +async def test_create_then_get_round_trips(pool: DictPool) -> None: + repo = InstanceRepo(pool) + inst = build_instance() + async with pool.connection() as conn: + created = await repo.create(conn, inst) + assert created.id == inst.id + assert created.release_name == inst.release_name + + got = await repo.get(inst.id, team="platform") + assert got is not None + assert got.service_type == "elasticsearch" + assert got.state is InstanceState.REQUESTED + # timestamptz round-trips as aware, or every later comparison raises TypeError. + assert got.created_at.tzinfo is not None + + +async def test_get_by_other_team_is_none_not_403(pool: DictPool) -> None: + """A wrong-team id is indistinguishable from a missing one.""" + repo = InstanceRepo(pool) + inst = build_instance(team="platform") + async with pool.connection() as conn: + await repo.create(conn, inst) + + assert await repo.get(inst.id, team="quant") is None + + +async def test_list_is_filtered_by_team(pool: DictPool) -> None: + repo = InstanceRepo(pool) + async with pool.connection() as conn: + await repo.create(conn, build_instance(team="platform")) + await repo.create(conn, build_instance(team="quant")) + + mine = await repo.list(team="platform") + assert len(mine) == 1 + assert all(i.team == "platform" for i in mine) + + +async def test_update_state_cas_rejects_stale_expectation(pool: DictPool) -> None: + repo = InstanceRepo(pool) + inst = build_instance() + async with pool.connection() as conn: + await repo.create(conn, inst) + + ok = await repo.update_state(inst.id, InstanceState.REQUESTED, InstanceState.PROVISIONING) + assert ok is True + + # The row already moved: the second caller must lose, and must not raise. + lost = await repo.update_state(inst.id, InstanceState.REQUESTED, InstanceState.PROVISIONING) + assert lost is False + + +async def test_update_state_sets_endpoint(pool: DictPool) -> None: + repo = InstanceRepo(pool) + inst = build_instance() + async with pool.connection() as conn: + await repo.create(conn, inst) + await repo.update_state(inst.id, InstanceState.REQUESTED, InstanceState.PROVISIONING) + ok = await repo.update_state( + inst.id, InstanceState.PROVISIONING, InstanceState.READY, endpoint="http://es:9200" + ) + assert ok is True + got = await repo.get(inst.id, team="platform") + assert got is not None + assert got.endpoint == "http://es:9200" + assert got.state is InstanceState.READY + + +@pytest.mark.parametrize("explode", [True]) +async def test_instance_and_task_roll_back_together(pool: DictPool, explode: bool) -> None: + """The reason the queue is in Postgres, as an executable claim. + + If the transaction aborts, BOTH the instance and its provision task must vanish. + An instance with no task never gets built; a task with no instance is an orphan. + """ + instances, tasks = InstanceRepo(pool), TaskRepo(pool) + inst = build_instance() + + with pytest.raises(RuntimeError): + async with pool.connection() as conn, conn.transaction(): + await instances.create(conn, inst) + await tasks.enqueue(conn, inst.id, TaskKind.PROVISION) + if explode: + raise RuntimeError("boom, mid-transaction") + + assert await instances.get(inst.id, team="platform") is None + async with pool.connection() as conn, conn.cursor() as cur: + await cur.execute("select count(*) as n from tasks") + row = await cur.fetchone() + assert row is not None + assert row["n"] == 0 diff --git a/tests/integration/test_reconciler.py b/tests/integration/test_reconciler.py new file mode 100644 index 0000000..73d7afe --- /dev/null +++ b/tests/integration/test_reconciler.py @@ -0,0 +1,556 @@ +"""The four checks, against a real Postgres and a fake cluster. + +Integration, not unit, because there is nothing to unit test: each check is a query and a +transaction. The behaviour worth asserting — that the CAS and the insert commit together, +that the idempotency guard is a real `not exists`, that a second tick does not double- +enqueue — lives entirely in the part a mock would replace. + +The cluster is faked; the database is not. FakeProvisioner is a dict of releases, which is +all the drift check needs: drift is "helm says X, the DB says Y", and a dict says X. +""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from typing import Any +from uuid import UUID + +import pytest +from opentelemetry import trace +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter + +from services.reconciler.main import ( + ReconcilerDeps, + check_drift, + check_lease_expiry, + check_ttl, + check_version_drift, + tick, +) +from svcforge_core.domain.models import CatalogEntry, SizeSpec, TaskKind, TaskState +from svcforge_core.domain.states import InstanceState +from svcforge_core.obs import RECONCILER_LAST_TICK +from svcforge_core.repo.db import DictPool +from svcforge_core.repo.instances import InstanceRepo +from svcforge_core.repo.reconcile import ReconcileRepo +from svcforge_core.repo.tasks import TaskRepo +from svcforge_core.settings import Settings +from tests.fakes import FakeClock, FakeNotifier, FakeProvisioner +from tests.integration.helpers import build_instance + +NOW = datetime(2026, 7, 17, 12, 0, tzinfo=UTC) # a Friday +OLD_VERSION = "21.3.19" +NEW_VERSION = "21.3.20" + +# '0 3 * * 0' is 03:00 Sunday. From a Friday noon that is always in the future, which is +# the whole assertion of the window test. +SUNDAY_0300_HCM = "0 3 * * 0|Asia/Ho_Chi_Minh" + + +def _entry(*, version: str = NEW_VERSION, security: bool = False) -> CatalogEntry: + return CatalogEntry( + service_type="elasticsearch", + chart="bitnamilegacy/elasticsearch", + chart_version=version, + security=security, + sizes={"small": SizeSpec(replicas=1, resources={})}, + ) + + +def _settings(**over: object) -> Settings: + return Settings(pg_dsn="postgresql://u:p@localhost:5432/db", **over) # type: ignore[arg-type] + + +def _deps( + pool: DictPool, + provisioner: FakeProvisioner, + *, + catalog: dict[str, CatalogEntry] | None = None, + max_in_flight: int = 1, + **settings_over: object, +) -> ReconcilerDeps: + return ReconcilerDeps( + pool=pool, + instances=InstanceRepo(pool), + tasks=TaskRepo(pool), + reconcile=ReconcileRepo(pool), + provisioner=provisioner, + notifier=FakeNotifier(), + clock=FakeClock(NOW), + catalog=catalog if catalog is not None else {"elasticsearch": _entry()}, + settings=_settings(**settings_over), + own_team="platform", + max_in_flight=max_in_flight, + ) + + +async def _seed( + pool: DictPool, + *, + state: InstanceState = InstanceState.READY, + team: str = "platform", + chart_version: str = OLD_VERSION, + expires_at: datetime | None = None, + maintenance_window: str | None = None, +) -> tuple[UUID, str, str]: + """Insert one instance. Returns (id, release_name, namespace). + + `expires_at` and `maintenance_window` go in with SQL rather than through + `InstanceRepo.create`: create() does not write `maintenance_window` at all (nothing but + the day-2 work list reads it), and that is a fact about the repo, not a gap in it. + """ + inst = build_instance(team=team, state=state, chart_version=chart_version) + repo = InstanceRepo(pool) + async with pool.connection() as conn: + await repo.create(conn, inst) + async with conn.cursor() as cur: + await cur.execute( + "update instances set expires_at = %s, maintenance_window = %s where id = %s", + (expires_at, maintenance_window, inst.id), + ) + return inst.id, inst.release_name, inst.namespace + + +async def _tasks_for(pool: DictPool, instance_id: UUID) -> list[dict[str, Any]]: + async with pool.connection() as conn, conn.cursor() as cur: + await cur.execute( + "select id, kind, state, run_after, traceparent from tasks where instance_id = %s order by id", + (instance_id,), + ) + return list(await cur.fetchall()) + + +async def _state_of(pool: DictPool, instance_id: UUID) -> str: + async with pool.connection() as conn, conn.cursor() as cur: + await cur.execute("select state from instances where id = %s", (instance_id,)) + row = await cur.fetchone() + assert row is not None + return str(row["state"]) + + +# --- Check 1: drift ------------------------------------------------------------------------ + + +async def test_drift_reprovisions_a_ready_instance_whose_release_vanished( + pool: DictPool, +) -> None: + """`helm uninstall` by hand. Nobody sends an event; the next tick notices anyway. + + This is the acceptance path from Module 7: + helm uninstall -n && python -m services.reconciler.main --once + select kind, state from tasks order by id desc limit 1 -> provision | queued + """ + instance_id, _, _ = await _seed(pool) + deps = _deps(pool, FakeProvisioner()) # empty cluster: the release is gone + + await check_drift(deps) + + tasks = await _tasks_for(pool, instance_id) + assert [(t["kind"], t["state"]) for t in tasks] == [(TaskKind.PROVISION.value, TaskState.QUEUED.value)] + + +async def test_drift_leaves_the_instance_in_provisioning_not_failed(pool: DictPool) -> None: + """The two-hop state change, and why it matters. + + `handle_provision` returns early on a `ready` row and ends with a + `provisioning -> ready` CAS. Hand it anything else and helm runs but the bookkeeping + lands nowhere. So the reconciler must leave the row in `provisioning` — via `failed`, + because LEGAL has no `ready -> provisioning` edge — before the worker can claim it. + """ + instance_id, _, _ = await _seed(pool) + + await check_drift(_deps(pool, FakeProvisioner())) + + assert await _state_of(pool, instance_id) == InstanceState.PROVISIONING.value + async with pool.connection() as conn, conn.cursor() as cur: + await cur.execute("select error from instances where id = %s", (instance_id,)) + row = await cur.fetchone() + assert row is not None + assert "drift" in row["error"] # the tenant gets told why, not just that + + +async def test_drift_ignores_an_instance_whose_release_is_present(pool: DictPool) -> None: + """The happy path is the same code. It must enqueue nothing at all.""" + instance_id, release, namespace = await _seed(pool) + provisioner = FakeProvisioner() + await provisioner.install(release, namespace, _entry(), {}) + + await check_drift(_deps(pool, provisioner)) + + assert await _tasks_for(pool, instance_id) == [] + assert await _state_of(pool, instance_id) == InstanceState.READY.value + + +async def test_drift_is_idempotent_across_ticks(pool: DictPool) -> None: + """Two ticks, one task. A provision takes minutes; ticks are 60 seconds apart. + + Without the guard the second tick sees a `provisioning` row — no longer `ready`, so the + drift branch skips it. The guard is what covers the case where it is `ready` again + before the task is done. + """ + instance_id, _, _ = await _seed(pool) + deps = _deps(pool, FakeProvisioner()) + + await check_drift(deps) + await check_drift(deps) + + assert len(await _tasks_for(pool, instance_id)) == 1 + + +async def test_drift_never_deletes_an_orphan_release(pool: DictPool) -> None: + """A release the DB has never heard of. Log it, bill nobody, delete nothing. + + v1 policy, and it is a policy about evidence: "no row in this table" is not proof the + release is unowned. It might belong to another tool, another team, or a migration that + is half done. An operator deletes it after reading the log. + """ + provisioner = FakeProvisioner() + await provisioner.install("someone-elses-redis", "other-ns", _entry(), {}) + + await check_drift(_deps(pool, provisioner)) + + assert "someone-elses-redis" in provisioner.releases + assert provisioner.uninstalled == [] + async with pool.connection() as conn, conn.cursor() as cur: + await cur.execute("select count(*) as n from tasks") + row = await cur.fetchone() + assert row is not None + assert row["n"] == 0 + + +async def test_drift_does_not_call_an_in_flight_provision_an_orphan(pool: DictPool) -> None: + """A `requested` instance a worker is installing right now is not an orphan. + + `known_releases` covers every row in any state for exactly this reason. Scope it to + `ready` and every provision in progress gets reported as an orphan on every tick, which + trains everyone to ignore the orphan log. + """ + _, release, namespace = await _seed(pool, state=InstanceState.REQUESTED) + provisioner = FakeProvisioner() + await provisioner.install(release, namespace, _entry(), {}) + + await check_drift(_deps(pool, provisioner)) + + assert provisioner.uninstalled == [] + + +# --- Check 2: lease expiry ----------------------------------------------------------------- + + +async def test_lease_expiry_returns_a_dead_workers_task_to_the_queue(pool: DictPool) -> None: + """SIGKILL leaves `running` with `locked_by` set and nobody running it. + + No cleanup code in the worker can fix this, because the worker is the part that died. + The lease is the only thing that recovers the row. + """ + instance_id, _, _ = await _seed(pool) + tasks = TaskRepo(pool) + await tasks.enqueue_standalone(instance_id, TaskKind.PROVISION) + claimed = await tasks.claim("worker-that-is-about-to-die") + assert claimed is not None + + # The worker died six minutes ago and never reported. + async with pool.connection() as conn, conn.cursor() as cur: + await cur.execute( + "update tasks set locked_at = now() - interval '6 minutes' where id = %s", + (claimed.id,), + ) + + await check_lease_expiry(_deps(pool, FakeProvisioner(), lease_seconds=300)) + + rows = await _tasks_for(pool, instance_id) + assert rows[0]["state"] == TaskState.QUEUED.value + + +async def test_lease_expiry_leaves_a_live_worker_alone(pool: DictPool) -> None: + """A task claimed a second ago is not a dead worker. Reclaiming it would double-provision. + + Handlers are idempotent, so a wrongly-freed lease is survivable — but survivable is not + free, and this is why lease_seconds sits above helm's own --timeout. + """ + instance_id, _, _ = await _seed(pool) + tasks = TaskRepo(pool) + await tasks.enqueue_standalone(instance_id, TaskKind.PROVISION) + assert await tasks.claim("worker-1") is not None + + await check_lease_expiry(_deps(pool, FakeProvisioner(), lease_seconds=300)) + + rows = await _tasks_for(pool, instance_id) + assert rows[0]["state"] == TaskState.RUNNING.value + + +# --- Check 3: TTL -------------------------------------------------------------------------- + + +async def test_ttl_expired_instance_goes_to_deleting_with_a_deprovision_task( + pool: DictPool, +) -> None: + """The check that stops a demo cluster from becoming a permanent line on the bill.""" + instance_id, _, _ = await _seed(pool, expires_at=datetime.now(UTC) - timedelta(minutes=1)) + + await check_ttl(_deps(pool, FakeProvisioner())) + + tasks = await _tasks_for(pool, instance_id) + assert [(t["kind"], t["state"]) for t in tasks] == [(TaskKind.DEPROVISION.value, TaskState.QUEUED.value)] + # `deleting` before the worker claims it: handle_deprovision ends with a + # `deleting -> deleted` CAS, and a `ready` row would leave the DB advertising an + # endpoint for a release helm has already removed. + assert await _state_of(pool, instance_id) == InstanceState.DELETING.value + + +async def test_ttl_ignores_an_instance_that_has_not_expired(pool: DictPool) -> None: + """And ignores one with no expires_at at all: null means no TTL, not expired.""" + live, _, _ = await _seed(pool, expires_at=datetime.now(UTC) + timedelta(hours=1)) + forever, _, _ = await _seed(pool, expires_at=None) + + await check_ttl(_deps(pool, FakeProvisioner())) + + assert await _tasks_for(pool, live) == [] + assert await _tasks_for(pool, forever) == [] + + +async def test_ttl_is_idempotent_across_ticks(pool: DictPool) -> None: + """A deprovision takes minutes and ticks are 60s apart. One task, not four.""" + instance_id, _, _ = await _seed(pool, expires_at=datetime.now(UTC) - timedelta(minutes=1)) + deps = _deps(pool, FakeProvisioner()) + + await check_ttl(deps) + await check_ttl(deps) + await check_ttl(deps) + + assert len(await _tasks_for(pool, instance_id)) == 1 + + +async def test_ttl_recovers_a_deleting_instance_whose_task_was_never_enqueued( + pool: DictPool, +) -> None: + """The API's DELETE crashed between the CAS and the enqueue. This is the sweep it relies on. + + That statement order is chosen *because* this check exists. The other order leaves a + deprovision task pointing at a `ready` instance, and a worker tears down a live service + nobody asked to delete. + """ + instance_id, _, _ = await _seed(pool, state=InstanceState.DELETING) + + await check_ttl(_deps(pool, FakeProvisioner())) + + tasks = await _tasks_for(pool, instance_id) + assert [t["kind"] for t in tasks] == [TaskKind.DEPROVISION.value] + assert await _state_of(pool, instance_id) == InstanceState.DELETING.value + + +async def test_ttl_re_enqueues_after_a_deprovision_exhausted_its_attempts( + pool: DictPool, +) -> None: + """`done` and `failed` are not outstanding. A transient outage must not strand the row. + + The guard asks "is one queued or running", not "has one ever existed" — otherwise a + deprovision that burned its five attempts during a cluster outage would leave the + instance billing forever with nothing left to retry it. + """ + instance_id, _, _ = await _seed(pool, state=InstanceState.DELETING) + async with pool.connection() as conn, conn.cursor() as cur: + await cur.execute( + "insert into tasks (instance_id, kind, state) values (%s, %s, %s)", + (instance_id, TaskKind.DEPROVISION.value, TaskState.FAILED.value), + ) + + await check_ttl(_deps(pool, FakeProvisioner())) + + states = [t["state"] for t in await _tasks_for(pool, instance_id)] + assert TaskState.QUEUED.value in states + + +# --- Check 4: version drift ---------------------------------------------------------------- + + +async def test_version_drift_enqueues_one_upgrade_for_the_own_team_instance_first( + pool: DictPool, +) -> None: + """max_in_flight=1 across three stale instances, and it picks ours. + + Eating your own dog food is an `order by`: we are the tenant who finds out the chart is + broken, and the halt stops the other two before they ever hear about it. + """ + await _seed(pool, team="payments") + await _seed(pool, team="search") + ours, _, _ = await _seed(pool, team="platform") + + await check_version_drift(_deps(pool, FakeProvisioner())) + + async with pool.connection() as conn, conn.cursor() as cur: + await cur.execute("select instance_id, kind from tasks") + rows = list(await cur.fetchall()) + assert len(rows) == 1 + assert rows[0]["instance_id"] == ours + assert rows[0]["kind"] == TaskKind.UPGRADE.value + + +async def test_version_drift_enqueues_nothing_while_the_rollout_is_halted( + pool: DictPool, +) -> None: + """One column stops the fleet. A failed verify writes it; a human clears it with SQL.""" + instance_id, _, _ = await _seed(pool) + async with pool.connection() as conn, conn.cursor() as cur: + await cur.execute( + "insert into catalog_versions (service_type, rollout_state) values ('elasticsearch', 'halted')" + ) + + await check_version_drift(_deps(pool, FakeProvisioner())) + + assert await _tasks_for(pool, instance_id) == [] + + +async def test_version_drift_ignores_an_instance_already_on_the_catalog_version( + pool: DictPool, +) -> None: + """`chart_version` is written only after helm succeeds, which is what makes this the query.""" + instance_id, _, _ = await _seed(pool, chart_version=NEW_VERSION) + + await check_version_drift(_deps(pool, FakeProvisioner())) + + assert await _tasks_for(pool, instance_id) == [] + + +async def test_version_drift_parks_the_upgrade_until_the_maintenance_window( + pool: DictPool, +) -> None: + """The queue does the waiting, in `where run_after <= now()`. + + Not a scheduler and not an in-memory timer: a task parked in Postgres until 03:00 Sunday + survives a reconciler restart. That is the whole reason `run_after` exists. + """ + instance_id, _, _ = await _seed(pool, maintenance_window=SUNDAY_0300_HCM) + + await check_version_drift(_deps(pool, FakeProvisioner())) + + tasks = await _tasks_for(pool, instance_id) + assert len(tasks) == 1 + assert tasks[0]["run_after"] > NOW # a Friday; the next 03:00 Sunday is days away + + +async def test_version_drift_bypasses_the_window_for_a_security_bump(pool: DictPool) -> None: + """A CVE with a public exploit does not wait until Sunday. That is what `security:` is for.""" + instance_id, _, _ = await _seed(pool, maintenance_window=SUNDAY_0300_HCM) + catalog = {"elasticsearch": _entry(security=True)} + + await check_version_drift(_deps(pool, FakeProvisioner(), catalog=catalog)) + + tasks = await _tasks_for(pool, instance_id) + assert len(tasks) == 1 + assert tasks[0]["run_after"] <= NOW + + +async def test_version_drift_is_idempotent_across_ticks(pool: DictPool) -> None: + """The guard that makes max_in_flight mean anything. + + The instance stays on the work list for the whole duration of its own upgrade — + `chart_version` is only written on success — and for the hours it spends parked waiting + for 03:00. Without the guard, `max_in_flight=1` is sixty tasks an hour against one + release. + """ + instance_id, _, _ = await _seed(pool, maintenance_window=SUNDAY_0300_HCM) + deps = _deps(pool, FakeProvisioner()) + + for _ in range(3): + await check_version_drift(deps) + + assert len(await _tasks_for(pool, instance_id)) == 1 + + +async def test_version_drift_skips_one_bad_window_and_keeps_going(pool: DictPool) -> None: + """One tenant's typo must not freeze everyone else's security rollout.""" + broken, _, _ = await _seed(pool, team="payments", maintenance_window="not a cron|Asia/Ho_Chi_Minh") + deps = _deps(pool, FakeProvisioner(), max_in_flight=5) + + await check_version_drift(deps) # must not raise + + assert await _tasks_for(pool, broken) == [] + + +# --- The tick ------------------------------------------------------------------------------ + + +class _AngryProvisioner(FakeProvisioner): + """A cluster that cannot be reached. The drift check's worst day.""" + + async def list_releases(self) -> list[Any]: + raise RuntimeError("dial tcp: i/o timeout") + + +async def test_tick_runs_the_other_three_checks_when_one_blows_up(pool: DictPool) -> None: + """A helm binary that cannot reach the API server must not stop TTLs from expiring. + + This is the entire argument for wrapping each check independently, and it is asserted + rather than assumed because the failure mode — a tick that dies on check one — looks + exactly like a tick that found nothing to do. + """ + expired, _, _ = await _seed(pool, expires_at=datetime.now(UTC) - timedelta(minutes=1)) + deps = _deps(pool, _AngryProvisioner()) + + await tick(deps) # must not raise + + assert [t["kind"] for t in await _tasks_for(pool, expired)] == [TaskKind.DEPROVISION.value] + + +async def test_tick_sets_the_gauges_and_the_heartbeat(pool: DictPool) -> None: + """queue_depth after the checks, not before, and the heartbeat unconditionally. + + The heartbeat is what `SvcforgeReconcilerStale` reads. It answers "is the loop running", + not "is everything fine" — the checks have their own alerts, and an alert that means two + things gets muted. + """ + from prometheus_client import REGISTRY + + await _seed(pool, expires_at=datetime.now(UTC) - timedelta(minutes=1)) + deps = _deps(pool, _AngryProvisioner()) # one check fails; the heartbeat still ticks + + await tick(deps) + + assert REGISTRY.get_sample_value("svcforge_queue_depth") == 1.0 + assert REGISTRY.get_sample_value("svcforge_instances", {"state": "deleting"}) == 1.0 + assert RECONCILER_LAST_TICK._value.get() == pytest.approx(NOW.timestamp()) + + +@pytest.fixture(scope="session") +def tracing() -> InMemorySpanExporter: + """A real tracer provider for the process, collecting spans in memory. + + Global because OTEL's is: `trace.set_tracer_provider` takes once per process, and + `obs.tracer()` resolves it at call time. Session-scoped so the second call never + happens. + """ + exporter = InMemorySpanExporter() + provider = TracerProvider() + provider.add_span_processor(SimpleSpanProcessor(exporter)) + trace.set_tracer_provider(provider) + return exporter + + +async def test_a_task_the_tick_enqueues_carries_the_ticks_traceparent( + pool: DictPool, + tracing: InMemorySpanExporter, +) -> None: + """Nothing propagates a trace through a table. The column is written at insert or never. + + Driven through `tick`, not through `check_drift` with a span wrapped around it by the + test — that version passed while the real entrypoint wrote null on every row, because + the only span in the production path (`helm.list`) had already closed by the time the + insert ran. A test that supplies the context under test proves the propagator works and + nothing about this service. + """ + instance_id, _, _ = await _seed(pool) + tracing.clear() + + await tick(_deps(pool, FakeProvisioner())) + + tasks = await _tasks_for(pool, instance_id) + traceparent = tasks[0]["traceparent"] + assert traceparent is not None, "the reconciler's own tasks are unjoinable to its tick" + + # Same trace as the tick's span, which is the entire point of storing the column. + tick_spans = [s for s in tracing.get_finished_spans() if s.name == "reconciler.tick"] + assert len(tick_spans) == 1 + assert traceparent.split("-")[1] == format(tick_spans[0].context.trace_id, "032x") diff --git a/tests/integration/test_redis.py b/tests/integration/test_redis.py new file mode 100644 index 0000000..fe75bd5 --- /dev/null +++ b/tests/integration/test_redis.py @@ -0,0 +1,494 @@ +"""Module 10 acceptance: the limiter, the idempotency store, the cache, and the budget. + +Six checks, in the spec's order: + +1. Rate limit 10/min — the 11th is refused. +2. A check costs exactly ONE Redis command. +3. Idempotency — the same key twice yields the same UUID. +4. Cache — the second read costs one command and no DB query. +5. Redis DOWN = the platform stays UP. +6. The budget metric exists, and `scripts/redis_budget.py` reads it correctly. + +**Two tiers, and the split is the budget.** Upstash's free tier is 500K commands/month; +a test suite that hammers it is itself the bug this module is about. So the semantics are +proved against `tests/fakes.py` (free, deterministic, runs on every commit), and only the +things a fake cannot prove — that the Lua is valid Lua, that `KEYS`/`ARGV` are 1-based, +that redis-py bills one EVALSHA, that `decode_responses=True` is set — are proved against +real Upstash under `@pytest.mark.slow`. That tier spends roughly thirty commands per run, +and `pytest -m "not slow"` skips it entirely. + +Check 5 needs no server at all: a closed port is a more faithful outage than a mock. +""" + +from __future__ import annotations + +import importlib.util +import os +import time +from collections.abc import AsyncIterator, Callable +from datetime import UTC, datetime +from pathlib import Path +from types import ModuleType +from typing import Any +from uuid import UUID, uuid4 + +import pytest +import pytest_asyncio +from prometheus_client import REGISTRY, generate_latest +from redis.asyncio import Redis + +from svcforge_core.adapters.redis import ( + IdempotencyStore, + InstanceCache, + RateLimiter, + RateLimitResult, + make_redis, +) +from svcforge_core.repo.db import DictPool +from svcforge_core.repo.instances import InstanceRepo +from svcforge_core.settings import Settings +from tests.fakes import FakeClock, FakeIdempotencyStore, FakeInstanceCache, FakeRateLimiter +from tests.integration.helpers import build_instance + +# Any DSN that parses. These tests never open a Postgres connection through Settings; the +# `pool` fixture owns the real database. +_DUMMY_PG_DSN = "postgresql://unused:unused@127.0.0.1:5432/unused" + +# A port nothing listens on. `make_redis` will build a client, every command will get +# ECONNREFUSED, and that is the point of check 5. +_DEAD_REDIS_DSN = "redis://127.0.0.1:1/0" + +_T0 = datetime(2026, 7, 17, 12, 0, 0, tzinfo=UTC) + + +def _metric(op: str) -> float: + """The budget counter for one op. Absent labels read as 0, not as an error.""" + value = REGISTRY.get_sample_value("svcforge_redis_commands_total", {"op": op}) + return value or 0.0 + + +def _errors(op: str) -> float: + value = REGISTRY.get_sample_value("svcforge_redis_errors_total", {"op": op}) + return value or 0.0 + + +# --- Fixtures --------------------------------------------------------------------------- + + +@pytest_asyncio.fixture +async def upstash() -> AsyncIterator[Redis]: + """The real thing, from `~/.config/svcforge/secrets.env`. Skipped when unset. + + Built through `make_redis` rather than `Redis.from_url` directly, so that + `decode_responses=True` is covered by these tests instead of being a comment. Forget it + and every assertion below dies on `bytes != str`, which is the whole reason it is the + first bug everyone hits. + """ + dsn = os.getenv("SVCFORGE_REDIS_DSN") + if not dsn: + pytest.skip("SVCFORGE_REDIS_DSN unset; real-Upstash checks skipped") + client = make_redis(Settings(pg_dsn=_DUMMY_PG_DSN, redis_dsn=dsn)) # type: ignore[arg-type] # pydantic coerces str -> *Dsn + assert client is not None + try: + yield client + finally: + await client.aclose() + + +@pytest_asyncio.fixture +async def dead_redis() -> AsyncIterator[Redis]: + """A client pointed at a closed port. No server was harmed, no commands were billed.""" + client = make_redis(Settings(pg_dsn=_DUMMY_PG_DSN, redis_dsn=_DEAD_REDIS_DSN)) # type: ignore[arg-type] # pydantic coerces str -> *Dsn + assert client is not None + try: + yield client + finally: + await client.aclose() + + +class _CommandCounter: + """Counts round trips by wrapping `execute_command` on one client instance. + + This counts what Upstash bills, which is the only definition that matters here. The + metric counter is our own bookkeeping and could be wrong in the same direction as the + code it measures; this one cannot. + """ + + def __init__(self, r: Redis) -> None: + self.count = 0 + self._inner: Callable[..., Any] = r.execute_command + r.execute_command = self._counting # type: ignore[method-assign] + + async def _counting(self, *args: Any, **kwargs: Any) -> Any: # noqa: ANN401 + self.count += 1 + return await self._inner(*args, **kwargs) + + +def _budget_script() -> ModuleType: + """Import `scripts/redis_budget.py` by path — `scripts/` is not a package, deliberately.""" + path = Path(__file__).resolve().parents[2] / "scripts" / "redis_budget.py" + spec = importlib.util.spec_from_file_location("redis_budget", path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +# --- 1. Rate limit: 10/min, the 11th is refused ------------------------------------------ + + +async def test_eleventh_request_in_the_window_is_refused() -> None: + """`200 x10` then `429`. The fake, so this runs on every commit for free.""" + limiter = FakeRateLimiter(limit=10, window_s=60, clock=FakeClock(start=_T0)) + + results = [await limiter.check("acme") for _ in range(11)] + + assert [r.allowed for r in results] == [True] * 10 + [False] + assert results[9].remaining == 0 + assert results[10].remaining == 0 + # What the handler puts in `Retry-After` on the 429. Never 0: a client told to retry + # immediately retries into the same closed window. + assert results[10].retry_after_s >= 1 + + +async def test_the_window_rolls_over_and_the_caller_is_allowed_again() -> None: + """A fixed window resets on a boundary, not on a sleep. Hence the injected clock.""" + clock = FakeClock(start=_T0) + limiter = FakeRateLimiter(limit=2, window_s=60, clock=clock) + + assert (await limiter.check("acme")).allowed + assert (await limiter.check("acme")).allowed + assert not (await limiter.check("acme")).allowed + + clock.advance(datetime(2026, 7, 17, 12, 1, 0, tzinfo=UTC) - _T0) + assert (await limiter.check("acme")).allowed + + +async def test_teams_do_not_share_a_window() -> None: + """`rl:{team}:{window}` — a noisy tenant must not refuse a quiet one.""" + limiter = FakeRateLimiter(limit=1, window_s=60, clock=FakeClock(start=_T0)) + + assert (await limiter.check("acme")).allowed + assert not (await limiter.check("acme")).allowed + assert (await limiter.check("globex")).allowed + + +@pytest.mark.slow +async def test_real_lua_refuses_the_eleventh(upstash: Redis) -> None: + """The same assertion against real Upstash. ~11 commands. + + This is what a fake cannot prove: that the script is valid Lua, that `KEYS[1]` and + `ARGV[1]` are 1-based (0-based indexing would read nil and compare false forever), and + that `INCR`-then-conditional-`EXPIRE` actually holds a window open. + """ + team = f"test-{uuid4().hex[:8]}" + limiter = RateLimiter(upstash, limit=10, window_s=60) + + results = [await limiter.check(team) for _ in range(11)] + + assert [r.allowed for r in results] == [True] * 10 + [False] + assert not any(r.degraded for r in results) + assert results[0].remaining == 9 + + # The EXPIRE fired on the first INCR only, so the key is not immortal. `Every key gets + # a TTL` is a rule with no enforcement other than checking. + window = int(time.time()) // 60 + keys = [f"rl:{team}:{window}", f"rl:{team}:{window - 1}"] + ttls = [await upstash.ttl(k) for k in keys] + assert any(0 < ttl <= 60 for ttl in ttls) + await upstash.delete(*keys) + + +# --- 2. It costs ONE command per check --------------------------------------------------- + + +@pytest.mark.slow +async def test_a_check_costs_exactly_one_redis_command(upstash: Redis) -> None: + """One EVALSHA. Not GET+INCR+EXPIRE, which is three billed commands and a race. + + At 500K/month the difference is not academic: three commands per request caps the + platform at 166K requests/month instead of 500K, for a limiter that is also wrong. + + The first call is excluded from the count on purpose. redis-py sends EVALSHA, Upstash + answers NOSCRIPT because it has never seen the hash, and redis-py replays it as EVAL — + two commands, once per Redis restart, and irrelevant to the steady state this measures. + """ + team = f"test-{uuid4().hex[:8]}" + limiter = RateLimiter(upstash, limit=100, window_s=60) + + await limiter.check(team) # warm the script cache + + counter = _CommandCounter(upstash) + before = _metric("ratelimit") + result = await limiter.check(team) + + assert counter.count == 1, "a rate limit check must be one round trip and one billed command" + assert _metric("ratelimit") - before == 1, "the budget counter must agree with the wire" + assert result.allowed + + window = int(time.time()) // 60 + await upstash.delete(f"rl:{team}:{window}", f"rl:{team}:{window - 1}") + + +# --- 3. Idempotency: the same key twice is one instance ---------------------------------- + + +async def test_same_idempotency_key_returns_the_first_instance_id() -> None: + """The first caller wins; the second is told who won and must not create anything.""" + store = FakeIdempotencyStore() + key = str(uuid4()) + first, second = uuid4(), uuid4() + + assert await store.claim(key, first) is None, "None means 'you won, go create it'" + assert await store.claim(key, second) == first, "the loser gets the winner's id, not its own" + + +async def test_different_idempotency_keys_do_not_collide() -> None: + store = FakeIdempotencyStore() + a, b = uuid4(), uuid4() + + assert await store.claim(str(uuid4()), a) is None + assert await store.claim(str(uuid4()), b) is None + + +@pytest.mark.slow +async def test_real_set_nx_ex_claims_once(upstash: Redis) -> None: + """SET NX EX against Upstash. ~3 commands. + + Also asserts the TTL, because a claim marker without one is a permanent record of a + request from last March, and 256 MB of those ends by evicting the keys you cared about. + """ + key = f"test-{uuid4()}" + first, second = uuid4(), uuid4() + store = IdempotencyStore(upstash, ttl_s=60) + + assert await store.claim(key, first) is None + assert await store.claim(key, second) == first + + assert 0 < await upstash.ttl(f"idem:{key}") <= 60 + await upstash.delete(f"idem:{key}") + + +# --- 4. Cache: the second read costs one command and no DB query ------------------------- + + +class _CountingRepo: + """Wraps InstanceRepo and counts reads. The DB query count is the assertion.""" + + def __init__(self, repo: InstanceRepo) -> None: + self._repo = repo + self.gets = 0 + + async def get(self, id: UUID, team: str) -> Any: # noqa: ANN401 + self.gets += 1 + return await self._repo.get(id, team) + + +async def _read_through( + instance_id: UUID, + team: str, + cache: FakeInstanceCache | InstanceCache, + repo: _CountingRepo, +) -> Any: # noqa: ANN401 + """Cache-aside, as `GET /v1/instances/{id}` performs it. Hit = 1 command, miss = 2.""" + cached = await cache.get(instance_id) + if cached is not None: + return cached + inst = await repo.get(instance_id, team) + if inst is not None: + await cache.put(inst) + return inst + + +async def test_second_read_is_served_from_cache_without_touching_postgres( + pool: DictPool, +) -> None: + """Miss, then hit. The DB is read exactly once for two reads.""" + repo = InstanceRepo(pool) + # `create` returns the row as Postgres stored it. Compare against that, never against + # the model that went in: `created_at`/`updated_at` are DB defaults, and a timestamptz + # comes back tagged `Etc/UTC` rather than `timezone.utc`. Same instant, different repr. + async with pool.connection() as conn: + inst = await repo.create(conn, build_instance(team="platform")) + + counting = _CountingRepo(repo) + cache = FakeInstanceCache() + + first = await _read_through(inst.id, inst.team, cache, counting) + second = await _read_through(inst.id, inst.team, cache, counting) + + assert first == second == inst + assert counting.gets == 1, "the second read must not reach Postgres" + assert (cache.misses, cache.hits) == (1, 1) + + +async def test_invalidate_sends_the_next_read_back_to_postgres() -> None: + """The worker calls this inside the code path that writes the state, not after it.""" + cache = FakeInstanceCache() + inst = build_instance() + + await cache.put(inst) + assert await cache.get(inst.id) == inst + + await cache.invalidate(inst.id) + assert await cache.get(inst.id) is None + + +@pytest.mark.slow +async def test_real_cache_hit_costs_one_command_and_no_db_query(pool: DictPool, upstash: Redis) -> None: + """Real Redis, real Postgres. ~4 commands. + + The round-trip count is the acceptance criterion: a hit that costs two commands is a + cache that has doubled the bill it was added to reduce. + """ + repo = InstanceRepo(pool) + async with pool.connection() as conn: + inst = await repo.create(conn, build_instance(team="platform")) + + counting = _CountingRepo(repo) + cache = InstanceCache(upstash, ttl_s=30) + + miss = await _read_through(inst.id, inst.team, cache, counting) + assert counting.gets == 1 + + counter = _CommandCounter(upstash) + before = _metric("cache_get") + hit = await _read_through(inst.id, inst.team, cache, counting) + + assert counter.count == 1, "a cache hit is one GET" + assert _metric("cache_get") - before == 1 + assert counting.gets == 1, "the second read must not reach Postgres" + # Round-tripped through JSON and back: `decode_responses=True` and the datetime/UUID + # serialisation both have to be right for this to compare equal. + assert hit == miss == inst + + assert 0 < await upstash.ttl(f"inst:{inst.id}") <= 30 + await cache.invalidate(inst.id) + assert await cache.get(inst.id) is None + + +# --- 5. Redis down = the platform stays up ----------------------------------------------- + + +async def test_rate_limiter_fails_open_when_redis_is_down(dead_redis: Redis) -> None: + """The load-bearing one. A limiter that fails closed turns a cache outage into an outage. + + `degraded=True` and the error counter are what stop this from being invisible: fail + open silently and you cannot tell a working limiter from one that has been allowing + everything for a month. + """ + limiter = RateLimiter(dead_redis, limit=1, window_s=60) + before = _errors("ratelimit") + + results = [await limiter.check("acme") for _ in range(3)] + + assert all(r.allowed for r in results), "Redis being down must never refuse legitimate traffic" + assert all(r.degraded for r in results) + assert _errors("ratelimit") - before == 3 + + +async def test_idempotency_falls_through_to_the_db_when_redis_is_down(dead_redis: Redis) -> None: + """None means "create it". Safe only because `instances.release_name` is UNIQUE.""" + store = IdempotencyStore(dead_redis) + key = str(uuid4()) + + assert await store.claim(key, uuid4()) is None + assert await store.claim(key, uuid4()) is None + + +async def test_cache_misses_instead_of_raising_when_redis_is_down(dead_redis: Redis) -> None: + """A miss falls through to Postgres. `put` and `invalidate` swallow it too.""" + cache = InstanceCache(dead_redis) + inst = build_instance() + + assert await cache.get(inst.id) is None + await cache.put(inst) # must not raise + await cache.invalidate(inst.id) # must not raise + + +async def test_platform_serves_reads_from_postgres_with_redis_down(pool: DictPool, dead_redis: Redis) -> None: + """The acceptance check: `docker compose stop redis` then GET -> 200. + + Every Redis path degrades and the read still returns the row. Nothing here consults + Redis for readiness, which is the other half of the rule — `/readyz` is Postgres-only, + so a Redis outage cannot make a single pod unready. + """ + repo = InstanceRepo(pool) + async with pool.connection() as conn: + inst = await repo.create(conn, build_instance(team="platform")) + + limiter = RateLimiter(dead_redis, limit=10, window_s=60) + cache = InstanceCache(dead_redis) + store = IdempotencyStore(dead_redis) + + assert (await limiter.check(inst.team)).allowed + assert await store.claim(str(uuid4()), inst.id) is None + assert await cache.get(inst.id) is None + assert await repo.get(inst.id, inst.team) == inst # the 200 + + +# --- 6. The budget metric exists and is sane --------------------------------------------- + + +async def test_the_budget_metric_is_exposed_and_labelled_by_op() -> None: + """`curl -s localhost:8000/metrics | grep svcforge_redis_commands_total`.""" + limiter = FakeRateLimiter(limit=10, window_s=60, clock=FakeClock(start=_T0)) + await limiter.check("acme") # the fake does not touch the real counter + RateLimitResult(allowed=True, limit=10, remaining=9, reset_at=_T0) + + text = generate_latest(REGISTRY).decode() + + assert "svcforge_redis_commands_total" in text + assert "svcforge_redis_errors_total" in text + # Per-op labels, because "you are over budget" is useless without "on cache_get". + assert _metric("ratelimit") >= 0 + + +def test_budget_script_projects_a_five_second_poller_over_budget() -> None: + """The spec's headline number, reproduced: one worker polling every 5s = 518,400/month. + + Exactly the free tier, spent doing nothing. This is the case the script exists to + catch, so it is the case that is asserted rather than left to a comment. + """ + budget = _budget_script() + now = time.time() + # One hour at one command every five seconds. + per_op, started_at = budget.collect( + f'svcforge_redis_commands_total{{op="poll"}} 720.0\nprocess_start_time_seconds {now - 3600}\n' + ) + + assert per_op == {"poll": 720.0} + assert budget.report(per_op, started_at, budget._FREE_TIER_BUDGET, now) == 1 + + +def test_budget_script_passes_a_request_path_workload() -> None: + """Request-path volume is bounded by humans, and humans are slow. That is the whole rule.""" + budget = _budget_script() + now = time.time() + per_op, started_at = budget.collect( + f'svcforge_redis_commands_total{{op="ratelimit"}} 100.0\n' + f'svcforge_redis_commands_total{{op="cache_get"}} 40.0\n' + f"process_start_time_seconds {now - 3600}\n" + ) + + assert sum(per_op.values()) == 140.0 + assert budget.report(per_op, started_at, budget._FREE_TIER_BUDGET, now) == 0 + + +def test_budget_script_refuses_to_guess_without_the_metric() -> None: + """No counter means the process never imported the adapter — say so, do not print a 0.""" + budget = _budget_script() + + with pytest.raises(budget.BudgetError, match="not exposed"): + budget.collect("process_start_time_seconds 1.0\n") + + with pytest.raises(budget.BudgetError, match="process_start_time_seconds"): + budget.collect('svcforge_redis_commands_total{op="ratelimit"} 5.0\n') + + +def test_budget_script_refuses_a_non_http_url() -> None: + """`--url file:///etc/passwd` is not a metrics endpoint.""" + budget = _budget_script() + + with pytest.raises(budget.BudgetError, match="non-http"): + budget.scrape("file:///etc/passwd") diff --git a/tests/integration/test_tasks.py b/tests/integration/test_tasks.py new file mode 100644 index 0000000..e9e5641 --- /dev/null +++ b/tests/integration/test_tasks.py @@ -0,0 +1,155 @@ +"""TaskRepo: enqueue, complete, fail, backoff, leases.""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta + +from svcforge_core.domain.models import TaskKind, TaskState +from svcforge_core.domain.states import InstanceState +from svcforge_core.repo.db import DictPool +from svcforge_core.repo.instances import InstanceRepo +from svcforge_core.repo.tasks import TaskRepo +from tests.integration.helpers import make_instance + + +async def _task_row(pool: DictPool, task_id: int) -> dict[str, object]: + async with pool.connection() as conn, conn.cursor() as cur: + await cur.execute("select * from tasks where id = %s", (task_id,)) + row = await cur.fetchone() + assert row is not None + return dict(row) + + +async def test_enqueue_defaults_to_runnable_now(pool: DictPool) -> None: + iid = await make_instance(pool) + repo = TaskRepo(pool) + tid = await repo.enqueue_standalone(iid, TaskKind.PROVISION) + row = await _task_row(pool, tid) + assert row["state"] == "queued" + assert row["attempts"] == 0 + + +async def test_complete_marks_done_and_releases_lock(pool: DictPool) -> None: + iid = await make_instance(pool) + repo = TaskRepo(pool) + tid = await repo.enqueue_standalone(iid, TaskKind.PROVISION) + claimed = await repo.claim("w1") + assert claimed is not None + await repo.complete(claimed.id) + row = await _task_row(pool, tid) + assert row["state"] == "done" + assert row["locked_by"] is None + + +async def test_fail_under_max_attempts_requeues_with_future_run_after(pool: DictPool) -> None: + iid = await make_instance(pool) + repo = TaskRepo(pool) + tid = await repo.enqueue_standalone(iid, TaskKind.PROVISION) + claimed = await repo.claim("w1") + assert claimed is not None + assert claimed.attempts == 1 # attempts increments at CLAIM time, not on failure + + await repo.fail(tid, "helm exploded", max_attempts=5) + row = await _task_row(pool, tid) + assert row["state"] == "queued" + assert row["last_error"] == "helm exploded" + assert row["locked_by"] is None + # Backoff pushed it out; it must not be immediately runnable again. + run_after = row["run_after"] + assert isinstance(run_after, datetime) + assert run_after >= datetime.now(UTC) - timedelta(seconds=1) + + +async def test_fail_at_max_attempts_dead_letters_and_marks_instance(pool: DictPool) -> None: + """A dead-letter state, not an infinite retry.""" + iid = await make_instance(pool) + tasks, instances = TaskRepo(pool), InstanceRepo(pool) + tid = await tasks.enqueue_standalone(iid, TaskKind.PROVISION) + + async with pool.connection() as conn, conn.cursor() as cur: + await cur.execute("update tasks set attempts = 5 where id = %s", (tid,)) + + await tasks.fail(tid, "chart not found", max_attempts=5) + + row = await _task_row(pool, tid) + assert row["state"] == "failed" + inst = await instances.get(iid, team="platform") + assert inst is not None + assert inst.state is InstanceState.FAILED + assert inst.error == "chart not found" + + +async def test_fail_does_not_resurrect_a_deleted_instance(pool: DictPool) -> None: + """Raw SQL must obey the same state machine domain.transition() enforces. + + LEGAL[DELETED] is empty — deleted is terminal. A deprovision task that exhausts its + retries after the instance is already gone must record nothing on it, not drag it + back to 'failed'. + """ + iid = await make_instance(pool, state=InstanceState.DELETED) + tasks, instances = TaskRepo(pool), InstanceRepo(pool) + tid = await tasks.enqueue_standalone(iid, TaskKind.DEPROVISION) + + async with pool.connection() as conn, conn.cursor() as cur: + await cur.execute("update tasks set attempts = 5 where id = %s", (tid,)) + + await tasks.fail(tid, "helm uninstall kept failing", max_attempts=5) + + # The task still dead-letters — that part is unconditional. + row = await _task_row(pool, tid) + assert row["state"] == "failed" + + # But the instance stays deleted. + inst = await instances.get(iid, team="platform") + assert inst is not None + assert inst.state is InstanceState.DELETED, "raw SQL bypassed the state machine" + assert inst.error is None + + +async def test_fail_truncates_error_to_2kb(pool: DictPool) -> None: + iid = await make_instance(pool) + repo = TaskRepo(pool) + tid = await repo.enqueue_standalone(iid, TaskKind.PROVISION) + await repo.claim("w1") + await repo.fail(tid, "x" * 9000, max_attempts=5) + row = await _task_row(pool, tid) + assert isinstance(row["last_error"], str) + assert len(row["last_error"]) == 2000 + + +async def test_run_after_in_the_future_is_not_claimable(pool: DictPool) -> None: + iid = await make_instance(pool) + repo = TaskRepo(pool) + future = datetime.now(UTC) + timedelta(hours=1) + await repo.enqueue_standalone(iid, TaskKind.PROVISION, run_after=future) + assert await repo.claim("w1") is None + + +async def test_reset_expired_leases_recovers_a_dead_workers_task(pool: DictPool) -> None: + """No distributed lock survives a power cut. Only the lease recovers this row.""" + iid = await make_instance(pool) + repo = TaskRepo(pool) + tid = await repo.enqueue_standalone(iid, TaskKind.PROVISION) + claimed = await repo.claim("worker-that-will-die") + assert claimed is not None + + # Simulate: the worker was SIGKILLed 10 minutes ago and never reported. + async with pool.connection() as conn, conn.cursor() as cur: + await cur.execute("update tasks set locked_at = now() - interval '10 minutes' where id = %s", (tid,)) + + n = await repo.reset_expired_leases(lease_seconds=300) + assert n == 1 + row = await _task_row(pool, tid) + assert row["state"] == TaskState.QUEUED.value + assert row["locked_by"] is None + + # And it is claimable again. + assert await repo.claim("w2") is not None + + +async def test_fresh_lease_is_not_reset(pool: DictPool) -> None: + iid = await make_instance(pool) + repo = TaskRepo(pool) + await repo.enqueue_standalone(iid, TaskKind.PROVISION) + await repo.claim("w1") + assert await repo.reset_expired_leases(lease_seconds=300) == 0 diff --git a/tests/integration/test_worker.py b/tests/integration/test_worker.py new file mode 100644 index 0000000..7535c43 --- /dev/null +++ b/tests/integration/test_worker.py @@ -0,0 +1,203 @@ +"""The worker loop: drains on SIGTERM, retries, stays idempotent. + +These run in milliseconds against a FakeProvisioner. That is the payoff for putting helm +behind a Protocol in Module 5: the crash-safety properties are testable without a cluster. +""" + +from __future__ import annotations + +import asyncio +import time +from pathlib import Path +from uuid import UUID + +import pytest + +from services.worker.deps import WorkerDeps +from services.worker.main import run_worker +from svcforge_core.adapters.clock import SystemClock +from svcforge_core.domain.catalog import load_catalog +from svcforge_core.domain.models import TaskKind +from svcforge_core.domain.states import InstanceState +from svcforge_core.repo.db import DictPool +from svcforge_core.repo.instances import InstanceRepo +from svcforge_core.repo.tasks import TaskRepo +from svcforge_core.settings import Settings +from tests.fakes import FakeNotifier, FakeProvisioner +from tests.integration.helpers import build_instance + +CATALOG = load_catalog(Path(__file__).resolve().parents[2] / "catalog.yaml") + + +def _settings(**over: object) -> Settings: + base: dict[str, object] = { + "pg_dsn": "postgresql://x:x@127.0.0.1:5432/x", + "worker_id": "w-test", + "worker_concurrency": 4, + "poll_interval_s": 0.05, + "max_attempts": 3, + } + base.update(over) + return Settings(**base) # type: ignore[arg-type] + + +def _deps( + pool: DictPool, + prov: FakeProvisioner, + notifier: FakeNotifier | None = None, + **over: object, +) -> WorkerDeps: + return WorkerDeps( + pool=pool, + instances=InstanceRepo(pool), + tasks=TaskRepo(pool), + provisioner=prov, + notifier=notifier or FakeNotifier(), + clock=SystemClock(), + catalog=CATALOG, + settings=_settings(**over), + ) + + +async def _seed(pool: DictPool, **kw: object) -> tuple[str, int]: + inst = build_instance(**kw) # type: ignore[arg-type] + async with pool.connection() as conn: + await InstanceRepo(pool).create(conn, inst) + tid = await TaskRepo(pool).enqueue_standalone(inst.id, TaskKind.PROVISION) + return str(inst.id), tid + + +async def _state_of(pool: DictPool, tid: int) -> str: + async with pool.connection() as conn, conn.cursor() as cur: + await cur.execute("select state from tasks where id = %s", (tid,)) + row = await cur.fetchone() + assert row is not None + return str(row["state"]) + + +async def test_worker_provisions_and_marks_ready(pool: DictPool) -> None: + iid, tid = await _seed(pool) + prov = FakeProvisioner() + notifier = FakeNotifier() + stop = asyncio.Event() + + worker = asyncio.create_task(run_worker(_deps(pool, prov, notifier), stop)) + await asyncio.sleep(0.5) + stop.set() + await asyncio.wait_for(worker, timeout=5) + + assert await _state_of(pool, tid) == "done" + inst = await InstanceRepo(pool).get(UUID(iid), team="platform") + assert inst is not None + assert inst.state is InstanceState.READY + assert inst.endpoint is not None + assert len(prov.installed) == 1 + + # Assert the notification fired, and that it happened on the FIRST attempt. + # + # Without this the handler could raise after marking the instance ready — the task + # requeues, the retry hits the idempotency early-return, and everything above still + # passes while the worker is quietly crashing on every provision. Idempotency is + # supposed to make crashes survivable, not invisible; asserting attempts==1 is what + # keeps a masked crash from reading as success. + assert notifier.events() == ["instance.ready"] + + async with pool.connection() as conn, conn.cursor() as cur: + await cur.execute("select attempts from tasks where id = %s", (tid,)) + row = await cur.fetchone() + assert row is not None + assert row["attempts"] == 1, "task was retried: the handler raised after doing the work" + + +async def test_sigterm_drains_in_flight(pool: DictPool) -> None: + """Stop is requested mid-provision: the worker must FINISH the task, then exit. + + Abandoning it would not lose the task — the lease would recover it — but only after + five minutes of a tenant watching 'provisioning'. Draining costs two seconds. + """ + _, tid = await _seed(pool) + prov = FakeProvisioner(delay=2.0) + stop = asyncio.Event() + + started = time.monotonic() + worker = asyncio.create_task(run_worker(_deps(pool, prov), stop)) + await asyncio.sleep(0.5) + stop.set() # mid-flight: the handler is still inside its 2s install + + await asyncio.wait_for(worker, timeout=5) + elapsed = time.monotonic() - started + + assert await _state_of(pool, tid) == "done", "worker abandoned an in-flight task" + assert elapsed >= 2.0, "worker returned before the in-flight task finished" + assert elapsed < 5.0 + + +async def test_idle_worker_stops_promptly(pool: DictPool) -> None: + """Nothing queued: stop must wake the poll sleep, not wait it out.""" + stop = asyncio.Event() + worker = asyncio.create_task(run_worker(_deps(pool, FakeProvisioner(), poll_interval_s=5.0), stop)) + await asyncio.sleep(0.2) + started = time.monotonic() + stop.set() + await asyncio.wait_for(worker, timeout=2) + assert time.monotonic() - started < 1.0, "stop did not interrupt the poll sleep" + + +async def test_failed_task_is_requeued_with_backoff(pool: DictPool) -> None: + _, tid = await _seed(pool) + prov = FakeProvisioner(fail_on={"platform-elasticsearch"}) + stop = asyncio.Event() + + worker = asyncio.create_task(run_worker(_deps(pool, prov), stop)) + await asyncio.sleep(0.6) + stop.set() + await asyncio.wait_for(worker, timeout=5) + + async with pool.connection() as conn, conn.cursor() as cur: + await cur.execute("select state, attempts, last_error from tasks where id = %s", (tid,)) + row = await cur.fetchone() + assert row is not None + assert row["state"] == "queued" # requeued, not failed — attempts remain + assert row["attempts"] >= 1 + assert row["last_error"] + + +async def test_provision_twice_installs_once(pool: DictPool) -> None: + """The idempotency claim, executed. + + Simulates the crash window: the release is installed and the instance is READY, but + the task got re-queued (worker died before reporting). Re-running must not re-install. + """ + from services.worker.handlers import handle_provision + + inst = build_instance(state=InstanceState.REQUESTED) + async with pool.connection() as conn: + await InstanceRepo(pool).create(conn, inst) + tid = await TaskRepo(pool).enqueue_standalone(inst.id, TaskKind.PROVISION) + task = await TaskRepo(pool).claim("w1") + assert task is not None + + prov = FakeProvisioner() + deps = _deps(pool, prov) + + await handle_provision(task, deps) + await handle_provision(task, deps) # the redelivery + + assert len(prov.installed) == 1, "second run re-installed: handler is not idempotent" + assert tid == task.id + + +@pytest.mark.parametrize("concurrency", [1, 4]) +async def test_concurrency_cap_is_respected(pool: DictPool, concurrency: int) -> None: + """The semaphore is what stops one worker from starting 200 helm processes.""" + for _ in range(6): + await _seed(pool) + + prov = FakeProvisioner(delay=0.2) + stop = asyncio.Event() + worker = asyncio.create_task(run_worker(_deps(pool, prov, worker_concurrency=concurrency), stop)) + await asyncio.sleep(0.5) + stop.set() + await asyncio.wait_for(worker, timeout=10) + + assert prov.max_concurrent <= concurrency, f"ran {prov.max_concurrent} at once, cap was {concurrency}" diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/test_backoff.py b/tests/unit/test_backoff.py new file mode 100644 index 0000000..4298cfd --- /dev/null +++ b/tests/unit/test_backoff.py @@ -0,0 +1,40 @@ +"""Unit tests for retry backoff math.""" + +from datetime import UTC, datetime, timedelta + +import pytest +from hypothesis import given +from hypothesis import strategies as st + +from svcforge_core.domain.backoff import next_attempt_at + +T = datetime(2026, 7, 17, 12, 0, 0, tzinfo=UTC) + + +def test_attempt_zero_full_jitter_is_base() -> None: + assert next_attempt_at(0, now=T, rand=lambda: 1.0) == T + timedelta(seconds=2) + + +def test_zero_jitter_returns_now() -> None: + assert next_attempt_at(0, now=T, rand=lambda: 0.0) == T + + +def test_negative_attempt_raises_value_error() -> None: + with pytest.raises(ValueError, match="attempt"): + next_attempt_at(-1, now=T) + + +@given(attempt=st.integers(min_value=0, max_value=64), r=st.floats(min_value=0.0, max_value=1.0)) +def test_delay_is_always_within_zero_and_cap(attempt: int, r: float) -> None: + cap_s = 300.0 + got = next_attempt_at(attempt, now=T, cap_s=cap_s, rand=lambda: r) + delay = (got - T).total_seconds() + assert 0.0 <= delay <= cap_s + + +@given(attempt=st.integers(min_value=0, max_value=63)) +def test_delay_is_non_decreasing_in_attempt(attempt: int) -> None: + def ceiling_of(a: int) -> float: + return (next_attempt_at(a, now=T, rand=lambda: 1.0) - T).total_seconds() + + assert ceiling_of(attempt) <= ceiling_of(attempt + 1) diff --git a/tests/unit/test_catalog.py b/tests/unit/test_catalog.py new file mode 100644 index 0000000..11e8138 --- /dev/null +++ b/tests/unit/test_catalog.py @@ -0,0 +1,155 @@ +"""Unit tests for catalog loading and validation.""" + +import textwrap +from pathlib import Path + +import pytest + +from svcforge_core.domain.catalog import CatalogError, load_catalog +from svcforge_core.domain.models import CatalogEntry + +VALID_YAML = textwrap.dedent(""" + services: + redis: + chart: bitnamilegacy/redis + chart_version: 20.6.2 + sizes: + small: + replicas: 1 + resources: + requests: {cpu: 100m, memory: 256Mi} + medium: + replicas: 3 + resources: + requests: {cpu: 500m, memory: 1Gi} + postgres: + chart: bitnamilegacy/postgresql + chart_version: 16.4.5 + sizes: + small: + replicas: 1 + resources: + requests: {cpu: 250m, memory: 512Mi} +""") + +MISSING_CHART_VERSION_YAML = textwrap.dedent(""" + services: + redis: + chart: bitnamilegacy/redis + sizes: + small: + replicas: 1 + resources: {} +""") + +ZERO_REPLICAS_YAML = textwrap.dedent(""" + services: + redis: + chart: bitnamilegacy/redis + chart_version: 20.6.2 + sizes: + small: + replicas: 0 + resources: {} +""") + + +def _write(tmp_path: Path, body: str) -> Path: + path = tmp_path / "catalog.yaml" + path.write_text(body) + return path + + +def test_valid_yaml_loads_to_catalog_entries(tmp_path: Path) -> None: + catalog = load_catalog(_write(tmp_path, VALID_YAML)) + + assert set(catalog) == {"redis", "postgres"} + assert all(isinstance(entry, CatalogEntry) for entry in catalog.values()) + redis = catalog["redis"] + assert redis.service_type == "redis" + assert redis.chart_version == "20.6.2" + assert set(redis.sizes) == {"small", "medium"} + assert redis.sizes["medium"].replicas == 3 + + +def test_missing_chart_version_raises_catalog_error(tmp_path: Path) -> None: + with pytest.raises(CatalogError) as excinfo: + load_catalog(_write(tmp_path, MISSING_CHART_VERSION_YAML)) + + assert excinfo.value.key == "redis" + assert "redis" in str(excinfo.value) + + +def test_zero_replicas_raises_catalog_error(tmp_path: Path) -> None: + with pytest.raises(CatalogError) as excinfo: + load_catalog(_write(tmp_path, ZERO_REPLICAS_YAML)) + + assert excinfo.value.key == "redis" + + +def test_missing_file_raises_catalog_error(tmp_path: Path) -> None: + with pytest.raises(CatalogError, match="cannot read catalog"): + load_catalog(tmp_path / "nope.yaml") + + +def test_unparseable_yaml_raises_catalog_error(tmp_path: Path) -> None: + with pytest.raises(CatalogError, match="not valid YAML"): + load_catalog(_write(tmp_path, "services: [unclosed\n")) + + +def test_scalar_root_raises_catalog_error(tmp_path: Path) -> None: + with pytest.raises(CatalogError, match="must be a mapping"): + load_catalog(_write(tmp_path, "just-a-string\n")) + + +def test_non_mapping_services_raises_catalog_error(tmp_path: Path) -> None: + with pytest.raises(CatalogError, match="'services' must be a mapping"): + load_catalog(_write(tmp_path, "services:\n - redis\n")) + + +def test_non_mapping_entry_raises_catalog_error_naming_the_key(tmp_path: Path) -> None: + with pytest.raises(CatalogError) as excinfo: + load_catalog(_write(tmp_path, "services:\n redis: just-a-string\n")) + + assert excinfo.value.key == "redis" + assert "must be a mapping" in str(excinfo.value) + + +def test_non_string_field_key_raises_catalog_error_naming_the_key(tmp_path: Path) -> None: + """A non-string YAML key inside an entry breaks `**body`; it must surface as CatalogError.""" + body = textwrap.dedent(""" + services: + redis: + 1: oops + chart: bitnamilegacy/redis + chart_version: 20.6.2 + sizes: {} + """) + with pytest.raises(CatalogError) as excinfo: + load_catalog(_write(tmp_path, body)) + + assert excinfo.value.key == "redis" + + +def test_bare_mapping_without_services_key_is_accepted(tmp_path: Path) -> None: + """The top-level `services:` wrapper is optional; a bare service_type mapping also loads.""" + body = textwrap.dedent(""" + redis: + chart: bitnamilegacy/redis + chart_version: 20.6.2 + sizes: + small: + replicas: 1 + resources: {} + """) + catalog = load_catalog(_write(tmp_path, body)) + + assert set(catalog) == {"redis"} + + +def test_repo_catalog_yaml_is_valid() -> None: + catalog = load_catalog(Path(__file__).parents[2] / "catalog.yaml") + + assert set(catalog) == {"elasticsearch", "redis", "postgres"} + for entry in catalog.values(): + assert set(entry.sizes) == {"small", "medium"} diff --git a/tests/unit/test_obs.py b/tests/unit/test_obs.py new file mode 100644 index 0000000..b6b6b47 --- /dev/null +++ b/tests/unit/test_obs.py @@ -0,0 +1,272 @@ +"""obs.py: the three things that are wrong by default. + +Not tested here: that structlog logs, that prometheus counts, that OTEL traces. Those are +the libraries' tests. What is tested is every place where the default is a bug — the +histogram buckets, the context that does not cross a queue, and the contextvars that leak +between tasks. +""" + +from __future__ import annotations + +import io +import json +import logging +import re +from collections.abc import Iterator +from typing import Any +from uuid import uuid4 + +import pytest +import structlog +from opentelemetry import trace +from opentelemetry.sdk.trace import TracerProvider +from prometheus_client import REGISTRY + +from svcforge_core import obs +from svcforge_core.settings import Settings + +# W3C traceparent: version-traceid-spanid-flags. +# +# The flags byte is matched loosely and the sampled bit checked separately, on purpose. +# Module 7's acceptance line says `00-<32 hex>-<16 hex>-01`, and current SDKs emit `-03`: +# bit 0x01 is `sampled`, and bit 0x02 is `random-trace-id` from trace-context level 2. An +# assertion pinned to `-01` fails on a spec revision that changed nothing we care about. +# What we care about is the trace being sampled, which is bit 0x01 and nothing else. +TRACEPARENT_RE = re.compile( + r"^00-(?P[0-9a-f]{32})-(?P[0-9a-f]{16})-(?P[0-9a-f]{2})$" +) +SAMPLED_BIT = 0x01 + +# A provision takes minutes. prometheus_client's default buckets end at 10 seconds. +DEFAULT_TOP_BUCKET = 10.0 + + +def _settings(**over: object) -> Settings: + return Settings(pg_dsn="postgresql://u:p@localhost:5432/db", **over) # type: ignore[arg-type] + + +@pytest.fixture +def logs() -> Iterator[io.StringIO]: + """setup() against a captured stream, restoring the process-wide config afterwards. + + setup() is deliberately global and deliberately once-only, which makes it deliberately + awkward to test. Reaching into the module flag is the honest price of that; the + alternative is a seam that exists only for tests. + """ + stream = io.StringIO() + saved_handlers = logging.getLogger().handlers[:] + saved_level = logging.getLogger().level + saved_config = structlog.get_config() + + obs._configured = False + obs.setup("test-service", _settings()) + + # Re-point the handler setup() installed at our buffer; everything else it configured — + # processors, formatter, the JSON renderer last — is exactly what production gets. + handler = logging.getLogger().handlers[0] + assert isinstance(handler, logging.StreamHandler) + handler.setStream(stream) + + try: + yield stream + finally: + structlog.contextvars.clear_contextvars() + structlog.configure(**saved_config) + logging.getLogger().handlers = saved_handlers + logging.getLogger().setLevel(saved_level) + obs._configured = False + + +def _lines(stream: io.StringIO) -> list[dict[str, Any]]: + return [json.loads(line) for line in stream.getvalue().splitlines() if line.strip()] + + +# --- Buckets ------------------------------------------------------------------------------ + + +def test_provision_histogram_has_a_bucket_for_a_thirty_minute_provision() -> None: + """The acceptance check, as a unit test: `le="1800"` exists. + + With the library defaults the top finite bucket is 10s, every provision lands in +Inf, + and histogram_quantile interpolates across a bucket spanning 10s to infinity. The p95 + it returns is not slow or fast, it is meaningless — and it is meaningless silently, + which is why this is asserted rather than eyeballed on a dashboard. + """ + bounds = obs.PROVISION_TIME._upper_bounds + assert 1800.0 in bounds + assert bounds[-1] == float("inf") + assert max(b for b in bounds if b != float("inf")) > DEFAULT_TOP_BUCKET + + +def test_provision_histogram_exports_the_1800_bucket() -> None: + """Same claim, checked through the exposition format the scrape actually reads.""" + obs.PROVISION_TIME.observe(42.0) + bucket = REGISTRY.get_sample_value("svcforge_provision_duration_seconds_bucket", {"le": "1800.0"}) + assert bucket is not None + + +def test_metric_names_are_the_ones_the_alerts_query() -> None: + """The four alerts are PromQL strings in values.yaml; nothing type-checks them. + + A rename here is a silent alert that never fires again. This test is the link between + the chart's rules and the code. + """ + for name in ( + "svcforge_tasks_claimed_total", + "svcforge_tasks_failed_total", + "svcforge_provision_duration_seconds", + "svcforge_queue_depth", + "svcforge_instances", + "svcforge_reconciler_last_tick_timestamp_seconds", + ): + assert REGISTRY._names_to_collectors.get(name) is not None, f"{name} is queried by an alert" + + +# --- Trace context across the queue ------------------------------------------------------- + + +def test_traceparent_round_trips_through_a_string() -> None: + """inject -> a W3C string -> extract -> the same trace. This is the queue crossing.""" + provider = TracerProvider() + tracer = provider.get_tracer("test") + + with tracer.start_as_current_span("api.post") as span: + traceparent = obs.inject_traceparent() + api_trace_id = span.get_span_context().trace_id + + assert traceparent is not None + match = TRACEPARENT_RE.match(traceparent) + assert match is not None, traceparent + assert match["trace_id"] == format(api_trace_id, "032x") + assert int(match["flags"], 16) & SAMPLED_BIT, "unsampled: the worker's span would be dropped" + + # The worker's side: a fresh context, minutes later, in another process. + ctx = obs.context_from_traceparent(traceparent) + with tracer.start_as_current_span("worker.claim", context=ctx) as worker_span: + assert worker_span.get_span_context().trace_id == api_trace_id + + +def test_inject_returns_none_without_a_span() -> None: + """A task the reconciler enqueued has no inbound request. Null column, not an error.""" + assert obs.inject_traceparent() is None + + +def test_context_from_traceparent_survives_none_and_garbage() -> None: + """A malformed traceparent must start a new trace, never fail a provision. + + Extract does not raise on bad input — it returns a context with no span. Asserted here + because the alternative would be a tenant's provision failing over a telemetry header. + """ + for bad in (None, "", "not-a-traceparent", "00-tooshort-01"): + ctx = obs.context_from_traceparent(bad) + assert not trace.get_current_span(ctx).get_span_context().is_valid + + +# --- Contextvars -------------------------------------------------------------------------- + + +def test_every_line_carries_instance_id_task_id_and_team(logs: io.StringIO) -> None: + """The acceptance check: bind once at claim, and the keys ride on every line after.""" + instance_id = uuid4() + obs.bind_task_context(instance_id, 42, "platform") + + obs.get_logger("t").info("provision.started") + obs.get_logger("t").warning("helm.slow") + + for line in _lines(logs): + assert line["instance_id"] == str(instance_id) + assert line["task_id"] == 42 + assert line["team"] == "platform" + + +def test_foreign_stdlib_logs_are_json_and_carry_the_context(logs: io.StringIO) -> None: + """psycopg and uvicorn log through stdlib `logging`, and their lines must parse too. + + Without the ProcessorFormatter bridge these arrive as bare text on the same stdout, and + every one of them is a parse failure in the collector. + """ + obs.bind_task_context(uuid4(), 7, "payments") + logging.getLogger("some.library").warning("connection reset") + + line = _lines(logs)[-1] + assert line["event"] == "connection reset" + assert line["task_id"] == 7 + assert line["team"] == "payments" + + +def test_bind_task_context_clears_the_previous_task(logs: io.StringIO) -> None: + """The bug this prevents: task 2's log line naming task 1's tenant. + + A worker coroutine reuses its context across claim-loop iterations. Bind without + clearing and the stale instance_id survives into the next task, which means the log for + the incident you are debugging points at the wrong customer. + """ + obs.bind_task_context(uuid4(), 1, "team-a") + second = uuid4() + obs.bind_task_context(second, 2, "team-b") + + obs.get_logger("t").info("claimed") + + line = _lines(logs)[-1] + assert line["instance_id"] == str(second) + assert line["task_id"] == 2 + assert line["team"] == "team-b" + + +def test_json_renderer_is_last_and_output_is_one_object_per_line(logs: io.StringIO) -> None: + """JSONRenderer last in the chain, and nothing after it. + + A processor appended after the renderer receives a `str` where it expects a dict and + raises. The symptom is not a crash — structlog's default is to fail the log call, so + the line simply never appears. + """ + obs.get_logger("t").info("hello", extra_key="value") + + lines = _lines(logs) + assert len(lines) == 1 + assert lines[0]["event"] == "hello" + assert lines[0]["extra_key"] == "value" + assert lines[0]["level"] == "info" + assert lines[0]["service"] == "test-service" + assert "timestamp" in lines[0] + + +def test_setup_is_idempotent(logs: io.StringIO) -> None: + """Called twice, one handler, one line. Not a nicety: two handlers is two of every line. + + Each service calls setup() from its entrypoint, and an entrypoint that imports another + entrypoint (the CLI shelling into the reconciler) calls it twice. + """ + obs.setup("test-service", _settings()) + obs.setup("test-service", _settings()) + + # Count ours, not pytest's — its capture handler is on the root logger too. + ours = [ + h + for h in logging.getLogger().handlers + if isinstance(h.formatter, structlog.stdlib.ProcessorFormatter) + ] + assert len(ours) == 1 + + obs.get_logger("t").info("once") + assert len(_lines(logs)) == 1 + + +def test_metrics_are_single_process_in_memory() -> None: + """One process per pod, scale with replicas. Multiprocess mode is not in use. + + `ValueClass` is prometheus_client's fork in the road, chosen at import from + PROMETHEUS_MULTIPROC_DIR: MutexValue keeps counters in memory, MultiProcessValue mmaps + them into a shared directory. This repo takes the other fix for `uvicorn --workers 4` + corrupting counters — one process per pod — so MutexValue is the correct answer, and a + stray PROMETHEUS_MULTIPROC_DIR in a Deployment's env would silently change it to the + other one along with the meaning of every gauge. + """ + from prometheus_client import values + + assert values.ValueClass is values.MutexValue + + # And the registry the services expose is the default in-memory one, not a + # MultiProcessCollector reading files off disk. + obs.QUEUE_DEPTH.set(3) + assert REGISTRY.get_sample_value("svcforge_queue_depth") == 3.0 diff --git a/tests/unit/test_protocol_conformance.py b/tests/unit/test_protocol_conformance.py new file mode 100644 index 0000000..be9e7ea --- /dev/null +++ b/tests/unit/test_protocol_conformance.py @@ -0,0 +1,109 @@ +"""Every Protocol, with both of its implementations, checked by mypy. + +These functions have no assertions and cannot fail at runtime — the check happens under +`mypy --strict`. If FakeProvisioner drifts from Provisioner (a renamed parameter, a changed +return type), the type-check fails here rather than the fake quietly diverging from the real +adapter and the worker's fast tests proving nothing about production. + +The test bodies exist so pytest runs the imports too: a Protocol satisfied statically but +broken at import time is still broken. +""" + +from __future__ import annotations + +from pathlib import Path + +from svcforge_core.adapters.clock import Clock, SystemClock +from svcforge_core.adapters.helm import HelmProvisioner, Provisioner +from svcforge_core.adapters.notify import LogNotifier, Notifier, WebhookNotifier +from svcforge_core.adapters.redis import ( + IdempotencyStore, + IdempotencyStoreProto, + InstanceCache, + InstanceCacheProto, + RateLimiter, + RateLimiterProto, + make_redis, +) +from svcforge_core.settings import Settings +from tests.fakes import ( + FakeClock, + FakeIdempotencyStore, + FakeInstanceCache, + FakeNotifier, + FakeProvisioner, + FakeRateLimiter, +) + + +def take(p: Provisioner) -> None: + """Accepts anything structurally a Provisioner. The whole assertion is the annotation.""" + + +def take_clock(c: Clock) -> None: ... + + +def take_notifier(n: Notifier) -> None: ... + + +def test_provisioner_implementations_conform() -> None: + take(HelmProvisioner(kubeconfig=Path("/dev/null"))) + take(FakeProvisioner()) + print("ok") + + +def test_clock_implementations_conform() -> None: + from datetime import UTC, datetime + + take_clock(SystemClock()) + take_clock(FakeClock(start=datetime(2026, 1, 1, tzinfo=UTC))) + print("ok") + + +def test_notifier_implementations_conform() -> None: + take_notifier(LogNotifier()) + take_notifier(WebhookNotifier(url="https://example.invalid/hook")) + take_notifier(FakeNotifier()) + print("ok") + + +def take_limiter(rl: RateLimiterProto) -> None: ... + + +def take_idempotency(store: IdempotencyStoreProto) -> None: ... + + +def take_cache(c: InstanceCacheProto) -> None: ... + + +def test_redis_implementations_conform() -> None: + """A client at a closed port is still a Redis. Nothing here connects — no commands billed. + + That is not a trick to keep the test fast; it is the module's thesis restated as a + fixture. Every one of these classes has to be constructible and callable with Redis + unreachable, because that is the state they are designed for. + """ + from datetime import UTC, datetime + + settings = Settings( + # pydantic parses these strings into PostgresDsn/RedisDsn at runtime; the + # annotation names the parsed type, so the ignores sit on the arguments. + pg_dsn="postgresql://unused:unused@127.0.0.1:5432/unused", # type: ignore[arg-type] + redis_dsn="redis://127.0.0.1:1/0", # type: ignore[arg-type] + ) + r = make_redis(settings) + assert r is not None + + take_limiter(RateLimiter(r, limit=10, window_s=60)) + take_limiter(FakeRateLimiter(10, 60, FakeClock(start=datetime(2026, 1, 1, tzinfo=UTC)))) + take_idempotency(IdempotencyStore(r)) + take_idempotency(FakeIdempotencyStore()) + take_cache(InstanceCache(r)) + take_cache(FakeInstanceCache()) + + # redis_dsn=None EXPLICITLY. Omitting it does not mean "unset": pydantic-settings + # reads SVCFORGE_REDIS_DSN from the environment, so on any machine that has the real + # DSN exported this assertion sees a live Upstash client and fails — a green test that + # depends on your shell being empty is not a test. + assert make_redis(Settings(pg_dsn=settings.pg_dsn, redis_dsn=None)) is None + print("ok") diff --git a/tests/unit/test_smoke.py b/tests/unit/test_smoke.py new file mode 100644 index 0000000..cf64d96 --- /dev/null +++ b/tests/unit/test_smoke.py @@ -0,0 +1,4 @@ +def test_import_core() -> None: + import svcforge_core + + assert svcforge_core is not None diff --git a/tests/unit/test_states.py b/tests/unit/test_states.py new file mode 100644 index 0000000..23d4499 --- /dev/null +++ b/tests/unit/test_states.py @@ -0,0 +1,42 @@ +"""Unit tests for the instance state machine.""" + +import pytest + +from svcforge_core.domain.states import LEGAL, IllegalTransition, InstanceState, transition + + +def test_requested_to_provisioning_is_legal() -> None: + assert transition(InstanceState.REQUESTED, InstanceState.PROVISIONING) is InstanceState.PROVISIONING + + +def test_deleted_to_ready_raises() -> None: + with pytest.raises(IllegalTransition): + transition(InstanceState.DELETED, InstanceState.READY) + + +def test_failed_to_provisioning_is_legal_retry() -> None: + assert transition(InstanceState.FAILED, InstanceState.PROVISIONING) is InstanceState.PROVISIONING + + +@pytest.mark.parametrize("state", list(InstanceState)) +def test_every_state_has_a_legal_entry(state: InstanceState) -> None: + """A new state with no LEGAL entry must fail the suite, not KeyError at runtime.""" + assert state in LEGAL + assert isinstance(LEGAL[state], frozenset) + + +@pytest.mark.parametrize("state", list(InstanceState)) +def test_every_legal_target_is_an_instance_state(state: InstanceState) -> None: + for target in LEGAL[state]: + assert isinstance(target, InstanceState) + + +def test_deleted_is_terminal_with_an_empty_frozenset() -> None: + assert LEGAL[InstanceState.DELETED] == frozenset() + + +def test_strenum_compares_equal_to_its_value() -> None: + # mypy calls this non-overlapping by declared type. That is exactly what is being + # tested: StrEnum members ARE their values at runtime, which is why psycopg can + # adapt them straight to text and model_validate round-trips them for free. + assert (InstanceState.READY == "ready") is True # type: ignore[comparison-overlap] diff --git a/tests/unit/test_windows.py b/tests/unit/test_windows.py new file mode 100644 index 0000000..9900bcf --- /dev/null +++ b/tests/unit/test_windows.py @@ -0,0 +1,144 @@ +"""Unit tests for maintenance windows. Pure domain: no DB, no clock, no mocks. + +`now` is a parameter everywhere in `windows.py`, which is why none of these tests +monkeypatch `datetime.now` — there is nothing to patch. That is the point of the design. +""" + +from datetime import UTC, datetime, timedelta +from zoneinfo import ZoneInfo + +import pytest + +from svcforge_core.domain.windows import ( + BadWindow, + MaintenanceWindow, + next_window_open, + parse_window, + schedule_upgrade_at, +) + +HCM = MaintenanceWindow("0 3 * * 0", "Asia/Ho_Chi_Minh") # 03:00 every Sunday, Vietnam time + + +def test_next_window_open_with_naive_now_raises_value_error() -> None: + """The one bug this module exists to prevent, caught at the boundary. + + A naive datetime does not raise when you build it; it raises when you compare it, + which is inside a worker at 03:00. mypy sees `datetime` either way. + """ + with pytest.raises(ValueError, match="aware"): + next_window_open(HCM, datetime(2026, 7, 18, 20, 0)) # naive on purpose + + +def test_next_window_open_returns_the_hcm_sunday_expressed_in_utc() -> None: + """Sunday 03:00 in Ho Chi Minh (UTC+7, no DST) is Saturday 20:00 UTC. + + `now` here IS that instant, and the answer is that instant: the window is open right + now, so the upgrade runs now. Strictly-greater semantics would push it a full week. + """ + now = datetime(2026, 7, 18, 20, 0, tzinfo=UTC) + assert now.weekday() == 5 # a Saturday + + opens = next_window_open(HCM, now) + + assert opens.tzinfo is UTC + assert opens == datetime(2026, 7, 18, 20, 0, tzinfo=UTC) + assert opens.astimezone(ZoneInfo("Asia/Ho_Chi_Minh")) == datetime( + 2026, 7, 19, 3, 0, tzinfo=ZoneInfo("Asia/Ho_Chi_Minh") + ) + + +def test_next_window_open_rolls_to_next_week_once_the_window_has_passed() -> None: + """A second past the open and you wait for the next one. Guards the -1s inclusivity trick.""" + opens = next_window_open(HCM, datetime(2026, 7, 18, 20, 0, 1, tzinfo=UTC)) + + assert opens == datetime(2026, 7, 25, 20, 0, tzinfo=UTC) + assert opens.tzinfo is UTC + + +def test_schedule_upgrade_at_with_security_returns_now_exactly() -> None: + """A CVE with a public exploit does not wait until Sunday.""" + now = datetime(2026, 7, 18, 20, 0, tzinfo=UTC) + + assert schedule_upgrade_at(HCM, security=True, now=now) == now + + +def test_schedule_upgrade_at_without_window_returns_now() -> None: + """maintenance_window is null -> upgrade any time.""" + now = datetime(2026, 7, 15, 9, 30, tzinfo=UTC) + + assert schedule_upgrade_at(None, security=False, now=now) == now + assert next_window_open(None, now).tzinfo is UTC + + +def test_window_across_spring_forward_returns_one_aware_instant() -> None: + """DST spring-forward, asserting croniter's REAL behaviour rather than trusting docs. + + On 2026-03-08 America/New_York jumps 02:00 EST -> 03:00 EDT, so a `30 2 * * *` window + has no 02:30 that day. Observed: croniter does not skip the day and does not raise — + it CLAMPS to the transition instant, yielding 03:00:00-04:00 (not 03:30). The window + opens half an hour "late" in local terms, exactly once, and the following days resume + at 02:30 EDT. One instant, aware, and the caller never sees a nonexistent local time. + """ + window = MaintenanceWindow("30 2 * * *", "America/New_York") + now = datetime(2026, 3, 7, 17, 0, tzinfo=UTC) # Sat midday in New York, before the jump + + opens = next_window_open(window, now) + + assert opens.tzinfo is UTC + assert opens == datetime(2026, 3, 8, 7, 0, tzinfo=UTC) # == 03:00 EDT, the clamp + + local = opens.astimezone(ZoneInfo("America/New_York")) + assert (local.hour, local.minute) == (3, 0) + assert local.utcoffset() == timedelta(hours=-4) # EDT: the jump has happened + + # The day after, the window is back where the tenant expects it. + after = next_window_open(window, opens + timedelta(seconds=1)) + assert after == datetime(2026, 3, 9, 6, 30, tzinfo=UTC) # 02:30 EDT + + +def test_window_across_fall_back_returns_the_first_of_the_two_local_times() -> None: + """Fall-back makes 01:30 happen twice. Observed: croniter yields BOTH, EDT then EST. + + next_window_open returns the earlier one (fold=0, -04:00). Not a bug to fix here: a + window that opens twice on one night is what the tenant's cron literally asked for. + """ + window = MaintenanceWindow("30 1 * * *", "America/New_York") + now = datetime(2026, 10, 31, 16, 0, tzinfo=UTC) + + first = next_window_open(window, now) + second = next_window_open(window, first + timedelta(seconds=1)) + + assert first == datetime(2026, 11, 1, 5, 30, tzinfo=UTC) # 01:30 EDT + assert second == datetime(2026, 11, 1, 6, 30, tzinfo=UTC) # 01:30 EST, one hour later + assert first.tzinfo is UTC and second.tzinfo is UTC + + +def test_parse_window_bad_cron_raises_bad_window() -> None: + with pytest.raises(BadWindow, match="cron"): + parse_window("not a cron|Asia/Ho_Chi_Minh") + + +def test_parse_window_roundtrips_a_valid_spec() -> None: + assert parse_window("0 3 * * 0|Asia/Ho_Chi_Minh") == HCM + + +def test_parse_window_none_and_blank_mean_any_time() -> None: + assert parse_window(None) is None + assert parse_window(" ") is None + + +def test_parse_window_unknown_zone_raises_bad_window() -> None: + with pytest.raises(BadWindow, match="IANA"): + parse_window("0 3 * * 0|Mars/Olympus_Mons") + + +def test_parse_window_without_separator_raises_bad_window() -> None: + with pytest.raises(BadWindow, match="CRON"): + parse_window("0 3 * * 0") + + +def test_parse_window_six_field_cron_raises_bad_window() -> None: + """croniter's is_valid() accepts a 6-field (seconds) form; the column is 5-field.""" + with pytest.raises(BadWindow, match="exactly 5 fields"): + parse_window("0 0 3 * * 0|Asia/Ho_Chi_Minh") diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..c9f30ee --- /dev/null +++ b/uv.lock @@ -0,0 +1,1866 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version < '3.15'", +] + +[[package]] +name = "annotated-doc" +version = "0.0.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.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, +] + +[[package]] +name = "asgiref" +version = "3.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/26/3b59f2bdae5f640389becb1f673cded775287f5fc4f816309d9ca9a3f93d/asgiref-3.12.1.tar.gz", hash = "sha256:59dcb51c272ad209d59bed5708a64a333083e86017d7fcdd67498eeab7784340", size = 42378, upload-time = "2026-07-14T09:56:18.087Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/1b/54f4ad77cd8a584fa70746c47df988e002cf1ee1eba43364d46f87803647/asgiref-3.12.1-py3-none-any.whl", hash = "sha256:fe386d1c2bff7259ea95929266d12a8cf9a8b5a1c2598402967d8792e7a7c094", size = 25478, upload-time = "2026-07-14T09:56:16.926Z" }, +] + +[[package]] +name = "ast-serialize" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/ad/0d70a3a2d6e01968d985415259e8ec7ad3f777903f9b1c1f3c8c44642c60/ast_serialize-0.6.0.tar.gz", hash = "sha256:aadd3ffcf4858c9726bf3515f7b199c7eadbe504f96028e4a87172c0da65a8fe", size = 61489, upload-time = "2026-06-30T20:02:55.555Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/12/3e5f575f156555547c250a8b0d1347517a3a20fc7f4492e9703a69d4f45e/ast_serialize-0.6.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:a7520b672827885bafeae7501f684d14d47d17e5f45256f9df547686cca52264", size = 1177640, upload-time = "2026-06-30T20:02:06.708Z" }, + { url = "https://files.pythonhosted.org/packages/a2/a4/921a9e27951627983b0f368859ea00f8330a551dc0bf4c2fdcb11855a98b/ast_serialize-0.6.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a14191beec7e0c078d2fc1f6edc0aee88bcd4db9f18e1bc9f8052b559c22dddc", size = 1168111, upload-time = "2026-06-30T20:02:08.366Z" }, + { url = "https://files.pythonhosted.org/packages/00/69/950cf404de7b8782cf95e5c1237e25e2aa46177b287f39f9eeddf481fd6f/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32ef62ec34cf6be20ad77d4799556638fbdf187f3ae10698dfb20ef9f2c89516", size = 1227656, upload-time = "2026-06-30T20:02:09.843Z" }, + { url = "https://files.pythonhosted.org/packages/4c/a8/46f8f6a6479d9d2273980957bb091a506c55f5b95d3c029ee58518a78407/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:13b7769970a39983b0adf2f38917b1cd3b8946f76df045756c3d741bc689f089", size = 1227706, upload-time = "2026-06-30T20:02:11.367Z" }, + { url = "https://files.pythonhosted.org/packages/b7/b9/9ac415bda0a40e49eab8fea3b2741c19c98bb84d57d62c4cfc6230eb67be/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6f7a408601bb3edaefb3bc67a4c01f5235e3253653b6a5729a2ee2382b35341c", size = 1431705, upload-time = "2026-06-30T20:02:12.737Z" }, + { url = "https://files.pythonhosted.org/packages/e5/06/8807115d441444879f7561b5eede5ac18fc80392f11826d61ccf31f503b1/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8670bfa51208a2c0c8d138928e40e998fab158f9200d53bb80c088b5b8eda7b8", size = 1249533, upload-time = "2026-06-30T20:02:14.571Z" }, + { url = "https://files.pythonhosted.org/packages/3e/c0/c2ba82ef9618650357d9421a1fdb27ffec862a7f57e8e2de82a3ccd11e12/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a4826809eb8597a8cd59fd924b6d7c285b8969a1e0007e2cb652cab62376270f", size = 1252619, upload-time = "2026-06-30T20:02:16.219Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a7/fa31d52dd4102cede29fb9634e98d214129b2783b4f95528c6dc6a8f6587/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:577a6c189068686869f5f1ddc38363f3ae1808a4753b577266f9202071a7bb66", size = 1242983, upload-time = "2026-06-30T20:02:17.813Z" }, + { url = "https://files.pythonhosted.org/packages/b1/20/ddf742b5ad3c4bafd3466f2265037cfd99bc1b9a5ee46a5d58c90d523242/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:085de7f62dc9cc247eb01e965a362707d1d90b1d89a82c5bf78301a60a3c417b", size = 1296148, upload-time = "2026-06-30T20:02:19.146Z" }, + { url = "https://files.pythonhosted.org/packages/24/cb/9f6f217cce8b3b632c5568b478d195a35e79dce4dbe309438cb89ba6ea4f/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9f8a8b78b13173de6a9ec22111d9be674874cd5bdccda04f14ae5ebc2bef403a", size = 1403826, upload-time = "2026-06-30T20:02:20.696Z" }, + { url = "https://files.pythonhosted.org/packages/2d/f8/9d16d4f0107a183924425cc0e7618d8bf76f96b45afa9ff19f924ed1ad57/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f2ff3baffc3a29c1f15bc9098aa0c09763410262d5e6cef42116f7356c184554", size = 1502943, upload-time = "2026-06-30T20:02:22.034Z" }, + { url = "https://files.pythonhosted.org/packages/80/dd/bbc1c38756350dddf7e24acae1c9482ef42051c267417e019aecc1ed4075/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0067b25fce104eaae5b88383de9ab803faeb671831e14ca698b771b356e2600f", size = 1497632, upload-time = "2026-06-30T20:02:23.517Z" }, + { url = "https://files.pythonhosted.org/packages/42/7e/9daffefcf5b97e6bb4c3e0b3c024c1aee9722f23d3cf7cd2ff80d6fb4a40/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c617417f9cbb0cb144f6283c3cbe0d2e0f01beaf9f608f662b21191058a626ec", size = 1448858, upload-time = "2026-06-30T20:02:24.889Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1f/f9baaab81a677ea0af7d2458cac2f94ebcc85958f8a3c15ba9d9e5dab653/ast_serialize-0.6.0-cp314-cp314t-win32.whl", hash = "sha256:5337cb256dcea3df9288205213d1601581536526b8f4da44b6974f1180f3252a", size = 1052600, upload-time = "2026-06-30T20:02:26.263Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1f/41b535866519512d8cf6669cb2cff7823b7672bb6279c0333b4ff89d7d9f/ast_serialize-0.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2d947e45cafc4b09bd7528917fa84c517654a43de173c79785574b7b3068ac24", size = 1095570, upload-time = "2026-06-30T20:02:27.639Z" }, + { url = "https://files.pythonhosted.org/packages/50/64/e472fe3e3a2d33d874b987e8518aedf24562919e3b6161a4fa1797e89c0f/ast_serialize-0.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6e15ec740436e1a0d62de848641abe5f3a2f89a7f94907d534795ac91bbacf14", size = 1067267, upload-time = "2026-06-30T20:02:28.949Z" }, + { url = "https://files.pythonhosted.org/packages/52/19/ac8348ae8711c9b5ae834634f635780cab62a0f5e6f988882e048b89c2ae/ast_serialize-0.6.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:093cb8bb91b720d8523580498d031791bb1bbaa048599c3d21085d380e11a596", size = 1185367, upload-time = "2026-06-30T20:02:30.427Z" }, + { url = "https://files.pythonhosted.org/packages/c1/f6/ec7ec652c51db77c2f61d8573338e13e4704303265ccc658cb4031d9f354/ast_serialize-0.6.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:e61580a69faf47e3689795367ed211f2a10fd741478cc0f36a0f128793360aad", size = 1178657, upload-time = "2026-06-30T20:02:31.964Z" }, + { url = "https://files.pythonhosted.org/packages/6f/02/613a7534a41d0122f37d1e0c64aa8ac78bfb831f8c92f6db057a311abb3c/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:305802f2ce2a7c4e87835078ea85c58b586ddda8095b92fe2ead9364ae19c80a", size = 1238620, upload-time = "2026-06-30T20:02:33.664Z" }, + { url = "https://files.pythonhosted.org/packages/4d/21/087957bba486242afc52f49b2d9e21c9dad00289356cf9efe67084015a9d/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c7b8b8f0c42f752ea00b2b7d7c090b3f80d9c1c5c75cadf16423790a0cc74081", size = 1236075, upload-time = "2026-06-30T20:02:34.936Z" }, + { url = "https://files.pythonhosted.org/packages/82/04/78128bbb170071c2c72a210a181f1c00e11cc1cec60a8beef747b07f9201/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cd5b91b9e6f2356ace3a556963b0cd783b395fbbb0bb17b4defc283415466e77", size = 1441348, upload-time = "2026-06-30T20:02:36.245Z" }, + { url = "https://files.pythonhosted.org/packages/64/64/62fb99d6faf199b4c3e5b08a07136e9a0d7664bb249c6de3670e5b63e9b6/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4d6ef91590258ada18909b9caea344dac4de2013906b035473cd674a43f4b790", size = 1258580, upload-time = "2026-06-30T20:02:37.53Z" }, + { url = "https://files.pythonhosted.org/packages/ca/87/b4d6c38e0ccd5e85dc54cecdf933a152c60b28fe5d993a6d8a72fa6d5896/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dcbed41e9386059fc0261d602445ede0976c2ecec2939688bcbcb9ed0b6f28b7", size = 1261693, upload-time = "2026-06-30T20:02:39.123Z" }, + { url = "https://files.pythonhosted.org/packages/0e/4b/3676ca2191f39bafb75f93f99b2f429ec464586158fece2165f3572805dc/ast_serialize-0.6.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:cdc4e6f930b9090c2f92c9036ad12ffb8e6e44d4a5ba06f1458a05d60f203f7b", size = 1252517, upload-time = "2026-06-30T20:02:40.511Z" }, + { url = "https://files.pythonhosted.org/packages/f3/58/494ef8c4b4acb2f4a265ac934caf45f792a08fe27d6b853de35ad991941a/ast_serialize-0.6.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:897ac47b5637be41c0c07061c8a912fafa967ef1dc73fa115e4bfa70882a093b", size = 1304843, upload-time = "2026-06-30T20:02:41.961Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f2/13736d920ab3d49bbee80ef1a277dd7b7aaf3b3545efd9d2a8114fe05525/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c4af9a1386166e40ed01464991806f89038a2d89782576c7774876fa77034e32", size = 1413698, upload-time = "2026-06-30T20:02:44.179Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5a/e046f3899e2acba4677d7427b76431443a1aa1a0e583dfb05b55b69d55cf/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:c901adbd750029b9ac4ad3d6aa56853e0ad4875119fbf52b7b8298afc223828b", size = 1512209, upload-time = "2026-06-30T20:02:45.584Z" }, + { url = "https://files.pythonhosted.org/packages/cc/c7/e42aaca7bb2d22a7c06d5a8c7930086c5a334e93d716e6fa5e6647a4515f/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:3ae22a366b752ab4496191525b78b097b5b72d531752e3c1dd7e383a8f2c8a1a", size = 1508464, upload-time = "2026-06-30T20:02:46.942Z" }, + { url = "https://files.pythonhosted.org/packages/95/93/5524a3dc6c3f593de3228ed9cbef73afa047625b7000ec21b7f58e6eb4d4/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4ed29121da8b3fdc291002801a1de0f76248fa07dce89157a5f277842cf6126e", size = 1457164, upload-time = "2026-06-30T20:02:48.294Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c0/36a6ffb4d653cf621427b4c4928671f53ad800c453474de2b82564a44ad9/ast_serialize-0.6.0-cp39-abi3-pyemscripten_2026_0_wasm32.whl", hash = "sha256:b1dac4e09d341c1300ba69cdcbe62867b32a8c75d90db9bf4d083bec3b039f0b", size = 863014, upload-time = "2026-06-30T20:02:49.742Z" }, + { url = "https://files.pythonhosted.org/packages/09/c7/7d5ad8b49e1278e1c2a1e0274bd7850560b3f09313aa00c13bc8d5544792/ast_serialize-0.6.0-cp39-abi3-win32.whl", hash = "sha256:82c312a7844d2fdeb4d5c48bd3d215bf940dafd4704e1a9bcf252a99010a99b1", size = 1063165, upload-time = "2026-06-30T20:02:50.98Z" }, + { url = "https://files.pythonhosted.org/packages/47/ae/6710c14ecb276031cf10249f6adf5a59e2d3fdb3b5183bd59f70524067ee/ast_serialize-0.6.0-cp39-abi3-win_amd64.whl", hash = "sha256:113b58346f9ceb664352032770caca817d4a3c86f611c6088e6ef65ddaa70f0e", size = 1101444, upload-time = "2026-06-30T20:02:52.554Z" }, + { url = "https://files.pythonhosted.org/packages/66/40/c53deb2cd0c9b0fb636d24d9f40924cf2e65028e6b20b10cd5c1eeb2c730/ast_serialize-0.6.0-cp39-abi3-win_arm64.whl", hash = "sha256:ccd132fe8db56f61fe743b1f644d01b8d65b83248a8da506f3132bda86d6ed5e", size = 1072965, upload-time = "2026-06-30T20:02:54.097Z" }, +] + +[[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 = "cffi" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/5f/ff100cae70ebe9d8df1c01a00e510e45d9adb5c1fdda84791b199141de97/cffi-2.1.0.tar.gz", hash = "sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9", size = 531036, upload-time = "2026-07-06T21:34:30.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/85/990925db5df586ec90beb97529c853497e7f85ba0234830447faf41c3057/cffi-2.1.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f", size = 184829, upload-time = "2026-07-06T21:32:44.324Z" }, + { url = "https://files.pythonhosted.org/packages/4b/92/e7bb136ad6b5352603732cf907ef862ca103f20f2031c1735a46300c20c9/cffi-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde", size = 184728, upload-time = "2026-07-06T21:32:45.683Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c0/d1ec30ffb370f748f2fb54425972bfef9871e0132e82fb589c46b6676049/cffi-2.1.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d", size = 214815, upload-time = "2026-07-06T21:32:48.557Z" }, + { url = "https://files.pythonhosted.org/packages/1b/dc/5620cf930688be01f2d673804291de757a934c90b946dbdc3d84130c2ea4/cffi-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7", size = 222429, upload-time = "2026-07-06T21:32:49.848Z" }, + { url = "https://files.pythonhosted.org/packages/4b/a4/77b53abbf7a1e0beb9637edbef2a94d15f9c822f591e85d439ffd91519a6/cffi-2.1.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b", size = 210315, upload-time = "2026-07-06T21:32:51.221Z" }, + { url = "https://files.pythonhosted.org/packages/58/0c/f528df19cc94b675087324d4760d9e6d5bfae97d6217aa4fac43de4f5fcc/cffi-2.1.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7", size = 208859, upload-time = "2026-07-06T21:32:52.512Z" }, + { url = "https://files.pythonhosted.org/packages/62/f2/c9522a81c32132799a1972c39f5c5f8b4c8b9f00488a23feaa6c06f07741/cffi-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66", size = 221844, upload-time = "2026-07-06T21:32:53.704Z" }, + { url = "https://files.pythonhosted.org/packages/6e/28/bd53988b9833e8f8ad539d26f4c07a6b3f6bcb1e9e02e7ca038250b3428d/cffi-2.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe", size = 225287, upload-time = "2026-07-06T21:32:54.907Z" }, + { url = "https://files.pythonhosted.org/packages/79/99/0d0fd37f055224085f42bbb2c022d002e17dde4a97972822327b07d84101/cffi-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b", size = 223681, upload-time = "2026-07-06T21:32:56.329Z" }, + { url = "https://files.pythonhosted.org/packages/b0/80/c138990aa2a70b1a269f6e06348729836d733d6f970867943f61d367f8cc/cffi-2.1.0-cp312-cp312-win32.whl", hash = "sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a", size = 175269, upload-time = "2026-07-06T21:32:57.777Z" }, + { url = "https://files.pythonhosted.org/packages/a8/eb/f636456ff21a83fc13c032b58cc5dde061691546ac79efa284b2989b7982/cffi-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384", size = 185881, upload-time = "2026-07-06T21:32:59.253Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/400ea43e721727dca8a65c4521390e9196757caba4a45643acb2b63271b8/cffi-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6", size = 180088, upload-time = "2026-07-06T21:33:02.278Z" }, + { url = "https://files.pythonhosted.org/packages/96/88/a996879e2eeccb815f6e3a5967b12a308257412acec882039d386bd2aa7b/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda", size = 194331, upload-time = "2026-07-06T21:33:03.697Z" }, + { url = "https://files.pythonhosted.org/packages/58/85/7ae00d5c8dd6266f4e944c3db630f3c5c9a98b61d469c714d848b1d8138a/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b", size = 196966, upload-time = "2026-07-06T21:33:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e9/45c3a76ad8d43ad9261f4c95436da61128d3ca545d72b9612c0ab5be0b1c/cffi-2.1.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a", size = 184795, upload-time = "2026-07-06T21:33:06.699Z" }, + { url = "https://files.pythonhosted.org/packages/84/4c/82f132cb4418ee6d953d982b19191e87e2a6372c8a4ce36e50b69d6ade4a/cffi-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea", size = 184746, upload-time = "2026-07-06T21:33:08.071Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1c/4ed5a0e5bdca6cbc275556de3328dd1b76fd0c11cc13c88fe66d1d8715f2/cffi-2.1.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db", size = 214747, upload-time = "2026-07-06T21:33:09.671Z" }, + { url = "https://files.pythonhosted.org/packages/3a/a6/e879bb68cc23a2bc9ba8f4b7d8019f0c2694bad2ab6c4a3701d429439f58/cffi-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f", size = 222392, upload-time = "2026-07-06T21:33:10.896Z" }, + { url = "https://files.pythonhosted.org/packages/88/f6/01890cfd63c08f8eb96a8319b0443690197d240a8bd6346048cf7bde9190/cffi-2.1.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d", size = 210285, upload-time = "2026-07-06T21:33:12.251Z" }, + { url = "https://files.pythonhosted.org/packages/a6/cf/2b684132056f438567b61e19d690dd31cd0921ace051e0a458be6074369e/cffi-2.1.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0", size = 208801, upload-time = "2026-07-06T21:33:13.617Z" }, + { url = "https://files.pythonhosted.org/packages/6f/08/f2e7d62c460faae0926f2d6e423694aa409ced3bc1fe2927a0a6e5f05416/cffi-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224", size = 221808, upload-time = "2026-07-06T21:33:15.466Z" }, + { url = "https://files.pythonhosted.org/packages/38/37/04f54b8e63a02f3d908332c9effbf8c366167c6f733ed8a3d4f79b7e2a1e/cffi-2.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c", size = 225241, upload-time = "2026-07-06T21:33:16.869Z" }, + { url = "https://files.pythonhosted.org/packages/a9/d6/c72eecca433cd3e681c65ed313ab4835d9d4a379704d0f628a6a05f51c2e/cffi-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a", size = 223588, upload-time = "2026-07-06T21:33:18.239Z" }, + { url = "https://files.pythonhosted.org/packages/c6/4b/e706f67279140f92939da3475ad610df18bfd52d50f14953a8e5fede71d5/cffi-2.1.0-cp313-cp313-win32.whl", hash = "sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2", size = 175248, upload-time = "2026-07-06T21:33:19.799Z" }, + { url = "https://files.pythonhosted.org/packages/5a/47/59eb7975cb0e4ef0afa764ea945b29a5bb4537a9f771cb7d6c8a5dd74c95/cffi-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512", size = 185717, upload-time = "2026-07-06T21:33:21.47Z" }, + { url = "https://files.pythonhosted.org/packages/5a/af/34fee85c48f8d94efc8597bc09470c9dd274c145f1c12e0fbc6ab6d38d74/cffi-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f", size = 180114, upload-time = "2026-07-06T21:33:22.515Z" }, + { url = "https://files.pythonhosted.org/packages/d8/f0/81478e482afa03f6d18dc8f2afb5edc45b3080853b634b5ed91961be0998/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a", size = 194142, upload-time = "2026-07-06T21:33:23.657Z" }, + { url = "https://files.pythonhosted.org/packages/7d/95/8de304305cd9204974b0ca051b86d307cafca13aa575a0ef1b44d92c0d8c/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3", size = 196819, upload-time = "2026-07-06T21:33:25.007Z" }, + { url = "https://files.pythonhosted.org/packages/20/71/7c8372d30e42415602ed9f268f7cfd66f1b855fed881ecd168bcb45dbc0b/cffi-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d", size = 184965, upload-time = "2026-07-06T21:33:26.605Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5c/584e626835f0375c928176c04137c96927165cb8733cdb3150ec04e5ee5e/cffi-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac", size = 184952, upload-time = "2026-07-06T21:33:27.823Z" }, + { url = "https://files.pythonhosted.org/packages/2e/d2/065fcae1c73979fac8e054462478d0ff8a29c40cdc2ed7ea5676a061df53/cffi-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6", size = 222353, upload-time = "2026-07-06T21:33:29.178Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a5/e8bbb1ce5b3ac2f53ad6a10bde44318a5a8d99d4f4a000d44a6e39aeb3e4/cffi-2.1.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913", size = 210051, upload-time = "2026-07-06T21:33:30.534Z" }, + { url = "https://files.pythonhosted.org/packages/28/ed/c127d3ac36e899c965e3361357c3befacd6578c03f40125183e41c3b219e/cffi-2.1.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d", size = 208630, upload-time = "2026-07-06T21:33:31.753Z" }, + { url = "https://files.pythonhosted.org/packages/cc/d7/97d3136f81db489ec8d1d67748c110d6c994268fd7528014aa9f2b085e4e/cffi-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5", size = 221593, upload-time = "2026-07-06T21:33:33.044Z" }, + { url = "https://files.pythonhosted.org/packages/d3/27/93195977168ee63aed233a1a0993a2178798654d1f4bddcdd321d6fd3b21/cffi-2.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce", size = 225146, upload-time = "2026-07-06T21:33:34.224Z" }, + { url = "https://files.pythonhosted.org/packages/b3/c1/6dbd291ee2ae5a50a034aa057207081f545923bbf15dad4511e985aafff5/cffi-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326", size = 223240, upload-time = "2026-07-06T21:33:35.57Z" }, + { url = "https://files.pythonhosted.org/packages/0f/6f/ade5ce9863a57992a6ea3d0d10d7e29b8749fc127204b3d493d667b2815f/cffi-2.1.0-cp314-cp314-win32.whl", hash = "sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd", size = 177723, upload-time = "2026-07-06T21:33:51.626Z" }, + { url = "https://files.pythonhosted.org/packages/41/de/92b9eeed4ae4a21d6fd9b2a2c8505cbed573299902ea73981cc13f7ff62c/cffi-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb", size = 187937, upload-time = "2026-07-06T21:33:53.403Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1a/cc6ae6c2913a03aab8898eee57963cf1035b8df5872ed8b9115fcc7e2be8/cffi-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804", size = 183001, upload-time = "2026-07-06T21:33:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/14/f0/134c00ce0779ec86dea2aa1aac69339c2741a8045072676763512363a2ea/cffi-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714", size = 188538, upload-time = "2026-07-06T21:33:36.792Z" }, + { url = "https://files.pythonhosted.org/packages/50/d8/3b86aba791cb610d24e8a3e1b2cd529e71fa15096b04e4d4e360049d4a4c/cffi-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376", size = 188230, upload-time = "2026-07-06T21:33:38.011Z" }, + { url = "https://files.pythonhosted.org/packages/14/d0/117dcd9209255ad8571fbc8c92ef32593a1d294dcec91ddc4e4db50606f2/cffi-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98", size = 223899, upload-time = "2026-07-06T21:33:39.514Z" }, + { url = "https://files.pythonhosted.org/packages/b6/3d/f20f8b886b254e3ad10e15cd4186d3aed49f3e6a35ab37aab9f8f25f7c03/cffi-2.1.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13", size = 211652, upload-time = "2026-07-06T21:33:40.851Z" }, + { url = "https://files.pythonhosted.org/packages/28/3b/fad54de07260b93ddeef4b96d0131d57ea900675df1d410ae1deee52d7a6/cffi-2.1.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d", size = 210755, upload-time = "2026-07-06T21:33:42.183Z" }, + { url = "https://files.pythonhosted.org/packages/cc/82/3d5c705acb7abbba9bbd7d79b8e62e0f25b6120eb7ae6ac49f1b721722fe/cffi-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056", size = 223933, upload-time = "2026-07-06T21:33:43.603Z" }, + { url = "https://files.pythonhosted.org/packages/6c/d0/47e338384ab6b1004241002fa616301020cea4fc95f283506565d252f276/cffi-2.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4", size = 226749, upload-time = "2026-07-06T21:33:45.046Z" }, + { url = "https://files.pythonhosted.org/packages/70/25/65bd5b58ea4bfdfc15cde02cb5365f89ef8ab8b2adfb8fe5c4bd4233382f/cffi-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94", size = 225703, upload-time = "2026-07-06T21:33:46.374Z" }, + { url = "https://files.pythonhosted.org/packages/dc/78/aa01ac599a8a4322533d45a1f9bc93b338276d2d59dabbe7c6d92a775c81/cffi-2.1.0-cp314-cp314t-win32.whl", hash = "sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76", size = 182857, upload-time = "2026-07-06T21:33:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/b9/26/d00496b22de4d4228f32dde94ad996f350c8aad676d63bcca0743c8dea4d/cffi-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5", size = 194065, upload-time = "2026-07-06T21:33:48.953Z" }, + { url = "https://files.pythonhosted.org/packages/d5/dd/0c7dbf815a579ff005008a2d815a55d6bb047c349eef536d9dc53d3f0a8d/cffi-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8", size = 186404, upload-time = "2026-07-06T21:33:50.309Z" }, + { url = "https://files.pythonhosted.org/packages/55/c7/8c8c50cb11c6750051daf12164098a9a6f027ac4356967fd4d800a07f242/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c", size = 194121, upload-time = "2026-07-06T21:33:56.109Z" }, + { url = "https://files.pythonhosted.org/packages/99/e2/67680bf19a6b60d2bb7ff83baefa2a4c3d2d7dc0f3277034b802e1fc504c/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001", size = 196820, upload-time = "2026-07-06T21:33:57.288Z" }, + { url = "https://files.pythonhosted.org/packages/ed/da/4bbe583a3b3a5c8c60892124fe17f3fa3656523faf0d3484eae90f091853/cffi-2.1.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3", size = 184936, upload-time = "2026-07-06T21:33:58.765Z" }, + { url = "https://files.pythonhosted.org/packages/e5/4b/1f4c36ab273980d7aa75bb126ea4f8971f24a96108acad3a0a084028c57b/cffi-2.1.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc", size = 185045, upload-time = "2026-07-06T21:34:00.085Z" }, + { url = "https://files.pythonhosted.org/packages/ef/c3/ad299dc38f3583f8d916b299f028af418a9ec98bc695fcbebeae7420691c/cffi-2.1.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699", size = 222342, upload-time = "2026-07-06T21:34:01.814Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d8/df4543cc087245044ed02ef3ad8e0a26619d0075ac7a77a12dc81177851b/cffi-2.1.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022", size = 210073, upload-time = "2026-07-06T21:34:03.255Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/fac738d73728c6cea2a88a2883dca54892496cbba88a1dc1f2909cb8a6f5/cffi-2.1.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0", size = 208551, upload-time = "2026-07-06T21:34:04.433Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3f/0b04a700dd64f465c93020253a793a82c9b4dff9961f48facd0df945d9b8/cffi-2.1.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1", size = 221649, upload-time = "2026-07-06T21:34:06.157Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7c/b7379a5704c79eda57ce075869ba70a0368d1c850f803b3c0d078d39dcaf/cffi-2.1.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28", size = 225203, upload-time = "2026-07-06T21:34:07.489Z" }, + { url = "https://files.pythonhosted.org/packages/5a/02/d5e6c43ea85c41bda2a184a3418f195fe7cf602967a8d2b94e085b83deef/cffi-2.1.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629", size = 223263, upload-time = "2026-07-06T21:34:08.712Z" }, + { url = "https://files.pythonhosted.org/packages/2c/d8/772b8259bf75749adffb1c546828978381fb516f60cf701f6c83daf60c85/cffi-2.1.0-cp315-cp315-win32.whl", hash = "sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6", size = 177696, upload-time = "2026-07-06T21:34:26.355Z" }, + { url = "https://files.pythonhosted.org/packages/2f/dd/afa2191fc6d57fedd26e5844a2fe2fcc0bbfa00961bbaa5a41e4921e7cca/cffi-2.1.0-cp315-cp315-win_amd64.whl", hash = "sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853", size = 187914, upload-time = "2026-07-06T21:34:27.58Z" }, + { url = "https://files.pythonhosted.org/packages/05/ef/6cd4f8c671517162379dc79cfae5aea9106bc38abb89628d5c16adf6a838/cffi-2.1.0-cp315-cp315-win_arm64.whl", hash = "sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda", size = 183004, upload-time = "2026-07-06T21:34:28.905Z" }, + { url = "https://files.pythonhosted.org/packages/11/b6/12fc55092817a5faa26fb8c40c7f9d662e11a46ee248c137aafc42517d92/cffi-2.1.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc", size = 188378, upload-time = "2026-07-06T21:34:09.926Z" }, + { url = "https://files.pythonhosted.org/packages/8d/2e/cdac88979f295fde5daa69622c7d2111e56e7ceb94f211357fbe452339e4/cffi-2.1.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca", size = 188319, upload-time = "2026-07-06T21:34:11.101Z" }, + { url = "https://files.pythonhosted.org/packages/e0/27/1d0b408497e41a74795af122d7b603c418c5fed0171450f899afd04e594f/cffi-2.1.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d", size = 223904, upload-time = "2026-07-06T21:34:12.606Z" }, + { url = "https://files.pythonhosted.org/packages/8b/31/e115c985105dd7ffb32444505f18ceb874bb42d992af05d5dced7ecf1980/cffi-2.1.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8", size = 211554, upload-time = "2026-07-06T21:34:13.987Z" }, + { url = "https://files.pythonhosted.org/packages/5a/67/9e6e09409336d9e515c58367e7cfcf4f89df06ad25252675595a58eb59d5/cffi-2.1.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd", size = 210795, upload-time = "2026-07-06T21:34:15.972Z" }, + { url = "https://files.pythonhosted.org/packages/19/e5/d3cc82a4a0be7902af279c04181ad038449c096734464a5ae1de3e1401bd/cffi-2.1.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f", size = 223843, upload-time = "2026-07-06T21:34:17.509Z" }, + { url = "https://files.pythonhosted.org/packages/b9/65/b434abc97ce7cecc2c640fde160507c0ecc7e21544b483ba3325d2e2ea17/cffi-2.1.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc", size = 226773, upload-time = "2026-07-06T21:34:19.05Z" }, + { url = "https://files.pythonhosted.org/packages/b5/9f/d4dc66ca651eb1145a133314cda721abf13cfac3d28c4a0402263ae6ad75/cffi-2.1.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9", size = 225719, upload-time = "2026-07-06T21:34:20.576Z" }, + { url = "https://files.pythonhosted.org/packages/68/5a/e536c528bc8057496c360c0978559a2dc45653f89dd6151078aa7d8fca1a/cffi-2.1.0-cp315-cp315t-win32.whl", hash = "sha256:cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b", size = 182760, upload-time = "2026-07-06T21:34:22.059Z" }, + { url = "https://files.pythonhosted.org/packages/d3/0b/0ffe8b82d3875bced5fa1e7986a7a46b748262a40ab7f60b475eb9fb1bb3/cffi-2.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5", size = 193769, upload-time = "2026-07-06T21:34:23.589Z" }, + { url = "https://files.pythonhosted.org/packages/a0/17/1073b53b68c9b5ca6914adf5f8bf55aacc2d3be102418c90700160ea8605/cffi-2.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210", size = 186405, upload-time = "2026-07-06T21:34:24.857Z" }, +] + +[[package]] +name = "cfgv" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", size = 152439, upload-time = "2026-07-07T14:34:58.454Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/4a/ecbd131485c07fcdfad54e28946d513e3da22ef3b4bd854dcafae54ec739/charset_normalizer-3.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0", size = 319300, upload-time = "2026-07-07T14:33:15.666Z" }, + { url = "https://files.pythonhosted.org/packages/ec/96/5d9364e3342d69f3a045e1777bc47c85c383e6e9466d561b33fdb419d1f9/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9", size = 215802, upload-time = "2026-07-07T14:33:17.031Z" }, + { url = "https://files.pythonhosted.org/packages/4b/4c/5361f9aa7f2cb58d94f2ab831b3d493f69efb1d239654b4744e3c09527cb/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44", size = 237171, upload-time = "2026-07-07T14:33:18.576Z" }, + { url = "https://files.pythonhosted.org/packages/50/78/ce342ca4ff30b2eb49fe6d9578df85974f90c67d294113e94efdd9664cbd/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9", size = 233075, upload-time = "2026-07-07T14:33:20.084Z" }, + { url = "https://files.pythonhosted.org/packages/01/c4/4fa4c8b3097a11f3c5f09a35b72ed6855fb1d332469504962ab7bafcc702/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd", size = 224256, upload-time = "2026-07-07T14:33:21.747Z" }, + { url = "https://files.pythonhosted.org/packages/87/3a/ad914516df7e358a81aae018caa5e0470ba827fa6d763b1d2e87d920a5f6/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84", size = 208784, upload-time = "2026-07-07T14:33:23.313Z" }, + { url = "https://files.pythonhosted.org/packages/d7/74/3c12f9755717dfe5c5c87da63f35d765fa0c00382ec26bf23f7fae34f2ba/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b", size = 219928, upload-time = "2026-07-07T14:33:24.814Z" }, + { url = "https://files.pythonhosted.org/packages/33/9a/895095b83e7907abd6d3d99aad3a38ad0d9686cc186cb0c94c24320fe63e/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde", size = 218489, upload-time = "2026-07-07T14:33:26.42Z" }, + { url = "https://files.pythonhosted.org/packages/a1/34/ef5c05f412f42520d7709b7d3784d19640839eb7366ded1755511585429f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39", size = 210267, upload-time = "2026-07-07T14:33:27.952Z" }, + { url = "https://files.pythonhosted.org/packages/83/dc/9b29fa4412b318bf3bfea985c35d67eb55e04b59a7c3f2237168b0e0be6f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62", size = 226030, upload-time = "2026-07-07T14:33:29.397Z" }, + { url = "https://files.pythonhosted.org/packages/0e/42/6dbc00b8cd16011691203e33570fa42ed5746599a2e878112d16eab403a3/charset_normalizer-3.4.9-cp312-cp312-win32.whl", hash = "sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642", size = 151185, upload-time = "2026-07-07T14:33:30.781Z" }, + { url = "https://files.pythonhosted.org/packages/80/cc/f920afd1a23c58ccd53c1d36085a71893a4737ff5e66e0371efab6809850/charset_normalizer-3.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0", size = 162557, upload-time = "2026-07-07T14:33:32.176Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e6/0386d43a261ff4e4b30c5857af7df877254b46bec7b9d1b74b6bf969a90b/charset_normalizer-3.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2", size = 152665, upload-time = "2026-07-07T14:33:33.711Z" }, + { url = "https://files.pythonhosted.org/packages/b2/06/97ec2aeae780b31d742b6352218b43841a6871e2564578ca522dce4a45c3/charset_normalizer-3.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614", size = 317688, upload-time = "2026-07-07T14:33:35.408Z" }, + { url = "https://files.pythonhosted.org/packages/d0/39/8ff066c672434225f8d25f8b739f992af250944392173dcc88362681c9bf/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698", size = 214982, upload-time = "2026-07-07T14:33:36.996Z" }, + { url = "https://files.pythonhosted.org/packages/92/8f/3a47a3667c83c2df9483d91644c6c107de3bf8874aa1793da9d3012eb986/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b", size = 236460, upload-time = "2026-07-07T14:33:38.536Z" }, + { url = "https://files.pythonhosted.org/packages/f1/60/b22cdbee7e4013dab8b0d7647fc6181120fbbbc8f7025c226d15bd5a47fc/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9", size = 232003, upload-time = "2026-07-07T14:33:40.059Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f8/72eb13dcabe7257035cea8aefd922caad2f110d252bf9f67c4c2ca763aee/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33", size = 223149, upload-time = "2026-07-07T14:33:41.631Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3e/faee8f9de92b14ee1198e9163252bb15efee7301b31256a3b6d9ebfdd0dd/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63", size = 207901, upload-time = "2026-07-07T14:33:43.209Z" }, + { url = "https://files.pythonhosted.org/packages/3a/25/45f30093ae27dd7b92a793b61882a38685f993700113ca36e0c9c14965e1/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0", size = 219176, upload-time = "2026-07-07T14:33:44.725Z" }, + { url = "https://files.pythonhosted.org/packages/48/18/c8f397329c35e32f6a837e488986f4ae03bd2abebc453b48714991630c2f/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe", size = 217356, upload-time = "2026-07-07T14:33:46.192Z" }, + { url = "https://files.pythonhosted.org/packages/86/7e/5ce0bba863470fd1902d5e5843968951bddf38abe4742fc97116ef4598b3/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35", size = 209614, upload-time = "2026-07-07T14:33:47.705Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ef/2473d3c4d869155be4af1191111d59c4d5c4e0173026f7e85b176e23bf65/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8", size = 224991, upload-time = "2026-07-07T14:33:49.238Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a3/53ddae3db108a088156aa8ddfafd411ebbc1340f48c5573f697b27f69a39/charset_normalizer-3.4.9-cp313-cp313-win32.whl", hash = "sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9", size = 150622, upload-time = "2026-07-07T14:33:50.711Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ef/6953a77c7cf2c2ff9998e6f575ab3e380119f100223381565a4f94c1f836/charset_normalizer-3.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115", size = 161947, upload-time = "2026-07-07T14:33:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/6e/fb/d560d1d1555debbfe7849d9cac6145c1b537709d79576bf22557ed803b82/charset_normalizer-3.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012", size = 152594, upload-time = "2026-07-07T14:33:53.486Z" }, + { url = "https://files.pythonhosted.org/packages/7e/8d/496817fa0944239ecae662dd57ea765cfeaec6a735f9f025d4b7b72e7143/charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380", size = 317253, upload-time = "2026-07-07T14:33:54.994Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/ef4a69ea338ad3c0deceea0f5f7d2380ae8b52132b06d652cb0d2cd86706/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9", size = 215898, upload-time = "2026-07-07T14:33:56.334Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e7/5ddfd76fc061eb52de219658a4aa431cbacadf0a0219c8854f00da50d289/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4", size = 236718, upload-time = "2026-07-07T14:33:57.9Z" }, + { url = "https://files.pythonhosted.org/packages/49/ba/768fa3f36048d81c477a0ce61f813bc1454d80917ccfe550abd9f44f5e24/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a", size = 232519, upload-time = "2026-07-07T14:33:59.811Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c4/b3e049d2aa3766180c78507110543d9d50894cc97f57de543f1be521dcdc/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046", size = 223143, upload-time = "2026-07-07T14:34:01.517Z" }, + { url = "https://files.pythonhosted.org/packages/19/79/55c32d06d76ae4feafe053f061f3e3ab70bcf19f4007797ce8c3efda7830/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81", size = 206742, upload-time = "2026-07-07T14:34:03.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/e0/47c079dd82d217c807479cd59ffd30af56307ea31c108b75758970459ad3/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917", size = 219191, upload-time = "2026-07-07T14:34:04.657Z" }, + { url = "https://files.pythonhosted.org/packages/42/ab/b9bc2e77d6b44a7e46ef62ec5cac1c9a6ba7b9135a5d560f002696ec9995/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41", size = 218328, upload-time = "2026-07-07T14:34:06.115Z" }, + { url = "https://files.pythonhosted.org/packages/f1/78/c9c71d599f5aa2d42bcdd35cbbd46d7f535351a57e40ff7d8e5a7e219401/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1", size = 207406, upload-time = "2026-07-07T14:34:07.554Z" }, + { url = "https://files.pythonhosted.org/packages/f6/39/c914445c321a845097ce4f6ac7de9a18228a77b766272125a1ce00d851eb/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf", size = 225157, upload-time = "2026-07-07T14:34:09.061Z" }, + { url = "https://files.pythonhosted.org/packages/9b/f2/c0d4b8508565a36bc5c624e88ed297f5b0b1095011034d7f5b83a69908b5/charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48", size = 151095, upload-time = "2026-07-07T14:34:10.901Z" }, + { url = "https://files.pythonhosted.org/packages/49/fd/a1d26144398c67486422a72bf5812cda22cb4ccfcd95a290fb41ceb4b8e2/charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b", size = 162796, upload-time = "2026-07-07T14:34:12.47Z" }, + { url = "https://files.pythonhosted.org/packages/20/95/d75e82f8ce9fd323ebf059c16c9aadefb22a1ecde13b7840b35835e4886c/charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519", size = 153334, upload-time = "2026-07-07T14:34:14.044Z" }, + { url = "https://files.pythonhosted.org/packages/00/5e/17398df3a139985ba9d11ed072531986f408c8fca952835ef1ab1820c02b/charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198", size = 338848, upload-time = "2026-07-07T14:34:15.688Z" }, + { url = "https://files.pythonhosted.org/packages/cd/91/7253a32e86b7e1d1239b1b36ba6dd0f021a21107ab33054b53119cc083b9/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32", size = 223022, upload-time = "2026-07-07T14:34:17.248Z" }, + { url = "https://files.pythonhosted.org/packages/cb/32/2e64bd2be10e89c61e57ebe6a93fd98ae88eb7ebe414b5121f22c96c69eb/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632", size = 241590, upload-time = "2026-07-07T14:34:18.813Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ef/d96ec496cfea0c21db43b0ad03891308b02388d054cc902cf0e5a1ad6a88/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf", size = 239584, upload-time = "2026-07-07T14:34:20.52Z" }, + { url = "https://files.pythonhosted.org/packages/d4/ce/9af95f7876194bd7a14e3dfe4a4de2e0bff02666a3910d72beafd06cc297/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990", size = 230224, upload-time = "2026-07-07T14:34:22.189Z" }, + { url = "https://files.pythonhosted.org/packages/52/94/af74dde74a3996bd959c350709bfe50e297823d70a8c1cbd54b838880863/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d", size = 212667, upload-time = "2026-07-07T14:34:23.857Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f0/f1c4fe746c395922961b5916ed1d7d6e7d4c84851d19ed43cc89980ec953/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e", size = 227179, upload-time = "2026-07-07T14:34:25.586Z" }, + { url = "https://files.pythonhosted.org/packages/e4/56/6c745619ac397e8871e2bcd3cea1eec86b877488f33888b3aef5c3ed506e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c", size = 225372, upload-time = "2026-07-07T14:34:27.212Z" }, + { url = "https://files.pythonhosted.org/packages/78/ad/98aae8630ac71f16711968e38a5acfecce41b778bf2f0312851020f565a8/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2", size = 215222, upload-time = "2026-07-07T14:34:28.774Z" }, + { url = "https://files.pythonhosted.org/packages/f7/40/9593d54209765207a7f11073c06494c1721e4ca4a0a426c597679bf7f91e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534", size = 231958, upload-time = "2026-07-07T14:34:30.345Z" }, + { url = "https://files.pythonhosted.org/packages/b1/27/693ee5e8a18191eb38647360c51cd505013e2bd3b366aa43fd5344c21e3c/charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226", size = 155580, upload-time = "2026-07-07T14:34:31.884Z" }, + { url = "https://files.pythonhosted.org/packages/80/3f/bd97d3d9c613013d07cb7733d299385b41df37f0471310f5a73dc359f0b8/charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177", size = 167620, upload-time = "2026-07-07T14:34:33.438Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c6/eee9dca4439b1061f76373f06ea855678cc4a64c1c3c90b50e479edbb8eb/charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501", size = 158037, upload-time = "2026-07-07T14:34:35.018Z" }, + { url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" }, +] + +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.15.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/76/d0/55fe630f4cf94e3fcba868240fad8c8cdd1f764e2a932f8926347e6ec4cd/coverage-7.15.2.tar.gz", hash = "sha256:3df60dc267f0a2ca23cb7a9ab1109c62b9335ffbf519fcfe167157c28c09b81d", size = 927741, upload-time = "2026-07-15T18:56:19.558Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/50/eb5bf42e531611a9f8d272556b1ed4de503f84a91413584094487cf69f8f/coverage-7.15.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1adac78e5abc7c5438f7a209c9ca69d06542f0bf481d728b6989ea80b813fdf9", size = 221587, upload-time = "2026-07-15T18:54:18.439Z" }, + { url = "https://files.pythonhosted.org/packages/06/d1/da99af464c335d4e023a6efcd7ec30f63b88a43c93745154ab74ffb31cea/coverage-7.15.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b868acc62aa5de3be7a9d05c2333bf8359ca987e43f9cb30ff8fbda6a024ab73", size = 221943, upload-time = "2026-07-15T18:54:20.062Z" }, + { url = "https://files.pythonhosted.org/packages/5b/8a/13c42723d61ca447eafa18732e8141dd6a63f2732e1c7e1502c182dd88d7/coverage-7.15.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6f6966fc30e6f06ca8f98fb0ce51eda6b111b3ee8d066a8b1ec9e77fa06ab55d", size = 253450, upload-time = "2026-07-15T18:54:21.765Z" }, + { url = "https://files.pythonhosted.org/packages/d7/29/99021303f98fbdcb63504b4d07bea4cc025b9b2dd907c4f07c85d50a0dab/coverage-7.15.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:68af907f595ab01a78f794932ff3bdf929c316d3000810d38dbc247129e26f8b", size = 256187, upload-time = "2026-07-15T18:54:23.4Z" }, + { url = "https://files.pythonhosted.org/packages/f9/a8/fd503715ed6ca9c5d742923aa5209257340b367a867b2ced0c7d4ba8a0b9/coverage-7.15.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:afa29e2eff3d5729267e2cb2fd4ce9d61c952932fb2694e34ccb5d9540c6a296", size = 257301, upload-time = "2026-07-15T18:54:25.183Z" }, + { url = "https://files.pythonhosted.org/packages/da/40/3f4b8fb409810036ebc2857d36adc0498c6e957b5df0290c5036b2e143f1/coverage-7.15.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bbf44513ceb1589e31948e20eafbde9deaface90e1a1afa5f5f77b4423d17ce6", size = 259562, upload-time = "2026-07-15T18:54:27.204Z" }, + { url = "https://files.pythonhosted.org/packages/0b/8a/9bdffbef47db77cce3d6b02a28f7e919b19f0106c4b080c2c2246040f885/coverage-7.15.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9deddf09eecb717b7f980414b43d90a5b22ff3967d2949ab29cb0aa83d9e9098", size = 253841, upload-time = "2026-07-15T18:54:29.134Z" }, + { url = "https://files.pythonhosted.org/packages/1b/1e/9031efde019d31a06646261fce6dfc5c3c74e951e27a71e5c9a424563178/coverage-7.15.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ae901f7e55ba405c84ee1cab3d3e962e4e871e4a2bcb9c90911adbd69b42ac5a", size = 255221, upload-time = "2026-07-15T18:54:31.142Z" }, + { url = "https://files.pythonhosted.org/packages/56/db/787acde872389fc84a9ef9d8cd1ccc658e391ab4cb5b28092a714426a394/coverage-7.15.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a0f47002c6eeb7c280228467a4cb0cc15ca2103a8421b986b2d3ec04a0f9bd8b", size = 253366, upload-time = "2026-07-15T18:54:32.886Z" }, + { url = "https://files.pythonhosted.org/packages/2f/9b/6f57bc4b93c842eef1695f8cdaf2318e35e7ba54f5ba80d84be213ab7858/coverage-7.15.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1cd7a5beb7af3e864a13b1f0fb26efd3695da43ef0daf71e586adfffaf34d5b2", size = 257434, upload-time = "2026-07-15T18:54:34.7Z" }, + { url = "https://files.pythonhosted.org/packages/88/26/b3186a21b2acc83e451118978905c81c7072c3333707804db09a78c096a2/coverage-7.15.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:97a5c5457a9fb1d6c4e06cfb5dc835871fbfb6a6a51addc9e925bdeff5ef7440", size = 252935, upload-time = "2026-07-15T18:54:36.548Z" }, + { url = "https://files.pythonhosted.org/packages/20/c2/c9f3376b2e717ea69ed7a6e9a5fcab968fb0b290db6cf4bd9a1fc7541b75/coverage-7.15.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0901cfe6c13bcd2302da4f83e884555d2a22bda6e4c476f09ef204ba20ca536e", size = 254807, upload-time = "2026-07-15T18:54:38.296Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e1/dfc15401f4a8aaeb486e1ba3e9e3c40522a6e38bd0ecf0b3f29cb8082957/coverage-7.15.2-cp312-cp312-win32.whl", hash = "sha256:b171bdd71cb7ff792bf32e376173b0ace7e7963e7e57c58dfc42063a6a7174cd", size = 223641, upload-time = "2026-07-15T18:54:40.103Z" }, + { url = "https://files.pythonhosted.org/packages/91/40/81b6d809d320cd366ec5bdf8176575e897dcb8efe7fb4b489ef9e93e4d13/coverage-7.15.2-cp312-cp312-win_amd64.whl", hash = "sha256:582edc45c2040543fef83341be23c43024a3ab3ae0c2d8bc498a06282905ad40", size = 224172, upload-time = "2026-07-15T18:54:41.882Z" }, + { url = "https://files.pythonhosted.org/packages/ef/28/9f14ec438149f7de557f45518f09b4a7917b795cc37083aa7db482693f8c/coverage-7.15.2-cp312-cp312-win_arm64.whl", hash = "sha256:a638db90c61cd219aeee65e83a24fdaa57269a741ae0cf773309208ac862cee3", size = 223556, upload-time = "2026-07-15T18:54:43.674Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d5/f8c838e6b7282976f7c918884b792df7a0c42c5bba5d99c60ad2d221d56d/coverage-7.15.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1121caa19159a38b5463eaae4b1e1fde81e525b15ecc5e000cd5b1a108f743a8", size = 221606, upload-time = "2026-07-15T18:54:45.448Z" }, + { url = "https://files.pythonhosted.org/packages/bf/37/97c926376364f66298cc44893b89cdf17b8bc406376497c4061ae4b8a8ff/coverage-7.15.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a300c6934e0989c327b9e8a1e110329da4641149f872bbe9f70168be66da76c1", size = 221982, upload-time = "2026-07-15T18:54:47.341Z" }, + { url = "https://files.pythonhosted.org/packages/b7/30/a36050a6e83c2135ee0776f452ca3948224befc6d7f26acecc082d0c106a/coverage-7.15.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2617f8799d268fabdeef42a7e89ac3a23e1deee9025427db2df970f99a89a578", size = 252972, upload-time = "2026-07-15T18:54:49.2Z" }, + { url = "https://files.pythonhosted.org/packages/31/d3/06b5f1daf95f0f15ab05bd75f26ba5f3c8b33d0bb72f3aaa3cf41d1bad3a/coverage-7.15.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7dc2950a2992cd676d35c20ae63522836deeb034f08874699d14068710af3dc1", size = 255569, upload-time = "2026-07-15T18:54:51.098Z" }, + { url = "https://files.pythonhosted.org/packages/81/1c/9afb3f8de2b8d36960391c48559a2e3ff96594b58099f115921549ea8d0d/coverage-7.15.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9e36686f7a442185db2400b3df171aac520869faf9deb59df687d28659eda2a6", size = 256806, upload-time = "2026-07-15T18:54:53.145Z" }, + { url = "https://files.pythonhosted.org/packages/64/d8/b989f96061a5e32d82fddd1b1b9ff48a7c8f8ae7606f0e80fd9de54b1e33/coverage-7.15.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d29ca7bd67af6e12e74632d65f026eabc1364da5c254494cd914446a28a3ef7", size = 258936, upload-time = "2026-07-15T18:54:55.015Z" }, + { url = "https://files.pythonhosted.org/packages/b8/fa/f99771f5110457c7b511c1935ca49ddf288218eaa84322e028b9334146ae/coverage-7.15.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:db9c8438057e5b0f6a22a0af99c0c1d26b57fbbdbd1be5861ddb8f897fcc3a2d", size = 253178, upload-time = "2026-07-15T18:54:57.527Z" }, + { url = "https://files.pythonhosted.org/packages/f6/96/c098a6044d119c751ceede7be91035fa8310170ec24a6523aff72f0a5793/coverage-7.15.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:63022c4c8dec1d0342f05c3ede99842fe3d007689acc45e86f123a1746e4a026", size = 254934, upload-time = "2026-07-15T18:54:59.41Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a2/1457b3a7a50c8d77500103b97a046db863e2f59a1cf6d2f814595f349885/coverage-7.15.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6c0be82b4d4aa5b2704e08518e2252f3e3d110164bcca826816801052e48a7aa", size = 252898, upload-time = "2026-07-15T18:55:01.338Z" }, + { url = "https://files.pythonhosted.org/packages/6c/0e/76958874c471ecfcdde0d2b2747bb2c61bdbf34a40636f4ce9db9923e643/coverage-7.15.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4510fb9cdf6bb02dfa6af0be4a534b8102d086e22e4a33f8836df663da3d660d", size = 257056, upload-time = "2026-07-15T18:55:03.243Z" }, + { url = "https://files.pythonhosted.org/packages/7c/7c/3d7c4e3bf58baa40327dc7edc2272b17cf02299366d52763db1b0ca1556a/coverage-7.15.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:42ec3d989421b174a2ab607c1539f24127ad362757b7f1c0c0d7a2993f7eb37b", size = 252718, upload-time = "2026-07-15T18:55:05.029Z" }, + { url = "https://files.pythonhosted.org/packages/c8/b8/1cecffed9ce14fb25be9ba42d37b6bb61485c9a3ddd43cd3dde36b6087d8/coverage-7.15.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e8f91bce78e32343af184c3b7fa28fcf5a9e2641f4b6623d392038f804939188", size = 254490, upload-time = "2026-07-15T18:55:06.889Z" }, + { url = "https://files.pythonhosted.org/packages/6c/2c/42984561bc7f4c045dca67516a0c50ee5ef8d84352dbeb5559dc86c4823e/coverage-7.15.2-cp313-cp313-win32.whl", hash = "sha256:434e68d531858205895eb0d74b73d20b84260de426387d53c422a5acda2cf050", size = 223647, upload-time = "2026-07-15T18:55:08.941Z" }, + { url = "https://files.pythonhosted.org/packages/41/9f/39c7c9245efc583beddf89a87683574e663ed93637f3afb6cd7b88405676/coverage-7.15.2-cp313-cp313-win_amd64.whl", hash = "sha256:26c3b04a6377fd7c09800921fa934e3a17c0020439cd59df73e73ae1d4b6a78c", size = 224190, upload-time = "2026-07-15T18:55:10.789Z" }, + { url = "https://files.pythonhosted.org/packages/c7/de/3a2883cf8a213659280ef4b403059e17a9acaeb7fc7fd4105e1226ff2e6d/coverage-7.15.2-cp313-cp313-win_arm64.whl", hash = "sha256:3ed010aa1b69cda8e827aabfca9866216c980e2dca82ab9a78c5f83689964c8b", size = 223583, upload-time = "2026-07-15T18:55:12.678Z" }, + { url = "https://files.pythonhosted.org/packages/81/5f/aed265fd7a3551a394f36dfe41868aee709b7f95db4052205b4ad1563ac3/coverage-7.15.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:40f633c5c5fc783732f6312280122e859538fa24461235597c13d803ea9a108a", size = 221650, upload-time = "2026-07-15T18:55:14.527Z" }, + { url = "https://files.pythonhosted.org/packages/6b/2c/222ba12a545189017120f8eddfc1a0bd4616b47d5d4a8d99421edb2fe4c6/coverage-7.15.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:075560438765b7a2ef43bf7aa7758661b53d889df47f062a31bda6c1ade553a2", size = 221988, upload-time = "2026-07-15T18:55:16.674Z" }, + { url = "https://files.pythonhosted.org/packages/aa/38/304b5877ab46e6c290b4292cfcf3fe28245f0e5597cad7f6acc91fc7e0a4/coverage-7.15.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:25fd15dd40a0a2c51a500d664ca29053c09c3259d998407bf982b6e114696138", size = 253029, upload-time = "2026-07-15T18:55:18.856Z" }, + { url = "https://files.pythonhosted.org/packages/6c/58/821b533b8db9e44cf1d8a97bd525149ced40dde1d0093da02cb78e715244/coverage-7.15.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b9a6367e4aff723e8ee8190836836124284e8fcd4265e307c844010cfa074f3f", size = 255536, upload-time = "2026-07-15T18:55:21.027Z" }, + { url = "https://files.pythonhosted.org/packages/f1/f2/7aa06604c389d32ea7f0a6a988359a7eafc3cd3f8e7bc2e88cd2fdf0b877/coverage-7.15.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9854ca62c152874b2060772503535be2e8f53f70b8aaa7686b094888d872f984", size = 256881, upload-time = "2026-07-15T18:55:23.125Z" }, + { url = "https://files.pythonhosted.org/packages/a2/4f/1ef342339c7916d0096bc5888cc0f653882cc7bc8f897d5cb89143287c9b/coverage-7.15.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:913b6c56e110da40e035bbd168353bf7aaa2544a5eaccea5d98a4629aac156c7", size = 259196, upload-time = "2026-07-15T18:55:25.099Z" }, + { url = "https://files.pythonhosted.org/packages/fe/f4/7ed055d7a9c5ec13b161773a115a5ccc6b0081d568c31fad830806306cc7/coverage-7.15.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aaccad4129d735a8a4d526f26929894c9a4e8ef7034566f210b176749d6906e3", size = 253036, upload-time = "2026-07-15T18:55:27.018Z" }, + { url = "https://files.pythonhosted.org/packages/14/79/ea82cca18c242a3a38b6c017da39726aa62dcb64aa635abf79b92009975c/coverage-7.15.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a164b50081fc7357331c4024ef4d17b78ba325f8380d05f5a69599a7e05257ee", size = 254887, upload-time = "2026-07-15T18:55:29.084Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ba/a136db3c0d9562b00e10b72540dbf3a33cd3bc5b95060c9308e247494623/coverage-7.15.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:bfd341ccf78128e72c094bc70cc25b3ef309c33c7c2c66ba3ed4309549e02de1", size = 252852, upload-time = "2026-07-15T18:55:31.184Z" }, + { url = "https://files.pythonhosted.org/packages/17/17/ea334246b16b7d059953fad6fdefa11e33c68efbd3fe37b1098120a1fac2/coverage-7.15.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1473b3ba8e7ee0f076117b1a72c23f579a2b9e2bb742f48a8d86ea27ca93f91a", size = 257128, upload-time = "2026-07-15T18:55:33.163Z" }, + { url = "https://files.pythonhosted.org/packages/ed/c3/074fb66d46d607855f710876b117cbda562c5ab08363528e78820449f937/coverage-7.15.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:17c432b5f73ad52ef46fb06019f6fa7c66ce381961cf0f7dfd1d3a4bd3a98145", size = 252668, upload-time = "2026-07-15T18:55:35.063Z" }, + { url = "https://files.pythonhosted.org/packages/e1/c1/f620850ada9b36435921c9a3a8057013422b1d964eb4bf37fe138724d192/coverage-7.15.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:77f0ef5011df53a4bd1b35211ab122287f8d9b8d7aa1c4553e5c2deb24b1d446", size = 254325, upload-time = "2026-07-15T18:55:37.125Z" }, + { url = "https://files.pythonhosted.org/packages/cc/31/a729ca3689404493af82ef8e6ff70bd88bdda8da89aeef6ca9b387aeb2b4/coverage-7.15.2-cp314-cp314-win32.whl", hash = "sha256:f653e5d7248c1191ec988a85c72edeab46c3ff44f90639a4ed4874ec0be90243", size = 223844, upload-time = "2026-07-15T18:55:39.078Z" }, + { url = "https://files.pythonhosted.org/packages/c6/83/5d809dc808fb1698c671f3e372259bb9158e64b7ea526fc6ab7de64de9fe/coverage-7.15.2-cp314-cp314-win_amd64.whl", hash = "sha256:9911f31aad8906abe337c271343485cf20df5e70df5d2f57f9f136e7b55f26bc", size = 224331, upload-time = "2026-07-15T18:55:41.346Z" }, + { url = "https://files.pythonhosted.org/packages/16/4e/35e488548e952795829e129995c4174df33bf432b591d1aa42c8d9e4e7ad/coverage-7.15.2-cp314-cp314-win_arm64.whl", hash = "sha256:e38def96ad59853824c97953fdcd2c320a84ba3ce99b417db78af8bb6c3db635", size = 223760, upload-time = "2026-07-15T18:55:43.518Z" }, + { url = "https://files.pythonhosted.org/packages/ed/49/dd2c86cd6374038f6e415fb5bfb86db5218553209c081384a020369dee79/coverage-7.15.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:835ec4e20b45f0a7f63ed78f94065aca00de033403df8377bfe8b9c6abc0a7be", size = 222384, upload-time = "2026-07-15T18:55:45.569Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/173ff17a1c0808e5a438f549f6f145d5ac7528f2791310b63523e3200ac7/coverage-7.15.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7466cc7ab6dc0db871d264bf99e8779f0917ee63d40730af0552f71535a6e072", size = 222647, upload-time = "2026-07-15T18:55:47.544Z" }, + { url = "https://files.pythonhosted.org/packages/84/f8/b8cba872162356fb44ac79c10309d987206a4461e32072fc29228dad7331/coverage-7.15.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e370c12133095ff18432de8c044962be85a5a96d90c6fcbce8e17e76236d2328", size = 264013, upload-time = "2026-07-15T18:55:49.768Z" }, + { url = "https://files.pythonhosted.org/packages/ee/67/a807a7586d0b8cae485308ddd55756f0806c92f8e0b411bacbf23c48edf3/coverage-7.15.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fe41909c9515c3bfdb5f02c4d1f857dba322d9a9a1178069b91eea77889df63a", size = 266135, upload-time = "2026-07-15T18:55:51.941Z" }, + { url = "https://files.pythonhosted.org/packages/ce/67/cd78771dc985f7e4ebdcc82b1a96d9a932af9e806f01f2f91a89f4c72e80/coverage-7.15.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6aa28cfb6488e5453b5b762d65f73aa586380f6693a04d58078ce228a29b06c0", size = 268555, upload-time = "2026-07-15T18:55:54.065Z" }, + { url = "https://files.pythonhosted.org/packages/18/3e/10134cf81275188c58568f324fc74aedff32c63ca4d5bbc513a91944a6f0/coverage-7.15.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bcc0aae933921d03096f53b0b03eeb702129fd406dee59f08d2efacc68681fa5", size = 269674, upload-time = "2026-07-15T18:55:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/75/4a/771b77de446cba985dc414bbc5844bd21604da05dbc044286df8318a48a7/coverage-7.15.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7c63387e21ab21f512c69c9756a8c7dadd322c7275edb064064433c9a09c3743", size = 263101, upload-time = "2026-07-15T18:55:58.107Z" }, + { url = "https://files.pythonhosted.org/packages/5f/b5/70a7011da15f4071943361183aefa27847f3e3aec4fd335f1cb3d3a622b1/coverage-7.15.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e55510bc98ae943cece9e667a6c0fe94c6a92913720dea34243657a17993d0c", size = 266007, upload-time = "2026-07-15T18:56:00.468Z" }, + { url = "https://files.pythonhosted.org/packages/b4/0d/f9547e804ce7ad49646ffeffac26699510efbe6c0f751b66fdc960c4e825/coverage-7.15.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:2ff08701be2d1556fc78b326c80a3e8042da09352ecb3819105f8e386c8a3071", size = 263611, upload-time = "2026-07-15T18:56:02.615Z" }, + { url = "https://files.pythonhosted.org/packages/ac/59/f576a396659c0efd351f5c1544f67c3560e89c7761cabf7f65e412beeda5/coverage-7.15.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:38c9518b7103826c403a461544e3c2e77151e8676d06eaed85911a97e962584a", size = 267344, upload-time = "2026-07-15T18:56:04.622Z" }, + { url = "https://files.pythonhosted.org/packages/7c/5d/c2e4fce3579c0cb635024293f1a32bbe26df101b3e3a69f22243d1352b6c/coverage-7.15.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:dee88b1ed88587abd8c0269a1fc1f4cc77f7750d1dfde2869e2a123af420e67d", size = 262456, upload-time = "2026-07-15T18:56:06.641Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/956287d69436b66094bc4b57ac2da71e43bfd2a5524e958900b9f582fcf8/coverage-7.15.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fbeeeecea279727f8ac16c8e1133ddfeee793e985c86ae343d6a5ce744eef8c", size = 264771, upload-time = "2026-07-15T18:56:08.795Z" }, + { url = "https://files.pythonhosted.org/packages/2c/5a/6f979530c2734c575de77cf58f5f28d51f7123a94b5030fd9156fe5f363c/coverage-7.15.2-cp314-cp314t-win32.whl", hash = "sha256:cb0fddaa6884be6aae36ced9544b5e90f7d5f03845a2853bf47a14953a4e8688", size = 224151, upload-time = "2026-07-15T18:56:10.856Z" }, + { url = "https://files.pythonhosted.org/packages/54/7e/27f6b2a74d484742f4017553e710b01e396b23d809df3e95ca0bb9a2824b/coverage-7.15.2-cp314-cp314t-win_amd64.whl", hash = "sha256:77f091ea3a9cc611cd29f433565476bc1936c084ac8eee00ea0e7e70c27e4199", size = 224981, upload-time = "2026-07-15T18:56:12.928Z" }, + { url = "https://files.pythonhosted.org/packages/b1/48/284863423aa474240f6842bd00d680da22f4e6ea2e466618ef7c9c9e69a9/coverage-7.15.2-cp314-cp314t-win_arm64.whl", hash = "sha256:6fc448c377d6eeb00a47c673494bd9bae29280ca53987e1869e67ebedfe20658", size = 224294, upload-time = "2026-07-15T18:56:15.156Z" }, + { url = "https://files.pythonhosted.org/packages/ec/82/32e3bd191d498e64f6f911ad55d14006a0861e54869d2d32452326399e65/coverage-7.15.2-py3-none-any.whl", hash = "sha256:eb6bcae8d1a9d305351ecb108232441d11c5cfe9de840a04388ba5d2db8d735c", size = 213375, upload-time = "2026-07-15T18:56:17.305Z" }, +] + +[[package]] +name = "croniter" +version = "6.2.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/37/57/2e2a65aee2a70483cb28e2b7e15a072d00a523207593b44400d4717bb100/croniter-6.2.4.tar.gz", hash = "sha256:fc124f751b1b04805c2a04b061898b436b45ab2320b045e1e052ea895de65189", size = 166267, upload-time = "2026-07-10T09:52:59.955Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cd/ba/d678e5bd329646ca51d3c92addbc77804e86d21f4b6b6a027218e6abb010/croniter-6.2.4-py3-none-any.whl", hash = "sha256:8ef3d544107a5c05a150a2d78f8bf5a8eb9c5c4d93405a736b824109574e3f4d", size = 46677, upload-time = "2026-07-10T09:52:58.425Z" }, +] + +[[package]] +name = "cryptography" +version = "49.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" }, + { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" }, + { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" }, + { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" }, + { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" }, + { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" }, + { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" }, + { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" }, + { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" }, + { url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" }, + { url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892, upload-time = "2026-06-12T20:02:10.735Z" }, + { url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" }, + { url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" }, + { url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" }, + { url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" }, + { url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" }, + { url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" }, + { url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" }, + { url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153, upload-time = "2026-06-12T20:01:39.059Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" }, + { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" }, + { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" }, + { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" }, + { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" }, + { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" }, + { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" }, + { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" }, + { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" }, +] + +[[package]] +name = "distlib" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/02/bd72be9134d25ed783ecbbc38a539ffaefbf90c78418c7fb7229600dbac7/distlib-0.4.3.tar.gz", hash = "sha256:f152097224a0ae24be5a0f6bae1b9359af82133bce63f98a95f86cae1aede9ed", size = 615141, upload-time = "2026-06-12T08:04:52.847Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl", hash = "sha256:4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b", size = 470628, upload-time = "2026-06-12T08:04:50.506Z" }, +] + +[[package]] +name = "docker" +version = "7.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "requests" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/88/7f/731ff914b0255d3d065f45fd4e626d4b8c95dbcbaada049f337a6ac16410/docker-7.2.0.tar.gz", hash = "sha256:cebb93773d334f778e023a7ee352a8d6e13ab1bd3b863a4d4a59dec897df43ac", size = 118731, upload-time = "2026-07-09T14:53:46.39Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/23/529140fe1aab80fc6992f93a706deec709140a6397439139a054e1515c45/docker-7.2.0-py3-none-any.whl", hash = "sha256:a3f45fdeb9165e2d25d9a1d02ddf3bc70fb572cf5ebbf9b58558c22caf29b71f", size = 148775, upload-time = "2026-07-09T14:53:45.224Z" }, +] + +[[package]] +name = "fastapi" +version = "0.139.2" +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/cd/95/d3f0ae10836324a2eab98a52b61210ac609f08200bf4bb0dc8132d32f78a/fastapi-0.139.2.tar.gz", hash = "sha256:333145a6891e9b5b3cfceb69baf817e8240cde4d4588ae5a10bf56ffacb6255e", size = 423428, upload-time = "2026-07-16T15:06:17.912Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/c7/cb03251d9dfb177246a9809a76f189d21df32dbd4a845951881d11323b7f/fastapi-0.139.2-py3-none-any.whl", hash = "sha256:b9ad015a835173d59865e2f5d8296fbc2b317bf56a2ba1a5bfbdd03de2fd4b1c", size = 130234, upload-time = "2026-07-16T15:06:19.557Z" }, +] + +[[package]] +name = "filelock" +version = "3.30.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/f7/2165ef325da22d854b8f81ca4799395f2eb6afa55cdb52c7710f028b5336/filelock-3.30.2.tar.gz", hash = "sha256:1ea7c857465c897a4a6e64c1aace28ff6b83f5bc66c1c06ea148efa65bc2ec5d", size = 176823, upload-time = "2026-07-16T19:50:42.724Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/df/05118016cad66cd0d7c9417b2d4fc245be35decc4c36810f3c8dbf729d88/filelock-3.30.2-py3-none-any.whl", hash = "sha256:a64b58f75048ec39589983e97f5117163f822261dcb6ba843e098f05aac9663f", size = 94092, upload-time = "2026-07-16T19:50:41.189Z" }, +] + +[[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 = "hypothesis" +version = "6.156.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/20/83/8dbe89bdb8c6f25a7a52e7898af6d82fe35dfef08e5c702f6e33231ce6c6/hypothesis-6.156.6.tar.gz", hash = "sha256:96de02faefa3ce079873541da96f42595583bb001e8e4219294ed7d4501cc4cc", size = 476304, upload-time = "2026-07-10T20:56:49.96Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/dc/0c2a851f06c91d5ac9ef0f3b9615efc1ed650411d2eee23b6334f491c85e/hypothesis-6.156.6-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:caf6a93d011c10972da111c38ceb34ced20feaa8581e2b350c0655b022e27875", size = 747998, upload-time = "2026-07-10T20:56:16.311Z" }, + { url = "https://files.pythonhosted.org/packages/8e/f8/59203ca978ab51595d12d6bc7e7a63300d7373431ab42ca3f1742e45db68/hypothesis-6.156.6-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:07f2bc9df1aeba80e12029c1618e2ee54abc440068c305d7075ffd6b85251843", size = 743073, upload-time = "2026-07-10T20:55:36.825Z" }, + { url = "https://files.pythonhosted.org/packages/68/d8/86a0023740434098d1b187a62bd5f99b198f098fb43e7fc58342283a8270/hypothesis-6.156.6-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7baca17f4803ad4aa151732326f3990baf54c3127df44aa872ac5bdf8a98a9a6", size = 1070169, upload-time = "2026-07-10T20:55:49.47Z" }, + { url = "https://files.pythonhosted.org/packages/9b/82/673453915fd0c67673f35a4876ba88f48c621335f293f3537d77b27d4286/hypothesis-6.156.6-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8083806645f84243aade727f4978185caaa0b7190af4318673999ee15fdbf424", size = 1121760, upload-time = "2026-07-10T20:55:53.502Z" }, + { url = "https://files.pythonhosted.org/packages/8a/c3/3a5557f52912f2fecc6ed59642dcf80dd8e89d0d9664502b68e23d66bf3d/hypothesis-6.156.6-cp310-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a922eedcd8618f9c2e17b79fa7b3f3f0b2df34e201958611cc3f0f46cca33c10", size = 1111440, upload-time = "2026-07-10T20:55:43.054Z" }, + { url = "https://files.pythonhosted.org/packages/38/a6/ae636d4ca7f996a1ccb4b3d5997d949f1718fba52b01559b3ab53b237b3f/hypothesis-6.156.6-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:5291bd33c4704d274d7c214d5c200e77f372a06644f5cbbe96dcbe53cb2fbf10", size = 1244944, upload-time = "2026-07-10T20:55:56.109Z" }, + { url = "https://files.pythonhosted.org/packages/1e/79/c425d22d734be0268ca60d120c6296299e4220a1783cb1a4cc76232807bb/hypothesis-6.156.6-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:55f3ec50161b4a95bae63bff2b5166e45935b493013d3be30ede279bf6192318", size = 1288808, upload-time = "2026-07-10T20:56:06.249Z" }, + { url = "https://files.pythonhosted.org/packages/c5/3a/cc9f479d22cbdd36ddfc55a978378eddadd183b09339ebdb81be33bb18e7/hypothesis-6.156.6-cp310-abi3-win32.whl", hash = "sha256:e96570ca5cdd9a5f2ff9e80a6fb2fd5420ebf33b833d7de5b09b6ebb26a3eb6c", size = 634868, upload-time = "2026-07-10T20:55:37.959Z" }, + { url = "https://files.pythonhosted.org/packages/d6/89/2008d287289841a936456cb13443ca89d88da6e4527d611d482e9544164d/hypothesis-6.156.6-cp310-abi3-win_amd64.whl", hash = "sha256:32710718c22fe8c5571464e898bb87d282837b02617d6ad68130abf7cb4843cb", size = 640382, upload-time = "2026-07-10T20:55:30.634Z" }, + { url = "https://files.pythonhosted.org/packages/8c/45/9f009005b9c796f4a40424484ac7e70847bc088456fd940a937f96bb4b6d/hypothesis-6.156.6-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a2a728b514fceb81e3f0464508911d5220fd74dadc3270f859427a686b60c4cf", size = 748844, upload-time = "2026-07-10T20:56:38.036Z" }, + { url = "https://files.pythonhosted.org/packages/02/2f/4d852bb8a9c73a68b18eca9b5b085285282122166e158f4d2a477639bfee/hypothesis-6.156.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7489b9a8f9df8227edd6c7cd8b9ccfab2483bab24da6a474c175973ca2294f58", size = 741936, upload-time = "2026-07-10T20:55:27.539Z" }, + { url = "https://files.pythonhosted.org/packages/74/89/b9968070ae042f9bf3149bb6ba6399d5f28f452e0fb7f638cafc69ff0b9a/hypothesis-6.156.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42760873d6db1069d6edbaa355a61b9078a9950259efcfc72fc695741d7db7cd", size = 1069749, upload-time = "2026-07-10T20:56:43.017Z" }, + { url = "https://files.pythonhosted.org/packages/00/a9/753806f5292b40aeab1d269e408e3a7e85be3c0d88828fb78ab4a34d6626/hypothesis-6.156.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b4e66aaa7385538a5d617174d47c198ee807f06de99e282a67c6cb724c69340d", size = 1120983, upload-time = "2026-07-10T20:56:25.424Z" }, + { url = "https://files.pythonhosted.org/packages/85/88/8386d064d680be27e936eba94f1448bc93ef6fa05473ee5034139f1c4284/hypothesis-6.156.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:08796b674c0b31a5dd4119b2173823390055921588d13eb77324e861b00fd7f8", size = 1243911, upload-time = "2026-07-10T20:55:54.799Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8c/7524c1e5279e7728eb47c99f2357cbc5f08ae92e9bce49bf50118b53f9c9/hypothesis-6.156.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4ca8cc26ea2d31d22cf7710e92951cfaa921f0f8aa1b6db33a5176335f583a4f", size = 1287806, upload-time = "2026-07-10T20:56:02.176Z" }, + { url = "https://files.pythonhosted.org/packages/5a/b3/c347ad913e1c5f2988956fe17826c0400b4ce470b973e6c248e97b6a0acf/hypothesis-6.156.6-cp312-cp312-win_amd64.whl", hash = "sha256:c3363d3fb8015594636689572510bb6090602d8e8e838a5693c2d52d3b5b09d8", size = 637679, upload-time = "2026-07-10T20:55:39.056Z" }, + { url = "https://files.pythonhosted.org/packages/70/5d/9583fe153573523dac27226c89e041a86ad4aeeae08c868160cbb93d39d2/hypothesis-6.156.6-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:59a8def90d9a5a9b67e1ac529e903a2363ceb6cf873c209da6b4284c5daab671", size = 749264, upload-time = "2026-07-10T20:56:46.118Z" }, + { url = "https://files.pythonhosted.org/packages/86/35/e4113d06769b544f0fb77ffea9195b598b4c56a298905c21fd47c4eed388/hypothesis-6.156.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c574c3224563d730848bc5d1ef1683c4f83993400c0167899fe328f4bfcd4725", size = 742095, upload-time = "2026-07-10T20:56:41.412Z" }, + { url = "https://files.pythonhosted.org/packages/d8/5c/a47666ede10384e8978722cade7ab96a42df71d2ab577317092d0fed7c8a/hypothesis-6.156.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:01bb8270c46b3ef53b0c2d23ff613ea506d609d06f936d823ea57c58b66b05f7", size = 1069917, upload-time = "2026-07-10T20:56:04.845Z" }, + { url = "https://files.pythonhosted.org/packages/79/93/75f6057dadd9dc0134f37c08d5d14d04d3cd7374debbcb0cc4569c6712f1/hypothesis-6.156.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d4ea6559c13606e13b645927f2e0906e52b5ac5d99b40d3abaaeb2e8c7ceeb75", size = 1121204, upload-time = "2026-07-10T20:55:52.008Z" }, + { url = "https://files.pythonhosted.org/packages/62/87/308efef08bc60d1e673d035e8ca8e9663f4b6b3ba519c3cdebf6583c2b76/hypothesis-6.156.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2d47054d0230f0dd9b6868fc030126c7a6c25527144272ff376cc4e9c39f7540", size = 1244168, upload-time = "2026-07-10T20:55:40.288Z" }, + { url = "https://files.pythonhosted.org/packages/3b/66/de8fff5bd9a40a4056dafbe7f904887ef12632282bbbac90f1977c30dd3b/hypothesis-6.156.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:050c8c0815f88d47dd0875a92698d20d61639b7b721ee043a6d687c7f14ff7d8", size = 1288127, upload-time = "2026-07-10T20:56:00.541Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8d/794fb26e1fd3ff004978f8f18b7aa7e1c2270ba72e1f977b987a812064f8/hypothesis-6.156.6-cp313-cp313-win_amd64.whl", hash = "sha256:f0d73edab7b8a0051b3634f2d04d62b7e7282f8f274963b11188ee4957d672ef", size = 637954, upload-time = "2026-07-10T20:56:33.35Z" }, + { url = "https://files.pythonhosted.org/packages/a5/be/5b4b27984cb43c60e95f570b069660335dad34cb67f7d226017c5d35d31e/hypothesis-6.156.6-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:34a70a7b8226e34d658072d8fb81d03f97f0a75ceb536329a321b94ce2232fd6", size = 749312, upload-time = "2026-07-10T20:55:46.902Z" }, + { url = "https://files.pythonhosted.org/packages/31/11/709cceffc28666c9d4cb75ffc6df5ce30db8c7dd5cc2c8b38a2fd837427f/hypothesis-6.156.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f1969646beead7d8cf6a2537d2765af89d73056e2cb218e7fae92b83802250a3", size = 742332, upload-time = "2026-07-10T20:56:30.254Z" }, + { url = "https://files.pythonhosted.org/packages/84/52/cfc79b13d8dd3cd6de6b9df921c557efe8528a9c90a3a7cd93b37188d57e/hypothesis-6.156.6-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cbc2ec7b7d905e6b6ec1635f6340bfa52aaab718101c59f052bc012a6b486cd8", size = 1070109, upload-time = "2026-07-10T20:55:48.244Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ac/1da4def1f006b5ad01187ff96379e24c37439d659ec10c3e944c03436c0f/hypothesis-6.156.6-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9367ae25dfa6dc1af37904785e43c4f8fe1c4118cafdc2f06514154fbdd90992", size = 1121528, upload-time = "2026-07-10T20:56:39.665Z" }, + { url = "https://files.pythonhosted.org/packages/68/47/744e4f5e3d635dea20dbedf3fa486e2a6fa5210e0a52a0d5c4da56babd84/hypothesis-6.156.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:455f09107ec07c78f2a83cb8fc19e23879c9d51cdc831de6f9cb6ec4059cb9af", size = 1244690, upload-time = "2026-07-10T20:56:31.854Z" }, + { url = "https://files.pythonhosted.org/packages/25/8a/42252fcd5e521d140dac532f29c2a13ca8f22908cb545ffdd64b5e225680/hypothesis-6.156.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c76634c45a3ceee4c4fdfed39aebd08b8b822ec8b0c556877ef82846fd777730", size = 1288519, upload-time = "2026-07-10T20:56:03.429Z" }, + { url = "https://files.pythonhosted.org/packages/44/e7/176df9e47cf583d2b8d234b78c0aac3a47075ad5d147e60b2c21a1338bb1/hypothesis-6.156.6-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:eb7e9f8343bc6b948937e6ec12e6879ed25a17b53ceccbd2b84adadd3d511698", size = 586452, upload-time = "2026-07-10T20:56:22.285Z" }, + { url = "https://files.pythonhosted.org/packages/8c/75/2c8a0411bbe76429f3ae738ef9a00107201bf6146d9534350014ce369d98/hypothesis-6.156.6-cp314-cp314-win_amd64.whl", hash = "sha256:f9631cd604ae6032c3edf99160dc1b9e33873f2e52762246b24f07fb758652ae", size = 637774, upload-time = "2026-07-10T20:56:34.97Z" }, + { url = "https://files.pythonhosted.org/packages/2a/22/8115005e9aa72c8d63d90e9db5e0b8425fd8950fbc5d6e332805d4d32c9e/hypothesis-6.156.6-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:1f81163d36d3763b09ffaef7c3a71e88174ca3e6816201fca9d1d159f448fdb5", size = 747428, upload-time = "2026-07-10T20:56:44.611Z" }, + { url = "https://files.pythonhosted.org/packages/e8/c2/66bfe9337f4a4b1f7754ee6d01d950280152a81d0d797e6c1d9eb0909750/hypothesis-6.156.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:556b905767e36147918634a64356aa05d8c956576f00aee01eb351678f193908", size = 740889, upload-time = "2026-07-10T20:55:57.656Z" }, + { url = "https://files.pythonhosted.org/packages/95/3b/69f45af2d4f0950b7d1af3cdbdd800b88a6c2370331481eda79d6171fbe3/hypothesis-6.156.6-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c3f2604b28d16d696aaaf4954d20f907b27e54034df98e64746a20c74c319f03", size = 1069270, upload-time = "2026-07-10T20:56:12.024Z" }, + { url = "https://files.pythonhosted.org/packages/f8/43/6b2549885da08f5e50ba34fb8e0d0a60b2f190ffd516fac220f8db5b5869/hypothesis-6.156.6-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ffe012ad66dbe7b8e8ddef6f6992ab1b36719ea64430c2bf1ff7135521052a15", size = 1120409, upload-time = "2026-07-10T20:55:34.551Z" }, + { url = "https://files.pythonhosted.org/packages/70/97/745c778c3eb29befa2367b1ded8437eecfbbe6932359d0f831275bc32170/hypothesis-6.156.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5bfa3c7b758f7278081c6bfec5f89b43c4eb075c0c9eb095323f7a9eb019b513", size = 1243111, upload-time = "2026-07-10T20:56:17.83Z" }, + { url = "https://files.pythonhosted.org/packages/ab/d7/c5ec6a442dc9b822f47064bda4b6d3e739dccdd1c5bf44c9a57fb6136830/hypothesis-6.156.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d0db1f4573800c618773622f03cb6533bb3377430ef938c9476ba10c39d22591", size = 1287262, upload-time = "2026-07-10T20:56:23.749Z" }, + { url = "https://files.pythonhosted.org/packages/11/0c/c134d61710e14b68b010215dcf6bd57d2ec05cd169dff8bfab8fefc2d410/hypothesis-6.156.6-cp314-cp314t-win_amd64.whl", hash = "sha256:38cd0c4a7b9f809f1e23a4d15adfa9c5d99869b9afc327350a5e563350b78e48", size = 637862, upload-time = "2026-07-10T20:56:13.347Z" }, +] + +[[package]] +name = "identify" +version = "2.6.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/63/51723b5f116cc04b061cb6f5a561790abf249d25931d515cd375e063e0f4/identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842", size = 99567, upload-time = "2026-04-17T18:39:50.265Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" }, +] + +[[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 = "librt" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/2f/3908645ddddab7120b46295e541ead308109fa48dbec7d67d7a778870d60/librt-0.13.0.tar.gz", hash = "sha256:1d2a610c14ac0d0750ee0a3ab8548e83155258387891caaca04def4bf7289781", size = 211402, upload-time = "2026-07-08T12:26:29.834Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/f4/b2933ddae222dac338476abb872641169a5cfed2c2bb5444a5b07b32b0c3/librt-0.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:30536798f4504c0fad0885b1d371b0539abb081e4570c9d7c641cb51141b49f0", size = 150990, upload-time = "2026-07-08T12:25:02.42Z" }, + { url = "https://files.pythonhosted.org/packages/90/ef/db98f744ca50e6efc9c95c70ee49b77aefac31f6a3fc7c83754a42d6a74f/librt-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:93d24ebb82aa4420b1409c389e7857bc35bd0b668007ac8172427d5c73cc8cc5", size = 155238, upload-time = "2026-07-08T12:25:03.681Z" }, + { url = "https://files.pythonhosted.org/packages/03/e7/a197e7bc72baf2c61ce7fdc6906a5054dc05bd8da0819aa894e4857bf87e/librt-0.13.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb8a1adce42d8b75485a5d56a9623a50bcab995b6079f1dac59fc44034dd93d9", size = 503073, upload-time = "2026-07-08T12:25:05.049Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e7/7887712e27da7c1ab80fcabb1de6eb24243964f6557cae530d4b70706dbd/librt-0.13.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0763ca2ab66058174f9dee426dc64f5e0a89c24a7df8d3fe3f1836c04e25de4b", size = 496528, upload-time = "2026-07-08T12:25:06.26Z" }, + { url = "https://files.pythonhosted.org/packages/94/f0/f2283385bb6b950b26a1410f4ce51ec27231e0b3a4b925c46366d218b198/librt-0.13.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b222493da6e7b6199db9bd79502436cf5a27da3c1f7fa83c7e285444fc93fd03", size = 531786, upload-time = "2026-07-08T12:25:07.658Z" }, + { url = "https://files.pythonhosted.org/packages/36/11/69ac3b54766ffba5fd7e5acebfb048d66dbe1f9f2d14516c2b3edc59cf87/librt-0.13.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fadc63331f4388c3dc90090448f682a7e9feafc11481391c1e94f2f907a3976e", size = 524393, upload-time = "2026-07-08T12:25:09.121Z" }, + { url = "https://files.pythonhosted.org/packages/61/5f/d72f95fd444a926a3c14b4e24979474116988dd57a45be242077c45d3c22/librt-0.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:70d9c62a4cffd9f23396cd5ef93fc5d11b31596b9b7d6306074abe3d5fcf09bd", size = 543026, upload-time = "2026-07-08T12:25:10.459Z" }, + { url = "https://files.pythonhosted.org/packages/c4/08/dcd9993ad192737a004ba263d549f8ea605b326b952e7d6205c7d4170b76/librt-0.13.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:66c0e7e6b02a155576df2c77ec933a70b72da726e248c494abf690923e624348", size = 546829, upload-time = "2026-07-08T12:25:11.716Z" }, + { url = "https://files.pythonhosted.org/packages/96/d5/6d9bb2f54e4109a956b7128836529653eb9d740f784bc47ed10a02c1000e/librt-0.13.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ac04bcd3328eb91d99dfedf6a60d9c1f15d3434e6f6daf922f0420f7d90b85c7", size = 535700, upload-time = "2026-07-08T12:25:13.144Z" }, + { url = "https://files.pythonhosted.org/packages/8c/f2/10946922503858a359492fa27f13e86228bde702116a740ac7b3cd185f24/librt-0.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:db327e7271e653c32040b85ae6188059c924b57d7e1e29f935523fa017cd4e82", size = 573566, upload-time = "2026-07-08T12:25:14.336Z" }, + { url = "https://files.pythonhosted.org/packages/48/a8/94f00e3c99479a18088af3685ea016c42f3c7d5d1964d8dbb40c08d7f1aa/librt-0.13.0-cp312-cp312-win32.whl", hash = "sha256:860bd1d8ba48456ce08feaf8d343a8aaeb2fa086f2bcaa2a923fa3f7a3ff9aa3", size = 106099, upload-time = "2026-07-08T12:25:16.159Z" }, + { url = "https://files.pythonhosted.org/packages/c9/7b/2da9c74c1ed25a89cc4e1c8e007ea2eb4a0f1fafa3e70d757fe3242c5c5c/librt-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:e54a315caf843c8d77e388cadc56ea9ded569935ee2d2347d7ea94992e5aa6fa", size = 126934, upload-time = "2026-07-08T12:25:17.275Z" }, + { url = "https://files.pythonhosted.org/packages/d0/65/aead61bbf3b5358593f9d4779d2a0e88eaf6ec191a6342dde36dd1df6371/librt-0.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:c718e99a0992127af84385378460db624103b559ab260435abcfe77a4e4ed1c1", size = 112236, upload-time = "2026-07-08T12:25:18.425Z" }, + { url = "https://files.pythonhosted.org/packages/67/3b/18e7b63255297a2bdc9c25c8d6d4ca8eca9f63aceb1252c0f7427ac7099e/librt-0.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a468951af16155824e88bdd8326ebe5bdb371f3ec0ac04642994b98201d914f3", size = 151027, upload-time = "2026-07-08T12:25:19.638Z" }, + { url = "https://files.pythonhosted.org/packages/4d/68/e2248452c00d1a03b45fee1752cdc8f790a476efd2402b75181da88a9e61/librt-0.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ae01d8512cc17079e53425635327dbf3f7ff57a42c00dec348bf79791c56444c", size = 155152, upload-time = "2026-07-08T12:25:20.851Z" }, + { url = "https://files.pythonhosted.org/packages/0e/16/52b1c99bf19057a062aac39c900cbb81499f6f75d6c537c14463d247ba78/librt-0.13.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32c26893cd085c1efe83219e78d866da23fb20a066101b8f68210004361d224c", size = 502499, upload-time = "2026-07-08T12:25:22.055Z" }, + { url = "https://files.pythonhosted.org/packages/9f/54/b811151805c795f55e0dedee6ec687b75f9982a8105d240ea3910737a77b/librt-0.13.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5929da1981a46bcf4b28b1b9499905f0ff58e2419da402a048234e9783acbc4b", size = 496108, upload-time = "2026-07-08T12:25:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/8f/f8/094d6b2bd93f3fdaa54db54cc788c4a365333bddad65ab02e04da0b1d004/librt-0.13.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:94b85d664d777bab6c0d709416cb42938251fda9e221b79e3a2215d85df5f4f9", size = 531576, upload-time = "2026-07-08T12:25:24.648Z" }, + { url = "https://files.pythonhosted.org/packages/2e/40/541733d5755824f968f7ec39d78ffbd75d145964157ae5e69a09ec6d7326/librt-0.13.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:531b2df3e9fe96b1fcf73a6d165921e4656be5f58d631d384ebce344298368db", size = 524390, upload-time = "2026-07-08T12:25:25.898Z" }, + { url = "https://files.pythonhosted.org/packages/c6/b5/255673cfdbf5ba663339d36cd863c897289ab4337577e19f9405ce059f36/librt-0.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:109b84a9edf69ad89dc1f66358659e14a031baca95e3e5b0060bd903ede8efd6", size = 543053, upload-time = "2026-07-08T12:25:27.436Z" }, + { url = "https://files.pythonhosted.org/packages/9e/11/ab5005e9c9850710f21e354201bf090646349d3fabf5f951eaf70235729e/librt-0.13.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1304368a3e7ffc3e9db986796cc5326fdb5943a3567ecc137cff318e4240c0e7", size = 546387, upload-time = "2026-07-08T12:25:28.65Z" }, + { url = "https://files.pythonhosted.org/packages/a2/04/a5d7ce1d1df1afd15ca283dcdf7530ac073e12d69ae8c40879dda96f7868/librt-0.13.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e4f9b472e7d308d94b62c801982065661158c6ed02790d6c7ddb4337cea0f9c1", size = 535970, upload-time = "2026-07-08T12:25:30.171Z" }, + { url = "https://files.pythonhosted.org/packages/5a/76/927e267a6daa290174ac281b23c9804c8829b042ade9c6f24a065f540958/librt-0.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9f836c37478f167a81200d8c8b2c920a22224564bed2c23d7aeec760965c367a", size = 573582, upload-time = "2026-07-08T12:25:31.507Z" }, + { url = "https://files.pythonhosted.org/packages/10/24/b6c5213efe39c19f9e13605644d0cf063b4ddaa33ac2e45b088e23a70e2e/librt-0.13.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:4000d961ff9598ac6ea603c6c836a5ed49bc205ade5fc378b998dfe1e2c36628", size = 82189, upload-time = "2026-07-08T12:25:32.675Z" }, + { url = "https://files.pythonhosted.org/packages/4c/00/d29736be177a906ac0b84a5b04b4fbfa22c776dc2f366de4172b0f968c08/librt-0.13.0-cp313-cp313-win32.whl", hash = "sha256:79e44cff71750d299d61a678e49995b0d5935a9cda238c2574daeca3ba536927", size = 106193, upload-time = "2026-07-08T12:25:33.692Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ac/aff6fb45393cb8912f39dfb156ef6b2d1cadb207ff465fc8f66141054be8/librt-0.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:54dab44a847d5ad1acd05c8a83fe518ae685516ecf4d3f7cc6e3df2a66767650", size = 126962, upload-time = "2026-07-08T12:25:34.769Z" }, + { url = "https://files.pythonhosted.org/packages/d9/3a/d68cb2b334d53fd30fac81d3a489ce4ba0d9506f4df43fcf676b68352b19/librt-0.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:d4cb6fbfdf874340ab5e51450753c0f817b6958a3621125ee695bbc3de866566", size = 112127, upload-time = "2026-07-08T12:25:35.981Z" }, + { url = "https://files.pythonhosted.org/packages/7b/66/f49ae0d592bd45b6941e9a8bafcb6a87cddcd501ee7874707e767f01b585/librt-0.13.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:25218d94b1d2cbc0ba1d8a3f9dc9af578d9646e5ed16443a70cde1dfdcce6d71", size = 149818, upload-time = "2026-07-08T12:25:37.203Z" }, + { url = "https://files.pythonhosted.org/packages/3d/50/51c76d74014d04fb95b6506d286808984b78a2f7a41039094e6b2194ac48/librt-0.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f26629539d4893c2957a16c41bb058e1e135c1f150f6a2e25ed047f64cf3f5c6", size = 154071, upload-time = "2026-07-08T12:25:39.399Z" }, + { url = "https://files.pythonhosted.org/packages/b8/fe/f19b0f5f82d5a1f2da736586bc840abd00ce07d6388136ae80b7333883fc/librt-0.13.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4517d47b2b8af26975a406fba7d314de9696d864252e0257c6ea90238cfe27f", size = 494168, upload-time = "2026-07-08T12:25:40.641Z" }, + { url = "https://files.pythonhosted.org/packages/94/bc/b8550c75775127fd31a5f20e8775997f7b527ad661fc8ddccd7497c064f7/librt-0.13.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f19e181de5b3a1148bb3420b8c4b0b0ea0fce6950099724ad151d6cea5acc180", size = 491054, upload-time = "2026-07-08T12:25:41.905Z" }, + { url = "https://files.pythonhosted.org/packages/30/14/4d0204867623df3f33f86efd3d3692ba5e01321443f4d6eab35a22697618/librt-0.13.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22034924f5b42d5a56371cf271771bfeaabf235a7a8b6264bef2d20013f786c6", size = 523006, upload-time = "2026-07-08T12:25:43.327Z" }, + { url = "https://files.pythonhosted.org/packages/19/0a/c45fc9a260934696bace1ac5df1e148ac92bd71767aee3bf7cd7a4534f4c/librt-0.13.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c7897db4e95e22468bdda33d8e012ceacd0182abf001e6389d763f0def6286b9", size = 515058, upload-time = "2026-07-08T12:25:44.541Z" }, + { url = "https://files.pythonhosted.org/packages/13/0a/50c5ce45b326854ef8fa6ae4c36cf5142e5c55315eaf9e51d0ae73ac4da3/librt-0.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1ce61b3746545029d4f5c17d6bd74b676254ad98433086c846ffb5e8fa73f007", size = 534025, upload-time = "2026-07-08T12:25:45.825Z" }, + { url = "https://files.pythonhosted.org/packages/89/2d/08c413c8f93fc13b8103624fce38e5caa86cd08cbbc8465870ab287af54b/librt-0.13.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:46c330e82565962c761dbce7941be2cff7db674ee807455a8d0cadc5f9b759b0", size = 540557, upload-time = "2026-07-08T12:25:47.059Z" }, + { url = "https://files.pythonhosted.org/packages/b3/c1/93af71fb4a364952210051811dd4e40174e79656b050c89cacac18af3330/librt-0.13.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:375f5af8f99cbaa99dd293af986e3d57caabc9ba81a5d3f021603764854197a1", size = 523201, upload-time = "2026-07-08T12:25:48.392Z" }, + { url = "https://files.pythonhosted.org/packages/c1/6e/9766f07b676a4889d9f8bc2864e9ba5fff165653143ef4dda7df6aa34d16/librt-0.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9320d34c3376ae204b2cd176e8d4883a013934e0aef822f1aed9c536490c275d", size = 565740, upload-time = "2026-07-08T12:25:49.678Z" }, + { url = "https://files.pythonhosted.org/packages/a2/1e/664e3472ce2b6e10e9b83f29d4a36eb982ff6b5a169ae7567bba3a4c4ff5/librt-0.13.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:9af313c66157a69dc69ea0059a66961692250e0dc95af9c385a48ffb770a0d16", size = 81611, upload-time = "2026-07-08T12:25:50.857Z" }, + { url = "https://files.pythonhosted.org/packages/2f/d4/8582a4d65e2234673685e07309d02c230b28a85724eb0acbf13f019b7f6e/librt-0.13.0-cp314-cp314-win32.whl", hash = "sha256:f2a7253458e34f33543551394ae4fe104b497ec2a65ac266074de64c1df82e37", size = 100106, upload-time = "2026-07-08T12:25:52.03Z" }, + { url = "https://files.pythonhosted.org/packages/63/ce/0cb99efe6086b46cd985dc26672166fae312a239690e75871f7fafbd3fc5/librt-0.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:a3dfe4edf10e8ed7e55b026a8bfc2c2a8704218b659cd4bffdf604fab966dc39", size = 121209, upload-time = "2026-07-08T12:25:53.166Z" }, + { url = "https://files.pythonhosted.org/packages/26/85/4f3ccb083a3c9b0d42e223acdb3c3f507953324a59cdcab4826e8e2e3b89/librt-0.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:68a5faee4bba381cb93b5961f684a514cf0053cb92308ff9c792c2fea0b174c6", size = 106404, upload-time = "2026-07-08T12:25:54.253Z" }, + { url = "https://files.pythonhosted.org/packages/b2/77/333191499538c8e8189de7a4cba8e6f49ee949fd6d6e6324b21fd1522466/librt-0.13.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a38fb81d8376dfa2f8963b265fec07637802b0d01e2a127c19c66cb070fb24f5", size = 159231, upload-time = "2026-07-08T12:25:55.432Z" }, + { url = "https://files.pythonhosted.org/packages/7a/9e/2aa83758f22c278b837a1d8025898434ce2b8bff36678d5330ecaef56dff/librt-0.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d4c8d9bd5abce34b2e75edb3bf37ab0f34e49b1f915a40ae8468eb7c85bc5b46", size = 161300, upload-time = "2026-07-08T12:25:56.585Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c0/86791e936553ca763d6b3c2fb4d31d596cd00e14fa631c283a40ba01559a/librt-0.13.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:387e2f1d27e89bffe0d3f520f0da0662c973fd607ca16c1808f8a5085419485e", size = 582056, upload-time = "2026-07-08T12:25:58.144Z" }, + { url = "https://files.pythonhosted.org/packages/a8/d3/a9ec15984a185e000c4d2a16ba28bd623124ad4c38a10974c7ff78e3a893/librt-0.13.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:4f6db193d2e5e0ed60359b9a5a682cd67205d0d3b1e459a867dd4b5c4e7eaa7a", size = 562758, upload-time = "2026-07-08T12:25:59.544Z" }, + { url = "https://files.pythonhosted.org/packages/3c/af/dbe36b78b19c06a55097f99305e4ea9458e2273e6ae16a3cbecaad7ee978/librt-0.13.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0d38604854e8d22faadf683ec6c02bb0f886e2ba56ef981a1c36ee275f21ea22", size = 602095, upload-time = "2026-07-08T12:26:00.991Z" }, + { url = "https://files.pythonhosted.org/packages/2a/a8/2966891b4dd2830f5203fbee92ac2c4947653a2390ba73dfa44244fad025/librt-0.13.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:371f7ce73026815dafd51c50ce38416e91428b28c4b2ec97cd39271164b0045c", size = 593452, upload-time = "2026-07-08T12:26:02.352Z" }, + { url = "https://files.pythonhosted.org/packages/61/f5/4df8bfc8405ecf8c0d525b4d69636f694bdd8620b313ec8b76e54a5926cc/librt-0.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3aaedf52171bee90860704c560bc798fe83b76247df47568e0197e9b13c735a0", size = 623729, upload-time = "2026-07-08T12:26:04.294Z" }, + { url = "https://files.pythonhosted.org/packages/d6/13/9ac202dffc8db06f75d06c08c2f9f6ff054be67d21272dcc078fa1cc0c57/librt-0.13.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:96bad8725a4f196a798366c25ce075d1f7543a4ec045ffc13e6a7ec095cdab04", size = 617077, upload-time = "2026-07-08T12:26:05.845Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f0/ebe38610716aee5cb28efd95089bb90192096179802779381e1c5dcf239c/librt-0.13.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:6bf6a559ffe4a93bbea6cf31ddf01a7fd9ba342ef51f27beb178e318b74acd61", size = 599561, upload-time = "2026-07-08T12:26:07.21Z" }, + { url = "https://files.pythonhosted.org/packages/4f/5c/c2e72e236fff7abc716d5b1753b8b8cd3ea85ac46fe17d2e7c51d4e1c723/librt-0.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:301067672387902c55f94b51d5022304b36c966ea9fe1f21caab99a9bef487c9", size = 645511, upload-time = "2026-07-08T12:26:08.562Z" }, + { url = "https://files.pythonhosted.org/packages/0c/99/6203ce619dee940d6bfbe099ec3fe4be00a68e9d60f70abf906cf124fe66/librt-0.13.0-cp314-cp314t-win32.whl", hash = "sha256:5fdcf34f86de8fb66d7dc7589f96ba91c4aa46671200d400e6fd6f109a483f18", size = 104357, upload-time = "2026-07-08T12:26:09.828Z" }, + { url = "https://files.pythonhosted.org/packages/52/dd/843b6314087c41657c7036d7914d8f294bdf9b580aa8513ea0588c8e9a3d/librt-0.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:260c33e92263fa629b4f6d3c51967a1c2158fe6c33237aaa3ebeac586b085259", size = 126998, upload-time = "2026-07-08T12:26:10.975Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/3dcec2884ba1b0806d1408612555c38dd5d68e90156b59f75f6e36435c3a/librt-0.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2f281549a4c52ac7bb97997f14353f8bd0e53a34ca0dad1c905cfd0b4a58ae99", size = 110771, upload-time = "2026-07-08T12:26:12.303Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "mypy" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ast-serialize" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/12/af/4e516a05d3ca2eb9283e9ec45b2c02225c1514dd6da49fd3c9eaa6639370/mypy-2.3.0.tar.gz", hash = "sha256:465965d41cd9a2726694e983e8ce7113259327bec798115d1e1dfa2a52fb666e", size = 3988104, upload-time = "2026-07-13T11:34:53.387Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/94/0e7e592619e2133596a47cdd642534b0456545c218430bd3b9d8fefdd1b1/mypy-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d53fc67b9d28a43c6199077f49fea0f05839e36cf6158500331c9549225e5a5", size = 15026523, upload-time = "2026-07-13T11:34:49.206Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/1e1731df090a857df2807177a4626863e5ac0f0256513c35780efe53986f/mypy-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fbc00cee7bdbb9291979ddc9d08034a29dfcda4932628c9bbc28c1edd589df0c", size = 14032189, upload-time = "2026-07-13T11:33:57.168Z" }, + { url = "https://files.pythonhosted.org/packages/44/95/cab921f4a806e171f34113e6181dd23c55358ccf6a80741269ef594a410e/mypy-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04e617030eca5221909c8b7d8d7fd1c637948199aa2100b2ad9813feb07e1491", size = 14198696, upload-time = "2026-07-13T11:32:12.767Z" }, + { url = "https://files.pythonhosted.org/packages/66/80/e6d008bb19fe446e3662d85e0e2717bf9f2d611a2164fb29d6e067dbf46c/mypy-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56c184d2c20ca6b6378d58d1960270a767f41f5e44acbbd27f05effef4f4e1d7", size = 15286904, upload-time = "2026-07-13T11:34:27.594Z" }, + { url = "https://files.pythonhosted.org/packages/db/83/94397c9293608a364aa03e8084fb34ede4ae976a260384b9b52929308135/mypy-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3961a4a34b05f7c74b0f05aa51fbfe99a2d1e126038df40318d15c8f558b7ef3", size = 15528342, upload-time = "2026-07-13T11:34:07.819Z" }, + { url = "https://files.pythonhosted.org/packages/cf/96/d8b37d819adec6cfccfb1fd3afc1735d94717ddeafb45536db9c6943e09b/mypy-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:b1942b9314d4c784b8ea1dbab4972603290e5dd5630f06675f13aec97526bc4c", size = 11218346, upload-time = "2026-07-13T11:28:27.745Z" }, + { url = "https://files.pythonhosted.org/packages/2b/cd/cd9f725b19b19e5b530a154cf9bcf9e94279c5d55b3c34fb42b3aa48ea1b/mypy-2.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:be51653d7669d7d7955d613b8d0bb57d5b652eaf71a873ddf65ac87254dd2595", size = 10204525, upload-time = "2026-07-13T11:31:02.552Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ae/f7d056eb0294586a572d0d0d89580ec633c064db520f11d37d5a2fb833bd/mypy-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:91ad22a52ae2c7e621c2f67c94d5a17f66b3209a4cff5cf8a573579835c69e97", size = 14947298, upload-time = "2026-07-13T11:27:47.734Z" }, + { url = "https://files.pythonhosted.org/packages/32/d5/db3e7af01e7844d21662c6ddc1f7825ec7cb4053f0391ac02faf3638396f/mypy-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:99ac767cc5d3b64c8d0ae226ead10c96694f94e4e7da1668642225dcd4e75aac", size = 13950768, upload-time = "2026-07-13T11:27:57.726Z" }, + { url = "https://files.pythonhosted.org/packages/d9/fb/43c031f0190513d1ec248ed037eceb742ddd2a4d74bbf406658a28173837/mypy-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de6d2c484742a4d7b0ed6d07b143375624d3b899c5749c7b3c947f56261f48a6", size = 14151586, upload-time = "2026-07-13T11:29:18.615Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c3/f8b2ffc60883084da91be51af58e88a7ffd4ff9795acb7d902ff88d31eb1/mypy-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7da939dd335cfd2ad788bdfd081c9f4e47634ab995e5a45eb15fd1e5bc052f8b", size = 15227411, upload-time = "2026-07-13T11:30:29.904Z" }, + { url = "https://files.pythonhosted.org/packages/83/2e/16b917fc7adcf03f1aadddfc93aab804ffb234b1ab09c0ffd6d92a5d34a2/mypy-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7247eb2824f996722a949530183394921ca71deb9680052a338cf53cff7925c2", size = 15478790, upload-time = "2026-07-13T11:33:14.686Z" }, + { url = "https://files.pythonhosted.org/packages/c0/88/aaa65a93c73d0cdae7e42f8adb302bf6885bb281302084f99d0290a35347/mypy-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:75b0984bb3cbd76bb5c9291a8671f7ae66ca3b51c7584c358fc2e923259f0757", size = 11234919, upload-time = "2026-07-13T11:33:39.28Z" }, + { url = "https://files.pythonhosted.org/packages/35/19/b40de63f1a80e63bc2d40f0679a6a8dbd34e95176c8122119bdf406aa552/mypy-2.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:d78fcf900b59cb7e82cb7e3a235e31b462d9333d92285bd1e4952d355b8ffba1", size = 10201510, upload-time = "2026-07-13T11:31:52.619Z" }, + { url = "https://files.pythonhosted.org/packages/a4/58/fa0ae047da911f540284009b4f44b96fe09d83c076d7c103e9d645f46303/mypy-2.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ea317b060ce83e26050f8f9e4d7d6bf44ed7597c8ff9990bccffbb9d1d8522db", size = 14941909, upload-time = "2026-07-13T11:32:34.332Z" }, + { url = "https://files.pythonhosted.org/packages/15/14/2ba1d61452d7c2a7fe12741e8d374e52b183476b07aa7f9e2a0d02b0720a/mypy-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:094af99f92638aa92852326188b85a89e50f4a472f44827c03362228482f0762", size = 13967581, upload-time = "2026-07-13T11:30:00.587Z" }, + { url = "https://files.pythonhosted.org/packages/ed/5a/483fb9e5ffbbb1a28dccc7b0a13d141b17ac769b6c9f488c0a0c63698962/mypy-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de121747278144fc9ae7caa2e978cf5df12aebc82933182f5b3b86081a30baef", size = 14168807, upload-time = "2026-07-13T11:28:48.6Z" }, + { url = "https://files.pythonhosted.org/packages/ae/77/70d7a10732063beb74ad713682cf871e88f5c5fa39bfc8beff8a524bf9cb/mypy-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:37fa4de896a84e2dc9200d91e614c22563b43d1a266789d4bbac7b22ebe6192b", size = 15200144, upload-time = "2026-07-13T11:31:25.283Z" }, + { url = "https://files.pythonhosted.org/packages/56/72/766218ac783be4fdfcd699b90037b63017348a3e86fb2c1fbfb18302637d/mypy-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f1b3a98dfd21058bc759bb3337d5d1f61d0fdf9f3cf9c00f4291790fb5427bff", size = 15460389, upload-time = "2026-07-13T11:29:29.077Z" }, + { url = "https://files.pythonhosted.org/packages/38/4e/8a9db7411ecb8ec0cb1fd05dba432f28bafffcd38b4e887714a4a0506689/mypy-2.3.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:944c665d984157cb96a679dfb7a4a81dd1d36b24b9c284b699514e6e626b82d4", size = 7753664, upload-time = "2026-07-13T11:29:08.147Z" }, + { url = "https://files.pythonhosted.org/packages/65/4c/c3f8bfd6ed0e5e38b5a244403b27f821d433443df5a15a278417c10a3a3c/mypy-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:4359424140d985192c778c1ce2c114a10c1ca58a381ed79cfa70d37df94b299f", size = 11417237, upload-time = "2026-07-13T11:33:47.467Z" }, + { url = "https://files.pythonhosted.org/packages/3c/00/89a32eaf5ccf174bc4f90db0eaea5d70636c01b8d49f384bdab2e8834390/mypy-2.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:3dd0bed92c4bdec57c42505b96416fb9e6a5aa7be84d2809bcd5f2ecec2860d7", size = 10389252, upload-time = "2026-07-13T11:31:43.81Z" }, + { url = "https://files.pythonhosted.org/packages/31/56/104f93d69aa9f339b6b9d3b0a7faa699b8b466c942cf3ae86cc2a2ec0915/mypy-2.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:691fdc37132b1ae628d834f672e74de83462d9fb4aff621835767fb43a8dd373", size = 16385495, upload-time = "2026-07-13T11:29:49.818Z" }, + { url = "https://files.pythonhosted.org/packages/d2/03/f1d2123313f55efafdd27706960f43a771c62f1b68426c76043f3ab9ebf3/mypy-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:aec15d465d477558fd842757b487849007311cf3897849cdda0e3162ac0ac556", size = 15098155, upload-time = "2026-07-13T11:30:40.301Z" }, + { url = "https://files.pythonhosted.org/packages/e5/5d/d5f9200399b445e81726c4f23becee33f233aee81c72680b1ef3a258b641/mypy-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b352b7e49f5e6576009e8df730e1ff4f915cb565b851b396d2ffe2f5a6f5da88", size = 15514155, upload-time = "2026-07-13T11:34:38.569Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ce/69977c555f08faa3190cfde44189b89dbd56861b1ab97aa18fc5f3a2e4a3/mypy-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c6c6bf687b17f90dbfcad95b960d32eaa0154c00da45f03ab50bf8952e047fe", size = 16766351, upload-time = "2026-07-13T11:33:29.195Z" }, + { url = "https://files.pythonhosted.org/packages/bc/92/6648b6caa3ab9e00f9ac0c2a78307805f873dd48139b24a6f6f7c3667bbf/mypy-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f4ed18f111bfe2d599bca7468e7f9251042c1c2118f762c8de2766a56d773c60", size = 17043490, upload-time = "2026-07-13T11:30:53.927Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ab/0dc91d80f3f016634c68d451f294a97320fe903a9b6f90b9e57b3f7f1717/mypy-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0b025a93cffb9781d231f232be07a17912f35f10a313c24f301c81e842870654", size = 12146869, upload-time = "2026-07-13T11:29:38.874Z" }, + { url = "https://files.pythonhosted.org/packages/85/b5/4c964d02634ba81f4d1c84838e5c5b18ab06d13ed568960f5d6318495ccc/mypy-2.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:adebc76aab4f3495a88b41d48aa4aff0c03f2822501da76625afcca5975f19e5", size = 10965113, upload-time = "2026-07-13T11:28:07.056Z" }, + { url = "https://files.pythonhosted.org/packages/2c/fa/fdc54fe583ba3cafbcedfb70eeeaf03849f75b1827a07096c7bd996f582d/mypy-2.3.0-py3-none-any.whl", hash = "sha256:6b1cdb579446b60432432b2b2403a6201b4b475a004d7f488511c9ba177c9e88", size = 2753292, upload-time = "2026-07-13T11:33:18.48Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + +[[package]] +name = "opentelemetry-api" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ee/8b/aa9e2d8b8dfa7c946f7dec5d1f8f6ba8eca062f43509a06bdb5ce93d26c0/opentelemetry_api-1.44.0.tar.gz", hash = "sha256:67647e5e9566edcf421166fdf022b3537f818635daa852b289e34604dc6fb33a", size = 72406, upload-time = "2026-07-16T15:25:32.678Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/6f/a04e900f465ff3221ccc395522503e2d10e79fa21f2723c8e177aae1e0d1/opentelemetry_api-1.44.0-py3-none-any.whl", hash = "sha256:94b98c893a91b88657eaac1e3ba89618cdb85be6918196705354f34728b2cdef", size = 60018, upload-time = "2026-07-16T15:25:11.657Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation" +version = "0.65b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "packaging" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/13/91/3c58961cb0360cd60509064734f0be4275383c8681d73c580a40ca83ddce/opentelemetry_instrumentation-0.65b0.tar.gz", hash = "sha256:071d9d9eced9bd6460444ec3b0c77229870ed05a881c22c84fdede58e4eed09b", size = 42689, upload-time = "2026-07-16T15:25:50.275Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/7b/85eab1215f72adf0e68d3dc4a679b9bff993fa679ff34cd8dd378e2659fd/opentelemetry_instrumentation-0.65b0-py3-none-any.whl", hash = "sha256:ea967a72b9939b5fcfdad572753b4306c59dcb99e3f382d95dae04286805e137", size = 36717, upload-time = "2026-07-16T15:24:51.424Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation-asgi" +version = "0.65b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asgiref" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "opentelemetry-util-http" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/17/83/8e8e83b7ac285281687c7be2fd305213ccccbb8c0a2dd4fb45a8ccaf12c7/opentelemetry_instrumentation_asgi-0.65b0.tar.gz", hash = "sha256:892bca67c56522ffa85a8a83cf934d7b50b3be2132e45cbee705825f0a5ba426", size = 26140, upload-time = "2026-07-16T15:25:54.544Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/9c/376962840b619d2d55fe8ee2285f8c70971c090e5fff614516fc654a6f3a/opentelemetry_instrumentation_asgi-0.65b0-py3-none-any.whl", hash = "sha256:3a845a8ebd1c4ef0d8263401e6545f5b219b2feee612090d50f578a87e71fd65", size = 15903, upload-time = "2026-07-16T15:24:57.198Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation-dbapi" +version = "0.65b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/97/b2e0ae6951cbf93c0c211910077cb50f9478fb4b7e89a402800b9a98151c/opentelemetry_instrumentation_dbapi-0.65b0.tar.gz", hash = "sha256:da048bb683347ddad2f47344bacfe1e111bf7bfb2e5a39796b1083679ad4f0f3", size = 20247, upload-time = "2026-07-16T15:26:02.726Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/e3/106953cea1d7f9318a4b56827ad10839fda3d033ecea2db717c051bf6a60/opentelemetry_instrumentation_dbapi-0.65b0-py3-none-any.whl", hash = "sha256:50b662578a6903b028e09b73f604de687752f5f904aa0ca032969157b29d60f2", size = 14815, upload-time = "2026-07-16T15:25:08.361Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation-fastapi" +version = "0.65b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-instrumentation-asgi" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "opentelemetry-util-http" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/30/23/b057f8196d06efdc1b50e3ff11fbc499a7d96b35c87f217eb7885542f4ea/opentelemetry_instrumentation_fastapi-0.65b0.tar.gz", hash = "sha256:10a3a95486036230413a58fe4fdf4a83fa6bba46918407e527476994bd92bd97", size = 26236, upload-time = "2026-07-16T15:26:05.954Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/b0/c9b0300d33349ecc3dfd2362516eaffc44877e90970e6a52178ff953fec3/opentelemetry_instrumentation_fastapi-0.65b0-py3-none-any.whl", hash = "sha256:cda2610a0ec1b22d19886f33e4d861e9f5dbb886aeaa3a1263b47aff82c36943", size = 13261, upload-time = "2026-07-16T15:25:12.429Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation-psycopg" +version = "0.65b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-instrumentation-dbapi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5f/e8/61953dd952ce3c7bfa5212d0f75e56c42c73ceee8795cd783750e3dfeaf2/opentelemetry_instrumentation_psycopg-0.65b0.tar.gz", hash = "sha256:62921ceaad2a0d0813a4185ce724aba40e583ead52db03bc3506de5ef53cab84", size = 12116, upload-time = "2026-07-16T15:26:13.173Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/f0/24595afbba7f7216c88ea167f71793fec735298f6fb4f597411f802c9b1b/opentelemetry_instrumentation_psycopg-0.65b0-py3-none-any.whl", hash = "sha256:a363c594a0dfcb1f6c8db5b1a1549a2e7dfde91f4d17e5fe0b48e777119705f9", size = 10820, upload-time = "2026-07-16T15:25:22.469Z" }, +] + +[[package]] +name = "opentelemetry-sdk" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5d/77/a6592cbc7c8d9bcc9d6757a9df45e04a7c585e3e6e7a13456da522b21109/opentelemetry_sdk-1.44.0.tar.gz", hash = "sha256:cebe7f65dc12f26ead75c6064de12fd2a9052e5060c0272d402cfa203aae123b", size = 208624, upload-time = "2026-07-16T15:25:46.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/23/ff077e61886ee020a17ce9c8b6fa11c601c8d8345b09ea24f605445df62a/opentelemetry_sdk-1.44.0-py3-none-any.whl", hash = "sha256:df081c4c6bcfdb1211e3e86140376792643128a25f8d72d1d27675936e7e96ad", size = 137221, upload-time = "2026-07-16T15:25:29.534Z" }, +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.65b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8f/73/0cbdebcb4cf545fdd328da14f5137e37d0770c3f26185e478b0d15d94f50/opentelemetry_semantic_conventions-0.65b0.tar.gz", hash = "sha256:f9b2b81e9d5b64f11bc952075e7e9c7fb0aab075c7fd1c46d597f1b919852d60", size = 148774, upload-time = "2026-07-16T15:25:46.902Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/0e/49df70d9b81fb5cbae4bbf2a49d865b09bcbcbc4eb53f5851b1027738d78/opentelemetry_semantic_conventions-0.65b0-py3-none-any.whl", hash = "sha256:1cacde7b0ad306f84c5ef08c3dbe1bbaf20165bba6f8bff43b670e555a086bcb", size = 204645, upload-time = "2026-07-16T15:25:30.688Z" }, +] + +[[package]] +name = "opentelemetry-util-http" +version = "0.65b0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/32/a9/d7525a59fdd240e69b5af4a6338e78fafa1b4203394122cbd6701fb5f84a/opentelemetry_util_http-0.65b0.tar.gz", hash = "sha256:84f82d826978bba416ab453460ff6a7391cdc3534c93a786595e4068680016b7", size = 11243, upload-time = "2026-07-16T15:26:27.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/23/3f/ab8d29df207ce5f470a07fa96ebb48af4e95b7fab7e7635311b9a32f2fab/opentelemetry_util_http-0.65b0-py3-none-any.whl", hash = "sha256:7553b606f963097cb190536dc30556cce85090692e471a422fff30ca29b04348", size = 8245, upload-time = "2026-07-16T15:25:46.482Z" }, +] + +[[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 = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "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 = "pre-commit" +version = "4.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8e/22/2de9408ac81acbb8a7d05d4cc064a152ccf33b3d480ebe0cd292153db239/pre_commit-4.6.0.tar.gz", hash = "sha256:718d2208cef53fdc38206e40524a6d4d9576d103eb16f0fec11c875e7716e9d9", size = 198525, upload-time = "2026-04-21T20:31:41.613Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" }, +] + +[[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 = "psycopg" +version = "3.3.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/db/2f/cb91e5502ec9de1de6f1b76cfbf69531932725361168bb06963620c77e2e/psycopg-3.3.4.tar.gz", hash = "sha256:e21207764952cff81b6b8bdacad9a3939f2793367fdac2987b3aac36a651b5bc", size = 165799, upload-time = "2026-05-01T23:31:55.179Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/e0/7b3dee031daae7743609ce3c746565d4a3ed7c2c186479eb48e34e838c64/psycopg-3.3.4-py3-none-any.whl", hash = "sha256:b6bbc25ccf05c8fad3b061d9db2ef0909a555171b84b07f29458a447253d679a", size = 213001, upload-time = "2026-05-01T23:20:50.816Z" }, +] + +[package.optional-dependencies] +binary = [ + { name = "psycopg-binary", marker = "implementation_name != 'pypy'" }, +] +pool = [ + { name = "psycopg-pool" }, +] + +[[package]] +name = "psycopg-binary" +version = "3.3.4" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/7d/03818e13ba7f36de93573c93ee3482006d3dfa8b0f8d28df511bad0a1a92/psycopg_binary-3.3.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5ab28a2a7649df3b72e6b674b4c190e448e8e77cf496a65bd846472048de2089", size = 4591122, upload-time = "2026-05-01T23:27:56.162Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b9/11b341edf8d54e2694726b273fe9652b254d989f4f63e3ac6816ad6b55f4/psycopg_binary-3.3.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6402a9d8146cf4b3974ded3fd28a971e83dc6a0333eb7822524a3aa20b546578", size = 4669943, upload-time = "2026-05-01T23:28:04.522Z" }, + { url = "https://files.pythonhosted.org/packages/8b/18/4665bacd65e7865b4372fcd8abb8b9186ada4b0025f8c2ca691b364a556c/psycopg_binary-3.3.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:580ae30a5f95ccd90008ec697d3ed6a4a2047a516407ad904283fa42086936e9", size = 5469697, upload-time = "2026-05-01T23:28:11.337Z" }, + { url = "https://files.pythonhosted.org/packages/7c/b1/b83136c6e510593d9b0c759ba5384337bc4ad82d19fda675adc4b2703c84/psycopg_binary-3.3.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e7510c37550f91a187e3660a8cc50d4b760f8c3b8b2f89ebc5698cd2c7f2c85d", size = 5152995, upload-time = "2026-05-01T23:28:20.529Z" }, + { url = "https://files.pythonhosted.org/packages/67/8d/a9821e2a648afe6091989929982a3b0f00b2631a859cb81379728f08fb75/psycopg_binary-3.3.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77df19583501ea288eaf15ac0fe7ad01e6d8091a91d5c41df5c718f307d8e31b", size = 6738180, upload-time = "2026-05-01T23:28:30.654Z" }, + { url = "https://files.pythonhosted.org/packages/7e/58/2e349e8d23905dc2317b80ac65f48fb6f821a4777a4e994a60da91c4850f/psycopg_binary-3.3.4-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:018fbed325936da502feb546642c982dcc4b9ffdea32dfef78dbf3b7f7ad4070", size = 4978828, upload-time = "2026-05-01T23:28:37.277Z" }, + { url = "https://files.pythonhosted.org/packages/45/48/57b00d03b4721878326122a1f1e6b0a90b85bcaec56b5b2f8ea6cfa45235/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:17a21953a9e5ff3a16dab692625a3676e2f101db5e40072f39dbee2250194d68", size = 4509757, upload-time = "2026-05-01T23:28:43.078Z" }, + { url = "https://files.pythonhosted.org/packages/25/37/33b47d8c007df69aec500df5889767c4d313748e8e9e27a2fef8a6dabcee/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:eb05ee1c2b817d27c537333224c9e83c7afb86fe7296ba970990068baf819b16", size = 4190546, upload-time = "2026-05-01T23:28:50.016Z" }, + { url = "https://files.pythonhosted.org/packages/ca/c6/32b0835dbc2122617902b649d76a91c1e75406e76bf3d595b0c3bb5ffad6/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:773d573e11f437ce0bdb95b7c18dc58390494f96d43f8b45b9760436114f7652", size = 3926197, upload-time = "2026-05-01T23:28:55.55Z" }, + { url = "https://files.pythonhosted.org/packages/cd/68/d190ef0c0c5b16ded07831dabc8ddd412f4cdab07ec6e30ed38d9bda0e1f/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:71e55ccbdfae79a2ed9c6369c3008a3025817ff9d7e27b32a2d84e2a4267e66e", size = 4236627, upload-time = "2026-05-01T23:29:05.336Z" }, + { url = "https://files.pythonhosted.org/packages/25/8f/81dcbc2e8454b74d14881275ea45f00791052dac531a9fa8be1730d1685b/psycopg_binary-3.3.4-cp312-cp312-win_amd64.whl", hash = "sha256:494ca54901be8cf9eb7e02c25b731f2317c378efa44f43e8f9bd0e1184ae7be4", size = 3560782, upload-time = "2026-05-01T23:29:11.967Z" }, + { url = "https://files.pythonhosted.org/packages/09/43/13e9c406fbbf354580476e248a16b64802a376873ebe6339e30bb655572d/psycopg_binary-3.3.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fbd1d4ed566895ad2d3bf4ddfd8bae90026930ddf29df3b9d91d32c8c47866a7", size = 4590377, upload-time = "2026-05-01T23:29:18.782Z" }, + { url = "https://files.pythonhosted.org/packages/22/be/2923cd7c3683e7afdecf4f10796a18de02f5c5ddc0969aa2ad0a8cdd3bbd/psycopg_binary-3.3.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:75a9067e236f9b9ae3535b66fe99bddb33d39c0de10112e49b9ab11eee53dc31", size = 4669023, upload-time = "2026-05-01T23:29:25.884Z" }, + { url = "https://files.pythonhosted.org/packages/96/a0/2c913d6fe13d6a8bd13597d36739bf47af063ad9399e402cfecab16f3c1e/psycopg_binary-3.3.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:b56b603ebcea8aa10b46228b8410ba7f13e7c2ee54389d4d9be0927fd8ce2a70", size = 5467423, upload-time = "2026-05-01T23:29:33.416Z" }, + { url = "https://files.pythonhosted.org/packages/e7/38/205d10bc1ad0df4a21c5c51659126bd3ea0ef98fcad1e852f78c249bb9c3/psycopg_binary-3.3.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c677c4ad433cb7150c8cd304a0769ae3bcfbe5ea0676eb53faa7b1443b16d0d3", size = 5151137, upload-time = "2026-05-01T23:29:42.013Z" }, + { url = "https://files.pythonhosted.org/packages/36/fc/f0381ddcd45eff3bb70dbca6823a996048d7f507b2ec3fc92c6fabc0fe87/psycopg_binary-3.3.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:26df2717e59c0473e4465a97dfb1b7afebaa479277870fd5784d1436470db47c", size = 6736671, upload-time = "2026-05-01T23:29:51.626Z" }, + { url = "https://files.pythonhosted.org/packages/95/40/fa545ae152c24327651e5624e4902121e808270be36c10b12e9939be09bc/psycopg_binary-3.3.4-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dc1f79fd16bb1f3f4421417a514607539f17804d95c7ed617265369d1981cae", size = 4979601, upload-time = "2026-05-01T23:29:56.961Z" }, + { url = "https://files.pythonhosted.org/packages/86/e4/2f8a47ee97f90cd2b933d0463081d35631ff419de2b8c984a5f369857de0/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:136f199a407b5348b9b857c504aff60c77622a28482e7195839ce1b51238c4cc", size = 4510513, upload-time = "2026-05-01T23:30:07.243Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0e/94e842ff4a7f98ed162580ca2e8b8864b28c1e0350f2443f8ee47f821167/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b6f5a29e9c775b9f12a1a717aa7a2c80f9e1db6f27ba44a5b59c80ac61d2ffcf", size = 4187243, upload-time = "2026-05-01T23:30:15.352Z" }, + { url = "https://files.pythonhosted.org/packages/d0/83/fc6c174b672e29b7de996ea77b6cbddf46c891751c3355f6974292baa6b4/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ee17a2cf4943cde261adfad1bbc5bf38d6b3776d7afff74c7cabcbeaeb08c260", size = 3927347, upload-time = "2026-05-01T23:30:21.186Z" }, + { url = "https://files.pythonhosted.org/packages/e9/65/768364d4a97a15b1a7f47ba52688c1686f22941d8332a8398cefc468e25f/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5c4ab71be17bdca30cb34c34c4e1496e2f5d6f20c199c12bad226070b22ef9bf", size = 4236393, upload-time = "2026-05-01T23:30:26.211Z" }, + { url = "https://files.pythonhosted.org/packages/bd/3b/218efbc9e645becd80cdf651acda05f85cfe546b7a9c0458c7cbc8fe1f74/psycopg_binary-3.3.4-cp313-cp313-win_amd64.whl", hash = "sha256:dbfdb9b6cc79f31104a7b162a2b921b765fcc62af6c00540a167a8de47e4ed38", size = 3564592, upload-time = "2026-05-01T23:30:31.764Z" }, + { url = "https://files.pythonhosted.org/packages/48/a6/828c9185701dab71b234c2a76c38a08b098ebfec5020716b4e93807492b5/psycopg_binary-3.3.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:28b7398fdd19db3232c884fb24550bdfe951221f510e195e233299e4c9b78f97", size = 4607292, upload-time = "2026-05-01T23:30:38.962Z" }, + { url = "https://files.pythonhosted.org/packages/92/58/5b40dbc9d839045c9dae956960e4fb6d20bcabe6c59a2aa34fc3a371913f/psycopg_binary-3.3.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1fbaa292a3c8bb61b45df1ad3da1908ccee7cb889db9425e3557d9e34e2a4829", size = 4687023, upload-time = "2026-05-01T23:30:47.227Z" }, + { url = "https://files.pythonhosted.org/packages/85/a9/793f0ac107a9003b48441d0d1f9f616d96e0f37458dd8dc12528ceff55fb/psycopg_binary-3.3.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:94596f9e7633ee3f6440711d43bb70aa31cc0a46a900ab8b4201a366ace5c9e7", size = 5486985, upload-time = "2026-05-01T23:30:55.517Z" }, + { url = "https://files.pythonhosted.org/packages/8f/26/42e8533497e2592334f68ec529cf5f840f7fa4e99575a4bb61aa184dbfbf/psycopg_binary-3.3.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8c0056529e68dbe9184cd4019a1f3d8f3a4ead2f6fc7a5afcf27d3314edd1277", size = 5168745, upload-time = "2026-05-01T23:31:01.904Z" }, + { url = "https://files.pythonhosted.org/packages/15/af/b7151776cc08d5935d45c833ec818a9beb417cf7c08239af1aafbdae78ee/psycopg_binary-3.3.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c09aad7051326e7603c14e50636db9c01f78272dc54b3accff03d46370461e6", size = 6761486, upload-time = "2026-05-01T23:31:14.511Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ed/c92533b9124712d592cbf1cd6c76da933a2e0acea81dfe1fbe7e735f0cff/psycopg_binary-3.3.4-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:514404ed543efd620c85602b747df2a23cf1241b4067199e1a66f2d2757aaa41", size = 4997427, upload-time = "2026-05-01T23:31:20.901Z" }, + { url = "https://files.pythonhosted.org/packages/a2/23/ccadfd0de416aa188356daa199453af24087b042e296088706d190ae0295/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:46893c26858be12cc49ca4226ed6a60b4bfccadd946b3bebb783a60b38788228", size = 4533549, upload-time = "2026-05-01T23:31:26.204Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a0/c8f43cee36386f7bc891ab41a9d31ea07cf9826038e732da79f26b1e5f34/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:df1d567fc430f6df15c9fcf67d87685fc49bdb325adc0db5af1adfb2f44eb5c9", size = 4210256, upload-time = "2026-05-01T23:31:33.884Z" }, + { url = "https://files.pythonhosted.org/packages/4e/2c/c1547871be3790676e8868b38655496422f94f0978dfb66b74bdba2f1676/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:6b9016b1714da4dd5ecaaa75b82098aa5a0b87854ce9b092e21c27c4ae23e014", size = 3946204, upload-time = "2026-05-01T23:31:39.626Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b1/f6670f00fa7ea601584623f6c11602ab92117d83eaff885e0210f6de7418/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:47c656a8a7ba6eb0cff1801a4caaa9c8bdc12d03080e273aff1c8ac39971a77e", size = 4255811, upload-time = "2026-05-01T23:31:44.986Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e6/5fff07a70d1f945ed90ae131c3bd76cab32beff7c58c6db15ad5820b6d1f/psycopg_binary-3.3.4-cp314-cp314-win_amd64.whl", hash = "sha256:c37e024c07308cd06cf3ec51bfd0e7f6157585a4d84d1bce4a7f5f7913719bf8", size = 3666849, upload-time = "2026-05-01T23:31:51.165Z" }, +] + +[[package]] +name = "psycopg-pool" +version = "3.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/82/7a23d26039827ecd4ebe93905651029ddd307c5182ad59296dfb6f67b528/psycopg_pool-3.3.1.tar.gz", hash = "sha256:b10b10b7a175d5cc1592147dc5b7eec8a9e0834eb3ed2c4a92c858e2f51eb63c", size = 31661, upload-time = "2026-05-01T23:31:59.809Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/ed/89c2c620af0e1660354cd8aabf9f5b21f911597ce22acb37c805d6c86bc8/psycopg_pool-3.3.1-py3-none-any.whl", hash = "sha256:2af5b432941c4c9ad5c87b3fa410aec910ec8f7c122855897983a06c45f2e4b5", size = 40023, upload-time = "2026-05-01T23:31:53.136Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[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 = "pyjwt" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-discovery" +version = "1.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4c/81/58c70036dffeccb7fe7d79d6260c69f7a28272bbd3909c29a01ea9422744/python_discovery-1.4.4.tar.gz", hash = "sha256:5cad33982d412c1f3ffb8f9ca4ea292c9680bca3942451d30b69c37fce53a4a3", size = 72212, upload-time = "2026-07-08T23:06:50.691Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/ae/84bc0d2440c95772272bb6f4b3d09ccf08b2898fce89b3d4f969a9fc74e9/python_discovery-1.4.4-py3-none-any.whl", hash = "sha256:abebe9120b43453b68c908acfb1e72a19d1a959ed2cb620ad38fc57d08056dbe", size = 34181, upload-time = "2026-07-08T23:06:49.402Z" }, +] + +[[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 = "pywin32" +version = "312" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/ff/32aa7d2ed0ab12b323aaa64f9b75e6ad4f8fd09f9ccfc28c79414d46838d/pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b", size = 6371877, upload-time = "2026-06-04T07:49:28.836Z" }, + { url = "https://files.pythonhosted.org/packages/03/d9/77040d3b43df3f3be32ea289433d660d2727f5ba327bc73be835127d9d60/pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc", size = 6914841, upload-time = "2026-06-04T07:49:31.85Z" }, + { url = "https://files.pythonhosted.org/packages/e3/cc/7b1ec671775756020a0ee7f4feeaf3c568f0ab86bd3900088cf986937a92/pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950", size = 6727901, upload-time = "2026-06-04T07:49:34.244Z" }, + { url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184, upload-time = "2026-06-04T07:49:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298, upload-time = "2026-06-04T07:49:38.876Z" }, + { url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640, upload-time = "2026-06-04T07:49:41.083Z" }, + { url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928, upload-time = "2026-06-04T07:49:43.188Z" }, + { url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157, upload-time = "2026-06-04T07:49:45.34Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598, upload-time = "2026-06-04T07:49:47.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/61/caa39686032d2ebdd04ff0ab5cbe163126c0066d98e00c9018646e42393b/pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed", size = 6471159, upload-time = "2026-06-04T07:49:50.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/cd/7e1de64a4a6f69c04214169657ccab0d93a670ea50e35eb8f489d7378249/pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5", size = 7025293, upload-time = "2026-06-04T07:49:54.857Z" }, + { url = "https://files.pythonhosted.org/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9", size = 6840337, upload-time = "2026-06-04T07:49:57.531Z" }, +] + +[[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.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/c3/928b290c2c0ca99ab96eea5b4ff8f30be8112b075301a7d3ba214a3c8c12/redis-8.0.1.tar.gz", hash = "sha256:afc5a7a2f5a084f5b1880dec548dd45be17db7e43c82a30d84f952aefb05cfb0", size = 5114170, upload-time = "2026-06-23T14:52:37.728Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/0a/c2345ebf1ebe70840ce3f6c6ee612f8fa749cfbd1b03069c53bf0c62aaad/redis-8.0.1-py3-none-any.whl", hash = "sha256:47daa35a058c23468d6437f17a8c76882cb316b838ef763036af99b96cedd743", size = 502406, upload-time = "2026-06-23T14:52:36.137Z" }, +] + +[[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.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/06/ae069393fc66e8ff33036d4b368003833bf6e88ccf182e17e7a2f1c754fd/ruff-0.15.22.tar.gz", hash = "sha256:3f15175b1fb580126f58285a5dae6b2ea89000136d980c64499211f116b54809", size = 4785063, upload-time = "2026-07-16T15:14:13.244Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/23/18/ee54b7ae1e121be7a28ea6da4b67564ebb0530e183a54415ab7e3bcd2c4e/ruff-0.15.22-py3-none-linux_armv6l.whl", hash = "sha256:44423e73493737f5e7c5b41d475483898ff37afcdae38bc3da5085e29af1c2d8", size = 10781258, upload-time = "2026-07-16T15:13:19.452Z" }, + { url = "https://files.pythonhosted.org/packages/2f/d2/2520cb14761ddbeaf57642a76942fc36adcbdbe53b4532241995f6fc485c/ruff-0.15.22-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b82c6482946e9eda7ff2e091d25b8bad3f718684e1916d41bd56873cee05b697", size = 10999477, upload-time = "2026-07-16T15:13:23.318Z" }, + { url = "https://files.pythonhosted.org/packages/c9/10/74e53572aa758dfaa678c2a2646b5c5515d884b7ca56be4d2ce03ca4b560/ruff-0.15.22-py3-none-macosx_11_0_arm64.whl", hash = "sha256:11c1c715af53a09f714e011106bffc419751ec8232fcb5da42173284ea3fec6f", size = 10466716, upload-time = "2026-07-16T15:13:26.162Z" }, + { url = "https://files.pythonhosted.org/packages/1e/cc/44eaaf0844e028182f2d0a8f2190d0f359159aed0a9e5ab861d892f1ae2a/ruff-0.15.22-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:742a29cf29bddb7c8327895d6a10e0e6c5b38a96dd407af9b5d0857f809c0576", size = 10892644, upload-time = "2026-07-16T15:13:29.229Z" }, + { url = "https://files.pythonhosted.org/packages/9f/21/8edf559014d2b0f82beea19cfb713993ad802ccda16868769979c6090a84/ruff-0.15.22-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72af58b951b0ae395935ae79763dc349bc0eb706319d28f7a33ad2cfb3cfc178", size = 10576719, upload-time = "2026-07-16T15:13:32.35Z" }, + { url = "https://files.pythonhosted.org/packages/bf/1e/3a13abd392a3b50b62e5938a831f9ab6e588358cacad5c18545b716d2182/ruff-0.15.22-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:62d425005c1835eb24e2ee4161cb90e8db263415f4a71c8c72c33abaa6c0c224", size = 11376494, upload-time = "2026-07-16T15:13:35.958Z" }, + { url = "https://files.pythonhosted.org/packages/bf/3e/422d3d95bcf04dd78e1aeac22184d4f9a8fb2c01865d39d44618484a0317/ruff-0.15.22-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8b9b3f8779a4f08c969defc3c8c35abffaa757e601ed5ae66d6d1db6519969a", size = 12208370, upload-time = "2026-07-16T15:13:39.185Z" }, + { url = "https://files.pythonhosted.org/packages/1e/91/5d065a0e0a02bf4813f5119ad278462eed081d2b832eb7c021ade0ec9e65/ruff-0.15.22-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1e0dd1b2e4d3d585f897a0d137cbf4eaf6223bef4e8ce34d6bb12556c5f9249e", size = 11581098, upload-time = "2026-07-16T15:13:42.132Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f9/a0d4871d12fae702eb1f41b686caf05f1f8b124dc6db6f784f53d74918fa/ruff-0.15.22-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:365523eb91d9224e1bcb03b022fbf0facb8f9e23792a2c53d9d4b3924bdbdebb", size = 11399422, upload-time = "2026-07-16T15:13:45.2Z" }, + { url = "https://files.pythonhosted.org/packages/18/80/c843a5176cddbceb0b7e8dd41cf9993490796c1c469348d384f5a5c13c56/ruff-0.15.22-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:fabfd168afdf29fee5be98b831efa9683c94d7c5a3b58b9ce5a2e38444589a74", size = 11381683, upload-time = "2026-07-16T15:13:48.46Z" }, + { url = "https://files.pythonhosted.org/packages/d4/00/8485de0ae92239438a36cfc51350db9b9e85c9ebdfaea91b18e422706662/ruff-0.15.22-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:225dbf095a87f1d9f90f5fd7924d2613ee452a75a4308c63a8f50f761787aa7c", size = 10850295, upload-time = "2026-07-16T15:13:51.655Z" }, + { url = "https://files.pythonhosted.org/packages/fa/91/24977ec2ec72eaf15e4394ace2959fdff2dd1e14f03e005e838023407169/ruff-0.15.22-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1877d63b9d24ed278744f1523fd11b85540566d54641f97c566d7d9dc5ca5296", size = 10579640, upload-time = "2026-07-16T15:13:54.79Z" }, + { url = "https://files.pythonhosted.org/packages/9c/47/9b51216951974df1f263ac19da550d34252e0ed7218c25f10c5ef9ed7517/ruff-0.15.22-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a1606c510bd7215680d32efab38965f7cdec3ef69f5170a3f4791404ffdd5262", size = 11105077, upload-time = "2026-07-16T15:13:57.915Z" }, + { url = "https://files.pythonhosted.org/packages/c2/47/20e9d4a3b8016778acea5fc32bb50d35d207500a17ddb529ffa6996feef8/ruff-0.15.22-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:630479b18625f5ffc373f77603a22a9f8ac0acd7ff0501178b5db28ec71e9c64", size = 11490980, upload-time = "2026-07-16T15:14:01.032Z" }, + { url = "https://files.pythonhosted.org/packages/4d/76/3f72d8fc38c1cb77b38c56a70da9d0c17700cc1cc50f9649c9d3c8f5ba71/ruff-0.15.22-py3-none-win32.whl", hash = "sha256:e5ba0e4a13fd14abbed2a77b517a3911290c6c6c59ef67784328d1668fab76cf", size = 10789165, upload-time = "2026-07-16T15:14:04.16Z" }, + { url = "https://files.pythonhosted.org/packages/cb/46/4965251734c2b6fcdca1b1b187d20bcac3af0ee5b083b89c910bb961ce3a/ruff-0.15.22-py3-none-win_amd64.whl", hash = "sha256:9be63ba1eb936acd2d1342fb8337c356353706fce233b2a15a09a97037e6acde", size = 11938297, upload-time = "2026-07-16T15:14:07.316Z" }, + { url = "https://files.pythonhosted.org/packages/57/c9/e69b1ff4c8b69093ef08b8919ab767af0569666865b39c30a8795d88d3c6/ruff-0.15.22-py3-none-win_arm64.whl", hash = "sha256:e1168075b72158510839f250027659cdd78476f40507dd517892304c41318661", size = 11298172, upload-time = "2026-07-16T15:14:10.51Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[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 = "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 = "svcforge" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "croniter" }, + { name = "fastapi" }, + { name = "httpx" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation-fastapi" }, + { name = "opentelemetry-instrumentation-psycopg" }, + { name = "opentelemetry-sdk" }, + { name = "prometheus-client" }, + { name = "psycopg", extra = ["binary", "pool"] }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "redis" }, + { name = "structlog" }, + { name = "svcforge-core" }, + { name = "typer" }, + { name = "tzdata" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[package.dev-dependencies] +dev = [ + { name = "hypothesis" }, + { name = "mypy" }, + { name = "pre-commit" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "ruff" }, + { name = "testcontainers" }, + { name = "types-pyyaml" }, +] + +[package.metadata] +requires-dist = [ + { name = "croniter", specifier = ">=3.0" }, + { name = "fastapi", specifier = ">=0.115" }, + { name = "httpx", specifier = ">=0.27" }, + { name = "opentelemetry-api", specifier = ">=1.28" }, + { name = "opentelemetry-instrumentation-fastapi", specifier = ">=0.49b0" }, + { name = "opentelemetry-instrumentation-psycopg", specifier = ">=0.49b0" }, + { name = "opentelemetry-sdk", specifier = ">=1.28" }, + { name = "prometheus-client", specifier = ">=0.21" }, + { name = "psycopg", extras = ["binary", "pool"], specifier = ">=3.2" }, + { name = "pyjwt", extras = ["crypto"], specifier = ">=2.9" }, + { name = "redis", specifier = ">=5.2" }, + { name = "structlog", specifier = ">=24.4" }, + { name = "svcforge-core", editable = "libs/svcforge_core" }, + { name = "typer", specifier = ">=0.15" }, + { name = "tzdata", specifier = ">=2024.2" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.32" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "hypothesis", specifier = ">=6.122" }, + { name = "mypy", specifier = ">=1.13" }, + { name = "pre-commit", specifier = ">=4.0" }, + { name = "pytest", specifier = ">=8.3" }, + { name = "pytest-asyncio", specifier = ">=0.24" }, + { name = "pytest-cov", specifier = ">=6.0" }, + { name = "ruff", specifier = ">=0.8" }, + { name = "testcontainers", extras = ["postgres"], specifier = ">=4.9" }, + { name = "types-pyyaml", specifier = ">=6.0.12.20260518" }, +] + +[[package]] +name = "svcforge-core" +version = "0.1.0" +source = { editable = "libs/svcforge_core" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "prometheus-client" }, + { name = "psycopg", extra = ["binary", "pool"] }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pyyaml" }, + { name = "redis" }, + { name = "structlog" }, +] + +[package.metadata] +requires-dist = [ + { name = "opentelemetry-api", specifier = ">=1.28" }, + { name = "prometheus-client", specifier = ">=0.21" }, + { name = "psycopg", extras = ["binary", "pool"], specifier = ">=3.2" }, + { name = "pydantic", specifier = ">=2.9" }, + { name = "pydantic-settings", specifier = ">=2.6" }, + { name = "pyyaml", specifier = ">=6.0" }, + { name = "redis", specifier = ">=5.2" }, + { name = "structlog", specifier = ">=24.4" }, +] + +[[package]] +name = "testcontainers" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docker" }, + { name = "python-dotenv" }, + { name = "typing-extensions" }, + { name = "urllib3" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ca/ac/a597c3a0e02b26cbed6dd07df68be1e57684766fd1c381dee9b170a99690/testcontainers-4.14.2.tar.gz", hash = "sha256:1340ccf16fe3acd9389a6c9e1d9ab21d9fe99a8afdf8165f89c3e69c1967d239", size = 166841, upload-time = "2026-03-18T05:19:16.696Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/2d/26b8b30067d94339afee62c3edc9b803a6eb9332f521ba77d8aaab5de873/testcontainers-4.14.2-py3-none-any.whl", hash = "sha256:0d0522c3cd8f8d9627cda41f7a6b51b639fa57bdc492923c045117933c668d68", size = 125712, upload-time = "2026-03-18T05:19:15.29Z" }, +] + +[[package]] +name = "typer" +version = "0.27.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/37/78/fda3361b56efc27944f24225f6ecd13d96d6fcfe37bd0eb34e2f4c63f9fc/typer-0.27.0.tar.gz", hash = "sha256:629bd12ea5d13a17148125d9a264f949eb171fb3f120f9b04d85873cab054fa5", size = 203430, upload-time = "2026-07-15T19:21:07.007Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/03/26a383c9e58c213199d1aad1c3d353cfc22d4444ec6d2c0bf8ad02523843/typer-0.27.0-py3-none-any.whl", hash = "sha256:6f4b27631e47f077871b7dc30e933ec0131c1390fbe0e387ea5574b5bac9ccf1", size = 122716, upload-time = "2026-07-15T19:21:05.553Z" }, +] + +[[package]] +name = "types-pyyaml" +version = "6.0.12.20260518" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b8/83/4a1afc3fbfcf5b8d46fc390cd95ed6b0dc9010a265f4e9f46314efffa37a/types_pyyaml-6.0.12.20260518.tar.gz", hash = "sha256:d917f83fb38462550338c1297faedd860b3ec83912b96b1e3d73255f7473e466", size = 17850, upload-time = "2026-05-18T06:01:58.675Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/a2/c01db32be2ae7d6a1689972f3c492b149ee4e164b12fdfd9f64b50888215/types_pyyaml-6.0.12.20260518-py3-none-any.whl", hash = "sha256:d2150f75a231c9fe9c7463bd29487d93e60bac90400287351384bc2284eba7cd", size = 20312, upload-time = "2026-05-18T06:01:57.368Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.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 = "tzdata" +version = "2026.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/ff/5a28bdfd8c3ebec42564ac7d0e54ca3db65044a9314a97f9564fa7a1e926/tzdata-2026.3.tar.gz", hash = "sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415", size = 198674, upload-time = "2026-07-10T08:50:37.887Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/6d/b53b99a9f2766d095985947a5782f1702cabb129a34f7a802d7197af832f/tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931", size = 348168, upload-time = "2026-07-10T08:50:36.46Z" }, +] + +[[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.51.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a2/65/b7c6c443ccc58678c91e1e973bbe2a878591538655d6e1d47f24ba1c51f3/uvicorn-0.51.0.tar.gz", hash = "sha256:f6f4b69b657c312f516dd2d268ab9ae6f254b11e4bac504f37b2ab58b24dd0b0", size = 94412, upload-time = "2026-07-08T10:59:05.962Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/ec/dbb7e5a6b91f86bfb9eb7d2988a2730907b6a729875b949c7f022e8b88fa/uvicorn-0.51.0-py3-none-any.whl", hash = "sha256:5d38af6cd620f2ae3849fb44fd4879e0890aa1febe8d47eb355fb45d93fe6a5b", size = 73219, upload-time = "2026-07-08T10:59:04.44Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, + { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, + { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, + { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, + { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, + { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, + { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, + { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, + { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, + { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, + { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, +] + +[[package]] +name = "virtualenv" +version = "21.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, + { name = "python-discovery" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/34/d9/b477fddb68840b570af8b22afe9b035cbc277b5fb7b33dea390617a8b10f/virtualenv-21.6.1.tar.gz", hash = "sha256:15f978b7cd329f24855ff4a0c4b4899cc7678589f49adbdcbbb4d3232e641128", size = 5526620, upload-time = "2026-07-10T19:33:53.312Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/7c/4e7225d46d634a0d8d534dd8a6ce0c319d09b4d0cf0337eb314ca4789d8c/virtualenv-21.6.1-py3-none-any.whl", hash = "sha256:afe991df855715a2b2f60edfcc0107ef95a79fdfd8cb4cdaa71603d1c12e463b", size = 5506392, upload-time = "2026-07-10T19:33:51.629Z" }, +] + +[[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.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/02/b9a097e1e16fee4e2fd1ec8c39f6a9c5d6257bae8fa12640caf869f54436/websockets-16.1.tar.gz", hash = "sha256:299468cbe42e2b9981134c7c51d99387d8a7bf562b00183b3eec53f882846dad", size = 182530, upload-time = "2026-07-10T06:32:57.734Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/52/748c014f07f4e0e170c8932de7e647a1511d5ab3049cd978797136aee577/websockets-16.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b6aa3f7ad345cf3862c21f4fbf2ef5e14d911348476c2845e137c091fe3a3f0b", size = 179798, upload-time = "2026-07-10T06:31:09.664Z" }, + { url = "https://files.pythonhosted.org/packages/8b/5e/2a2e64d977d084e49d37c187c26c056daaff41965be7300cd5dbde6f8b07/websockets-16.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b43fcfb521ac2f34ba80b7b8ea16303e4ad82dd8af667bf40839ad3a5d37b164", size = 177478, upload-time = "2026-07-10T06:31:11.072Z" }, + { url = "https://files.pythonhosted.org/packages/aa/12/5b85b4e75d697e548a94962ce5c036b05dd21cb9545759d555c5586422fc/websockets-16.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2bd3e12cd9afbe2baedae0b1eeade8ba64329b60fe2f9abdc966bd10fd2c2ef5", size = 177746, upload-time = "2026-07-10T06:31:12.386Z" }, + { url = "https://files.pythonhosted.org/packages/9d/62/79b1c8f0cee0da648b4899e1c5b0dbd3aa59846985136a54854db6827ab4/websockets-16.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:35f41979c8623df9bd30d949d82010a8fda5c56ff12cd8508a5b7272b6d4b53a", size = 187345, upload-time = "2026-07-10T06:31:13.754Z" }, + { url = "https://files.pythonhosted.org/packages/25/34/b7c5c52c2f24280e1c017acb7ad491a566750a5cceca7f3cf999373bba21/websockets-16.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a24d1f35aef07d794a16c853c688e74956c50239bec37b4f2de080056046419b", size = 188581, upload-time = "2026-07-10T06:31:15.075Z" }, + { url = "https://files.pythonhosted.org/packages/bc/37/604193bebcbeffe96fdf795960b83a15d600880c64dc17ec9c31c5b3427d/websockets-16.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0c64c024ddf7a35331b21fcddb562a039c275d2c82e8c2d12939e7da23997270", size = 191362, upload-time = "2026-07-10T06:31:16.395Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b4/5ee27575b367d7110d4d13945e2a9de067ec84dc71e54b87f01e38550d9a/websockets-16.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c3e99757f5baafe20fc598e202ea6f5b0b265186ad38d0a17bd8beca16296955", size = 189216, upload-time = "2026-07-10T06:31:17.776Z" }, + { url = "https://files.pythonhosted.org/packages/7e/22/3e2dcc78d85fc5d9d814895ce6d07d0dfacc0f6aaa1d151f2b8c8d772299/websockets-16.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:353f3bc6e058ac1ccab4b3588e8598837a8c04cfc8351233e6d523be675d844c", size = 187971, upload-time = "2026-07-10T06:31:19.152Z" }, + { url = "https://files.pythonhosted.org/packages/9e/2f/cd271717b93d5ee19626cb5e38a85baab745c86e33db7c31a3ac729b31b8/websockets-16.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0352f5b38b40e857b6428d468fa21dbb4dd4a567d933c26d9831b4efe1b92f43", size = 185381, upload-time = "2026-07-10T06:31:20.665Z" }, + { url = "https://files.pythonhosted.org/packages/78/91/6ad6f2f1426317b5001bd490534208c7360636b35bac1dec2e0c22bfc40e/websockets-16.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:70bd789afab579602968c39f21cb925466505f3edff22f0ae852bca54978a4f9", size = 188015, upload-time = "2026-07-10T06:31:22.024Z" }, + { url = "https://files.pythonhosted.org/packages/c7/6d/533733132ab4c07540efd4a8f0b9a435d3a5059b2f26cc476ace1abf7f45/websockets-16.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d0fb4b46f121eccd539353baebd1083a8767a9a351109453d1d1caecd1ba40c2", size = 186619, upload-time = "2026-07-10T06:31:23.376Z" }, + { url = "https://files.pythonhosted.org/packages/08/73/16c059f3d73b3331eba10793704afa4faa9939234fb08ef7dca35794e8f0/websockets-16.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c14b6634af01541e4efe2954fd8f263386f7aa6d37c01e55dd8109fd17661452", size = 188497, upload-time = "2026-07-10T06:31:25.024Z" }, + { url = "https://files.pythonhosted.org/packages/4d/89/9a8fae7dd2acdcfb1a8844c29fe42b518a04b64fce38a0923b6290e452f1/websockets-16.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:a58532c49a851bcb481e58c1be23b315c17fe2fbbed509d75aeea12f543d2c15", size = 186051, upload-time = "2026-07-10T06:31:26.291Z" }, + { url = "https://files.pythonhosted.org/packages/f6/40/b240c7dd6a0e0c59c1f68377cc3015263521080c327c15f5e753c1f6d378/websockets-16.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4e969170c3b08e1d8dabd990fef1fa702c4233aeaabec33f871806e444f6a0e4", size = 187029, upload-time = "2026-07-10T06:31:27.605Z" }, + { url = "https://files.pythonhosted.org/packages/50/35/524e3fac40e47d6fdcf6c4b2c95ef1bc8a97e01593c90eff86621df7b716/websockets-16.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ff9b000064b88787ba9f7a3cb2af2b68a658ca5aad76458a46469e7124b678a0", size = 187308, upload-time = "2026-07-10T06:31:28.927Z" }, + { url = "https://files.pythonhosted.org/packages/00/13/56840cf62c8859af6ba22b9529da937332468c80f32b598753e8a66d3990/websockets-16.1-cp312-cp312-win32.whl", hash = "sha256:b9f5d83f80f4d7c4bba6d97f3755ac05850c784dce0fd2ab371c4e41172f53ff", size = 180161, upload-time = "2026-07-10T06:31:30.316Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ff/87eb9eb44cb62424a8d729834f2b0515a47e2669fabec29820268f4d50a1/websockets-16.1-cp312-cp312-win_amd64.whl", hash = "sha256:6852c9f653966c16109d3b6f31181fd734f7914927e3f0fa1117af7a18c9aa21", size = 180462, upload-time = "2026-07-10T06:31:31.708Z" }, + { url = "https://files.pythonhosted.org/packages/d9/63/df158b155420b566f025e75613424ad9649a24bcb0e9f259321ab3d58bea/websockets-16.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:b0232ed141cec3df2af5a3959a071c51f40036336b0d37e17faf9ef52fc73e47", size = 179791, upload-time = "2026-07-10T06:31:33.108Z" }, + { url = "https://files.pythonhosted.org/packages/74/cf/00fe9414dfeafa6fe54eae9f5716c8c8e9ac59d192be3b893c096d395846/websockets-16.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a71b73d143991714144e159f767b698f03c4a70b8a65ae1733b650cff488045b", size = 177472, upload-time = "2026-07-10T06:31:34.522Z" }, + { url = "https://files.pythonhosted.org/packages/8b/76/b10633424d40681b4e892ffd08ca5226322b2426e62d4ab71eae484c3a32/websockets-16.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:187323204c3b2fc465e8fc2609e60437c521790cb9c1acb49c4c452a33e57f37", size = 177737, upload-time = "2026-07-10T06:31:35.964Z" }, + { url = "https://files.pythonhosted.org/packages/dc/61/d3bb03b2229bb1afd72008742d586cf1ea240dce64dd48c71c8c7fd3294c/websockets-16.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9dba74233c8c3ce368850818c98354dad2570f57231b3fd3bd00d7aa57628881", size = 187403, upload-time = "2026-07-10T06:31:37.496Z" }, + { url = "https://files.pythonhosted.org/packages/26/16/cc2e80478f688fc3c39c67dc1fac6a0783858058914ebc2489917462cb42/websockets-16.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:63339bc8c63c86a463177775cb7c677691f5bcfac7b3b2f01b286d42acd41600", size = 188639, upload-time = "2026-07-10T06:31:38.86Z" }, + { url = "https://files.pythonhosted.org/packages/15/d6/ad87b2507e57de1cbf897a56c963f2925962ed5e85fbe06aaa83ced27acd/websockets-16.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:23e545ea8ae4263e37cdfd4e22a217f519e48e432728bc461185bbf585f38a83", size = 190078, upload-time = "2026-07-10T06:31:40.218Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1a/5b37b3fd335d5811f29fc829f2646a3e6d1463a4bf09c3100708684c766e/websockets-16.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2237081454846fb40403a80ba86d82e2038b9c45865ab96af0abe7d002a91045", size = 189267, upload-time = "2026-07-10T06:31:41.523Z" }, + { url = "https://files.pythonhosted.org/packages/42/98/06afc33e9450d4230f94c664db78875d90f5f6a5fb77f0bc6ec15ae74e1c/websockets-16.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5f5218de1ed047385ca53744caba9435d65f75d008364970a3fae95a05812cf9", size = 188022, upload-time = "2026-07-10T06:31:42.838Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/42fef5d5887c18cf2d148b02debf56cecb9cfbffc68027cde9b12c8f432c/websockets-16.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:75c98e3920039d0edff03b74478ada504b7ce3a1bc406db2cabfca84320f7baf", size = 185435, upload-time = "2026-07-10T06:31:44.219Z" }, + { url = "https://files.pythonhosted.org/packages/a0/9b/8021c133add5fe40ed40312553a6cd1408c069d7efe3444ad483d4973ed3/websockets-16.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1facd189d8190af30487a55b4c3688484dd50801628a3b5b2ccd26db08e67057", size = 188080, upload-time = "2026-07-10T06:31:45.986Z" }, + { url = "https://files.pythonhosted.org/packages/69/54/1e37384f395eaa127383aab15c1c45e200890a7d7b99db5c312233d193e0/websockets-16.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:cc0c6a6eef613c7da32d4fb068f82ef834b58134f6a16b54e6c1e5bf9529ab3d", size = 186678, upload-time = "2026-07-10T06:31:47.449Z" }, + { url = "https://files.pythonhosted.org/packages/68/79/1caeacab5bc2081e4519288d248bc8bd2de30652e6eaa94be6be09a1fe5b/websockets-16.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:ad9411eded8988b879be6038206698bf7106c85a78f642c004485bcb95be17eb", size = 188554, upload-time = "2026-07-10T06:31:48.886Z" }, + { url = "https://files.pythonhosted.org/packages/ee/83/b3dca5fad71487b726e31cb0acf56f226792c1cc34e6ab18cbf146bd2d74/websockets-16.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:cd68f0914f3b64694895bc5e9b14e8b447e41d7bf5ffaf989bb8dcb5e2dfdce7", size = 186109, upload-time = "2026-07-10T06:31:50.508Z" }, + { url = "https://files.pythonhosted.org/packages/5b/0b/8f246c3712f07f207b52ea5fb47f3b2b66fafec7303162644c74aed51c6a/websockets-16.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fef2debfe7f7ebdda12176f26166f95b7af17af05ba06150fcf889032e0213e9", size = 187061, upload-time = "2026-07-10T06:31:51.861Z" }, + { url = "https://files.pythonhosted.org/packages/47/eb/27d6c92a01696b6495386af4fc941d7d0a13f2eab2bf9c336111d7321491/websockets-16.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a3cd6c9b798218798f4bb7b2e71c38f0e744bb94ca537b13376f88019d46384d", size = 187347, upload-time = "2026-07-10T06:31:53.246Z" }, + { url = "https://files.pythonhosted.org/packages/6b/d5/eeee439921f55d5eaeabcea18d0f7ce32cdc39cb8fc1e185431a094c5c7b/websockets-16.1-cp313-cp313-win32.whl", hash = "sha256:84c170c6869633536921e4474b1cce7254c0c9b0053ef5725f966cee47e718e4", size = 180149, upload-time = "2026-07-10T06:31:55.058Z" }, + { url = "https://files.pythonhosted.org/packages/a3/03/971e98d4a4864cf263f9e94c5b2b7c9a9b7682d77bfbba4e732c55ee85a9/websockets-16.1-cp313-cp313-win_amd64.whl", hash = "sha256:bef52d327d70fa75dad93ee61ea2cb1d1489aca9f35c188833563f5a3b4df0a5", size = 180458, upload-time = "2026-07-10T06:31:56.767Z" }, + { url = "https://files.pythonhosted.org/packages/8d/e6/da1dc11507f8118145a81c751fe0c77e5e1c11b8554496addb39389e2dc2/websockets-16.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:f881fca0a45dd6789939bd6637cd98169b92f1c3fdc78262f2cb9ec2cb1f324e", size = 179833, upload-time = "2026-07-10T06:31:58.19Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ac/c0d46f62e31e232487b2c123bc3cfd9a4e45684ca7dc0c37f0987f29baae/websockets-16.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:30c379d5b207d3a7f0ba4c2e4602a895b0bcc63fb5f5371a4ae7fbddb03b672b", size = 177524, upload-time = "2026-07-10T06:31:59.563Z" }, + { url = "https://files.pythonhosted.org/packages/4a/33/abd966074b34a51e4f134e0aaed80f5a4a0a35163ea5ac58a1bc5a076d23/websockets-16.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:98ab58a4faa72b46da0127ccc1931dcbfc0985b0778892300a092185910c4cbe", size = 177743, upload-time = "2026-07-10T06:32:00.959Z" }, + { url = "https://files.pythonhosted.org/packages/ea/30/646e47b8a8dff04e227bdab512e6dde60663a647eeac7bbd6edddd92bbc5/websockets-16.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e9c4e369fc181b2d41a99e01477215cecdc8546a39f7d41a59cc0a7065a0b09", size = 187474, upload-time = "2026-07-10T06:32:02.54Z" }, + { url = "https://files.pythonhosted.org/packages/d2/72/890ab9d77494af93ea65268230bfbc0a90ba789401ed7a44356a44785644/websockets-16.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0704df094b2d5fa7f6f410925a594c2a5c9a09167731a76292e5410934208209", size = 188717, upload-time = "2026-07-10T06:32:04.156Z" }, + { url = "https://files.pythonhosted.org/packages/d5/aa/baedbbaa6bf9ed6029617ed5e8976535bd805f483ca9b3484e7ad9ee08bf/websockets-16.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b22b1f4950f6ab7126623329c3b47b3b90a14c05db517f2db2a026ad6c928352", size = 190090, upload-time = "2026-07-10T06:32:05.822Z" }, + { url = "https://files.pythonhosted.org/packages/52/4f/d813ec94e18002571ef4959d87a630eff6e01b72a51bcb0832b75ae8c51a/websockets-16.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1ae4a686a662964a6671069f84f7f908cc3475e782227726b0c622c715962105", size = 189320, upload-time = "2026-07-10T06:32:07.223Z" }, + { url = "https://files.pythonhosted.org/packages/b8/3c/8ec52a6662f3df64090fba28cd521d405d54759268d8e820477037e8c80d/websockets-16.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:856bdd638f8277f86465057bfdd4da097c73058fb0f9d2bd5baea29e2bf2d367", size = 188068, upload-time = "2026-07-10T06:32:08.586Z" }, + { url = "https://files.pythonhosted.org/packages/96/7f/f0ae6042b14f86fa5f996c6563ea4cf107adc036ccbedc9d4f418d0095f9/websockets-16.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9003a1fde1c21a322a3ca3fa0c4bda8c639da81dbc925162766086643b05ba87", size = 185493, upload-time = "2026-07-10T06:32:09.968Z" }, + { url = "https://files.pythonhosted.org/packages/89/ad/5ffc53af9939c49fd653d147fa5b8f78ced1f6bce6c49a7446860945b0ce/websockets-16.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:39e947b1f5fdab045174306e3916785bf3ed537648acc1549827c08c33b10953", size = 188141, upload-time = "2026-07-10T06:32:11.434Z" }, + { url = "https://files.pythonhosted.org/packages/67/62/729206c0ee577a4db8eae6dd06e0eef725a1287c6df11b2ef831d003df31/websockets-16.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5dd0e666b5931c0509cf65714686a1c5126771e663a79ac5d40da4f58b1f9502", size = 186653, upload-time = "2026-07-10T06:32:12.845Z" }, + { url = "https://files.pythonhosted.org/packages/1b/86/e8806a99ec4589914f255e6b658853fe537bf359c05e6ba5762ad9c27917/websockets-16.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a0285df7925657ad65a65fb8dc330808bce082827538fd50ef45fa12d1fc5bca", size = 188614, upload-time = "2026-07-10T06:32:14.236Z" }, + { url = "https://files.pythonhosted.org/packages/89/38/ac554e2fc6ff0b8deeff9798b92e7abd8f99e2bd9731532e7033de208220/websockets-16.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:82d1c2cab3c133e9d059b3a5420bed9376bd30e21c185c63dda4ddadf6ddda47", size = 186165, upload-time = "2026-07-10T06:32:15.626Z" }, + { url = "https://files.pythonhosted.org/packages/6c/c5/4ef4d8e53342f94f3c49e1ae089b32c1e8b3878e15e0022c7708c647f351/websockets-16.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c39907f1eaf11f6277def65aa02d68f30576b693d0c1ca332aafa3caa723ac6d", size = 187119, upload-time = "2026-07-10T06:32:17.114Z" }, + { url = "https://files.pythonhosted.org/packages/3a/33/4788b1dd417bd97eeb2698af3b9df6775ac656f96e9987da0419a067602f/websockets-16.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:45c5ea55446171949eb99fd34b771ceddd511ca21958d40d0197ced33159e5ee", size = 187411, upload-time = "2026-07-10T06:32:18.629Z" }, + { url = "https://files.pythonhosted.org/packages/30/38/00d37aad6dc3244ce349e2864815362e50b3cfc00cac28d216db20efe40f/websockets-16.1-cp314-cp314-win32.whl", hash = "sha256:b8ef8b1c8d6bd029a475ac432e730fba2dfd456715d26c473e2a82291024b99c", size = 179822, upload-time = "2026-07-10T06:32:20.233Z" }, + { url = "https://files.pythonhosted.org/packages/9d/37/2a8cb0eaddee5eaebda47a90a3ba0898d1ce3d866b02a4857fea17d82e5b/websockets-16.1-cp314-cp314-win_amd64.whl", hash = "sha256:7358ff21632b5d062707f73e859c824f1c3807e73d8ca25e71caca7c4cdcf145", size = 180167, upload-time = "2026-07-10T06:32:21.749Z" }, + { url = "https://files.pythonhosted.org/packages/07/5a/262ad5fcaef4198997b165060f09a63f861e76939b1786ab546ccc3f8120/websockets-16.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d0f38f4c3e9b359e257c339c2cc1967ccaeedb102e57c1c986bdce4bf4f32268", size = 180166, upload-time = "2026-07-10T06:32:23.278Z" }, + { url = "https://files.pythonhosted.org/packages/1a/c7/36377db690f4292826e4501a6dec2801dc55fd1cf0405923b04937e478df/websockets-16.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:3c3d2cbd1602593bad49bd86fa3fbb25407d87a3b4bf8857c0ac5ac4914e1901", size = 177697, upload-time = "2026-07-10T06:32:25.164Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c7/07171abce1e39799a76f473608580fe98bd43a1230f5146159622c02bccf/websockets-16.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:36069b74671e7e667f48a7484249f84c45a825a134c8b1bdc01875d0daa10d79", size = 177902, upload-time = "2026-07-10T06:32:26.564Z" }, + { url = "https://files.pythonhosted.org/packages/14/17/c831f48e250bc4749f57c00dcce73337c41cd32f6d59a64567b84e782601/websockets-16.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:587f83c2ce8a5d628e166384d77fa7f0ac69b9007d515ab442123e6615aa8da3", size = 187766, upload-time = "2026-07-10T06:32:27.981Z" }, + { url = "https://files.pythonhosted.org/packages/2c/2e/4dfe63e245b0ecfaf470cf082d25c6ce35808159135fd88c82653a6b11ab/websockets-16.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a6db7972d52bc1b66cefe2246902e256cbaebc9ba8a45eac09343d7eb6671b2", size = 188939, upload-time = "2026-07-10T06:32:29.365Z" }, + { url = "https://files.pythonhosted.org/packages/ba/e5/5faf65aebd9562f6b4bc473d24ce38cc56f84eb5f5bee66ed9b86733f93c/websockets-16.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e7d6014888a0632e1ed7a4095248bb3095232999447f2d83bfb1900987dd9ed9", size = 191081, upload-time = "2026-07-10T06:32:30.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/cd/2634f2f2c0556c1aae6501ed6840019cc569dd6fdbcac6494378daea4dc0/websockets-16.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9cb074d150e4ad2a77aa8a332c2be85f3f64f2681519d2570c1225c12c9821ff", size = 189513, upload-time = "2026-07-10T06:32:32.399Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/2c700b51196104f09715b326b1f092ed25326bdf79a03e00a4842e503743/websockets-16.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d19c9067e1fe9490f974bffbc0e443b80a7674c5efb4980c429cc00771f07c5a", size = 188240, upload-time = "2026-07-10T06:32:33.897Z" }, + { url = "https://files.pythonhosted.org/packages/f1/20/86283636e499a1a357fa9441f690ba34f255e731f2fea174132b3b762b57/websockets-16.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d440ff0c6c7469ad59c0a412c383c235935b43635e89425e3f6a0c36de90c31b", size = 185955, upload-time = "2026-07-10T06:32:35.279Z" }, + { url = "https://files.pythonhosted.org/packages/91/23/d7fb734b0095d43bc7f1c9f68afd50adb4176e7e513403e8c70ad7daa4fa/websockets-16.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8613129a2533f08de24505e69a3e403cedaadae49abdb043c4d170ca71b7e4bd", size = 188491, upload-time = "2026-07-10T06:32:36.673Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5e/168a192689db468405ecf3b8e4a2c18811936b0724d017ad7e6d252734f0/websockets-16.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:a5bf9c23f197b4ec88290fd5463f33db67362a1bb10f85fc2e8e7627f0ddab97", size = 186983, upload-time = "2026-07-10T06:32:38.207Z" }, + { url = "https://files.pythonhosted.org/packages/7e/9b/66795fa91ebe49019ebe4fa910282172252e37046b80e08fc52e0c365150/websockets-16.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:520b0fd0395f075febb283c76755af724ab9fd19dffa4f3bfd18cb4e622790a3", size = 188890, upload-time = "2026-07-10T06:32:39.545Z" }, + { url = "https://files.pythonhosted.org/packages/5a/32/126bbc844be5afb3613fd43211dac10a9645f4cf39741d04acaa2ec7030c/websockets-16.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:7143aa09a67e1c013be44e81a88dfe90fc6244198ab86c7edd064152cf619805", size = 186583, upload-time = "2026-07-10T06:32:41.038Z" }, + { url = "https://files.pythonhosted.org/packages/22/b9/0b5db9cbcf6e4970db4496893244a8d92e07f71a8ef27cf34b08aa02fef1/websockets-16.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:7acb811fad08e611755800d1560e395c67e11a6bd563598ea6abb319afb86938", size = 187353, upload-time = "2026-07-10T06:32:42.501Z" }, + { url = "https://files.pythonhosted.org/packages/99/2e/254b2131a10d831b76e2c18dfe7add9729c6292c674a8085bf8de01ad151/websockets-16.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c5cf88e3faa2f7931bc6baeee7599c97656a3f6ac7f831f4fccba233e141783a", size = 187784, upload-time = "2026-07-10T06:32:43.929Z" }, + { url = "https://files.pythonhosted.org/packages/21/dc/e7288aa8e3ac5a88a0924619984d663c1abf2a87d0ea98290c66fdaee0ec/websockets-16.1-cp314-cp314t-win32.whl", hash = "sha256:589f8842521c8307684ce0b40ce4ad70c5e0aa46484c6f1225a94ef4b8970341", size = 179947, upload-time = "2026-07-10T06:32:45.495Z" }, + { url = "https://files.pythonhosted.org/packages/d3/de/37edf1260ff0fbbd2f82433489c4cfbe799ac2ff21355331609879329fe6/websockets-16.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c0e0857c30bbbc2bb5c30687508f0b7ec19aa026cd9f2ff8424d0fee42dcc07", size = 180291, upload-time = "2026-07-10T06:32:47.119Z" }, + { url = "https://files.pythonhosted.org/packages/66/58/bd83247f39ddc26ffc2c24eb05087a3b749e00cb4509fc6d19daa23c8495/websockets-16.1-py3-none-any.whl", hash = "sha256:c5149dfe490ec7e5ee5dbf624c642fb725f93a5575c7f00ab594ca9eddb8dd81", size = 174031, upload-time = "2026-07-10T06:32:56.079Z" }, +] + +[[package]] +name = "wrapt" +version = "2.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/a4/282c8e64300a59fc834518a54bf0afabb4ff9218b5fa76958b450459a844/wrapt-2.2.2.tar.gz", hash = "sha256:0788e321027c999bf221b667bd4a54aaefd1a36283749a860ac3eb77daed0302", size = 129068, upload-time = "2026-06-20T23:49:44.49Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/85/180b40628b23772692a0c76e8030114e1c0ae068470ed531919f0a5f2a4a/wrapt-2.2.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8417fd3c674d3c8023d080292d29301531a12daf8bd938dd419710dd2f464f2b", size = 81484, upload-time = "2026-06-20T23:47:59.924Z" }, + { url = "https://files.pythonhosted.org/packages/94/f2/21c90f2a16689702e2aaff45795b11018dff2c9b1242bac10d225483f676/wrapt-2.2.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e7070c7472582e31af3dfc2622b2381a0df7435110a9388ed8db5ffbce67efb", size = 82151, upload-time = "2026-06-20T23:48:01.303Z" }, + { url = "https://files.pythonhosted.org/packages/5f/b3/7e6e9fcf4fe7e1b69a49fe6cc5a44e8224bab6283c5233c97e132f14908e/wrapt-2.2.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2e096c9d39a59b35b63c9aacfbbbec2088ff51ff1fc31051acc60a07f42f273a", size = 169828, upload-time = "2026-06-20T23:48:02.719Z" }, + { url = "https://files.pythonhosted.org/packages/0b/43/894f132d857ed5a9904d937baf368badcbe5ea9e436e2f1930fe21c9f1f0/wrapt-2.2.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d1a6050405bf334be33bf66296f113563622972a34900ae6fa60fd283a1a900", size = 171544, upload-time = "2026-06-20T23:48:04.266Z" }, + { url = "https://files.pythonhosted.org/packages/29/de/3c833e03725b477e9ea34028224dd21a48781830101e4e036f77e8b6b102/wrapt-2.2.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:10adb01371408c6de504a6658b9886480f1a4919a83752748a387a504a21df79", size = 160663, upload-time = "2026-06-20T23:48:05.708Z" }, + { url = "https://files.pythonhosted.org/packages/33/be/27edce350b24e3054d9d047f65f16d4c4d4c1f3f31c4278a1f8a95c723c8/wrapt-2.2.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3442eee2a5798f9b451f1b2cd7518ce8b7e28a2a364696c414460a0e295c012a", size = 169387, upload-time = "2026-06-20T23:48:07.243Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c4/9fd9679af8bf38e146652c7f47b6b352c3e5795b4ad1c0b7f94e15ac2aa7/wrapt-2.2.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:6c99012a22f735a85eed7c4b86a3e99c30fdd57d9e115b2b45f796264b58d0bf", size = 158849, upload-time = "2026-06-20T23:48:08.91Z" }, + { url = "https://files.pythonhosted.org/packages/bc/c2/aa6c0c2206803068c6859dabe01f8c84c43744da93d4c67b8946d21655ee/wrapt-2.2.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3b686cfc008776a3952d6213cb296ed7f45d782a8453936406faa89eac0835ab", size = 168147, upload-time = "2026-06-20T23:48:10.374Z" }, + { url = "https://files.pythonhosted.org/packages/42/63/3eb25da41049d20ae18fcab2dd8b056e02387c4bfa626cbdfb7c3b872e4f/wrapt-2.2.2-cp312-cp312-win32.whl", hash = "sha256:ef2cce266b5b0b07e19fa82e59673b81142b7a3607c8ed1254113d048ed668da", size = 77734, upload-time = "2026-06-20T23:48:11.769Z" }, + { url = "https://files.pythonhosted.org/packages/da/09/0390e008a305360948fa9ce69507d041ac12cb2ee5d28e34467e2ee79391/wrapt-2.2.2-cp312-cp312-win_amd64.whl", hash = "sha256:abf8c20a2d72ee69e16328b3c91342c446e723bfe48bfcc4dded3b9722ac027f", size = 80585, upload-time = "2026-06-20T23:48:13.117Z" }, + { url = "https://files.pythonhosted.org/packages/d3/b3/84c445c66969f2d3457276b183a48c91097d59bbef9af6c075366b0f8c36/wrapt-2.2.2-cp312-cp312-win_arm64.whl", hash = "sha256:c6c64c5d02578bc4c4bca4f0aef1504de933c1d5b4ac2710b9131111459506c8", size = 79553, upload-time = "2026-06-20T23:48:14.5Z" }, + { url = "https://files.pythonhosted.org/packages/43/fc/f32f4b22c6511173c11d9e541ab4e7d8467a0f1b3455acaf784115d31ff8/wrapt-2.2.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9e8b648270c613720a202d9a45ebabc33261b22c3a839b115ac5bce8c0bb0d69", size = 81296, upload-time = "2026-06-20T23:48:15.881Z" }, + { url = "https://files.pythonhosted.org/packages/72/06/4d117d5d77a9344776c0248b24dae3d3dd2f58e5f765fa08cf887072e719/wrapt-2.2.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6fb7e94e8fe3e4c3067bb1653a91cce7c5e83acc119fdd41501b1bf74654617", size = 81841, upload-time = "2026-06-20T23:48:17.262Z" }, + { url = "https://files.pythonhosted.org/packages/15/ff/63ad96f98eb58a742b1a20d80f21da88924405910149950b912368150468/wrapt-2.2.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb18fc51e813df0d9c98049e3bf2298a5495a648602040e21fa3c7329371159e", size = 167882, upload-time = "2026-06-20T23:48:18.764Z" }, + { url = "https://files.pythonhosted.org/packages/20/1f/8bb62d8933df7acf3247194e6e9fc68edf9d2fa203252c89c94b319dd472/wrapt-2.2.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94b00b00f806eb3ef2abe9049ed45994a81ee9284884d96e6b8314927c6cea3d", size = 167411, upload-time = "2026-06-20T23:48:20.315Z" }, + { url = "https://files.pythonhosted.org/packages/17/09/8789dcb09ee1de715727db7521aabbb68ffa68dfade3a49468440cfced49/wrapt-2.2.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:62415fd095bc590b842b6d092f2b5d9ccbaeb7e0b28535c03dcea2718b48636b", size = 158607, upload-time = "2026-06-20T23:48:21.728Z" }, + { url = "https://files.pythonhosted.org/packages/9c/20/66e02562d53ee67d841f175e38e3c993c2d78a3e104c576cad61c028b43c/wrapt-2.2.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a41e758d80dc0ab8c210f641ac892009d356cf1f955d97db544c8dd317b4d14c", size = 166367, upload-time = "2026-06-20T23:48:23.177Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a3/832ac4e41222fb263b3042d42c2f08d305db7d0f0c9b1d3a271a9eede8f6/wrapt-2.2.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b84cd4058001c9727b0e9980b7a9e66325b5ca748b1b578e822cade1bc6b304f", size = 157176, upload-time = "2026-06-20T23:48:24.711Z" }, + { url = "https://files.pythonhosted.org/packages/b7/01/1bd5e4d2df9c0178989ac8da9186543465388588ee2ef153e2591accebef/wrapt-2.2.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:26fc73a1b15e0946d2942b9a4426d162b51676338327dc067ccd8d2d76385f94", size = 167025, upload-time = "2026-06-20T23:48:26.118Z" }, + { url = "https://files.pythonhosted.org/packages/1c/69/583ed25291ab53e1ec117135fb1c33425e2f46d2bc8f29c17f7a94cf4274/wrapt-2.2.2-cp313-cp313-win32.whl", hash = "sha256:3c4095803491f6ef72128914c28ec05bbad9758433bb35f6715a3e9c8e46fb2d", size = 77605, upload-time = "2026-06-20T23:48:27.643Z" }, + { url = "https://files.pythonhosted.org/packages/29/68/e69fc6d06e1523c68e0d00f95c9aed1158ce9908ee41603f7f2eae3d5db6/wrapt-2.2.2-cp313-cp313-win_amd64.whl", hash = "sha256:2cb07f414fab25dbe6b5c7398e1491423a5c81a6209533639969a6c928d474a4", size = 80508, upload-time = "2026-06-20T23:48:29.013Z" }, + { url = "https://files.pythonhosted.org/packages/55/21/fe7a393d9e5dc0923bed8f5d857e9dcff210f1fa0888c02cc8f3ffaa55aa/wrapt-2.2.2-cp313-cp313-win_arm64.whl", hash = "sha256:1fc7691f070220215cccb2a20836b9adbaecb8ff22ad47abe63de5f110994fac", size = 79565, upload-time = "2026-06-20T23:48:30.429Z" }, + { url = "https://files.pythonhosted.org/packages/b6/e5/c120d13bf5091164f68c3c1657e84f16f57e71d978421b626393ac5bd7eb/wrapt-2.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ec8f83949028366531383603139403cac7a826e4011955813cdd640017845ce5", size = 83264, upload-time = "2026-06-20T23:48:31.807Z" }, + { url = "https://files.pythonhosted.org/packages/d3/b0/d4a1eb97e0e286625bdf21bc7f702637f9607787ffbbdb5ec14d50c79dbf/wrapt-2.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4b481fb0c40d9fd90a5809911208da700987d373a20a4709dc9e3944af7a6bec", size = 83791, upload-time = "2026-06-20T23:48:33.482Z" }, + { url = "https://files.pythonhosted.org/packages/18/1e/f060df47755e87b57684cee7bfc1362b204df55fac96ffebc0631b697b79/wrapt-2.2.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0065a3b657cec06813b4241d2462ccec287f6863103d7445b725fb3a889736f9", size = 203399, upload-time = "2026-06-20T23:48:34.97Z" }, + { url = "https://files.pythonhosted.org/packages/c4/de/2316a757a1abb6453700b79d83e532146dcef2611348282d4d8889792161/wrapt-2.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:30f7424af5c5c345b7f26490e097f74a2ef45b3d08b664dc33571aee3bd3b56c", size = 210461, upload-time = "2026-06-20T23:48:36.569Z" }, + { url = "https://files.pythonhosted.org/packages/ed/29/d1160785ae18ca2495a6d82a21154103d74f656c9fd457fb35f6b11b965a/wrapt-2.2.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:07fdcb012821859168641acf68afad61ef9783cf37100af85f152550e9677194", size = 195313, upload-time = "2026-06-20T23:48:38.175Z" }, + { url = "https://files.pythonhosted.org/packages/f5/2d/7caa9598ae61a9cf0989cc501739cbeeb7d650ab3193cca1407b9af0c6ab/wrapt-2.2.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f90038ab58fafb584801ca62d72384d7d5225d93c76f7b773c22fae545bd8066", size = 206116, upload-time = "2026-06-20T23:48:39.804Z" }, + { url = "https://files.pythonhosted.org/packages/ac/02/281ea1088b8650d865f311b35cf86fd21df89128e2909714f1161e01c9d0/wrapt-2.2.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:c5d7825491bfa2d08b97e9557768987952c7b9ae687d06c3320b40a37ccb7f20", size = 192668, upload-time = "2026-06-20T23:48:41.346Z" }, + { url = "https://files.pythonhosted.org/packages/be/7d/976e2d5b4b5c5babda40974edd54d0a5585cb60132ed86b46f4b80239b16/wrapt-2.2.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0ad520e6daa9bbf136f14de735474dbec7dcc0891f718e1d274ce8dc92e645af", size = 198891, upload-time = "2026-06-20T23:48:43.056Z" }, + { url = "https://files.pythonhosted.org/packages/59/b7/e47651797c097f75a37e2ce86dcf04048ff576f3a674f7c558df7b5e9622/wrapt-2.2.2-cp313-cp313t-win32.whl", hash = "sha256:25904acb9475f46c24fe0423dbc8fda8cc5fbc282ab3dc6e72e919748c53f4e9", size = 78537, upload-time = "2026-06-20T23:48:44.509Z" }, + { url = "https://files.pythonhosted.org/packages/d1/6f/9fa5d59fb06d890defb5a8f727ce6a14d2932c8760153f96956628559fee/wrapt-2.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:305d4c247d61c4115794a169141823c62f719525ddb90b23aa332741c77d2c28", size = 82005, upload-time = "2026-06-20T23:48:46.391Z" }, + { url = "https://files.pythonhosted.org/packages/15/80/4c7bd9873d1f9f7d138d93556b500469dbe24f42710b877519c2b9eb380d/wrapt-2.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:c20279cd1a29800815d7b2d6338b60a6c6e78263f9d6e62e0eda251ba9cae2d0", size = 80762, upload-time = "2026-06-20T23:48:47.964Z" }, + { url = "https://files.pythonhosted.org/packages/24/05/7fd9c3f83b2c74cbfc572a0b88aa37431e04bd8aed70d2c0efd3464206de/wrapt-2.2.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:0e64826f920c42d9d9f87e8cc09ffae66c51ede12d59061a5a426deb9aa71745", size = 81341, upload-time = "2026-06-20T23:48:49.39Z" }, + { url = "https://files.pythonhosted.org/packages/4b/68/1bfa43100dd90d4ef74a05897b86275cf57e1313ca14aae2545bc9f872c9/wrapt-2.2.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:dcaa5e1451bd8751d7bd1568dfa3321c78092a52a7ecb5d1a0f18a5791e1fd00", size = 81921, upload-time = "2026-06-20T23:48:50.986Z" }, + { url = "https://files.pythonhosted.org/packages/74/eb/df7b7f0b631dbbc750f39be27d8b55f65777d8ac86da80e12be41a644c4b/wrapt-2.2.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0abfd648dac9ac9c5b3aa9b523d27f1789046640b58dcd5652a720ddb325e1fc", size = 167713, upload-time = "2026-06-20T23:48:52.598Z" }, + { url = "https://files.pythonhosted.org/packages/4d/9a/d1bd36f6d088c8e652a9383cabbd49af30b8c576302a7eccddbab6963e3f/wrapt-2.2.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f4bfd8d1eb438153eff8b8cfe87f032ba65731e1ce06138b5090f745a33f6f95", size = 166779, upload-time = "2026-06-20T23:48:54.33Z" }, + { url = "https://files.pythonhosted.org/packages/4c/ae/24ffacd4187fac2740a1972093929e836dea092d42c87d728cd98fee11a6/wrapt-2.2.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c427c9d06d859848a69f0d928fe28b5c33a941b2265d10a0e1f15cd244f1ee33", size = 158407, upload-time = "2026-06-20T23:48:55.944Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ed/974427668249a356051e8d67d47fa54ef6c777f0fcf3bae9d292c047d4b6/wrapt-2.2.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4250b43d1a129d947e083c4dc6baf333c9bb34edd26f912d5b0457841fc858ab", size = 166594, upload-time = "2026-06-20T23:48:57.617Z" }, + { url = "https://files.pythonhosted.org/packages/fb/5f/e1d7c6e4523f78db2fbd7826babd0348da1d5e0834c4f918b9ab5757dfae/wrapt-2.2.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:173e5bb5ca350a6e0abab60b7ec7cdd7992a814cb14b4de670a28f067f105663", size = 157068, upload-time = "2026-06-20T23:48:59.171Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c1/7ebd1027f00700c0b0233b20aceef2b4784294ed64971424c4a78e069e34/wrapt-2.2.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:aa14b01804bce36c6d63d7b6a4f55df390f29f8648cc13a1f40b166f4d54680d", size = 166470, upload-time = "2026-06-20T23:49:00.737Z" }, + { url = "https://files.pythonhosted.org/packages/99/eb/974e471a6a978b8180186b8a9dc5ae3361ce269a967190b709b8ce17abfb/wrapt-2.2.2-cp314-cp314-win32.whl", hash = "sha256:58f9f8d637c9a6e245c6ef5b109b67ec187d2faed23d1405656b51d96e0a5b56", size = 78062, upload-time = "2026-06-20T23:49:02.327Z" }, + { url = "https://files.pythonhosted.org/packages/49/ec/e1281156cdc7a66693838ad7a0865ad641c74abd337a957d668b575aaffb/wrapt-2.2.2-cp314-cp314-win_amd64.whl", hash = "sha256:385cb1866f20479e83299af585375bfa0a4b0c6c9907a981483ea782ea8ae406", size = 80832, upload-time = "2026-06-20T23:49:03.837Z" }, + { url = "https://files.pythonhosted.org/packages/45/7d/1b6b5ddd94005a2dac97a4490c9838f3154977850d633abcb65b30089437/wrapt-2.2.2-cp314-cp314-win_arm64.whl", hash = "sha256:8ffbeaea6771a6eba6e6eeb09767864995726bc8240bb54baf88a9bb1db34d5c", size = 80029, upload-time = "2026-06-20T23:49:05.237Z" }, + { url = "https://files.pythonhosted.org/packages/b0/33/9ebcf8aafe91c601127cbd93708c16aa8f688f34a10bf004046803ecdc4f/wrapt-2.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:09f811d43f6f33ec7515f0be76b159569f4057ab54d3e079c3204dddb90afa2a", size = 83357, upload-time = "2026-06-20T23:49:06.632Z" }, + { url = "https://files.pythonhosted.org/packages/39/38/ec45b635153327b52e52732a0ea980e5f00b7efba65f9e018828f1e69daa/wrapt-2.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a795d3c06e5fbf9ea2f13196180b77aeab1b4685917256ee0d014cc163d90063", size = 83794, upload-time = "2026-06-20T23:49:08.098Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ea/1a89e6d3b7a83c3affe5c09cde77792c947e63e4bc85ad84cd5bb9abb0d8/wrapt-2.2.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:45c2f2768e790c9f8db90f239ef23a2af8e7570f25a35619ef902df4a738447f", size = 203362, upload-time = "2026-06-20T23:49:09.811Z" }, + { url = "https://files.pythonhosted.org/packages/19/d8/3b58763d9863b5a73771c0d97110f9595d248db454009e07e1535ee905a4/wrapt-2.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bbf00ee0cb55ec24e2b0995a71942b85b21a066db8f3f46e1dbfdb9433ffba81", size = 210449, upload-time = "2026-06-20T23:49:11.521Z" }, + { url = "https://files.pythonhosted.org/packages/2d/6f/17fd9e053103d8be148d20d5d7505facc72d5fe1f9127973904ceaed79cf/wrapt-2.2.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2252f77663651b89255895f58cc6ac08fcb206d4371813e5af61bb62d4f7689c", size = 195349, upload-time = "2026-06-20T23:49:13.346Z" }, + { url = "https://files.pythonhosted.org/packages/ef/04/d0d1ccaaa12cb7dccf28a23f0279a608ba498f71e81d949d5ed54bcfd5c1/wrapt-2.2.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2cd7181ab1c31192ff5219269830744b5a62020b3a6d433588c4f1c95b8f8bff", size = 206099, upload-time = "2026-06-20T23:49:15.051Z" }, + { url = "https://files.pythonhosted.org/packages/44/b3/e8aa07b619890a2aa6cde1931b1887abb08820721b564a5f80b7ca3f3aa0/wrapt-2.2.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:6fe35fd51b74867d8b80174c277bd6bbf6a73e443f908129dc531c4b688a20d5", size = 192728, upload-time = "2026-06-20T23:49:16.854Z" }, + { url = "https://files.pythonhosted.org/packages/b7/f0/1819fb50f0d3c9bd758d8a83b56f1b470dee8b5b8eac8702b7c137cea9d4/wrapt-2.2.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:11d95fc2fbad3163596c39d440e6f21ca9fccece74b56e30a37ac2fca786a07c", size = 198842, upload-time = "2026-06-20T23:49:18.504Z" }, + { url = "https://files.pythonhosted.org/packages/67/7c/e88313f16a99930b899ef970d91c281544a470749a359decad994483bbda/wrapt-2.2.2-cp314-cp314t-win32.whl", hash = "sha256:d8a15813215f33fa83667bfc978b300e35669ea8bb424e970a1426bcb7bc6cca", size = 79059, upload-time = "2026-06-20T23:49:20.107Z" }, + { url = "https://files.pythonhosted.org/packages/a0/4f/ac12fda57a55068a094ec42851fb0a40e8489d8941863d517452de62e507/wrapt-2.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d09db0f7e8357060d3c38fc22a018aba683a796bf184360fd1a58f6fc180dc77", size = 82462, upload-time = "2026-06-20T23:49:21.631Z" }, + { url = "https://files.pythonhosted.org/packages/48/a7/df732dac86d9b2027c56bd163dbc883e037b16c3469614752e148d219c61/wrapt-2.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:f32fe639c39561ccc187bcae17e9271be0eb45f1c2952510d2f29b33ab577347", size = 81182, upload-time = "2026-06-20T23:49:23.199Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d2/6317eb6d4554855bbf12d61857774af34747bf88a42c19bf306de67e2fa3/wrapt-2.2.2-py3-none-any.whl", hash = "sha256:5bad217350f19ce99ca5b5e71d406765ea86fe541628426772b657375ee1c048", size = 61460, upload-time = "2026-06-20T23:49:42.966Z" }, +]