From 80a2d1c44cc90df854a42d5b25b1f4715869f249 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 20 Oct 2026 00:29:41 +0400 Subject: [PATCH 1/8] Check the collector read secret from the dashboard deploy script The production dashboard deploy confirmed that the collector read secret exists by running telemetry-service/scripts/check-secrets-exist.sh. It now runs `deploy-dashboard.sh check-read-secret-exists`, which makes the same metadata- only `gcloud secrets describe` call with the same PROJECT_ID and READ_SECRET_NAME from the Production environment, so the deploy no longer depends on telemetry-service/. Co-Authored-By: Claude Opus 5.5 --- .github/scripts/deploy-dashboard.sh | 19 +++++++ .github/workflows/deploy.yml | 2 +- frontend/tests/test_deploy_script.py | 68 ++++++++++++++++++++++++++ frontend/tests/test_deploy_workflow.py | 5 +- 4 files changed, 90 insertions(+), 4 deletions(-) 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/frontend/tests/test_deploy_script.py b/frontend/tests/test_deploy_script.py index 19108903..ab5704aa 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,69 @@ 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 + + +def _read_secret_check(tmp_path, gcloud_exit_code=0, **configuration): + """Run check-read-secret-exists with a gcloud that records its arguments.""" + arguments_file = tmp_path / "gcloud-arguments.json" + stub = tmp_path / "gcloud" + stub.write_text( + "#!/usr/bin/env python3\n" + "import json, os, sys\n" + "from pathlib import Path\n" + "Path(os.environ['ARGUMENTS_FILE']).write_text(json.dumps(sys.argv[1:]))\n" + f"sys.exit({gcloud_exit_code})\n" + ) + stub.chmod(0o755) + environment = { + "PATH": f"{tmp_path}:{os.environ['PATH']}", + "ARGUMENTS_FILE": str(arguments_file), + **configuration, + } + result = subprocess.run( + ["bash", str(SCRIPT), "check-read-secret-exists"], + env=environment, + capture_output=True, + text=True, + check=False, + ) + recorded_arguments = ( + json.loads(arguments_file.read_text()) if arguments_file.exists() else None + ) + return result, recorded_arguments + + +def test_read_secret_check_reads_only_the_secret_metadata(tmp_path): + result, gcloud_arguments = _read_secret_check( + tmp_path, PROJECT_ID="test-project", READ_SECRET_NAME="test-read-secret" + ) + assert result.returncode == 0 + assert gcloud_arguments == [ + "secrets", + "describe", + "test-read-secret", + "--project", + "test-project", + "--format=none", + ] + + +def test_missing_read_secret_stops_deployment(tmp_path): + result, _ = _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 + + +@pytest.mark.parametrize("missing_name", ["PROJECT_ID", "READ_SECRET_NAME"]) +def test_read_secret_check_requires_its_configuration(tmp_path, missing_name): + configuration = {"PROJECT_ID": "test-project", "READ_SECRET_NAME": "test-secret"} + configuration.pop(missing_name) + result, gcloud_arguments = _read_secret_check(tmp_path, **configuration) + assert result.returncode != 0 + assert missing_name in result.stderr + assert gcloud_arguments is None 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 }}" From 3386b7cc8364fe41532ac09a7460281636321716 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 20 Oct 2026 00:30:22 +0400 Subject: [PATCH 2/8] Remove telemetry-service/ now that the collector deploys from its own repo PolicyEngine/microcosm-collector holds the collector's history, source, tests and deployment. Remove telemetry-service/, its telemetry-service.yml workflow (including the manual redeploy kept for rolling the deploy-source switch back), and the test of that workflow. Point the build monitor's documentation at the collector's repo. New tests require that the directory and workflow stay removed, that no tracked file refers to either, and that no workflow or workflow script here deploys the collector's Cloud Run service or migration job. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/telemetry-service.yml | 284 ------ docs/build-monitor.md | 3 +- .../test_collector_lives_in_its_own_repo.py | 70 ++ .../tests/test_telemetry_service_workflow.py | 224 ----- telemetry-service/.dockerignore | 6 - telemetry-service/.gcloudignore | 7 - telemetry-service/Dockerfile | 17 - telemetry-service/README.md | 168 ---- telemetry-service/alembic.ini | 38 - telemetry-service/migrations/env.py | 75 -- telemetry-service/migrations/script.py.mako | 25 - .../versions/0001_initial_schema.py | 133 --- telemetry-service/pyproject.toml | 38 - .../scripts/check-secrets-exist.sh | 23 - telemetry-service/scripts/deploy.sh | 390 -------- .../require-collector-push-runs-finished.sh | 21 - .../telemetry_collector/__init__.py | 1 - telemetry-service/telemetry_collector/app.py | 462 --------- telemetry-service/telemetry_collector/auth.py | 97 -- .../telemetry_collector/database.py | 208 ---- .../telemetry_collector/migrate.py | 232 ----- .../telemetry_collector/models.py | 127 --- .../telemetry_collector/storage.py | 774 --------------- telemetry-service/tests/__init__.py | 1 - telemetry-service/tests/conftest.py | 34 - telemetry-service/tests/event_schedules.py | 538 ----------- telemetry-service/tests/fakes.py | 130 --- telemetry-service/tests/test_app.py | 676 ------------- telemetry-service/tests/test_auth.py | 61 -- telemetry-service/tests/test_database.py | 83 -- telemetry-service/tests/test_deploy_script.py | 252 ----- .../tests/test_deployment_workflow.py | 169 ---- .../tests/test_incremental_batch_apply.py | 307 ------ telemetry-service/tests/test_migrate.py | 140 --- .../tests/test_migrations_postgres.py | 220 ----- .../tests/test_secret_existence.py | 67 -- telemetry-service/tests/test_storage.py | 348 ------- .../tests/test_storage_postgres.py | 444 --------- telemetry-service/uv.lock | 899 ------------------ 39 files changed, 72 insertions(+), 7720 deletions(-) delete mode 100644 .github/workflows/telemetry-service.yml create mode 100644 frontend/tests/test_collector_lives_in_its_own_repo.py delete mode 100644 frontend/tests/test_telemetry_service_workflow.py delete mode 100644 telemetry-service/.dockerignore delete mode 100644 telemetry-service/.gcloudignore delete mode 100644 telemetry-service/Dockerfile delete mode 100644 telemetry-service/README.md delete mode 100644 telemetry-service/alembic.ini delete mode 100644 telemetry-service/migrations/env.py delete mode 100644 telemetry-service/migrations/script.py.mako delete mode 100644 telemetry-service/migrations/versions/0001_initial_schema.py delete mode 100644 telemetry-service/pyproject.toml delete mode 100644 telemetry-service/scripts/check-secrets-exist.sh delete mode 100755 telemetry-service/scripts/deploy.sh delete mode 100755 telemetry-service/scripts/require-collector-push-runs-finished.sh delete mode 100644 telemetry-service/telemetry_collector/__init__.py delete mode 100644 telemetry-service/telemetry_collector/app.py delete mode 100644 telemetry-service/telemetry_collector/auth.py delete mode 100644 telemetry-service/telemetry_collector/database.py delete mode 100644 telemetry-service/telemetry_collector/migrate.py delete mode 100644 telemetry-service/telemetry_collector/models.py delete mode 100644 telemetry-service/telemetry_collector/storage.py delete mode 100644 telemetry-service/tests/__init__.py delete mode 100644 telemetry-service/tests/conftest.py delete mode 100644 telemetry-service/tests/event_schedules.py delete mode 100644 telemetry-service/tests/fakes.py delete mode 100644 telemetry-service/tests/test_app.py delete mode 100644 telemetry-service/tests/test_auth.py delete mode 100644 telemetry-service/tests/test_database.py delete mode 100644 telemetry-service/tests/test_deploy_script.py delete mode 100644 telemetry-service/tests/test_deployment_workflow.py delete mode 100644 telemetry-service/tests/test_incremental_batch_apply.py delete mode 100644 telemetry-service/tests/test_migrate.py delete mode 100644 telemetry-service/tests/test_migrations_postgres.py delete mode 100644 telemetry-service/tests/test_secret_existence.py delete mode 100644 telemetry-service/tests/test_storage.py delete mode 100644 telemetry-service/tests/test_storage_postgres.py delete mode 100644 telemetry-service/uv.lock 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..c739abb7 100644 --- a/docs/build-monitor.md +++ b/docs/build-monitor.md @@ -14,7 +14,8 @@ checks stop builds. The run selected on either tab carries over to the other. 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. + 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 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..340b28b2 --- /dev/null +++ b/frontend/tests/test_collector_lives_in_its_own_repo.py @@ -0,0 +1,70 @@ +"""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_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: + assert not (ROOT / REMOVED_DIRECTORY).exists() + assert not (GITHUB_DIRECTORY / "workflows" / f"{REMOVED_DIRECTORY}.yml").exists() + + +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_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" }, -] From 5cceb491f77140fe5ddfdcb3b553bf6a259caa97 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:44:52 +0400 Subject: [PATCH 3/8] Check the removed collector directory with git ls-files The test asserted that telemetry-service/ did not exist on disk. Switching an existing clone to this branch removes the tracked files but leaves ignored ones, such as .venv/ and __pycache__/, so the test failed there. It now requires that git tracks no file under the directory or at the removed workflow's path. Co-Authored-By: Claude Opus 5.5 --- .../tests/test_collector_lives_in_its_own_repo.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/frontend/tests/test_collector_lives_in_its_own_repo.py b/frontend/tests/test_collector_lives_in_its_own_repo.py index 340b28b2..a854c0ca 100644 --- a/frontend/tests/test_collector_lives_in_its_own_repo.py +++ b/frontend/tests/test_collector_lives_in_its_own_repo.py @@ -15,6 +15,7 @@ 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", @@ -39,8 +40,17 @@ def tracked_files() -> list[Path]: def test_the_collector_directory_and_its_workflow_are_removed() -> None: - assert not (ROOT / REMOVED_DIRECTORY).exists() - assert not (GITHUB_DIRECTORY / "workflows" / f"{REMOVED_DIRECTORY}.yml").exists() + # 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: From bd8759e938bed9a34ca547f3a1a5cf3f58d38e69 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:46:18 +0400 Subject: [PATCH 4/8] Document how the dashboard reads the collector Removing telemetry-service/README.md removed the only description of the dashboard's own settings for reading the collector. Move it into the Build progress documentation: MICROCOSM_TELEMETRY_COLLECTOR_URL, MICROCOSM_TELEMETRY_READ_SECRET_VERSION and MICROCOSM_TELEMETRY_GOOGLE_IDENTITY_PROVIDER, the Vercel OIDC trust, the per-environment secret access, local development with Application Default Credentials, and what the production deploy's read-secret check does and doesn't show. Also correct the claim that the read credential comes from the deployment environment: the server fetches a Secret Manager version itself. Co-Authored-By: Claude Opus 5.5 --- docs/build-monitor.md | 59 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 55 insertions(+), 4 deletions(-) diff --git a/docs/build-monitor.md b/docs/build-monitor.md index c739abb7..21b2420e 100644 --- a/docs/build-monitor.md +++ b/docs/build-monitor.md @@ -12,10 +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. The collector's source, tests and deployment - are in [PolicyEngine/microcosm-collector](https://github.com/PolicyEngine/microcosm-collector). + 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 @@ -43,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 Vercel project sets these server-only, non-secret settings, separately for +production and preview: + +| Setting | Value | +|---|---| +| `MICROCOSM_TELEMETRY_COLLECTOR_URL` | The matching environment's collector address. It must use HTTPS, except on `localhost`. | +| `MICROCOSM_TELEMETRY_READ_SECRET_VERSION` | The full Secret Manager version resource of that collector's read credential: `projects//secrets//versions/`. | +| `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. | + +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 From ff40071aadf25ead74dd82c0c1513990bb53b3c6 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:52:29 +0400 Subject: [PATCH 5/8] Say where production gets the collector address Production deployments don't take MICROCOSM_TELEMETRY_COLLECTOR_URL from the Vercel project's settings: deploy.yml passes the GitHub Production environment variable of the same name to vercel deploy --env. The read secret version and the identity provider do come from the Vercel project. Co-Authored-By: Claude Opus 5.5 --- docs/build-monitor.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/build-monitor.md b/docs/build-monitor.md index 21b2420e..96699994 100644 --- a/docs/build-monitor.md +++ b/docs/build-monitor.md @@ -55,14 +55,14 @@ 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 Vercel project sets these server-only, non-secret settings, separately for -production and preview: - -| Setting | Value | -|---|---| -| `MICROCOSM_TELEMETRY_COLLECTOR_URL` | The matching environment's collector address. It must use HTTPS, except on `localhost`. | -| `MICROCOSM_TELEMETRY_READ_SECRET_VERSION` | The full Secret Manager version resource of that collector's read credential: `projects//secrets//versions/`. | -| `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 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`. | 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 From 9587f51936cd655fe05972cd62efa3fa4f8f1980 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Wed, 21 Oct 2026 03:37:14 +0400 Subject: [PATCH 6/8] Record every gcloud call in the read-secret check tests The gcloud stub replaced its arguments file on each call, so the tests saw only the last call. A check that first read the secret's value with `gcloud secrets versions access` and then ran the describe call passed test_read_secret_check_reads_only_the_secret_metadata. The stub now appends one record per call, and the tests compare the full list: exactly one `gcloud secrets describe` call when the secret exists or is missing, and no call when a setting is missing. The old script's test on main compared the full list of calls too. Test-only; the script already makes just the describe call. Against a temporary, uncommitted change to the script that adds the value-reading call, this file gave 2 failed (the two tests above); against the committed script, 7 passed. Co-Authored-By: Claude Opus 5.5 --- frontend/tests/test_deploy_script.py | 51 +++++++++++++++++----------- 1 file changed, 31 insertions(+), 20 deletions(-) diff --git a/frontend/tests/test_deploy_script.py b/frontend/tests/test_deploy_script.py index ab5704aa..7a87b061 100644 --- a/frontend/tests/test_deploy_script.py +++ b/frontend/tests/test_deploy_script.py @@ -91,21 +91,34 @@ def test_frontend_receives_release_token_as_server_environment(tmp_path): 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 its arguments.""" - arguments_file = tmp_path / "gcloud-arguments.json" + """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" - "from pathlib import Path\n" - "Path(os.environ['ARGUMENTS_FILE']).write_text(json.dumps(sys.argv[1:]))\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']}", - "ARGUMENTS_FILE": str(arguments_file), + "GCLOUD_CALLS_FILE": str(calls_file), **configuration, } result = subprocess.run( @@ -115,29 +128,26 @@ def _read_secret_check(tmp_path, gcloud_exit_code=0, **configuration): text=True, check=False, ) - recorded_arguments = ( - json.loads(arguments_file.read_text()) if arguments_file.exists() else None + gcloud_calls = ( + [json.loads(call) for call in calls_file.read_text().splitlines()] + if calls_file.exists() + else [] ) - return result, recorded_arguments + return result, gcloud_calls def test_read_secret_check_reads_only_the_secret_metadata(tmp_path): - result, gcloud_arguments = _read_secret_check( + result, gcloud_calls = _read_secret_check( tmp_path, PROJECT_ID="test-project", READ_SECRET_NAME="test-read-secret" ) assert result.returncode == 0 - assert gcloud_arguments == [ - "secrets", - "describe", - "test-read-secret", - "--project", - "test-project", - "--format=none", - ] + # 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, _ = _read_secret_check( + result, gcloud_calls = _read_secret_check( tmp_path, gcloud_exit_code=1, PROJECT_ID="test-project", @@ -145,13 +155,14 @@ def test_missing_read_secret_stops_deployment(tmp_path): ) 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"]) def test_read_secret_check_requires_its_configuration(tmp_path, missing_name): configuration = {"PROJECT_ID": "test-project", "READ_SECRET_NAME": "test-secret"} configuration.pop(missing_name) - result, gcloud_arguments = _read_secret_check(tmp_path, **configuration) + result, gcloud_calls = _read_secret_check(tmp_path, **configuration) assert result.returncode != 0 assert missing_name in result.stderr - assert gcloud_arguments is None + assert gcloud_calls == [] From d4cb57361297e5154baf6533049f4c1b942d8cb8 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Wed, 21 Oct 2026 03:37:38 +0400 Subject: [PATCH 7/8] Test the read-secret check with empty settings GitHub Actions expands `${{ vars.NAME }}` to an empty string when the variable doesn't exist, so a missing TELEMETRY_GCP_PROJECT_ID or TELEMETRY_READ_SECRET_NAME reaches check-read-secret-exists as an empty PROJECT_ID or READ_SECRET_NAME. The tests only removed the variable. Run each case both ways: the check must fail, name the setting, and not call gcloud. Test-only; the script already treats an empty value as missing. Against a temporary, uncommitted change that treats an empty value as present (`${!name+x}` in place of `${!name-}`), this file gave 2 failed (the two empty cases: the check exited 0 after calling gcloud); against the committed script, 9 passed. Co-Authored-By: Claude Opus 5.5 --- frontend/tests/test_deploy_script.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/frontend/tests/test_deploy_script.py b/frontend/tests/test_deploy_script.py index 7a87b061..9d34a110 100644 --- a/frontend/tests/test_deploy_script.py +++ b/frontend/tests/test_deploy_script.py @@ -159,9 +159,17 @@ def test_missing_read_secret_stops_deployment(tmp_path): @pytest.mark.parametrize("missing_name", ["PROJECT_ID", "READ_SECRET_NAME"]) -def test_read_secret_check_requires_its_configuration(tmp_path, missing_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"} - configuration.pop(missing_name) + 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 From df1e4baa88fb9fb7615225f7920c2371ab9e0976 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Wed, 21 Oct 2026 03:37:48 +0400 Subject: [PATCH 8/8] Say that the collector address may also use HTTP on 127.0.0.1 telemetry-collector-client.ts accepts plain HTTP for both localhost and 127.0.0.1, but the settings table named only localhost. Documentation only, so no red step. Co-Authored-By: Claude Opus 5.5 --- docs/build-monitor.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/build-monitor.md b/docs/build-monitor.md index 96699994..566d1d9b 100644 --- a/docs/build-monitor.md +++ b/docs/build-monitor.md @@ -60,7 +60,7 @@ 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`. | 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_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. |