diff --git a/.github/scripts/deploy-dashboard.sh b/.github/scripts/deploy-dashboard.sh index 6d8cefbf..4eecb3a3 100644 --- a/.github/scripts/deploy-dashboard.sh +++ b/.github/scripts/deploy-dashboard.sh @@ -18,6 +18,24 @@ require_configuration() { fi } +check_read_secret_exists() { + # Only inspect the secret's metadata. Never access, compare, or print its value. + local missing=() + local name + for name in PROJECT_ID READ_SECRET_NAME; do + if [[ -z "${!name-}" ]]; then missing+=("$name"); fi + done + if (( ${#missing[@]} )); then + echo "Missing collector read secret configuration: ${missing[*]}" >&2 + return 1 + fi + if ! gcloud secrets describe "$READ_SECRET_NAME" \ + --project "$PROJECT_ID" --format=none; then + echo "Cannot confirm the existence of the collector read secret: ${READ_SECRET_NAME}" >&2 + return 1 + fi +} + install_clients() { uv venv --python 3.12 .deploy-venv uv pip install --python .deploy-venv/bin/python modal==1.5.5 @@ -61,6 +79,7 @@ deploy_frontend() { case "${1:-}" in require-configuration) require_configuration ;; + check-read-secret-exists) check_read_secret_exists ;; install-clients) install_clients ;; define-backend) define_backend ;; resolve-backend) resolve_backend ;; diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 1971fc14..b992c26e 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -66,7 +66,7 @@ jobs: - uses: google-github-actions/setup-gcloud@v2 - name: Check collector read secret exists - run: bash telemetry-service/scripts/check-secrets-exist.sh "$READ_SECRET_NAME" + run: bash .github/scripts/deploy-dashboard.sh check-read-secret-exists env: PROJECT_ID: ${{ vars.TELEMETRY_GCP_PROJECT_ID }} READ_SECRET_NAME: ${{ vars.TELEMETRY_READ_SECRET_NAME }} diff --git a/.github/workflows/telemetry-service.yml b/.github/workflows/telemetry-service.yml deleted file mode 100644 index 9a1e6872..00000000 --- a/.github/workflows/telemetry-service.yml +++ /dev/null @@ -1,284 +0,0 @@ -name: Telemetry service - -# PolicyEngine/microcosm-collector releases and deploys the collector from its -# own repo. This workflow no longer runs on push, so merging a change to -# telemetry-service/ deploys nothing. It still validates pull requests that -# change telemetry-service/, and it keeps one manual redeploy of main to -# staging and then production, for rolling the deploy-source switch back. -# -# Dispatch it from main, and only while the repo variable -# COLLECTOR_DEPLOY_ENABLED is unset in PolicyEngine/microcosm-collector and no -# microcosm-collector Push run is queued or in progress: both repos deploy the -# same Cloud Run services. The deployment jobs run only when the person -# dispatching checks collector_deploy_disabled to confirm this. A dispatch from -# another branch, without the confirmation, or while a microcosm-collector Push -# run is unfinished fails without deploying. -# Removing telemetry-service/ removes this workflow. -on: - pull_request: - paths: - - telemetry-service/** - - .github/workflows/telemetry-service.yml - workflow_dispatch: - inputs: - collector_deploy_disabled: - description: >- - COLLECTOR_DEPLOY_ENABLED is unset in PolicyEngine/microcosm-collector, - and no microcosm-collector deployment is running - type: boolean - required: true - default: false - -concurrency: - group: telemetry-service-${{ github.event_name == 'pull_request' && github.ref || 'production' }} - cancel-in-progress: false - -permissions: - contents: read - -jobs: - validate: - name: Test, lint, and build - runs-on: ubuntu-latest - env: - TEST_DATABASE_URL: postgresql://postgres:postgres@localhost:5432/telemetry_test - services: - postgres: - image: postgres:16-alpine - env: - POSTGRES_PASSWORD: postgres - POSTGRES_DB: telemetry_test - ports: - - 5432:5432 - options: >- - --health-cmd "pg_isready -U postgres -d telemetry_test" - --health-interval 5s - --health-timeout 5s - --health-retries 10 - defaults: - run: - working-directory: telemetry-service - steps: - - uses: actions/checkout@v4 - - uses: astral-sh/setup-uv@v6 - with: - python-version: "3.13" - - run: uv sync --frozen --dev - - run: uv run pytest -q - - run: DATABASE_URL="$TEST_DATABASE_URL" uv run python -m telemetry_collector.migrate - - run: DATABASE_URL="$TEST_DATABASE_URL" uv run alembic check - - run: uv run ruff check . - - run: uv run ruff format --check . - - run: docker build --tag microcosm-telemetry:validation . - - # Every dispatch runs this job, and deploy-staging needs it. It fails the run, - # rather than leaving a green run whose deployment jobs were skipped, unless - # the dispatch is from main with collector_deploy_disabled checked and no - # microcosm-collector Push run is queued or in progress. - require-collector-deploy-disabled: - name: Require the microcosm-collector deployment to be off - if: github.event_name == 'workflow_dispatch' - runs-on: ubuntu-latest - steps: - - name: Refuse an unconfirmed redeploy, or one from a branch other than main - if: github.ref != 'refs/heads/main' || !inputs.collector_deploy_disabled - run: echo "::error::Nothing was deployed. Unset COLLECTOR_DEPLOY_ENABLED in PolicyEngine/microcosm-collector, wait for its Push runs to finish, then dispatch again from main with collector_deploy_disabled checked." && exit 1 - - - uses: actions/checkout@v4 - - - name: Refuse while a microcosm-collector Push run is queued or in progress - env: - GH_TOKEN: ${{ github.token }} - run: telemetry-service/scripts/require-collector-push-runs-finished.sh - - deploy-staging: - name: Deploy image and migration in staging - if: >- - github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' - && inputs.collector_deploy_disabled - needs: [validate, require-collector-deploy-disabled] - runs-on: ubuntu-latest - timeout-minutes: 30 - environment: staging - permissions: - contents: read - id-token: write - outputs: - image_uri: ${{ steps.image.outputs.uri }} - env: - DEPLOYMENT_ENVIRONMENT: staging - PROJECT_ID: ${{ vars.TELEMETRY_GCP_PROJECT_ID }} - REGION: ${{ vars.TELEMETRY_GCP_REGION }} - ARTIFACT_REGISTRY_NAME: ${{ vars.TELEMETRY_ARTIFACT_REGISTRY_NAME }} - CLOUD_SQL_CONNECTION: ${{ vars.TELEMETRY_CLOUD_SQL_CONNECTION }} - RUNTIME_SERVICE_ACCOUNT: ${{ vars.TELEMETRY_RUNTIME_SERVICE_ACCOUNT }} - MIGRATION_SERVICE_ACCOUNT: ${{ vars.TELEMETRY_MIGRATION_SERVICE_ACCOUNT }} - SERVICE: ${{ vars.TELEMETRY_SERVICE }} - MIGRATION_JOB: ${{ vars.TELEMETRY_MIGRATION_JOB }} - IMAGE_NAME: ${{ vars.TELEMETRY_IMAGE_NAME }} - DATABASE_SECRET_NAME: ${{ vars.TELEMETRY_DATABASE_SECRET_NAME }} - MIGRATION_DATABASE_SECRET_NAME: ${{ vars.TELEMETRY_MIGRATION_DATABASE_SECRET_NAME }} - JWT_SECRET_NAME: ${{ vars.TELEMETRY_JWT_SECRET_NAME }} - READ_SECRET_NAME: ${{ vars.TELEMETRY_READ_SECRET_NAME }} - PRODUCTION_CLOUD_SQL_CONNECTION: ${{ vars.TELEMETRY_PRODUCTION_CLOUD_SQL_CONNECTION }} - PRODUCTION_SERVICE: ${{ vars.TELEMETRY_PRODUCTION_SERVICE }} - steps: - - uses: actions/checkout@v4 - - - name: Require deployment configuration - run: telemetry-service/scripts/deploy.sh require-configuration - - - name: Require an isolated staging target - run: telemetry-service/scripts/deploy.sh validate-environment-target - - - uses: google-github-actions/auth@v2 - with: - workload_identity_provider: ${{ secrets.TELEMETRY_GCP_WIF_PROVIDER }} - service_account: ${{ secrets.TELEMETRY_GCP_DEPLOY_SERVICE_ACCOUNT }} - - - uses: google-github-actions/setup-gcloud@v2 - - - name: Check required Secret Manager entries exist - run: telemetry-service/scripts/deploy.sh check-secrets-exist - - - name: Configure Artifact Registry authentication - run: gcloud auth configure-docker "${REGION}-docker.pkg.dev" --quiet - - - name: Build and push immutable image - id: image - run: telemetry-service/scripts/deploy.sh build-and-push-image - - - name: Deploy an isolated maintenance revision - run: telemetry-service/scripts/deploy.sh deploy-maintenance - env: - IMAGE_URI: ${{ steps.image.outputs.uri }} - - - name: Resolve and verify the maintenance revision - id: maintenance - run: telemetry-service/scripts/deploy.sh resolve-and-verify-maintenance - - - name: Route traffic to maintenance mode - run: telemetry-service/scripts/deploy.sh route-service-traffic - env: - REVISION: ${{ steps.maintenance.outputs.revision }} - - - name: Wait for old requests to finish - run: telemetry-service/scripts/deploy.sh wait-for-cutover - - - name: Configure the database migration job - run: telemetry-service/scripts/deploy.sh configure-migration-job - env: - IMAGE_URI: ${{ steps.image.outputs.uri }} - - - name: Apply database migrations - run: telemetry-service/scripts/deploy.sh apply-migrations - - - name: Deploy an isolated candidate revision - run: telemetry-service/scripts/deploy.sh deploy-candidate - env: - IMAGE_URI: ${{ steps.image.outputs.uri }} - - - name: Resolve and verify the candidate revision - id: candidate - run: telemetry-service/scripts/deploy.sh resolve-and-verify-candidate - - - name: Promote the verified revision - run: telemetry-service/scripts/deploy.sh route-service-traffic - env: - REVISION: ${{ steps.candidate.outputs.revision }} - - - name: Verify the stable service address - run: telemetry-service/scripts/deploy.sh verify-stable-service - - deploy-production: - name: Deploy staging image to production - if: >- - github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' - && inputs.collector_deploy_disabled - needs: deploy-staging - runs-on: ubuntu-latest - timeout-minutes: 45 - environment: Production - permissions: - contents: read - id-token: write - env: - DEPLOYMENT_ENVIRONMENT: production - PROJECT_ID: ${{ vars.TELEMETRY_GCP_PROJECT_ID }} - REGION: ${{ vars.TELEMETRY_GCP_REGION }} - ARTIFACT_REGISTRY_NAME: ${{ vars.TELEMETRY_ARTIFACT_REGISTRY_NAME }} - CLOUD_SQL_CONNECTION: ${{ vars.TELEMETRY_CLOUD_SQL_CONNECTION }} - RUNTIME_SERVICE_ACCOUNT: ${{ vars.TELEMETRY_RUNTIME_SERVICE_ACCOUNT }} - MIGRATION_SERVICE_ACCOUNT: ${{ vars.TELEMETRY_MIGRATION_SERVICE_ACCOUNT }} - SERVICE: ${{ vars.TELEMETRY_SERVICE }} - MIGRATION_JOB: ${{ vars.TELEMETRY_MIGRATION_JOB }} - IMAGE_NAME: ${{ vars.TELEMETRY_IMAGE_NAME }} - DATABASE_SECRET_NAME: ${{ vars.TELEMETRY_DATABASE_SECRET_NAME }} - MIGRATION_DATABASE_SECRET_NAME: ${{ vars.TELEMETRY_MIGRATION_DATABASE_SECRET_NAME }} - JWT_SECRET_NAME: ${{ vars.TELEMETRY_JWT_SECRET_NAME }} - READ_SECRET_NAME: ${{ vars.TELEMETRY_READ_SECRET_NAME }} - PRODUCTION_CLOUD_SQL_CONNECTION: ${{ vars.TELEMETRY_PRODUCTION_CLOUD_SQL_CONNECTION }} - PRODUCTION_SERVICE: ${{ vars.TELEMETRY_PRODUCTION_SERVICE }} - steps: - - uses: actions/checkout@v4 - - - name: Require deployment configuration - run: telemetry-service/scripts/deploy.sh require-configuration - - - name: Require the declared production target - run: telemetry-service/scripts/deploy.sh validate-environment-target - - - uses: google-github-actions/auth@v2 - with: - workload_identity_provider: ${{ secrets.TELEMETRY_GCP_WIF_PROVIDER }} - service_account: ${{ secrets.TELEMETRY_GCP_DEPLOY_SERVICE_ACCOUNT }} - - - uses: google-github-actions/setup-gcloud@v2 - - - name: Check required Secret Manager entries exist - run: telemetry-service/scripts/deploy.sh check-secrets-exist - - - name: Deploy an isolated maintenance revision - run: telemetry-service/scripts/deploy.sh deploy-maintenance - env: - IMAGE_URI: ${{ needs.deploy-staging.outputs.image_uri }} - - - name: Resolve and verify the maintenance revision - id: maintenance - run: telemetry-service/scripts/deploy.sh resolve-and-verify-maintenance - - - name: Route traffic to maintenance mode - run: telemetry-service/scripts/deploy.sh route-service-traffic - env: - REVISION: ${{ steps.maintenance.outputs.revision }} - - - name: Wait for old requests to finish - run: telemetry-service/scripts/deploy.sh wait-for-cutover - - - name: Back up the production database - run: telemetry-service/scripts/deploy.sh create-database-backup - - - name: Configure the database migration job - run: telemetry-service/scripts/deploy.sh configure-migration-job - env: - IMAGE_URI: ${{ needs.deploy-staging.outputs.image_uri }} - - - name: Apply database migrations - run: telemetry-service/scripts/deploy.sh apply-migrations - - - name: Deploy an isolated candidate revision - run: telemetry-service/scripts/deploy.sh deploy-candidate - env: - IMAGE_URI: ${{ needs.deploy-staging.outputs.image_uri }} - - - name: Resolve and verify the candidate revision - id: candidate - run: telemetry-service/scripts/deploy.sh resolve-and-verify-candidate - - - name: Promote the verified revision - run: telemetry-service/scripts/deploy.sh route-service-traffic - env: - REVISION: ${{ steps.candidate.outputs.revision }} - - - name: Verify the stable service address - run: telemetry-service/scripts/deploy.sh verify-stable-service diff --git a/docs/build-monitor.md b/docs/build-monitor.md index 57741c34..566d1d9b 100644 --- a/docs/build-monitor.md +++ b/docs/build-monitor.md @@ -12,9 +12,12 @@ checks stop builds. The run selected on either tab carries over to the other. socket; that service samples the build process tree, stores unsent events in a bounded SQLite retry queue, authenticates with the ambient Hugging Face credential, and delivers events to the hosted collector. The dashboard - server receives the collector address and its independent read credential - from the deployment environment. Neither value belongs in documentation or - browser-visible configuration. + server reads the collector address from its deployment settings, and + fetches its independent read credential from Google Secret Manager itself + (see [Reading the collector](#reading-the-collector)). Neither value belongs + in documentation or browser-visible configuration. The collector's source, + tests and deployment are in + [PolicyEngine/microcosm-collector](https://github.com/PolicyEngine/microcosm-collector). A missing credential, a user outside the PolicyEngine Hugging Face organization, or an unavailable network does not stop a build. Events remain @@ -42,6 +45,55 @@ checks stop builds. The run selected on either tab carries over to the other. checkpoint trees and hidden folders. Local runs are off unless the variable is set, so a hosted deployment never reads its own filesystem. +## Reading the collector + +The dashboard server sends the collector's read credential in the +`X-Telemetry-Read-Token` header of each query +(`frontend/lib/server/microcosm/telemetry-collector-client.ts`). The credential +is not a deployment setting. The server fetches it from a Google Secret Manager +secret version (`frontend/lib/server/secret-manager.ts`), keeps the value in +memory for up to one minute, and after a failed fetch tries again on the next +request. It never reaches the browser and never passes through GitHub Actions. + +The server reads three server-only, non-secret settings, each with a separate +value for production and preview: + +| Setting | Value | Where production and preview get it | +|---|---|---| +| `MICROCOSM_TELEMETRY_COLLECTOR_URL` | The matching environment's collector address. It must use HTTPS, except on `localhost` or `127.0.0.1`. | Production: the GitHub `Production` environment variable of the same name, which `.github/workflows/deploy.yml` passes to `vercel deploy --env`. Preview: the Vercel project's settings. | +| `MICROCOSM_TELEMETRY_READ_SECRET_VERSION` | The full Secret Manager version resource of that collector's read credential: `projects//secrets//versions/`. | The Vercel project's settings. | +| `MICROCOSM_TELEMETRY_GOOGLE_IDENTITY_PROVIDER` | The full Google Workload Identity provider resource that trusts this Vercel project: `projects//locations/global/workloadIdentityPools//providers/`. Read only on Vercel. | The Vercel project's settings. | + +Without the first two, the dashboard doesn't query the collector and shows only +the historical Hugging Face runs. If a collector request fails, the tab still +shows the Hugging Face runs, and lists `collector` with the error under "Runs +that could not be read". + +**On Vercel** (`VERCEL=1`), the server exchanges the deployment's Vercel OIDC +token for a Google access token through that provider, then reads the secret +version with it. This needs, once per environment and outside this repo: + +- a provider that trusts the Vercel project's OIDC issuer and audience; +- access for the production identity to only the production read secret, and + for the preview identity to only the staging read secret. Grant the access + permission (`secretmanager.versions.access`) on each secret, not on the + project. + +**Local development** uses Google Application Default Credentials (for example, +after `gcloud auth application-default login`), with the same +`MICROCOSM_TELEMETRY_COLLECTOR_URL` and +`MICROCOSM_TELEMETRY_READ_SECRET_VERSION`. Your Google account needs access to +the chosen secret. No copied collector token or service-account key is needed, +and `MICROCOSM_TELEMETRY_GOOGLE_IDENTITY_PROVIDER` is not read. + +**The production dashboard deploy** (`.github/workflows/deploy.yml`) checks only +that the read secret exists. `deploy-dashboard.sh check-read-secret-exists` +runs `gcloud secrets describe` for `TELEMETRY_READ_SECRET_NAME` in +`TELEMETRY_GCP_PROJECT_ID`, both variables of the GitHub `Production` +environment. That reads metadata, never the value, so it needs only +`secretmanager.secrets.get` on that secret. It doesn't show that the credential +works or that the Vercel identities can read it. + ## Testing without a build - **Replay a recorded run.** Any run folder (a build's local diff --git a/frontend/tests/test_collector_lives_in_its_own_repo.py b/frontend/tests/test_collector_lives_in_its_own_repo.py new file mode 100644 index 00000000..a854c0ca --- /dev/null +++ b/frontend/tests/test_collector_lives_in_its_own_repo.py @@ -0,0 +1,80 @@ +"""The collector's source, tests and deployment live in PolicyEngine/microcosm-collector. + +This repo only uses the deployed collector: the dashboard server reads from it, +and the production dashboard deploy checks that its read secret exists. The +directory the collector was developed in is removed, nothing here refers to it, +and no workflow here deploys the collector's Cloud Run service or migration job. +""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +GITHUB_DIRECTORY = ROOT / ".github" +# Built from parts, so that this file does not match its own search. +REMOVED_DIRECTORY = "telemetry" + "-service" +REMOVED_WORKFLOW = f".github/workflows/{REMOVED_DIRECTORY}.yml" +REMOVED_PATH_REFERENCES = (f"{REMOVED_DIRECTORY}/", f"{REMOVED_DIRECTORY}.yml") +COLLECTOR_DEPLOYMENT_MARKERS = ( + "vars.TELEMETRY_SERVICE", + "vars.TELEMETRY_PRODUCTION_SERVICE", + "vars.TELEMETRY_MIGRATION_JOB", + "vars.TELEMETRY_IMAGE_NAME", + "gcloud run deploy", + "gcloud run jobs", + "gcloud run services update-traffic", +) + + +def tracked_files() -> list[Path]: + listing = subprocess.run( + ["git", "ls-files", "-z"], cwd=ROOT, capture_output=True, check=True + ).stdout + return [ + ROOT / relative_path.decode() + for relative_path in listing.split(b"\0") + if relative_path and (ROOT / relative_path.decode()).is_file() + ] + + +def test_the_collector_directory_and_its_workflow_are_removed() -> None: + # Ask git rather than the filesystem: switching an existing clone to this + # commit leaves ignored files such as .venv/ and __pycache__/ on disk. + listing = subprocess.run( + ["git", "ls-files", "--", REMOVED_DIRECTORY, REMOVED_WORKFLOW], + cwd=ROOT, + capture_output=True, + text=True, + check=True, + ).stdout + + assert listing == "" + + +def test_no_tracked_file_refers_to_the_removed_collector_directory() -> None: + referring_files = [ + str(tracked_path.relative_to(ROOT)) + for tracked_path in tracked_files() + if any( + reference.encode() in tracked_path.read_bytes() + for reference in REMOVED_PATH_REFERENCES + ) + ] + + assert referring_files == [] + + +def test_no_workflow_or_workflow_script_deploys_the_collector() -> None: + deploying_files = [ + str(github_path.relative_to(ROOT)) + for github_path in sorted(GITHUB_DIRECTORY.rglob("*")) + if github_path.is_file() + and any( + marker in github_path.read_text(encoding="utf-8") + for marker in COLLECTOR_DEPLOYMENT_MARKERS + ) + ] + + assert deploying_files == [] diff --git a/frontend/tests/test_deploy_script.py b/frontend/tests/test_deploy_script.py index 19108903..9d34a110 100644 --- a/frontend/tests/test_deploy_script.py +++ b/frontend/tests/test_deploy_script.py @@ -7,6 +7,8 @@ import subprocess from pathlib import Path +import pytest + ROOT = Path(__file__).resolve().parents[2] SCRIPT = ROOT / ".github/scripts/deploy-dashboard.sh" CONFIGURATION = ( @@ -87,3 +89,88 @@ def test_frontend_receives_release_token_as_server_environment(tmp_path): assert arguments[arguments.index(token_argument) - 1] == "--env" assert not any("NEXT_PUBLIC_HF" in argument for argument in arguments) assert "test-HF_RELEASE_READ_TOKEN" not in result.stdout + result.stderr + + +READ_SECRET_METADATA_CALL = [ + "secrets", + "describe", + "test-read-secret", + "--project", + "test-project", + "--format=none", +] + + +def _read_secret_check(tmp_path, gcloud_exit_code=0, **configuration): + """Run check-read-secret-exists with a gcloud that records every call. + + Returns the script's result and the arguments of each gcloud call, in order. + """ + calls_file = tmp_path / "gcloud-calls.jsonl" + stub = tmp_path / "gcloud" + stub.write_text( + "#!/usr/bin/env python3\n" + "import json, os, sys\n" + "with open(os.environ['GCLOUD_CALLS_FILE'], 'a') as calls:\n" + " calls.write(json.dumps(sys.argv[1:]) + '\\n')\n" + f"sys.exit({gcloud_exit_code})\n" + ) + stub.chmod(0o755) + environment = { + "PATH": f"{tmp_path}:{os.environ['PATH']}", + "GCLOUD_CALLS_FILE": str(calls_file), + **configuration, + } + result = subprocess.run( + ["bash", str(SCRIPT), "check-read-secret-exists"], + env=environment, + capture_output=True, + text=True, + check=False, + ) + gcloud_calls = ( + [json.loads(call) for call in calls_file.read_text().splitlines()] + if calls_file.exists() + else [] + ) + return result, gcloud_calls + + +def test_read_secret_check_reads_only_the_secret_metadata(tmp_path): + result, gcloud_calls = _read_secret_check( + tmp_path, PROJECT_ID="test-project", READ_SECRET_NAME="test-read-secret" + ) + assert result.returncode == 0 + # Every call is recorded, so any other call, such as one that reads the + # secret's value with `gcloud secrets versions access`, fails this test. + assert gcloud_calls == [READ_SECRET_METADATA_CALL] + + +def test_missing_read_secret_stops_deployment(tmp_path): + result, gcloud_calls = _read_secret_check( + tmp_path, + gcloud_exit_code=1, + PROJECT_ID="test-project", + READ_SECRET_NAME="test-read-secret", + ) + assert result.returncode != 0 + assert "test-read-secret" in result.stderr + assert gcloud_calls == [READ_SECRET_METADATA_CALL] + + +@pytest.mark.parametrize("missing_name", ["PROJECT_ID", "READ_SECRET_NAME"]) +@pytest.mark.parametrize("missing_value", [None, ""], ids=["unset", "empty"]) +def test_read_secret_check_requires_its_configuration( + tmp_path, missing_name, missing_value +): + # GitHub Actions expands `${{ vars.NAME }}` to an empty string when the + # variable doesn't exist, so the deploy passes an empty value, not none. + configuration = {"PROJECT_ID": "test-project", "READ_SECRET_NAME": "test-secret"} + if missing_value is None: + configuration.pop(missing_name) + else: + configuration[missing_name] = missing_value + result, gcloud_calls = _read_secret_check(tmp_path, **configuration) + assert result.returncode != 0 + assert missing_name in result.stderr + assert gcloud_calls == [] diff --git a/frontend/tests/test_deploy_workflow.py b/frontend/tests/test_deploy_workflow.py index 321321d2..502f62c0 100644 --- a/frontend/tests/test_deploy_workflow.py +++ b/frontend/tests/test_deploy_workflow.py @@ -219,9 +219,8 @@ def test_dashboard_checks_read_secret_metadata_before_deployment(): if step.get("name") == "Check collector read secret exists" ) check = steps[check_index] - assert ( - check["run"] - == 'bash telemetry-service/scripts/check-secrets-exist.sh "$READ_SECRET_NAME"' + assert check["run"] == ( + "bash .github/scripts/deploy-dashboard.sh check-read-secret-exists" ) assert check["env"]["PROJECT_ID"] == "${{ vars.TELEMETRY_GCP_PROJECT_ID }}" assert check["env"]["READ_SECRET_NAME"] == "${{ vars.TELEMETRY_READ_SECRET_NAME }}" diff --git a/frontend/tests/test_telemetry_service_workflow.py b/frontend/tests/test_telemetry_service_workflow.py deleted file mode 100644 index b261f98f..00000000 --- a/frontend/tests/test_telemetry_service_workflow.py +++ /dev/null @@ -1,224 +0,0 @@ -"""PolicyEngine/microcosm-collector deploys the collector; this repo no longer does. - -`.github/workflows/telemetry-service.yml` still validates pull requests that -change `telemetry-service/`, and keeps one manual redeploy from `main` for -rolling the deploy-source switch back. Every dispatch first runs a job that -fails unless the dispatch is from `main`, the person dispatching it confirms -that microcosm-collector's deployment is switched off, and no -microcosm-collector Push run is queued or in progress. So the two repos never -deploy the same Cloud Run service at the same time, and a refused dispatch is -a failed run rather than a green run whose deployment jobs were skipped. -""" - -from __future__ import annotations - -import json -import os -import subprocess -from pathlib import Path - -import pytest -import yaml - -ROOT = Path(__file__).resolve().parents[2] -WORKFLOW_PATH = ROOT / ".github/workflows/telemetry-service.yml" -DEPLOY_JOBS = ("deploy-staging", "deploy-production") -CONFIRMATION_INPUT = "collector_deploy_disabled" -CONFIRMED_MANUAL_DISPATCH_ON_MAIN = ( - "github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main'" - f" && inputs.{CONFIRMATION_INPUT}" -) -MANUAL_DISPATCH = "github.event_name == 'workflow_dispatch'" -UNCONFIRMED_OR_NOT_FROM_MAIN = ( - f"github.ref != 'refs/heads/main' || !inputs.{CONFIRMATION_INPUT}" -) -CONFIRMATION_JOB = "require-collector-deploy-disabled" -COLLECTOR_PUSH_RUNS_ENDPOINT = ( - "repos/PolicyEngine/microcosm-collector/actions/workflows/push.yaml/runs" - "?per_page=100" -) -COLLECTOR_RUN_URL = "https://github.com/PolicyEngine/microcosm-collector/actions/runs" - - -def load_workflow() -> dict: - # BaseLoader keeps YAML's `on` key a string, as GitHub's parser does. - return yaml.load(WORKFLOW_PATH.read_text(encoding="utf-8"), Loader=yaml.BaseLoader) - - -def confirmation_steps() -> tuple[dict, dict]: - refusal_step, checkout_step, push_run_step = load_workflow()["jobs"][ - CONFIRMATION_JOB - ]["steps"] - assert checkout_step["uses"] == "actions/checkout@v4" - return refusal_step, push_run_step - - -def run_step_script( - step: dict, environment: dict[str, str] -) -> subprocess.CompletedProcess: - # GitHub runs a `run:` step without an explicit shell as `bash -e {0}`, - # from the repository root. - return subprocess.run( - ["bash", "-e", "-c", step["run"]], - cwd=ROOT, - env=environment, - capture_output=True, - text=True, - check=False, - ) - - -def collector_push_runs_response(*run_statuses: str) -> str: - return json.dumps( - { - "total_count": len(run_statuses), - "workflow_runs": [ - { - "id": 1000 + run_index, - "status": run_status, - "html_url": f"{COLLECTOR_RUN_URL}/{1000 + run_index}", - } - for run_index, run_status in enumerate(run_statuses) - ], - } - ) - - -def install_gh_stub(stub_directory: Path, response: str, exit_code: int = 0) -> Path: - """Put a `gh` on PATH that records its arguments and prints `response`.""" - recorded_call = stub_directory / "gh-call.json" - stub = stub_directory / "gh" - stub.write_text( - "#!/usr/bin/env python3\n" - "import json, os, sys\n" - f"with open({str(recorded_call)!r}, 'w') as recorded:\n" - " json.dump({'arguments': sys.argv[1:]," - " 'token': os.environ.get('GH_TOKEN')}, recorded)\n" - f"sys.stdout.write({response!r})\n" - f"sys.exit({exit_code})\n", - encoding="utf-8", - ) - stub.chmod(0o755) - return recorded_call - - -def stubbed_environment(stub_directory: Path) -> dict[str, str]: - return { - "PATH": f"{stub_directory}{os.pathsep}{os.environ['PATH']}", - "GH_TOKEN": "test-github-token", - } - - -def test_service_workflow_does_not_run_on_push() -> None: - workflow = load_workflow() - - assert set(workflow["on"]) == {"pull_request", "workflow_dispatch"} - - -def test_pull_requests_that_change_the_service_are_still_validated() -> None: - workflow = load_workflow() - - assert workflow["on"]["pull_request"]["paths"] == [ - "telemetry-service/**", - ".github/workflows/telemetry-service.yml", - ] - assert "if" not in workflow["jobs"]["validate"] - - -def test_manual_dispatch_requires_confirming_the_collector_deploy_is_off() -> None: - dispatch = load_workflow()["on"]["workflow_dispatch"] - confirmation = dispatch["inputs"][CONFIRMATION_INPUT] - - assert confirmation["type"] == "boolean" - assert confirmation["required"] == "true" - assert confirmation["default"] == "false" - assert "COLLECTOR_DEPLOY_ENABLED" in confirmation["description"] - assert "PolicyEngine/microcosm-collector" in confirmation["description"] - - -def test_deployment_runs_only_from_a_confirmed_manual_dispatch_on_main() -> None: - jobs = load_workflow()["jobs"] - - for job_name in DEPLOY_JOBS: - assert jobs[job_name]["if"] == CONFIRMED_MANUAL_DISPATCH_ON_MAIN, job_name - assert jobs["deploy-staging"]["needs"] == ["validate", CONFIRMATION_JOB] - assert jobs["deploy-production"]["needs"] == "deploy-staging" - oidc_jobs = { - job_name - for job_name, job in jobs.items() - if job.get("permissions", {}).get("id-token") == "write" - } - assert oidc_jobs == set(DEPLOY_JOBS) - - -def test_every_manual_dispatch_runs_the_confirmation_job_without_credentials() -> None: - job = load_workflow()["jobs"][CONFIRMATION_JOB] - - assert job["if"] == MANUAL_DISPATCH - assert "permissions" not in job - assert "environment" not in job - - -def test_dispatch_unconfirmed_or_from_another_branch_fails_without_deploying() -> None: - refusal_step, push_run_step = confirmation_steps() - - assert refusal_step["if"] == UNCONFIRMED_OR_NOT_FROM_MAIN - # The Push run check runs only if the refusal step was skipped. - assert "if" not in push_run_step - result = run_step_script(refusal_step, {"PATH": os.environ["PATH"]}) - assert result.returncode != 0 - output = result.stdout + result.stderr - assert "Nothing was deployed" in output - assert "main" in output - assert "COLLECTOR_DEPLOY_ENABLED" in output - assert "PolicyEngine/microcosm-collector" in output - - -@pytest.mark.parametrize("unfinished_status", ["queued", "in_progress", "waiting"]) -def test_confirmed_dispatch_fails_while_a_collector_push_run_is_unfinished( - tmp_path: Path, unfinished_status: str -) -> None: - _, push_run_step = confirmation_steps() - recorded_call = install_gh_stub( - tmp_path, collector_push_runs_response("completed", unfinished_status) - ) - - result = run_step_script(push_run_step, stubbed_environment(tmp_path)) - - assert result.returncode != 0 - output = result.stdout + result.stderr - assert "Nothing was deployed" in output - assert f"{COLLECTOR_RUN_URL}/1001" in output - assert f"{COLLECTOR_RUN_URL}/1000" not in output - assert json.loads(recorded_call.read_text(encoding="utf-8")) == { - "arguments": ["api", COLLECTOR_PUSH_RUNS_ENDPOINT], - "token": "test-github-token", - } - - -def test_confirmed_dispatch_continues_when_every_collector_push_run_is_finished( - tmp_path: Path, -) -> None: - _, push_run_step = confirmation_steps() - install_gh_stub(tmp_path, collector_push_runs_response("completed", "completed")) - - result = run_step_script(push_run_step, stubbed_environment(tmp_path)) - - assert result.returncode == 0, result.stdout + result.stderr - - -def test_confirmed_dispatch_fails_when_the_collector_push_runs_cannot_be_listed( - tmp_path: Path, -) -> None: - _, push_run_step = confirmation_steps() - install_gh_stub(tmp_path, "", exit_code=1) - - result = run_step_script(push_run_step, stubbed_environment(tmp_path)) - - assert result.returncode != 0 - - -def test_push_run_check_uses_the_workflow_token_for_a_public_api_read() -> None: - _, push_run_step = confirmation_steps() - - assert push_run_step["env"] == {"GH_TOKEN": "${{ github.token }}"} diff --git a/telemetry-service/.dockerignore b/telemetry-service/.dockerignore deleted file mode 100644 index b55170d0..00000000 --- a/telemetry-service/.dockerignore +++ /dev/null @@ -1,6 +0,0 @@ -.venv -.pytest_cache -.ruff_cache -**/__pycache__ -*.py[cod] -tests diff --git a/telemetry-service/.gcloudignore b/telemetry-service/.gcloudignore deleted file mode 100644 index 182f2e83..00000000 --- a/telemetry-service/.gcloudignore +++ /dev/null @@ -1,7 +0,0 @@ -.git -.venv -.pytest_cache -.ruff_cache -**/__pycache__ -*.py[cod] -tests diff --git a/telemetry-service/Dockerfile b/telemetry-service/Dockerfile deleted file mode 100644 index d68e8087..00000000 --- a/telemetry-service/Dockerfile +++ /dev/null @@ -1,17 +0,0 @@ -FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim - -ENV PYTHONDONTWRITEBYTECODE=1 \ - PYTHONUNBUFFERED=1 \ - UV_COMPILE_BYTECODE=1 \ - UV_LINK_MODE=copy - -WORKDIR /app -COPY pyproject.toml uv.lock ./ -RUN uv sync --frozen --no-dev --no-install-project -COPY alembic.ini ./ -COPY migrations ./migrations -COPY telemetry_collector ./telemetry_collector -RUN uv sync --frozen --no-dev - -EXPOSE 8080 -CMD ["uv", "run", "--no-sync", "uvicorn", "telemetry_collector.app:create_app_from_environment", "--factory", "--host", "0.0.0.0", "--port", "8080", "--no-access-log"] diff --git a/telemetry-service/README.md b/telemetry-service/README.md deleted file mode 100644 index 3bc3503e..00000000 --- a/telemetry-service/README.md +++ /dev/null @@ -1,168 +0,0 @@ -# Microcosm telemetry collector - -This service accepts live build telemetry from Microcosm and exposes a -read-only API to the calibration dashboard. It does not store dataset artifacts -or Hugging Face credentials. - -## Authentication - -Microcosm sends its ambient Hugging Face token only to -`POST /v1/auth/huggingface/exchange`. The collector validates that opaque token -against Hugging Face's `whoami-v2` endpoint and confirms current membership in -the `policyengine` organization. It then returns a short-lived collector JWT. -That credential registers runs and uploads events; the collector verifies that -the authenticated Hugging Face user owns the referenced run and that each -producer was registered first. The raw Hugging Face token is neither logged nor -written to Postgres. - -The dashboard query endpoints require `X-Telemetry-Read-Token`. The dashboard -server retrieves this credential from Google Secret Manager at runtime. Vercel -authenticates using its OIDC identity; the credential is never returned to the -browser or passed through GitHub Actions. - -## Responses a producer acts on - -Microcosm's delivery service (from PolicyEngine/microcosm#1147) treats any 4xx -other than 401, 408 and 429 as the collector's final answer for a run: it stops -delivering that run, keeps its telemetry on the build machine and warns once. -A 401 on registration or events makes it exchange its token again (a 401 or -403 from the token exchange itself is final); 408, 429, 5xx and the -maintenance revision's 503 are retried. So the collector answers 4xx only when -sending the same request again cannot succeed: - -- `403`: the Hugging Face user is not a member of the organization (token - exchange), the run belongs to another user, the producer was not registered - for it, or an event names a different run than its path. -- `404` on `POST /v1/runs/{run_id}/events` ("Run is not registered"): the - delivery service sends events only after its registration returned 201, so - this means the run disappeared after it registered, for example a database - reset while a build was running. That build's remaining telemetry stays local. -- `409` on `POST /v1/runs`: the run id is already registered with different - metadata. -- `413`: the request body is larger than 1 MiB. -- `422`: the request does not match the event schema, including a `blocked` - event without a valid block (a safe-identifier `phase`, a - `blocking_failure_count` of at least 1, and `blocking_gate_ids`). - -## Local development - -```bash -uv sync --dev -uv run python -m telemetry_collector.migrate -uv run uvicorn telemetry_collector.app:create_app_from_environment \ - --factory --host 127.0.0.1 --port 8080 --no-access-log -``` - -Before running the migration or service, provide the database connection, JWT -signing secret, and dashboard read credential through the documented runtime -environment variables. Use at least 32 random characters for the JWT signing -secret and at least 24 for the dashboard read credential. Use Microcosm's -loopback-only development option when testing ingestion. Production Microcosm -builds use the fixed collector origin shipped by the package and do not accept -a destination environment variable. - -## Cloud Run deployment - -The production service is isolated in this top-level directory. Its Docker -build context is `telemetry-service/`, so dashboard source files cannot enter -the image. Cloud Run assigns the production service address. Microcosm fixes -that origin in its delivery service; the address is intentionally not repeated -in documentation. - -Infrastructure was provisioned once with disposable scripts kept outside the -source tree. The source tree intentionally contains no bootstrap script or -mutable infrastructure template. - -PolicyEngine/microcosm-collector, where this history was extracted, now -releases and deploys the collector. Merging a change to this directory deploys -nothing: `.github/workflows/telemetry-service.yml` no longer runs on push. -Pull requests that change this directory or the workflow still run tests, -Ruff, and a container build. The workflow keeps one manual redeploy of `main`, -for rolling the deploy-source switch back. Dispatch it from `main`, only while -the repo variable `COLLECTOR_DEPLOY_ENABLED` is unset in microcosm-collector, -and check `collector_deploy_disabled` to confirm this. A dispatch from another -branch, without the confirmation, or while a microcosm-collector Push run is -queued or in progress (`scripts/require-collector-push-runs-finished.sh` lists -them) fails without deploying. - -A redeploy authenticates to Google Cloud with -GitHub OIDC and pushes one image identified by the commit SHA. It first applies -the migration to the persistent staging PostgreSQL database, verifies both the -Alembic revision and the complete SQLAlchemy metadata, and deploys the staging -collector. Production receives that exact image only when the staging sequence -succeeds. Before either environment changes, deployment checks only that its -runtime database, migration database, JWT signing, and read-token secret -resources exist. It does not access their payloads or test their values. The -previous HF authentication and write/read deployment qualification is removed. - -Each deployment first routes traffic to a verified maintenance revision that -returns HTTP 503 for data operations, then waits for prior requests to finish. -Production also creates a completed Cloud SQL backup before its migration. The -workflow runs the image as a one-task Cloud Run job to apply and verify Alembic -migrations, deploys a candidate without stable traffic, checks the candidate's -`/ready` endpoint, and only then directs traffic to the candidate. Once a -production migration begins, failures leave the maintenance revision serving -retryable responses; the workflow does not restore schema-incompatible -application code. Manual deployment runs are accepted only from `main`. - -The workflow reads resource identifiers from the GitHub `staging` and -`Production` environments. Runtime secrets remain in Google Secret Manager. -The migration job uses a separate service account and database credential with -schema-modification access; the collector runtime credential has only the data -access required by the application. The dashboard receives its collector -address and Secret Manager resource references through non-secret configuration. -Public ingress lets developer machines reach the service; -ingestion still requires a short-lived collector credential issued after -Hugging Face authentication. - -## Dashboard runtime access - -The Vercel project configures these server-only, non-secret settings separately -for production and preview: - -- `MICROCOSM_TELEMETRY_COLLECTOR_URL`: the matching environment's collector address. -- `MICROCOSM_TELEMETRY_READ_SECRET_VERSION`: the full Secret Manager version - resource for that collector's read credential. -- `MICROCOSM_TELEMETRY_GOOGLE_IDENTITY_PROVIDER`: the full Google workload - identity provider resource that trusts this Vercel project. - -The provider must trust the project's Vercel OIDC issuer and audience. Bind the -production identity to only the production read secret, and the preview identity -to only the staging read secret. Grant secret-access permission on those -individual resources. These identity bindings and Vercel settings require -one-time provisioning before this change is deployed; their provisioning scripts -stay outside the source tree. The GitHub deployment identity needs metadata read -permission on the required secrets for the existence checks. - -Local dashboard development uses Google Application Default Credentials and -the same collector address and secret-version settings. No copied collector -token or service-account key is required. The runtime caches a retrieved value -for up to one minute and retries failed retrievals on subsequent requests. -Configuration checks during deployment only confirm secret existence; they do -not establish that credentials work or that runtime permissions are correct. - -Restrict the Cloud SQL instance to the Cloud Run connection and disable request -access logs for the container so authorization headers cannot enter application -logs. Cloud Run and the HTTPS load balancer may retain request metadata, but the -service never includes credentials in paths, query strings, bodies, or errors. - -Application persistence uses SQLAlchemy ORM sessions and SQLAlchemy-managed -connection pooling. Psycopg is installed only as SQLAlchemy's PostgreSQL DBAPI -driver; application modules do not call it directly. - -Alembic owns the complete database schema. The service never creates or alters -tables during application startup, and production deployment never requires a -person to run database commands. Add a reviewed Alembic revision for every -schema change. The deployment job recognizes an empty database, an -Alembic-managed database, or the exact schema installed before Alembic was -introduced; it refuses to modify any other schema. Production deployment -upgrades an Alembic-managed database. For the one-time cutover in this change, -it recognizes and removes the unversioned prototype tables through SQLAlchemy -metadata before Alembic creates the initial ORM schema. This intentionally -discards prototype telemetry data. Downgrades are reserved for disposable test -databases. - -`/health` reports that the HTTP process is running. `/ready` additionally -requires a database connection and the current Alembic revision. The migration -job performs the stronger release-time comparison between the live schema and -the complete SQLAlchemy metadata. diff --git a/telemetry-service/alembic.ini b/telemetry-service/alembic.ini deleted file mode 100644 index 1f3d3e9b..00000000 --- a/telemetry-service/alembic.ini +++ /dev/null @@ -1,38 +0,0 @@ -[alembic] -script_location = migrations -prepend_sys_path = . -path_separator = os - -[loggers] -keys = root,sqlalchemy,alembic - -[handlers] -keys = console - -[formatters] -keys = generic - -[logger_root] -level = WARN -handlers = console -qualname = - -[logger_sqlalchemy] -level = WARN -handlers = -qualname = sqlalchemy.engine - -[logger_alembic] -level = INFO -handlers = -qualname = alembic - -[handler_console] -class = StreamHandler -args = (sys.stderr,) -level = NOTSET -formatter = generic - -[formatter_generic] -format = %(levelname)-5.5s [%(name)s] %(message)s -datefmt = %H:%M:%S diff --git a/telemetry-service/migrations/env.py b/telemetry-service/migrations/env.py deleted file mode 100644 index 31c6e384..00000000 --- a/telemetry-service/migrations/env.py +++ /dev/null @@ -1,75 +0,0 @@ -"""Alembic runtime configuration for the telemetry collector.""" - -from __future__ import annotations - -import os -from logging.config import fileConfig - -from alembic import context -from sqlalchemy import engine_from_config, pool - -from telemetry_collector.database import Base, normalize_database_url - -config = context.config -if config.config_file_name is not None: - fileConfig(config.config_file_name) - -target_metadata = Base.metadata - - -def _database_url() -> str: - value = os.environ.get("DATABASE_URL", "").strip() - if not value: - raise RuntimeError("DATABASE_URL is required to run migrations.") - return normalize_database_url(value).render_as_string(hide_password=False) - - -def run_migrations_offline() -> None: - """Run migrations without creating an Engine.""" - - context.configure( - url=_database_url(), - target_metadata=target_metadata, - literal_binds=True, - compare_type=True, - compare_server_default=True, - dialect_opts={"paramstyle": "named"}, - ) - with context.begin_transaction(): - context.run_migrations() - - -def _run_with_connection(connection: object) -> None: - context.configure( - connection=connection, - target_metadata=target_metadata, - compare_type=True, - compare_server_default=True, - ) - with context.begin_transaction(): - context.run_migrations() - - -def run_migrations_online() -> None: - """Run migrations with a caller-provided or locally-created connection.""" - - supplied_connection = config.attributes.get("connection") - if supplied_connection is not None: - _run_with_connection(supplied_connection) - return - - section = config.get_section(config.config_ini_section) or {} - section["sqlalchemy.url"] = _database_url() - connectable = engine_from_config( - section, - prefix="sqlalchemy.", - poolclass=pool.NullPool, - ) - with connectable.connect() as connection: - _run_with_connection(connection) - - -if context.is_offline_mode(): - run_migrations_offline() -else: - run_migrations_online() diff --git a/telemetry-service/migrations/script.py.mako b/telemetry-service/migrations/script.py.mako deleted file mode 100644 index cb520450..00000000 --- a/telemetry-service/migrations/script.py.mako +++ /dev/null @@ -1,25 +0,0 @@ -"""${message} - -Revision ID: ${up_revision} -Revises: ${down_revision | comma,n} -Create Date: ${create_date} -""" - -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa -${imports if imports else ""} - -revision: str = ${repr(up_revision)} -down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} -branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} -depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} - - -def upgrade() -> None: - ${upgrades if upgrades else "pass"} - - -def downgrade() -> None: - ${downgrades if downgrades else "pass"} diff --git a/telemetry-service/migrations/versions/0001_initial_schema.py b/telemetry-service/migrations/versions/0001_initial_schema.py deleted file mode 100644 index 4a138f27..00000000 --- a/telemetry-service/migrations/versions/0001_initial_schema.py +++ /dev/null @@ -1,133 +0,0 @@ -"""Create the SQLAlchemy ORM telemetry schema. - -Revision ID: 0001_initial_schema -Revises: None -""" - -from collections.abc import Sequence - -import sqlalchemy as sa -from alembic import op -from sqlalchemy.dialects import postgresql - -revision: str = "0001_initial_schema" -down_revision: str | Sequence[str] | None = None -branch_labels: str | Sequence[str] | None = None -depends_on: str | Sequence[str] | None = None - - -def upgrade() -> None: - """Create the complete collector schema.""" - - op.create_table( - "telemetry_runs", - sa.Column("run_id", sa.Text(), nullable=False), - sa.Column("country_code", sa.Text(), nullable=False), - sa.Column("pipeline", sa.Text(), nullable=False), - sa.Column("candidate_id", sa.Text()), - sa.Column("release_id", sa.Text()), - sa.Column("run_kind", sa.Text(), nullable=False), - sa.Column("owner_hf_id", sa.Text(), nullable=False), - sa.Column("owner_hf_username", sa.Text(), nullable=False), - sa.Column("status", sa.Text(), nullable=False), - sa.Column("current_stage", sa.Text(), nullable=False), - sa.Column( - "registered_at", - sa.DateTime(timezone=True), - server_default=sa.func.current_timestamp(), - nullable=False, - ), - sa.Column("started_at", sa.DateTime(timezone=True), nullable=False), - sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), - sa.Column("ended_at", sa.DateTime(timezone=True)), - sa.Column("heartbeat_at", sa.DateTime(timezone=True)), - sa.Column("resources", postgresql.JSONB()), - sa.Column("work", postgresql.JSONB()), - sa.Column("failure", postgresql.JSONB()), - sa.PrimaryKeyConstraint("run_id", name="pk_telemetry_runs"), - ) - op.create_index( - "telemetry_runs_country_registered_idx", - "telemetry_runs", - ["country_code", sa.column("registered_at").desc(), sa.column("run_id").desc()], - ) - op.create_table( - "telemetry_producers", - sa.Column("run_id", sa.Text(), nullable=False), - sa.Column("producer_id", sa.Text(), nullable=False), - sa.Column( - "registered_at", - sa.DateTime(timezone=True), - server_default=sa.func.current_timestamp(), - nullable=False, - ), - sa.ForeignKeyConstraint( - ["run_id"], - ["telemetry_runs.run_id"], - name="fk_telemetry_producers_run_id_telemetry_runs", - ondelete="CASCADE", - ), - sa.PrimaryKeyConstraint("run_id", "producer_id", name="pk_telemetry_producers"), - ) - op.create_index( - "telemetry_producers_run_registered_idx", - "telemetry_producers", - ["run_id", "registered_at", "producer_id"], - ) - op.create_table( - "telemetry_events", - sa.Column("event_id", sa.Text(), nullable=False), - sa.Column("run_id", sa.Text(), nullable=False), - sa.Column("producer_id", sa.Text(), nullable=False), - sa.Column("sequence", sa.BigInteger(), nullable=False), - sa.Column("emitted_at", sa.DateTime(timezone=True), nullable=False), - sa.Column( - "received_at", - sa.DateTime(timezone=True), - server_default=sa.func.current_timestamp(), - nullable=False, - ), - sa.Column("event_type", sa.Text(), nullable=False), - sa.Column("stage_id", sa.Text()), - sa.Column("status", sa.Text(), nullable=False), - sa.Column("message", sa.Text()), - sa.Column( - "details", - postgresql.JSONB(), - server_default="{}", - nullable=False, - ), - sa.Column("resources", postgresql.JSONB()), - sa.ForeignKeyConstraint( - ["run_id"], - ["telemetry_runs.run_id"], - name="fk_telemetry_events_run_id_telemetry_runs", - ondelete="CASCADE", - ), - sa.ForeignKeyConstraint( - ["run_id", "producer_id"], - ["telemetry_producers.run_id", "telemetry_producers.producer_id"], - name="fk_telemetry_events_run_id_producer_id_telemetry_producers", - ondelete="CASCADE", - ), - sa.PrimaryKeyConstraint("event_id", name="pk_telemetry_events"), - sa.UniqueConstraint( - "run_id", - "producer_id", - "sequence", - name="telemetry_events_run_id_producer_id_sequence_key", - ), - ) - op.create_index( - "telemetry_events_run_order_idx", - "telemetry_events", - ["run_id", "producer_id", "sequence"], - ) - - -def downgrade() -> None: - """Remove the collector schema.""" - - op.drop_table("telemetry_events") - op.drop_table("telemetry_producers") - op.drop_table("telemetry_runs") diff --git a/telemetry-service/pyproject.toml b/telemetry-service/pyproject.toml deleted file mode 100644 index 10714974..00000000 --- a/telemetry-service/pyproject.toml +++ /dev/null @@ -1,38 +0,0 @@ -[project] -name = "policyengine-telemetry-collector" -version = "0.1.0" -description = "Authenticated ingestion and query API for Microcosm build telemetry" -requires-python = ">=3.13" -dependencies = [ - "alembic>=1.16,<2", - "fastapi>=0.115,<1", - "httpx>=0.28,<1", - "psycopg[binary]>=3.2,<4", - "pyjwt>=2.10,<3", - "sqlalchemy>=2.0,<3", - "uvicorn[standard]>=0.34,<1", -] - -[dependency-groups] -dev = ["pytest>=8.3,<9", "ruff>=0.11,<1"] - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.hatch.build.targets.wheel] -packages = ["telemetry_collector"] - -[tool.pytest.ini_options] -testpaths = ["tests"] - -[tool.ruff] -line-length = 88 -target-version = "py313" - -[tool.ruff.lint] -select = ["E", "F", "I", "N", "W", "UP", "B"] -ignore = ["E501"] - -[tool.ruff.lint.per-file-ignores] -"telemetry_collector/app.py" = ["B008"] diff --git a/telemetry-service/scripts/check-secrets-exist.sh b/telemetry-service/scripts/check-secrets-exist.sh deleted file mode 100644 index cfa21585..00000000 --- a/telemetry-service/scripts/check-secrets-exist.sh +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env bash - -set -Eeuo pipefail - -: "${PROJECT_ID:?PROJECT_ID is required}" -if (( $# == 0 )); then - echo "At least one secret name is required." >&2 - exit 1 -fi - -# Only inspect resource metadata. Never access, compare, or print secret values. -failed=0 -for secret_name in "$@"; do - if [[ -z "$secret_name" ]]; then - echo "An empty secret name was supplied." >&2 - failed=1 - elif ! gcloud secrets describe "$secret_name" \ - --project "$PROJECT_ID" --format=none; then - echo "Cannot confirm the existence of secret: ${secret_name}" >&2 - failed=1 - fi -done -exit "$failed" diff --git a/telemetry-service/scripts/deploy.sh b/telemetry-service/scripts/deploy.sh deleted file mode 100755 index 00d8fc0e..00000000 --- a/telemetry-service/scripts/deploy.sh +++ /dev/null @@ -1,390 +0,0 @@ -#!/usr/bin/env bash - -set -Eeuo pipefail - -require_variables() { - local missing=() - local name - - for name in "$@"; do - if [[ -z "${!name-}" ]]; then - missing+=("$name") - fi - done - - if (( ${#missing[@]} )); then - echo "Missing telemetry deployment configuration: ${missing[*]}" >&2 - return 1 - fi -} - -require_configuration() { - require_variables \ - PROJECT_ID \ - REGION \ - ARTIFACT_REGISTRY_NAME \ - CLOUD_SQL_CONNECTION \ - RUNTIME_SERVICE_ACCOUNT \ - MIGRATION_SERVICE_ACCOUNT \ - SERVICE \ - MIGRATION_JOB \ - IMAGE_NAME \ - DATABASE_SECRET_NAME \ - MIGRATION_DATABASE_SECRET_NAME \ - JWT_SECRET_NAME \ - READ_SECRET_NAME \ - DEPLOYMENT_ENVIRONMENT \ - PRODUCTION_CLOUD_SQL_CONNECTION \ - PRODUCTION_SERVICE -} - -validate_environment_target() { - require_variables \ - DEPLOYMENT_ENVIRONMENT \ - CLOUD_SQL_CONNECTION \ - PRODUCTION_CLOUD_SQL_CONNECTION \ - SERVICE \ - PRODUCTION_SERVICE - - case "$DEPLOYMENT_ENVIRONMENT" in - staging) - if [[ "$CLOUD_SQL_CONNECTION" == "$PRODUCTION_CLOUD_SQL_CONNECTION" ]]; then - echo "Staging Cloud SQL target matches production." >&2 - return 1 - fi - if [[ "$SERVICE" == "$PRODUCTION_SERVICE" ]]; then - echo "Staging Cloud Run service matches production." >&2 - return 1 - fi - ;; - production) - if [[ "$CLOUD_SQL_CONNECTION" != "$PRODUCTION_CLOUD_SQL_CONNECTION" ]]; then - echo "Production Cloud SQL target does not match its declared resource." >&2 - return 1 - fi - if [[ "$SERVICE" != "$PRODUCTION_SERVICE" ]]; then - echo "Production Cloud Run service does not match its declared resource." >&2 - return 1 - fi - ;; - *) - echo "Unsupported deployment environment: ${DEPLOYMENT_ENVIRONMENT}" >&2 - return 1 - ;; - esac -} - -check_secrets_exist() { - require_variables \ - PROJECT_ID DATABASE_SECRET_NAME MIGRATION_DATABASE_SECRET_NAME \ - JWT_SECRET_NAME READ_SECRET_NAME - - bash "$(dirname "$0")/check-secrets-exist.sh" \ - "$DATABASE_SECRET_NAME" "$MIGRATION_DATABASE_SECRET_NAME" \ - "$JWT_SECRET_NAME" "$READ_SECRET_NAME" -} - -build_and_push_image() { - require_variables \ - REGION \ - PROJECT_ID \ - ARTIFACT_REGISTRY_NAME \ - IMAGE_NAME \ - GITHUB_SHA \ - GITHUB_OUTPUT - - local image - image="${REGION}-docker.pkg.dev/${PROJECT_ID}/${ARTIFACT_REGISTRY_NAME}/${IMAGE_NAME}:${GITHUB_SHA}" - docker build \ - --file telemetry-service/Dockerfile \ - --tag "$image" \ - telemetry-service - docker push "$image" - printf 'uri=%s\n' "$image" >> "$GITHUB_OUTPUT" -} - -configure_migration_job() { - require_variables \ - MIGRATION_JOB \ - PROJECT_ID \ - REGION \ - IMAGE_URI \ - MIGRATION_SERVICE_ACCOUNT \ - CLOUD_SQL_CONNECTION \ - MIGRATION_DATABASE_SECRET_NAME - - gcloud run jobs deploy "$MIGRATION_JOB" \ - --project "$PROJECT_ID" \ - --region "$REGION" \ - --image "$IMAGE_URI" \ - --service-account "$MIGRATION_SERVICE_ACCOUNT" \ - --set-cloudsql-instances "$CLOUD_SQL_CONNECTION" \ - --set-secrets "DATABASE_URL=${MIGRATION_DATABASE_SECRET_NAME}:latest" \ - --command uv \ - --args "run,--no-sync,python,-m,telemetry_collector.migrate" \ - --tasks 1 \ - --max-retries 0 \ - --task-timeout 10m \ - --quiet -} - -apply_migrations() { - require_variables MIGRATION_JOB PROJECT_ID REGION - - gcloud run jobs execute "$MIGRATION_JOB" \ - --project "$PROJECT_ID" \ - --region "$REGION" \ - --wait \ - --quiet -} - -create_database_backup() { - require_variables \ - PROJECT_ID \ - CLOUD_SQL_CONNECTION \ - GITHUB_RUN_ID \ - GITHUB_RUN_ATTEMPT - - local connection_project - local connection_region - local instance - local remainder - IFS=: read -r connection_project connection_region instance remainder \ - <<< "$CLOUD_SQL_CONNECTION" - if [[ -z "$connection_project" || -z "$connection_region" || -z "$instance" || -n "${remainder-}" ]]; then - echo "CLOUD_SQL_CONNECTION must be project:region:instance." >&2 - return 1 - fi - if [[ "$connection_project" != "$PROJECT_ID" ]]; then - echo "Cloud SQL connection project does not match PROJECT_ID." >&2 - return 1 - fi - - gcloud sql backups create \ - --project "$PROJECT_ID" \ - --instance "$instance" \ - --description "telemetry-release-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" \ - --quiet -} - -deploy_maintenance() { - require_variables \ - SERVICE \ - PROJECT_ID \ - REGION \ - IMAGE_URI \ - RUNTIME_SERVICE_ACCOUNT \ - CLOUD_SQL_CONNECTION \ - DATABASE_SECRET_NAME \ - JWT_SECRET_NAME \ - READ_SECRET_NAME - - gcloud run deploy "$SERVICE" \ - --project "$PROJECT_ID" \ - --region "$REGION" \ - --platform managed \ - --image "$IMAGE_URI" \ - --service-account "$RUNTIME_SERVICE_ACCOUNT" \ - --add-cloudsql-instances "$CLOUD_SQL_CONNECTION" \ - --set-secrets "DATABASE_URL=${DATABASE_SECRET_NAME}:latest,TELEMETRY_JWT_SECRET=${JWT_SECRET_NAME}:latest,TELEMETRY_READ_TOKEN=${READ_SECRET_NAME}:latest" \ - --set-env-vars "TELEMETRY_MAINTENANCE_MODE=1" \ - --min 1 \ - --max 4 \ - --cpu 1 \ - --memory 512Mi \ - --concurrency 40 \ - --timeout 30 \ - --allow-unauthenticated \ - --default-url \ - --startup-probe "httpGet.path=/health,httpGet.port=8080,timeoutSeconds=10,periodSeconds=10,failureThreshold=12" \ - --no-traffic \ - --tag maintenance \ - --quiet -} - -resolve_and_verify_maintenance() { - require_variables SERVICE PROJECT_ID REGION GITHUB_OUTPUT - - local revision - local service_json - local status_code - local url - service_json="$( - gcloud run services describe "$SERVICE" \ - --project "$PROJECT_ID" \ - --region "$REGION" \ - --format=json - )" - revision="$(jq -er '.status.latestCreatedRevisionName' <<< "$service_json")" - url="$( - jq -er ' - .status.traffic[] - | select(.tag == "maintenance") - | .url - ' <<< "$service_json" - )" - curl --fail --show-error --silent \ - --retry 12 --retry-all-errors --retry-delay 5 \ - "${url}/health" - status_code="$( - curl --show-error --silent \ - --output /dev/null \ - --write-out '%{http_code}' \ - --request POST \ - "${url}/v1/auth/huggingface/exchange" - )" - if [[ "$status_code" != "503" ]]; then - echo "Maintenance revision accepted a data request with HTTP ${status_code}." >&2 - return 1 - fi - printf 'revision=%s\n' "$revision" >> "$GITHUB_OUTPUT" -} - -wait_for_cutover() { - sleep 35 -} - -deploy_candidate() { - require_variables \ - SERVICE \ - PROJECT_ID \ - REGION \ - IMAGE_URI \ - RUNTIME_SERVICE_ACCOUNT \ - CLOUD_SQL_CONNECTION \ - DATABASE_SECRET_NAME \ - JWT_SECRET_NAME \ - READ_SECRET_NAME - - gcloud run deploy "$SERVICE" \ - --project "$PROJECT_ID" \ - --region "$REGION" \ - --platform managed \ - --image "$IMAGE_URI" \ - --service-account "$RUNTIME_SERVICE_ACCOUNT" \ - --add-cloudsql-instances "$CLOUD_SQL_CONNECTION" \ - --set-secrets "DATABASE_URL=${DATABASE_SECRET_NAME}:latest,TELEMETRY_JWT_SECRET=${JWT_SECRET_NAME}:latest,TELEMETRY_READ_TOKEN=${READ_SECRET_NAME}:latest" \ - --set-env-vars "TELEMETRY_MAINTENANCE_MODE=0" \ - --min 1 \ - --max 4 \ - --cpu 1 \ - --memory 512Mi \ - --concurrency 40 \ - --timeout 30 \ - --allow-unauthenticated \ - --default-url \ - --startup-probe "httpGet.path=/health,httpGet.port=8080,timeoutSeconds=10,periodSeconds=10,failureThreshold=12" \ - --no-traffic \ - --tag candidate \ - --quiet -} - -resolve_and_verify_candidate() { - require_variables SERVICE PROJECT_ID REGION GITHUB_OUTPUT - - local revision - local service_json - local url - service_json="$( - gcloud run services describe "$SERVICE" \ - --project "$PROJECT_ID" \ - --region "$REGION" \ - --format=json - )" - revision="$(jq -er '.status.latestCreatedRevisionName' <<< "$service_json")" - url="$( - jq -er ' - .status.traffic[] - | select(.tag == "candidate") - | .url - ' <<< "$service_json" - )" - curl --fail --show-error --silent \ - --retry 12 --retry-all-errors --retry-delay 5 \ - "${url}/ready" - { - printf 'revision=%s\n' "$revision" - printf 'url=%s\n' "$url" - } >> "$GITHUB_OUTPUT" -} - -route_service_traffic() { - require_variables SERVICE PROJECT_ID REGION REVISION - - # REVISION is a required workflow environment variable, not the local - # revision used by other commands in this script. - # shellcheck disable=SC2153 - gcloud run services update-traffic "$SERVICE" \ - --project "$PROJECT_ID" \ - --region "$REGION" \ - --to-revisions "${REVISION}=100" \ - --quiet -} - -verify_stable_service() { - require_variables SERVICE PROJECT_ID REGION - - local stable_url - stable_url="$( - gcloud run services describe "$SERVICE" \ - --project "$PROJECT_ID" \ - --region "$REGION" \ - --format='value(status.url)' - )" - curl --fail --show-error --silent \ - --retry 12 --retry-all-errors --retry-delay 5 \ - "${stable_url}/ready" -} - -usage() { - cat >&2 <<'EOF' -Usage: deploy.sh COMMAND - -Commands: - require-configuration - validate-environment-target - check-secrets-exist - build-and-push-image - deploy-maintenance - resolve-and-verify-maintenance - wait-for-cutover - configure-migration-job - apply-migrations - create-database-backup - deploy-candidate - resolve-and-verify-candidate - route-service-traffic - verify-stable-service -EOF -} - -main() { - if (( $# != 1 )); then - usage - return 2 - fi - - case "$1" in - require-configuration) require_configuration ;; - validate-environment-target) validate_environment_target ;; - check-secrets-exist) check_secrets_exist ;; - build-and-push-image) build_and_push_image ;; - deploy-maintenance) deploy_maintenance ;; - resolve-and-verify-maintenance) resolve_and_verify_maintenance ;; - wait-for-cutover) wait_for_cutover ;; - configure-migration-job) configure_migration_job ;; - apply-migrations) apply_migrations ;; - create-database-backup) create_database_backup ;; - deploy-candidate) deploy_candidate ;; - resolve-and-verify-candidate) resolve_and_verify_candidate ;; - route-service-traffic) route_service_traffic ;; - verify-stable-service) verify_stable_service ;; - *) - echo "Unknown deployment command: $1" >&2 - usage - return 2 - ;; - esac -} - -main "$@" diff --git a/telemetry-service/scripts/require-collector-push-runs-finished.sh b/telemetry-service/scripts/require-collector-push-runs-finished.sh deleted file mode 100755 index 56fa83a1..00000000 --- a/telemetry-service/scripts/require-collector-push-runs-finished.sh +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env bash -# Fail while any PolicyEngine/microcosm-collector Push run is unfinished. -# -# A Push run on a release commit reads COLLECTOR_DEPLOY_ENABLED only when it -# reaches its deployment jobs, after verify and tag, and a deployment that has -# started keeps going if the variable is unset later. So a redeploy from this -# repo waits until no Push run is queued, waiting or in progress. The -# microcosm-collector repo is public, so this workflow's own token can list its -# runs; it cannot read that repo's variables. -set -Eeuo pipefail - -readonly COLLECTOR_PUSH_RUNS_ENDPOINT="repos/PolicyEngine/microcosm-collector/actions/workflows/push.yaml/runs?per_page=100" - -collector_push_runs=$(gh api "$COLLECTOR_PUSH_RUNS_ENDPOINT") -unfinished_run_urls=$(jq -r '[.workflow_runs[] | select(.status != "completed") | .html_url] | join(" ")' <<<"$collector_push_runs") - -if [[ -n "$unfinished_run_urls" ]]; then - echo "::error::Nothing was deployed. These PolicyEngine/microcosm-collector Push runs are not finished, and one may still deploy: ${unfinished_run_urls}. Check that COLLECTOR_DEPLOY_ENABLED is unset there, wait for the runs to finish, then dispatch again." - exit 1 -fi -echo "No PolicyEngine/microcosm-collector Push run is queued or in progress." diff --git a/telemetry-service/telemetry_collector/__init__.py b/telemetry-service/telemetry_collector/__init__.py deleted file mode 100644 index ed75edd9..00000000 --- a/telemetry-service/telemetry_collector/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""PolicyEngine's hosted Microcosm telemetry collector.""" diff --git a/telemetry-service/telemetry_collector/app.py b/telemetry-service/telemetry_collector/app.py deleted file mode 100644 index a4fa7177..00000000 --- a/telemetry-service/telemetry_collector/app.py +++ /dev/null @@ -1,462 +0,0 @@ -"""FastAPI application for authenticated Microcosm telemetry ingestion.""" - -from __future__ import annotations - -import base64 -import binascii -import hmac -import json -import os -from collections.abc import Iterator -from contextlib import asynccontextmanager -from dataclasses import dataclass -from datetime import UTC, datetime, timedelta -from typing import Annotated, Any - -import jwt -from fastapi import Depends, FastAPI, Header, HTTPException, Query, status -from fastapi.responses import JSONResponse -from jwt import InvalidTokenError -from pydantic import ValidationError -from starlette.types import ASGIApp, Message, Receive, Scope, Send - -from telemetry_collector.auth import ( - HuggingFaceAuthenticationError, - HuggingFaceAuthenticationUnavailableError, - HuggingFaceAuthenticator, - HuggingFacePrincipal, -) -from telemetry_collector.models import ( - CollectorToken, - EventBatch, - PageCursor, - RunRegistration, -) -from telemetry_collector.storage import ( - PostgresTelemetryStore, - RunRegistrationConflictError, - TelemetryStore, -) - - -@dataclass(frozen=True) -class CollectorSettings: - """Secrets and policy read once when the collector starts.""" - - jwt_secret: str - read_token: str - required_huggingface_org: str = "policyengine" - collector_issuer: str = "policyengine-telemetry" - collector_audience: str = "microcosm-telemetry-collector" - session_token_ttl_seconds: int = 60 * 60 - maintenance_mode: bool = False - - @classmethod - def from_environment(cls) -> CollectorSettings: - jwt_secret = _required_environment("TELEMETRY_JWT_SECRET") - read_token = _required_environment("TELEMETRY_READ_TOKEN") - if len(jwt_secret) < 32: - raise RuntimeError( - "TELEMETRY_JWT_SECRET must contain at least 32 characters." - ) - if len(read_token) < 24: - raise RuntimeError( - "TELEMETRY_READ_TOKEN must contain at least 24 characters." - ) - return cls( - jwt_secret=jwt_secret, - read_token=read_token, - required_huggingface_org=os.environ.get("TELEMETRY_HF_ORG", "policyengine"), - collector_issuer=os.environ.get( - "TELEMETRY_JWT_ISSUER", "policyengine-telemetry" - ), - collector_audience=os.environ.get( - "TELEMETRY_JWT_AUDIENCE", "microcosm-telemetry-collector" - ), - session_token_ttl_seconds=int( - os.environ.get("TELEMETRY_SESSION_TOKEN_TTL_SECONDS", "3600") - ), - maintenance_mode=os.environ.get("TELEMETRY_MAINTENANCE_MODE") == "1", - ) - - -@dataclass(frozen=True) -class SessionTokenClaims: - """PolicyEngine identity carried by a short-lived collector token.""" - - subject: str - username: str - organizations: tuple[str, ...] - - -class RequestBodyLimitMiddleware: - """Reject oversized fixed-length and streamed request bodies.""" - - def __init__(self, app: ASGIApp, *, max_bytes: int) -> None: - self.app = app - self.max_bytes = max_bytes - - async def __call__( - self, - scope: Scope, - receive: Receive, - send: Send, - ) -> None: - if scope["type"] != "http": - await self.app(scope, receive, send) - return - headers = {key.lower(): value for key, value in scope.get("headers", [])} - declared = headers.get(b"content-length") - if declared is not None: - try: - too_large = int(declared) > self.max_bytes - except ValueError: - too_large = True - if too_large: - await self._reject(scope, receive, send) - return - - body = bytearray() - more_body = True - while more_body: - message = await receive() - if message["type"] != "http.request": - await self.app(scope, receive, send) - return - body.extend(message.get("body", b"")) - if len(body) > self.max_bytes: - await self._reject(scope, receive, send) - return - more_body = bool(message.get("more_body", False)) - - replayed = False - - async def replay_receive() -> Message: - nonlocal replayed - if not replayed: - replayed = True - return { - "type": "http.request", - "body": bytes(body), - "more_body": False, - } - return await receive() - - await self.app(scope, replay_receive, send) - - @staticmethod - async def _reject(scope: Scope, receive: Receive, send: Send) -> None: - response = JSONResponse( - status_code=413, - content={"detail": "Request body exceeds 1 MiB."}, - ) - await response(scope, receive, send) - - -class MaintenanceModeMiddleware: - """Keep health checks available while rejecting all data operations.""" - - def __init__(self, app: ASGIApp) -> None: - self.app = app - - async def __call__( - self, - scope: Scope, - receive: Receive, - send: Send, - ) -> None: - if scope["type"] != "http" or scope.get("path") == "/health": - await self.app(scope, receive, send) - return - response = JSONResponse( - status_code=503, - content={"detail": "Telemetry service is temporarily unavailable."}, - headers={"Retry-After": "60"}, - ) - await response(scope, receive, send) - - -def _required_environment(name: str) -> str: - value = os.environ.get(name, "").strip() - if not value: - raise RuntimeError(f"{name} is required.") - return value - - -def _bearer_token(value: str | None) -> str: - if not value: - raise HTTPException( - status_code=401, detail="Bearer authentication is required." - ) - scheme, separator, token = value.partition(" ") - if separator != " " or scheme.lower() != "bearer" or not token.strip(): - raise HTTPException( - status_code=401, detail="Bearer authentication is required." - ) - return token.strip() - - -def _page_cursor(registered_at: datetime, run_id: str) -> str: - payload = json.dumps( - [registered_at.isoformat(), run_id], separators=(",", ":") - ).encode() - return base64.urlsafe_b64encode(payload).decode().rstrip("=") - - -def _parse_page_cursor(value: str | None) -> tuple[datetime, str] | None: - if value is None: - return None - try: - padded = value + "=" * (-len(value) % 4) - raw = base64.b64decode(padded, altchars=b"-_", validate=True) - decoded = json.loads(raw.decode()) - timestamp, run_id = PageCursor.model_validate(decoded).root - except ( - ValueError, - TypeError, - UnicodeDecodeError, - binascii.Error, - ValidationError, - ) as error: - raise HTTPException( - status_code=422, detail="Pagination cursor is invalid." - ) from error - return timestamp, run_id - - -def _issue_session_token( - settings: CollectorSettings, - principal: HuggingFacePrincipal, -) -> CollectorToken: - now = datetime.now(UTC) - expires = now + timedelta(seconds=settings.session_token_ttl_seconds) - encoded = jwt.encode( - { - "iss": settings.collector_issuer, - "aud": settings.collector_audience, - "sub": principal.user_id, - "username": principal.username, - "organizations": list(principal.organizations), - "scope": "telemetry:write", - "iat": now, - "exp": expires, - }, - settings.jwt_secret, - algorithm="HS256", - ) - return CollectorToken( - access_token=encoded, - expires_in=settings.session_token_ttl_seconds, - ) - - -def _decode_token( - settings: CollectorSettings, authorization: str | None -) -> dict[str, Any]: - token = _bearer_token(authorization) - try: - return jwt.decode( - token, - settings.jwt_secret, - algorithms=["HS256"], - issuer=settings.collector_issuer, - audience=settings.collector_audience, - options={"require": ["aud", "exp", "iat", "iss", "scope", "sub"]}, - ) - except InvalidTokenError as error: - raise HTTPException( - status_code=401, detail="Collector credential is invalid." - ) from error - - -def _decode_session_token( - settings: CollectorSettings, authorization: str | None -) -> SessionTokenClaims: - payload = _decode_token(settings, authorization) - if payload.get("scope") != "telemetry:write": - raise HTTPException( - status_code=403, - detail="Collector credential cannot write telemetry.", - ) - subject = payload.get("sub") - username = payload.get("username") - organizations = payload.get("organizations") - if ( - not isinstance(subject, str) - or not subject - or not isinstance(username, str) - or not username - or not isinstance(organizations, list) - or not all(isinstance(item, str) and item for item in organizations) - ): - raise HTTPException( - status_code=401, detail="Collector credential is incomplete." - ) - return SessionTokenClaims( - subject=subject, - username=username, - organizations=tuple(organizations), - ) - - -def create_app( - *, - settings: CollectorSettings, - store: TelemetryStore, - huggingface_authenticator: HuggingFaceAuthenticator, -) -> FastAPI: - """Create the collector with explicit dependencies for tests and deployment.""" - - @asynccontextmanager - async def lifespan(_: FastAPI) -> Iterator[None]: - try: - yield - finally: - close = getattr(store, "close", None) - if callable(close): - close() - - application = FastAPI( - title="PolicyEngine Microcosm telemetry collector", - version="1.0.0", - lifespan=lifespan, - ) - - application.add_middleware(RequestBodyLimitMiddleware, max_bytes=1_048_576) - if settings.maintenance_mode: - application.add_middleware(MaintenanceModeMiddleware) - - def require_read_token( - token: Annotated[str | None, Header(alias="X-Telemetry-Read-Token")] = None, - ) -> None: - if token is None or not hmac.compare_digest(token, settings.read_token): - raise HTTPException( - status_code=401, - detail="A valid dashboard read credential is required.", - ) - - def session_claims( - authorization: Annotated[str | None, Header()] = None, - ) -> SessionTokenClaims: - return _decode_session_token(settings, authorization) - - @application.get("/health") - def health() -> dict[str, str]: - return {"status": "ok"} - - @application.get("/ready") - def ready() -> dict[str, str]: - if not store.is_ready(): - raise HTTPException( - status_code=503, - detail="Telemetry database is unavailable or not fully migrated.", - ) - return {"status": "ok"} - - @application.post( - "/v1/auth/huggingface/exchange", - response_model=CollectorToken, - ) - def exchange_huggingface_token( - authorization: Annotated[str | None, Header()] = None, - ) -> CollectorToken: - opaque_token = _bearer_token(authorization) - try: - principal = huggingface_authenticator.authenticate( - opaque_token, - settings.required_huggingface_org, - ) - except HuggingFaceAuthenticationUnavailableError as error: - raise HTTPException(status_code=503, detail=str(error)) from error - except HuggingFaceAuthenticationError as error: - raise HTTPException(status_code=401, detail=str(error)) from error - if settings.required_huggingface_org not in principal.organizations: - raise HTTPException( - status_code=403, - detail="The Hugging Face user is not a PolicyEngine organization member.", - ) - return _issue_session_token(settings, principal) - - @application.post("/v1/runs", status_code=status.HTTP_201_CREATED) - def register_run( - registration: RunRegistration, - claims: SessionTokenClaims = Depends(session_claims), - ) -> dict[str, bool]: - principal = HuggingFacePrincipal( - user_id=claims.subject, - username=claims.username, - organizations=claims.organizations, - ) - try: - store.register_run(registration, principal) - except PermissionError as error: - raise HTTPException(status_code=403, detail=str(error)) from error - except RunRegistrationConflictError as error: - raise HTTPException(status_code=409, detail=str(error)) from error - return {"registered": True} - - @application.post( - "/v1/runs/{run_id}/events", - status_code=status.HTTP_202_ACCEPTED, - ) - def ingest_events( - run_id: str, - batch: EventBatch, - claims: SessionTokenClaims = Depends(session_claims), - ) -> dict[str, int]: - for event in batch.events: - if event.run_id != run_id: - raise HTTPException( - status_code=403, - detail="Event identity does not match the request path.", - ) - try: - accepted, duplicates = store.append_events( - run_id, claims.subject, batch.events - ) - except PermissionError as error: - raise HTTPException(status_code=403, detail=str(error)) from error - except KeyError as error: - raise HTTPException( - status_code=404, detail="Run is not registered." - ) from error - return {"accepted": accepted, "duplicates": duplicates} - - @application.get("/v1/runs", dependencies=[Depends(require_read_token)]) - def list_runs( - country: Annotated[str | None, Query(pattern=r"^[A-Z]{2}$")] = None, - limit: Annotated[int, Query(ge=1, le=200)] = 60, - before: str | None = None, - ) -> dict[str, Any]: - runs = store.list_runs( - country=country, - limit=limit, - before=_parse_page_cursor(before), - ) - next_before = ( - _page_cursor(runs[-1]["registered_at"], runs[-1]["run_id"]) - if len(runs) == limit - else None - ) - return {"runs": runs, "next_before": next_before} - - @application.get("/v1/runs/{run_id}", dependencies=[Depends(require_read_token)]) - def get_run(run_id: str) -> dict[str, Any]: - run = store.get_run(run_id) - if run is None: - raise HTTPException(status_code=404, detail="Run was not found.") - return run - - return application - - -def create_app_from_environment() -> FastAPI: - """Create the production application from Cloud Run environment values.""" - - settings = CollectorSettings.from_environment() - store = PostgresTelemetryStore(_required_environment("DATABASE_URL")) - return create_app( - settings=settings, - store=store, - huggingface_authenticator=HuggingFaceAuthenticator(), - ) diff --git a/telemetry-service/telemetry_collector/auth.py b/telemetry-service/telemetry_collector/auth.py deleted file mode 100644 index a8b05db9..00000000 --- a/telemetry-service/telemetry_collector/auth.py +++ /dev/null @@ -1,97 +0,0 @@ -"""Hugging Face identity validation for collector token exchange.""" - -from __future__ import annotations - -from dataclasses import dataclass - -import httpx - - -class HuggingFaceAuthenticationError(RuntimeError): - """The presented Hugging Face credential could not be authenticated.""" - - -class HuggingFaceAuthenticationUnavailableError(RuntimeError): - """Hugging Face could not currently verify a credential.""" - - -@dataclass(frozen=True) -class HuggingFacePrincipal: - """Identity returned by Hugging Face for one bearer credential.""" - - user_id: str - username: str - organizations: tuple[str, ...] - - -class HuggingFaceAuthenticator: - """Validate opaque Hugging Face tokens using the live whoami endpoint.""" - - def __init__( - self, - *, - endpoint: str = "https://huggingface.co/api/whoami-v2", - timeout_seconds: float = 10.0, - ) -> None: - self._endpoint = endpoint - self._timeout_seconds = timeout_seconds - - def authenticate(self, token: str, required_org: str) -> HuggingFacePrincipal: - try: - response = httpx.get( - self._endpoint, - headers={"Authorization": f"Bearer {token}"}, - timeout=self._timeout_seconds, - follow_redirects=False, - ) - except httpx.HTTPError as error: - raise HuggingFaceAuthenticationUnavailableError( - "Hugging Face identity verification is unavailable." - ) from error - if response.status_code in {401, 403}: - raise HuggingFaceAuthenticationError( - "The Hugging Face credential is invalid or expired." - ) - if response.status_code != 200: - raise HuggingFaceAuthenticationUnavailableError( - "Hugging Face identity verification failed." - ) - try: - payload = response.json() - except ValueError as error: - raise HuggingFaceAuthenticationUnavailableError( - "Hugging Face returned an invalid identity response." - ) from error - if not isinstance(payload, dict): - raise HuggingFaceAuthenticationUnavailableError( - "Hugging Face returned an invalid identity response." - ) - user_id = payload.get("id") - username = payload.get("name") - if not isinstance(user_id, str) or not user_id: - raise HuggingFaceAuthenticationUnavailableError( - "Hugging Face did not identify the credential owner." - ) - if not isinstance(username, str) or not username: - raise HuggingFaceAuthenticationUnavailableError( - "Hugging Face did not identify the credential owner." - ) - organizations = tuple( - name - for entry in payload.get("orgs", []) - if (name := _organization_name(entry)) is not None - ) - return HuggingFacePrincipal( - user_id=user_id, - username=username, - organizations=organizations, - ) - - -def _organization_name(value: object) -> str | None: - if isinstance(value, str) and value: - return value - if isinstance(value, dict): - name = value.get("name") - return name if isinstance(name, str) and name else None - return None diff --git a/telemetry-service/telemetry_collector/database.py b/telemetry-service/telemetry_collector/database.py deleted file mode 100644 index 657559db..00000000 --- a/telemetry-service/telemetry_collector/database.py +++ /dev/null @@ -1,208 +0,0 @@ -"""SQLAlchemy engine, session, and ORM models for collector persistence.""" - -from __future__ import annotations - -from datetime import datetime -from typing import Any - -from sqlalchemy import ( - BigInteger, - DateTime, - ForeignKey, - ForeignKeyConstraint, - Index, - MetaData, - Text, - UniqueConstraint, - create_engine, -) -from sqlalchemy.dialects.postgresql import JSONB -from sqlalchemy.engine import URL, Engine, make_url -from sqlalchemy.orm import ( - DeclarativeBase, - Mapped, - mapped_column, - relationship, - sessionmaker, -) -from sqlalchemy.sql import func - -DB_POOL_SIZE = 2 -DB_MAX_OVERFLOW = 3 -DB_POOL_TIMEOUT_SECONDS = 10 -DB_POOL_RECYCLE_SECONDS = 300 - -NAMING_CONVENTION = { - "ix": "ix_%(table_name)s_%(column_0_N_name)s", - "uq": "uq_%(table_name)s_%(column_0_N_name)s", - "ck": "ck_%(table_name)s_%(constraint_name)s", - "fk": "fk_%(table_name)s_%(column_0_N_name)s_%(referred_table_name)s", - "pk": "pk_%(table_name)s", -} - - -class Base(DeclarativeBase): - """Declarative base shared by every collector ORM model.""" - - metadata = MetaData(naming_convention=NAMING_CONVENTION) - - -class TelemetryRun(Base): - """Registered build and its materialized current telemetry state.""" - - __tablename__ = "telemetry_runs" - - run_id: Mapped[str] = mapped_column(Text, primary_key=True) - country_code: Mapped[str] = mapped_column(Text, nullable=False) - pipeline: Mapped[str] = mapped_column(Text, nullable=False) - candidate_id: Mapped[str | None] = mapped_column(Text) - release_id: Mapped[str | None] = mapped_column(Text) - run_kind: Mapped[str] = mapped_column(Text, nullable=False) - owner_hf_id: Mapped[str] = mapped_column(Text, nullable=False) - owner_hf_username: Mapped[str] = mapped_column(Text, nullable=False) - status: Mapped[str] = mapped_column(Text, nullable=False) - current_stage: Mapped[str] = mapped_column(Text, nullable=False) - registered_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), server_default=func.current_timestamp(), nullable=False - ) - started_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), nullable=False - ) - updated_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), nullable=False - ) - ended_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) - heartbeat_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) - resources: Mapped[dict[str, Any] | None] = mapped_column(JSONB) - work: Mapped[dict[str, Any] | None] = mapped_column(JSONB) - failure: Mapped[dict[str, Any] | None] = mapped_column(JSONB) - - producers: Mapped[list[TelemetryProducer]] = relationship( - back_populates="run", - cascade="all, delete-orphan", - passive_deletes=True, - ) - - -class TelemetryProducer(Base): - """One telemetry-emitting process registered for a build.""" - - __tablename__ = "telemetry_producers" - - run_id: Mapped[str] = mapped_column( - Text, - ForeignKey( - "telemetry_runs.run_id", - name="fk_telemetry_producers_run_id_telemetry_runs", - ondelete="CASCADE", - ), - primary_key=True, - ) - producer_id: Mapped[str] = mapped_column(Text, primary_key=True) - registered_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), server_default=func.current_timestamp(), nullable=False - ) - - run: Mapped[TelemetryRun] = relationship(back_populates="producers") - events: Mapped[list[TelemetryEvent]] = relationship( - back_populates="producer", - cascade="all, delete-orphan", - passive_deletes=True, - ) - - -class TelemetryEvent(Base): - """An idempotent telemetry event belonging to a registered producer.""" - - __tablename__ = "telemetry_events" - __table_args__ = ( - ForeignKeyConstraint( - ["run_id", "producer_id"], - ["telemetry_producers.run_id", "telemetry_producers.producer_id"], - name="fk_telemetry_events_run_id_producer_id_telemetry_producers", - ondelete="CASCADE", - ), - UniqueConstraint( - "run_id", - "producer_id", - "sequence", - name="telemetry_events_run_id_producer_id_sequence_key", - ), - ) - - event_id: Mapped[str] = mapped_column(Text, primary_key=True) - run_id: Mapped[str] = mapped_column( - Text, - ForeignKey( - "telemetry_runs.run_id", - name="fk_telemetry_events_run_id_telemetry_runs", - ondelete="CASCADE", - ), - nullable=False, - ) - producer_id: Mapped[str] = mapped_column(Text, nullable=False) - sequence: Mapped[int] = mapped_column(BigInteger, nullable=False) - emitted_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), nullable=False - ) - received_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), server_default=func.current_timestamp(), nullable=False - ) - event_type: Mapped[str] = mapped_column(Text, nullable=False) - stage_id: Mapped[str | None] = mapped_column(Text) - status: Mapped[str] = mapped_column(Text, nullable=False) - message: Mapped[str | None] = mapped_column(Text) - details: Mapped[dict[str, Any]] = mapped_column( - JSONB, default=dict, server_default="{}", nullable=False - ) - resources: Mapped[dict[str, Any] | None] = mapped_column(JSONB) - - producer: Mapped[TelemetryProducer] = relationship(back_populates="events") - - -Index( - "telemetry_runs_country_registered_idx", - TelemetryRun.country_code, - TelemetryRun.registered_at.desc(), - TelemetryRun.run_id.desc(), -) -Index( - "telemetry_events_run_order_idx", - TelemetryEvent.run_id, - TelemetryEvent.producer_id, - TelemetryEvent.sequence, -) -Index( - "telemetry_producers_run_registered_idx", - TelemetryProducer.run_id, - TelemetryProducer.registered_at, - TelemetryProducer.producer_id, -) - - -def normalize_database_url(database_url: str) -> URL: - """Return a PostgreSQL URL using SQLAlchemy's synchronous Psycopg dialect.""" - - url = make_url(database_url) - if url.get_backend_name() != "postgresql": - raise ValueError("DATABASE_URL must identify a PostgreSQL database.") - return url.set(drivername="postgresql+psycopg") - - -def create_database_engine(database_url: str) -> Engine: - """Create the collector's bounded, health-checked SQLAlchemy engine.""" - - return create_engine( - normalize_database_url(database_url), - pool_size=DB_POOL_SIZE, - max_overflow=DB_MAX_OVERFLOW, - pool_timeout=DB_POOL_TIMEOUT_SECONDS, - pool_pre_ping=True, - pool_recycle=DB_POOL_RECYCLE_SECONDS, - ) - - -def create_session_factory(engine: Engine) -> sessionmaker: - """Create the short-lived session factory used by the store.""" - - return sessionmaker(bind=engine, expire_on_commit=False) diff --git a/telemetry-service/telemetry_collector/migrate.py b/telemetry-service/telemetry_collector/migrate.py deleted file mode 100644 index 70e5b7d8..00000000 --- a/telemetry-service/telemetry_collector/migrate.py +++ /dev/null @@ -1,232 +0,0 @@ -"""Automated Alembic migration entry point for Cloud Run deployment.""" - -from __future__ import annotations - -import os -from enum import StrEnum -from pathlib import Path -from typing import Any - -import sqlalchemy as sa -from alembic import command -from alembic.autogenerate import compare_metadata -from alembic.config import Config -from alembic.migration import MigrationContext -from alembic.script import ScriptDirectory -from sqlalchemy import inspect -from sqlalchemy.dialects.postgresql import JSONB -from sqlalchemy.engine import Connection, Engine -from sqlalchemy.engine.reflection import Inspector - -from telemetry_collector.database import Base, create_database_engine - -SERVICE_ROOT = Path(__file__).resolve().parents[1] -ALEMBIC_INI = SERVICE_ROOT / "alembic.ini" -MIGRATIONS = SERVICE_ROOT / "migrations" -MIGRATION_LOCK_NAME = "microcosm-telemetry-alembic" - - -class SchemaState(StrEnum): - """Recognized database states before an automated migration.""" - - EMPTY = "empty" - LEGACY = "legacy" - VERSIONED = "versioned" - UNEXPECTED = "unexpected" - - -LEGACY_COLUMNS: dict[str, dict[str, tuple[type[sa.types.TypeEngine[Any]], bool]]] = { - "telemetry_runs": { - "run_id": (sa.Text, False), - "country_code": (sa.Text, False), - "pipeline": (sa.Text, False), - "candidate_id": (sa.Text, True), - "release_id": (sa.Text, True), - "run_kind": (sa.Text, False), - "owner_hf_id": (sa.Text, False), - "owner_hf_username": (sa.Text, False), - "status": (sa.Text, False), - "current_stage": (sa.Text, False), - "started_at": (sa.DateTime, False), - "updated_at": (sa.DateTime, False), - "ended_at": (sa.DateTime, True), - "heartbeat_at": (sa.DateTime, True), - "resources": (JSONB, True), - "work": (JSONB, True), - "failure": (JSONB, True), - }, - "telemetry_events": { - "event_id": (sa.Text, False), - "run_id": (sa.Text, False), - "producer_id": (sa.Text, False), - "sequence": (sa.BigInteger, False), - "emitted_at": (sa.DateTime, False), - "received_at": (sa.DateTime, False), - "event_type": (sa.Text, False), - "stage_id": (sa.Text, True), - "status": (sa.Text, False), - "message": (sa.Text, True), - "details": (JSONB, False), - "resources": (JSONB, True), - }, -} - - -def _columns_match(inspector: Inspector, table_name: str) -> bool: - expected = LEGACY_COLUMNS[table_name] - actual = {column["name"]: column for column in inspector.get_columns(table_name)} - if set(actual) != set(expected): - return False - return all( - isinstance(actual[name]["type"], type_class) - and actual[name]["nullable"] is nullable - for name, (type_class, nullable) in expected.items() - ) - - -def classify_schema(inspector: Inspector) -> SchemaState: - """Classify a database without changing it.""" - - tables = set(inspector.get_table_names()) - if "alembic_version" in tables: - return SchemaState.VERSIONED - telemetry_tables = {table for table in tables if table.startswith("telemetry_")} - if not telemetry_tables: - return SchemaState.EMPTY - if telemetry_tables != set(LEGACY_COLUMNS): - return SchemaState.UNEXPECTED - if not all(_columns_match(inspector, table) for table in LEGACY_COLUMNS): - return SchemaState.UNEXPECTED - - run_primary_key = inspector.get_pk_constraint("telemetry_runs") - event_primary_key = inspector.get_pk_constraint("telemetry_events") - if run_primary_key.get("constrained_columns") != ["run_id"]: - return SchemaState.UNEXPECTED - if event_primary_key.get("constrained_columns") != ["event_id"]: - return SchemaState.UNEXPECTED - - event_uniques = { - tuple(constraint["column_names"]) - for constraint in inspector.get_unique_constraints("telemetry_events") - } - if ("run_id", "producer_id", "sequence") not in event_uniques: - return SchemaState.UNEXPECTED - event_foreign_keys = { - ( - tuple(constraint["constrained_columns"]), - constraint["referred_table"], - tuple(constraint["referred_columns"]), - ) - for constraint in inspector.get_foreign_keys("telemetry_events") - } - if (("run_id",), "telemetry_runs", ("run_id",)) not in event_foreign_keys: - return SchemaState.UNEXPECTED - - run_indexes = { - (index["name"], tuple(index["column_names"])) - for index in inspector.get_indexes("telemetry_runs") - } - event_indexes = { - (index["name"], tuple(index["column_names"])) - for index in inspector.get_indexes("telemetry_events") - } - if ( - "telemetry_runs_country_updated_idx", - ("country_code", "updated_at"), - ) not in run_indexes: - return SchemaState.UNEXPECTED - if ( - "telemetry_events_run_order_idx", - ("run_id", "emitted_at", "producer_id", "sequence"), - ) not in event_indexes: - return SchemaState.UNEXPECTED - return SchemaState.LEGACY - - -def alembic_config() -> Config: - """Return Alembic configuration independent of the current directory.""" - - config = Config(str(ALEMBIC_INI)) - config.set_main_option("script_location", str(MIGRATIONS)) - return config - - -def _verify_connection(connection: Connection, config: Config) -> None: - expected_heads = set(ScriptDirectory.from_config(config).get_heads()) - current_heads = set(MigrationContext.configure(connection).get_current_heads()) - if not current_heads or current_heads != expected_heads: - raise RuntimeError( - "Database is not at the complete Alembic head after migration." - ) - context = MigrationContext.configure( - connection, - opts={"compare_type": True, "compare_server_default": True}, - ) - differences = compare_metadata(context, Base.metadata) - if differences: - raise RuntimeError( - "Database schema differs from SQLAlchemy metadata after migration " - f"({len(differences)} difference(s))." - ) - - -def verify_database(database_url: str) -> None: - """Require a reachable database at Alembic head with no ORM schema drift.""" - - engine = create_database_engine(database_url) - try: - with engine.connect() as connection: - _verify_connection(connection, alembic_config()) - finally: - engine.dispose() - - -def upgrade_database(database_url: str) -> SchemaState: - """Replace the unversioned prototype schema, then upgrade to Alembic head.""" - - engine: Engine = create_database_engine(database_url) - try: - config = alembic_config() - with engine.begin() as connection: - connection.execute( - sa.text("SELECT pg_advisory_xact_lock(hashtext(:name), 0)"), - {"name": MIGRATION_LOCK_NAME}, - ) - state = classify_schema(inspect(connection)) - if state is SchemaState.UNEXPECTED: - raise RuntimeError( - "Database schema does not match an empty, legacy, or " - "Alembic-managed state." - ) - config.attributes["connection"] = connection - if state is SchemaState.LEGACY: - Base.metadata.drop_all(connection) - command.upgrade(config, "head") - _verify_connection(connection, config) - return state - finally: - engine.dispose() - - -def database_revision_is_current(engine: Engine) -> bool: - """Return whether the database is reachable and at the Alembic head.""" - - config = alembic_config() - expected = ScriptDirectory.from_config(config).get_current_head() - with engine.connect() as connection: - connection.execute(sa.select(1)) - current = MigrationContext.configure(connection).get_current_revision() - return current is not None and current == expected - - -def main() -> None: - """Upgrade the database named by DATABASE_URL.""" - - database_url = os.environ.get("DATABASE_URL", "").strip() - if not database_url: - raise RuntimeError("DATABASE_URL is required to run migrations.") - upgrade_database(database_url) - - -if __name__ == "__main__": - main() diff --git a/telemetry-service/telemetry_collector/models.py b/telemetry-service/telemetry_collector/models.py deleted file mode 100644 index 193b6281..00000000 --- a/telemetry-service/telemetry_collector/models.py +++ /dev/null @@ -1,127 +0,0 @@ -"""Validated collector request and storage models.""" - -from __future__ import annotations - -import re -from datetime import datetime -from typing import Any, Literal - -from pydantic import ( - BaseModel, - ConfigDict, - Field, - RootModel, - field_validator, - model_validator, -) - -SAFE_IDENTIFIER = r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,191}$" - - -def _require_block(details: dict[str, Any]) -> None: - """A ``blocked`` event carries the refusal as staging contract v3 defines a - block: the gate phase, at least one blocking failure, and the gate ids - (possibly none). ``gate_statuses`` and other details are optional.""" - - phase = details.get("phase") - count = details.get("blocking_failure_count") - gate_ids = details.get("blocking_gate_ids") - if not isinstance(phase, str) or not re.fullmatch(SAFE_IDENTIFIER, phase): - raise ValueError("a blocked event's phase must be a safe identifier") - if isinstance(count, bool) or not isinstance(count, int) or count < 1: - raise ValueError( - "a blocked event's blocking_failure_count must be an integer of at least 1" - ) - if not isinstance(gate_ids, list) or any( - not isinstance(gate_id, str) or not 1 <= len(gate_id) <= 200 - for gate_id in gate_ids - ): - raise ValueError( - "a blocked event's blocking_gate_ids must be a list of gate ids" - ) - - -class PageCursor(RootModel[tuple[datetime, str]]): - """Typed contents of an opaque run-list pagination cursor.""" - - @model_validator(mode="after") - def validate_contents(self) -> PageCursor: - registered_at, run_id = self.root - if registered_at.tzinfo is None or registered_at.utcoffset() is None: - raise ValueError("cursor timestamp must include a timezone") - if not re.fullmatch(SAFE_IDENTIFIER, run_id): - raise ValueError("cursor run identifier is invalid") - return self - - -class RunRegistration(BaseModel): - """Run identity bound to a short-lived collector credential.""" - - model_config = ConfigDict(extra="forbid") - - run_id: str = Field(pattern=SAFE_IDENTIFIER) - producer_id: str = Field(pattern=SAFE_IDENTIFIER) - country_code: str = Field(pattern=r"^[A-Z]{2}$") - pipeline: str = Field(pattern=SAFE_IDENTIFIER) - candidate_id: str | None = Field(default=None, pattern=SAFE_IDENTIFIER) - release_id: str | None = Field(default=None, pattern=SAFE_IDENTIFIER) - run_kind: str = Field(default="build", pattern=SAFE_IDENTIFIER) - - -class ResourceSnapshot(BaseModel): - """Cumulative CPU and current/peak resident memory for a process tree.""" - - model_config = ConfigDict(extra="forbid") - - cpu_user_seconds: float | None = Field(default=None, ge=0) - cpu_system_seconds: float | None = Field(default=None, ge=0) - rss_bytes: int | None = Field(default=None, ge=0) - peak_rss_bytes: int | None = Field(default=None, ge=0) - - -class TelemetryEvent(BaseModel): - """One idempotent event emitted by a Microcosm build process.""" - - model_config = ConfigDict(extra="forbid") - - schema_version: Literal[1] - event_id: str = Field(pattern=SAFE_IDENTIFIER) - run_id: str = Field(pattern=SAFE_IDENTIFIER) - producer_id: str = Field(pattern=SAFE_IDENTIFIER) - sequence: int = Field(ge=1) - timestamp: datetime - event_type: Literal["run", "stage", "progress", "calibration", "heartbeat"] - stage_id: str | None = Field(default=None, pattern=SAFE_IDENTIFIER) - status: Literal["started", "progress", "completed", "failed", "blocked"] - message: str | None = Field(default=None, max_length=500) - details: dict[str, Any] = Field(default_factory=dict) - resources: ResourceSnapshot | None = None - - @field_validator("timestamp") - @classmethod - def require_timezone(cls, value: datetime) -> datetime: - if value.tzinfo is None or value.utcoffset() is None: - raise ValueError("timestamp must include a timezone") - return value - - @model_validator(mode="after") - def require_block_details(self) -> TelemetryEvent: - if self.status == "blocked": - _require_block(self.details) - return self - - -class EventBatch(BaseModel): - """Bounded event batch accepted by the ingestion endpoint.""" - - model_config = ConfigDict(extra="forbid") - - events: list[TelemetryEvent] = Field(max_length=200) - - -class CollectorToken(BaseModel): - """Short-lived token returned after Hugging Face verification.""" - - access_token: str - token_type: Literal["bearer"] = "bearer" - expires_in: int diff --git a/telemetry-service/telemetry_collector/storage.py b/telemetry-service/telemetry_collector/storage.py deleted file mode 100644 index 6d74ea45..00000000 --- a/telemetry-service/telemetry_collector/storage.py +++ /dev/null @@ -1,774 +0,0 @@ -"""SQLAlchemy persistence and deterministic run-document materialization.""" - -from __future__ import annotations - -from collections.abc import Iterable -from copy import deepcopy -from dataclasses import dataclass -from datetime import UTC, datetime -from typing import Any, Protocol - -from sqlalchemy import Select, and_, func, select, tuple_ -from sqlalchemy.dialects.postgresql import insert -from sqlalchemy.engine import Engine -from sqlalchemy.exc import SQLAlchemyError -from sqlalchemy.orm import Session, sessionmaker - -from telemetry_collector.auth import HuggingFacePrincipal -from telemetry_collector.database import ( - TelemetryEvent as StoredTelemetryEvent, -) -from telemetry_collector.database import ( - TelemetryProducer, - TelemetryRun, - create_database_engine, - create_session_factory, -) -from telemetry_collector.models import RunRegistration, TelemetryEvent - - -class RunRegistrationConflictError(ValueError): - """Raised when a run identifier is reused with different metadata.""" - - -class TelemetryStore(Protocol): - """Storage operations used by the HTTP service.""" - - def register_run( - self, - registration: RunRegistration, - principal: HuggingFacePrincipal, - ) -> None: ... - - def append_events( - self, - run_id: str, - owner_hf_id: str, - events: Iterable[TelemetryEvent], - ) -> tuple[int, int]: ... - - def list_runs( - self, - *, - country: str | None, - limit: int, - before: tuple[datetime, str] | None, - ) -> list[dict[str, Any]]: ... - - def get_run(self, run_id: str) -> dict[str, Any] | None: ... - - def is_ready(self) -> bool: ... - - -def _now() -> datetime: - return datetime.now(UTC) - - -def initial_run_state( - registration: RunRegistration, - principal: HuggingFacePrincipal, - registered_at: datetime | None = None, -) -> dict[str, Any]: - """Build the initial materialized state for a newly registered run.""" - - timestamp = registered_at or _now() - return { - "run_id": registration.run_id, - "country_code": registration.country_code, - "pipeline": registration.pipeline, - "candidate_id": registration.candidate_id, - "release_id": registration.release_id, - "run_kind": registration.run_kind, - "owner_hf_id": principal.user_id, - "owner_hf_username": principal.username, - "registered_at": timestamp, - "status": "running", - "current_stage": "created", - "started_at": timestamp, - "updated_at": timestamp, - "ended_at": None, - "heartbeat_at": None, - "resources": None, - "work": None, - "failure": None, - } - - -def _event_order(event: dict[str, Any]) -> tuple[datetime, str, int, str]: - return ( - event["producer_registered_at"], - event["producer_id"], - event["sequence"], - event["event_id"], - ) - - -def canonical_events(events: Iterable[dict[str, Any]]) -> list[dict[str, Any]]: - """Order producer streams independently from client clocks and delivery order.""" - - return sorted(events, key=_event_order) - - -def _apply_event(run: dict[str, Any], event: dict[str, Any]) -> None: - """Apply one event to the run state. - - ``apply_event_batch`` folds batches onto stored columns, which relies on - ``updated_at`` staying a maximum, ``started_at`` a minimum, and every other - write depending only on the event. A rule change does not revise the - stored columns of runs already in progress. - """ - - timestamp = event["timestamp"] - run["started_at"] = min(run["started_at"], timestamp) - run["updated_at"] = max( - run["updated_at"], timestamp, event["producer_registered_at"] - ) - if event.get("resources") is not None: - run["resources"] = event["resources"] - if event["event_type"] == "heartbeat": - run["heartbeat_at"] = timestamp - stage_id = event.get("stage_id") - if stage_id and stage_id not in {"complete", "failed", "blocked"}: - run["current_stage"] = stage_id - if event["event_type"] == "run" and event["status"] == "started": - # A later producer represents a restarted attempt for the same stable - # run identifier. Its creation event must reopen a previously finished - # run before subsequent progress arrives. - run["status"] = "running" - run["ended_at"] = None - run["failure"] = None - run["block"] = None - details = event.get("details") or {} - if event["event_type"] == "progress" and { - "done", - "total", - }.issubset(details): - run["work"] = { - "stage": stage_id, - "done": details["done"], - "total": details["total"], - "unit": details.get("unit"), - "elapsed_seconds": details.get("elapsed_seconds", 0), - "updated_at": timestamp.isoformat(), - "details": { - key: value - for key, value in details.items() - if key not in {"done", "total", "unit", "elapsed_seconds"} - }, - } - if (event["status"] == "failed" and event["event_type"] == "run") or ( - stage_id == "failed" - ): - run["status"] = "failed" - run["current_stage"] = "failed" - run["ended_at"] = timestamp - run["failure"] = { - "message": event.get("message"), - "error_type": details.get("error_type"), - "error_code": details.get("error_code"), - "failure_class": details.get("failure_class"), - "failed_during": details.get("failed_during") or stage_id, - } - run["block"] = None - elif event["status"] == "blocked" and ( - event["event_type"] == "run" or stage_id == "blocked" - ): - # The gates refused the candidate: the run ended without failing. - run["status"] = "blocked" - run["current_stage"] = "blocked" - run["ended_at"] = timestamp - run["failure"] = None - run["block"] = { - "phase": details.get("phase"), - "blocking_failure_count": details.get("blocking_failure_count"), - "blocking_gate_ids": details.get("blocking_gate_ids") or [], - } - elif event["status"] == "completed" and ( - event["event_type"] == "run" or stage_id == "complete" - ): - run["status"] = "completed" - run["current_stage"] = "complete" - run["ended_at"] = timestamp - run["failure"] = None - run["block"] = None - - -def _fold_events( - run: dict[str, Any], - active_producer_id: str | None, - ordered_events: Iterable[dict[str, Any]], -) -> None: - """Apply ordered events that follow ``active_producer_id``'s events.""" - - for event in ordered_events: - producer_id = event["producer_id"] - if active_producer_id is not None and producer_id != active_producer_id: - # A producer id identifies one execution attempt. Starting a later - # producer under the same stable run id must not retain the prior - # attempt's completion, progress, heartbeat, or resource snapshot. - run.update( - { - "status": "running", - "current_stage": "created", - "started_at": event["timestamp"], - "updated_at": max( - run["updated_at"], - event["timestamp"], - event["producer_registered_at"], - ), - "ended_at": None, - "heartbeat_at": None, - "resources": None, - "work": None, - "failure": None, - "block": None, - } - ) - active_producer_id = producer_id - _apply_event(run, event) - - -def materialize_run( - registered_run: dict[str, Any], events: Iterable[dict[str, Any]] -) -> dict[str, Any]: - """Rebuild mutable run state from a stable canonical event order.""" - - run = { - **registered_run, - "status": "running", - "current_stage": "created", - "started_at": registered_run["registered_at"], - "updated_at": registered_run["registered_at"], - "ended_at": None, - "heartbeat_at": None, - "resources": None, - "work": None, - "failure": None, - # Rebuilt from the events on every read; not a stored column. - "block": None, - } - _fold_events(run, None, canonical_events(events)) - return run - - -MATERIALIZED_RUN_COLUMNS = ( - "status", - "current_stage", - "started_at", - "updated_at", - "ended_at", - "heartbeat_at", - "resources", - "work", - "failure", -) - - -@dataclass(frozen=True) -class StoredProducer: - """A registered producer and its highest sequence stored before a batch.""" - - producer_id: str - registered_at: datetime - last_sequence: int | None - - -class StoredEventReader(Protocol): - """Targeted reads of one producer's stored events, including the batch's.""" - - def events_from(self, producer_id: str, sequence: int) -> list[dict[str, Any]]: - """Return the producer's events from ``sequence`` onward, in order.""" - ... - - def earliest_timestamp(self, producer_id: str) -> datetime: - """Return the earliest timestamp among the producer's events.""" - ... - - -def _materialized_columns(run: dict[str, Any]) -> dict[str, Any]: - return {column: run[column] for column in MATERIALIZED_RUN_COLUMNS} - - -def apply_event_batch( - run: dict[str, Any], - producers: Iterable[StoredProducer], - accepted_events: Iterable[dict[str, Any]], - stored_events: StoredEventReader, -) -> dict[str, Any]: - """Return the materialized columns after a batch, without replaying the run. - - ``run`` holds the columns materialized before the batch, ``producers`` each - producer's highest sequence stored before it, and ``accepted_events`` its - newly stored events. The result equals ``materialize_run`` over every stored event. - ``updated_at`` is a maximum over every event and ``started_at`` a minimum - over the final producer's events; every other column is set by the latest - event that writes it. Folding new events onto the stored columns is - therefore exact when they follow the active producer's stored events. - Only an event delivered after later events of its producer replays that - producer's stored events from it onward. - - The caller must read ``run`` and ``producers``, and serve ``stored_events``, - while holding a lock that keeps other batches for the run from committing, - so that they describe every event stored before this batch. - """ - - ordered_events = canonical_events(accepted_events) - reported_producers = [ - producer for producer in producers if producer.last_sequence is not None - ] - if not reported_producers: - return _materialized_columns(materialize_run(run, ordered_events)) - active_producer = max( - reported_producers, - key=lambda producer: (producer.registered_at, producer.producer_id), - ) - active_position = ( - active_producer.registered_at, - active_producer.producer_id, - active_producer.last_sequence, - ) - state = _materialized_columns(run) - for event in ordered_events: - state["updated_at"] = max( - state["updated_at"], event["timestamp"], event["producer_registered_at"] - ) - following_events = [ - event for event in ordered_events if _event_order(event)[:3] > active_position - ] - if any( - event["producer_id"] != active_producer.producer_id - for event in following_events - ): - # A later producer's first event resets the run, so the events it - # follows contribute only to updated_at. - _fold_events(state, active_producer.producer_id, following_events) - return _materialized_columns(state) - if len(reported_producers) == 1 and any( - event["producer_id"] != active_producer.producer_id for event in ordered_events - ): - # An earlier producer's first events now precede the active producer's, - # whose attempt therefore starts at its own earliest event rather than - # at the run's registration. - state["started_at"] = stored_events.earliest_timestamp( - active_producer.producer_id - ) - late_sequences = [ - event["sequence"] - for event in ordered_events - if event["producer_id"] == active_producer.producer_id - and event["sequence"] < active_producer.last_sequence - ] - if late_sequences: - following_events = stored_events.events_from( - active_producer.producer_id, min(late_sequences) - ) - _fold_events(state, active_producer.producer_id, following_events) - return _materialized_columns(state) - - -def run_summary(run: dict[str, Any]) -> dict[str, Any]: - """Return the bounded run representation used by list responses.""" - - return { - "run_id": run["run_id"], - "country_code": run["country_code"], - "pipeline": run["pipeline"], - "candidate_id": run.get("candidate_id"), - "release_id": run.get("release_id"), - "run_kind": run["run_kind"], - "status": run["status"], - "current_stage": run["current_stage"], - "registered_at": run["registered_at"], - "started_at": run["started_at"], - "updated_at": run["updated_at"], - "ended_at": run.get("ended_at"), - "heartbeat_at": run.get("heartbeat_at"), - } - - -def run_documents( - registered_run: dict[str, Any], events: Iterable[dict[str, Any]] -) -> dict[str, Any]: - """Return dashboard-compatible documents from the canonical event stream.""" - - ordered = canonical_events(events) - run = materialize_run(registered_run, ordered) - lifecycle_events: list[dict[str, Any]] = [] - calibration_events: list[dict[str, Any]] = [] - identity: dict[str, Any] | None = None - for sequence, event in enumerate(ordered, start=1): - details = deepcopy(event.get("details") or {}) - if event["event_type"] == "run" and isinstance(details.get("identity"), dict): - identity = details["identity"] - event_type = "calibration" if event["event_type"] == "calibration" else "stage" - lifecycle_events.append( - { - "schema_version": 2, - "sequence": sequence, - "timestamp": event["timestamp"], - "time": event["timestamp"], - "event_type": event_type, - "run_id": event["run_id"], - "stage_id": event.get("stage_id"), - "stage": event.get("stage_id"), - "status": event["status"], - "message": event.get("message"), - "details": details, - "resources": event.get("resources"), - "producer_id": event["producer_id"], - "event_id": event["event_id"], - } - ) - if event["event_type"] == "calibration": - calibration_events.append( - {"time": event["timestamp"], "timestamp": event["timestamp"], **details} - ) - common = { - "schema_version": 2, - "run_id": run["run_id"], - "country_code": run["country_code"], - "operation_id": run["run_id"], - "pipeline": {"id": run["pipeline"], "version": "collector-v1"}, - "candidate_id": run.get("candidate_id"), - "release_id": run.get("release_id"), - "run_kind": run["run_kind"], - "non_release": run.get("release_id") is None, - "started_at": run["started_at"], - "updated_at": run["updated_at"], - "status": run["status"], - "current_stage": run["current_stage"], - } - progress = { - **common, - "heartbeat_at": run.get("heartbeat_at"), - "resources": run.get("resources"), - "work": run.get("work"), - "failure": run.get("failure"), - "block": run.get("block"), - } - manifest = { - **common, - "identity": identity, - "failure": run.get("failure"), - "block": run.get("block"), - "delivery": { - "mode": "collector", - "upload_attempts": None, - "upload_successes": None, - }, - } - return { - "run_id": run["run_id"], - "country_code": run["country_code"], - "progress": progress, - "run_manifest": manifest, - "calibration_progress": { - "schema_version": 2, - "run_id": run["run_id"], - "candidate_id": run.get("candidate_id"), - "updated_at": run["updated_at"], - "events": calibration_events, - } - if calibration_events - else None, - "events": lifecycle_events, - } - - -def _run_mapping(run: TelemetryRun) -> dict[str, Any]: - return { - column.name: getattr(run, column.name) - for column in TelemetryRun.__table__.columns - } - - -def _event_mapping( - event: StoredTelemetryEvent, producer_registered_at: datetime -) -> dict[str, Any]: - return { - "event_id": event.event_id, - "run_id": event.run_id, - "producer_id": event.producer_id, - "producer_registered_at": producer_registered_at, - "sequence": event.sequence, - "timestamp": event.emitted_at, - "event_type": event.event_type, - "stage_id": event.stage_id, - "status": event.status, - "message": event.message, - "details": event.details, - "resources": event.resources, - } - - -def _stored_events_statement( - run_id: str, -) -> Select[tuple[StoredTelemetryEvent, datetime]]: - return ( - select( - StoredTelemetryEvent, - TelemetryProducer.registered_at, - ) - .join( - TelemetryProducer, - and_( - StoredTelemetryEvent.run_id == TelemetryProducer.run_id, - StoredTelemetryEvent.producer_id == TelemetryProducer.producer_id, - ), - ) - .where(StoredTelemetryEvent.run_id == run_id) - ) - - -class _PostgresStoredEvents: - """Reads one producer's stored events inside the ingestion transaction.""" - - def __init__(self, session: Session, run_id: str) -> None: - self._session = session - self._run_id = run_id - - def events_from(self, producer_id: str, sequence: int) -> list[dict[str, Any]]: - rows = self._session.execute( - _stored_events_statement(self._run_id) - .where( - StoredTelemetryEvent.producer_id == producer_id, - StoredTelemetryEvent.sequence >= sequence, - ) - .order_by(StoredTelemetryEvent.sequence, StoredTelemetryEvent.event_id) - ).all() - return [_event_mapping(event, registered_at) for event, registered_at in rows] - - def earliest_timestamp(self, producer_id: str) -> datetime: - return self._session.execute( - select(func.min(StoredTelemetryEvent.emitted_at)).where( - StoredTelemetryEvent.run_id == self._run_id, - StoredTelemetryEvent.producer_id == producer_id, - ) - ).scalar_one() - - -def _registered_metadata( - run: TelemetryRun, -) -> tuple[str, str, str | None, str | None, str]: - return ( - run.country_code, - run.pipeline, - run.candidate_id, - run.release_id, - run.run_kind, - ) - - -def _requested_metadata( - registration: RunRegistration, -) -> tuple[str, str, str | None, str | None, str]: - return ( - registration.country_code, - registration.pipeline, - registration.candidate_id, - registration.release_id, - registration.run_kind, - ) - - -class PostgresTelemetryStore: - """PostgreSQL store implemented with SQLAlchemy ORM sessions.""" - - def __init__(self, database_url: str) -> None: - self.engine: Engine = create_database_engine(database_url) - self._sessions: sessionmaker[Session] = create_session_factory(self.engine) - - def close(self) -> None: - """Dispose the SQLAlchemy engine and its connection pool.""" - - self.engine.dispose() - - def is_ready(self) -> bool: - """Return whether PostgreSQL is reachable and fully migrated.""" - - from telemetry_collector.migrate import database_revision_is_current - - try: - return database_revision_is_current(self.engine) - except SQLAlchemyError: - return False - - def register_run( - self, - registration: RunRegistration, - principal: HuggingFacePrincipal, - ) -> None: - timestamp = _now() - initial = initial_run_state(registration, principal, timestamp) - with self._sessions.begin() as session: - session.execute( - insert(TelemetryRun) - .values(**initial) - .on_conflict_do_nothing(index_elements=[TelemetryRun.run_id]) - ) - run = session.execute( - select(TelemetryRun) - .where(TelemetryRun.run_id == registration.run_id) - .with_for_update() - ).scalar_one() - if run.owner_hf_id != principal.user_id: - raise PermissionError("Run belongs to another Hugging Face user.") - if _registered_metadata(run) != _requested_metadata(registration): - raise RunRegistrationConflictError( - "Run identifier is already registered with different metadata." - ) - run.owner_hf_username = principal.username - session.execute( - insert(TelemetryProducer) - .values( - run_id=registration.run_id, - producer_id=registration.producer_id, - registered_at=timestamp, - ) - .on_conflict_do_nothing( - index_elements=[ - TelemetryProducer.run_id, - TelemetryProducer.producer_id, - ] - ) - ) - - def _events_for_run(self, session: Session, run_id: str) -> list[dict[str, Any]]: - rows = session.execute( - _stored_events_statement(run_id).order_by( - TelemetryProducer.registered_at, - TelemetryProducer.producer_id, - StoredTelemetryEvent.sequence, - StoredTelemetryEvent.event_id, - ) - ).all() - return [_event_mapping(event, registered_at) for event, registered_at in rows] - - def _stored_producers( - self, session: Session, run_id: str - ) -> dict[str, StoredProducer]: - last_sequence = ( - select(func.max(StoredTelemetryEvent.sequence)) - .where( - StoredTelemetryEvent.run_id == TelemetryProducer.run_id, - StoredTelemetryEvent.producer_id == TelemetryProducer.producer_id, - ) - .scalar_subquery() - ) - rows = session.execute( - select( - TelemetryProducer.producer_id, - TelemetryProducer.registered_at, - last_sequence, - ).where(TelemetryProducer.run_id == run_id) - ).all() - return { - producer_id: StoredProducer(producer_id, registered_at, sequence) - for producer_id, registered_at, sequence in rows - } - - def append_events( - self, - run_id: str, - owner_hf_id: str, - events: Iterable[TelemetryEvent], - ) -> tuple[int, int]: - models = list(events) - with self._sessions.begin() as session: - run = session.execute( - select(TelemetryRun) - .where(TelemetryRun.run_id == run_id) - .with_for_update() - ).scalar_one_or_none() - if run is None: - raise KeyError(run_id) - if run.owner_hf_id != owner_hf_id: - raise PermissionError("Run belongs to another Hugging Face user.") - if not models: - return 0, 0 - # Keep this read after the run's row lock above. Appending events and - # registering a producer both take that lock, so every earlier batch - # for this run has committed, and no other can commit until this - # transaction ends. Read before the lock, a concurrent batch's events - # would be missing from ``last_sequence``: this batch's earlier - # sequences would then fold on top of them instead of being replayed - # as late events. - producers = self._stored_producers(session, run_id) - if not {model.producer_id for model in models} <= producers.keys(): - raise PermissionError( - "Telemetry producer is not registered for this run." - ) - values = [] - for model in models: - event = model.model_dump(mode="python") - values.append( - { - "event_id": event["event_id"], - "run_id": event["run_id"], - "producer_id": event["producer_id"], - "sequence": event["sequence"], - "emitted_at": event["timestamp"], - "event_type": event["event_type"], - "stage_id": event["stage_id"], - "status": event["status"], - "message": event["message"], - "details": event["details"], - "resources": event["resources"], - } - ) - # Returning the stored rows gives the fold the same database values - # (time zone, JSON) that a full re-read would. - inserted_events = ( - session.execute( - insert(StoredTelemetryEvent) - .values(values) - .on_conflict_do_nothing() - .returning(StoredTelemetryEvent) - ) - .scalars() - .all() - ) - if inserted_events: - materialized = apply_event_batch( - _run_mapping(run), - producers.values(), - [ - _event_mapping( - event, producers[event.producer_id].registered_at - ) - for event in inserted_events - ], - _PostgresStoredEvents(session, run_id), - ) - for column, value in materialized.items(): - setattr(run, column, value) - return len(inserted_events), len(models) - len(inserted_events) - - def list_runs( - self, - *, - country: str | None, - limit: int, - before: tuple[datetime, str] | None, - ) -> list[dict[str, Any]]: - statement = select(TelemetryRun) - if country is not None: - statement = statement.where(TelemetryRun.country_code == country) - if before is not None: - statement = statement.where( - tuple_(TelemetryRun.registered_at, TelemetryRun.run_id) < before - ) - statement = statement.order_by( - TelemetryRun.registered_at.desc(), TelemetryRun.run_id.desc() - ).limit(limit) - with self._sessions() as session: - runs = session.execute(statement).scalars().all() - return [run_summary(_run_mapping(run)) for run in runs] - - def get_run(self, run_id: str) -> dict[str, Any] | None: - with self._sessions() as session: - run = session.get(TelemetryRun, run_id) - if run is None: - return None - events = self._events_for_run(session, run_id) - return run_documents(_run_mapping(run), events) diff --git a/telemetry-service/tests/__init__.py b/telemetry-service/tests/__init__.py deleted file mode 100644 index e5daa6dc..00000000 --- a/telemetry-service/tests/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Telemetry collector test support.""" diff --git a/telemetry-service/tests/conftest.py b/telemetry-service/tests/conftest.py deleted file mode 100644 index c4a0c1ca..00000000 --- a/telemetry-service/tests/conftest.py +++ /dev/null @@ -1,34 +0,0 @@ -from __future__ import annotations - -import os -from collections.abc import Iterator - -import pytest -import sqlalchemy as sa - -from telemetry_collector.database import create_database_engine, normalize_database_url - - -@pytest.fixture -def postgres_url() -> Iterator[str]: - """Provide and reset the dedicated PostgreSQL integration-test database.""" - - raw_url = os.environ.get("TEST_DATABASE_URL", "").strip() - if not raw_url: - pytest.skip("TEST_DATABASE_URL is required for PostgreSQL integration tests.") - url = normalize_database_url(raw_url) - if "test" not in (url.database or "").lower(): - pytest.fail("TEST_DATABASE_URL must identify a dedicated test database.") - engine = create_database_engine(raw_url) - - def reset() -> None: - metadata = sa.MetaData() - metadata.reflect(engine) - metadata.drop_all(engine) - - reset() - try: - yield raw_url - finally: - reset() - engine.dispose() diff --git a/telemetry-service/tests/event_schedules.py b/telemetry-service/tests/event_schedules.py deleted file mode 100644 index 86e4b0ba..00000000 --- a/telemetry-service/tests/event_schedules.py +++ /dev/null @@ -1,538 +0,0 @@ -"""Telemetry event kinds and delivery schedules for the batch-application tests.""" - -from __future__ import annotations - -import random -from collections.abc import Callable, Sequence -from datetime import UTC, datetime, timedelta -from typing import Any - -from telemetry_collector.models import TelemetryEvent - -RUN_REGISTERED_AT = datetime(2026, 10, 1, 12, tzinfo=UTC) - -Event = dict[str, Any] -EventKind = Callable[[str, int, float], Event] - - -def telemetry_event( - producer_id: str, - sequence: int, - minutes_after_registration: float, - *, - event_type: str, - status: str, - stage_id: str | None = None, - details: dict[str, Any] | None = None, - resources: dict[str, Any] | None = None, - message: str | None = None, - event_id: str | None = None, -) -> Event: - """Return an event as a producer sends it, validated by the collector model.""" - - event = TelemetryEvent( - schema_version=1, - event_id=event_id or f"{producer_id}-{sequence}", - run_id="run-1", - producer_id=producer_id, - sequence=sequence, - timestamp=RUN_REGISTERED_AT + timedelta(minutes=minutes_after_registration), - event_type=event_type, - stage_id=stage_id, - status=status, - message=message, - details=details or {}, - resources=resources, - ).model_dump(mode="python") - del event["schema_version"] - return event - - -def resource_snapshot(sequence: int) -> dict[str, Any]: - return { - "cpu_user_seconds": sequence * 1.5, - "rss_bytes": sequence * 1024, - "peak_rss_bytes": sequence * 2048, - } - - -def run_started(producer_id: str, sequence: int, minutes: float) -> Event: - return telemetry_event( - producer_id, - sequence, - minutes, - event_type="run", - status="started", - stage_id="created", - message="Run started", - ) - - -def stage_started(producer_id: str, sequence: int, minutes: float) -> Event: - return telemetry_event( - producer_id, - sequence, - minutes, - event_type="stage", - status="started", - stage_id="calibrate", - ) - - -def stage_completed(producer_id: str, sequence: int, minutes: float) -> Event: - return telemetry_event( - producer_id, - sequence, - minutes, - event_type="stage", - status="completed", - stage_id="export", - ) - - -def stage_failed_without_failing_the_run( - producer_id: str, sequence: int, minutes: float -) -> Event: - return telemetry_event( - producer_id, - sequence, - minutes, - event_type="stage", - status="failed", - stage_id="validate", - ) - - -def progress_with_totals(producer_id: str, sequence: int, minutes: float) -> Event: - return telemetry_event( - producer_id, - sequence, - minutes, - event_type="progress", - status="progress", - stage_id="calibrate", - details={ - "done": sequence, - "total": 500, - "unit": "epochs", - "elapsed_seconds": sequence * 2.5, - "loss": 0.125, - }, - ) - - -def progress_without_totals(producer_id: str, sequence: int, minutes: float) -> Event: - return telemetry_event( - producer_id, - sequence, - minutes, - event_type="progress", - status="progress", - stage_id="calibrate", - details={"done": sequence}, - ) - - -def calibration_epoch(producer_id: str, sequence: int, minutes: float) -> Event: - return telemetry_event( - producer_id, - sequence, - minutes, - event_type="calibration", - status="progress", - stage_id="calibrate", - details={"epoch": sequence, "loss": 0.5 / sequence}, - ) - - -def heartbeat(producer_id: str, sequence: int, minutes: float) -> Event: - return telemetry_event( - producer_id, - sequence, - minutes, - event_type="heartbeat", - status="progress", - resources=resource_snapshot(sequence), - ) - - -def stage_resource_snapshot(producer_id: str, sequence: int, minutes: float) -> Event: - return telemetry_event( - producer_id, - sequence, - minutes, - event_type="stage", - status="progress", - stage_id="export", - resources=resource_snapshot(sequence), - ) - - -def run_failed(producer_id: str, sequence: int, minutes: float) -> Event: - return telemetry_event( - producer_id, - sequence, - minutes, - event_type="run", - status="failed", - stage_id="calibrate", - message="Calibration diverged", - details={ - "error_type": "FloatingPointError", - "error_code": "DIVERGED", - "failure_class": "build", - "failed_during": "calibrating", - }, - ) - - -def run_failed_without_details( - producer_id: str, sequence: int, minutes: float -) -> Event: - return telemetry_event( - producer_id, sequence, minutes, event_type="run", status="failed" - ) - - -def failed_stage(producer_id: str, sequence: int, minutes: float) -> Event: - return telemetry_event( - producer_id, - sequence, - minutes, - event_type="stage", - status="failed", - stage_id="failed", - message="Interrupted", - details={"error_type": "KeyboardInterrupt", "error_code": "INTERRUPTED"}, - ) - - -def run_blocked(producer_id: str, sequence: int, minutes: float) -> Event: - return telemetry_event( - producer_id, - sequence, - minutes, - event_type="run", - status="blocked", - details={ - "phase": "terminal", - "blocking_failure_count": 1, - "blocking_gate_ids": ["uk_target_fit"], - "gate_statuses": {"uk_target_fit": "failed"}, - }, - ) - - -def blocked_stage(producer_id: str, sequence: int, minutes: float) -> Event: - return telemetry_event( - producer_id, - sequence, - minutes, - event_type="stage", - status="blocked", - stage_id="blocked", - details={ - "phase": "preflight", - "blocking_failure_count": 2, - "blocking_gate_ids": [], - }, - ) - - -def blocked_status_on_another_stage( - producer_id: str, sequence: int, minutes: float -) -> Event: - return telemetry_event( - producer_id, - sequence, - minutes, - event_type="stage", - status="blocked", - stage_id="gate_battery", - details={ - "phase": "terminal", - "blocking_failure_count": 3, - "blocking_gate_ids": ["us_target_fit"], - }, - ) - - -def run_completed(producer_id: str, sequence: int, minutes: float) -> Event: - return telemetry_event( - producer_id, sequence, minutes, event_type="run", status="completed" - ) - - -def complete_stage(producer_id: str, sequence: int, minutes: float) -> Event: - return telemetry_event( - producer_id, - sequence, - minutes, - event_type="stage", - status="completed", - stage_id="complete", - ) - - -def complete_stage_id_without_completion( - producer_id: str, sequence: int, minutes: float -) -> Event: - return telemetry_event( - producer_id, - sequence, - minutes, - event_type="stage", - status="started", - stage_id="complete", - ) - - -# Every branch of the materializer: reopening, stage tracking, work, heartbeats, -# resources, failure, block and completion, and terminal-looking events that -# don't end the run. -EVENT_KINDS: dict[str, EventKind] = { - kind.__name__: kind - for kind in ( - run_started, - stage_started, - stage_completed, - stage_failed_without_failing_the_run, - progress_with_totals, - progress_without_totals, - calibration_epoch, - heartbeat, - stage_resource_snapshot, - run_failed, - run_failed_without_details, - failed_stage, - run_blocked, - blocked_stage, - blocked_status_on_another_stage, - run_completed, - complete_stage, - complete_stage_id_without_completion, - ) -} - - -def every_kind_stream( - producer_id: str, - kinds: Sequence[EventKind] = tuple(EVENT_KINDS.values()), - first_minute: float = 1.0, -) -> list[Event]: - return [ - kind(producer_id, sequence, first_minute + sequence) - for sequence, kind in enumerate(kinds, start=1) - ] - - -def long_stream( - producer_id: str, event_count: int, first_minute: float = 1.0 -) -> list[Event]: - kinds = list(EVENT_KINDS.values()) - return [ - kinds[(sequence - 1) % len(kinds)]( - producer_id, sequence, first_minute + sequence / 10 - ) - for sequence in range(1, event_count + 1) - ] - - -def in_batches(events: Sequence[Event], batch_size: int) -> list[list[Event]]: - return [ - list(events[start : start + batch_size]) - for start in range(0, len(events), batch_size) - ] - - -ONE_PRODUCER = {"producer-a": RUN_REGISTERED_AT} -RESTARTED_PRODUCERS = { - "producer-a": RUN_REGISTERED_AT, - "producer-b": RUN_REGISTERED_AT + timedelta(minutes=60), - "producer-c": RUN_REGISTERED_AT + timedelta(minutes=60), -} - - -def out_of_order_schedules(shuffle_count: int = 20) -> dict[str, list[list[Event]]]: - """One producer's events of every kind, delivered out of order.""" - - stream = every_kind_stream("producer-a") + every_kind_stream( - "producer-a", first_minute=100.0 - ) - stream = [ - {**event, "sequence": sequence, "event_id": f"producer-a-{sequence}"} - for sequence, event in enumerate(stream, start=1) - ] - batches = in_batches(stream, 4) - schedules = { - "batches in reverse": list(reversed(batches)), - "events in reverse": [[event] for event in reversed(stream)], - "first event last": [stream[1:], [stream[0]]], - "middle batch last": [batches[0], *batches[2:], batches[1]], - "events reversed inside each batch": [ - list(reversed(batch)) for batch in batches - ], - } - for shuffle_seed in range(shuffle_count): - shuffled = list(stream) - random.Random(shuffle_seed).shuffle(shuffled) - schedules[f"shuffled single events {shuffle_seed}"] = [ - [event] for event in shuffled - ] - schedules[f"shuffled batches of five {shuffle_seed}"] = in_batches(shuffled, 5) - - skew = random.Random(99) - skewed_stream = [ - { - **event, - "timestamp": RUN_REGISTERED_AT + timedelta(minutes=skew.uniform(-30, 300)), - } - for event in stream - ] - schedules["skewed clocks, in order"] = in_batches(skewed_stream, 3) - schedules["skewed clocks, batches in reverse"] = list( - reversed(in_batches(skewed_stream, 3)) - ) - return schedules - - -def restarted_producer_schedules() -> dict[str, list[list[Event]]]: - """Attempts registered as ``RESTARTED_PRODUCERS``, delivered in several orders.""" - - first_attempt = every_kind_stream("producer-a") - second_attempt = every_kind_stream("producer-b", first_minute=61.0) - third_attempt = every_kind_stream("producer-c", first_minute=62.0) - first_without_fifth = first_attempt[:4] + first_attempt[5:] - mixed_batch = first_attempt[:6] + second_attempt[:6] - random.Random(7).shuffle(mixed_batch) - early_clock_attempt = [ - {**event, "timestamp": RUN_REGISTERED_AT - timedelta(minutes=sequence)} - for sequence, event in enumerate(second_attempt, start=1) - ] - - schedules = { - "restart after the first attempt ends": [ - *in_batches(first_attempt, 5), - *in_batches(second_attempt, 5), - ], - "later attempt reports before the earlier one": [ - *in_batches(second_attempt, 5), - *in_batches(first_attempt, 5), - ], - "earlier attempt reports late": [ - *in_batches(first_attempt[:8], 4), - *in_batches(second_attempt, 4), - *in_batches(first_attempt[8:], 4), - ], - "one batch spans both attempts": [ - mixed_batch, - first_attempt[6:], - second_attempt[6:], - ], - "late event of the earlier attempt beside the later attempt": [ - first_without_fifth, - [first_attempt[4], *second_attempt[:3]], - second_attempt[3:], - ], - "earlier attempt's late event after the later attempt reports": [ - first_without_fifth, - second_attempt[:3], - [first_attempt[4]], - ], - "three attempts with tied registrations": [ - *in_batches(first_attempt, 6), - *[ - batch - for pair in zip( - in_batches(third_attempt, 3), - in_batches(second_attempt, 3), - strict=True, - ) - for batch in pair - ], - ], - "later attempt clocks before the registration": [ - *in_batches(first_attempt, 5), - *in_batches(early_clock_attempt, 5), - ], - "earlier attempt's last event carries the latest clock": [ - *in_batches(first_attempt, 5), - *in_batches(second_attempt, 5), - [heartbeat("producer-a", len(first_attempt) + 1, 1000.0)], - ], - } - return schedules - - -def random_delivery_schedule( - rng: random.Random, -) -> tuple[dict[str, datetime], list[list[Event]], set[str]]: - """Generate events of random kinds and deliver them out of order, with re-sends.""" - - producer_ids = ["producer-a", "producer-b", "producer-c"][: rng.randint(1, 3)] - registrations = {"producer-a": RUN_REGISTERED_AT} - for producer_id in producer_ids[1:]: - registrations[producer_id] = RUN_REGISTERED_AT + timedelta( - minutes=rng.choice([0, 30, 30, 90]) - ) - kinds_used: set[str] = set() - batches_by_producer: list[list[list[Event]]] = [] - for producer_id in producer_ids: - stream = [] - for sequence in range(1, rng.randint(1, 30) + 1): - kind_name = rng.choice(list(EVENT_KINDS)) - kinds_used.add(kind_name) - stream.append( - EVENT_KINDS[kind_name](producer_id, sequence, rng.uniform(-30, 300)) - ) - batches = [] - while stream: - batch_size = rng.randint(1, 8) - batches.append(stream[:batch_size]) - stream = stream[batch_size:] - batches_by_producer.append(batches) - - schedule: list[list[Event]] = [] - while any(batches_by_producer): - producer_batches = rng.choice( - [batches for batches in batches_by_producer if batches] - ) - schedule.append(producer_batches.pop(0)) - - for conflict_number in range(rng.randint(0, 6)): - perturbation = rng.choice( - ["swap", "merge", "shuffle", "resend", "withhold", "conflict"] - ) - position = rng.randrange(len(schedule)) - if perturbation == "swap": - other_position = rng.randrange(len(schedule)) - schedule[position], schedule[other_position] = ( - schedule[other_position], - schedule[position], - ) - elif perturbation == "merge" and position + 1 < len(schedule): - schedule[position] = schedule[position] + schedule.pop(position + 1) - elif perturbation == "shuffle": - shuffled_batch = list(schedule[position]) - rng.shuffle(shuffled_batch) - schedule[position] = shuffled_batch - elif perturbation == "resend": - schedule.insert(rng.randrange(len(schedule) + 1), list(schedule[position])) - elif perturbation == "withhold" and len(schedule[position]) > 1: - remaining_batch = list(schedule[position]) - withheld_event = remaining_batch.pop(rng.randrange(len(remaining_batch))) - schedule[position] = remaining_batch - schedule.insert( - rng.randrange(position + 1, len(schedule) + 1), [withheld_event] - ) - elif perturbation == "conflict": - original = rng.choice(schedule[position]) - kind_name = rng.choice(list(EVENT_KINDS)) - kinds_used.add(kind_name) - conflicting_event = EVENT_KINDS[kind_name]( - original["producer_id"], original["sequence"], rng.uniform(-30, 300) - ) | { - "event_id": f"{original['event_id']}-conflict-{conflict_number}", - } - schedule.insert(rng.randrange(len(schedule) + 1), [conflicting_event]) - return registrations, schedule, kinds_used diff --git a/telemetry-service/tests/fakes.py b/telemetry-service/tests/fakes.py deleted file mode 100644 index d020ab37..00000000 --- a/telemetry-service/tests/fakes.py +++ /dev/null @@ -1,130 +0,0 @@ -"""In-memory protocol fake for HTTP contract unit tests.""" - -from __future__ import annotations - -from collections.abc import Iterable -from copy import deepcopy -from datetime import datetime -from threading import RLock -from typing import Any - -from telemetry_collector.auth import HuggingFacePrincipal -from telemetry_collector.models import RunRegistration, TelemetryEvent -from telemetry_collector.storage import ( - RunRegistrationConflictError, - canonical_events, - initial_run_state, - materialize_run, - run_documents, - run_summary, -) - - -class FakeTelemetryStore: - """Thread-safe in-memory implementation of the store protocol.""" - - def __init__(self) -> None: - self._runs: dict[str, dict[str, Any]] = {} - self._events: dict[str, dict[str, dict[str, Any]]] = {} - self._producers: dict[tuple[str, str], datetime] = {} - self._sequences: set[tuple[str, str, int]] = set() - self._lock = RLock() - - def register_run( - self, - registration: RunRegistration, - principal: HuggingFacePrincipal, - ) -> None: - with self._lock: - existing = self._runs.get(registration.run_id) - if existing is None: - existing = initial_run_state(registration, principal) - self._runs[registration.run_id] = existing - self._events[registration.run_id] = {} - elif existing["owner_hf_id"] != principal.user_id: - raise PermissionError("Run belongs to another Hugging Face user.") - elif ( - existing["country_code"], - existing["pipeline"], - existing["candidate_id"], - existing["release_id"], - existing["run_kind"], - ) != ( - registration.country_code, - registration.pipeline, - registration.candidate_id, - registration.release_id, - registration.run_kind, - ): - raise RunRegistrationConflictError( - "Run identifier is already registered with different metadata." - ) - self._producers.setdefault( - (registration.run_id, registration.producer_id), - existing["registered_at"], - ) - - def append_events( - self, - run_id: str, - owner_hf_id: str, - events: Iterable[TelemetryEvent], - ) -> tuple[int, int]: - accepted = 0 - duplicates = 0 - with self._lock: - if run_id not in self._runs: - raise KeyError(run_id) - if self._runs[run_id]["owner_hf_id"] != owner_hf_id: - raise PermissionError("Run belongs to another Hugging Face user.") - stored = self._events[run_id] - for model in events: - event = model.model_dump(mode="python") - sequence_key = (run_id, event["producer_id"], event["sequence"]) - if event["event_id"] in stored or sequence_key in self._sequences: - duplicates += 1 - continue - producer_key = (run_id, event["producer_id"]) - if producer_key not in self._producers: - raise PermissionError( - "Telemetry producer is not registered for this run." - ) - event["producer_registered_at"] = self._producers[producer_key] - stored[event["event_id"]] = event - self._sequences.add(sequence_key) - accepted += 1 - self._runs[run_id] = materialize_run( - self._runs[run_id], canonical_events(stored.values()) - ) - return accepted, duplicates - - def list_runs( - self, - *, - country: str | None, - limit: int, - before: tuple[datetime, str] | None, - ) -> list[dict[str, Any]]: - with self._lock: - runs = [ - run - for run in self._runs.values() - if (country is None or run["country_code"] == country) - and (before is None or (run["registered_at"], run["run_id"]) < before) - ] - runs.sort( - key=lambda run: (run["registered_at"], run["run_id"]), reverse=True - ) - return [run_summary(deepcopy(run)) for run in runs[:limit]] - - def get_run(self, run_id: str) -> dict[str, Any] | None: - with self._lock: - run = self._runs.get(run_id) - if run is None: - return None - return run_documents( - deepcopy(run), deepcopy(list(self._events[run_id].values())) - ) - - def is_ready(self) -> bool: - return True diff --git a/telemetry-service/tests/test_app.py b/telemetry-service/tests/test_app.py deleted file mode 100644 index 707cdd49..00000000 --- a/telemetry-service/tests/test_app.py +++ /dev/null @@ -1,676 +0,0 @@ -from __future__ import annotations - -import base64 -import json -from dataclasses import replace -from datetime import UTC, datetime - -import jwt -import pytest -from fastapi.testclient import TestClient - -from telemetry_collector.app import CollectorSettings, create_app -from telemetry_collector.auth import ( - HuggingFaceAuthenticationUnavailableError, - HuggingFacePrincipal, -) -from telemetry_collector.models import RunRegistration, TelemetryEvent -from tests.fakes import FakeTelemetryStore - - -class StubHuggingFaceAuthenticator: - def __init__(self, *, member: bool = True) -> None: - self.member = member - self.tokens: list[str] = [] - - def authenticate(self, token: str, required_org: str) -> HuggingFacePrincipal: - self.tokens.append(token) - return HuggingFacePrincipal( - user_id="hf-user-1", - username="builder", - organizations=(required_org,) if self.member else ("somewhere-else",), - ) - - -def settings() -> CollectorSettings: - return CollectorSettings( - jwt_secret="test-secret-that-is-long-enough-32", - read_token="dashboard-read-token", - required_huggingface_org="policyengine", - collector_issuer="test-collector", - session_token_ttl_seconds=900, - ) - - -def registration() -> dict[str, object]: - return { - "run_id": "route-a-1", - "producer_id": "producer-1", - "country_code": "US", - "pipeline": "us-fiscal-refresh", - "candidate_id": "candidate-1", - "release_id": None, - "run_kind": "build", - } - - -def exchange(client: TestClient) -> str: - response = client.post( - "/v1/auth/huggingface/exchange", - headers={"Authorization": "Bearer hf_example"}, - ) - assert response.status_code == 200 - return response.json()["access_token"] - - -def register(client: TestClient, session_token: str) -> None: - response = client.post( - "/v1/runs", - headers={"Authorization": f"Bearer {session_token}"}, - json=registration(), - ) - assert response.status_code == 201 - assert response.json() == {"registered": True} - - -def test_exchange_ingest_and_read_run() -> None: - store = FakeTelemetryStore() - authenticator = StubHuggingFaceAuthenticator() - client = TestClient( - create_app( - settings=settings(), - store=store, - huggingface_authenticator=authenticator, - ) - ) - session_token = exchange(client) - register(client, session_token) - timestamp = datetime.now(UTC).isoformat() - event = { - "schema_version": 1, - "event_id": "evt-1", - "run_id": "route-a-1", - "producer_id": "producer-1", - "sequence": 1, - "timestamp": timestamp, - "event_type": "stage", - "stage_id": "target_compilation", - "status": "started", - "message": "Compiling targets.", - "details": {}, - "resources": { - "cpu_user_seconds": 2.0, - "cpu_system_seconds": 0.5, - "rss_bytes": 1024, - "peak_rss_bytes": 2048, - }, - } - - first = client.post( - "/v1/runs/route-a-1/events", - headers={"Authorization": f"Bearer {session_token}"}, - json={"events": [event]}, - ) - duplicate = client.post( - "/v1/runs/route-a-1/events", - headers={"Authorization": f"Bearer {session_token}"}, - json={"events": [event]}, - ) - - assert first.status_code == 202 - assert first.json() == {"accepted": 1, "duplicates": 0} - assert duplicate.status_code == 202 - assert duplicate.json() == {"accepted": 0, "duplicates": 1} - assert authenticator.tokens == ["hf_example"] - - session_claims = jwt.decode( - session_token, - settings().jwt_secret, - algorithms=["HS256"], - issuer=settings().collector_issuer, - audience="microcosm-telemetry-collector", - ) - assert session_claims["scope"] == "telemetry:write" - - denied = client.get("/v1/runs?country=US") - assert denied.status_code == 401 - - headers = {"X-Telemetry-Read-Token": "dashboard-read-token"} - listed = client.get("/v1/runs?country=US", headers=headers) - assert listed.status_code == 200 - assert listed.json()["runs"][0]["run_id"] == "route-a-1" - - detail = client.get("/v1/runs/route-a-1", headers=headers) - assert detail.status_code == 200 - payload = detail.json() - assert payload["progress"]["current_stage"] == "target_compilation" - assert payload["events"][0]["stage_id"] == "target_compilation" - assert payload["events"][0]["resources"]["rss_bytes"] == 1024 - - -def test_non_member_cannot_exchange_token() -> None: - client = TestClient( - create_app( - settings=settings(), - store=FakeTelemetryStore(), - huggingface_authenticator=StubHuggingFaceAuthenticator(member=False), - ) - ) - response = client.post( - "/v1/auth/huggingface/exchange", - headers={"Authorization": "Bearer hf_outsider"}, - ) - assert response.status_code == 403 - - -def test_identity_provider_outage_is_retryable() -> None: - authenticator = StubHuggingFaceAuthenticator() - - def unavailable(token: str, required_org: str) -> HuggingFacePrincipal: - raise HuggingFaceAuthenticationUnavailableError( - "Hugging Face identity verification is unavailable." - ) - - authenticator.authenticate = unavailable # type: ignore[method-assign] - client = TestClient( - create_app( - settings=settings(), - store=FakeTelemetryStore(), - huggingface_authenticator=authenticator, - ) - ) - - response = client.post( - "/v1/auth/huggingface/exchange", - headers={"Authorization": "Bearer hf_example"}, - ) - - assert response.status_code == 503 - - -def test_run_registration_requires_collector_membership_token() -> None: - store = FakeTelemetryStore() - client = TestClient( - create_app( - settings=settings(), - store=store, - huggingface_authenticator=StubHuggingFaceAuthenticator(), - ) - ) - - response = client.post( - "/v1/runs", - headers={"Authorization": "Bearer hf_raw_credential"}, - json=registration(), - ) - - assert response.status_code == 401 - assert store.list_runs(country=None, limit=10, before=None) == [] - - -def test_run_identifier_cannot_be_reused_with_different_metadata() -> None: - store = FakeTelemetryStore() - client = TestClient( - create_app( - settings=settings(), - store=store, - huggingface_authenticator=StubHuggingFaceAuthenticator(), - ) - ) - session_token = exchange(client) - register(client, session_token) - - response = client.post( - "/v1/runs", - headers={"Authorization": f"Bearer {session_token}"}, - json={ - "run_id": "route-a-1", - "producer_id": "producer-2", - "country_code": "US", - "pipeline": "different-pipeline", - "candidate_id": "candidate-1", - "release_id": None, - "run_kind": "build", - }, - ) - - assert response.status_code == 409 - assert response.json()["detail"] == ( - "Run identifier is already registered with different metadata." - ) - - -def test_session_token_cannot_ingest_another_users_run() -> None: - store = FakeTelemetryStore() - store.register_run( - RunRegistration( - run_id="another-run", - producer_id="another-producer", - country_code="US", - pipeline="us-fiscal-refresh", - ), - HuggingFacePrincipal( - user_id="another-owner", - username="another-builder", - organizations=("policyengine",), - ), - ) - client = TestClient( - create_app( - settings=settings(), - store=store, - huggingface_authenticator=StubHuggingFaceAuthenticator(), - ) - ) - token = exchange(client) - response = client.post( - "/v1/runs/another-run/events", - headers={"Authorization": f"Bearer {token}"}, - json={ - "events": [ - { - "schema_version": 1, - "event_id": "other-event", - "run_id": "another-run", - "producer_id": "another-producer", - "sequence": 1, - "timestamp": datetime.now(UTC).isoformat(), - "event_type": "heartbeat", - "status": "progress", - "details": {}, - } - ] - }, - ) - assert response.status_code == 403 - - -def test_failed_validation_event_does_not_finish_the_run() -> None: - store = FakeTelemetryStore() - client = TestClient( - create_app( - settings=settings(), - store=store, - huggingface_authenticator=StubHuggingFaceAuthenticator(), - ) - ) - token = exchange(client) - register(client, token) - response = client.post( - "/v1/runs/route-a-1/events", - headers={"Authorization": f"Bearer {token}"}, - json={ - "events": [ - { - "schema_version": 1, - "event_id": "validation-failure", - "run_id": "route-a-1", - "producer_id": "producer-1", - "sequence": 1, - "timestamp": datetime.now(UTC).isoformat(), - "event_type": "stage", - "stage_id": "weight_validation", - "status": "failed", - "message": "Validation failed.", - "details": {}, - "resources": None, - } - ] - }, - ) - detail = client.get( - "/v1/runs/route-a-1", - headers={"X-Telemetry-Read-Token": "dashboard-read-token"}, - ) - - assert response.status_code == 202 - assert detail.json()["progress"]["status"] == "running" - - -def test_blocked_run_event_is_accepted_and_ends_the_run() -> None: - store = FakeTelemetryStore() - client = TestClient( - create_app( - settings=settings(), - store=store, - huggingface_authenticator=StubHuggingFaceAuthenticator(), - ) - ) - token = exchange(client) - register(client, token) - response = client.post( - "/v1/runs/route-a-1/events", - headers={"Authorization": f"Bearer {token}"}, - json={ - "events": [ - { - "schema_version": 1, - "event_id": "gates-refused", - "run_id": "route-a-1", - "producer_id": "producer-1", - "sequence": 1, - "timestamp": datetime.now(UTC).isoformat(), - "event_type": "run", - "stage_id": "blocked", - "status": "blocked", - "message": "The gates refused the candidate at terminal.", - "details": { - "phase": "terminal", - "blocking_failure_count": 1, - "blocking_gate_ids": ["uk_target_fit"], - "gate_statuses": {"uk_target_fit": "failed"}, - }, - "resources": None, - } - ] - }, - ) - headers = {"X-Telemetry-Read-Token": "dashboard-read-token"} - detail = client.get("/v1/runs/route-a-1", headers=headers) - listed = client.get("/v1/runs?country=US", headers=headers) - - assert response.status_code == 202 - assert response.json() == {"accepted": 1, "duplicates": 0} - progress = detail.json()["progress"] - assert progress["status"] == "blocked" - assert progress["block"] == { - "phase": "terminal", - "blocking_failure_count": 1, - "blocking_gate_ids": ["uk_target_fit"], - } - assert listed.json()["runs"][0]["status"] == "blocked" - assert listed.json()["runs"][0]["ended_at"] is not None - - -def _blocked_event(details: dict[str, object]) -> dict[str, object]: - return { - "schema_version": 1, - "event_id": "gates-refused", - "run_id": "route-a-1", - "producer_id": "producer-1", - "sequence": 1, - "timestamp": datetime.now(UTC).isoformat(), - "event_type": "run", - "stage_id": "blocked", - "status": "blocked", - "message": "The gates refused the candidate.", - "details": details, - "resources": None, - } - - -@pytest.mark.parametrize( - "details", - [ - {}, - {"blocking_failure_count": 1, "blocking_gate_ids": []}, - {"phase": "pre flight", "blocking_failure_count": 1, "blocking_gate_ids": []}, - {"phase": "terminal", "blocking_gate_ids": ["uk_target_fit"]}, - {"phase": "terminal", "blocking_failure_count": 0, "blocking_gate_ids": []}, - {"phase": "terminal", "blocking_failure_count": True, "blocking_gate_ids": []}, - {"phase": "terminal", "blocking_failure_count": 1}, - {"phase": "terminal", "blocking_failure_count": 1, "blocking_gate_ids": [""]}, - ], -) -def test_blocked_event_without_a_valid_block_is_rejected( - details: dict[str, object], -) -> None: - store = FakeTelemetryStore() - client = TestClient( - create_app( - settings=settings(), - store=store, - huggingface_authenticator=StubHuggingFaceAuthenticator(), - ) - ) - token = exchange(client) - register(client, token) - response = client.post( - "/v1/runs/route-a-1/events", - headers={"Authorization": f"Bearer {token}"}, - json={"events": [_blocked_event(details)]}, - ) - detail = client.get( - "/v1/runs/route-a-1", - headers={"X-Telemetry-Read-Token": "dashboard-read-token"}, - ) - - assert response.status_code == 422 - assert detail.json()["progress"]["status"] == "running" - - -def test_blocked_event_naming_no_gate_and_no_statuses_is_accepted() -> None: - client = TestClient( - create_app( - settings=settings(), - store=FakeTelemetryStore(), - huggingface_authenticator=StubHuggingFaceAuthenticator(), - ) - ) - token = exchange(client) - register(client, token) - response = client.post( - "/v1/runs/route-a-1/events", - headers={"Authorization": f"Bearer {token}"}, - json={ - "events": [ - _blocked_event( - { - "phase": "preflight", - "blocking_failure_count": 2, - "blocking_gate_ids": [], - } - ) - ] - }, - ) - - assert response.status_code == 202 - - -def test_health_does_not_require_credentials() -> None: - client = TestClient( - create_app( - settings=settings(), - store=FakeTelemetryStore(), - huggingface_authenticator=StubHuggingFaceAuthenticator(), - ) - ) - response = client.get("/health") - assert response.status_code == 200 - assert response.json() == {"status": "ok"} - - -def test_maintenance_mode_keeps_health_available_and_rejects_data_requests() -> None: - client = TestClient( - create_app( - settings=replace(settings(), maintenance_mode=True), - store=FakeTelemetryStore(), - huggingface_authenticator=StubHuggingFaceAuthenticator(), - ) - ) - - assert client.get("/health").status_code == 200 - unavailable = client.post( - "/v1/auth/huggingface/exchange", - headers={"Authorization": "Bearer hf_example"}, - ) - assert unavailable.status_code == 503 - assert unavailable.headers["Retry-After"] == "60" - assert client.get("/ready").status_code == 503 - - -def test_readiness_checks_store_without_credentials() -> None: - store = FakeTelemetryStore() - client = TestClient( - create_app( - settings=settings(), - store=store, - huggingface_authenticator=StubHuggingFaceAuthenticator(), - ) - ) - - assert client.get("/ready").json() == {"status": "ok"} - - store.is_ready = lambda: False # type: ignore[method-assign] - unavailable = client.get("/ready") - assert unavailable.status_code == 503 - assert unavailable.json()["detail"] == ( - "Telemetry database is unavailable or not fully migrated." - ) - - -def test_streamed_request_body_is_bounded() -> None: - client = TestClient( - create_app( - settings=settings(), - store=FakeTelemetryStore(), - huggingface_authenticator=StubHuggingFaceAuthenticator(), - ) - ) - - def chunks(): - yield b'{"events":[{"details":"' - yield b"x" * 1_048_576 - yield b'"}]}' - - response = client.post( - "/v1/runs/unregistered/events", - headers={"Authorization": "Bearer invalid"}, - content=chunks(), - ) - - assert response.status_code == 413 - - -def test_run_pagination_does_not_skip_equal_timestamps(monkeypatch) -> None: - fixed = datetime(2026, 10, 2, tzinfo=UTC) - monkeypatch.setattr("telemetry_collector.storage._now", lambda: fixed) - store = FakeTelemetryStore() - principal = HuggingFacePrincipal( - user_id="hf-user-1", - username="builder", - organizations=("policyengine",), - ) - for index in range(205): - store.register_run( - RunRegistration( - run_id=f"run-{index:03d}", - producer_id="producer-1", - country_code="US", - pipeline="us-fiscal-refresh", - ), - principal, - ) - client = TestClient( - create_app( - settings=settings(), - store=store, - huggingface_authenticator=StubHuggingFaceAuthenticator(), - ) - ) - headers = {"X-Telemetry-Read-Token": "dashboard-read-token"} - - first = client.get("/v1/runs?country=US&limit=200", headers=headers) - cursor = first.json()["next_before"] - second = client.get( - f"/v1/runs?country=US&limit=200&before={cursor}", headers=headers - ) - - run_ids = [run["run_id"] for run in first.json()["runs"]] - run_ids.extend(run["run_id"] for run in second.json()["runs"]) - assert len(run_ids) == 205 - assert len(set(run_ids)) == 205 - assert second.json()["next_before"] is None - - -def test_run_pagination_is_stable_when_an_older_run_updates(monkeypatch) -> None: - timestamps = iter(datetime(2026, 10, day, tzinfo=UTC) for day in (1, 2, 3)) - monkeypatch.setattr("telemetry_collector.storage._now", lambda: next(timestamps)) - store = FakeTelemetryStore() - principal = HuggingFacePrincipal( - user_id="hf-user-1", - username="builder", - organizations=("policyengine",), - ) - for run_id in ("old", "middle", "new"): - store.register_run( - RunRegistration( - run_id=run_id, - producer_id="producer-1", - country_code="US", - pipeline="us-fiscal-refresh", - ), - principal, - ) - client = TestClient( - create_app( - settings=settings(), - store=store, - huggingface_authenticator=StubHuggingFaceAuthenticator(), - ) - ) - headers = {"X-Telemetry-Read-Token": "dashboard-read-token"} - - first = client.get("/v1/runs?country=US&limit=2", headers=headers) - store.append_events( - "old", - "hf-user-1", - [ - TelemetryEvent( - schema_version=1, - event_id="old-updated", - run_id="old", - producer_id="producer-1", - sequence=1, - timestamp=datetime(2026, 10, 5, tzinfo=UTC), - event_type="heartbeat", - stage_id=None, - status="progress", - details={}, - ) - ], - ) - second = client.get( - f"/v1/runs?country=US&limit=2&before={first.json()['next_before']}", - headers=headers, - ) - - assert [run["run_id"] for run in first.json()["runs"]] == ["new", "middle"] - assert [run["run_id"] for run in second.json()["runs"]] == ["old"] - - -def _encoded_cursor(value: object) -> str: - return base64.urlsafe_b64encode(json.dumps(value).encode()).decode().rstrip("=") - - -@pytest.mark.parametrize( - "cursor", - [ - "not-base64!", - _encoded_cursor({}), - _encoded_cursor("cursor"), - _encoded_cursor([]), - _encoded_cursor(["2026-10-01T00:00:00+00:00"]), - _encoded_cursor(["2026-10-01T00:00:00+00:00", "run", "extra"]), - _encoded_cursor(["not-a-timestamp", "run"]), - _encoded_cursor(["2026-10-01T00:00:00", "run"]), - _encoded_cursor(["2026-10-01T00:00:00+00:00", "bad run"]), - ], -) -def test_invalid_pagination_cursor_returns_422(cursor: str) -> None: - client = TestClient( - create_app( - settings=settings(), - store=FakeTelemetryStore(), - huggingface_authenticator=StubHuggingFaceAuthenticator(), - ) - ) - - response = client.get( - f"/v1/runs?before={cursor}", - headers={"X-Telemetry-Read-Token": "dashboard-read-token"}, - ) - - assert response.status_code == 422 - assert response.json() == {"detail": "Pagination cursor is invalid."} diff --git a/telemetry-service/tests/test_auth.py b/telemetry-service/tests/test_auth.py deleted file mode 100644 index 492fc540..00000000 --- a/telemetry-service/tests/test_auth.py +++ /dev/null @@ -1,61 +0,0 @@ -import httpx -import pytest - -from telemetry_collector.auth import ( - HuggingFaceAuthenticationError, - HuggingFaceAuthenticationUnavailableError, - HuggingFaceAuthenticator, -) - - -def test_whoami_resolves_organization_membership(monkeypatch) -> None: - calls: list[tuple[str, dict[str, str]]] = [] - - def fake_get(url, *, headers, timeout, follow_redirects): - calls.append((url, headers)) - return httpx.Response( - 200, - json={ - "id": "user-id", - "name": "builder", - "orgs": [{"name": "policyengine"}, {"name": "another-org"}], - }, - ) - - monkeypatch.setattr(httpx, "get", fake_get) - principal = HuggingFaceAuthenticator().authenticate( - "hf-opaque-secret", "policyengine" - ) - - assert principal.user_id == "user-id" - assert principal.organizations == ("policyengine", "another-org") - assert calls == [ - ( - "https://huggingface.co/api/whoami-v2", - {"Authorization": "Bearer hf-opaque-secret"}, - ) - ] - - -def test_invalid_hugging_face_token_is_not_echoed(monkeypatch) -> None: - monkeypatch.setattr( - httpx, - "get", - lambda *args, **kwargs: httpx.Response(401, json={"error": "bad token"}), - ) - - with pytest.raises(HuggingFaceAuthenticationError) as captured: - HuggingFaceAuthenticator().authenticate("hf-private-value", "policyengine") - - assert "hf-private-value" not in str(captured.value) - - -def test_hugging_face_outage_is_distinct_from_an_invalid_token(monkeypatch) -> None: - monkeypatch.setattr( - httpx, - "get", - lambda *args, **kwargs: httpx.Response(503, json={"error": "unavailable"}), - ) - - with pytest.raises(HuggingFaceAuthenticationUnavailableError): - HuggingFaceAuthenticator().authenticate("hf-private-value", "policyengine") diff --git a/telemetry-service/tests/test_database.py b/telemetry-service/tests/test_database.py deleted file mode 100644 index 09240e8b..00000000 --- a/telemetry-service/tests/test_database.py +++ /dev/null @@ -1,83 +0,0 @@ -from __future__ import annotations - -import ast -from pathlib import Path - -from sqlalchemy import inspect -from sqlalchemy.pool import QueuePool - -from telemetry_collector.database import ( - DB_MAX_OVERFLOW, - DB_POOL_SIZE, - Base, - TelemetryEvent, - TelemetryProducer, - TelemetryRun, - create_database_engine, - normalize_database_url, -) - - -def test_orm_metadata_defines_runs_producers_and_events() -> None: - assert set(Base.metadata.tables) == { - "telemetry_runs", - "telemetry_producers", - "telemetry_events", - } - assert set(inspect(TelemetryRun).relationships.keys()) == {"producers"} - assert set(inspect(TelemetryProducer).relationships.keys()) == {"run", "events"} - assert set(inspect(TelemetryEvent).relationships.keys()) == {"producer"} - - -def test_events_are_bound_to_a_producer_and_sequence() -> None: - event_table = TelemetryEvent.__table__ - foreign_keys = { - (tuple(constraint.column_keys), constraint.referred_table.name) - for constraint in event_table.foreign_key_constraints - } - unique_columns = { - tuple(column.name for column in constraint.columns) - for constraint in event_table.constraints - if constraint.__class__.__name__ == "UniqueConstraint" - } - - assert (("run_id", "producer_id"), "telemetry_producers") in foreign_keys - assert ("run_id", "producer_id", "sequence") in unique_columns - - -def test_database_url_selects_the_synchronous_psycopg_dialect() -> None: - normalized = normalize_database_url( - "postgresql://collector:secret@database.example/telemetry" - ) - - assert normalized.drivername == "postgresql+psycopg" - assert normalized.password == "secret" - - -def test_engine_uses_sqlalchemy_queue_pool() -> None: - engine = create_database_engine( - "postgresql://collector:secret@database.example/telemetry" - ) - try: - assert isinstance(engine.pool, QueuePool) - assert engine.pool.size() == DB_POOL_SIZE - assert engine.pool._max_overflow == DB_MAX_OVERFLOW - finally: - engine.dispose() - - -def test_application_modules_do_not_import_psycopg_directly() -> None: - package = Path(__file__).resolve().parents[1] / "telemetry_collector" - imported_modules: set[str] = set() - for source in package.glob("*.py"): - tree = ast.parse(source.read_text(encoding="utf-8")) - for node in ast.walk(tree): - if isinstance(node, ast.Import): - imported_modules.update(alias.name for alias in node.names) - elif isinstance(node, ast.ImportFrom) and node.module: - imported_modules.add(node.module) - - assert not any( - module == "psycopg" or module.startswith("psycopg.") - for module in imported_modules - ) diff --git a/telemetry-service/tests/test_deploy_script.py b/telemetry-service/tests/test_deploy_script.py deleted file mode 100644 index 3f8005db..00000000 --- a/telemetry-service/tests/test_deploy_script.py +++ /dev/null @@ -1,252 +0,0 @@ -import os -import subprocess -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -DEPLOY_SCRIPT = ROOT / "telemetry-service" / "scripts" / "deploy.sh" - - -def run_deploy( - command: str, - *, - environment: dict[str, str] | None = None, -) -> subprocess.CompletedProcess[str]: - env = {"PATH": os.environ["PATH"]} - env.update(environment or {}) - return subprocess.run( - ["bash", str(DEPLOY_SCRIPT), command], - cwd=ROOT, - env=env, - capture_output=True, - check=False, - text=True, - ) - - -def install_command_stub(directory: Path, name: str, body: str) -> None: - command = directory / name - command.write_text(f"#!/usr/bin/env bash\nset -Eeuo pipefail\n{body}\n") - command.chmod(0o755) - - -def test_deploy_script_has_valid_bash_syntax_and_is_executable() -> None: - result = subprocess.run( - ["bash", "-n", str(DEPLOY_SCRIPT)], - capture_output=True, - check=False, - text=True, - ) - - assert result.returncode == 0, result.stderr - assert os.access(DEPLOY_SCRIPT, os.X_OK) - - -def test_require_configuration_lists_every_missing_variable() -> None: - result = run_deploy("require-configuration") - - assert result.returncode == 1 - assert result.stderr == ( - "Missing telemetry deployment configuration: PROJECT_ID REGION " - "ARTIFACT_REGISTRY_NAME CLOUD_SQL_CONNECTION RUNTIME_SERVICE_ACCOUNT " - "MIGRATION_SERVICE_ACCOUNT SERVICE MIGRATION_JOB IMAGE_NAME " - "DATABASE_SECRET_NAME MIGRATION_DATABASE_SECRET_NAME JWT_SECRET_NAME " - "READ_SECRET_NAME DEPLOYMENT_ENVIRONMENT " - "PRODUCTION_CLOUD_SQL_CONNECTION PRODUCTION_SERVICE\n" - ) - - -def test_require_configuration_accepts_complete_environment() -> None: - result = run_deploy( - "require-configuration", - environment={ - "PROJECT_ID": "project", - "REGION": "region", - "ARTIFACT_REGISTRY_NAME": "container-images", - "CLOUD_SQL_CONNECTION": "connection", - "RUNTIME_SERVICE_ACCOUNT": "service-account", - "MIGRATION_SERVICE_ACCOUNT": "migration-service-account", - "SERVICE": "service", - "MIGRATION_JOB": "migration-job", - "IMAGE_NAME": "image", - "DATABASE_SECRET_NAME": "database-secret", - "MIGRATION_DATABASE_SECRET_NAME": "migration-database-secret", - "JWT_SECRET_NAME": "jwt-secret", - "READ_SECRET_NAME": "read-secret", - "DEPLOYMENT_ENVIRONMENT": "staging", - "PRODUCTION_CLOUD_SQL_CONNECTION": "project:region:production", - "PRODUCTION_SERVICE": "production-service", - }, - ) - - assert result.returncode == 0, result.stderr - - -def test_build_and_push_image_records_the_immutable_uri(tmp_path: Path) -> None: - command_log = tmp_path / "commands.log" - github_output = tmp_path / "github-output" - install_command_stub( - tmp_path, - "docker", - "printf 'docker' >> \"$COMMAND_LOG\"\n" - 'printf \' <%s>\' "$@" >> "$COMMAND_LOG"\n' - "printf '\\n' >> \"$COMMAND_LOG\"", - ) - - result = run_deploy( - "build-and-push-image", - environment={ - "PATH": f"{tmp_path}:{os.environ['PATH']}", - "COMMAND_LOG": str(command_log), - "GITHUB_OUTPUT": str(github_output), - "GITHUB_SHA": "abc123", - "REGION": "us-central1", - "PROJECT_ID": "example-project", - "ARTIFACT_REGISTRY_NAME": "containers", - "IMAGE_NAME": "collector", - }, - ) - - image = "us-central1-docker.pkg.dev/example-project/containers/collector:abc123" - assert result.returncode == 0, result.stderr - assert command_log.read_text().splitlines() == [ - "docker <--file> " - f"<--tag> <{image}> ", - f"docker <{image}>", - ] - assert github_output.read_text() == f"uri={image}\n" - - -def test_route_service_traffic_passes_the_requested_revision( - tmp_path: Path, -) -> None: - command_log = tmp_path / "commands.log" - install_command_stub( - tmp_path, - "gcloud", - "printf 'gcloud' >> \"$COMMAND_LOG\"\n" - 'printf \' <%s>\' "$@" >> "$COMMAND_LOG"\n' - "printf '\\n' >> \"$COMMAND_LOG\"", - ) - - result = run_deploy( - "route-service-traffic", - environment={ - "PATH": f"{tmp_path}:{os.environ['PATH']}", - "COMMAND_LOG": str(command_log), - "SERVICE": "microcosm-telemetry", - "PROJECT_ID": "example-project", - "REGION": "us-central1", - "REVISION": "microcosm-telemetry-00042", - }, - ) - - assert result.returncode == 0, result.stderr - assert command_log.read_text() == ( - "gcloud " - "<--project> <--region> " - "<--to-revisions> <--quiet>\n" - ) - - -def test_staging_target_must_not_use_production_resources() -> None: - result = run_deploy( - "validate-environment-target", - environment={ - "DEPLOYMENT_ENVIRONMENT": "staging", - "CLOUD_SQL_CONNECTION": "project:region:production", - "PRODUCTION_CLOUD_SQL_CONNECTION": "project:region:production", - "SERVICE": "staging-service", - "PRODUCTION_SERVICE": "production-service", - }, - ) - - assert result.returncode == 1 - assert "Staging Cloud SQL target matches production" in result.stderr - - -def test_production_target_must_match_declared_production_resources() -> None: - result = run_deploy( - "validate-environment-target", - environment={ - "DEPLOYMENT_ENVIRONMENT": "production", - "CLOUD_SQL_CONNECTION": "project:region:not-production", - "PRODUCTION_CLOUD_SQL_CONNECTION": "project:region:production", - "SERVICE": "production-service", - "PRODUCTION_SERVICE": "production-service", - }, - ) - - assert result.returncode == 1 - assert "Production Cloud SQL target does not match" in result.stderr - - -def test_migration_job_uses_separate_identity_and_database_secret( - tmp_path: Path, -) -> None: - command_log = tmp_path / "commands.log" - install_command_stub( - tmp_path, - "gcloud", - "printf 'gcloud' >> \"$COMMAND_LOG\"\n" - 'printf \' <%s>\' "$@" >> "$COMMAND_LOG"\n' - "printf '\\n' >> \"$COMMAND_LOG\"", - ) - - result = run_deploy( - "configure-migration-job", - environment={ - "PATH": f"{tmp_path}:{os.environ['PATH']}", - "COMMAND_LOG": str(command_log), - "MIGRATION_JOB": "migration-job", - "PROJECT_ID": "project", - "REGION": "region", - "IMAGE_URI": "registry/image:sha", - "MIGRATION_SERVICE_ACCOUNT": "migrator@example.invalid", - "CLOUD_SQL_CONNECTION": "project:region:database", - "MIGRATION_DATABASE_SECRET_NAME": "migration-database-url", - }, - ) - - assert result.returncode == 0, result.stderr - command = command_log.read_text() - assert "<--service-account> " in command - assert "<--set-secrets> " in command - assert "runtime" not in command - - -def test_database_backup_uses_instance_from_connection_name(tmp_path: Path) -> None: - command_log = tmp_path / "commands.log" - install_command_stub( - tmp_path, - "gcloud", - "printf 'gcloud' >> \"$COMMAND_LOG\"\n" - 'printf \' <%s>\' "$@" >> "$COMMAND_LOG"\n' - "printf '\\n' >> \"$COMMAND_LOG\"", - ) - - result = run_deploy( - "create-database-backup", - environment={ - "PATH": f"{tmp_path}:{os.environ['PATH']}", - "COMMAND_LOG": str(command_log), - "PROJECT_ID": "project", - "CLOUD_SQL_CONNECTION": "project:region:telemetry-production", - "GITHUB_RUN_ID": "123", - "GITHUB_RUN_ATTEMPT": "2", - }, - ) - - assert result.returncode == 0, result.stderr - assert command_log.read_text() == ( - "gcloud <--project> " - "<--instance> " - "<--description> <--quiet>\n" - ) - - -def test_unknown_command_fails_with_usage() -> None: - result = run_deploy("not-a-command") - - assert result.returncode == 2 - assert "Unknown deployment command: not-a-command" in result.stderr - assert "Usage: deploy.sh COMMAND" in result.stderr diff --git a/telemetry-service/tests/test_deployment_workflow.py b/telemetry-service/tests/test_deployment_workflow.py deleted file mode 100644 index 49fafa06..00000000 --- a/telemetry-service/tests/test_deployment_workflow.py +++ /dev/null @@ -1,169 +0,0 @@ -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -WORKFLOW = ROOT / ".github" / "workflows" / "telemetry-service.yml" -DOCKERFILE = ROOT / "telemetry-service" / "Dockerfile" -DEPLOY_SCRIPT = ROOT / "telemetry-service" / "scripts" / "deploy.sh" - - -def test_service_workflow_is_path_scoped_and_uses_an_isolated_build_context() -> None: - workflow = WORKFLOW.read_text(encoding="utf-8") - deploy_script = DEPLOY_SCRIPT.read_text(encoding="utf-8") - - # Only pull requests are path-scoped: the workflow no longer runs on push. - assert workflow.count("- telemetry-service/**") == 1 - assert "github.ref == 'refs/heads/main'" in workflow - assert "working-directory: telemetry-service" in workflow - assert "docker build \\" in deploy_script - assert "--file telemetry-service/Dockerfile \\" in deploy_script - assert '--tag "$image" \\' in deploy_script - assert "telemetry-service" in deploy_script - assert ( - 'docker build --file telemetry-service/Dockerfile --tag "$image" .' - not in deploy_script - ) - - -def test_service_deployment_uses_oidc_and_verifies_before_promotion() -> None: - workflow = WORKFLOW.read_text(encoding="utf-8") - deploy_script = DEPLOY_SCRIPT.read_text(encoding="utf-8") - - assert "id-token: write" in workflow - assert "uses: google-github-actions/auth@v2" in workflow - assert "--default-url" in deploy_script - assert "httpGet.path=/health" in deploy_script - assert '"${url}/ready"' in deploy_script - assert '"${stable_url}/ready"' in deploy_script - assert "--no-traffic" in deploy_script - assert "TELEMETRY_MAINTENANCE_MODE=1" in deploy_script - assert "TELEMETRY_MAINTENANCE_MODE=0" in deploy_script - assert '"${url}/health"' in deploy_script - assert '"${url}/v1/auth/huggingface/exchange"' in deploy_script - assert workflow.index("Resolve and verify the candidate revision") < workflow.index( - "Promote the verified revision" - ) - assert "Restore the previous revision" not in workflow - - -def test_service_deployment_checks_secret_existence_before_changing_traffic() -> None: - workflow = WORKFLOW.read_text(encoding="utf-8") - - staging_start = workflow.index(" deploy-staging:") - production_start = workflow.index(" deploy-production:") - staging_job = workflow[staging_start:production_start] - production_job = workflow[production_start:] - - assert "environment: staging" in staging_job - assert "image_uri: ${{ steps.image.outputs.uri }}" in staging_job - assert "build-and-push-image" in staging_job - - assert "needs: deploy-staging" in production_job - assert "environment: Production" in production_job - assert "needs.deploy-staging.outputs.image_uri" in production_job - assert "build-and-push-image" not in production_job - assert "create-database-backup" in production_job - for job in (staging_job, production_job): - assert job.index("setup-gcloud@v2") < job.index("check-secrets-exist") - assert job.index("check-secrets-exist") < job.index("deploy-maintenance") - assert job.index("check-secrets-exist") < job.index("apply-migrations") - - -def test_service_deployment_reads_resource_names_from_environment_variables() -> None: - workflow = WORKFLOW.read_text(encoding="utf-8") - - expected_variables = { - "ARTIFACT_REGISTRY_NAME": "TELEMETRY_ARTIFACT_REGISTRY_NAME", - "SERVICE": "TELEMETRY_SERVICE", - "MIGRATION_JOB": "TELEMETRY_MIGRATION_JOB", - "IMAGE_NAME": "TELEMETRY_IMAGE_NAME", - "DATABASE_SECRET_NAME": "TELEMETRY_DATABASE_SECRET_NAME", - "JWT_SECRET_NAME": "TELEMETRY_JWT_SECRET_NAME", - "READ_SECRET_NAME": "TELEMETRY_READ_SECRET_NAME", - "MIGRATION_SERVICE_ACCOUNT": "TELEMETRY_MIGRATION_SERVICE_ACCOUNT", - "MIGRATION_DATABASE_SECRET_NAME": ("TELEMETRY_MIGRATION_DATABASE_SECRET_NAME"), - "PRODUCTION_CLOUD_SQL_CONNECTION": ( - "TELEMETRY_PRODUCTION_CLOUD_SQL_CONNECTION" - ), - "PRODUCTION_SERVICE": "TELEMETRY_PRODUCTION_SERVICE", - } - for environment_name, github_variable_name in expected_variables.items(): - assert f"{environment_name}: ${{{{ vars.{github_variable_name} }}}}" in workflow - - -def test_telemetry_service_uses_store_vocabulary() -> None: - forbidden_term = "repo" + "sitory" - source_suffixes = {".ini", ".md", ".py", ".sh", ".toml", ".yml"} - source_files = [ - path - for path in (ROOT / "telemetry-service").rglob("*") - if path.is_file() - and path.suffix in source_suffixes - and not {".pytest_cache", ".ruff_cache", ".venv"}.intersection(path.parts) - ] - source_files.append(WORKFLOW) - - matches = [ - str(path.relative_to(ROOT)) - for path in source_files - if forbidden_term in path.read_text(encoding="utf-8").lower() - or forbidden_term in path.name.lower() - ] - - assert matches == [] - - -def test_service_workflow_hard_cuts_over_before_migration() -> None: - workflow = WORKFLOW.read_text(encoding="utf-8") - deploy_script = DEPLOY_SCRIPT.read_text(encoding="utf-8") - - assert "image: postgres:16-alpine" in workflow - assert "TEST_DATABASE_URL:" in workflow - assert "uv run alembic check" in workflow - assert "--set-cloudsql-instances" in deploy_script - assert '"run,--no-sync,python,-m,telemetry_collector.migrate"' in deploy_script - assert "--max-retries 0" in deploy_script - assert "--wait" in deploy_script - assert "--max 4" in deploy_script - assert workflow.index("Route traffic to maintenance mode") < workflow.index( - "Apply database migrations" - ) - assert workflow.index("Wait for old requests to finish") < workflow.index( - "Apply database migrations" - ) - assert workflow.index("Apply database migrations") < workflow.index( - "Deploy an isolated candidate revision" - ) - assert workflow.count("steps.image.outputs.uri") >= 2 - - -def test_service_workflow_has_no_multiline_run_blocks() -> None: - workflow = WORKFLOW.read_text(encoding="utf-8") - - assert "run: |" not in workflow - assert "run: >" not in workflow - assert workflow.count("run: telemetry-service/scripts/deploy.sh") == 28 - - for command in ( - "require-configuration", - "validate-environment-target", - "check-secrets-exist", - "build-and-push-image", - "deploy-maintenance", - "resolve-and-verify-maintenance", - "wait-for-cutover", - "configure-migration-job", - "apply-migrations", - "deploy-candidate", - "resolve-and-verify-candidate", - "route-service-traffic", - "verify-stable-service", - "create-database-backup", - ): - assert f"run: telemetry-service/scripts/deploy.sh {command}" in workflow - - -def test_service_image_contains_alembic_runtime_files() -> None: - dockerfile = DOCKERFILE.read_text(encoding="utf-8") - - assert "COPY alembic.ini ./" in dockerfile - assert "COPY migrations ./migrations" in dockerfile diff --git a/telemetry-service/tests/test_incremental_batch_apply.py b/telemetry-service/tests/test_incremental_batch_apply.py deleted file mode 100644 index ca52f7ad..00000000 --- a/telemetry-service/tests/test_incremental_batch_apply.py +++ /dev/null @@ -1,307 +0,0 @@ -"""Incremental event-batch application must equal full re-materialization. - -The store folds each accepted batch onto a run's materialized columns instead -of replaying the run. These tests deliver event schedules to an in-memory run -that de-duplicates events as the store does, and after every batch compare its -columns with ``materialize_run`` over every stored event. -""" - -from __future__ import annotations - -import random -from collections.abc import Sequence -from copy import deepcopy -from datetime import datetime, timedelta -from typing import Any - -from telemetry_collector.auth import HuggingFacePrincipal -from telemetry_collector.models import RunRegistration -from telemetry_collector.storage import ( - MATERIALIZED_RUN_COLUMNS, - StoredProducer, - apply_event_batch, - initial_run_state, - materialize_run, -) -from tests.event_schedules import ( - EVENT_KINDS, - ONE_PRODUCER, - RESTARTED_PRODUCERS, - RUN_REGISTERED_AT, - Event, - complete_stage, - every_kind_stream, - heartbeat, - in_batches, - long_stream, - out_of_order_schedules, - progress_with_totals, - random_delivery_schedule, - restarted_producer_schedules, - run_completed, - run_failed, -) - - -class CountingStoredEvents: - """In-memory stored-event reader that counts what the reducer reads.""" - - def __init__(self, stored_events: dict[str, Event]) -> None: - self._stored_events = stored_events - self.events_read = 0 - self.earliest_timestamp_reads = 0 - - def events_from(self, producer_id: str, sequence: int) -> list[Event]: - selected = sorted( - ( - event - for event in self._stored_events.values() - if event["producer_id"] == producer_id and event["sequence"] >= sequence - ), - key=lambda event: (event["sequence"], event["event_id"]), - ) - self.events_read += len(selected) - return deepcopy(selected) - - def earliest_timestamp(self, producer_id: str) -> datetime: - self.earliest_timestamp_reads += 1 - return min( - event["timestamp"] - for event in self._stored_events.values() - if event["producer_id"] == producer_id - ) - - -class IncrementalRun: - """One run stored as the collector stores it, folding each accepted batch.""" - - def __init__(self, producer_registrations: dict[str, datetime]) -> None: - self.state = initial_run_state( - RunRegistration( - run_id="run-1", - producer_id="producer-a", - country_code="UK", - pipeline="uk-calibration", - ), - HuggingFacePrincipal( - user_id="owner-1", - username="builder", - organizations=("policyengine",), - ), - RUN_REGISTERED_AT, - ) - self.producer_registrations = producer_registrations - self.stored_events: dict[str, Event] = {} - self.reader = CountingStoredEvents(self.stored_events) - - def deliver(self, batch: Sequence[Event]) -> tuple[int, int]: - """Store the batch's new events and fold them; return (accepted, duplicates).""" - - producers = [ - StoredProducer( - producer_id=producer_id, - registered_at=registered_at, - last_sequence=max( - ( - event["sequence"] - for event in self.stored_events.values() - if event["producer_id"] == producer_id - ), - default=None, - ), - ) - for producer_id, registered_at in self.producer_registrations.items() - ] - stored_sequences = { - (event["producer_id"], event["sequence"]) - for event in self.stored_events.values() - } - accepted_events = [] - for event in batch: - sequence_key = (event["producer_id"], event["sequence"]) - if event["event_id"] in self.stored_events or ( - sequence_key in stored_sequences - ): - continue - stored_event = { - **deepcopy(event), - "producer_registered_at": self.producer_registrations[ - event["producer_id"] - ], - } - self.stored_events[event["event_id"]] = stored_event - stored_sequences.add(sequence_key) - accepted_events.append(deepcopy(stored_event)) - if accepted_events: - self.state.update( - apply_event_batch(self.state, producers, accepted_events, self.reader) - ) - return len(accepted_events), len(batch) - len(accepted_events) - - def stored_columns(self) -> dict[str, Any]: - return {column: self.state[column] for column in MATERIALIZED_RUN_COLUMNS} - - def fully_materialized_columns(self) -> dict[str, Any]: - materialized = materialize_run(self.state, self.stored_events.values()) - return {column: materialized[column] for column in MATERIALIZED_RUN_COLUMNS} - - -def deliver_and_compare( - producer_registrations: dict[str, datetime], - schedule: Sequence[Sequence[Event]], - description: str, -) -> IncrementalRun: - """Deliver every batch, checking the stored columns after each one.""" - - run = IncrementalRun(producer_registrations) - for batch_number, batch in enumerate(schedule, start=1): - run.deliver(batch) - assert run.stored_columns() == run.fully_materialized_columns(), ( - f"{description}: batch {batch_number} of {len(schedule)}" - ) - return run - - -def test_incremental_batch_apply_matches_full_materialization_for_every_event_kind() -> ( - None -): - kind_orders = [list(EVENT_KINDS.values())] - for order_seed in range(8): - shuffled_kinds = list(EVENT_KINDS.values()) - random.Random(order_seed).shuffle(shuffled_kinds) - kind_orders.append(shuffled_kinds) - - for order_number, kinds in enumerate(kind_orders): - stream = every_kind_stream("producer-a", kinds) - for batch_size in (1, 2, 3, 5, len(stream)): - deliver_and_compare( - ONE_PRODUCER, - in_batches(stream, batch_size), - f"kind order {order_number}, batches of {batch_size}", - ) - - -def test_incremental_batch_apply_matches_full_materialization_for_out_of_order_batches() -> ( - None -): - for description, schedule in out_of_order_schedules().items(): - deliver_and_compare(ONE_PRODUCER, schedule, description) - - -def test_incremental_batch_apply_matches_full_materialization_for_duplicate_resends() -> ( - None -): - stream = every_kind_stream("producer-a") - run = IncrementalRun(ONE_PRODUCER) - - for batch in in_batches(stream, 4): - assert run.deliver(batch) == (len(batch), 0) - assert run.stored_columns() == run.fully_materialized_columns() - before_resend = run.stored_columns() - assert run.deliver(batch) == (0, len(batch)) - assert run.stored_columns() == before_resend - - assert run.deliver(stream) == (0, len(stream)) - conflicting_sequence = run_failed("producer-a", 3, 500.0) | { - "event_id": "producer-a-3-resent-with-another-id" - } - assert run.deliver([conflicting_sequence]) == (0, 1) - assert run.stored_columns() == run.fully_materialized_columns() - - next_sequence = len(stream) + 1 - new_events = [ - progress_with_totals("producer-a", next_sequence, 600.0), - heartbeat("producer-a", next_sequence + 1, 601.0), - ] - overlapping_batch = [stream[-1], new_events[0], new_events[0], new_events[1]] - assert run.deliver(overlapping_batch) == (2, 2) - assert run.stored_columns() == run.fully_materialized_columns() - - late_resend_with_late_event = [ - *stream[:3], - complete_stage("producer-a", next_sequence + 3, 602.0), - run_completed("producer-a", next_sequence + 2, 603.0), - ] - assert run.deliver(late_resend_with_late_event) == (2, 3) - assert run.stored_columns() == run.fully_materialized_columns() - - -def test_incremental_batch_apply_matches_full_materialization_across_restarted_producers() -> ( - None -): - for description, schedule in restarted_producer_schedules().items(): - deliver_and_compare(RESTARTED_PRODUCERS, schedule, description) - - -def test_incremental_batch_apply_matches_full_materialization_for_random_delivery_schedules() -> ( - None -): - kinds_covered: set[str] = set() - for schedule_seed in range(300): - registrations, schedule, kinds_used = random_delivery_schedule( - random.Random(schedule_seed) - ) - kinds_covered |= kinds_used - deliver_and_compare(registrations, schedule, f"schedule seed {schedule_seed}") - - assert kinds_covered == set(EVENT_KINDS) - - -def test_incremental_batch_apply_reads_no_stored_events_for_in_order_batches() -> None: - first_attempt = long_stream("producer-a", 600) - second_attempt = long_stream("producer-b", 600, first_minute=61.0) - - run = deliver_and_compare( - RESTARTED_PRODUCERS, - [*in_batches(first_attempt, 20), *in_batches(second_attempt, 20)], - "two in-order attempts", - ) - - assert run.reader.events_read == 0 - assert run.reader.earliest_timestamp_reads == 0 - - -def test_incremental_batch_apply_reads_only_the_events_after_a_late_event() -> None: - events_read_by_run_length = {} - for run_length in (60, 600): - stream = long_stream("producer-a", run_length) - late_event = stream[-6] - run = deliver_and_compare( - ONE_PRODUCER, - in_batches([event for event in stream if event is not late_event], 20), - f"{run_length} events with one withheld", - ) - assert run.reader.events_read == 0 - - assert run.deliver([late_event]) == (1, 0) - - assert run.stored_columns() == run.fully_materialized_columns() - events_read_by_run_length[run_length] = run.reader.events_read - - # The late event and the five stored after it, however long the run is. - assert events_read_by_run_length == {60: 6, 600: 6} - - -def test_incremental_batch_apply_reads_the_earliest_timestamp_once_when_an_earlier_producer_first_reports() -> ( - None -): - registrations = { - "producer-a": RUN_REGISTERED_AT, - "producer-b": RUN_REGISTERED_AT + timedelta(minutes=30), - } - later_attempt = long_stream("producer-b", 300, first_minute=40.0) - earlier_attempt = long_stream("producer-a", 30) - run = deliver_and_compare( - registrations, in_batches(later_attempt, 20), "later attempt first" - ) - assert run.state["started_at"] == RUN_REGISTERED_AT - - for batch in in_batches(earlier_attempt, 5): - run.deliver(batch) - assert run.stored_columns() == run.fully_materialized_columns() - - # Once an earlier attempt has events, the later one's segment starts at its - # own earliest event rather than at the run's registration. - assert run.state["started_at"] == min(event["timestamp"] for event in later_attempt) - assert run.reader.earliest_timestamp_reads == 1 - assert run.reader.events_read == 0 diff --git a/telemetry-service/tests/test_migrate.py b/telemetry-service/tests/test_migrate.py deleted file mode 100644 index 6b99ce90..00000000 --- a/telemetry-service/tests/test_migrate.py +++ /dev/null @@ -1,140 +0,0 @@ -from __future__ import annotations - -from unittest.mock import Mock - -import sqlalchemy as sa -from sqlalchemy.dialects.postgresql import JSONB - -from telemetry_collector.migrate import SchemaState, classify_schema - - -def _legacy_inspector() -> Mock: - inspector = Mock() - inspector.get_table_names.return_value = ["telemetry_runs", "telemetry_events"] - column_types = { - "telemetry_runs": { - "run_id": sa.Text(), - "country_code": sa.Text(), - "pipeline": sa.Text(), - "candidate_id": sa.Text(), - "release_id": sa.Text(), - "run_kind": sa.Text(), - "owner_hf_id": sa.Text(), - "owner_hf_username": sa.Text(), - "status": sa.Text(), - "current_stage": sa.Text(), - "started_at": sa.DateTime(timezone=True), - "updated_at": sa.DateTime(timezone=True), - "ended_at": sa.DateTime(timezone=True), - "heartbeat_at": sa.DateTime(timezone=True), - "resources": JSONB(), - "work": JSONB(), - "failure": JSONB(), - }, - "telemetry_events": { - "event_id": sa.Text(), - "run_id": sa.Text(), - "producer_id": sa.Text(), - "sequence": sa.BigInteger(), - "emitted_at": sa.DateTime(timezone=True), - "received_at": sa.DateTime(timezone=True), - "event_type": sa.Text(), - "stage_id": sa.Text(), - "status": sa.Text(), - "message": sa.Text(), - "details": JSONB(), - "resources": JSONB(), - }, - } - nullable = { - "candidate_id", - "release_id", - "ended_at", - "heartbeat_at", - "resources", - "work", - "failure", - "stage_id", - "message", - } - inspector.get_columns.side_effect = lambda table: [ - {"name": name, "type": type_, "nullable": name in nullable} - for name, type_ in column_types[table].items() - ] - inspector.get_pk_constraint.side_effect = lambda table: { - "constrained_columns": ["run_id"] if table == "telemetry_runs" else ["event_id"] - } - inspector.get_unique_constraints.return_value = [ - {"column_names": ["run_id", "producer_id", "sequence"]} - ] - inspector.get_foreign_keys.return_value = [ - { - "constrained_columns": ["run_id"], - "referred_table": "telemetry_runs", - "referred_columns": ["run_id"], - } - ] - inspector.get_indexes.side_effect = lambda table: ( - [ - { - "name": "telemetry_runs_country_updated_idx", - "column_names": ["country_code", "updated_at"], - } - ] - if table == "telemetry_runs" - else [ - { - "name": "telemetry_events_run_order_idx", - "column_names": ["run_id", "emitted_at", "producer_id", "sequence"], - } - ] - ) - return inspector - - -def test_classifies_empty_database() -> None: - inspector = Mock() - inspector.get_table_names.return_value = [] - - assert classify_schema(inspector) is SchemaState.EMPTY - - -def test_classifies_alembic_database() -> None: - inspector = Mock() - inspector.get_table_names.return_value = ["alembic_version", "telemetry_runs"] - - assert classify_schema(inspector) is SchemaState.VERSIONED - - -def test_classifies_exact_legacy_database() -> None: - assert classify_schema(_legacy_inspector()) is SchemaState.LEGACY - - -def test_rejects_partial_legacy_database() -> None: - inspector = _legacy_inspector() - inspector.get_table_names.return_value = ["telemetry_runs"] - - assert classify_schema(inspector) is SchemaState.UNEXPECTED - - -def test_rejects_unversioned_database_with_additional_telemetry_tables() -> None: - inspector = _legacy_inspector() - inspector.get_table_names.return_value = [ - "telemetry_runs", - "telemetry_events", - "telemetry_producers", - ] - - assert classify_schema(inspector) is SchemaState.UNEXPECTED - - -def test_rejects_legacy_database_with_wrong_columns() -> None: - inspector = _legacy_inspector() - columns = inspector.get_columns("telemetry_runs") - inspector.get_columns.side_effect = lambda table: ( - columns[:-1] - if table == "telemetry_runs" - else _legacy_inspector().get_columns(table) - ) - - assert classify_schema(inspector) is SchemaState.UNEXPECTED diff --git a/telemetry-service/tests/test_migrations_postgres.py b/telemetry-service/tests/test_migrations_postgres.py deleted file mode 100644 index a0055ab6..00000000 --- a/telemetry-service/tests/test_migrations_postgres.py +++ /dev/null @@ -1,220 +0,0 @@ -from __future__ import annotations - -import pytest -import sqlalchemy as sa -from alembic import command -from alembic.migration import MigrationContext -from alembic.script import ScriptDirectory -from sqlalchemy import inspect -from sqlalchemy.dialects.postgresql import JSONB - -from telemetry_collector.database import create_database_engine -from telemetry_collector.migrate import ( - SchemaState, - alembic_config, - upgrade_database, - verify_database, -) - - -def _current_revision(database_url: str) -> tuple[str | None, str | None]: - engine = create_database_engine(database_url) - config = alembic_config() - try: - with engine.connect() as connection: - current = MigrationContext.configure(connection).get_current_revision() - head = ScriptDirectory.from_config(config).get_current_head() - return current, head - finally: - engine.dispose() - - -def _legacy_metadata() -> sa.MetaData: - metadata = sa.MetaData() - runs = sa.Table( - "telemetry_runs", - metadata, - sa.Column("run_id", sa.Text(), primary_key=True), - sa.Column("country_code", sa.Text(), nullable=False), - sa.Column("pipeline", sa.Text(), nullable=False), - sa.Column("candidate_id", sa.Text()), - sa.Column("release_id", sa.Text()), - sa.Column("run_kind", sa.Text(), nullable=False), - sa.Column("owner_hf_id", sa.Text(), nullable=False), - sa.Column("owner_hf_username", sa.Text(), nullable=False), - sa.Column("status", sa.Text(), nullable=False), - sa.Column("current_stage", sa.Text(), nullable=False), - sa.Column("started_at", sa.DateTime(timezone=True), nullable=False), - sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), - sa.Column("ended_at", sa.DateTime(timezone=True)), - sa.Column("heartbeat_at", sa.DateTime(timezone=True)), - sa.Column("resources", JSONB()), - sa.Column("work", JSONB()), - sa.Column("failure", JSONB()), - ) - sa.Index( - "telemetry_runs_country_updated_idx", - runs.c.country_code, - runs.c.updated_at.desc(), - ) - events = sa.Table( - "telemetry_events", - metadata, - sa.Column("event_id", sa.Text(), primary_key=True), - sa.Column( - "run_id", - sa.Text(), - sa.ForeignKey("telemetry_runs.run_id", ondelete="CASCADE"), - nullable=False, - ), - sa.Column("producer_id", sa.Text(), nullable=False), - sa.Column("sequence", sa.BigInteger(), nullable=False), - sa.Column("emitted_at", sa.DateTime(timezone=True), nullable=False), - sa.Column( - "received_at", - sa.DateTime(timezone=True), - server_default=sa.func.current_timestamp(), - nullable=False, - ), - sa.Column("event_type", sa.Text(), nullable=False), - sa.Column("stage_id", sa.Text()), - sa.Column("status", sa.Text(), nullable=False), - sa.Column("message", sa.Text()), - sa.Column("details", JSONB(), server_default="{}", nullable=False), - sa.Column("resources", JSONB()), - sa.UniqueConstraint( - "run_id", - "producer_id", - "sequence", - name="telemetry_events_run_id_producer_id_sequence_key", - ), - ) - sa.Index( - "telemetry_events_run_order_idx", - events.c.run_id, - events.c.emitted_at, - events.c.producer_id, - events.c.sequence, - ) - return metadata - - -def test_upgrades_empty_postgres_database_to_head(postgres_url: str) -> None: - assert upgrade_database(postgres_url) is SchemaState.EMPTY - - engine = create_database_engine(postgres_url) - try: - assert set(inspect(engine).get_table_names()) == { - "alembic_version", - "telemetry_runs", - "telemetry_producers", - "telemetry_events", - } - assert _current_revision(postgres_url) == ( - "0001_initial_schema", - "0001_initial_schema", - ) - finally: - engine.dispose() - - -def test_hard_cutover_replaces_populated_unversioned_database( - postgres_url: str, -) -> None: - engine = create_database_engine(postgres_url) - with engine.begin() as connection: - metadata = _legacy_metadata() - metadata.create_all(connection) - runs = metadata.tables["telemetry_runs"] - events = metadata.tables["telemetry_events"] - timestamp = sa.func.current_timestamp() - connection.execute( - runs.insert().values( - run_id="legacy-run", - country_code="US", - pipeline="us-fiscal-refresh", - run_kind="build", - owner_hf_id="owner", - owner_hf_username="builder", - status="running", - current_stage="created", - started_at=timestamp, - updated_at=timestamp, - ) - ) - connection.execute( - events.insert().values( - event_id="legacy-event", - run_id="legacy-run", - producer_id="legacy-producer", - sequence=1, - emitted_at=timestamp, - event_type="stage", - stage_id="compile", - status="started", - details={}, - ) - ) - engine.dispose() - - assert upgrade_database(postgres_url) is SchemaState.LEGACY - - engine = create_database_engine(postgres_url) - try: - inspector = inspect(engine) - assert set(inspector.get_table_names()) == { - "alembic_version", - "telemetry_runs", - "telemetry_producers", - "telemetry_events", - } - metadata = sa.MetaData() - runs = sa.Table("telemetry_runs", metadata, autoload_with=engine) - events = sa.Table("telemetry_events", metadata, autoload_with=engine) - with engine.connect() as connection: - assert connection.scalar(sa.select(sa.func.count()).select_from(runs)) == 0 - assert ( - connection.scalar(sa.select(sa.func.count()).select_from(events)) == 0 - ) - assert _current_revision(postgres_url) == ( - "0001_initial_schema", - "0001_initial_schema", - ) - finally: - engine.dispose() - - -def test_migration_chain_downgrades_and_reupgrades_in_test_database( - postgres_url: str, -) -> None: - upgrade_database(postgres_url) - engine = create_database_engine(postgres_url) - config = alembic_config() - try: - with engine.begin() as connection: - config.attributes["connection"] = connection - command.downgrade(config, "base") - command.upgrade(config, "head") - assert _current_revision(postgres_url) == ( - "0001_initial_schema", - "0001_initial_schema", - ) - finally: - engine.dispose() - - -def test_verification_rejects_schema_drift_at_alembic_head( - postgres_url: str, -) -> None: - upgrade_database(postgres_url) - engine = create_database_engine(postgres_url) - try: - with engine.begin() as connection: - connection.execute( - sa.text("ALTER TABLE telemetry_runs ADD COLUMN unexpected TEXT") - ) - finally: - engine.dispose() - - with pytest.raises(RuntimeError, match="differs from SQLAlchemy metadata"): - verify_database(postgres_url) diff --git a/telemetry-service/tests/test_secret_existence.py b/telemetry-service/tests/test_secret_existence.py deleted file mode 100644 index e2b3650a..00000000 --- a/telemetry-service/tests/test_secret_existence.py +++ /dev/null @@ -1,67 +0,0 @@ -"""Deployment inspects secret metadata without accessing credential values.""" - -import os -import subprocess -from pathlib import Path - -import pytest - -from tests.test_deploy_script import install_command_stub - -ROOT = Path(__file__).resolve().parents[2] -SCRIPT = ROOT / "telemetry-service/scripts/check-secrets-exist.sh" - - -@pytest.mark.parametrize("missing_secret", ["", "database", "migration", "jwt", "read"]) -def test_checks_all_resources_without_accessing_payloads( - tmp_path: Path, missing_secret: str -) -> None: - command_log = tmp_path / "commands.log" - install_command_stub( - tmp_path, - "gcloud", - 'printf "%s\\n" "$*" >> "$COMMAND_LOG"\n' - '[[ "$1 $2" == "secrets describe" ]] || exit 99\n' - '[[ "$3" != "$MISSING_SECRET" ]]', - ) - result = subprocess.run( - ["bash", str(SCRIPT), "database", "migration", "jwt", "read"], - env={ - "PATH": f"{tmp_path}:{os.environ['PATH']}", - "PROJECT_ID": "test-project", - "COMMAND_LOG": str(command_log), - "MISSING_SECRET": missing_secret, - }, - capture_output=True, - text=True, - ) - - assert result.returncode == (1 if missing_secret else 0), result.stderr - assert command_log.read_text().splitlines() == [ - f"secrets describe {name} --project test-project --format=none" - for name in ("database", "migration", "jwt", "read") - ] - if missing_secret: - assert missing_secret in result.stderr - - -def test_missing_configuration_stops_before_contacting_google() -> None: - result = subprocess.run( - ["bash", str(SCRIPT), "read"], - env={"PATH": os.environ["PATH"]}, - capture_output=True, - text=True, - ) - assert result.returncode != 0 - assert "PROJECT_ID" in result.stderr - - -def test_requires_at_least_one_secret_name() -> None: - result = subprocess.run( - ["bash", str(SCRIPT)], - env={"PATH": os.environ["PATH"], "PROJECT_ID": "test-project"}, - capture_output=True, - text=True, - ) - assert result.returncode != 0 - assert "secret name" in result.stderr diff --git a/telemetry-service/tests/test_storage.py b/telemetry-service/tests/test_storage.py deleted file mode 100644 index 6b5d4009..00000000 --- a/telemetry-service/tests/test_storage.py +++ /dev/null @@ -1,348 +0,0 @@ -from __future__ import annotations - -from datetime import UTC, datetime, timedelta - -from telemetry_collector.auth import HuggingFacePrincipal -from telemetry_collector.models import RunRegistration, TelemetryEvent -from telemetry_collector.storage import ( - initial_run_state, - materialize_run, - run_documents, -) - - -def _registered(at: datetime) -> dict[str, object]: - return initial_run_state( - RunRegistration( - run_id="run-1", - producer_id="producer-1", - country_code="US", - pipeline="us-fiscal-refresh", - ), - HuggingFacePrincipal( - user_id="owner-1", - username="builder", - organizations=("policyengine",), - ), - at, - ) - - -def _event( - *, - event_id: str, - producer_id: str = "producer-1", - producer_registered_at: datetime, - sequence: int, - timestamp: datetime, - status: str, - stage_id: str, -) -> dict[str, object]: - return { - "event_id": event_id, - "run_id": "run-1", - "producer_id": producer_id, - "producer_registered_at": producer_registered_at, - "sequence": sequence, - "timestamp": timestamp, - "event_type": "run", - "stage_id": stage_id, - "status": status, - "message": event_id, - "details": {}, - "resources": None, - } - - -def test_materialization_is_independent_of_delivery_order() -> None: - registered_at = datetime(2026, 10, 1, tzinfo=UTC) - failed = _event( - event_id="failed-first", - producer_registered_at=registered_at, - sequence=1, - timestamp=registered_at + timedelta(minutes=2), - status="failed", - stage_id="failed", - ) - completed = _event( - event_id="completed-second", - producer_registered_at=registered_at, - sequence=2, - timestamp=registered_at + timedelta(minutes=1), - status="completed", - stage_id="complete", - ) - - normal = materialize_run(_registered(registered_at), [failed, completed]) - delayed = materialize_run(_registered(registered_at), [completed, failed]) - - assert delayed == normal - assert delayed["status"] == "completed" - assert delayed["failure"] is None - assert delayed["ended_at"] == completed["timestamp"] - - -def test_sequence_order_wins_when_a_producer_clock_moves_backward() -> None: - registered_at = datetime(2026, 10, 1, tzinfo=UTC) - events = [ - _event( - event_id="started", - producer_registered_at=registered_at, - sequence=1, - timestamp=registered_at + timedelta(minutes=2), - status="started", - stage_id="compile", - ), - _event( - event_id="completed", - producer_registered_at=registered_at, - sequence=2, - timestamp=registered_at + timedelta(minutes=1), - status="completed", - stage_id="complete", - ), - ] - - materialized = materialize_run(_registered(registered_at), events) - - assert materialized["status"] == "completed" - assert materialized["started_at"] == registered_at - assert materialized["updated_at"] == registered_at + timedelta(minutes=2) - - -def test_later_registered_producer_stream_takes_precedence() -> None: - registered_at = datetime(2026, 10, 1, tzinfo=UTC) - events = [ - _event( - event_id="old-completion", - producer_registered_at=registered_at, - sequence=1, - timestamp=registered_at + timedelta(minutes=5), - status="completed", - stage_id="complete", - ), - _event( - event_id="restart-failure", - producer_id="producer-2", - producer_registered_at=registered_at + timedelta(minutes=10), - sequence=1, - timestamp=registered_at + timedelta(minutes=3), - status="failed", - stage_id="failed", - ), - ] - - materialized = materialize_run(_registered(registered_at), events) - documents = run_documents(_registered(registered_at), events) - - assert materialized["status"] == "failed" - assert materialized["failure"]["message"] == "restart-failure" - assert [event["event_id"] for event in documents["events"]] == [ - "old-completion", - "restart-failure", - ] - - -def test_later_registered_producer_reopens_a_finished_run() -> None: - registered_at = datetime(2026, 10, 1, tzinfo=UTC) - old_progress = _event( - event_id="old-progress", - producer_registered_at=registered_at, - sequence=1, - timestamp=registered_at + timedelta(minutes=4), - status="progress", - stage_id="compile", - ) - old_progress.update( - { - "event_type": "progress", - "details": {"done": 5, "total": 10}, - "resources": {"cpu_user_seconds": 100}, - } - ) - events = [ - old_progress, - _event( - event_id="old-completion", - producer_registered_at=registered_at, - sequence=2, - timestamp=registered_at + timedelta(minutes=5), - status="completed", - stage_id="complete", - ), - _event( - event_id="restart-started", - producer_id="producer-2", - producer_registered_at=registered_at + timedelta(minutes=10), - sequence=1, - timestamp=registered_at + timedelta(minutes=6), - status="started", - stage_id="created", - ), - ] - - materialized = materialize_run(_registered(registered_at), events) - - assert materialized["status"] == "running" - assert materialized["current_stage"] == "created" - assert materialized["ended_at"] is None - assert materialized["failure"] is None - assert materialized["started_at"] == registered_at + timedelta(minutes=6) - assert materialized["updated_at"] == registered_at + timedelta(minutes=10) - assert materialized["heartbeat_at"] is None - assert materialized["resources"] is None - assert materialized["work"] is None - - -def test_progress_work_uses_json_compatible_timestamp() -> None: - registered_at = datetime(2026, 10, 1, tzinfo=UTC) - event = TelemetryEvent( - schema_version=1, - event_id="progress", - run_id="run-1", - producer_id="producer-1", - sequence=1, - timestamp=registered_at, - event_type="progress", - stage_id="compile", - status="progress", - details={"done": 1, "total": 2}, - ).model_dump(mode="python") - event["producer_registered_at"] = registered_at - - materialized = materialize_run(_registered(registered_at), [event]) - - assert materialized["work"]["updated_at"] == registered_at.isoformat() - - -def test_blocked_run_event_ends_the_run_with_its_block() -> None: - registered_at = datetime(2026, 10, 1, tzinfo=UTC) - blocked = _event( - event_id="blocked", - producer_registered_at=registered_at, - sequence=2, - timestamp=registered_at + timedelta(minutes=9), - status="blocked", - stage_id="blocked", - ) - blocked["details"] = { - "phase": "terminal", - "blocking_failure_count": 1, - "blocking_gate_ids": ["uk_target_fit"], - "gate_statuses": {"uk_target_fit": "failed"}, - } - events = [ - _event( - event_id="gates", - producer_registered_at=registered_at, - sequence=1, - timestamp=registered_at + timedelta(minutes=8), - status="completed", - stage_id="gate_battery", - ), - blocked, - ] - events[0]["event_type"] = "stage" - - materialized = materialize_run(_registered(registered_at), events) - documents = run_documents(_registered(registered_at), events) - - block = { - "phase": "terminal", - "blocking_failure_count": 1, - "blocking_gate_ids": ["uk_target_fit"], - } - assert materialized["status"] == "blocked" - assert materialized["current_stage"] == "blocked" - assert materialized["ended_at"] == blocked["timestamp"] - assert materialized["failure"] is None - assert materialized["block"] == block - assert documents["progress"]["status"] == "blocked" - assert documents["progress"]["block"] == block - assert documents["run_manifest"]["block"] == block - assert documents["events"][-1]["details"]["gate_statuses"] == { - "uk_target_fit": "failed" - } - - -def test_blocked_run_without_gate_ids_or_statuses_keeps_its_count() -> None: - registered_at = datetime(2026, 10, 1, tzinfo=UTC) - blocked = _event( - event_id="blocked", - producer_registered_at=registered_at, - sequence=1, - timestamp=registered_at + timedelta(minutes=1), - status="blocked", - stage_id="blocked", - ) - blocked["details"] = { - "phase": "preflight", - "blocking_failure_count": 2, - "blocking_gate_ids": [], - } - - materialized = materialize_run(_registered(registered_at), [blocked]) - - assert materialized["status"] == "blocked" - assert materialized["block"] == { - "phase": "preflight", - "blocking_failure_count": 2, - "blocking_gate_ids": [], - } - - -def test_failed_run_keeps_its_error_code() -> None: - registered_at = datetime(2026, 10, 1, tzinfo=UTC) - failed = _event( - event_id="failed", - producer_registered_at=registered_at, - sequence=1, - timestamp=registered_at + timedelta(minutes=1), - status="failed", - stage_id="failed", - ) - failed["details"] = { - "error_type": "KeyboardInterrupt", - "error_code": "INTERRUPTED", - "failure_class": "interrupted", - "failed_during": "calibrating", - } - - assert materialize_run(_registered(registered_at), [failed])["failure"] == { - "message": "failed", - "error_type": "KeyboardInterrupt", - "error_code": "INTERRUPTED", - "failure_class": "interrupted", - "failed_during": "calibrating", - } - - -def test_restarted_producer_clears_a_previous_block() -> None: - registered_at = datetime(2026, 10, 1, tzinfo=UTC) - blocked = _event( - event_id="blocked", - producer_registered_at=registered_at, - sequence=1, - timestamp=registered_at + timedelta(minutes=1), - status="blocked", - stage_id="blocked", - ) - blocked["details"] = { - "phase": "terminal", - "blocking_failure_count": 1, - "blocking_gate_ids": ["uk_target_fit"], - } - restarted = _event( - event_id="restart-started", - producer_id="producer-2", - producer_registered_at=registered_at + timedelta(minutes=10), - sequence=1, - timestamp=registered_at + timedelta(minutes=11), - status="started", - stage_id="created", - ) - - materialized = materialize_run(_registered(registered_at), [blocked, restarted]) - - assert materialized["status"] == "running" - assert materialized["block"] is None diff --git a/telemetry-service/tests/test_storage_postgres.py b/telemetry-service/tests/test_storage_postgres.py deleted file mode 100644 index 26e5caca..00000000 --- a/telemetry-service/tests/test_storage_postgres.py +++ /dev/null @@ -1,444 +0,0 @@ -from __future__ import annotations - -import random -from collections.abc import Iterator, Sequence -from contextlib import contextmanager -from datetime import UTC, datetime, timedelta, timezone -from typing import Any - -import pytest -from sqlalchemy import event as sqlalchemy_event - -from telemetry_collector import storage -from telemetry_collector.auth import HuggingFacePrincipal -from telemetry_collector.database import TelemetryRun, create_session_factory -from telemetry_collector.migrate import upgrade_database -from telemetry_collector.models import RunRegistration, TelemetryEvent -from telemetry_collector.storage import ( - MATERIALIZED_RUN_COLUMNS, - PostgresTelemetryStore, - RunRegistrationConflictError, - materialize_run, - run_summary, -) -from tests.event_schedules import ( - ONE_PRODUCER, - RESTARTED_PRODUCERS, - RUN_REGISTERED_AT, - Event, - every_kind_stream, - in_batches, - long_stream, - out_of_order_schedules, - random_delivery_schedule, - restarted_producer_schedules, - run_failed, -) - - -def _principal(user_id: str = "owner-1") -> HuggingFacePrincipal: - return HuggingFacePrincipal( - user_id=user_id, - username="builder", - organizations=("policyengine",), - ) - - -def _registration(**changes: str) -> RunRegistration: - values = { - "run_id": "run-1", - "producer_id": "producer-1", - "country_code": "US", - "pipeline": "us-fiscal-refresh", - } - values.update(changes) - return RunRegistration(**values) - - -def _event( - *, event_id: str, sequence: int, timestamp: datetime, status: str, stage_id: str -) -> TelemetryEvent: - return TelemetryEvent( - schema_version=1, - event_id=event_id, - run_id="run-1", - producer_id="producer-1", - sequence=sequence, - timestamp=timestamp, - event_type="run", - stage_id=stage_id, - status=status, - details={}, - resources={ - "cpu_user_seconds": sequence, - "rss_bytes": sequence * 1024, - }, - ) - - -def test_store_is_idempotent_and_reduces_out_of_order_events( - postgres_url: str, -) -> None: - upgrade_database(postgres_url) - store = PostgresTelemetryStore(postgres_url) - registered_at = datetime.now(UTC) - store.register_run(_registration(), _principal()) - completed = _event( - event_id="completed", - sequence=2, - timestamp=registered_at + timedelta(minutes=1), - status="completed", - stage_id="complete", - ) - delayed_failure = _event( - event_id="delayed-failure", - sequence=1, - timestamp=registered_at + timedelta(minutes=2), - status="failed", - stage_id="failed", - ) - - assert store.append_events("run-1", "owner-1", [completed]) == (1, 0) - assert store.append_events("run-1", "owner-1", [completed]) == (0, 1) - assert store.append_events("run-1", "owner-1", [delayed_failure]) == ( - 1, - 0, - ) - - listed = store.list_runs(country="US", limit=10, before=None) - detail = store.get_run("run-1") - store.close() - - assert listed[0]["status"] == "completed" - assert detail is not None - assert detail["progress"]["status"] == "completed" - assert detail["progress"]["failure"] is None - assert detail["progress"]["resources"]["rss_bytes"] == 2048 - assert [event["event_id"] for event in detail["events"]] == [ - "delayed-failure", - "completed", - ] - - -def test_registration_rejects_owner_and_metadata_conflicts( - postgres_url: str, -) -> None: - upgrade_database(postgres_url) - store = PostgresTelemetryStore(postgres_url) - store.register_run(_registration(), _principal()) - - with pytest.raises(PermissionError): - store.register_run(_registration(), _principal("owner-2")) - with pytest.raises(RunRegistrationConflictError): - store.register_run(_registration(pipeline="another-pipeline"), _principal()) - - store.close() - - -def test_event_ingestion_rejects_another_owner_and_unregistered_producer( - postgres_url: str, -) -> None: - upgrade_database(postgres_url) - store = PostgresTelemetryStore(postgres_url) - store.register_run(_registration(), _principal()) - event = _event( - event_id="owned-event", - sequence=1, - timestamp=datetime.now(UTC), - status="progress", - stage_id="compile", - ) - - with pytest.raises(PermissionError, match="another Hugging Face user"): - store.append_events("run-1", "owner-2", [event]) - - unregistered = event.model_copy( - update={"event_id": "unknown-producer", "producer_id": "producer-2"} - ) - with pytest.raises(PermissionError, match="not registered"): - store.append_events("run-1", "owner-1", [unregistered]) - - store.close() - - -def test_store_readiness_requires_alembic_head(postgres_url: str) -> None: - store = PostgresTelemetryStore(postgres_url) - assert store.is_ready() is False - - upgrade_database(postgres_url) - - assert store.is_ready() is True - store.close() - - -def _register_producers( - store: PostgresTelemetryStore, - run_id: str, - registrations: dict[str, datetime], - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Register each producer at its stated time; the first registers the run.""" - - for producer_id, registered_at in registrations.items(): - monkeypatch.setattr( - storage, "_now", lambda registered_at=registered_at: registered_at - ) - store.register_run( - _registration(run_id=run_id, producer_id=producer_id), _principal() - ) - - -def _for_run(batch: Sequence[Event], run_id: str) -> list[TelemetryEvent]: - """Address scheduled events to one run, with event ids unique across runs.""" - - return [ - TelemetryEvent( - schema_version=1, - **{ - **event, - "run_id": run_id, - "event_id": f"{run_id}.{event['event_id']}", - }, - ) - for event in batch - ] - - -def _assert_stored_state_is_fully_materialized( - store: PostgresTelemetryStore, run_id: str, context: str -) -> dict[str, Any]: - """Compare the stored run with a re-materialization from every stored event.""" - - with create_session_factory(store.engine)() as session: - run = session.get(TelemetryRun, run_id) - stored_columns = { - column: getattr(run, column) for column in MATERIALIZED_RUN_COLUMNS - } - # What ingestion stored before batches were applied incrementally. - expected = materialize_run( - storage._run_mapping(run), store._events_for_run(session, run_id) - ) - assert stored_columns == { - column: expected[column] for column in MATERIALIZED_RUN_COLUMNS - }, context - return expected - - -def _assert_listed_as_fully_materialized( - store: PostgresTelemetryStore, run_id: str, expected: dict[str, Any] -) -> None: - listed = store.list_runs(country="US", limit=200, before=None) - assert [summary for summary in listed if summary["run_id"] == run_id] == [ - run_summary(expected) - ], run_id - - -@contextmanager -def _event_rows_read(store: PostgresTelemetryStore) -> Iterator[list[int]]: - """Record how many rows each query reading telemetry_events returns.""" - - row_counts: list[int] = [] - - def record_rows(connection, cursor, statement, parameters, context, executemany): - if statement.lstrip().upper().startswith("SELECT") and ( - "telemetry_events" in statement - ): - row_counts.append(cursor.rowcount) - - sqlalchemy_event.listen(store.engine, "after_cursor_execute", record_rows) - try: - yield row_counts - finally: - sqlalchemy_event.remove(store.engine, "after_cursor_execute", record_rows) - - -def test_incremental_batch_apply_reads_a_constant_number_of_event_rows_per_in_order_batch( - postgres_url: str, monkeypatch: pytest.MonkeyPatch -) -> None: - upgrade_database(postgres_url) - store = PostgresTelemetryStore(postgres_url) - _register_producers(store, "run-1", ONE_PRODUCER, monkeypatch) - batches = in_batches(long_stream("producer-a", 400), 20) - - rows_read_per_batch = [] - for batch in batches: - with _event_rows_read(store) as row_counts: - assert store.append_events( - "run-1", "owner-1", _for_run(batch, "run-1") - ) == (len(batch), 0) - rows_read_per_batch.append(sum(row_counts)) - expected = _assert_stored_state_is_fully_materialized(store, "run-1", "400 events") - _assert_listed_as_fully_materialized(store, "run-1", expected) - store.close() - - # Re-reading the run would read 20 rows for the first batch and 400 for the last. - assert len(set(rows_read_per_batch)) == 1, rows_read_per_batch - assert rows_read_per_batch[0] <= len(batches[0]) - - -def test_incremental_batch_apply_reads_only_the_event_rows_after_a_late_event( - postgres_url: str, monkeypatch: pytest.MonkeyPatch -) -> None: - upgrade_database(postgres_url) - store = PostgresTelemetryStore(postgres_url) - - rows_read_by_run_length = {} - for run_length in (60, 600): - run_id = f"run-{run_length}" - _register_producers(store, run_id, ONE_PRODUCER, monkeypatch) - stream = long_stream("producer-a", run_length) - late_event = stream[-6] - for batch in in_batches( - [event for event in stream if event is not late_event], 20 - ): - store.append_events(run_id, "owner-1", _for_run(batch, run_id)) - - with _event_rows_read(store) as row_counts: - assert store.append_events( - run_id, "owner-1", _for_run([late_event], run_id) - ) == (1, 0) - - rows_read_by_run_length[run_length] = sum(row_counts) - expected = _assert_stored_state_is_fully_materialized( - store, run_id, f"{run_length} events with a late one" - ) - _assert_listed_as_fully_materialized(store, run_id, expected) - store.close() - - # One producer row, then the late event and the five stored after it. - assert rows_read_by_run_length == {60: 7, 600: 7} - - -def test_incremental_batch_apply_stores_the_fully_materialized_state( - postgres_url: str, monkeypatch: pytest.MonkeyPatch -) -> None: - upgrade_database(postgres_url) - store = PostgresTelemetryStore(postgres_url) - every_kind = every_kind_stream("producer-a") - deliveries = [ - ("every kind in batches of three", ONE_PRODUCER, in_batches(every_kind, 3)), - ( - "each batch sent twice, a conflicting sequence, then everything again", - ONE_PRODUCER, - [ - *[batch for batch in in_batches(every_kind, 4) for _ in range(2)], - [run_failed("producer-a", 3, 500.0) | {"event_id": "resent-3"}], - every_kind, - ], - ), - *[ - (description, ONE_PRODUCER, schedule) - for description, schedule in out_of_order_schedules(shuffle_count=2).items() - ], - *[ - (description, RESTARTED_PRODUCERS, schedule) - for description, schedule in restarted_producer_schedules().items() - ], - ] - for schedule_seed in range(20): - registrations, schedule, _ = random_delivery_schedule( - random.Random(schedule_seed) - ) - deliveries.append((f"schedule seed {schedule_seed}", registrations, schedule)) - - for run_number, (description, registrations, schedule) in enumerate(deliveries): - run_id = f"run-{run_number}" - _register_producers(store, run_id, registrations, monkeypatch) - for batch_number, batch in enumerate(schedule, start=1): - store.append_events(run_id, "owner-1", _for_run(batch, run_id)) - expected = _assert_stored_state_is_fully_materialized( - store, run_id, f"{description}: batch {batch_number}" - ) - _assert_listed_as_fully_materialized(store, run_id, expected) - store.close() - - -# Offsets far from UTC on either side, so consecutive events alternate between -# them and every second pair has wall-clock times in the opposite order to -# their instants. -NON_UTC_OFFSETS = (timezone(timedelta(hours=14)), timezone(timedelta(hours=-12))) - - -def _with_non_utc_offsets(schedule: Sequence[Sequence[Event]]) -> list[list[Event]]: - """Re-express every timestamp in a non-UTC offset, keeping its instant.""" - - return [ - [ - event - | { - "timestamp": event["timestamp"].astimezone( - NON_UTC_OFFSETS[event["sequence"] % len(NON_UTC_OFFSETS)] - ) - } - for event in batch - ] - for batch in schedule - ] - - -def test_incremental_batch_apply_stores_database_values_for_non_utc_timestamps( - postgres_url: str, monkeypatch: pytest.MonkeyPatch -) -> None: - """Fold the stored rows, not the submitted values, whatever offset a producer uses. - - ``work.updated_at`` is stored as text. A fold over the submitted timestamps - would keep the producer's offset where a full re-read of the stored events - gives the database's. - """ - - upgrade_database(postgres_url) - store = PostgresTelemetryStore(postgres_url) - late_event_stream = long_stream("producer-a", 60) - late_event = late_event_stream[-6] - earlier_and_later_attempts = { - "producer-a": RUN_REGISTERED_AT, - "producer-b": RUN_REGISTERED_AT + timedelta(minutes=30), - } - deliveries = [ - ( - "every kind in batches of three", - ONE_PRODUCER, - in_batches(every_kind_stream("producer-a"), 3), - ), - ( - "a late event replays the events stored after it", - ONE_PRODUCER, - [ - *in_batches( - [event for event in late_event_stream if event is not late_event], - 20, - ), - [late_event], - ], - ), - ( - "an earlier attempt first reports after a later attempt", - earlier_and_later_attempts, - [ - *in_batches(long_stream("producer-b", 40, first_minute=40.0), 10), - *in_batches(long_stream("producer-a", 10), 5), - ], - ), - ] - - for run_number, (description, registrations, schedule) in enumerate(deliveries): - materialized_by_offset = {} - for offset_name, offset_schedule in ( - ("UTC", schedule), - ("non-UTC", _with_non_utc_offsets(schedule)), - ): - run_id = f"run-{run_number}-{offset_name}" - _register_producers(store, run_id, registrations, monkeypatch) - for batch_number, batch in enumerate(offset_schedule, start=1): - store.append_events(run_id, "owner-1", _for_run(batch, run_id)) - materialized = _assert_stored_state_is_fully_materialized( - store, run_id, f"{description}, {offset_name}: batch {batch_number}" - ) - materialized_by_offset[offset_name] = { - column: materialized[column] for column in MATERIALIZED_RUN_COLUMNS - } - # The same instants give the same run, whatever offset carried them. - assert materialized_by_offset["non-UTC"] == materialized_by_offset["UTC"], ( - description - ) - store.close() diff --git a/telemetry-service/uv.lock b/telemetry-service/uv.lock deleted file mode 100644 index bfd250d2..00000000 --- a/telemetry-service/uv.lock +++ /dev/null @@ -1,899 +0,0 @@ -version = 1 -revision = 5 -requires-python = ">=3.13" - -[[package]] -name = "alembic" -version = "1.20.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mako" }, - { name = "sqlalchemy" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ed/aa/02910bdb8e2f1444f6654d5b296cd827d126f82209050ee7b1000f92ac4b/alembic-1.20.0.tar.gz", hash = "sha256:db505480647bc60386c5369402f4a57a506b7539c9e9ef5e270d45cbbe4939bf", size = 2093272, upload-time = "2026-09-11T19:09:11.126Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3f/27/78a89b55b0904d222183164e079b4ca56208e94eff1d35ad1f1ad5be9b06/alembic-1.20.0-py3-none-any.whl", hash = "sha256:77eb101048d95f982c0353e9233404889dcd7a6fc244c107836c0e2fc9cf7d9d", size = 268719, upload-time = "2026-09-11T19:09:12.88Z" }, -] - -[[package]] -name = "annotated-doc" -version = "0.0.5" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" }, -] - -[[package]] -name = "annotated-types" -version = "0.8.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, -] - -[[package]] -name = "anyio" -version = "4.15.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "idna" }, - { name = "typing-extensions", marker = "python_full_version < '3.15'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, -] - -[[package]] -name = "certifi" -version = "2026.10.19" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a6/bd/1436c50cc46699de0230fbaacc1d1de7bfec413176099949e9d5112e88f2/certifi-2026.10.19.tar.gz", hash = "sha256:035c1e85572b141bf1c3a3eff0e33a8eb74917522f2559075ff8d2fea780a1ae", size = 137598, upload-time = "2026-10-20T02:24:16.604Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c4/51/29046e48f693c54e5b7412216d447a1d8362c9653148d98c02cdf65fa733/certifi-2026.10.19-py3-none-any.whl", hash = "sha256:42d35340cc5b9ba78e818ddbff0c5b1c2b779de448a340fd1b989f5fa8670e1b", size = 136464, upload-time = "2026-10-20T02:24:15.072Z" }, -] - -[[package]] -name = "click" -version = "8.5.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, -] - -[[package]] -name = "colorama" -version = "0.4.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, -] - -[[package]] -name = "fastapi" -version = "0.142.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "annotated-doc" }, - { name = "opentelemetry-api" }, - { name = "pydantic" }, - { name = "starlette" }, - { name = "typing-extensions" }, - { name = "typing-inspection" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/56/4f/f7c30a73127e0a8bbffe788369b8359e530b01ae06e2757936fa35bc5e6d/fastapi-0.142.2.tar.gz", hash = "sha256:06366626f2e70576367714d9ab2fe8472e6c8456dba69b399f9f797ab5e92570", size = 466721, upload-time = "2026-09-30T09:51:11.277Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/b6/78aaf9141fb46742928c113f3cf6ef2259d538cb02b604b7656c1dc9883c/fastapi-0.142.2-py3-none-any.whl", hash = "sha256:bd5f4d81f1e93a88bcd77caf4dfe3c2dbffc3805407a0007e9a114c18b3a670b", size = 144409, upload-time = "2026-09-30T09:51:09.8Z" }, -] - -[[package]] -name = "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/5e/e5/8cfcabc5546e8022f168be28bcdaa128a240a0befdd03b59d558b4f18bd6/httptools-0.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:614ceea8ea606848bece2338ac03b3ce5324bcb4be8dc7d377ed708012fa4db8", size = 205148, upload-time = "2026-05-25T22:17:16.333Z" }, - { url = "https://files.pythonhosted.org/packages/2a/0e/0fb14848c19a686c8062ff9067c1a48793e3224b47bc5b201535b6036fce/httptools-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2d689918c15a013c65ef52d9fd495d766893ab831a2c8d89f2ac5940a5df847c", size = 111368, upload-time = "2026-05-25T22:17:17.586Z" }, - { url = "https://files.pythonhosted.org/packages/2e/1b/46f1cecf06b9bbde8e4b8c88034ac7908989e5ff7a3a388ef38392949c1f/httptools-0.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb3028cca2fc0a6d720e52ef61d8ebb62fcbfeb1de56874546d858d3f25a26b7", size = 486447, upload-time = "2026-05-25T22:17:18.564Z" }, - { url = "https://files.pythonhosted.org/packages/77/00/258bfc0837221f81d9725c45f9b948a6a6b2994a147a4fb66e85100c668f/httptools-0.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88bdd940f2b5d487b4d032c6afa5489a7dc4694410d43de3c38c4fb3af0dc45d", size = 482448, upload-time = "2026-05-25T22:17:19.912Z" }, - { url = "https://files.pythonhosted.org/packages/04/ab/d1cef3b5523f4d272a70f42a776c3169a2dddfe3a54de4b2ce4a36341528/httptools-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a43c9dd399758ccc0531acb0a3c4a6c299ee893ee9400e9c893b7bdcfae0681", size = 464460, upload-time = "2026-05-25T22:17:20.882Z" }, - { url = "https://files.pythonhosted.org/packages/ce/48/5d1d072442277bb2b3434e0e60690b8e8c23840ef7de8b6ea54040a536d3/httptools-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683", size = 471312, upload-time = "2026-05-25T22:17:22.085Z" }, - { url = "https://files.pythonhosted.org/packages/0d/66/b96623b27e51a68199ef4efdda0613cced9233fe3062ac74e50749c5ad37/httptools-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1", size = 90117, upload-time = "2026-05-25T22:17:23.074Z" }, - { url = "https://files.pythonhosted.org/packages/1a/12/fa3fbf5f9517b273edea2dc982aa82a8c634091e67c590792b729017bc6f/httptools-0.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6", size = 206183, upload-time = "2026-05-25T22:17:24.004Z" }, - { url = "https://files.pythonhosted.org/packages/30/fc/5e7c4cb443370f2090a3aba0453a07384d29ff66b7435bb90e77e1037599/httptools-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b", size = 112079, upload-time = "2026-05-25T22:17:25.216Z" }, - { url = "https://files.pythonhosted.org/packages/ba/53/771bd891eb0f236f32145d6a1775777ec85745f3cc983a1f23d1a3b8ddfe/httptools-0.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0", size = 481596, upload-time = "2026-05-25T22:17:26.186Z" }, - { url = "https://files.pythonhosted.org/packages/62/42/94e15bc68ce3d423243c45d7f1b0c7561f13844f97dc52ae23182fb65628/httptools-0.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e", size = 480865, upload-time = "2026-05-25T22:17:27.542Z" }, - { url = "https://files.pythonhosted.org/packages/1c/7c/fe2980fc03723272e30f135b62360b075f513dfe7cc73aef36c7f04012bd/httptools-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b", size = 463189, upload-time = "2026-05-25T22:17:28.546Z" }, - { url = "https://files.pythonhosted.org/packages/15/1b/47fc5fff68acd1bfa20b4734059c9a06cadb88119dcd5258b5b0d21d91c8/httptools-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0", size = 466610, upload-time = "2026-05-25T22:17:29.816Z" }, - { url = "https://files.pythonhosted.org/packages/60/bd/07b13c93ffd9bec9546e0d43f8e19378dd696dbd278511406bc07371ef1f/httptools-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527", size = 92705, upload-time = "2026-05-25T22:17:31.133Z" }, - { url = "https://files.pythonhosted.org/packages/fd/c4/121648f68ce066d7bd762d6b6d97e620847642d38d54f3d90ff11d947629/httptools-0.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568", size = 215023, upload-time = "2026-05-25T22:17:32.401Z" }, - { url = "https://files.pythonhosted.org/packages/b9/b0/312a062ae741ae3e8baa8c8bf20be81b2e67337b259ab4349bebc7b6142e/httptools-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b", size = 117405, upload-time = "2026-05-25T22:17:33.742Z" }, - { url = "https://files.pythonhosted.org/packages/fc/37/fccd705f795386bb05bf413012fecff2a33e5aa8c2f069096de3e9fd8702/httptools-0.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca", size = 558497, upload-time = "2026-05-25T22:17:34.732Z" }, - { url = "https://files.pythonhosted.org/packages/bd/39/f172e8003576de35f5ba77ff417cf0e34429d35dc014deef15afa337a72c/httptools-0.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f", size = 571585, upload-time = "2026-05-25T22:17:35.813Z" }, - { url = "https://files.pythonhosted.org/packages/3e/b9/f5564760af99f3dbbf3f9104dc00e5da27e96cf433c6bdcf77617f70bf3f/httptools-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d", size = 543297, upload-time = "2026-05-25T22:17:37.08Z" }, - { url = "https://files.pythonhosted.org/packages/99/67/8d9f2c313618e161b82f3873188e7196126da1d6e29688df40eb3997c77a/httptools-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081", size = 539535, upload-time = "2026-05-25T22:17:38.032Z" }, - { url = "https://files.pythonhosted.org/packages/48/63/b906c01e53f50d432c0defe43ce52764a111dc1bdd028bafbeb54dcfd008/httptools-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77", size = 108209, upload-time = "2026-05-25T22:17:39.473Z" }, -] - -[[package]] -name = "httpx" -version = "0.28.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "certifi" }, - { name = "httpcore" }, - { name = "idna" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, -] - -[[package]] -name = "idna" -version = "3.20" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" }, -] - -[[package]] -name = "iniconfig" -version = "2.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, -] - -[[package]] -name = "mako" -version = "1.4.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markupsafe" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5a/09/e07c4b5579a79f4b16f8d4f29f6c54514ac787c4ad506b8c4f28a0e6b0bf/mako-1.4.3.tar.gz", hash = "sha256:cd6537fe88d5fec315c55c2f8529bc4ce7a9a352ad7db3eeaa6a66e2dd4ec37a", size = 412799, upload-time = "2026-09-22T20:54:31.509Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6d/a0/053d6af3e8f871e0073b4a36732d9e65be77a72e5434c31b94f6af78a6bb/mako-1.4.3-py3-none-any.whl", hash = "sha256:723296007c870bfd6b3f0c3230dba7198096e5269297ebf5e4eff9e7ffa39d4f", size = 80164, upload-time = "2026-09-22T20:54:33.128Z" }, -] - -[[package]] -name = "markupsafe" -version = "3.0.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/38/9b/e422a865e1d5d57d0e509b4e0bf1c1a70a7f6382c29a5aa428df994c8bc8/markupsafe-3.0.4.tar.gz", hash = "sha256:2e9ad7dd851bf45fab9f75cbff4cb493fee9979e8d8c7c9c3ee119022518edd6", size = 153777, upload-time = "2026-10-02T23:07:22.29Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6d/18/4bc5ba32499e87bb2b0ef5b3a9bb9c00a131fa961ddf0be548cb550f548b/markupsafe-3.0.4-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:de8b364c423ef0a4bad9069657d617f9a5d2b2062457a89b1fa16ee199c399c1", size = 12786, upload-time = "2026-10-02T23:05:08.709Z" }, - { url = "https://files.pythonhosted.org/packages/4e/6f/17f0c099bf25f3e31e63cc19244d9f6af861a9a4ab778c203997903cfdd0/markupsafe-3.0.4-cp313-cp313-android_24_x86_64.whl", hash = "sha256:34bdde374c5932765d7dc685c4a1d191a3207852d67e8e0a9eb6ea85156181f1", size = 12510, upload-time = "2026-10-02T23:05:09.93Z" }, - { url = "https://files.pythonhosted.org/packages/11/af/1a141081b905036ee904ec4bd945e1f70b4e1b32d33c4e59e8cf1d58b247/markupsafe-3.0.4-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:6bd9e1788e15bfcf6a9082de42e30387e7b85d211ab21e57a939bb8cfaaf8d96", size = 11583, upload-time = "2026-10-02T23:05:10.884Z" }, - { url = "https://files.pythonhosted.org/packages/e7/0a/a89385ae590232622a03e091805cff12f24fabe6c11e0e8bae096cece81c/markupsafe-3.0.4-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:5066b244f576f91afc8ee3ba029a89f99d39c79b1853fe9d39bea9f0afbec148", size = 11801, upload-time = "2026-10-02T23:05:11.913Z" }, - { url = "https://files.pythonhosted.org/packages/ed/85/ea548dc013962eb73653124bc595635fbf9e0fa41d1f181a967ccb784dfb/markupsafe-3.0.4-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7a83aa6e4805df46fed18e989d3d16f86ef60cb50bbc8d9ce3a6be89165fbf6e", size = 11443, upload-time = "2026-10-02T23:05:12.887Z" }, - { url = "https://files.pythonhosted.org/packages/cc/72/15f2e5ec9cf2eb00d5cdfe968d94e4156a7bd7303832c3f3b2c403a36839/markupsafe-3.0.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2d1b7d9308288661f56672b1b157d75fc536714d3638487bbea17b6318a78248", size = 11665, upload-time = "2026-10-02T23:05:13.829Z" }, - { url = "https://files.pythonhosted.org/packages/ca/e0/4030bea613677e333c8a2c901fd405055f657f9d06acba5b7357984b6ef7/markupsafe-3.0.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73e77980c7207854f00fc4e71fb1626868d5740ab4012623d55c7a99ad122a72", size = 12061, upload-time = "2026-10-02T23:05:14.807Z" }, - { url = "https://files.pythonhosted.org/packages/f3/a5/28b76a7449eb702966b88bef599e2360b411fbb3afeee8fe560939be06ec/markupsafe-3.0.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7018d4af1cd272e847aa5917983ab5e83e4f6579f9dbfecd4a79c0ca80b144c2", size = 24395, upload-time = "2026-10-02T23:05:15.909Z" }, - { url = "https://files.pythonhosted.org/packages/07/6c/21232811afc3a063b5e934b1ae2efda52f46154ec382f585149c020e61fe/markupsafe-3.0.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c90d5b3d4e944e065a301d741b3c1d784f6bd1f503aa68b4967e32b2ba313d85", size = 27183, upload-time = "2026-10-02T23:05:16.976Z" }, - { url = "https://files.pythonhosted.org/packages/14/38/6ccdfa5b59049cb36fb80cbc80aee9cf1fc9bb77d1335ad435f2070b08cf/markupsafe-3.0.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:18a801868a884f216e784d7d14db2a4077143ce7610440aee2ce8f734e7cfcde", size = 24772, upload-time = "2026-10-02T23:05:18.209Z" }, - { url = "https://files.pythonhosted.org/packages/63/e0/cec6865dfe88cb48fedd4b20aed6af5158e41092adcbf3e028bcc6ec2108/markupsafe-3.0.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:434139499bb20b502ed3baa1f169e618f924a97e7a777fea1a49446d80106cf6", size = 23017, upload-time = "2026-10-02T23:05:19.286Z" }, - { url = "https://files.pythonhosted.org/packages/ee/76/6ed4940bb7648a9aac457c14f870cfdd5105f139a0fb1f29cd61fafa47d1/markupsafe-3.0.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9e227f3dbe6bde7491cf0a9965d00b88c6b1a4a95d11480ddf88bb96d397c19f", size = 22036, upload-time = "2026-10-02T23:05:20.352Z" }, - { url = "https://files.pythonhosted.org/packages/a1/4f/ed476226d4fe46a09090a36025bf319296810028df55eb12f1253b540f3a/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b8cd1f918b26fd7b1832ece557cc18f2d8747309ff8b3f0ef9d4250c5ad67a39", size = 23786, upload-time = "2026-10-02T23:05:21.576Z" }, - { url = "https://files.pythonhosted.org/packages/9a/35/66ff30450e35ef5fba9ebc930c9411747e537fd9447b65e44f5007e2b84d/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a5fcffb37e602b0b3c1638a97746b9b96125caa9bcf6fa41d337a9261de231ee", size = 22672, upload-time = "2026-10-02T23:05:22.922Z" }, - { url = "https://files.pythonhosted.org/packages/32/0b/72f45ce4b4efcbca4b80cf1b06703eff0be8d37e82abb78f66c85a7ead1e/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5989cb26b2e1efc6a42216a9f6b5ee495ce5ace2e5b352a9af489976b32d1ee2", size = 24107, upload-time = "2026-10-02T23:05:24.175Z" }, - { url = "https://files.pythonhosted.org/packages/d2/03/71776e5fdcba04614b384cc102e8a4198208579d896fd1394cb7cb9aa900/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:add96447a86d205ab616665d53b2950ee81083757f56e6ea833c8b2917646b46", size = 21615, upload-time = "2026-10-02T23:05:25.215Z" }, - { url = "https://files.pythonhosted.org/packages/ab/5f/801ce02a02e7aee0f784b1ec7843026178f6adeb9c93ac67eb1992a9a84d/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2628d3a8cb648ecebb3c5d6b0a1052d400e4d8b7ac0fb786be8d285b50040d17", size = 23076, upload-time = "2026-10-02T23:05:26.423Z" }, - { url = "https://files.pythonhosted.org/packages/4a/85/c43776625428f3bb4a61e8633940400e3efe6409e3c6f5bff26de5e45618/markupsafe-3.0.4-cp313-cp313-win32.whl", hash = "sha256:672d207103e6b16ca098611b0f9efad6bc00afd47c03d6ef62186495ca677dc0", size = 14118, upload-time = "2026-10-02T23:05:27.716Z" }, - { url = "https://files.pythonhosted.org/packages/6f/36/163da64de88a13db79214ef75fa041be7fa13bdb42261cf5b7484de14bfb/markupsafe-3.0.4-cp313-cp313-win_amd64.whl", hash = "sha256:1f1f9477e174582b0a1b583d60b66e1f2cf5d3fe12cee985e4aedf44766600e5", size = 14313, upload-time = "2026-10-02T23:05:28.749Z" }, - { url = "https://files.pythonhosted.org/packages/9f/a8/9b662783ffaa1149221432a923cee562f78b9cbbb8baa3df9b3753e63e1e/markupsafe-3.0.4-cp313-cp313-win_arm64.whl", hash = "sha256:06de8ef6331f6e822c28d577dc8bf43fe398800477c49498f38fc38b67ff33fc", size = 14189, upload-time = "2026-10-02T23:05:29.917Z" }, - { url = "https://files.pythonhosted.org/packages/5c/c3/a944f3b0df22bd129e96915b9f4e98d2eeca6516687d7618304a966c3c74/markupsafe-3.0.4-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:4ed644d75aa94a2baf7ec3a96eaa160ea58c742eb9d27c6506053c5c40fc84ed", size = 12787, upload-time = "2026-10-02T23:05:30.971Z" }, - { url = "https://files.pythonhosted.org/packages/d4/d6/a44863f69d88b6c7e27889108f70d47aed259edf89d5df3c5fca1eac87d6/markupsafe-3.0.4-cp314-cp314-android_24_x86_64.whl", hash = "sha256:6d2a9efe686f9de00d0d1ea32a4a5a86d558a2277501bd78d964214eab625e59", size = 12525, upload-time = "2026-10-02T23:05:32.263Z" }, - { url = "https://files.pythonhosted.org/packages/17/8f/168ba80e532dd6a93f96f8f706f1ad41d7990b6e1aeedc1cc0d211a33497/markupsafe-3.0.4-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8781a792a070cf2bd1b86d3aa943894115faaba6e88122a7bf32d62072742453", size = 11589, upload-time = "2026-10-02T23:05:33.251Z" }, - { url = "https://files.pythonhosted.org/packages/32/b3/aa2c95a574d3af39403a469b295886eb9b6d448da568cbebb5a2cbfdc2e5/markupsafe-3.0.4-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:971a3bbb75d97ae4e2e8f7d4834236f86f85f0c85e04ab2e191db1123b04f80b", size = 11801, upload-time = "2026-10-02T23:05:34.315Z" }, - { url = "https://files.pythonhosted.org/packages/60/d0/34b810107d83840e768bf485de795893ebbae35b26ab061b487adfa0a692/markupsafe-3.0.4-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:8909c2f1c6dd65e054ac4b573a91c8384d1492281e55d82d159d653f7a13adf6", size = 11447, upload-time = "2026-10-02T23:05:35.302Z" }, - { url = "https://files.pythonhosted.org/packages/6c/ab/2f8488f0f817a39fca068d2b17daf446bf5cdb3eae28c3720af534d873b4/markupsafe-3.0.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4cf3468d5ec187ffffcaca8e61929a37448f215dafc1386a12c750a72fe53634", size = 11722, upload-time = "2026-10-02T23:05:36.363Z" }, - { url = "https://files.pythonhosted.org/packages/ad/40/e2d117b048d47282ade906fbfd92814cbee5647afc13fda88a3406039372/markupsafe-3.0.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:52704c5d36eb6dda8866493decd61111fff86244c9b1ad225ca01b9e91e5970f", size = 12061, upload-time = "2026-10-02T23:05:37.397Z" }, - { url = "https://files.pythonhosted.org/packages/9a/a8/73a81135e85ba66217f5af7facb03bbb386807e1a729ab64532e4c802652/markupsafe-3.0.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1caa2fa5a6184fb233153b35f654e6687bd555476f6170f29d8ee9be1a8b0af9", size = 24434, upload-time = "2026-10-02T23:05:38.407Z" }, - { url = "https://files.pythonhosted.org/packages/ac/ca/fa9216dd01efee2dfdacafe7df32b4d0170fbac694b0c258a193d6e53999/markupsafe-3.0.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:387d8cd30e69b3f0a72877b9ae717033396404e19095b17fe89753a981fda44f", size = 27183, upload-time = "2026-10-02T23:05:39.581Z" }, - { url = "https://files.pythonhosted.org/packages/fa/4e/a469509e538d37af51103b17b073126973f2b1cbf197ff32c7ddf025cfe5/markupsafe-3.0.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:051417f74bcaaefa316276e0ff723f541616ca51043d070da00249d9bddd3e3c", size = 24813, upload-time = "2026-10-02T23:05:40.671Z" }, - { url = "https://files.pythonhosted.org/packages/8f/db/d7282caf7ab03af44d5d6fdbaa019b35c7d7f1c90588b839c07cba640d6a/markupsafe-3.0.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8e9f292fcda89b324f2f5c91d13f1424a153e40fc2756f38ee23b15835ff300", size = 23049, upload-time = "2026-10-02T23:05:41.864Z" }, - { url = "https://files.pythonhosted.org/packages/30/f3/b6a425206e6964efda6acee544d0eb01d1501784d0b8e2dcc74986f33b17/markupsafe-3.0.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:df1ae86ff54725a01fa1a0510b914ca53a161b7050be74f6204e24aded5971d0", size = 22088, upload-time = "2026-10-02T23:05:43.014Z" }, - { url = "https://files.pythonhosted.org/packages/ea/8a/84d3582fc1f0d5bd466cdf2eebf175e172158a6e70701aacec1de1b35430/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8965520ac587c94a4ac48b729be3d8b8de00af39699b17585dfb599babe77977", size = 23846, upload-time = "2026-10-02T23:05:44.098Z" }, - { url = "https://files.pythonhosted.org/packages/1c/65/db101cce51b7ba4864ac491a9859d297dd1adf0e55b103fee9db9c47c527/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:340cbb1957ba99929cbf19a75626d36ba1ae21d1730b287d1cf7f824a20c4fc7", size = 22674, upload-time = "2026-10-02T23:05:45.23Z" }, - { url = "https://files.pythonhosted.org/packages/e0/49/ddee9813d71db0c7a5c9d97c832125e6758a0c844777f1cf076569bb0e22/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:3a93d9616ddecfb393727a0041a562cf0b15a244e20f2bd25efc7949be4c4f17", size = 24104, upload-time = "2026-10-02T23:05:46.398Z" }, - { url = "https://files.pythonhosted.org/packages/aa/0e/7d8518d726726870a2399d69fd30d0fa36c5e57a2132c336b58d7c491073/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d2e56fd3b00222722abfb3f5f0759ddbae4b90811b5ad4343c64030ad1bde70c", size = 21637, upload-time = "2026-10-02T23:05:47.48Z" }, - { url = "https://files.pythonhosted.org/packages/b4/b0/b505e8a361ba557dbf3b3aa7331ea39b00d2022a26e925ff8463b9714bb3/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0d9c47709875fdb321452056622e930c52afbc07a7d780762fbb8b4d91ce6fa4", size = 23081, upload-time = "2026-10-02T23:05:48.611Z" }, - { url = "https://files.pythonhosted.org/packages/1c/ea/9cc3cea873f980c75cbdb6f4277ce30ee955de38be0b3d02f14c108e0698/markupsafe-3.0.4-cp314-cp314-win32.whl", hash = "sha256:38fc55594dab834470b6733dead2ee9e3f657fb0608c769dcafa0ba5ab52f45c", size = 14272, upload-time = "2026-10-02T23:05:49.707Z" }, - { url = "https://files.pythonhosted.org/packages/80/f0/5792ff768a410f93ee3f84fc19345295ffc352d2c936b424cb37e514714c/markupsafe-3.0.4-cp314-cp314-win_amd64.whl", hash = "sha256:c1bc67752d5f21013cfe430df4062441714eab79f65a6a05e01505957e9c35fe", size = 14501, upload-time = "2026-10-02T23:05:50.788Z" }, - { url = "https://files.pythonhosted.org/packages/5f/cf/3d074a8edffcc6899355232ff2543ae8d929733239596423b7db79698bc9/markupsafe-3.0.4-cp314-cp314-win_arm64.whl", hash = "sha256:7e1636da3d8dfc220b6dd10264db5f2b165e4888c4518594898fbe381049af8a", size = 14364, upload-time = "2026-10-02T23:05:51.857Z" }, - { url = "https://files.pythonhosted.org/packages/d9/31/87ce42159aae2163cf3bbbd0c44bc87780510eecab1ea3859099aed95dcb/markupsafe-3.0.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:805c8b84534fa10891890f0e4be39f3a99e94615d93e8836bf9fa1fdca2feeb2", size = 11759, upload-time = "2026-10-02T23:05:52.951Z" }, - { url = "https://files.pythonhosted.org/packages/5f/53/b047207eeb7752e960aca3eb1df5fb7eefa7dd4c62ac49bb156456c8a702/markupsafe-3.0.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:fa95848c929b6a75f6848d3c9793e59db365ee436776e57db835cdbfa79ba977", size = 12096, upload-time = "2026-10-02T23:05:54.066Z" }, - { url = "https://files.pythonhosted.org/packages/ee/51/4326c88a13c7b755657d44b4bb986f8c3d9843ecba7e22d98661d87f9a57/markupsafe-3.0.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e916035e3e9930cbdfdd10abf48861340221857f45509565898e012263f7b289", size = 25699, upload-time = "2026-10-02T23:05:55.15Z" }, - { url = "https://files.pythonhosted.org/packages/f2/bb/990581b7474bfcf2cf34bed6ba5ea23bd87adb9d671213d68e88620e7a6b/markupsafe-3.0.4-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b4d12837e0203bbace818ff4a7461afdcd78bcd782351cea148139180d7bcffe", size = 27804, upload-time = "2026-10-02T23:05:56.29Z" }, - { url = "https://files.pythonhosted.org/packages/6b/89/89491878c28e8291f5aa2fffe2c2d57230d10ae366d55dd810b840513d78/markupsafe-3.0.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5086f9975abb1ab531ee6afca1761e4b59a19b446f3f6522ed776963228cfe5a", size = 25912, upload-time = "2026-10-02T23:05:57.416Z" }, - { url = "https://files.pythonhosted.org/packages/30/77/680998b54efdea06fc114565cd739b6d059f826a0279219b218dfa750d29/markupsafe-3.0.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b4a635a0487774f841cb1fb62e907e7195cc95bc761e053184b8acc3ceb20733", size = 23623, upload-time = "2026-10-02T23:05:58.557Z" }, - { url = "https://files.pythonhosted.org/packages/ae/75/2709f5ac5de9467b40b10e2bb8f89cc63dfb74582e09aa734b1124a217de/markupsafe-3.0.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb96e6e088d6cf71c1ea977510948320234824cf226e32f6f6e044f7a9c82b34", size = 23301, upload-time = "2026-10-02T23:05:59.94Z" }, - { url = "https://files.pythonhosted.org/packages/a0/c8/39eadc6c5b14c9c7679bfb98f4d4c6a97863b5beb91839aca4d2d6e16e55/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8b5d563170ff8ba3181caa967c99a3c804d1dedb702c7cb93a6a7c32247da978", size = 24950, upload-time = "2026-10-02T23:06:01.289Z" }, - { url = "https://files.pythonhosted.org/packages/1a/5e/01037f8a43e8ccb0bffb4fbdc5212db05bf080fdd7286cd392332d58128a/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:396ec4e65cc889f69786b3b89478b471cee5a3bcf468b9d9bb03e1a30fb291fc", size = 23330, upload-time = "2026-10-02T23:06:02.441Z" }, - { url = "https://files.pythonhosted.org/packages/d4/f4/23e83ce0596bb0cbe670502d31df8f757bbd01a392aa486fa3b40d1ed399/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:15ba9e28640feef770374b116a6f019c21f52404aeabe516aa7f800587b98cfc", size = 25239, upload-time = "2026-10-02T23:06:03.579Z" }, - { url = "https://files.pythonhosted.org/packages/88/5b/3708897368073cc683d524750474f41a77d2986152c380dcc55b20fdf340/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d920abdfa61279ba1a2ef9484aab07bf03331f8c08a10120fa332353d06e6932", size = 22767, upload-time = "2026-10-02T23:06:04.699Z" }, - { url = "https://files.pythonhosted.org/packages/c6/61/ebda1307864b409e6b3115757a3d4a09cca46cfb6cc65191b5de226b424b/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a9f54054101545a9a9cccefddf54316aa6e4491611fcbef9e91b3b6bebec04f6", size = 23668, upload-time = "2026-10-02T23:06:05.9Z" }, - { url = "https://files.pythonhosted.org/packages/09/15/98075cceac3b5ba0dbb8e4762a847be967d2befc349a2cf2d0ac77f62c9d/markupsafe-3.0.4-cp314-cp314t-win32.whl", hash = "sha256:12a606a492de952afcb43b59a14aaaaad120e708d3663dd0fdf2d738d427a691", size = 14301, upload-time = "2026-10-02T23:06:07.109Z" }, - { url = "https://files.pythonhosted.org/packages/0b/a3/768b560fcc4156685cb563d922b217810cfa7bc135773367f62f1f9d2078/markupsafe-3.0.4-cp314-cp314t-win_amd64.whl", hash = "sha256:a18f38cafc329bac5e3c2b96c765b4c96d3d103421ed22ab7988c1e3fce27464", size = 14526, upload-time = "2026-10-02T23:06:08.276Z" }, - { url = "https://files.pythonhosted.org/packages/93/63/da554b4c97a6b0ea3229ca7fe8cbfb620be81613d517f482e85958550537/markupsafe-3.0.4-cp314-cp314t-win_arm64.whl", hash = "sha256:eba154571c16e032112afac0dc2dfe9e63c2ceb7aedd07bb7eecf2ce26d4dd4c", size = 14421, upload-time = "2026-10-02T23:06:09.402Z" }, - { url = "https://files.pythonhosted.org/packages/a9/30/54d11c8ca027114898cab97421fb39e4ffd9ddf47cdbc44df2ec76722da9/markupsafe-3.0.4-cp315-cp315-android_24_arm64_v8a.whl", hash = "sha256:737c9c3981998eba27f11786f84fddcbabc74068b72a4a1f454ea02094b57b65", size = 12791, upload-time = "2026-10-02T23:06:10.485Z" }, - { url = "https://files.pythonhosted.org/packages/10/6d/97c913e253a14bd3cd0e15a5c56d13203b823fa7ee32498342896a072dc4/markupsafe-3.0.4-cp315-cp315-android_24_x86_64.whl", hash = "sha256:489505b03f692c3f376394e49194fa7a7f9e8558d6e293a7056a0032b0c38163", size = 12527, upload-time = "2026-10-02T23:06:11.834Z" }, - { url = "https://files.pythonhosted.org/packages/26/f9/b86d032042a4d597d9e1997f0e5f63a3eedaf11258e0a05760b0a0a826ea/markupsafe-3.0.4-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:077293e425f28ec737dbcad442a71752e28f8ae27cde3d68acd1fb212091cd92", size = 11602, upload-time = "2026-10-02T23:06:13.122Z" }, - { url = "https://files.pythonhosted.org/packages/f2/dc/73c14c1eedf0ac5fa3292ba43435e6c49d2c2050f33cebde541f8f4807f1/markupsafe-3.0.4-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9348cbb300d224fe3b89793262cb093504d4ae927004468463f745188a193e4a", size = 11817, upload-time = "2026-10-02T23:06:14.227Z" }, - { url = "https://files.pythonhosted.org/packages/8f/69/2c2fcaa5fcee22d72c7819c0d536fd181c74a688e6143845419579cd2863/markupsafe-3.0.4-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:b807e598953730f82e4eae3bd30f6a122cf6b31c398c6b504c0e04c13c170429", size = 11446, upload-time = "2026-10-02T23:06:15.574Z" }, - { url = "https://files.pythonhosted.org/packages/88/54/9e5ec76c62e6e2834d5a93623018c943e8b3bb41d663e3fd4c03303b9b85/markupsafe-3.0.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:799c39bdf5e2f1292fedd3009f7b3c9e760f10b2420cb9638d56920840ff6db8", size = 11719, upload-time = "2026-10-02T23:06:16.701Z" }, - { url = "https://files.pythonhosted.org/packages/96/24/3ec292b44064c16229e064d770b2625bd8ea941aa61f44905a9fa44942c0/markupsafe-3.0.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:ae9dcb8fbe244cb82f8a6458b455b927a03685e383d9bacf1ea5ce180b96dc97", size = 12075, upload-time = "2026-10-02T23:06:17.855Z" }, - { url = "https://files.pythonhosted.org/packages/aa/85/b64fdb1f304848518742136983c24e96d967bfb59a0ea160e92736901ab0/markupsafe-3.0.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bced6e2a6dba6a28f7dd3c6ce14df1b2dd495923f16ea484cad03decd463b2b", size = 24656, upload-time = "2026-10-02T23:06:18.963Z" }, - { url = "https://files.pythonhosted.org/packages/9c/18/23997d4c65b355da6390d61cd56e0ab3befd6ba8dda25cb40c602bd0fa6b/markupsafe-3.0.4-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3882fb412298575bae3b9c46868251f15cc69307359f87bb1b382e53d6e5a2c9", size = 27737, upload-time = "2026-10-02T23:06:20.117Z" }, - { url = "https://files.pythonhosted.org/packages/d4/36/35998dead3c6af88c38265a56e58100211f036234ab88eb2283fd4cbce44/markupsafe-3.0.4-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04e7902ba80ee4bac1d50a549606527a1dcf0476cd81403db41099d3b60ec653", size = 25003, upload-time = "2026-10-02T23:06:21.284Z" }, - { url = "https://files.pythonhosted.org/packages/82/96/ef49135ce260db4ca4a12b119ed468449cd248db6b1468e2112b546d7a2e/markupsafe-3.0.4-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:925f929d6b59a8b3f8b8c6ac363cd0af7eecc81efb3071770b3c6717c450a369", size = 23111, upload-time = "2026-10-02T23:06:22.524Z" }, - { url = "https://files.pythonhosted.org/packages/50/7d/83126e338bd88c17a220668235368ad719fd4638e426739858cbb8508f77/markupsafe-3.0.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f68edfc67aabac33708941f26f22a7b8e9f81429bc0cf249fcf7d66b23af8d19", size = 22403, upload-time = "2026-10-02T23:06:23.785Z" }, - { url = "https://files.pythonhosted.org/packages/83/dd/daf7e420de23c8206c365204e7b85e1251d8e19d34196a56336f316e5ed2/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:e5c802729725bd07e2bc3ab7b76dc7e0bbfc53129d8f1eb1c002c24cf774717e", size = 24083, upload-time = "2026-10-02T23:06:25.037Z" }, - { url = "https://files.pythonhosted.org/packages/19/3c/11eecdc06bc44ad5570350085b572ebf049e8f9a38d1ece6d76640b739cd/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:55ffd6ce583d97dc71dc92e930324c8c0d25aea7e3ade6ae54ef77cedb096811", size = 22931, upload-time = "2026-10-02T23:06:26.328Z" }, - { url = "https://files.pythonhosted.org/packages/0d/9e/ac0fd77f2a726e56ecc3ca0235d095feace1358d1b822406c2a2ef26a4dc/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:2cb3dd71fc6be918ad4264346a8ed69485f9b7ed7bf35495d8e22807cd6b8bea", size = 24299, upload-time = "2026-10-02T23:06:27.742Z" }, - { url = "https://files.pythonhosted.org/packages/d7/09/c6bd842ad58ff5b3bc76eeed7e9a42a6f11adc5d090ec697b72c9672731e/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:94f5407f7bc64fa6463906b896f9904beeeb7dd8dc116ee8e9056c8714ff9916", size = 21948, upload-time = "2026-10-02T23:06:29.274Z" }, - { url = "https://files.pythonhosted.org/packages/a3/46/82f586711fed61e86faa1ee1bc317d68cd45a10c8bdbe3f7d1fdf9026ad8/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2dad610540cb2e6272855c178f08ae9a1c7ac258a7fb71660553a5f104b42741", size = 23150, upload-time = "2026-10-02T23:06:30.583Z" }, - { url = "https://files.pythonhosted.org/packages/19/2d/2dfdce99318abbfa26925195fbc17db188c46a1ec6457be121b6f9cfeb42/markupsafe-3.0.4-cp315-cp315-win32.whl", hash = "sha256:03470d1a8268e692ecf79ecd565593e59d44219377a7ead61f1f1b94c1f7ff6b", size = 14269, upload-time = "2026-10-02T23:06:31.949Z" }, - { url = "https://files.pythonhosted.org/packages/5b/ec/6000fd82e8791e58fcd0456ec20f098957e2b03d5ed02eb73241a577c0ba/markupsafe-3.0.4-cp315-cp315-win_amd64.whl", hash = "sha256:d882a373d8093c2941e01291b7ced96e9cbe4781da9a7751ca7e6c70385e5214", size = 14496, upload-time = "2026-10-02T23:06:33.258Z" }, - { url = "https://files.pythonhosted.org/packages/bc/66/e73bd5016421d5d6e2fb6de7dd609f9de020942ac8c626526bd8c6eeaf82/markupsafe-3.0.4-cp315-cp315-win_arm64.whl", hash = "sha256:353bd63081912ab8cfa6a0c7d185934cdf8426f04c618bba6bc4b394f2069b67", size = 14361, upload-time = "2026-10-02T23:06:34.539Z" }, - { url = "https://files.pythonhosted.org/packages/90/df/cb8c3dc98d313a951df2f8968f44e4cb5643df6d3cab749a530ce2f7d972/markupsafe-3.0.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c61750fadcd119d0825bcb7d7d675dd264dcc89cc05292aab5be68ebdbb374ad", size = 11760, upload-time = "2026-10-02T23:06:35.807Z" }, - { url = "https://files.pythonhosted.org/packages/d6/bb/4af9b3ca0753d654ac75f9531d5bd741bb77ca6e696f36807c475ffc099a/markupsafe-3.0.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:1c0df495a977d10460a94941799c72d5b5ab03d3858d949b55b5a66c8f371c99", size = 12108, upload-time = "2026-10-02T23:06:37.089Z" }, - { url = "https://files.pythonhosted.org/packages/3f/d4/b56429313aee5fd59b079c3df5615299959e25e7113eb6d8caadbdd7d38a/markupsafe-3.0.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02fa4acbc6a3fc5c693c34d4dd8c1130b7fe99cc915181b0ddd6f72aeb296002", size = 25762, upload-time = "2026-10-02T23:06:38.419Z" }, - { url = "https://files.pythonhosted.org/packages/65/f5/34c181e891aa4f7d59c918584672e0c5eb7fffe76c1387d1246008bf4081/markupsafe-3.0.4-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:05295589e619b9bed252a86b532b8e27350abc372d18ba89b59375325e91ec1e", size = 28400, upload-time = "2026-10-02T23:06:39.819Z" }, - { url = "https://files.pythonhosted.org/packages/ce/b5/ad14694fd0ac9a5ce30bc6498f2999378f418583dd1679cca5a1b512957e/markupsafe-3.0.4-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:be6cb0c799abb0e2ba3e618e6d28ddddf7e485f6c2ce938dfa237daf3905072c", size = 25946, upload-time = "2026-10-02T23:06:41.381Z" }, - { url = "https://files.pythonhosted.org/packages/d6/a8/26b606445387d0ceb1eb1f21840094b84e4e3c3c3983d80d10b89823b490/markupsafe-3.0.4-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:26e9867520db70d37f7fb421a7f0d8adb40171011fb84ce869afa1a83370dfa8", size = 23634, upload-time = "2026-10-02T23:06:42.748Z" }, - { url = "https://files.pythonhosted.org/packages/39/a2/b8814de672f1f0094d498bf646f2fec9d6356b503d28ef500b71c5095377/markupsafe-3.0.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f03460ff076f70ab595bb45a0205ccea1971443575b6920c52e755dec2b3fbfe", size = 23488, upload-time = "2026-10-02T23:06:44.176Z" }, - { url = "https://files.pythonhosted.org/packages/db/c7/287223376fb73335a3cc5d6eb22c6ab01358cf33945a9c39c06b9dac3f4b/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:436e3ffc6310d3c41878c601db29098102fe5d8a467c49da4a4125254e0980f2", size = 25037, upload-time = "2026-10-02T23:06:45.646Z" }, - { url = "https://files.pythonhosted.org/packages/f9/29/4df8355e313426d19e62ba33e0253c009ca12a0894ee77d67fa67255361c/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:4e2c4809c14559aa7ef426f27fb35afbb38104c349a903bf8f3600456764bb38", size = 23543, upload-time = "2026-10-02T23:06:47.264Z" }, - { url = "https://files.pythonhosted.org/packages/71/e5/8377731e8495668dcc768f645e717df18318c841edaf023a99395f6da9b4/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:da2af0d7aebfc2074080d72efa6ab8317c62481ef1f896f65d9999c1c01f4494", size = 25282, upload-time = "2026-10-02T23:06:48.795Z" }, - { url = "https://files.pythonhosted.org/packages/ed/5f/373456e37ceb1478d657d6fe769cbe0a39f0a8dfc1548eeb19c471eefdd9/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:aa2c838cc024642cc04c6854232f32b43e5e22833dd11119c1766c7873b8370d", size = 22993, upload-time = "2026-10-02T23:06:50.31Z" }, - { url = "https://files.pythonhosted.org/packages/d7/93/2cbd5628435afb6f541bbaced4bce0c2edac4b09a142e6e928b8b0da9858/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:b91cc9d336957239ff200f30097e6fea2dc6d6fb3c81e853eaa09eac904fd894", size = 23712, upload-time = "2026-10-02T23:06:51.759Z" }, - { url = "https://files.pythonhosted.org/packages/81/99/157e10966b033b363aeda5263e82596ee232a0b1d082fdbf90aa417ff083/markupsafe-3.0.4-cp315-cp315t-win32.whl", hash = "sha256:e49fb0d1ce92cfa0cb198cc5b1b11cdf9d0638658e2a2db2687e39db7c87fc78", size = 14300, upload-time = "2026-10-02T23:06:53.241Z" }, - { url = "https://files.pythonhosted.org/packages/33/05/55884815414c9706a23deca150b72c25a62109e65b0b6ce232077802c719/markupsafe-3.0.4-cp315-cp315t-win_amd64.whl", hash = "sha256:4f6e0852a0283b1b1fd776eeb7b766a5f440b3e2bd31ab51af3b400585f3965c", size = 14522, upload-time = "2026-10-02T23:06:54.729Z" }, - { url = "https://files.pythonhosted.org/packages/92/f9/ecbde7149e95b8a0f18e16d5d747f7dc06049d5da2e4f77f6f5e4a1f46a8/markupsafe-3.0.4-cp315-cp315t-win_arm64.whl", hash = "sha256:39dbacefc411633db5b4378b066a9aca70a3d7e2922c9e578d825f844026eeba", size = 14417, upload-time = "2026-10-02T23:06:56.246Z" }, -] - -[[package]] -name = "opentelemetry-api" -version = "1.45.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/1f/dc/e12c1fe1ed8a7b7149777127b1a0e12ce5bd5a81d97408bedc2128c260f5/opentelemetry_api-1.45.0.tar.gz", hash = "sha256:711ede81773c8025c2c03dac0450bc89f3d30aea6eabcc815c570d4e35a963f7", size = 72115, upload-time = "2026-09-25T12:32:28.399Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/44/b9/040d1a1c7836922828e6480cd2366bb8fe0ebf75b413d2bb51a9b0e7f78f/opentelemetry_api-1.45.0-py3-none-any.whl", hash = "sha256:80e068aba7cd56c8b58512d6a36f8d25cb1dfaa0c0a4cc1c938ccf9f362d9cb3", size = 60020, upload-time = "2026-09-25T12:32:03.192Z" }, -] - -[[package]] -name = "packaging" -version = "26.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, -] - -[[package]] -name = "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 = "policyengine-telemetry-collector" -version = "0.1.0" -source = { editable = "." } -dependencies = [ - { name = "alembic" }, - { name = "fastapi" }, - { name = "httpx" }, - { name = "psycopg", extra = ["binary"] }, - { name = "pyjwt" }, - { name = "sqlalchemy" }, - { name = "uvicorn", extra = ["standard"] }, -] - -[package.dev-dependencies] -dev = [ - { name = "pytest" }, - { name = "ruff" }, -] - -[package.metadata] -requires-dist = [ - { name = "alembic", specifier = ">=1.16,<2" }, - { name = "fastapi", specifier = ">=0.115,<1" }, - { name = "httpx", specifier = ">=0.28,<1" }, - { name = "psycopg", extras = ["binary"], specifier = ">=3.2,<4" }, - { name = "pyjwt", specifier = ">=2.10,<3" }, - { name = "sqlalchemy", specifier = ">=2.0,<3" }, - { name = "uvicorn", extras = ["standard"], specifier = ">=0.34,<1" }, -] - -[package.metadata.requires-dev] -dev = [ - { name = "pytest", specifier = ">=8.3,<9" }, - { name = "ruff", specifier = ">=0.11,<1" }, -] - -[[package]] -name = "psycopg" -version = "3.3.6" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "tzdata", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/76/26/3ea4ca5eaea1c0debcdf7ee7c1613fbe721dc27a03c461c0817ffd8a0601/psycopg-3.3.6.tar.gz", hash = "sha256:c081f2250df751a943036e42db6df4571c66cd0aabe8291a7a506512b12007d2", size = 168171, upload-time = "2026-09-18T13:22:55.152Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4e/de/748bd7609c71cae5d737f0ba9192f19329f70180ecda8fff3cac02c5abe3/psycopg-3.3.6-py3-none-any.whl", hash = "sha256:a1db9f7148b06a28606767efaca51fa6f9398c5c0a3810519be69d7000bdb631", size = 215490, upload-time = "2026-09-18T13:15:29.374Z" }, -] - -[package.optional-dependencies] -binary = [ - { name = "psycopg-binary", marker = "implementation_name != 'pypy'" }, -] - -[[package]] -name = "psycopg-binary" -version = "3.3.6" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b4/c3/c072584b69ad44a747b448cfc9766fecb8aae56e372a017e2ef668790057/psycopg_binary-3.3.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ad8f35e67cc16d1fad1fa8c88972dc9b3a3141ea67897399904edab96a301b6", size = 4712284, upload-time = "2026-09-18T13:19:13.451Z" }, - { url = "https://files.pythonhosted.org/packages/0a/b9/4283b785339e8e2318d03048994b093d650ea6289fabaa806b765dc0d449/psycopg_binary-3.3.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:373704aea331d3f3e3402c125a1543f5875e2986ebb54f97d1647942161f803f", size = 4772031, upload-time = "2026-09-18T13:19:18.524Z" }, - { url = "https://files.pythonhosted.org/packages/6f/72/7a1321d359246769fff1affffbd0132785a28f7f63c18524c15a502398f4/psycopg_binary-3.3.6-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:b82491019b884d62318b5f30706c3d7e6d4e5a6cb7eabcb3edc0c1b0fdaceae9", size = 5556392, upload-time = "2026-09-18T13:19:24.418Z" }, - { url = "https://files.pythonhosted.org/packages/de/b0/c6f8a0585a5dacbea74e130bcfc66629390e8f5bbc79d2a8e806e8952150/psycopg_binary-3.3.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cec5ea900390897d0b46130f60bc2883bf19c314f9044235217c8be88b0ef269", size = 5237855, upload-time = "2026-09-18T13:19:31.257Z" }, - { url = "https://files.pythonhosted.org/packages/e2/fc/c3a7a8bbef7e945ec584ac61d460a612363ea398511cd0e220242b1d69f1/psycopg_binary-3.3.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98c02090d88f2ebc0ec1e8da538f77d225ce0fffecf372aa39262e62a1b054ef", size = 6833856, upload-time = "2026-09-18T13:19:43.622Z" }, - { url = "https://files.pythonhosted.org/packages/a9/f2/8e80b921db728ebb68fc105bd7c4277f908210ad755bd6481d5ea7add740/psycopg_binary-3.3.6-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ee2c4728c691245e24501fcd7a97b5b381236b9985bc445bba88cdce7d1b5784", size = 5070730, upload-time = "2026-09-18T13:19:49.968Z" }, - { url = "https://files.pythonhosted.org/packages/54/6a/5b313e0c5348244f0e973aff3258bf86766656256d5ece8d541a53e35b4a/psycopg_binary-3.3.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f19cc87343eaa55255e76b31259a570072ac95d6ae82c92dd34b97691f5e49dc", size = 4598089, upload-time = "2026-09-18T13:19:56.426Z" }, - { url = "https://files.pythonhosted.org/packages/32/e9/db7f76ec24bf6699e92bf604e5c4bae10664a681a8999ef42aa0faf0f2c6/psycopg_binary-3.3.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fdccb3a0e184b03e9baa673b15a809cf36c339c85dbda0ebc25a698846dfbee8", size = 4278481, upload-time = "2026-09-18T13:20:04.681Z" }, - { url = "https://files.pythonhosted.org/packages/61/83/72c67013656f4d6b547caabffb193e91d57e63f90eefdcc6d045c400e97d/psycopg_binary-3.3.6-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:9892188bb15e5803beb51afe8a25add6b56be391a53058e8bca03b74e1e6bf22", size = 4009229, upload-time = "2026-09-18T13:20:11.905Z" }, - { url = "https://files.pythonhosted.org/packages/82/35/5e4500df2c999eb0faed8b184e6958b834172128274f06167a5deef4c19c/psycopg_binary-3.3.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3af90f92769d8cc10f94515ee7a0aef36ea85ca733a0ce22858f6e0953f41138", size = 4321467, upload-time = "2026-09-18T13:20:17.949Z" }, - { url = "https://files.pythonhosted.org/packages/55/7f/e350e1cf498ba2565c3f87b12f429d2012eb86b76c2b3845a19ee5fbb4d6/psycopg_binary-3.3.6-cp313-cp313-win_amd64.whl", hash = "sha256:0ebfad5d131de9f892ae9e70cc7616207768b6714b66a52d4612b8ceaf78b372", size = 3658179, upload-time = "2026-09-18T13:20:22.691Z" }, - { url = "https://files.pythonhosted.org/packages/6d/b9/60711317c284a442511644ea7185b56ebe627606d6741e732cd16108c47b/psycopg_binary-3.3.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:b3f75dee0f9afafabe4edc52c4842f1e1878ed2069bd05b22d6fe961e97e4dba", size = 4720512, upload-time = "2026-09-18T13:20:29.278Z" }, - { url = "https://files.pythonhosted.org/packages/63/da/28befc84454cbc6374550de7746f591f8fe1b6165c1fce249652cc8291c4/psycopg_binary-3.3.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5927b7ba63153cd8e9862987290a2b783a5c590daf2a4ef981700cc3569166d4", size = 4782318, upload-time = "2026-09-18T13:20:35.401Z" }, - { url = "https://files.pythonhosted.org/packages/a4/8a/0d21c2c833cdc0d4244c77e858e0ed37fa2abec2623be4fd686f617109ce/psycopg_binary-3.3.6-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:0bf08b749cc144f33b44a91b78e3f71c60eb07963746a0df5a100b36ce3d7475", size = 5567460, upload-time = "2026-09-18T13:20:41.902Z" }, - { url = "https://files.pythonhosted.org/packages/49/6d/7692d0d4e656b6cc9868d8acc2e3b42f17a0db4a625400a6d093cb0533a1/psycopg_binary-3.3.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:31cd942c23f613276b81a6e6598cefa12960058b0f46e1e874b540c793f6aca5", size = 5246902, upload-time = "2026-09-18T13:20:47.661Z" }, - { url = "https://files.pythonhosted.org/packages/d4/c1/b8a1f18fb1b7558a17f57f7cb3fc8bc93189feea2958925950b3acb15743/psycopg_binary-3.3.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4690cf67738f0e0e49a32aeec99bf0e4595cc2b4f1af984a4345394b1dcff91a", size = 6847192, upload-time = "2026-09-18T13:20:56.874Z" }, - { url = "https://files.pythonhosted.org/packages/a5/76/404f33519167c65cca88ec4998776f1dbebccc301ee977f0e62c47fb0826/psycopg_binary-3.3.6-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ad1c785e784cfd87e8436c6b7702f2d321fc39601bbaf29bc63a41a867091638", size = 5079573, upload-time = "2026-09-18T13:21:04.155Z" }, - { url = "https://files.pythonhosted.org/packages/f0/d9/79e8fbc8f37262a415f3550f0bcc5f98037442bf3d12ef6cbae2056655ae/psycopg_binary-3.3.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:79a2a1c3449f6c3409427078ed1cec10de79f3023cb5f2504f0597d350ad46c7", size = 4613633, upload-time = "2026-09-18T13:21:10.664Z" }, - { url = "https://files.pythonhosted.org/packages/d4/47/96225db74be7d2ce04b3a58678b53cda610225055edf5faa775c9f501d8b/psycopg_binary-3.3.6-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:86147cb5d140341c3363fb5bacce31f8d5543902a46699d3c536b101bbceaf9e", size = 4293375, upload-time = "2026-09-18T13:21:16.027Z" }, - { url = "https://files.pythonhosted.org/packages/2a/d2/18e9c779a5efd565250329adaf529ecc2b8b2ed5be5cb0f6ccee208cbfd9/psycopg_binary-3.3.6-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:7308c93cf0b19bbaf8e6ff0a6ad50d3c442385739245fe15a8d593bf841734a6", size = 4019883, upload-time = "2026-09-18T13:21:21.587Z" }, - { url = "https://files.pythonhosted.org/packages/ef/28/0cc654afc6c2cda982767f5679d3646b30b1ec86545bdaa9402202d6776c/psycopg_binary-3.3.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:05a83ac9fd52b9bca7cb5ab04b3691163170bd16f53defa27216ea3aa07ee781", size = 4332607, upload-time = "2026-09-18T13:21:27.63Z" }, - { url = "https://files.pythonhosted.org/packages/f1/3e/0a753a74fbd7aef120f286c016e09d3cc3f1daf7688f4a145d27281260b2/psycopg_binary-3.3.6-cp314-cp314-win_amd64.whl", hash = "sha256:1fbd30e537dab22cafdf080608f10148fe2a5f3a61294ddb5113caac8a623840", size = 3755671, upload-time = "2026-09-18T13:21:33.855Z" }, - { url = "https://files.pythonhosted.org/packages/0e/b1/a372b9c02aea50148e71c9853e19efca8fa5ae2010a8e27243b9b8f790c0/psycopg_binary-3.3.6-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:bf8c8481d026b85dd70c5fa7dde85b2333aed0b32a2602bcd38a900cbd78a49c", size = 4719571, upload-time = "2026-09-18T13:21:41.437Z" }, - { url = "https://files.pythonhosted.org/packages/65/7c/811e3828c6b82e2f10c6c9cdd963cfc66f3e024026e5a69ac18530bad984/psycopg_binary-3.3.6-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:b599defe9190b17e9907c8b4d114c181e702c87efcd1b8a0ad40971cdcc4634a", size = 4781230, upload-time = "2026-09-18T13:21:49.516Z" }, - { url = "https://files.pythonhosted.org/packages/3e/15/9a784eed813ea9e97c294af3ead63d02b7b203502c66380336c50065e441/psycopg_binary-3.3.6-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:b8ece331509f7a975b90501f41e83ad905e4141753fedf3f2711b2bc70a8efbc", size = 5566111, upload-time = "2026-09-18T13:21:58.089Z" }, - { url = "https://files.pythonhosted.org/packages/68/16/47194e002007c27337b11e49bf459c4b19727463f9aff2e1a90917bcc806/psycopg_binary-3.3.6-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c61617eaae0112ca154da87ffb99b73af2c74067acac28dfb9a4455b019dff2e", size = 5249963, upload-time = "2026-09-18T13:22:06.695Z" }, - { url = "https://files.pythonhosted.org/packages/53/84/5dcf9f310b11f0675cd860c6b2c70f58ce61798a3ee3f6f962b53fa358ca/psycopg_binary-3.3.6-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c6d19cb4999d03231e8730a5f66c8f5068bc3b532677eb39dab0f600bff3e312", size = 6847925, upload-time = "2026-09-18T13:22:13.088Z" }, - { url = "https://files.pythonhosted.org/packages/f3/06/1957a06dc22963c418c27b284929579de84f29c37ad1abe6dc6ee9e8cf25/psycopg_binary-3.3.6-cp315-cp315-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8cbb54454dbf1bbf2ff08dd7693e8d94ac94b1a20f70f4b3b813d52ecb5cbc1", size = 5087720, upload-time = "2026-09-18T13:22:17.959Z" }, - { url = "https://files.pythonhosted.org/packages/21/43/ac07d042bae99b57bf123bb473632f29af544008094da0ffd285ab8011e2/psycopg_binary-3.3.6-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:dc75da5a20951049f7b773145f998f69d181adad9c58a0ff36e0cf1d73c10e10", size = 4613412, upload-time = "2026-09-18T13:22:26.719Z" }, - { url = "https://files.pythonhosted.org/packages/aa/b1/019156fbeafcefb4cccc9d109de4699493bceb8313c7545c8349e089dfbc/psycopg_binary-3.3.6-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:955e3dd94da361e052d2e49acf591017158dc8f8ed2c8a42c2e3943403c39dc2", size = 4292618, upload-time = "2026-09-18T13:22:33.042Z" }, - { url = "https://files.pythonhosted.org/packages/5d/0f/62113dc6b1df65983a1f2fc816c04b1edfa22f2ae9d4abee74ed267f4a96/psycopg_binary-3.3.6-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:c7753871eb57e6a5f4646f6168590c6653073dea5e9e720b201c8875332df4c8", size = 4027121, upload-time = "2026-09-18T13:22:38.334Z" }, - { url = "https://files.pythonhosted.org/packages/5d/d5/cf0cbd1ea5a7d8167fe2c6953efde19101f7b193bd61a23e6d622ad6854c/psycopg_binary-3.3.6-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:303732e798fe6729f8e12021b9c96107df8e95ecec4dd487c67b98ec2a59435e", size = 4336388, upload-time = "2026-09-18T13:22:45.576Z" }, - { url = "https://files.pythonhosted.org/packages/98/33/e2a5b36edf8aa422f6fa4b894756eb33dc93b36df5f65121280bb8b929c4/psycopg_binary-3.3.6-cp315-cp315-win_amd64.whl", hash = "sha256:2f122603f36050937982abf9668d8bc4769a79f7c93a65013b1c49f1cab7b56b", size = 3756154, upload-time = "2026-09-18T13:22:51.283Z" }, -] - -[[package]] -name = "pydantic" -version = "2.13.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "annotated-types" }, - { name = "pydantic-core" }, - { name = "typing-extensions" }, - { name = "typing-inspection" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" }, -] - -[[package]] -name = "pydantic-core" -version = "2.46.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f5/37/5abe39a8372a61d3dc3c1338fc504281c01b32fdb3169cd7187153b56d3e/pydantic_core-2.46.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:b7ca9034437b6022f941f4857459562ee00a560b97e7cce8a0ec5a74fc6766e0", size = 2075885, upload-time = "2026-08-28T09:58:47.856Z" }, - { url = "https://files.pythonhosted.org/packages/21/43/6323b1f8b217780454c61304bcd2b38ae4762f50754414124603ccc90bb2/pydantic_core-2.46.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f332f0e72a5a0400141f830744e141bf9f97917878dbe968669e8a7fefea78ff", size = 1922768, upload-time = "2026-08-28T09:58:49.58Z" }, - { url = "https://files.pythonhosted.org/packages/0f/a3/c05ca796e1197618a774b01e596aeedfefc2f7d8c01ae3054e910b120e8a/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:193375f3548919d3f0b60936ca113ada3e38f264f91b9b8e0508efaad57be931", size = 1951241, upload-time = "2026-08-28T09:58:51.511Z" }, - { url = "https://files.pythonhosted.org/packages/68/32/33bc39ac705c52cffc908e8389f9754fdb208aea5c69cceddf4eb3ce99af/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:79bdfa52f843137045b2d081cc05c120ba6665d29b7559c2c47690906f39279f", size = 2031975, upload-time = "2026-08-28T09:58:53.166Z" }, - { url = "https://files.pythonhosted.org/packages/b0/70/2333e885c0f6a67bc105c5916965dac9b57f2718ee20d81d1a06a4ebdc13/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:24922243639cbdac66c75fcb6fd6495a9cb52b213d62f9a0d16f0310b1ff8038", size = 2208542, upload-time = "2026-08-28T09:58:55.017Z" }, - { url = "https://files.pythonhosted.org/packages/f7/ea/296debfb4264207bbda5936133892e027c0a58875ad53ebd512fba8ec3a2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c76fe65e607be28c7fd4d56fc3c42b1583aa058ce3408b7ad0fd540171d31f9f", size = 2264692, upload-time = "2026-08-28T09:58:56.767Z" }, - { url = "https://files.pythonhosted.org/packages/d3/f2/9e4de77a6271e07a76d2d58b11c091a979c191ed2939bf80067568b369d2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f7b393a8b3da82f5c1fc0751e6d01ac6c55b93c18226a60bdfba4a724efafd1", size = 2066633, upload-time = "2026-08-28T09:58:58.531Z" }, - { url = "https://files.pythonhosted.org/packages/8d/db/f9e9d0c97445987b2084823d5c240de88087338f04fc2cfaa2df186b8049/pydantic_core-2.46.5-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:7ac031912d54f3d83ef3b3eb98dfabc1608802e2202263d25957eeed40b94761", size = 2105235, upload-time = "2026-08-28T09:59:00.421Z" }, - { url = "https://files.pythonhosted.org/packages/07/c5/79169b047b3b2c3e99e04bc76372af9637e0bf6db638274fa927df96369e/pydantic_core-2.46.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:837b396ca3d7b74091ca623f6cbd8351bd42d670a79c2683e79fb089f06a2de5", size = 2157367, upload-time = "2026-08-28T09:59:02.442Z" }, - { url = "https://files.pythonhosted.org/packages/26/b5/ba6057afb7c291bd449f51b867f95aef2072941c4ce4e5c31d6ffd132d3b/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:5ee239d575f80b08eca11f6e20f90c4c695de7825c67eefe6091fbf20dda648e", size = 2158420, upload-time = "2026-08-28T09:59:04.2Z" }, - { url = "https://files.pythonhosted.org/packages/6e/28/2057abecaafdc22912afa819603a51f0a62d40643b7c4871c51721fea9be/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:e80675d75ae2cd14372cb65cad5400d9347a3d3f6c13000183f22dfd027283ed", size = 2309588, upload-time = "2026-08-28T09:59:06.048Z" }, - { url = "https://files.pythonhosted.org/packages/71/9d/881156dc404e27479c4246128d73538464cab4a239bec61995e227644c30/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:9c4b71f10dd532fb7a5cbc8f58707779e64f03a258c2bf8bfbaecfcd9970b519", size = 2341866, upload-time = "2026-08-28T09:59:08.539Z" }, - { url = "https://files.pythonhosted.org/packages/5a/38/d66f443a259f84d13babdceae568e572b0ed26da17ca5d0a649ebb110a67/pydantic_core-2.46.5-cp313-cp313-win32.whl", hash = "sha256:97bf8de4d541598c94a59344eeb988a94c08ff76b5723c41f6567ec18c7892ea", size = 1938580, upload-time = "2026-08-28T09:59:10.402Z" }, - { url = "https://files.pythonhosted.org/packages/2c/1e/1d5371213f4cc9a7ed70c0bfcc7911de22311ee99a662a56077d7292d2ac/pydantic_core-2.46.5-cp313-cp313-win_amd64.whl", hash = "sha256:15f4a94963c95accac15b7b657bb177d3ad82bb90b0d0526d9a9b85079925db5", size = 2041980, upload-time = "2026-08-28T09:59:12.396Z" }, - { url = "https://files.pythonhosted.org/packages/5a/48/4222d90b1c67568bace4dec6dca6271449c66de3595d72b6d098f5fde597/pydantic_core-2.46.5-cp313-cp313-win_arm64.whl", hash = "sha256:d22a945598fb91236b4dd793a6e42e4f3dd7740bb5aace5ebd7d4c08d13bb575", size = 1997213, upload-time = "2026-08-28T09:59:14.245Z" }, - { url = "https://files.pythonhosted.org/packages/8e/8a/14596f2a8367da50cf7cbac48169ee5d9c8e11d486a3b527082384630c72/pydantic_core-2.46.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c1c43ad4339643d70ebb8124e1305a7dab423001eff58bb41a0f731adbc98355", size = 2074081, upload-time = "2026-08-28T09:59:16.141Z" }, - { url = "https://files.pythonhosted.org/packages/ae/d5/d8a4eb6d6c7f66b91dd37c576d76e9e60fba900caf5372c17bcf949febc2/pydantic_core-2.46.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a353f84de772f423b5ffb11d7ae352fbbef0f446f3c0b0af0f8236d7233606e", size = 1920497, upload-time = "2026-08-28T09:59:18.065Z" }, - { url = "https://files.pythonhosted.org/packages/8e/26/092079428f86e927e030b2c0ced87df69dbb1c875cdeaa67bf42ea2be746/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5086029a57366b8cf81b130a43908738095c270c21a8d7f0e8bdfdb89718e2f3", size = 1952130, upload-time = "2026-08-28T09:59:20.476Z" }, - { url = "https://files.pythonhosted.org/packages/08/c3/8ec0e290a9ebaebd64047bf5fda94be835c6b1551b02437e4b76778fbcd7/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46c25dda9d092a06c08db76ffe0a197107904d0dfac653f7d5306bbcd6d6119c", size = 2026371, upload-time = "2026-08-28T09:59:22.227Z" }, - { url = "https://files.pythonhosted.org/packages/01/72/4fd20ad520fb8da0157f95b27a7eb05a72790ef08138e7701ac972c342ea/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:37ea7b83c935e5b0d68c9449b82651accf78a10828b2c02b2f2d9e9496446c21", size = 2202822, upload-time = "2026-08-28T09:59:24.277Z" }, - { url = "https://files.pythonhosted.org/packages/31/b0/d16e0771206b29314f0d52198b720be21e8a99ab2bf11e3bc0d7c9cebdff/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e64e88d5585bea9ce95861079de72006c7fa6d3df4e3a3b65ba31eb979c15c9f", size = 2262756, upload-time = "2026-08-28T09:59:26.608Z" }, - { url = "https://files.pythonhosted.org/packages/2c/9b/59634b7ac631c63b2a37760eb6943af3e29573d6b59a4abc5e7f019d4cee/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d510bac3ee52247af28ed4bb18a1e799f040ac60fd2bf5ccd4c92f1fbe786f", size = 2068352, upload-time = "2026-08-28T09:59:29.044Z" }, - { url = "https://files.pythonhosted.org/packages/08/7c/570abb1ad2155348dc754ea91be22e5aaa18eb6d69a6068f7c6f2679a6ed/pydantic_core-2.46.5-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:a2a5e1d0ff29adddc9f6d6821a66302e4493f8ca898b715b6b1182c2c201ea0a", size = 2104777, upload-time = "2026-08-28T09:59:30.95Z" }, - { url = "https://files.pythonhosted.org/packages/8e/25/5bf74adc65a1ac5b7be3f6cb0bcb5433615c1598a801c19d830d84c98ded/pydantic_core-2.46.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:03b9666e41e35d8909852ba191a0607520f81b74eaf12ccf8737005dbb313821", size = 2156312, upload-time = "2026-08-28T09:59:32.604Z" }, - { url = "https://files.pythonhosted.org/packages/90/6a/2ef38830675e050121040618135564ed56b860b45433b02d9b4ebece46f3/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:a91c17edf6eea2402cb5457b4c89e99bc5ed1004aa34c4adf1d4258c1a5c22c2", size = 2150067, upload-time = "2026-08-28T09:59:34.453Z" }, - { url = "https://files.pythonhosted.org/packages/90/ef/a7dbb03a14a64c2a4621f989c615ed9a892535a6cad938fc27079f919d80/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b49924c73a235e969511bf2aabdff3beebf9820931f646c80274d5d780010c47", size = 2304516, upload-time = "2026-08-28T09:59:36.194Z" }, - { url = "https://files.pythonhosted.org/packages/68/f8/6bb4c4b80e8a6fde1904c64a51c62a1d04fcdfa3ea521a66b2ddefa1d885/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:2cbd9a5eff05e51c447c34dfa4632145b26b09120cf04bd0c871e44c1a5e1c9a", size = 2335223, upload-time = "2026-08-28T09:59:37.931Z" }, - { url = "https://files.pythonhosted.org/packages/2a/80/f46b8c681195190b2c1f1c7c0a81abce60663e987613e09ef64d433dd96b/pydantic_core-2.46.5-cp314-cp314-win32.whl", hash = "sha256:2d5d76654becf5efd62c9e51c3756c67b49498b0c9a40884934c40807adbd074", size = 1934827, upload-time = "2026-08-28T09:59:39.836Z" }, - { url = "https://files.pythonhosted.org/packages/f7/3c/60674207246bc0a4009d2391b7c7251c7159f279c8d2ab8aae8ef46f3dee/pydantic_core-2.46.5-cp314-cp314-win_amd64.whl", hash = "sha256:fa10ef4112775900e7a0661068635eb67b2ab824fbde764de6e0e21982a93db0", size = 2042648, upload-time = "2026-08-28T09:59:41.792Z" }, - { url = "https://files.pythonhosted.org/packages/69/0c/117c562c7c1babdf44576b72a5e496906506c93690387ecfbca7c729ae2e/pydantic_core-2.46.5-cp314-cp314-win_arm64.whl", hash = "sha256:045ab3b6d308439e32b81cc173bba5b9018bc6ed896afd0c65b3b009b1699af5", size = 1989652, upload-time = "2026-08-28T09:59:43.702Z" }, - { url = "https://files.pythonhosted.org/packages/e8/66/9336ae58f9eb68c41d121894e52c4c89eccb07eb8f602a04ee9c3f37736a/pydantic_core-2.46.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8816f3d218beb4b787de5c9759c259b8fa61f9dec42dc7811f320a33771778b7", size = 2065829, upload-time = "2026-08-28T09:59:45.364Z" }, - { url = "https://files.pythonhosted.org/packages/c5/02/bc19b47a96c2d3109760711acf22369e56bd7e405ca52f7ade164d2ead57/pydantic_core-2.46.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bce57638e08ac148e5778cce7feb968307a727d66f8e2274a543d0cf0c9ad6a3", size = 1905716, upload-time = "2026-08-28T09:59:47.18Z" }, - { url = "https://files.pythonhosted.org/packages/52/a4/70b47c0509923dd98ccfed04fb3e32ea3849c82a0ff2205bb41009b43c00/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:976e1128455aa595ea04c79ccfedff1aaeab96ee013fcc916bed120c4f0ad94f", size = 1934216, upload-time = "2026-08-28T09:59:49.241Z" }, - { url = "https://files.pythonhosted.org/packages/52/ab/aa03b65f7bb198585edf806b906c3223ecf1795543e39e23aec4cce27ad2/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b891faeedeafba41b2983e5001a81b6a915b69544c7e7570d1989ce1c36ac7", size = 2010635, upload-time = "2026-08-28T09:59:51.692Z" }, - { url = "https://files.pythonhosted.org/packages/3c/8b/0da06343f30b84ec549aafd309c6456223d5dc8bd36af504c573faad561d/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f194189415698233dd1114a093a9b56e61e2c57e11b469be3b0506f46f0771c", size = 2209369, upload-time = "2026-08-28T09:59:53.582Z" }, - { url = "https://files.pythonhosted.org/packages/d6/5b/844c4defaa34a3df66eb9257087d121d70c201298b96abdf9f492fc2f1bf/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:82a36973cf8a2ef5406f4fe2edbf8ed0c99629535d959e0b100c76a32535a111", size = 2253238, upload-time = "2026-08-28T09:59:55.484Z" }, - { url = "https://files.pythonhosted.org/packages/f4/64/a4e536cb16d7f61a7fd3120b46c577fc7fa7325992f69c4f52bc786d77d8/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdbb78909f52b981d3b2d56b97328d71eb0b974c36bd77c920123a7ebb192829", size = 2065740, upload-time = "2026-08-28T09:59:58.038Z" }, - { url = "https://files.pythonhosted.org/packages/5f/75/aaa38c6bc2d085f6605b34eabdc6a8a4e0b2e61fc9c8e6e52b28e97b3125/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:52e24eacdb536cade636aa90fb851835222becff8484b7001fdc78cb0290f2aa", size = 2087425, upload-time = "2026-08-28T09:59:59.898Z" }, - { url = "https://files.pythonhosted.org/packages/55/ae/fcab4cfc39aba3689e1d20c8b5250ad280957022c09af2ed9cd585602a5e/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:37ae34309d7bd8c0d61ab839668058f2a7962ea1fc51d105d2db228fe0618034", size = 2139306, upload-time = "2026-08-28T10:00:03.057Z" }, - { url = "https://files.pythonhosted.org/packages/2d/f4/f1d03a4bc9d9acbc62f4d742b8a319af52f71885079868b2ff8e48a651ee/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:0cdbada856a1c69a7624a64d3d9aefe79300bd6ef827b43a4f265010b9b55184", size = 2144589, upload-time = "2026-08-28T10:00:05.645Z" }, - { url = "https://files.pythonhosted.org/packages/83/f3/7a53bb1356de514a4cd295f25b6ac39237895620c0462d2592b76c16e114/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:545f26c504b27c3758439a5e6d9349931f0a04f855668d5fe323c89e82300a38", size = 2288882, upload-time = "2026-08-28T10:00:07.931Z" }, - { url = "https://files.pythonhosted.org/packages/cd/94/5a81583660c175c59d49ffb09f4b3a44debeaf86a19fca664ae1cdd9ee32/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:ff218293c9c806138dca139765e3b067621be52bcd93cdc14c7711be7ddc90a9", size = 2335210, upload-time = "2026-08-28T10:00:10.177Z" }, - { url = "https://files.pythonhosted.org/packages/5a/9f/5d685c2693b972d1a59c998586e8823712b66603aeff47ee60a4bdaafd37/pydantic_core-2.46.5-cp314-cp314t-win32.whl", hash = "sha256:97cf3eb53a8cccacf9d46686a0926186c9bfb5574f2ed66d3639d5fe117cd3a9", size = 1921180, upload-time = "2026-08-28T10:00:12.35Z" }, - { url = "https://files.pythonhosted.org/packages/70/12/5c94ee16d65a37a15f9e869f5e6256df111154491173801a4c5e800ab548/pydantic_core-2.46.5-cp314-cp314t-win_amd64.whl", hash = "sha256:d2f9fc07a8042a8f95925b35c4f04f469707c981fc33245b6ca187cf5d2dd290", size = 2020515, upload-time = "2026-08-28T10:00:14.774Z" }, - { url = "https://files.pythonhosted.org/packages/63/19/67830dda664e6bdf9285ee2e40f355d0d7d6b92aa0c42e8d217bb8d33d36/pydantic_core-2.46.5-cp314-cp314t-win_arm64.whl", hash = "sha256:acf8a67ba51f4ca9ddbd0e6b3000a65ac51ab734661778b3e7ba64d99a710f2f", size = 1989276, upload-time = "2026-08-28T10:00:16.984Z" }, -] - -[[package]] -name = "pygments" -version = "2.21.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, -] - -[[package]] -name = "pyjwt" -version = "2.15.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/ea/5194e52748b0da83d71e082d75496eaec6e58f419f5e184786ded517e6a9/pyjwt-2.15.1.tar.gz", hash = "sha256:4f259e80cdfb6b3fc18a7de51fd1ef9ec79652f25019bae68975ca2468a34df8", size = 121252, upload-time = "2026-09-28T18:40:42.598Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/50/ca/44de4e75f8aadc457f0634be3b542815078ded46dca30efb960edeecad6e/pyjwt-2.15.1-py3-none-any.whl", hash = "sha256:42d59d631f7768a1028a64c7ff581a9bf7519804daf91fc5b6c56e30eec5e193", size = 33860, upload-time = "2026-09-28T18:40:41.429Z" }, -] - -[[package]] -name = "pytest" -version = "8.4.2" -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/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, -] - -[[package]] -name = "python-dotenv" -version = "1.2.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/74/26/2fbeedb218a787a5eea551c7532cac4e009f83d689dd2faa0d0353473f86/python_dotenv-1.2.4.tar.gz", hash = "sha256:f0d53e69935a851c0dcc78f3ab7aaccd8cabef0b92382b576b824212902873c0", size = 60824, upload-time = "2026-10-01T05:36:10Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/60/d1/38f3a3405989a89ac18390803e70c6ad7c7760da4f9b83cbeca0c44a0c72/python_dotenv-1.2.4-py3-none-any.whl", hash = "sha256:42269a8a5b3fd54ffa6f3d84b18abed50064717576b4ecf03dc4a55d8aa04fdc", size = 23266, upload-time = "2026-10-01T05:36:08.633Z" }, -] - -[[package]] -name = "pyyaml" -version = "6.0.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, - { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, - { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, - { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, - { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, - { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, - { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, - { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, - { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, - { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, - { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, - { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, - { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, - { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, - { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, - { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, - { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, - { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, - { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, - { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, - { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, - { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, - { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, - { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, - { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, - { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, - { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, - { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, -] - -[[package]] -name = "ruff" -version = "0.16.10" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c4/49/23802c45f093eb14bde54b141d2b2f058edfa63a06db7beed047308cc08f/ruff-0.16.10.tar.gz", hash = "sha256:eff4728c4eaae93f0955cd264d24b2ab348e74bf59986ccf282ba6dc16b3b017", size = 4958724, upload-time = "2026-10-01T18:03:21.697Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2f/21/ebce22e1d90cdb2cd691026b9c6e9bec6499f0b396089481755b5d49efff/ruff-0.16.10-py3-none-linux_armv6l.whl", hash = "sha256:488b0fe3f3574210e5cf80d9f59b9e3ab17a127a8155de3f307b392589cfb511", size = 10095557, upload-time = "2026-10-01T18:02:36.072Z" }, - { url = "https://files.pythonhosted.org/packages/cb/98/a54de85876a8b2612bfa0d84c7b9abfb39c6a3354aee7800b09c1649b9e3/ruff-0.16.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e748ff95c934c4e978783b8e687bc174e7bd84e8ad24e3243e1ecfcda5e0282d", size = 10340577, upload-time = "2026-10-01T18:02:39.258Z" }, - { url = "https://files.pythonhosted.org/packages/9f/16/1a5a4a2657effe4806110f2b907313802f1367fcdbb29e8122766e407fab/ruff-0.16.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3031a4a2e8e7b8a46f70be45f198c35a11ece509a94b80334d8d397a33c67550", size = 9774282, upload-time = "2026-10-01T18:02:41.79Z" }, - { url = "https://files.pythonhosted.org/packages/6e/fb/470085af734da396e68cd80fb0a3e7c109a459716ae59588c0f6fab8a17d/ruff-0.16.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:494401c86df4c4c25f69b9419605d944467ee98c42fb6ad405ef4fa40b8fb67d", size = 9920895, upload-time = "2026-10-01T18:02:44.458Z" }, - { url = "https://files.pythonhosted.org/packages/57/de/f10cffe4f88a37ea6615ec460f01bf76f0bc1477737a35d9ee61a630a78b/ruff-0.16.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d203abc0ff2b773ee33d00ab8df0bb67046fbc7c07b119332f08b9b345cf8221", size = 9902707, upload-time = "2026-10-01T18:02:47.041Z" }, - { url = "https://files.pythonhosted.org/packages/ff/44/3fdcedf83ae60ef837dd239170e480dce606a9142cfa2db911afdf855fd9/ruff-0.16.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bd83d1235a5258d318477bc5b576303974cbdef5df0c01a1bff14efcc23bd12a", size = 10619287, upload-time = "2026-10-01T18:02:49.485Z" }, - { url = "https://files.pythonhosted.org/packages/c1/62/02e76a5574002153618eafbb70e159468addc72a5d2c488e65cbb4639d2d/ruff-0.16.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bc2610fb269fa56dd8a68669ae470fa6272902668c0fc2ebc3aa112b2633d5b8", size = 11339942, upload-time = "2026-10-01T18:02:52.008Z" }, - { url = "https://files.pythonhosted.org/packages/6b/c4/cde27d47ad8d4126c587e608c67c46e7feac11763f606d261a1bc8a489e6/ruff-0.16.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3e70175e29cc94c26ea296c80e470180b744b7419026898e58f520c6ab32578e", size = 10934316, upload-time = "2026-10-01T18:02:54.811Z" }, - { url = "https://files.pythonhosted.org/packages/e4/03/17234145f302a645a123e8c3bb2411ecf4669fbc350de3b0d803230a1729/ruff-0.16.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f33f43a864a8483eebd160e713336c8bab02c934feaff0a33cf5ccb41546d09a", size = 10387968, upload-time = "2026-10-01T18:02:57.497Z" }, - { url = "https://files.pythonhosted.org/packages/71/29/2493af60240ee7644b38a4b821f5f9c3b5a4fa3178770fe1d0c685217395/ruff-0.16.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1dfc6f0088149fb6a362c1c446bcbb3fd2157b3852fe2fa68409276eab9ad9b3", size = 10537906, upload-time = "2026-10-01T18:03:00.006Z" }, - { url = "https://files.pythonhosted.org/packages/2e/9a/f56b28f3b143bb9e518c34fb89ab191b626bca8b70dbf8af0d2e2d473572/ruff-0.16.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6553498afc35f580f036030795810b9e6bcea31604b0fd9e8d352795473042e3", size = 10012938, upload-time = "2026-10-01T18:03:03.006Z" }, - { url = "https://files.pythonhosted.org/packages/c2/c4/fca37362848ea4d4d80712df13632e645e7c7cfb1bdedf140699ac7a090b/ruff-0.16.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a3b8471dea115d37f123882be852bed13403746d3a76c11de4a19ec5f9ff5a03", size = 9897945, upload-time = "2026-10-01T18:03:05.818Z" }, - { url = "https://files.pythonhosted.org/packages/7d/c7/e0bc57664d6e0c61fd22f620af260fe9662d7e1ddb320ea7f160e76ef165/ruff-0.16.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:92e59a70bcbd9d3a5483656da906ec28edfdacfce00afd99edb8b4e9d15644be", size = 10333134, upload-time = "2026-10-01T18:03:08.25Z" }, - { url = "https://files.pythonhosted.org/packages/21/aa/5c9f3b68737c0e4a33a1db5dfab44f7b786d96ca91a7233112ddab1e6dc2/ruff-0.16.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:7ae7375f803b5520dc9f546bed7e3a0acb70b91812e9bb4b19927de22f25b77d", size = 10741702, upload-time = "2026-10-01T18:03:10.775Z" }, - { url = "https://files.pythonhosted.org/packages/78/fa/0f9c2020dc316c53d983be011720b8157cc052b97e3f4dfa7db6d0f880a6/ruff-0.16.10-py3-none-win32.whl", hash = "sha256:2a12e01cb9156c10c466f63b46eaae5ecea28dfbd21b5836353ae498e7d1349a", size = 10139176, upload-time = "2026-10-01T18:03:13.267Z" }, - { url = "https://files.pythonhosted.org/packages/99/29/cfb0df9448d4d4ad48c2de029ada9ebd71baa6da983a6c77ee6c6cd0fe82/ruff-0.16.10-py3-none-win_amd64.whl", hash = "sha256:97f2015c92aa97105b0eab19eb5d224884399281cfc5da86a92db4ab5e7fb2ca", size = 10584734, upload-time = "2026-10-01T18:03:16.006Z" }, - { url = "https://files.pythonhosted.org/packages/fc/05/c16957eb287c3fc062e032619a25868d93d408a844b725bcf514f7a378ff/ruff-0.16.10-py3-none-win_arm64.whl", hash = "sha256:25a65fe998c4e6861ec079ada5826a2fc605e6cbccbe9dcd7fac1f54e791621b", size = 10366440, upload-time = "2026-10-01T18:03:19.04Z" }, -] - -[[package]] -name = "sqlalchemy" -version = "2.1.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/02/4b/81d972a46c9f1d978af1795e2abc988855c711453552cb45d75f6e4abbf5/sqlalchemy-2.1.3.tar.gz", hash = "sha256:ade5281df06038c6394f532590d592d3381ee5d11b9e0006a2570aef41145118", size = 10525778, upload-time = "2026-10-02T22:57:00.308Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1a/71/68c29931fb2c5178241f7d0c63dbdd800ad65685eac70d7f2117145b5e3c/sqlalchemy-2.1.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:260bdd8baa1dc631d76d14b26ad009ebbb665b4469666afef8dcaa1f964016cb", size = 2457479, upload-time = "2026-10-03T02:39:19.741Z" }, - { url = "https://files.pythonhosted.org/packages/94/21/5dbd91816b2cdb367bca3195f300ffeca916762e088d6924fc33a18f50ad/sqlalchemy-2.1.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5fc01a8047d6a4f4e997441994f07af79fdd470fc80669751e32f91153d6b491", size = 4582199, upload-time = "2026-10-02T23:37:44.508Z" }, - { url = "https://files.pythonhosted.org/packages/32/d1/79962ce06d6cd34516def113f5170184fa9401ebddc59fd6cb18f6e919f3/sqlalchemy-2.1.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b1b3ff10eba0f9290142375e667351c99d24ee7fcff6e0f85bd8be9d39b50a17", size = 4632923, upload-time = "2026-10-02T23:35:55.318Z" }, - { url = "https://files.pythonhosted.org/packages/3c/94/03e98031dc9a1738c580c3bf01beeafe695f247eb647f4e8dda3fab2454f/sqlalchemy-2.1.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:efb1ebf695e80d9a69f5bbff9d812d2491fd168b27d6b42ad4c8a4d3d6faaef1", size = 4300224, upload-time = "2026-10-02T23:46:45.495Z" }, - { url = "https://files.pythonhosted.org/packages/3a/1f/388c87607eb6c7abee4866355eeebb6221b3bcb413a64ce4a5cfc3edd22f/sqlalchemy-2.1.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a95754928d47b37ea5c4b5180101db2afabef1da0dd5cd0c573b547d5d036ff0", size = 4507170, upload-time = "2026-10-02T23:38:29.697Z" }, - { url = "https://files.pythonhosted.org/packages/57/73/5515a3f555d31748564fbeb0b9e0b6ab7a647bf2ea0cdb6d233f20f710e3/sqlalchemy-2.1.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:7606b413730ac6002930faa96f52195ed6969040b78071a3336370661f5e9e09", size = 4301439, upload-time = "2026-10-02T23:46:47.845Z" }, - { url = "https://files.pythonhosted.org/packages/a3/a0/0eae9b96f6f8a09f3e5d388f6fc5d6fe8d5ff2aced2a5c75bea7333d445e/sqlalchemy-2.1.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:76c3d5c7ada0925ff12e8c2395c547f88fcfccc13368ff8e8dd55ab5e28dcdab", size = 4594441, upload-time = "2026-10-03T02:46:16.928Z" }, - { url = "https://files.pythonhosted.org/packages/f5/b1/b6d681015414ce9048c07ef3ac49b693c1b8373de4716ee4bdeff4ec19a0/sqlalchemy-2.1.3-cp313-cp313-win32.whl", hash = "sha256:cb6fc5c856e8f1c0228bf74b636c0a03a6c12dd502ced7aa82ced21dcdc7f547", size = 2371547, upload-time = "2026-10-03T02:49:23.549Z" }, - { url = "https://files.pythonhosted.org/packages/56/ad/35aa4d737016640c6da7573a4bcd9d91b59a87359bc6df075627963a4781/sqlalchemy-2.1.3-cp313-cp313-win_amd64.whl", hash = "sha256:82d4c63d338737d593f1d29c4dae0a124577a9a76186d90b5c0f9ec9cbeeab35", size = 2421558, upload-time = "2026-10-03T02:49:25.448Z" }, - { url = "https://files.pythonhosted.org/packages/fc/42/0dcc7cd92a3f4773376a5488b6ce0d6d75d2d029ad7e7f9e0ecc5fb48fcd/sqlalchemy-2.1.3-cp313-cp313-win_arm64.whl", hash = "sha256:0ab6f42761cc5c48d2f3e8a70810d10630d8222a51a7111cf9b3a48f22c3f148", size = 2382507, upload-time = "2026-10-03T02:42:42.812Z" }, - { url = "https://files.pythonhosted.org/packages/11/55/d626c933b7d034619c57120fafb47c0d44b2553ad2b8f74e904a40c01258/sqlalchemy-2.1.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:660c6e16d254568874d4b77fd593aa5426f55cf40d0acc8b5364a5e61cc1044d", size = 2460626, upload-time = "2026-10-03T02:39:21.751Z" }, - { url = "https://files.pythonhosted.org/packages/35/90/9b276c8eed59694d5c2291588fae9c51f25a5c63efb00ef9a7c6e754a2cd/sqlalchemy-2.1.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7277ba31ffb636666ed207d2aaf74de3cd2ee78436da7552bd4eeadf1f67740a", size = 4576390, upload-time = "2026-10-02T23:38:31.943Z" }, - { url = "https://files.pythonhosted.org/packages/60/21/818e89fe73ba4600abf5090e0d2356d92de9ca3a8aa9222d64801e510bd2/sqlalchemy-2.1.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b728c406b1b202e8998e8f9adacb56773bfeea67da74252ad3025bcb8f5863f1", size = 4606177, upload-time = "2026-10-03T02:46:19.291Z" }, - { url = "https://files.pythonhosted.org/packages/88/75/ab96bb0e27153d123340d6f1b14fac3713c03b7e3ca3f74f4c0715345775/sqlalchemy-2.1.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:85623c57c3696dcbc4fc909bbf335a2f0c27ae36f762d83810067fb49abc040e", size = 4300438, upload-time = "2026-10-02T23:46:50.03Z" }, - { url = "https://files.pythonhosted.org/packages/94/49/c7e0223fe5553ff1c31853c07f158f75245f8d435435755a789249f5121a/sqlalchemy-2.1.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ac0de6dc326cc99ac31206359e9fc2c13345f667edeb6ef556585fedac4ff698", size = 4506089, upload-time = "2026-10-02T23:38:34.209Z" }, - { url = "https://files.pythonhosted.org/packages/10/83/d6aad5e1a5b3a3e9257f932971b7f2105be6669205d4d9946f5f87d73216/sqlalchemy-2.1.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:65e002b794efa1bacbbde9fa973e5340d6c3c29b53e592b863a281802829319d", size = 4301730, upload-time = "2026-10-02T23:46:52.115Z" }, - { url = "https://files.pythonhosted.org/packages/04/54/310b9fd03ba7de6359185a0914bcd0cf45a76c6314e49d1ec814a50ae13e/sqlalchemy-2.1.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:65e4813c78eaa679c9369b289e67fdba924db6be14067700c5ccbda37953af9a", size = 4565315, upload-time = "2026-10-03T02:46:21.64Z" }, - { url = "https://files.pythonhosted.org/packages/d4/8b/f6f7d46bc4e7bdc16fa78ec9a390f01852f2b080b7c75e83db4947b707c6/sqlalchemy-2.1.3-cp314-cp314-win32.whl", hash = "sha256:726987e7f8500614016be056ba5c9715b760ab465295741e2087b29753d72632", size = 2378402, upload-time = "2026-10-03T02:49:27.524Z" }, - { url = "https://files.pythonhosted.org/packages/9a/5f/dabd1ea48dd6c45383526c7f2e453fea15dcb53235edbadab49857fd7bfd/sqlalchemy-2.1.3-cp314-cp314-win_amd64.whl", hash = "sha256:0560458a775917bcd7a47eccf4f9e0b135fd9b72d7b819cb1bd60581de946f9a", size = 2428828, upload-time = "2026-10-03T02:49:29.481Z" }, - { url = "https://files.pythonhosted.org/packages/23/aa/ecba6a6141d24dbd4b002b451e0c410b7c6cb82a8a873f915f89314af830/sqlalchemy-2.1.3-cp314-cp314-win_arm64.whl", hash = "sha256:63c9289ed81991ba4d500611ce201531711430f5d23d7ae9a61a8842639b85ea", size = 2393599, upload-time = "2026-10-03T02:42:44.416Z" }, - { url = "https://files.pythonhosted.org/packages/33/41/f2c5eb9371cc1352a16001642702dfc2d73537995eea5cdeec7738f90a9d/sqlalchemy-2.1.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:08b2f345db8b0742e7814c49f9a210d5f5c8fb9d80d12c8a63bba762fa277477", size = 2500084, upload-time = "2026-10-02T23:35:35.774Z" }, - { url = "https://files.pythonhosted.org/packages/73/f1/fabd748dd8a1700579906649ac05414d65eca5bca44a3ac3c22856ed8d25/sqlalchemy-2.1.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:493b632f426579c4b0e97a5610f76c8c3d401cede442d462bf700b537035c91e", size = 4904105, upload-time = "2026-10-03T02:49:06.101Z" }, - { url = "https://files.pythonhosted.org/packages/ad/6b/a7a3d5c77c16386e16eb9dc8ba82e0cc6b03d6ee0ef6620921655df82413/sqlalchemy-2.1.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:55e06933580f4faa5a7b1d2c9ad04f365fe01c40465eb982e38fda8758c1e95c", size = 4814161, upload-time = "2026-10-02T23:34:39.253Z" }, - { url = "https://files.pythonhosted.org/packages/63/72/275dac552b869a21774c08a7084bad16bd99f28384b8c95ffcded1a18884/sqlalchemy-2.1.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fdcee9536cb3281b8810787ba651f4e5f22bd687b177de89fba2155b055b07b0", size = 4494471, upload-time = "2026-10-02T23:59:11.642Z" }, - { url = "https://files.pythonhosted.org/packages/e8/3e/41d8071c58a20e2ba9fabc422938a219461f0b32aa537ab69afe06daa0e5/sqlalchemy-2.1.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d2cb28779dbf54067655030d7ab3a6726c7957c56bde5ff6745ed86b5cebff41", size = 4762070, upload-time = "2026-10-03T02:49:08.052Z" }, - { url = "https://files.pythonhosted.org/packages/75/73/b747233b8ad935467eb1f2aa1c466af69831693cab3495c88842bf1bd3ac/sqlalchemy-2.1.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:01a90cac3f8d93fcbda0df119ef56c4d58aad5601f6b7ee5a38bfabbd5d119b8", size = 4494691, upload-time = "2026-10-02T23:59:13.733Z" }, - { url = "https://files.pythonhosted.org/packages/71/aa/ba11b725b9529879aff489e36cdaccc3fd69620fb30b89186792523bc262/sqlalchemy-2.1.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:edbc28049ab8da07387ea2b85ceee747b36b25444ffa4aaba9f6e1461d5b2634", size = 4752692, upload-time = "2026-10-03T02:54:01.04Z" }, - { url = "https://files.pythonhosted.org/packages/b6/b8/86ec625db10e285ed8459e39ab3e56b7630554bc733e64a190e248eeea25/sqlalchemy-2.1.3-cp314-cp314t-win32.whl", hash = "sha256:724f587ced076d89291032b35476d6bdb401cce1dd680c1e3418c01d9bd55ad4", size = 2436746, upload-time = "2026-10-03T02:56:47.297Z" }, - { url = "https://files.pythonhosted.org/packages/18/be/1309b0b3acfcd6e0060c815762012c66d23eece8c9eb8091da5c850a1289/sqlalchemy-2.1.3-cp314-cp314t-win_amd64.whl", hash = "sha256:935085a5b8849d324c22bc3cc09fd07b3669cc22ba5bdd3ec208829e535cdef9", size = 2504160, upload-time = "2026-10-03T02:56:49.705Z" }, - { url = "https://files.pythonhosted.org/packages/9b/b2/ac9067f4d6c43887570df54e6c340ae9c591a8e75a5fd3029a9d7deba163/sqlalchemy-2.1.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2ebfff82cece947fea1ed6337d4f0613b9799f6db621c9729790fdd41edfb5ea", size = 2422266, upload-time = "2026-10-02T23:35:30.802Z" }, - { url = "https://files.pythonhosted.org/packages/a0/e5/54a863bf8e12fa97cd06bb2e012aeca53b5535bc07aabe0192a4ed77ed44/sqlalchemy-2.1.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:47a052dcd935a62326216bc5f0ed10ad941c77c3acc9200385e0c03c4f31d5a9", size = 2460525, upload-time = "2026-10-03T02:38:02.872Z" }, - { url = "https://files.pythonhosted.org/packages/f9/63/f6f8a8d8e8f8be456bea70ad2b9af90a60a7f98d349b178b9eaaea86431b/sqlalchemy-2.1.3-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e5916b43a9a8f346026f663583f203d0c43febad504ff2fed504dec6e078f20", size = 4579659, upload-time = "2026-10-02T23:35:35.789Z" }, - { url = "https://files.pythonhosted.org/packages/fa/7f/9f1064ef0f8f15e3c1cb8fa7ad5e4fc7e1e311ad0e1e46bc2e77940585bb/sqlalchemy-2.1.3-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9af78a85af63114e2650f019ddf8fffb871e659e92bffb7d0bce5753f706caf", size = 4615129, upload-time = "2026-10-02T23:38:44.119Z" }, - { url = "https://files.pythonhosted.org/packages/c4/4a/f1a002235cc785d219738e096c7c65f68e9b7ed30c810997236567c50af9/sqlalchemy-2.1.3-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d218698c4f05728a941d6ad7c769b6491f03058e102cf811b7e4900f78fffa07", size = 4316528, upload-time = "2026-10-02T23:39:13.275Z" }, - { url = "https://files.pythonhosted.org/packages/a8/9c/6ee4e5ab55558408c16fe1713db415e45024f42cdb1c573ebceacc09d01c/sqlalchemy-2.1.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:80e6ccf111bc3f57530935f5541c6c1da50ac9713c418378840683532a45dd2d", size = 4509727, upload-time = "2026-10-03T02:41:25.314Z" }, - { url = "https://files.pythonhosted.org/packages/64/8c/a6bdf2874518c628fdedf24144194afa88cceb70339e5050459cdc2723cf/sqlalchemy-2.1.3-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:9a3ce1ff88dfb9f9f4305da07f4fab87720b609e264d134e47ae84a52df130bd", size = 4316490, upload-time = "2026-10-02T23:39:15.342Z" }, - { url = "https://files.pythonhosted.org/packages/10/3b/d859b638495a63ef7c155a8185369fa86ba2b860154d777e46eba922324f/sqlalchemy-2.1.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:77a365f7cb81bbd738c948b64e0b683f8f1b68efe51ca2815e8ad4baf6287ed9", size = 4576491, upload-time = "2026-10-02T23:38:45.97Z" }, - { url = "https://files.pythonhosted.org/packages/23/0b/c560177600613df0cfe27e27c346bac956b631cd5236178b81144e783e9e/sqlalchemy-2.1.3-cp315-cp315-win32.whl", hash = "sha256:83757320391b96cde715af0772675941d3e345da9ced3696c030f7f55ffa8eab", size = 2378116, upload-time = "2026-10-02T23:14:57.441Z" }, - { url = "https://files.pythonhosted.org/packages/d3/72/d39f60d0bc873653c0fec76aa088c969a2394ea7737f18d812fbe0e8c0c5/sqlalchemy-2.1.3-cp315-cp315-win_amd64.whl", hash = "sha256:f87533295b84c9e19ca2a72dc26a76e0192f0a2424f8fd575a82e75e7d58f9e1", size = 2428865, upload-time = "2026-10-02T23:14:58.959Z" }, - { url = "https://files.pythonhosted.org/packages/c5/03/953cf93136de44ee9d6cebee14666dcc2c4a4285fe159b2e459326631e49/sqlalchemy-2.1.3-cp315-cp315-win_arm64.whl", hash = "sha256:3e46a27b0c74af74aa6c08c983ca51db88d22dd86c316d72a634e989b7caef58", size = 2393289, upload-time = "2026-10-02T23:08:31.958Z" }, - { url = "https://files.pythonhosted.org/packages/a2/1e/5b5af9c0fe4e6f713d2ab684b88f10fd1cbe55bd34cca4d676fb930c9da3/sqlalchemy-2.1.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:06ef75e3e0c72d774b41f659bfc4f63d5e207d6c8418abe2b80843a4a25e2fa6", size = 2496326, upload-time = "2026-10-03T02:48:37.363Z" }, - { url = "https://files.pythonhosted.org/packages/2b/6c/6192d30fe24edc7c9e98c315341b7342ba7168d1729867c5d87e56285658/sqlalchemy-2.1.3-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70e05ae07dd9be29d4723bd0c880df622387eb51efb46a7b8efd49295248ffd7", size = 4880284, upload-time = "2026-10-03T02:49:10.221Z" }, - { url = "https://files.pythonhosted.org/packages/40/11/a758ae0116ab663f3520970cd870da5616cbb09c369b843239cce50696d7/sqlalchemy-2.1.3-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10fdff11782842696172489bd4861ae63c212e62ee7c4fc61fdda90961abd1f3", size = 4830599, upload-time = "2026-10-03T02:54:03.353Z" }, - { url = "https://files.pythonhosted.org/packages/8a/e8/faa500255fd90f421ef9e918d522aa4f02212b53859c35dadbafb3532bb2/sqlalchemy-2.1.3-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:edd3e6690f350c89c616e78e9b58833c3d8925607ac5faf3530f38f053f58e4d", size = 4481272, upload-time = "2026-10-02T23:59:15.592Z" }, - { url = "https://files.pythonhosted.org/packages/3c/4d/26fc9adb3fbc1d5fb8b92043a1639fd111761b8c52a2ce61df8b00ff405d/sqlalchemy-2.1.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:e3c165df26eca3d2612f60ea91af02648cb1bd492eadfa64dba15c9a3a0fb09c", size = 4737553, upload-time = "2026-10-03T02:49:12.413Z" }, - { url = "https://files.pythonhosted.org/packages/08/41/3639fc1d9fd7ea201ed4941215c14b87ce3c3b5f5f0190eb93321d673e48/sqlalchemy-2.1.3-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:70c073d28d043766eb1725010792f302f59b14636ffef478793045471efe92dd", size = 4479761, upload-time = "2026-10-02T23:59:17.572Z" }, - { url = "https://files.pythonhosted.org/packages/47/c8/541035b19b34dd581fb24b6fcf065db6fb4f210b9fbf93e1b2bf5f8cd5c0/sqlalchemy-2.1.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:bf5f63229512f14e7bffc9a0041386318f592e76bbeaf9b750b39056094d91f3", size = 4757307, upload-time = "2026-10-03T02:54:05.524Z" }, - { url = "https://files.pythonhosted.org/packages/40/a3/bb51886c02a60f1b7fb5461a25d6a3e57ce0557b916032dc3c7a72fae226/sqlalchemy-2.1.3-cp315-cp315t-win32.whl", hash = "sha256:697942762c40e31eb0fa39bf0e58e1ac1b5b1ff9022da62b74f7cdc53096a482", size = 2433533, upload-time = "2026-10-03T02:56:52.298Z" }, - { url = "https://files.pythonhosted.org/packages/e5/8e/1739b378a2b40bfc5541355c9d944b96dffcbe86aa1ed3aff31ad13f5d9b/sqlalchemy-2.1.3-cp315-cp315t-win_amd64.whl", hash = "sha256:a9c33c0d081a9d2c94229b9ae1834cb2ef81dd81253df4763e32111287b64c31", size = 2499211, upload-time = "2026-10-03T02:56:54.801Z" }, - { url = "https://files.pythonhosted.org/packages/5d/9a/ce4ea58551258203381017bc4724369cc8948ac5a46212671266b13424c4/sqlalchemy-2.1.3-cp315-cp315t-win_arm64.whl", hash = "sha256:c36ff11196dacf50312a2cc51a798bb41611331c174871f065be651a838a5361", size = 2418086, upload-time = "2026-10-03T02:44:11.611Z" }, - { url = "https://files.pythonhosted.org/packages/e5/12/23d8fb7ed90e5b674006ace415ede1c60cdfc38ace50968b59ef42c22754/sqlalchemy-2.1.3-py3-none-any.whl", hash = "sha256:6f0cf4debd86cb5623a50310784bd005c215761ca7f618d0d4f46193d033675a", size = 2052603, upload-time = "2026-10-03T02:34:19.115Z" }, -] - -[[package]] -name = "starlette" -version = "1.7.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/7b/2b/3850dc6bf7ef71b088962eba31dafc6cffd2f96e577ebb0bb316df96da3e/starlette-1.7.0.tar.gz", hash = "sha256:c79f74ea63cff761804fbbfb182f1e0b440c2d07b164d24700c5a1bab5d6ff5d", size = 2736246, upload-time = "2026-09-23T07:30:26.35Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4e/d6/1ec1b290f9e0fb067899b61e1d37a30c923068bad260b216dbe37a7d2967/starlette-1.7.0-py3-none-any.whl", hash = "sha256:67f8e99895493dd2911a03f11314af6ceebeae4e704bb9f43dfc6a9db151c93e", size = 78980, upload-time = "2026-09-23T07:30:24.567Z" }, -] - -[[package]] -name = "typing-extensions" -version = "4.16.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, -] - -[[package]] -name = "typing-inspection" -version = "0.4.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, -] - -[[package]] -name = "tzdata" -version = "2026.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e4/31/3d74fa778a63b98b7374323befcc0be5ab3bd94afd4096a0124e7379152c/tzdata-2026.4.tar.gz", hash = "sha256:f1b8bd365d8d210c55353f4d7f8d6d8561c0ba50d704b700d195a9424bba0d79", size = 199350, upload-time = "2026-09-12T12:56:03.251Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f9/bc/8737e8d54cf51106118039b83f485a4783112fab49ea9d044b234978a46e/tzdata-2026.4-py2.py3-none-any.whl", hash = "sha256:c2169a8b0a7a5e9674da5a135ccdfb2b3e671b333ed9fed17b41f73c34476e81", size = 347494, upload-time = "2026-09-12T12:56:01.67Z" }, -] - -[[package]] -name = "uvicorn" -version = "0.34.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "h11" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/de/ad/713be230bcda622eaa35c28f0d328c3675c371238470abdea52417f17a8e/uvicorn-0.34.3.tar.gz", hash = "sha256:35919a9a979d7a59334b6b10e05d77c1d0d574c50e0fc98b8b1a0f165708b55a", size = 76631, upload-time = "2025-06-01T07:48:17.531Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6d/0d/8adfeaa62945f90d19ddc461c55f4a50c258af7662d34b6a3d5d1f8646f6/uvicorn-0.34.3-py3-none-any.whl", hash = "sha256:16246631db62bdfbf069b0645177d6e8a77ba950cfedbfd093acef9444e4d885", size = 62431, upload-time = "2025-06-01T07:48:15.664Z" }, -] - -[package.optional-dependencies] -standard = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "httptools" }, - { name = "python-dotenv" }, - { name = "pyyaml" }, - { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, - { name = "watchfiles" }, - { name = "websockets" }, -] - -[[package]] -name = "uvloop" -version = "0.23.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fa/42/02c739ce85fb2ee8d99212c61417da8140c6b87e9d97c430bea520d76044/uvloop-0.23.0.tar.gz", hash = "sha256:28d160f51ab4da3b187063652e643dea6831072add4adc1e6d62afbe73b6be27", size = 2559185, upload-time = "2026-10-01T03:17:04.4Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5f/83/eb980d64e6dd5da46d4dc35755fa6afd6b5b47141437cf89615f1117c5a6/uvloop-0.23.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2dcff2d69be43e6559e5dad2c5a7a2dbfb60e05a77311b6c4b7a4a8123d86c65", size = 1412726, upload-time = "2026-10-01T03:15:52.49Z" }, - { url = "https://files.pythonhosted.org/packages/04/c1/02a725e7698134c647904bdee6589e2be14a0e7fc9942c74f86e2b90d48b/uvloop-0.23.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:19c64108b507cd0bc140e400e3396bacebd9d504956aa7726272bf6de7d9aabb", size = 779071, upload-time = "2026-10-01T03:15:54.02Z" }, - { url = "https://files.pythonhosted.org/packages/0b/1d/cde53c79e8c01884ad1cdca8e407e086d523362cfe4139e2c2a8dde27304/uvloop-0.23.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1748321e3c59a14a75404b1ae8d5a8d81c4e201803ea0e14c1b6fd84421024b5", size = 4395323, upload-time = "2026-10-01T03:15:55.549Z" }, - { url = "https://files.pythonhosted.org/packages/98/54/b12915bebbf99d7ae0796211e7f5977b95f069830dca45dc1a346d84125d/uvloop-0.23.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2cba180d6451822763eda8364f342435a873bcfb3849cbd82fdeca248ca65eb", size = 4480449, upload-time = "2026-10-01T03:15:57.362Z" }, - { url = "https://files.pythonhosted.org/packages/f7/8e/da6de68c31549a052a105fc76f5a9a204f6df22cb0909440aa4dbb06f9a2/uvloop-0.23.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:dc61e4f9e37b507069dc7e659ae28bca7adcb04c993c3508214315d12c63f848", size = 4219177, upload-time = "2026-10-01T03:15:59.351Z" }, - { url = "https://files.pythonhosted.org/packages/a1/c3/1b53c6a89dc9c9d5cb75eb9a0b891ad69b32e1421ad3aa01617a9cbdcc78/uvloop-0.23.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7337b06a9f9ed9ea3049f04b76f65819db9b19bb832ee598e97b388eadf25e5f", size = 4346132, upload-time = "2026-10-01T03:16:01.064Z" }, - { url = "https://files.pythonhosted.org/packages/4e/a4/00e85345871c59c834a23c136c1771205856028ecc8ba940b3951178e59b/uvloop-0.23.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:b90397a50ad6332ed3e459c648ac20d182cce24a557354363ad85fc9ea4a17cd", size = 1421363, upload-time = "2026-10-01T03:16:02.599Z" }, - { url = "https://files.pythonhosted.org/packages/d0/a9/e5f0f3cfde30af3ec32eba8ec07bccdba2b5116afbd1ecc53edfeb0a0790/uvloop-0.23.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:be53e1d5f83de43dc175c87612ecc128d444b38e5c56cb3f807f5a73d6887476", size = 785177, upload-time = "2026-10-01T03:16:04.018Z" }, - { url = "https://files.pythonhosted.org/packages/9e/79/9ddf78f8cd75a15c14a09a57f59c587b8cd9d82802c5c8368b9c3ebefa0b/uvloop-0.23.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b3cbc4f96ddfa1fb88a78a69dd851369825b7816d9702eee8c4461505ba172e", size = 4381060, upload-time = "2026-10-01T03:16:05.642Z" }, - { url = "https://files.pythonhosted.org/packages/1e/20/57d63c44d32326878fcad5c63854afc9deb394ed95673c1b1a429178c79d/uvloop-0.23.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:31e0cf90bc8fd88784f6802cdba968a51fb1aec1cc3feec74d862b2d371d1330", size = 4418891, upload-time = "2026-10-01T03:16:07.326Z" }, - { url = "https://files.pythonhosted.org/packages/12/c5/0795abecda2cc3dfe41033f880a32a9ff103be4e6b177ac736833c153a0e/uvloop-0.23.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa8ed556fcc87a4091cf61587ef172fa104323dc89ecc085a618ba7ff8629a8f", size = 4214811, upload-time = "2026-10-01T03:16:09.13Z" }, - { url = "https://files.pythonhosted.org/packages/20/18/9010dacd5221eec1bd79a4a83ac68f3db6a42d7bb657f7b640c4838ca6b6/uvloop-0.23.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f3fbfe82829d8e381426a289b87e59e585278728361db9ce975b88b51f64f410", size = 4294876, upload-time = "2026-10-01T03:16:10.875Z" }, - { url = "https://files.pythonhosted.org/packages/b1/08/f6384a03c771d00067cba4f542a69b2fc1a982e9fd78b357c2f788678d72/uvloop-0.23.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:7e35c9bc977760981693e1a7a51493b58ee5a501f9ebb1e547565ee40b6c6208", size = 1494811, upload-time = "2026-10-01T03:16:12.399Z" }, - { url = "https://files.pythonhosted.org/packages/ac/01/756a4fb24a449f313cf4a153eb0c6210b49cfe5539255ec9fb1e17d2c4ef/uvloop-0.23.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5bb9be71d9ee39b4359b832f9569518ec9bc08704194034e79e4958e6bc4d46d", size = 819396, upload-time = "2026-10-01T03:16:14.094Z" }, - { url = "https://files.pythonhosted.org/packages/3e/45/e314b0c600b14f53dad3a3c2d7a922a249a88225fd727652b53e1854b9dd/uvloop-0.23.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e84575f11873c109cf3962ad0bdf679094466184125f4cadcc41a73febff41f", size = 4734966, upload-time = "2026-10-01T03:16:15.815Z" }, - { url = "https://files.pythonhosted.org/packages/66/0d/8686a7f0b1b2d55ebd770ba21f8e0e4ffa0cde5ab738f43ffb8264499052/uvloop-0.23.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbbdb8fcd5e7062e546eec1ac78c28bb21ae7df54c18f8e4b06e15a18d661a49", size = 4584963, upload-time = "2026-10-01T03:16:18.198Z" }, - { url = "https://files.pythonhosted.org/packages/78/b2/034a2d47e435ac02357c42956246887167bdc0357bdd6ad31c5f6d94497b/uvloop-0.23.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:76345f51367fb1f23e08605c6efb18374f669be5b223658fbab6b17627950507", size = 4421388, upload-time = "2026-10-01T03:16:19.953Z" }, - { url = "https://files.pythonhosted.org/packages/f0/77/131f4b583e6b4b715c404a66b51c812d701db20f25c9018b188a2b00062c/uvloop-0.23.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c7ef4701a96553514b2688e342ef1bf2beae6cfd172d89a76c768292aabf405", size = 4402414, upload-time = "2026-10-01T03:16:21.716Z" }, - { url = "https://files.pythonhosted.org/packages/58/3d/ee11f4718ea1280595c67ed25c83d4c92115dc100bbdfd192d3ed9339168/uvloop-0.23.0-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:f1341c6abcee1c31277cfe28d34e46196f2143ec3d755e6efe7452126e1f626d", size = 1418095, upload-time = "2026-10-01T03:16:23.241Z" }, - { url = "https://files.pythonhosted.org/packages/f8/0c/7ca516a0671418517d79a09d3ff2ccbb44af94c75711afa6e4cf58aa6f65/uvloop-0.23.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:e095f9e105af76593b4c183bb0bcbdae64bd913a59ec595732dc108b48730ab5", size = 784837, upload-time = "2026-10-01T03:16:24.666Z" }, - { url = "https://files.pythonhosted.org/packages/35/95/75d4e28e596d505b7ae11de517646b4ca3d369fb8537ba755410380da11a/uvloop-0.23.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f673d835bdb1a60229cc3609a113fd2c9ce3f4a3c75ad4eaed111180c00199d2", size = 4380276, upload-time = "2026-10-01T03:16:26.389Z" }, - { url = "https://files.pythonhosted.org/packages/10/99/68daf827ad62efaf4667d1f3fda127046d42161178396bdd93aab3684082/uvloop-0.23.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c3f23f403a273900d57de6ee5ca0614c650f7f58563065dad1a4744498960e53", size = 4451496, upload-time = "2026-10-01T03:16:28.364Z" }, - { url = "https://files.pythonhosted.org/packages/71/69/f67e696ee688f426a96f99099bae26fec14a1d0fa75dccdd6518ee267c0c/uvloop-0.23.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:cbe8d03d4efcccdb7fcedecbaa1e1fa02913eaf3a74cb933634a6bc6d2ea9e2a", size = 4212541, upload-time = "2026-10-01T03:16:30.014Z" }, - { url = "https://files.pythonhosted.org/packages/f1/6a/c8c436a9d7453297b4be70bdf6a9f9fc9400da45e0059ddf7b28ab63f4c7/uvloop-0.23.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:4f1798f56c6f4ba5ac11fa2869e5717926e4470d97a1dd42b4f59219d43b5027", size = 4319377, upload-time = "2026-10-01T03:16:31.705Z" }, - { url = "https://files.pythonhosted.org/packages/3b/2c/8fc15a03489299aab8a6212dfe0f137dc39836f915c87f7fd9d9ddd814de/uvloop-0.23.0-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:098a85e1393ef5202767b7e5fb41a32cd8bd81e6ee4af364c179801c4aa3f6d4", size = 1493428, upload-time = "2026-10-01T03:16:33.859Z" }, - { url = "https://files.pythonhosted.org/packages/b7/7c/05e4a210790229607f71460fcb2ed4a2c7bc72668d8a928ce577c22e38f8/uvloop-0.23.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:5a2bbad3a63007f7e9524d4903ba04fee252557c2acd86f9a3d4f91786695254", size = 818115, upload-time = "2026-10-01T03:16:35.45Z" }, - { url = "https://files.pythonhosted.org/packages/65/14/a40b11c6c024213803b13955664a15754c72f64c873a33d986b26ec9ff5b/uvloop-0.23.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a08875543bbd4519faf30497506c9cda8a48470467ffdf967c7313c7a5981a8", size = 4734149, upload-time = "2026-10-01T03:16:37.025Z" }, - { url = "https://files.pythonhosted.org/packages/9f/83/f421a077712c1e87603bfec62744c3cd3a2f4b47378025db3d740df9af0d/uvloop-0.23.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:12634f15e6625f78b3f2922f91404c4d7173487eba11746764153f556e9852dc", size = 4661763, upload-time = "2026-10-01T03:16:38.719Z" }, - { url = "https://files.pythonhosted.org/packages/f5/62/25dcaa6b7e7b48f82ce633854ce96597ab768f9650931f4f86c572de392c/uvloop-0.23.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:378188efbb1524f2219d05246a3e1e5907217848d2882144dff59585f1b81d55", size = 4421324, upload-time = "2026-10-01T03:16:40.488Z" }, - { url = "https://files.pythonhosted.org/packages/05/46/04628239b43dcef703af314202a3307d6060918e2d76aa86c5b1188f5551/uvloop-0.23.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:4b8e207c67d207a8608fec57e116511030af3495dc0109b8c333cf9cb412b16f", size = 4462501, upload-time = "2026-10-01T03:16:42.359Z" }, -] - -[[package]] -name = "watchfiles" -version = "1.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b3/68/e6aa0b77d217b31f8f486ec0cdfe5e00e6e38dc0be657e7d85819b9faf0a/watchfiles-1.3.0.tar.gz", hash = "sha256:99aee4a07847c06820765fd7b1b49ceac4f3f711ccb7d104655a33231de1c207", size = 108119, upload-time = "2026-09-21T09:08:55.664Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/68/fa/c0b840d5d8bafe640925408ce11095948c8945bb61be1e67d0ab629b872a/watchfiles-1.3.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:000b9688fc8133037a8b075c8ebf98f32844ff8964dda61e85c1db547dafc441", size = 399703, upload-time = "2026-09-21T09:08:13.518Z" }, - { url = "https://files.pythonhosted.org/packages/c7/8a/894799b485fe9473ad10422a0d9668e53fb78e3a2b5cc6061159572844ad/watchfiles-1.3.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:bbc1198edfdc90fda0600f825aa94150f428dfcbf8138746f55998e0e660d64c", size = 397647, upload-time = "2026-09-21T09:08:15.16Z" }, - { url = "https://files.pythonhosted.org/packages/5a/61/f277cd5ead05f5d37b98b41fbdd0c943ee0c04d49738f62e902cb291cf43/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4af9c464d410e8c44b58ddf1cbec7c3a02c1cb2c6c80df9c74659ba2fbda0ab5", size = 454994, upload-time = "2026-09-21T09:08:16.587Z" }, - { url = "https://files.pythonhosted.org/packages/46/66/ea4c01382975e53b1ac1b0a0ea1b353416b8127fb6e04f5c1c313119aab6/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb94e40b9a0db19636b5e3fed0a9803452ecc7381df13b385272abb779ee83ca", size = 460105, upload-time = "2026-09-21T09:08:17.961Z" }, - { url = "https://files.pythonhosted.org/packages/31/c3/1a501b597817096c9831c255f6270c124ce0e2faeb74124be24dcc93b86d/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7398930a2b76b9dc67a4e6b8bfc7baf30946892a86f417a3920c07bdd6febc30", size = 493761, upload-time = "2026-09-21T09:08:19.327Z" }, - { url = "https://files.pythonhosted.org/packages/5a/16/77352d05e152c7786a016e9f1decdb4507e8aa5bcf3343c72142a0413d14/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:025b108f2d5cc2799cb941f79380df3e8b22bfb589c218e801767fe78d598c95", size = 577500, upload-time = "2026-09-21T09:08:20.723Z" }, - { url = "https://files.pythonhosted.org/packages/95/fe/81001c55b24466fe9911135e50269fd3a29f43e446627a92adfab54553b6/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:be9ef3cd403d756a304a0a08a112163c63da7ec72c8c9d083c98818291ff8f29", size = 469209, upload-time = "2026-09-21T09:08:22.346Z" }, - { url = "https://files.pythonhosted.org/packages/8a/cf/e3ad894ed5909a7c44b0707e89bc25d19a3d78b3a7aec846119443b6ae56/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b5768b49e426fd5b550b012c866db347cdf15c398ef98dc557b6e6b72fa74cd1", size = 458353, upload-time = "2026-09-21T09:08:23.7Z" }, - { url = "https://files.pythonhosted.org/packages/7f/f5/2f106829e6e2bc91ad74614730f6df484202b9a4d20d927dbf2f08d37d44/watchfiles-1.3.0-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:d36c72fcdc08f143d87316a4c4c3b064e340da2c7383d39803950740ed418935", size = 468707, upload-time = "2026-09-21T09:08:25.167Z" }, - { url = "https://files.pythonhosted.org/packages/15/37/2741ab776b978669c1f3bba1e573f1a184f727ab8b4d3a6d9c61090800ed/watchfiles-1.3.0-cp310-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:f0c9865240e065a2247f4b5248528ae3d01e0e8ae071462c250c884dc16e3ee3", size = 632510, upload-time = "2026-09-21T09:08:26.532Z" }, - { url = "https://files.pythonhosted.org/packages/48/64/db4a4684275fe9b696ee5118c194705c1423b3d42181c3f8ee70194d5c73/watchfiles-1.3.0-cp310-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:380f513d26cc2e598266b88d66456e07d67ef4be8f0f435a1154d9c44b43d509", size = 661723, upload-time = "2026-09-21T09:08:28.004Z" }, - { url = "https://files.pythonhosted.org/packages/52/80/95ddb24f6a2f595c031c24432ab7b8aa7d6d1ada0c6d63a552166e06e8d1/watchfiles-1.3.0-cp310-abi3-win32.whl", hash = "sha256:509d9f74d2bec5c1f4cd868ddcfe0c9601f7ea53066dfcb19d8ede742c4de661", size = 277761, upload-time = "2026-09-21T09:08:29.335Z" }, - { url = "https://files.pythonhosted.org/packages/48/a1/1c4a1b3c8030cd03206232612be8a6e8799093490c220b0fcb935d02b75a/watchfiles-1.3.0-cp310-abi3-win_amd64.whl", hash = "sha256:1acabde19b67e673274e89a04d613b7fb1f122e2c4b8ec9a581e59c149264b61", size = 290643, upload-time = "2026-09-21T09:08:30.45Z" }, - { url = "https://files.pythonhosted.org/packages/82/e0/d2db577c632d26d08078c4390dd870b8555c4e558df25d22270f0166c71f/watchfiles-1.3.0-cp310-abi3-win_arm64.whl", hash = "sha256:d978cc1dd7ba44f5590d7de74f7a9c8be7262d9e469c2c0efb8bbfc2574321dc", size = 285510, upload-time = "2026-09-21T09:08:31.561Z" }, - { url = "https://files.pythonhosted.org/packages/4f/55/a122743607939429ab6a7272672f49303aa02bbd34c3f06676765a3861de/watchfiles-1.3.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:e0204f90677b7fb8d3a824279a71a01166bea9f6e53923b8056592d87b33b0b6", size = 395978, upload-time = "2026-09-21T09:08:32.747Z" }, - { url = "https://files.pythonhosted.org/packages/3e/9d/0ee1b0ea42acfe72afa2484f0222e2386dd09992e9034a81e63cc3cdab0a/watchfiles-1.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7c1125e99f84934a92996ae343501285617373acd66f16b59fd5654b27ad8d80", size = 387997, upload-time = "2026-09-21T09:08:33.953Z" }, - { url = "https://files.pythonhosted.org/packages/b7/f2/4319c6b683e8bd28465dfc53c750617a2a07f072eb2e3ab0182ccf812229/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a85e243c87109d9f48a2e0d10d6abdee8768cc114848b32281ec83d7f81d86ef", size = 448067, upload-time = "2026-09-21T09:08:35.104Z" }, - { url = "https://files.pythonhosted.org/packages/b7/71/9f37ac192a51a24de3a2d7fe9aac1e99b2b2668300674ff7a9f85d95beb1/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:dd538c71766c59c732e2c5cedc39323c99c11897b99f93eac773438e54af206a", size = 452881, upload-time = "2026-09-21T09:08:36.238Z" }, - { url = "https://files.pythonhosted.org/packages/34/07/1224667c32a4a96876ddd642da69733cbfe6a15897db0865aec5a882cebc/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:da213822ec9082b62cadf3008e07a8c690ec7a3c89f6dc5e55a8cf07b56a1b26", size = 484802, upload-time = "2026-09-21T09:08:37.717Z" }, - { url = "https://files.pythonhosted.org/packages/bd/4d/7f29cd87e1f826df86fc0418d0b8f370ca0311c29c618406dc492aad9951/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d4593392e87669836670f87d62fc754059eaeb0e6157af44060a46558cebe5a3", size = 567522, upload-time = "2026-09-21T09:08:38.94Z" }, - { url = "https://files.pythonhosted.org/packages/e3/ac/3aa1f84abd4dbbfbae99a817dd931f77b439261c911189cad4c30fdfd470/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:26b81bc515a0f03f66a69a6fea86eea54c8eca4173c46dd7222569f79dd2f977", size = 461997, upload-time = "2026-09-21T09:08:40.142Z" }, - { url = "https://files.pythonhosted.org/packages/82/1c/27c66764d5b0dbb70a36770a32eca217eba739c28b24e8583a035087a3d7/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca39661934749df580d89f3dde266f2af7c6968f4c21c72ce2dc387d299e6aa7", size = 451856, upload-time = "2026-09-21T09:08:41.305Z" }, - { url = "https://files.pythonhosted.org/packages/1e/b3/a45827084d953c1d0dd3d75abf85dcac26a0f5d115ad2fe0b7e9e931573b/watchfiles-1.3.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:04c5500faa725d69a99b0f63750e80795e455bb951849e9c98a811b4324997c4", size = 459889, upload-time = "2026-09-21T09:08:42.58Z" }, - { url = "https://files.pythonhosted.org/packages/7a/3d/07740d23ce2bb23419bb24055c435a9abe5fe03efdaea1ba0720b831aab6/watchfiles-1.3.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:ec3cd4bb181a7b6329134204c2277c7144ab5f62cce9c5335d6f901ee7ce8d6a", size = 624482, upload-time = "2026-09-21T09:08:44.064Z" }, - { url = "https://files.pythonhosted.org/packages/91/46/9540f8123eaec86df989f26d7704cad0448d9fb7271763a4f8f9fe23617d/watchfiles-1.3.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:e5871d4a7f788a9f64fb06f5793389d29d171e5c32d37c3ebfd520b017cc7694", size = 653724, upload-time = "2026-09-21T09:08:45.257Z" }, - { url = "https://files.pythonhosted.org/packages/25/9d/b9deb52f1e4528ffe44338c5ebab95c32ed79203082da661de50288e130a/watchfiles-1.3.0-cp314-cp314t-win32.whl", hash = "sha256:81c989d7267bfb7b676cd32c83bec97914e396870983cbf493e29415037bff5e", size = 270863, upload-time = "2026-09-21T09:08:46.518Z" }, - { url = "https://files.pythonhosted.org/packages/59/59/8b1881afccbafda06248be4185e28034dd73c80c85e4da24f2cb0c1321eb/watchfiles-1.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d754e049006e81a9be49dc78ba7802081cee426e968cbc63ee19e164fa2d0e39", size = 286879, upload-time = "2026-09-21T09:08:47.874Z" }, - { url = "https://files.pythonhosted.org/packages/46/dd/41c12ae9745d5d72f709333287679d71cbba68ad7678fed872207bba94f4/watchfiles-1.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:a5b631db08fd3032db57ec6a92d1777cd60f88803287e4e941a4858c53aab1e0", size = 276924, upload-time = "2026-09-21T09:08:48.976Z" }, -] - -[[package]] -name = "websockets" -version = "17.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/18/72/fba934cb3dff7a85d811820efffcd141ddd52b5a2a01637f64551373ff4d/websockets-17.1.tar.gz", hash = "sha256:acfea4c20bf54384883ea33b1240fc1db4f52e190823a4e2b334bc3e8bfca96a", size = 187520, upload-time = "2026-08-26T17:25:33.063Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1d/31/5f6450a7879f4f063ef08897cc385ea3ce3f1fe17f08b11e3fd959abdf27/websockets-17.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2a0162a6372110a5601cb5c9fd826635cedf69f3e110c545dd19774e040b970e", size = 217006, upload-time = "2026-08-26T14:56:10.509Z" }, - { url = "https://files.pythonhosted.org/packages/d0/2a/c1b006fc861695d2aa4e35327b842015ce1d98cf8f99241829b3d6460bfc/websockets-17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:829dba1bc049779de9b332088c1a6a9858e96bd67e50b6b644a95e02b67836bc", size = 214690, upload-time = "2026-08-26T14:56:11.681Z" }, - { url = "https://files.pythonhosted.org/packages/46/69/66e5b7d01445e0eeb1d4ab419c30315f2c90cf7a8a8cd4ecc47f894dba54/websockets-17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fd8f47dbf2e8adb15c847215f83436de3fdb120b51fdae0fbbdf69fd97a3ad80", size = 214947, upload-time = "2026-08-26T14:56:12.923Z" }, - { url = "https://files.pythonhosted.org/packages/07/ce/033cafe2d2538562efa876b9149a2c7a0f7787870a4b1bb6e28adc9ceb6b/websockets-17.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9f4c0377a83e163a303514fdfab501dbe379bdc13e5b9312a91d112658b29dce", size = 224329, upload-time = "2026-08-26T14:56:14.212Z" }, - { url = "https://files.pythonhosted.org/packages/34/c7/e1c2e8a67f6cc0aa43abe0046fb3b7a020980649e6a843751dc7ce9eb170/websockets-17.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c3241d684a76eaaef8b2dc789afde4343cd3aad55ea81e4e8ab3605b529bae51", size = 224611, upload-time = "2026-08-26T14:56:15.702Z" }, - { url = "https://files.pythonhosted.org/packages/be/de/07c6d48eb3d2069709410c851e7de10ab83d752c4bd09862899627c2729b/websockets-17.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e5f5c7a893507d0e83a80b88aefd6522f7e882cd53f9722c6f23f5a020c9557c", size = 225848, upload-time = "2026-08-26T14:56:16.962Z" }, - { url = "https://files.pythonhosted.org/packages/f3/dd/3c68572d20509648cc2fb6f50ccf3deeb4b87270f2c8966e99476e278ea3/websockets-17.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:00bf34b64501e3477e81fc281532ff3cbf4da26633c10b63979d5085d46602d3", size = 227290, upload-time = "2026-08-26T14:56:18.204Z" }, - { url = "https://files.pythonhosted.org/packages/0a/4a/8f6651c8a22093539c9215af0c5bbf217b87b382c99d2112039b92d593c2/websockets-17.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ce0305b702b20d1e1d60a9aaace6bc89970e1753565543f310d549eab22c2435", size = 226476, upload-time = "2026-08-26T14:56:19.459Z" }, - { url = "https://files.pythonhosted.org/packages/f5/be/f6fc33cea86b1127fd1297b18c107e81580ab55a73a39f9a934441ef321f/websockets-17.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29176d8b429cfa0fa443c473878d37a5c06cfd0cb36b71ba4314accc71e05906", size = 225233, upload-time = "2026-08-26T14:56:20.939Z" }, - { url = "https://files.pythonhosted.org/packages/cb/83/65edaf05f7c9b1dea82f4d252fdc37706a84571646f06119a27b0a16fe19/websockets-17.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3709a1ab30b4b922027d22f68d2b61a0656a91680ac894a537624e6be7dd7f7c", size = 222488, upload-time = "2026-08-26T14:56:22.208Z" }, - { url = "https://files.pythonhosted.org/packages/07/42/d1169c2f7f1f0032b0d4b0c00f0711a070cd7c735de37bfeb876bc0f9606/websockets-17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:43bd0c1ceb924d67f5c1a5254d8361dd9d94246e6331a726064dfa2917880780", size = 225295, upload-time = "2026-08-26T14:56:23.445Z" }, - { url = "https://files.pythonhosted.org/packages/a6/f4/64e2a386c3899b917c2933225c9b47887874229d159797f3bf1a11c20d51/websockets-17.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:1fce0f43e0d41422e0b2cad6561e1970df22f212f4c7e884967df7cf591b031c", size = 223891, upload-time = "2026-08-26T14:56:24.647Z" }, - { url = "https://files.pythonhosted.org/packages/26/b3/dfb5c482f7e310a3432fdbb045ddfe6d34114680e89a233d4ff900a32961/websockets-17.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4031152769179ab8dcdeafc7b0e58052a49117560a28671700b47b2c7b717aad", size = 224661, upload-time = "2026-08-26T14:56:26.027Z" }, - { url = "https://files.pythonhosted.org/packages/a4/cf/94865130a336029f46412adc127c4fbe380f46172b90ce251369e35c4302/websockets-17.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a06f3b5085176763182449559e20391d7ce616a8972a9f7a33deda87ea6d4f3c", size = 225766, upload-time = "2026-08-26T14:56:27.455Z" }, - { url = "https://files.pythonhosted.org/packages/96/34/eb8c658f86dfe562ed49a887a27424bfe9e618c26ea6f865b093d075d3a6/websockets-17.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:77b37cceca17291897c3c73bd30a7c7c7909593554b5da574ec852af83c1742a", size = 223323, upload-time = "2026-08-26T14:56:28.807Z" }, - { url = "https://files.pythonhosted.org/packages/1b/7e/2629609652ece5ca0c7ac235927dd4511b08131e3a5d53439b798fddf002/websockets-17.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:d8e83333385cac6030a5167fd18bf96cc6c58b914c308e683f05b0cf94bc8dd0", size = 224276, upload-time = "2026-08-26T14:56:29.991Z" }, - { url = "https://files.pythonhosted.org/packages/a1/6b/8525737fe840b38e5f40956c198fb586a4fac1e07144d41a5b949b989cf8/websockets-17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:073c5c3f7e127041fa9d34a9e29ceefee8c3cafbd267ed2927318f425144380d", size = 224558, upload-time = "2026-08-26T14:56:31.184Z" }, - { url = "https://files.pythonhosted.org/packages/74/ab/3a958c6cbcf74b118f601c20a80ac8bd5e8dfec0bcf7345116feaeefb121/websockets-17.1-cp313-cp313-win32.whl", hash = "sha256:2afb58c7ba48b329d56769f8dfd89f394efe587b65ef806bae810a484d6d3608", size = 217475, upload-time = "2026-08-26T14:56:32.431Z" }, - { url = "https://files.pythonhosted.org/packages/22/36/fb521f0f2994c25509651f169efe5582dddd8713d57a0757ba87859372ef/websockets-17.1-cp313-cp313-win_amd64.whl", hash = "sha256:0340bbef6bfbe16da888b3983d666a4db4954ac3253c38f13bc7aba0c7db5a2f", size = 217784, upload-time = "2026-08-26T14:56:33.608Z" }, - { url = "https://files.pythonhosted.org/packages/68/92/9b8419584681a12a7534b746dfb2737c466efe2455483e2fbf8b941a04ec/websockets-17.1-cp313-cp313-win_arm64.whl", hash = "sha256:7a72efa3bf4fa3a6669a54420a472ad056da3973d827f10e3a536da463f926c2", size = 217715, upload-time = "2026-08-26T14:56:34.865Z" }, - { url = "https://files.pythonhosted.org/packages/90/0d/500cf5daea09d4669dff3a7d67159094a0bd6c4ef130381404f6edd3eb5f/websockets-17.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0c9982938980e086da59f70d05f9418cd143401a601a0faac10fa48f7bb1cd3e", size = 217048, upload-time = "2026-08-26T14:56:36.03Z" }, - { url = "https://files.pythonhosted.org/packages/97/12/5b12c6168aa269cffbfd24d177cd492b130120403a418c7e89462e27b4ac/websockets-17.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:57b39dc8541cf7ed3f639da82bf7451060483967f9e733da1f8173e4095f0642", size = 214737, upload-time = "2026-08-26T14:56:37.43Z" }, - { url = "https://files.pythonhosted.org/packages/0c/36/e453e5106e4e2416f008ac222837c2f1637a063b08008afcd1088889b631/websockets-17.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:96abdecbaae746851b87c3a36cb4a661df93ca3d92f114270f79228bf1d00de6", size = 214955, upload-time = "2026-08-26T14:56:38.71Z" }, - { url = "https://files.pythonhosted.org/packages/dd/30/0204bb86176db02cdfc678ce65ed808a66fab87d250ce61a8790800a60b0/websockets-17.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d9fc873e239c5abeb150bc24dbd1a7af23a9254526383ce0a077f5e20adbeb19", size = 224331, upload-time = "2026-08-26T14:56:39.924Z" }, - { url = "https://files.pythonhosted.org/packages/46/c8/d8372256e00c4e3cab1115c45075d1eeedb642a3f2b42bd70c4deae03f06/websockets-17.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6f42912fa9eb4cb7c7ec9fde9b3332ba339eb8a8811981043d4029599f3d950b", size = 224685, upload-time = "2026-08-26T14:56:41.169Z" }, - { url = "https://files.pythonhosted.org/packages/12/7d/650355b8f67f908ff99603351d4458d1a0b787d627950a47c38db7e25308/websockets-17.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f98bf378d7a5be047a044a1a27c987a8f355e10e3b5754617dbe756248cbc5ce", size = 225927, upload-time = "2026-08-26T14:56:42.359Z" }, - { url = "https://files.pythonhosted.org/packages/34/6c/a9ffa5b903579eed76017870f055d75ecc73988d9d0c9b65a92ba0bf2a27/websockets-17.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d334d11398086bb5559606cb42d51c013ea7c061c7db701521392373d3c087f5", size = 227300, upload-time = "2026-08-26T14:56:43.538Z" }, - { url = "https://files.pythonhosted.org/packages/9b/5d/4551c2269066af7481ee44605a0813770961615b5b5da3e87a8f5cb859ea/websockets-17.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5c27336b1a0ac56569493e858497870347854372395f50483725f8cdacc5a45c", size = 226533, upload-time = "2026-08-26T14:56:44.669Z" }, - { url = "https://files.pythonhosted.org/packages/3c/43/237a99233e5c445759a613831b3a92e91905afc064dc3bd0ad33c35fd1e2/websockets-17.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:67258b00302a5aaf0b267771c7014b13429abd7ea17eebc4c55bd935ff101555", size = 225280, upload-time = "2026-08-26T14:56:45.83Z" }, - { url = "https://files.pythonhosted.org/packages/d3/b5/e9407a91613d1d1cd932414143a1012096b26674a782fc55a0bd23217ee4/websockets-17.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:455ffeea0879d313205df1e745e5883e1feb7f31ecd26be882f5f0babd3db04f", size = 222540, upload-time = "2026-08-26T14:56:47.053Z" }, - { url = "https://files.pythonhosted.org/packages/db/d2/db76628db0577b783205d9779f64d8e373416b04c62d1546be4b75dc8540/websockets-17.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f7233eaf441a345a5943a929fd4b5ea3278f11aed35a9ed0f3106b8cb3ca846a", size = 225354, upload-time = "2026-08-26T14:56:48.32Z" }, - { url = "https://files.pythonhosted.org/packages/a9/4c/2174181c067b89a74ae18e2650c2ac29959f4b796afe876ab3f4d30d642c/websockets-17.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:c65da239a5ad553619804c1f9d65c1a0b3005381c6158ee14da2c7444cbd0c78", size = 223867, upload-time = "2026-08-26T14:56:49.579Z" }, - { url = "https://files.pythonhosted.org/packages/df/75/274decb9a8253561b5be3261e02a6676fc8ecdf31e95b722e53d5bfb8fd2/websockets-17.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9fa1ffa08c81a4f809cdab6129f8e55bee4650b9d6d3461019dda73aacd146b6", size = 224652, upload-time = "2026-08-26T14:56:50.885Z" }, - { url = "https://files.pythonhosted.org/packages/9f/e6/49824f1fb4db7656d2f7492b1d8be16147b759d909490e32f4776843ee64/websockets-17.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:406b8107943a43ef4649b1e0cb0cdc052bbf08fe1c8905a623c4af9586e5cebb", size = 225822, upload-time = "2026-08-26T14:56:52.356Z" }, - { url = "https://files.pythonhosted.org/packages/b8/6a/5dc43838c0b02a95f42c47a0de33c5ddd7767a9feeb4d0d8777ac1cfefe4/websockets-17.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:4e8ffcb486c8490a34a4cef5e4409d8da5a1cb1681e5bf7d786ce5e84aa8540d", size = 223379, upload-time = "2026-08-26T14:56:53.699Z" }, - { url = "https://files.pythonhosted.org/packages/c2/62/585637cf06d6b321232f79c55dc14d65518d12cf87c94c44f5864068810e/websockets-17.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:fb88076df585b69c5761c387c0081aa87d7b9eb1b205a6535ca4777e25650d81", size = 224330, upload-time = "2026-08-26T14:56:55.184Z" }, - { url = "https://files.pythonhosted.org/packages/de/68/c3b234a6a1366b6ab5bbfaa4434a1b946e1dc4e8ddd6824bfd93a8835b7f/websockets-17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5d4724255fb8398acd9e583b97eb2279cec20e0bd0f9a94bf75f6056ef9f13da", size = 224622, upload-time = "2026-08-26T14:56:56.393Z" }, - { url = "https://files.pythonhosted.org/packages/6a/d4/84cf3d1376f5d8207f55f43c1c818babd6b89447f5dcd01f18a6d5526796/websockets-17.1-cp314-cp314-win32.whl", hash = "sha256:be3f0129c5654517b2abf07dcb75bb1d9479759a4ccfb569e8293579e9fc029a", size = 217036, upload-time = "2026-08-26T14:56:57.652Z" }, - { url = "https://files.pythonhosted.org/packages/d0/0f/9e7ac63c5d7cb642952200814f584318e65146df008b7d375d5d9c6b2c97/websockets-17.1-cp314-cp314-win_amd64.whl", hash = "sha256:2a4dc6ef83f4559e0d05f313a375cb38f63c986096a9da99fe94fdd779d313e5", size = 217382, upload-time = "2026-08-26T14:56:59.065Z" }, - { url = "https://files.pythonhosted.org/packages/54/bb/1ae6b91f7f3ac05f5c9f14a72dc2181c115ff370bcd8a7f10f02c174adfd/websockets-17.1-cp314-cp314-win_arm64.whl", hash = "sha256:46c0331c9eaaf73a559f3a9e388466be0df96eb83d40f06f1ca6ab6613b35c82", size = 217268, upload-time = "2026-08-26T14:57:00.654Z" }, - { url = "https://files.pythonhosted.org/packages/b3/f0/f65644d0e0b2b90918a8c41503841cc4072a58f2bf76c09bc36e751fc0dd/websockets-17.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d411ea5ca18ac1b12c0c94be88b60c18ca641ac43bcdfdf1c9f79d46cdbe1603", size = 217379, upload-time = "2026-08-26T14:57:02.181Z" }, - { url = "https://files.pythonhosted.org/packages/ff/35/4c46d1f620ac1a30f92b6eae78ee40a772a93f568647ca7ccdc5ea283cf8/websockets-17.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:07fa3e7c30e2c577928d359b56bf872a3e0cbcc15553eaa0907c1ee86344b56f", size = 214911, upload-time = "2026-08-26T14:57:03.478Z" }, - { url = "https://files.pythonhosted.org/packages/04/6e/4587e8406d7c1188e97b9cf466c081e93399380d447f885bfce81626cd37/websockets-17.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6de9acef07e3a78e9567fcd26c29011a4da8f050b13004bbf880a0fd82a6eea5", size = 215115, upload-time = "2026-08-26T14:57:04.692Z" }, - { url = "https://files.pythonhosted.org/packages/ec/06/1381c8fff525041025909eb80ace32489194a00ba22a0a8d428030afcc84/websockets-17.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ea0ed9373b880115911d9d39634bccc95b8ce590c9c42e8589f5cacc3ef3cee2", size = 224696, upload-time = "2026-08-26T14:57:05.899Z" }, - { url = "https://files.pythonhosted.org/packages/36/9d/9034e867dc85340be058619751742b895f722326e83100d110063461ca07/websockets-17.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50903d335bfda026c2fa11dd9aed09d8cbee0c451e3a85122a9acb041b7dc69b", size = 224975, upload-time = "2026-08-26T14:57:07.262Z" }, - { url = "https://files.pythonhosted.org/packages/40/eb/ed03aa3cae748ebf6397e5d44028f433f746bad09dc568ff754fda3a3c9b/websockets-17.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a74531ce81af587f906ab42f194032388fcff8fc7938402e5917c9147a39441", size = 226151, upload-time = "2026-08-26T14:57:08.524Z" }, - { url = "https://files.pythonhosted.org/packages/b1/c9/cc1964a096d16f3b73cb1ee5f14f277f5a3bcac07c6e8f9a1dcded99f4c8/websockets-17.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8fbf28e639544503b7d1c96452a5e5e043e4108d89b1f3fa02910603622d19db", size = 228292, upload-time = "2026-08-26T14:57:09.846Z" }, - { url = "https://files.pythonhosted.org/packages/1a/26/46da6dd0363c2db2e4876fd59a40fd40c1943a82d7018d0a33afbce47d52/websockets-17.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f612dc57f00c07cf4aa2673f7cbceabd654ad2457b7e639f061b794d6e11f9fd", size = 226722, upload-time = "2026-08-26T14:57:11.118Z" }, - { url = "https://files.pythonhosted.org/packages/78/98/ecd8f5e1c5d0e54c08ebc5c66852271112166db68107cb0e17ca1bf25009/websockets-17.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1c7ac77401227212dc6e849182feee50d57cf456ec6329ffd6979c94bb136c5c", size = 225451, upload-time = "2026-08-26T14:57:12.601Z" }, - { url = "https://files.pythonhosted.org/packages/65/4d/da8d2760db53e17aae763738b6ba834b1fcf16813d3632f3edb6951e1ec8/websockets-17.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32a2a68d989d6e5b74a9d5095415c51189ebae29fceb7cf2b64a1c0318a81256", size = 223003, upload-time = "2026-08-26T14:57:13.875Z" }, - { url = "https://files.pythonhosted.org/packages/a4/40/ea401c141a79c5b1d0021a0dab9d0df2051c108f1620fbb39a6e7c714c3b/websockets-17.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:aec00f018d34c67500ff0438dc314b40277be4a1b983cbacbf53ccf7db63e257", size = 225704, upload-time = "2026-08-26T14:57:15.091Z" }, - { url = "https://files.pythonhosted.org/packages/e1/8e/07ab3f44215d89840d5385fdcaaab1fed8caeffa67c6899e15062957c12c/websockets-17.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:0014eaff8ad5b3b43feda2279f9d34bf2eaae040720b9fbbb55944b10f40b14d", size = 224192, upload-time = "2026-08-26T14:57:16.3Z" }, - { url = "https://files.pythonhosted.org/packages/58/93/ccf1af0a23e5748d4e22292a377d78d15cf294d7e707bbb11a8990ae6bd5/websockets-17.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:db9d7ee47f3ba531e278be539af39e2c7c7d28fb94897b6cd1120d63b0ef5922", size = 225082, upload-time = "2026-08-26T14:57:17.531Z" }, - { url = "https://files.pythonhosted.org/packages/e2/db/e32200f99ce282e728d2929f2c429db353cf3282db7d0eba99eb32c9fec1/websockets-17.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:ff3e2ba7a9f0a110b0555452e9b5a03a34e11662544e01beea15f144b48ba7b7", size = 226101, upload-time = "2026-08-26T14:57:18.802Z" }, - { url = "https://files.pythonhosted.org/packages/28/3d/e7a6e9777b29433620167c98f3caaff0d6b08b1239a273ef7f7fd1393349/websockets-17.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:6da17fc94bd270f5987b10bee113461ac36a36a98b0481ddcc98056e5a90001a", size = 223794, upload-time = "2026-08-26T14:57:20.313Z" }, - { url = "https://files.pythonhosted.org/packages/48/05/ac569090726dedd6656f3ee28b0c02dfb1ba76e898dceaccc2987a237cef/websockets-17.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:e8dc3fa6d6b7ead3f9de57895f41b116a28787548e066365d9d90f7356bcaad2", size = 224567, upload-time = "2026-08-26T14:57:21.634Z" }, - { url = "https://files.pythonhosted.org/packages/14/50/4ef62941111db6b31193f4fabbb65f845a5177579040cb8fe0d774d25034/websockets-17.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b65d5fe48219dc2d5e158de9e6514e75600f379cc7e37108d35f31764c155566", size = 224993, upload-time = "2026-08-26T14:57:22.86Z" }, - { url = "https://files.pythonhosted.org/packages/28/42/2b95ada4ea19bf3a2072b68669ce4f4afb212690b727d31640576287fd68/websockets-17.1-cp314-cp314t-win32.whl", hash = "sha256:2cce251f3e2469b99b6802b55435bcdd07123b41870f54c87b336183af9d7e68", size = 217168, upload-time = "2026-08-26T14:57:24.466Z" }, - { url = "https://files.pythonhosted.org/packages/32/0a/67d5ee08dd8060a37d612fd40a625b5376ad19ae48fe1c8ad428c278b817/websockets-17.1-cp314-cp314t-win_amd64.whl", hash = "sha256:8f6c38cdcaf98a911d7acc25577f2f9e710f3a2fc2bde1563556784320196b51", size = 217508, upload-time = "2026-08-26T14:57:25.983Z" }, - { url = "https://files.pythonhosted.org/packages/76/a3/822005d0c674451d2411027b878cdc128a2b7ea5a30d337d9e279da22eba/websockets-17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:d1e2f5fa2b6d01f0d85b4f223fea7ed1d504be282a02a81bd2be4817ef7a2f03", size = 217425, upload-time = "2026-08-26T14:57:27.324Z" }, - { url = "https://files.pythonhosted.org/packages/de/d5/99a6c6a1eb5d5ae9f45f59a3c97f4e3b21f310eb404a547fb3e7d2fc054c/websockets-17.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:88381602e379165b66244b2ebc29f9b23ea0851fbe63ae157f91ca324f072d6f", size = 216970, upload-time = "2026-08-26T14:57:28.575Z" }, - { url = "https://files.pythonhosted.org/packages/a6/0e/1e7f6e833728193958d3ed3d67b5d57c3c7cfa948abf94d4bc553257c954/websockets-17.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:88bc5138e53903a85c354e59df7ba73ce306f7b09724cef74dba121e60a88ce2", size = 214699, upload-time = "2026-08-26T14:57:29.862Z" }, - { url = "https://files.pythonhosted.org/packages/07/00/95d39549f86e34425a0412bcbe61708dd1fc46af654e2134a6c4389102ad/websockets-17.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:3546ef55b3a074494106508bc6505c73825970d2d9505f7bf53882b3e88b0d1e", size = 214927, upload-time = "2026-08-26T14:57:31.148Z" }, - { url = "https://files.pythonhosted.org/packages/4c/ff/b442415fc4f7f9943b0fc8e8eebaa13923ca73361e167c439ba634eecbd9/websockets-17.1-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9ae55d24241fc055f22aea3ac924559069848bd0ad4ea065fdd72d2194685fe8", size = 224373, upload-time = "2026-08-26T14:57:32.833Z" }, - { url = "https://files.pythonhosted.org/packages/a8/dd/b83537aae4cf61615b9d8b2dbb235c0030ba85457a6d934798273814600f/websockets-17.1-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d7b349265fad6244013eecd99df8d83c12bf3013943e431f4fadd5bffc37db42", size = 224801, upload-time = "2026-08-26T14:57:34.041Z" }, - { url = "https://files.pythonhosted.org/packages/76/83/5ab0abed58454909e8dbab45086ac68ee4556d7a8ada26735addc909b903/websockets-17.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc5789e5ea182b77a38881383ada5347202a6c66f4857d054e075290e80b604b", size = 225967, upload-time = "2026-08-26T14:57:35.292Z" }, - { url = "https://files.pythonhosted.org/packages/4b/26/e2412f2b998a8c1dfc00c0709ff6ee0c634dd0b0b4f92bdfe9667876b71c/websockets-17.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ce13c7d233239e739600a57d4a73c1192ad8259e655a4d55aa1a454242bc809d", size = 227664, upload-time = "2026-08-26T14:57:36.493Z" }, - { url = "https://files.pythonhosted.org/packages/ec/25/0dd4495df3c0e02f6db705312ba85ab9b2dd42257dc23eb0da10066e4844/websockets-17.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1036189bd34b0bc1b10a4679321e2c7968af317efe6e8e4c1c5141c4254fb5bb", size = 226447, upload-time = "2026-08-26T14:57:37.781Z" }, - { url = "https://files.pythonhosted.org/packages/be/67/6df3f63ffc48f08126ed0cd2fd2a41092967c3e364f8ec100deae90b6d77/websockets-17.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e78fd4b7b2c5086a38671c9c882c1e643385eccea360b5b1fda4a105e590087e", size = 225343, upload-time = "2026-08-26T14:57:39.133Z" }, - { url = "https://files.pythonhosted.org/packages/b1/8d/a8479bbb09ff054907d141123d8f52fb6ae5ac39c6dbe39e6a02a8408309/websockets-17.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:46e7a10bf04318c7b0c0273791925ae5e1cbe4a11e34aa934d2ef27862058a80", size = 222748, upload-time = "2026-08-26T14:57:40.478Z" }, - { url = "https://files.pythonhosted.org/packages/40/fb/4c3d2a3269cde3f3087916de9c3d9fc5d7196b46846d8c3a9ae59ad0a884/websockets-17.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:33e45c7ea38428e740a7f233555d71df0b875cef7fc080acebc9654475e35335", size = 225453, upload-time = "2026-08-26T14:57:41.859Z" }, - { url = "https://files.pythonhosted.org/packages/7f/1c/6467b401d19408f34e1c7389c222c2c7e1dfdf08c551190269b5eabc726c/websockets-17.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:6e63c01803be425ff062b7f7fc201a74def1d49fc94a2410dd17375df75936e9", size = 224112, upload-time = "2026-08-26T14:57:43.136Z" }, - { url = "https://files.pythonhosted.org/packages/c5/5f/744e032ac80e11039a7447657ebabb46e9b5c2dbcec83be571335212932f/websockets-17.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:722ec21717eec6477bce582147a28acdfe034e604239466a6a95daedb863e774", size = 224646, upload-time = "2026-08-26T14:57:44.871Z" }, - { url = "https://files.pythonhosted.org/packages/9f/47/bcb9128d9afc4d0934d9192e2a24897ca2f7a63df2654904915349c6c46d/websockets-17.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:e74e41f0ad12ff1e8983e349daef79d37cc8280c743ce9d134d6c74c18dab5d6", size = 225797, upload-time = "2026-08-26T14:57:46.338Z" }, - { url = "https://files.pythonhosted.org/packages/c7/e0/b058047b7cf565e1105b10ef6b6b24a6ebe3575678c7dc75a645334705a7/websockets-17.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:12fe8984a32dbfd084e0603f1a8d740c0180cb85b3174585c54a80d2455a8394", size = 223605, upload-time = "2026-08-26T14:57:48.175Z" }, - { url = "https://files.pythonhosted.org/packages/b9/69/fc1555bff884de363f1bf9eebf2836dbeb29fa7e4f957debb7bbcf43abba/websockets-17.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:01dcb47deebc40b38fd4a493b9b9f4d0a704b7bec6f35e4d34085b329abce71a", size = 224508, upload-time = "2026-08-26T14:57:49.407Z" }, - { url = "https://files.pythonhosted.org/packages/e7/f9/648d4e68621688b19093b06f7b497d520952e68cdea1c1b54371fe9491de/websockets-17.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f4c45ee2512d3757b5e6c67c5a34e435143f2ecb7df3324f9fd888688c45c0f4", size = 224767, upload-time = "2026-08-26T14:57:50.799Z" }, - { url = "https://files.pythonhosted.org/packages/58/93/f8342b55864f71df13eb8e9ef7dce691b87a87f04f75bb8a1385b3336e7c/websockets-17.1-cp315-cp315-win32.whl", hash = "sha256:0f4f50dfe2cc810fc4e2de979b35e83bf8bb4bccdc6fe472d93762ea7b1d5927", size = 217003, upload-time = "2026-08-26T14:57:52.122Z" }, - { url = "https://files.pythonhosted.org/packages/ea/f0/7b5fdb774c245e0b6217009e2a24d2105c1a64923949f33be41aa7959302/websockets-17.1-cp315-cp315-win_amd64.whl", hash = "sha256:4af784f3e436f65b355c117c6497320f2b5cf6a559295cb1c4c7338e335d45cc", size = 217300, upload-time = "2026-08-26T14:57:53.492Z" }, - { url = "https://files.pythonhosted.org/packages/76/33/1fe6ed1b5087516115ca451b2c240314b010647071f8fc3bd78a21e4dddb/websockets-17.1-cp315-cp315-win_arm64.whl", hash = "sha256:d58159af7835fde09c462394293c0d7aaf8fb4557d8f8e5699f5e722ccae013d", size = 217214, upload-time = "2026-08-26T14:57:54.88Z" }, - { url = "https://files.pythonhosted.org/packages/94/ca/ed02e75996a266d76c5fcb5dd9b930db4cf2b388ca5fa3d2a72086f81568/websockets-17.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:1a5cf4e7bbe3ca499e6a289206cb4fcb7444b09919e129bd517f57d5fa192c13", size = 217282, upload-time = "2026-08-26T14:57:56.108Z" }, - { url = "https://files.pythonhosted.org/packages/bd/7d/d536f5bc89ea5b52fd1c1727c59fabafee6bc41f5ce92c3bd2f83047908c/websockets-17.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:416b4bc8789a1865a3ff643ec4ee073a5f52402d0dbeafd27b1798d5dd6b6a51", size = 214863, upload-time = "2026-08-26T14:57:57.355Z" }, - { url = "https://files.pythonhosted.org/packages/37/37/944cf17bad668e9be1247e6314f88a48b9faf7c250e383410db8b38af0b9/websockets-17.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:259f45358c76d3b18489e3e80636cdbe807e05ecf1b10fdf1a779106d23d0c8e", size = 215073, upload-time = "2026-08-26T14:57:58.719Z" }, - { url = "https://files.pythonhosted.org/packages/74/bf/3267966cc1bbc2b8fa62fd329651b0af502df1f5d1c0eed027ff339d6aa8/websockets-17.1-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d9d01e8ede41fea4f5a847dad9d628355f74905f437a5b6856d67aa66d193800", size = 225229, upload-time = "2026-08-26T14:58:00.235Z" }, - { url = "https://files.pythonhosted.org/packages/7f/d8/85ea722f483510abb39fc71aafb4465d17cf9051a275ab036874ff3c300c/websockets-17.1-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a7b35181a14cbfcae163b4de545d22abfd07d06c2c41ca69cfcd99251d6888ab", size = 225500, upload-time = "2026-08-26T14:58:01.994Z" }, - { url = "https://files.pythonhosted.org/packages/50/ce/64c7d00005bd0d15ecb5c5fcb7fb2597b6b92ddd16c4fa6bbc3d2835ad63/websockets-17.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6a8e768a048c2220697477ce2e67e4345dc9f693d0ee6af53945b5e30227c6a7", size = 226829, upload-time = "2026-08-26T14:58:03.327Z" }, - { url = "https://files.pythonhosted.org/packages/b4/dc/096c67940fb957e667ca3c542818150434eb0388c6fdc90b3a502f3c3e96/websockets-17.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:880069d21cc33a558dcf180924a546d1ecf8ada5be3e4e70acee87019d706a24", size = 228457, upload-time = "2026-08-26T14:58:04.78Z" }, - { url = "https://files.pythonhosted.org/packages/51/fe/f2331b6b7ccc67589891da354fa46a5cb79e95f83b9fd0e734d77f1f2140/websockets-17.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cec1bb8f22abccc8d20f8ca63df9be41600c26c190f4b97ee86c675fd4a863a6", size = 227265, upload-time = "2026-08-26T14:58:06.102Z" }, - { url = "https://files.pythonhosted.org/packages/47/a5/fb1642302f8ec77ca922203074f155a9831a5128ad75e725059a476d1227/websockets-17.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f3a1d577e081667dda7f8e5b4796e6e32f9713c93e2a3d930669519840a3c623", size = 226143, upload-time = "2026-08-26T14:58:07.464Z" }, - { url = "https://files.pythonhosted.org/packages/d7/41/7133fcfb63f5562750b269d6a845c689dde6a2c6407286da395beea19ddd/websockets-17.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dc053f9e95a76213c5eb7ed95779f7daf0d2bf0e4e03073629ebfa43a033f151", size = 223501, upload-time = "2026-08-26T14:58:08.766Z" }, - { url = "https://files.pythonhosted.org/packages/64/b1/82b36bfabc79ff2d383a1fc043cee6a13f794ef4f6bf1b4810ad6988cf6f/websockets-17.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:bb0efe019480a1c93e168ce96479273aaebd672fc8c350d5eed1e507ababb1b8", size = 226330, upload-time = "2026-08-26T14:58:09.987Z" }, - { url = "https://files.pythonhosted.org/packages/41/7d/5b511b9bf6e9ad331e6ff902fcbcc71c3794d10ef3b5efe80ccb8f0a7861/websockets-17.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:615746b12b26a3fd4077bc6fbeb277a1c192a45dd57b531d07ad9ed5c52a9a7a", size = 224980, upload-time = "2026-08-26T14:58:11.303Z" }, - { url = "https://files.pythonhosted.org/packages/e0/50/aed08f25301f8eef23be903ff9319fcf35630ca2bdec9d226f7d804dd5b3/websockets-17.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:1a20136d61f9ca3a31493732762661fafc2c20e8861930214e21afc6a8a692a2", size = 225478, upload-time = "2026-08-26T14:58:12.543Z" }, - { url = "https://files.pythonhosted.org/packages/3e/47/0d63d4168536b4682c9d19b7399443b1176f25dbb68878374fa716670230/websockets-17.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:2786cbd273ab69c22612db8a41229ddf2c158060b17b5928884bf388d07887f3", size = 226588, upload-time = "2026-08-26T14:58:14.457Z" }, - { url = "https://files.pythonhosted.org/packages/b3/dd/844bd0b6386fc81ed6a55f4b6dd26f01c6987eda205afa10175ea12b2164/websockets-17.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:b1c323fc3be1dc3f87f6c59458cb7d9e13dcbbf971d6c3f3e2bbaf58d3bfcdfe", size = 224336, upload-time = "2026-08-26T14:58:15.778Z" }, - { url = "https://files.pythonhosted.org/packages/96/18/03709c84bc88ec4dcea68d4be4ccd07d611073dec111203a5bf45af8809d/websockets-17.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:12c8e2b25df59755954a04dfa09c990b96691025aaf7eafd19ed6da24b09c18d", size = 225197, upload-time = "2026-08-26T14:58:17.141Z" }, - { url = "https://files.pythonhosted.org/packages/27/cf/0d1c694b6466c89e875b85b32b51312c472cf6708eee91914866f5087dde/websockets-17.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f58f58b4b29bbea2a3635e2c56eff4a3adab011fe383802a9e542e31b97085fc", size = 225493, upload-time = "2026-08-26T14:58:18.521Z" }, - { url = "https://files.pythonhosted.org/packages/4e/f5/99857c3dd9676749f33e3668665a34ad6099505fb8d75eb084f49f7807a9/websockets-17.1-cp315-cp315t-win32.whl", hash = "sha256:f78a3ffb1994304db2c0c4588e4d1a518079b557054fa3bb985a6f5e50ff49a3", size = 217130, upload-time = "2026-08-26T14:58:20.037Z" }, - { url = "https://files.pythonhosted.org/packages/2c/84/77599922ab441bfe61508f97dab2c71f8e114d31793993ea54011db16199/websockets-17.1-cp315-cp315t-win_amd64.whl", hash = "sha256:ad68c28a27246fed109a4409393d677b7e1388345cbbd2f5aee5c182d8506110", size = 217448, upload-time = "2026-08-26T14:58:21.382Z" }, - { url = "https://files.pythonhosted.org/packages/ce/3c/8b9a225b523f06a9389be81f1b0ab07c49bec6014742e6aa359c1f920f1f/websockets-17.1-cp315-cp315t-win_arm64.whl", hash = "sha256:e552e0037230ac16e5f568de7012041344d1b18c9feed30ec2891b8eba55af81", size = 217372, upload-time = "2026-08-26T14:58:22.807Z" }, - { url = "https://files.pythonhosted.org/packages/41/63/23572870e01836a98346075b9e17a8bc24a6ddd9800a3204ceee58677f3c/websockets-17.1-py3-none-any.whl", hash = "sha256:f221081107b8c48184d99f7019604486376e7ef826037e70aad6b02540732c23", size = 211134, upload-time = "2026-08-26T17:25:31.397Z" }, -]