diff --git a/.github/scripts/cloud-run-simulation-entry-smoke.sh b/.github/scripts/cloud-run-simulation-entry-smoke.sh new file mode 100755 index 000000000..d25d937bb --- /dev/null +++ b/.github/scripts/cloud-run-simulation-entry-smoke.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash + +set -euo pipefail + +base_url="${1:?Simulation Entrypoint base URL is required}" +base_url="${base_url%/}" +expected_revision="${2:-}" + +health_headers="$(mktemp)" +health_body="$(mktemp)" +trap 'rm -f "${health_headers}" "${health_body}"' EXIT + +curl --fail --silent --show-error \ + --dump-header "${health_headers}" \ + --output "${health_body}" \ + "${base_url}/health" +jq -e '.status == "healthy"' "${health_body}" >/dev/null + +if [ -n "${expected_revision}" ]; then + actual_revision="$( + awk ' + tolower($1) == "x-policyengine-simulation-revision:" { + gsub("\r", "", $2) + print $2 + } + ' "${health_headers}" | + tail -n 1 + )" + if [ "${actual_revision}" != "${expected_revision}" ]; then + printf 'Expected revision %s at %s, received %s\n' \ + "${expected_revision}" "${base_url}" "${actual_revision:-no revision header}" >&2 + exit 1 + fi +fi + +curl --fail --silent --show-error "${base_url}/ready" | + jq -e '.status == "ready"' >/dev/null +curl --fail --silent --show-error "${base_url}/versions" >/dev/null +curl --fail --silent --show-error \ + --request POST \ + --header "Content-Type: application/json" \ + --data '{"value": 1}' \ + "${base_url}/ping" | + jq -e '.incremented == 2' >/dev/null diff --git a/.github/scripts/modal-deploy-app.sh b/.github/scripts/modal-deploy-app.sh deleted file mode 100755 index 1ba1da99b..000000000 --- a/.github/scripts/modal-deploy-app.sh +++ /dev/null @@ -1,70 +0,0 @@ -#!/bin/bash -# Deploy simulation API to Modal -# Usage: ./modal-deploy-app.sh [force-latest] -# Required env vars: POLICYENGINE_VERSION, POLICYENGINE_CORE_VERSION, -# POLICYENGINE_US_VERSION, POLICYENGINE_UK_VERSION -# These should come from the bundled policyengine.py release manifest. -# -# Deploys two apps: -# 1. policyengine-simulation-gateway - Stable gateway with fixed URL -# 2. policyengine-simulation-py{X} - Versioned simulation app - -set -euo pipefail - -MODAL_ENV="${1:?Modal environment required}" -FORCE_LATEST="${2:-false}" - -# Generate versioned simulation app name (dots replaced with dashes for URL safety) -POLICYENGINE_VERSION_SAFE="${POLICYENGINE_VERSION//./-}" -SIMULATION_APP_NAME="policyengine-simulation-py${POLICYENGINE_VERSION_SAFE}" -UPDATE_REGISTRY_COMMAND=( - uv run python -m src.modal.utils.update_version_registry - --app-name "$SIMULATION_APP_NAME" - --policyengine-version "${POLICYENGINE_VERSION}" - --us-version "${POLICYENGINE_US_VERSION}" - --uk-version "${POLICYENGINE_UK_VERSION}" - --environment "$MODAL_ENV" -) -if [[ "$FORCE_LATEST" == "true" || "$FORCE_LATEST" == "1" ]]; then - UPDATE_REGISTRY_COMMAND+=(--force-latest) -fi - -echo "========================================" -echo "Deploying to Modal environment: $MODAL_ENV" -echo " policyengine.py version: ${POLICYENGINE_VERSION}" -echo " policyengine-core version: ${POLICYENGINE_CORE_VERSION}" -echo " US version: ${POLICYENGINE_US_VERSION}" -echo " UK version: ${POLICYENGINE_UK_VERSION}" -echo " Force latest: ${FORCE_LATEST}" -echo "========================================" - -# 1. Deploy the gateway app (stable URL) from its own project -echo "" -echo "Step 1: Deploying gateway app..." -echo " App name: policyengine-simulation-gateway" -REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -( - cd "$REPO_ROOT/projects/policyengine-simulation-gateway" - uv run modal deploy --env="$MODAL_ENV" \ - src/policyengine_simulation_gateway/app.py -) - -# 2. Deploy the versioned simulation app -echo "" -echo "Step 2: Deploying versioned simulation app..." -echo " App name: ${SIMULATION_APP_NAME}" -export MODAL_APP_NAME="$SIMULATION_APP_NAME" -uv run modal deploy --env="$MODAL_ENV" src/modal/app.py - -# 3. Publish active routing state -echo "" -echo "Step 3: Publishing active routing state..." -"${UPDATE_REGISTRY_COMMAND[@]}" - -echo "" -echo "========================================" -echo "Deployment complete!" -echo " Gateway app: policyengine-simulation-gateway" -echo " Simulation app: $SIMULATION_APP_NAME" -echo " Routing state: simulation-api-routing-state[active]" -echo "========================================" diff --git a/.github/scripts/set-cloud-run-simulation-entry-revision.sh b/.github/scripts/set-cloud-run-simulation-entry-revision.sh new file mode 100755 index 000000000..39abee16e --- /dev/null +++ b/.github/scripts/set-cloud-run-simulation-entry-revision.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash + +# Assign all stable Cloud Run service traffic to one exact, ready revision. +# The expected-current guard prevents this workflow from overwriting a traffic +# change made after its candidate deployment. The same command supports +# rollback by swapping the target and expected-current revisions. + +set -euo pipefail + +gcloud_bin="${GCLOUD_BIN:-gcloud}" +project_id="${SIMULATION_ENTRYPOINT_GCP_PROJECT_ID:?SIMULATION_ENTRYPOINT_GCP_PROJECT_ID is required}" +region="${SIMULATION_ENTRYPOINT_GCP_REGION:-us-central1}" +service="${SIMULATION_ENTRYPOINT_SERVICE:?SIMULATION_ENTRYPOINT_SERVICE is required}" +target_revision="${SIMULATION_ENTRYPOINT_TARGET_REVISION:?SIMULATION_ENTRYPOINT_TARGET_REVISION is required}" +expected_current_revision="${SIMULATION_ENTRYPOINT_EXPECTED_CURRENT_REVISION:?SIMULATION_ENTRYPOINT_EXPECTED_CURRENT_REVISION is required}" + +for revision in "${target_revision}" "${expected_current_revision}"; do + if [ "${revision}" = "LATEST" ] || [ "${revision}" = "latest" ]; then + printf 'Traffic revisions must be exact; LATEST is not allowed\n' >&2 + exit 2 + fi +done + +active_revision() { + jq -er ' + [ + .status.traffic[]? + | select((.percent // 0) == 100) + | .revisionName + | select(type == "string" and length > 0) + ] + | if length == 1 then .[0] + else error("service must have exactly one revision at 100 percent") + end + ' +} + +service_json="$("${gcloud_bin}" run services describe "${service}" \ + --project "${project_id}" \ + --region "${region}" \ + --format=json)" +current_revision="$(active_revision <<<"${service_json}")" + +if [ "${current_revision}" != "${expected_current_revision}" ]; then + printf 'Stable traffic changed after deployment: expected %s, found %s\n' \ + "${expected_current_revision}" "${current_revision}" >&2 + exit 2 +fi + +revision_json="$("${gcloud_bin}" run revisions describe "${target_revision}" \ + --project "${project_id}" \ + --region "${region}" \ + --format=json)" +revision_service="$(jq -r \ + '.metadata.labels["serving.knative.dev/service"] // empty' \ + <<<"${revision_json}")" + +if [ "${revision_service}" != "${service}" ]; then + printf 'Revision %s belongs to %s, not %s\n' \ + "${target_revision}" "${revision_service:-an unknown service}" "${service}" >&2 + exit 2 +fi + +if ! jq -e \ + '.status.conditions[]? | select(.type == "Ready" and .status == "True")' \ + >/dev/null <<<"${revision_json}"; then + printf 'Revision %s is not Ready\n' "${target_revision}" >&2 + exit 2 +fi + +"${gcloud_bin}" run services update-traffic "${service}" \ + --project "${project_id}" \ + --region "${region}" \ + --to-revisions "${target_revision}=100" + +updated_service_json="$("${gcloud_bin}" run services describe "${service}" \ + --project "${project_id}" \ + --region "${region}" \ + --format=json)" +updated_revision="$(active_revision <<<"${updated_service_json}")" + +if [ "${updated_revision}" != "${target_revision}" ]; then + printf 'Traffic update did not activate %s; found %s\n' \ + "${target_revision}" "${updated_revision}" >&2 + exit 2 +fi diff --git a/.github/scripts/modal-deployment-summary.sh b/.github/scripts/simulation-deployment-summary.sh similarity index 56% rename from .github/scripts/modal-deployment-summary.sh rename to .github/scripts/simulation-deployment-summary.sh index f31e62a44..a332e259a 100755 --- a/.github/scripts/modal-deployment-summary.sh +++ b/.github/scripts/simulation-deployment-summary.sh @@ -1,6 +1,6 @@ #!/bin/bash # Generate deployment summary for GitHub Actions -# Usage: ./modal-deployment-summary.sh +# Usage: ./simulation-deployment-summary.sh set -euo pipefail @@ -10,13 +10,13 @@ PROD_RESULT="${3:-skipped}" PROD_URL="${4:-}" { - echo "## Modal Deployment Summary" + echo "## Simulation API Deployment Summary" echo "" case "$BETA_RESULT" in success) echo "✅ **Beta deployment**: Success" - [ -n "$BETA_URL" ] && echo " - URL: $BETA_URL" + [ -n "$BETA_URL" ] && echo " - Candidate URL: $BETA_URL" ;; skipped) echo "⏭️ **Beta deployment**: Skipped" @@ -30,14 +30,14 @@ PROD_URL="${4:-}" case "$PROD_RESULT" in success) - echo "✅ **Production deployment**: Success" - [ -n "$PROD_URL" ] && echo " - URL: $PROD_URL" + echo "✅ **Prod deployment**: Success" + [ -n "$PROD_URL" ] && echo " - Stable URL: $PROD_URL" ;; skipped) - echo "⏭️ **Production deployment**: Skipped" + echo "⏭️ **Prod deployment**: Skipped" ;; *) - echo "❌ **Production deployment**: $PROD_RESULT" + echo "❌ **Prod deployment**: $PROD_RESULT" ;; esac } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/scripts/modal-run-integ-tests.sh b/.github/scripts/simulation-run-integ-tests.sh similarity index 72% rename from .github/scripts/modal-run-integ-tests.sh rename to .github/scripts/simulation-run-integ-tests.sh index 16ff0dae3..c4426b469 100755 --- a/.github/scripts/modal-run-integ-tests.sh +++ b/.github/scripts/simulation-run-integ-tests.sh @@ -1,6 +1,6 @@ #!/bin/bash -# Run simulation integration tests -# Usage: ./modal-run-integ-tests.sh [us-version] [uk-version] +# Run integration tests through the deployed Simulation Entrypoint. +# Usage: ./simulation-run-integ-tests.sh [us-version] [uk-version] # Environment: beta runs all tests, prod excludes beta_only tests set -euo pipefail @@ -17,15 +17,15 @@ truthy() { esac } -GATEWAY_AUTH_VARS=( - GATEWAY_AUTH_ISSUER - GATEWAY_AUTH_AUDIENCE - GATEWAY_AUTH_CLIENT_ID - GATEWAY_AUTH_CLIENT_SECRET +TEST_AUTH_VARS=( + SIMULATION_TEST_AUTH_ISSUER + SIMULATION_TEST_AUTH_AUDIENCE + SIMULATION_TEST_AUTH_CLIENT_ID + SIMULATION_TEST_AUTH_CLIENT_SECRET ) present=() missing=() -for var in "${GATEWAY_AUTH_VARS[@]}"; do +for var in "${TEST_AUTH_VARS[@]}"; do if [ -n "${!var:-}" ]; then present+=("$var") else @@ -34,36 +34,36 @@ for var in "${GATEWAY_AUTH_VARS[@]}"; do done if [ ${#present[@]} -gt 0 ] && [ ${#missing[@]} -gt 0 ]; then - echo "Gateway auth integration-test config is partial." >&2 + echo "Simulation integration-test auth config is partial." >&2 echo " Present: ${present[*]-}" >&2 echo " Missing: ${missing[*]-}" >&2 exit 1 fi SHOULD_MINT_TOKEN=0 -if truthy "${GATEWAY_AUTH_REQUIRED:-}"; then +if truthy "${SIMULATION_TEST_AUTH_REQUIRED:-}"; then if [ ${#missing[@]} -gt 0 ]; then - echo "GATEWAY_AUTH_REQUIRED is enabled but integration-test auth secrets are missing." >&2 + echo "SIMULATION_TEST_AUTH_REQUIRED is enabled but auth secrets are missing." >&2 echo " Missing: ${missing[*]-}" >&2 exit 1 fi SHOULD_MINT_TOKEN=1 -elif [ ${#present[@]} -eq ${#GATEWAY_AUTH_VARS[@]} ]; then +elif [ ${#present[@]} -eq ${#TEST_AUTH_VARS[@]} ]; then SHOULD_MINT_TOKEN=1 fi ACCESS_TOKEN="" if [ "$SHOULD_MINT_TOKEN" -eq 1 ]; then - ISSUER="${GATEWAY_AUTH_ISSUER%/}" + ISSUER="${SIMULATION_TEST_AUTH_ISSUER%/}" TOKEN_URL="$ISSUER/oauth/token" # Build the token-request JSON with Python so that any ", \, or newline in # the client secret is encoded correctly (Auth0-generated secrets are random # strings that routinely contain characters that break a shell heredoc). TOKEN_REQUEST_JSON=$( - CLIENT_ID="$GATEWAY_AUTH_CLIENT_ID" \ - CLIENT_SECRET="$GATEWAY_AUTH_CLIENT_SECRET" \ - AUDIENCE="$GATEWAY_AUTH_AUDIENCE" \ + CLIENT_ID="$SIMULATION_TEST_AUTH_CLIENT_ID" \ + CLIENT_SECRET="$SIMULATION_TEST_AUTH_CLIENT_SECRET" \ + AUDIENCE="$SIMULATION_TEST_AUTH_AUDIENCE" \ python3 -c ' import json, os print(json.dumps({ @@ -100,12 +100,12 @@ print(token) fi cd projects/policyengine-apis-integ -uv sync --extra test +uv sync --extra test --frozen export simulation_integ_test_base_url="$BASE_URL" -if [ -n "${GATEWAY_AUTH_REQUIRED:-}" ]; then - export simulation_integ_test_gateway_auth_required="$GATEWAY_AUTH_REQUIRED" +if [ -n "${SIMULATION_TEST_AUTH_REQUIRED:-}" ]; then + export simulation_integ_test_gateway_auth_required="$SIMULATION_TEST_AUTH_REQUIRED" fi if [ -n "$ACCESS_TOKEN" ]; then diff --git a/.github/workflows/modal-deploy.reusable.yml b/.github/workflows/modal-deploy.reusable.yml deleted file mode 100644 index e9fd4e77d..000000000 --- a/.github/workflows/modal-deploy.reusable.yml +++ /dev/null @@ -1,147 +0,0 @@ -name: Reusable Modal deploy workflow - -on: - workflow_call: - inputs: - environment: - required: true - type: string - description: 'The environment to deploy to (e.g., beta, prod)' - modal_environment: - required: true - type: string - description: 'The Modal environment name (e.g., staging, main)' - force_latest: - required: false - default: false - type: boolean - description: 'Force latest pointers to this deployment' - outputs: - simulation_api_url: - description: 'The deployed simulation API URL' - value: ${{ jobs.deploy.outputs.simulation_api_url }} - policyengine_version: - description: 'The deployed policyengine.py package version' - value: ${{ jobs.deploy.outputs.policyengine_version }} - policyengine_core_version: - description: 'The deployed policyengine-core package version' - value: ${{ jobs.deploy.outputs.policyengine_core_version }} - us_version: - description: 'The deployed policyengine-us package version' - value: ${{ jobs.deploy.outputs.us_version }} - us_data_version: - description: 'The bundled US data release version' - value: ${{ jobs.deploy.outputs.us_data_version }} - uk_version: - description: 'The deployed policyengine-uk package version' - value: ${{ jobs.deploy.outputs.uk_version }} - uk_data_version: - description: 'The bundled UK data release version' - value: ${{ jobs.deploy.outputs.uk_data_version }} - -jobs: - deploy: - name: Deploy to Modal - runs-on: ubuntu-latest - environment: ${{ inputs.environment }} - outputs: - simulation_api_url: ${{ steps.get-url.outputs.simulation_api_url }} - policyengine_version: ${{ steps.versions.outputs.policyengine_version }} - policyengine_core_version: ${{ steps.versions.outputs.policyengine_core_version }} - us_version: ${{ steps.versions.outputs.us_version }} - us_data_version: ${{ steps.versions.outputs.us_data_version }} - uk_version: ${{ steps.versions.outputs.uk_version }} - uk_data_version: ${{ steps.versions.outputs.uk_data_version }} - - steps: - - name: Checkout repo - uses: actions/checkout@v6 - - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: "3.13" - - - name: Install uv - uses: astral-sh/setup-uv@v8.1.0 - - - name: Make scripts executable - run: chmod +x .github/scripts/*.sh - - - name: Install dependencies - working-directory: projects/policyengine-simulation-executor - run: uv sync - - - name: Extract package versions - id: versions - run: .github/scripts/modal-extract-versions.sh projects/policyengine-simulation-executor - - - name: Sync Modal secrets from GitHub - working-directory: projects/policyengine-simulation-executor - env: - MODAL_TOKEN_ID: ${{ secrets.MODAL_TOKEN_ID }} - MODAL_TOKEN_SECRET: ${{ secrets.MODAL_TOKEN_SECRET }} - LOGFIRE_TOKEN: ${{ secrets.LOGFIRE_TOKEN }} - HF_TOKEN: ${{ secrets.HF_TOKEN }} - GCP_CREDENTIALS_JSON: ${{ secrets.GCP_CREDENTIALS_JSON }} - GATEWAY_AUTH_ISSUER: ${{ secrets.GATEWAY_AUTH_ISSUER }} - GATEWAY_AUTH_AUDIENCE: ${{ secrets.GATEWAY_AUTH_AUDIENCE }} - GATEWAY_AUTH_CLIENT_ID: ${{ secrets.GATEWAY_AUTH_CLIENT_ID }} - GATEWAY_AUTH_CLIENT_SECRET: ${{ secrets.GATEWAY_AUTH_CLIENT_SECRET }} - GATEWAY_AUTH_REQUIRED: ${{ vars.GATEWAY_AUTH_REQUIRED }} - run: ../../.github/scripts/modal-sync-secrets.sh "${{ inputs.modal_environment }}" "${{ inputs.environment }}" - - - name: Deploy simulation API to Modal - working-directory: projects/policyengine-simulation-executor - env: - MODAL_TOKEN_ID: ${{ secrets.MODAL_TOKEN_ID }} - MODAL_TOKEN_SECRET: ${{ secrets.MODAL_TOKEN_SECRET }} - POLICYENGINE_VERSION: ${{ steps.versions.outputs.policyengine_version }} - POLICYENGINE_CORE_VERSION: ${{ steps.versions.outputs.policyengine_core_version }} - POLICYENGINE_US_VERSION: ${{ steps.versions.outputs.us_version }} - POLICYENGINE_UK_VERSION: ${{ steps.versions.outputs.uk_version }} - run: ../../.github/scripts/modal-deploy-app.sh "${{ inputs.modal_environment }}" "${{ inputs.force_latest }}" - - - name: Get deployed URL - id: get-url - working-directory: projects/policyengine-simulation-executor - env: - MODAL_TOKEN_ID: ${{ secrets.MODAL_TOKEN_ID }} - MODAL_TOKEN_SECRET: ${{ secrets.MODAL_TOKEN_SECRET }} - run: ../../.github/scripts/modal-get-url.sh "${{ inputs.modal_environment }}" - - - name: Verify deployment health - run: .github/scripts/modal-health-check.sh "${{ steps.get-url.outputs.simulation_api_url }}" - - integ_test: - name: Run integration tests - needs: [deploy] - runs-on: ubuntu-latest - environment: ${{ inputs.environment }} - - steps: - - name: Checkout repo - uses: actions/checkout@v6 - - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: "3.13" - - - name: Install uv - uses: astral-sh/setup-uv@v8.1.0 - - - name: Make scripts executable - run: chmod +x .github/scripts/*.sh - - - name: Generate API clients - run: ./scripts/generate-clients.sh - - - name: Run simulation integration tests - env: - GATEWAY_AUTH_ISSUER: ${{ secrets.GATEWAY_AUTH_ISSUER }} - GATEWAY_AUTH_AUDIENCE: ${{ secrets.GATEWAY_AUTH_AUDIENCE }} - GATEWAY_AUTH_CLIENT_ID: ${{ secrets.GATEWAY_AUTH_CLIENT_ID }} - GATEWAY_AUTH_CLIENT_SECRET: ${{ secrets.GATEWAY_AUTH_CLIENT_SECRET }} - GATEWAY_AUTH_REQUIRED: ${{ vars.GATEWAY_AUTH_REQUIRED }} - run: .github/scripts/modal-run-integ-tests.sh "${{ inputs.environment }}" "${{ needs.deploy.outputs.simulation_api_url }}" "${{ needs.deploy.outputs.us_version }}" "${{ needs.deploy.outputs.uk_version }}" diff --git a/.github/workflows/modal-deploy.yml b/.github/workflows/modal-deploy.yml deleted file mode 100644 index 42dcad834..000000000 --- a/.github/workflows/modal-deploy.yml +++ /dev/null @@ -1,93 +0,0 @@ -name: Deploy Simulation API to Modal - -on: - push: - branches: - - main - workflow_dispatch: - inputs: - skip_beta: - description: 'Skip beta deployment and deploy directly to prod' - required: false - default: false - type: boolean - force_latest: - description: 'Force latest pointers to this deployment, for intentional rollbacks' - required: false - default: false - type: boolean - -concurrency: - group: modal-deploy-main - -jobs: - setup_environments: - name: Setup Modal environments - runs-on: ubuntu-latest - steps: - - name: Checkout repo - uses: actions/checkout@v6 - - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: "3.13" - - - name: Install uv - uses: astral-sh/setup-uv@v8.1.0 - - - name: Make scripts executable - run: chmod +x .github/scripts/*.sh - - - name: Install dependencies - working-directory: projects/policyengine-simulation-executor - run: uv sync - - - name: Ensure Modal environments exist - working-directory: projects/policyengine-simulation-executor - env: - MODAL_TOKEN_ID: ${{ secrets.MODAL_TOKEN_ID }} - MODAL_TOKEN_SECRET: ${{ secrets.MODAL_TOKEN_SECRET }} - run: ../../.github/scripts/modal-setup-environments.sh - - deploy_beta: - name: Deploy to beta - needs: [setup_environments] - if: ${{ !inputs.skip_beta }} - uses: ./.github/workflows/modal-deploy.reusable.yml - with: - environment: beta - modal_environment: staging - force_latest: ${{ inputs.force_latest || false }} - secrets: inherit - - deploy_prod: - name: Deploy to production - needs: [deploy_beta] - if: ${{ always() && (needs.deploy_beta.result == 'success' || inputs.skip_beta) }} - uses: ./.github/workflows/modal-deploy.reusable.yml - with: - environment: prod - modal_environment: main - force_latest: ${{ inputs.force_latest || false }} - secrets: inherit - - summary: - name: Deployment summary - needs: [deploy_beta, deploy_prod] - if: always() - runs-on: ubuntu-latest - steps: - - name: Checkout repo - uses: actions/checkout@v6 - - - name: Make scripts executable - run: chmod +x .github/scripts/*.sh - - - name: Report status - run: | - .github/scripts/modal-deployment-summary.sh \ - "${{ needs.deploy_beta.result }}" \ - "${{ needs.deploy_beta.outputs.simulation_api_url }}" \ - "${{ needs.deploy_prod.result }}" \ - "${{ needs.deploy_prod.outputs.simulation_api_url }}" diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 90e95d4c9..496fcb1f8 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -15,6 +15,7 @@ jobs: # Modal image installs with uv_sync(frozen=True). project: - projects/policyengine-simulation-executor + - projects/policyengine-simulation-entry - projects/policyengine-simulation-gateway - libs/policyengine-simulation-contract - libs/policyengine-simulation-observability @@ -75,7 +76,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - service: [simulation-executor] + service: [simulation-entry, simulation-executor] steps: - uses: actions/checkout@v6 diff --git a/.github/workflows/publish-clients.yml b/.github/workflows/publish-clients.yml index 1f7667ea2..3cc3e8faa 100644 --- a/.github/workflows/publish-clients.yml +++ b/.github/workflows/publish-clients.yml @@ -2,7 +2,7 @@ name: Publish API clients on: workflow_run: - workflows: ["Deploy Simulation API to Modal"] + workflows: ["Deploy Simulation API"] types: - completed branches: @@ -13,11 +13,13 @@ jobs: publish: name: Publish API clients runs-on: ubuntu-latest - if: ${{ github.event.workflow_run.conclusion == 'success' }} + if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' }} steps: - name: Checkout repo uses: actions/checkout@v6 + with: + ref: ${{ github.event.workflow_run.head_sha }} - name: Set up Python uses: actions/setup-python@v6 @@ -31,9 +33,9 @@ jobs: run: | ./scripts/generate-clients.sh - - name: Build simulation API client + - name: Build simulation API client from the entrypoint contract run: | - cd projects/policyengine-simulation-gateway/artifacts/clients/python + cd projects/policyengine-simulation-entry/artifacts/clients/python # Update version DATE=$(date +%Y%m%d) RUN_NUMBER="${{ github.run_number }}" @@ -45,4 +47,4 @@ jobs: uses: pypa/gh-action-pypi-publish@release/v1 with: password: ${{ secrets.PYPI }} - packages-dir: projects/policyengine-simulation-gateway/artifacts/clients/python/dist/ + packages-dir: projects/policyengine-simulation-entry/artifacts/clients/python/dist/ diff --git a/.github/workflows/simulation-deploy.reusable.yml b/.github/workflows/simulation-deploy.reusable.yml new file mode 100644 index 000000000..9823587d1 --- /dev/null +++ b/.github/workflows/simulation-deploy.reusable.yml @@ -0,0 +1,530 @@ +name: Reusable simulation deployment + +on: + workflow_call: + inputs: + release_environment: + required: true + type: string + description: "GitHub environment for Modal deployment secrets" + modal_environment: + required: true + type: string + description: "Modal environment name" + entrypoint_service: + required: true + type: string + description: "Cloud Run service name" + entrypoint_revision_prefix: + required: true + type: string + description: "Prefix for the no-traffic Cloud Run revision tag" + entrypoint_min_instances: + required: true + type: number + entrypoint_max_instances: + required: true + type: number + promote_entrypoint: + required: true + type: boolean + description: "Assign stable service traffic after all environment tests pass" + force_latest: + required: false + default: false + type: boolean + description: "Force latest routing pointers for an intentional rollback" + outputs: + simulation_api_url: + description: "Tagged Cloud Run candidate URL qualified by this deployment" + value: ${{ jobs.deploy_entrypoint.outputs.candidate_url }} + stable_url: + description: "Stable Cloud Run service URL" + value: ${{ jobs.deploy_entrypoint.outputs.stable_url }} + entrypoint_revision: + description: "Exact Cloud Run revision qualified by this deployment" + value: ${{ jobs.deploy_entrypoint.outputs.revision }} + entrypoint_image: + description: "Image reference resolved on the deployed Cloud Run revision" + value: ${{ jobs.deploy_entrypoint.outputs.image }} + policyengine_version: + description: "Deployed policyengine.py package version" + value: ${{ jobs.prepare.outputs.policyengine_version }} + policyengine_core_version: + description: "Deployed policyengine-core package version" + value: ${{ jobs.prepare.outputs.policyengine_core_version }} + us_version: + description: "Deployed policyengine-us package version" + value: ${{ jobs.prepare.outputs.us_version }} + us_data_version: + description: "Bundled US data release version" + value: ${{ jobs.prepare.outputs.us_data_version }} + uk_version: + description: "Deployed policyengine-uk package version" + value: ${{ jobs.prepare.outputs.uk_version }} + uk_data_version: + description: "Bundled UK data release version" + value: ${{ jobs.prepare.outputs.uk_data_version }} + +permissions: + contents: read + +jobs: + prepare: + name: Prepare deployment inputs + runs-on: ubuntu-latest + environment: ${{ inputs.release_environment }} + outputs: + policyengine_version: ${{ steps.versions.outputs.policyengine_version }} + policyengine_core_version: ${{ steps.versions.outputs.policyengine_core_version }} + us_version: ${{ steps.versions.outputs.us_version }} + us_data_version: ${{ steps.versions.outputs.us_data_version }} + uk_version: ${{ steps.versions.outputs.uk_version }} + uk_data_version: ${{ steps.versions.outputs.uk_data_version }} + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-python@v6 + with: + python-version: "3.13" + + - uses: astral-sh/setup-uv@v8.1.0 + + - name: Install deployment dependencies + working-directory: projects/policyengine-simulation-executor + run: uv sync --frozen + + - name: Extract package versions + id: versions + run: .github/scripts/modal-extract-versions.sh projects/policyengine-simulation-executor + + - name: Synchronize Modal runtime secrets + working-directory: projects/policyengine-simulation-executor + env: + MODAL_TOKEN_ID: ${{ secrets.MODAL_TOKEN_ID }} + MODAL_TOKEN_SECRET: ${{ secrets.MODAL_TOKEN_SECRET }} + LOGFIRE_TOKEN: ${{ secrets.LOGFIRE_TOKEN }} + HF_TOKEN: ${{ secrets.HF_TOKEN }} + GCP_CREDENTIALS_JSON: ${{ secrets.GCP_CREDENTIALS_JSON }} + GATEWAY_AUTH_ISSUER: ${{ secrets.GATEWAY_AUTH_ISSUER }} + GATEWAY_AUTH_AUDIENCE: ${{ secrets.GATEWAY_AUTH_AUDIENCE }} + GATEWAY_AUTH_CLIENT_ID: ${{ secrets.GATEWAY_AUTH_CLIENT_ID }} + GATEWAY_AUTH_CLIENT_SECRET: ${{ secrets.GATEWAY_AUTH_CLIENT_SECRET }} + GATEWAY_AUTH_REQUIRED: ${{ vars.GATEWAY_AUTH_REQUIRED }} + run: ../../.github/scripts/modal-sync-secrets.sh "${{ inputs.modal_environment }}" "${{ inputs.release_environment }}" + + deploy_entrypoint: + name: Deploy and unit test Cloud Run entrypoint + needs: prepare + runs-on: ubuntu-latest + environment: ${{ inputs.release_environment }} + permissions: + contents: read + id-token: write + env: + PROJECT_DIR: projects/policyengine-simulation-entry + REGION: ${{ vars.SIMULATION_ENTRYPOINT_GCP_REGION || 'us-central1' }} + PROJECT_ID: ${{ vars.SIMULATION_ENTRYPOINT_GCP_PROJECT_ID }} + REPOSITORY: ${{ vars.SIMULATION_ENTRYPOINT_ARTIFACT_REPOSITORY || 'policyengine-simulation-entry' }} + IMAGE_NAME: policyengine-simulation-entry + outputs: + image: ${{ steps.candidate.outputs.image }} + revision_tag: ${{ steps.metadata.outputs.tag }} + revision: ${{ steps.candidate.outputs.revision }} + candidate_url: ${{ steps.candidate.outputs.url }} + stable_url: ${{ steps.previous.outputs.stable_url }} + previous_revision: ${{ steps.previous.outputs.revision }} + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-python@v6 + with: + python-version: "3.13" + + - uses: astral-sh/setup-uv@v8.1.0 + + - uses: google-github-actions/auth@v2 + with: + workload_identity_provider: ${{ secrets.SIMULATION_ENTRYPOINT_GCP_WIF_PROVIDER }} + service_account: ${{ secrets.SIMULATION_ENTRYPOINT_GCP_DEPLOY_SERVICE_ACCOUNT }} + + - uses: google-github-actions/setup-gcloud@v2 + + - name: Configure Artifact Registry authentication + run: gcloud auth configure-docker "${REGION}-docker.pkg.dev" --quiet + + - name: Capture stable service state before deployment + id: previous + run: | + service_json="$(gcloud run services describe "${{ inputs.entrypoint_service }}" \ + --project "${PROJECT_ID}" \ + --region "${REGION}" \ + --format=json)" + stable_url="$(jq -er '.status.url | select(type == "string" and length > 0)' <<<"${service_json}")" + revision="$(jq -er ' + [ + .status.traffic[]? + | select((.percent // 0) == 100) + | .revisionName + | select(type == "string" and length > 0) + ] + | if length == 1 then .[0] + else error("service must have exactly one revision at 100 percent") + end + ' <<<"${service_json}")" + { + echo "stable_url=${stable_url}" + echo "revision=${revision}" + } >> "$GITHUB_OUTPUT" + + - name: Select immutable image + id: image + run: echo "uri=${REGION}-docker.pkg.dev/${PROJECT_ID}/${REPOSITORY}/${IMAGE_NAME}:${GITHUB_SHA}" >> "$GITHUB_OUTPUT" + + - name: Build and push entrypoint image + run: | + docker build \ + --file "${PROJECT_DIR}/Dockerfile" \ + --tag "${{ steps.image.outputs.uri }}" \ + . + docker push "${{ steps.image.outputs.uri }}" + + - name: Select no-traffic revision tag + id: metadata + run: echo "tag=${{ inputs.entrypoint_revision_prefix }}${GITHUB_RUN_NUMBER}" >> "$GITHUB_OUTPUT" + + - name: Deploy Cloud Run entrypoint candidate + env: + IMAGE: ${{ steps.image.outputs.uri }} + TAG: ${{ steps.metadata.outputs.tag }} + run: | + gcloud run deploy "${{ inputs.entrypoint_service }}" \ + --project "${PROJECT_ID}" \ + --region "${REGION}" \ + --image "${IMAGE}" \ + --tag "${TAG}" \ + --no-traffic \ + --allow-unauthenticated \ + --service-account "${{ vars.SIMULATION_ENTRYPOINT_RUNTIME_SERVICE_ACCOUNT }}" \ + --set-env-vars "^@^APP_ENVIRONMENT=${{ inputs.release_environment }}@SIMULATION_ENTRYPOINT_AUTH_REQUIRED=1@SIMULATION_ENTRYPOINT_AUTH_ISSUER=${{ secrets.SIMULATION_ENTRYPOINT_AUTH_ISSUER }}@SIMULATION_ENTRYPOINT_AUTH_AUDIENCE=${{ secrets.SIMULATION_ENTRYPOINT_AUTH_AUDIENCE }}@OLD_GATEWAY_URL=${{ secrets.OLD_GATEWAY_URL }}@OLD_GATEWAY_AUTH_ISSUER=${{ secrets.OLD_GATEWAY_AUTH_ISSUER }}@OLD_GATEWAY_AUTH_AUDIENCE=${{ secrets.OLD_GATEWAY_AUTH_AUDIENCE }}@OLD_GATEWAY_AUTH_CLIENT_ID=${{ secrets.OLD_GATEWAY_AUTH_CLIENT_ID }}" \ + --set-secrets "OLD_GATEWAY_AUTH_CLIENT_SECRET=${{ vars.OLD_GATEWAY_AUTH_CLIENT_SECRET_SECRET_NAME }}:latest" \ + --cpu 1 \ + --memory 512Mi \ + --concurrency 80 \ + --timeout 60 \ + --min-instances "${{ inputs.entrypoint_min_instances }}" \ + --max-instances "${{ inputs.entrypoint_max_instances }}" + + - name: Resolve exact candidate metadata + id: candidate + env: + TAG: ${{ steps.metadata.outputs.tag }} + run: | + service_json="$(gcloud run services describe "${{ inputs.entrypoint_service }}" \ + --project "${PROJECT_ID}" \ + --region "${REGION}" \ + --format=json)" + url="$(jq -er --arg tag "${TAG}" ' + .status.traffic[] + | select(.tag == $tag) + | .url + | select(type == "string" and length > 0) + ' <<<"${service_json}")" + revision="$(jq -er --arg tag "${TAG}" ' + .status.traffic[] + | select(.tag == $tag) + | .revisionName + | select(type == "string" and length > 0) + ' <<<"${service_json}")" + revision_json="$(gcloud run revisions describe "${revision}" \ + --project "${PROJECT_ID}" \ + --region "${REGION}" \ + --format=json)" + image="$(jq -er ' + (.status.imageDigest // .spec.containers[0].image) + | select( + type == "string" + and contains("@sha256:") + ) + ' <<<"${revision_json}")" + { + echo "url=${url}" + echo "revision=${revision}" + echo "image=${image}" + } >> "$GITHUB_OUTPUT" + + - name: Install entrypoint unit-test dependencies + working-directory: ${{ env.PROJECT_DIR }} + run: uv sync --extra test --frozen + + - name: Run entrypoint unit tests + working-directory: ${{ env.PROJECT_DIR }} + run: uv run pytest tests/ -v + + deploy_gateway: + name: Deploy and unit test Modal gateway + needs: prepare + runs-on: ubuntu-latest + environment: ${{ inputs.release_environment }} + outputs: + url: ${{ steps.url.outputs.simulation_api_url }} + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-python@v6 + with: + python-version: "3.13" + + - uses: astral-sh/setup-uv@v8.1.0 + + - name: Install gateway dependencies + working-directory: projects/policyengine-simulation-gateway + run: uv sync --extra test --frozen + + - name: Deploy stable Modal gateway + working-directory: projects/policyengine-simulation-gateway + env: + MODAL_TOKEN_ID: ${{ secrets.MODAL_TOKEN_ID }} + MODAL_TOKEN_SECRET: ${{ secrets.MODAL_TOKEN_SECRET }} + run: uv run modal deploy --env="${{ inputs.modal_environment }}" src/policyengine_simulation_gateway/app.py + + - name: Run gateway unit tests + working-directory: projects/policyengine-simulation-gateway + run: uv run pytest tests/ -v + + - name: Resolve gateway URL + id: url + working-directory: projects/policyengine-simulation-executor + env: + MODAL_TOKEN_ID: ${{ secrets.MODAL_TOKEN_ID }} + MODAL_TOKEN_SECRET: ${{ secrets.MODAL_TOKEN_SECRET }} + run: ../../.github/scripts/modal-get-url.sh "${{ inputs.modal_environment }}" + + - name: Verify gateway health + run: .github/scripts/modal-health-check.sh "${{ steps.url.outputs.simulation_api_url }}" + + deploy_executor: + name: Deploy and unit test Modal executor + needs: prepare + runs-on: ubuntu-latest + environment: ${{ inputs.release_environment }} + outputs: + app_name: ${{ steps.app.outputs.name }} + env: + POLICYENGINE_VERSION: ${{ needs.prepare.outputs.policyengine_version }} + POLICYENGINE_CORE_VERSION: ${{ needs.prepare.outputs.policyengine_core_version }} + POLICYENGINE_US_VERSION: ${{ needs.prepare.outputs.us_version }} + POLICYENGINE_UK_VERSION: ${{ needs.prepare.outputs.uk_version }} + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-python@v6 + with: + python-version: "3.13" + + - uses: astral-sh/setup-uv@v8.1.0 + + - name: Install executor dependencies + working-directory: projects/policyengine-simulation-executor + run: uv sync --extra test --frozen + + - name: Select versioned executor app + id: app + run: | + version_safe="${POLICYENGINE_VERSION//./-}" + echo "name=policyengine-simulation-py${version_safe}" >> "$GITHUB_OUTPUT" + + - name: Deploy versioned Modal executor + working-directory: projects/policyengine-simulation-executor + env: + MODAL_TOKEN_ID: ${{ secrets.MODAL_TOKEN_ID }} + MODAL_TOKEN_SECRET: ${{ secrets.MODAL_TOKEN_SECRET }} + MODAL_APP_NAME: ${{ steps.app.outputs.name }} + run: uv run modal deploy --env="${{ inputs.modal_environment }}" src/modal/app.py + + - name: Run executor unit tests + working-directory: projects/policyengine-simulation-executor + run: uv run pytest tests/ -v + + update_routing: + name: Publish routing after all services pass + needs: [prepare, deploy_entrypoint, deploy_gateway, deploy_executor] + runs-on: ubuntu-latest + environment: ${{ inputs.release_environment }} + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-python@v6 + with: + python-version: "3.13" + + - uses: astral-sh/setup-uv@v8.1.0 + + - name: Install routing publisher dependencies + working-directory: projects/policyengine-simulation-executor + run: uv sync --frozen + + - name: Publish active routing state + working-directory: projects/policyengine-simulation-executor + env: + MODAL_TOKEN_ID: ${{ secrets.MODAL_TOKEN_ID }} + MODAL_TOKEN_SECRET: ${{ secrets.MODAL_TOKEN_SECRET }} + APP_NAME: ${{ needs.deploy_executor.outputs.app_name }} + POLICYENGINE_VERSION: ${{ needs.prepare.outputs.policyengine_version }} + POLICYENGINE_US_VERSION: ${{ needs.prepare.outputs.us_version }} + POLICYENGINE_UK_VERSION: ${{ needs.prepare.outputs.uk_version }} + FORCE_LATEST: ${{ inputs.force_latest }} + run: | + args=( + --app-name "${APP_NAME}" + --policyengine-version "${POLICYENGINE_VERSION}" + --us-version "${POLICYENGINE_US_VERSION}" + --uk-version "${POLICYENGINE_UK_VERSION}" + --environment "${{ inputs.modal_environment }}" + ) + if [[ "${FORCE_LATEST}" == "true" ]]; then + args+=(--force-latest) + fi + uv run python -m src.modal.utils.update_version_registry "${args[@]}" + + integration: + name: Run three-service integration tests + needs: [prepare, deploy_entrypoint, deploy_gateway, deploy_executor, update_routing] + runs-on: ubuntu-latest + environment: ${{ inputs.release_environment }} + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-python@v6 + with: + python-version: "3.13" + + - uses: astral-sh/setup-uv@v8.1.0 + + - name: Verify the complete deployed request path + run: .github/scripts/cloud-run-simulation-entry-smoke.sh "${{ needs.deploy_entrypoint.outputs.candidate_url }}" "${{ needs.deploy_entrypoint.outputs.revision }}" + + - name: Generate API clients + run: ./scripts/generate-clients.sh + + - name: Run integration tests through entrypoint, gateway, and executor + env: + SIMULATION_TEST_AUTH_REQUIRED: "1" + SIMULATION_TEST_AUTH_ISSUER: ${{ secrets.SIMULATION_ENTRYPOINT_AUTH_ISSUER }} + SIMULATION_TEST_AUTH_AUDIENCE: ${{ secrets.SIMULATION_ENTRYPOINT_AUTH_AUDIENCE }} + SIMULATION_TEST_AUTH_CLIENT_ID: ${{ secrets.SIMULATION_ENTRYPOINT_TEST_AUTH_CLIENT_ID }} + SIMULATION_TEST_AUTH_CLIENT_SECRET: ${{ secrets.SIMULATION_ENTRYPOINT_TEST_AUTH_CLIENT_SECRET }} + run: .github/scripts/simulation-run-integ-tests.sh "${{ inputs.release_environment }}" "${{ needs.deploy_entrypoint.outputs.candidate_url }}" "${{ needs.prepare.outputs.us_version }}" "${{ needs.prepare.outputs.uk_version }}" + + authenticated_test: + name: Run authenticated deployment tests + needs: [deploy_entrypoint, integration] + runs-on: ubuntu-latest + environment: ${{ inputs.release_environment }} + env: + SIMULATION_ENTRYPOINT_TEST_BASE_URL: ${{ needs.deploy_entrypoint.outputs.candidate_url }} + SIMULATION_ENTRYPOINT_TEST_AUTH_ISSUER: ${{ secrets.SIMULATION_ENTRYPOINT_AUTH_ISSUER }} + SIMULATION_ENTRYPOINT_TEST_AUTH_AUDIENCE: ${{ secrets.SIMULATION_ENTRYPOINT_AUTH_AUDIENCE }} + SIMULATION_ENTRYPOINT_TEST_AUTH_CLIENT_ID: ${{ secrets.SIMULATION_ENTRYPOINT_TEST_AUTH_CLIENT_ID }} + SIMULATION_ENTRYPOINT_TEST_AUTH_CLIENT_SECRET: ${{ secrets.SIMULATION_ENTRYPOINT_TEST_AUTH_CLIENT_SECRET }} + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-python@v6 + with: + python-version: "3.13" + + - uses: astral-sh/setup-uv@v8.1.0 + + - name: Install authenticated-test dependencies + working-directory: projects/policyengine-simulation-entry + run: uv sync --extra test --frozen + + - name: Run authenticated deployment tests + working-directory: projects/policyengine-simulation-entry + run: uv run pytest authenticated_tests/ -v + + promote_entrypoint: + name: Promote and verify Cloud Run entrypoint + if: ${{ inputs.promote_entrypoint }} + needs: [deploy_entrypoint, authenticated_test] + runs-on: ubuntu-latest + environment: ${{ inputs.release_environment }} + permissions: + contents: read + id-token: write + env: + REGION: ${{ vars.SIMULATION_ENTRYPOINT_GCP_REGION || 'us-central1' }} + PROJECT_ID: ${{ vars.SIMULATION_ENTRYPOINT_GCP_PROJECT_ID }} + SERVICE: ${{ inputs.entrypoint_service }} + TARGET_REVISION: ${{ needs.deploy_entrypoint.outputs.revision }} + PREVIOUS_REVISION: ${{ needs.deploy_entrypoint.outputs.previous_revision }} + STABLE_URL: ${{ needs.deploy_entrypoint.outputs.stable_url }} + CUSTOM_URL: ${{ vars.SIMULATION_ENTRYPOINT_CUSTOM_URL }} + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-python@v6 + with: + python-version: "3.13" + + - uses: astral-sh/setup-uv@v8.1.0 + + - name: Install stable-endpoint test dependencies + working-directory: projects/policyengine-simulation-entry + run: uv sync --extra test --frozen + + - uses: google-github-actions/auth@v2 + with: + workload_identity_provider: ${{ secrets.SIMULATION_ENTRYPOINT_GCP_WIF_PROVIDER }} + service_account: ${{ secrets.SIMULATION_ENTRYPOINT_GCP_DEPLOY_SERVICE_ACCOUNT }} + + - uses: google-github-actions/setup-gcloud@v2 + + - name: Promote exact tested revision + id: promote + env: + SIMULATION_ENTRYPOINT_GCP_PROJECT_ID: ${{ env.PROJECT_ID }} + SIMULATION_ENTRYPOINT_GCP_REGION: ${{ env.REGION }} + SIMULATION_ENTRYPOINT_SERVICE: ${{ env.SERVICE }} + SIMULATION_ENTRYPOINT_TARGET_REVISION: ${{ env.TARGET_REVISION }} + SIMULATION_ENTRYPOINT_EXPECTED_CURRENT_REVISION: ${{ env.PREVIOUS_REVISION }} + run: .github/scripts/set-cloud-run-simulation-entry-revision.sh + + - name: Verify stable service routing + run: .github/scripts/cloud-run-simulation-entry-smoke.sh "${STABLE_URL}" "${TARGET_REVISION}" + + - name: Verify stable service authentication + working-directory: projects/policyengine-simulation-entry + env: + SIMULATION_ENTRYPOINT_TEST_BASE_URL: ${{ env.STABLE_URL }} + SIMULATION_ENTRYPOINT_TEST_AUTH_ISSUER: ${{ secrets.SIMULATION_ENTRYPOINT_AUTH_ISSUER }} + SIMULATION_ENTRYPOINT_TEST_AUTH_AUDIENCE: ${{ secrets.SIMULATION_ENTRYPOINT_AUTH_AUDIENCE }} + SIMULATION_ENTRYPOINT_TEST_AUTH_CLIENT_ID: ${{ secrets.SIMULATION_ENTRYPOINT_TEST_AUTH_CLIENT_ID }} + SIMULATION_ENTRYPOINT_TEST_AUTH_CLIENT_SECRET: ${{ secrets.SIMULATION_ENTRYPOINT_TEST_AUTH_CLIENT_SECRET }} + run: uv run pytest authenticated_tests/ -v + + - name: Verify configured custom hostname + if: ${{ env.CUSTOM_URL != '' }} + run: .github/scripts/cloud-run-simulation-entry-smoke.sh "${CUSTOM_URL}" "${TARGET_REVISION}" + + - name: Verify configured custom-hostname authentication + if: ${{ env.CUSTOM_URL != '' }} + working-directory: projects/policyengine-simulation-entry + env: + SIMULATION_ENTRYPOINT_TEST_BASE_URL: ${{ env.CUSTOM_URL }} + SIMULATION_ENTRYPOINT_TEST_AUTH_ISSUER: ${{ secrets.SIMULATION_ENTRYPOINT_AUTH_ISSUER }} + SIMULATION_ENTRYPOINT_TEST_AUTH_AUDIENCE: ${{ secrets.SIMULATION_ENTRYPOINT_AUTH_AUDIENCE }} + SIMULATION_ENTRYPOINT_TEST_AUTH_CLIENT_ID: ${{ secrets.SIMULATION_ENTRYPOINT_TEST_AUTH_CLIENT_ID }} + SIMULATION_ENTRYPOINT_TEST_AUTH_CLIENT_SECRET: ${{ secrets.SIMULATION_ENTRYPOINT_TEST_AUTH_CLIENT_SECRET }} + run: uv run pytest authenticated_tests/ -v + + - name: Restore previous revision after failed verification + if: ${{ failure() && steps.promote.outcome == 'success' }} + env: + SIMULATION_ENTRYPOINT_GCP_PROJECT_ID: ${{ env.PROJECT_ID }} + SIMULATION_ENTRYPOINT_GCP_REGION: ${{ env.REGION }} + SIMULATION_ENTRYPOINT_SERVICE: ${{ env.SERVICE }} + SIMULATION_ENTRYPOINT_TARGET_REVISION: ${{ env.PREVIOUS_REVISION }} + SIMULATION_ENTRYPOINT_EXPECTED_CURRENT_REVISION: ${{ env.TARGET_REVISION }} + run: .github/scripts/set-cloud-run-simulation-entry-revision.sh diff --git a/.github/workflows/simulation-deploy.yml b/.github/workflows/simulation-deploy.yml new file mode 100644 index 000000000..0007c9f2c --- /dev/null +++ b/.github/workflows/simulation-deploy.yml @@ -0,0 +1,72 @@ +name: Deploy Simulation API + +on: + push: + branches: + - main + workflow_dispatch: + inputs: + force_latest: + description: "Force latest routing pointers for an intentional rollback" + required: false + default: false + type: boolean + +permissions: + contents: read + +concurrency: + group: simulation-deploy-main + cancel-in-progress: false + +jobs: + beta: + name: Complete beta deployment + permissions: + contents: read + id-token: write + uses: ./.github/workflows/simulation-deploy.reusable.yml + with: + release_environment: beta + modal_environment: staging + entrypoint_service: policyengine-simulation-entry-staging + entrypoint_revision_prefix: s + entrypoint_min_instances: 0 + entrypoint_max_instances: 2 + promote_entrypoint: false + force_latest: ${{ inputs.force_latest || false }} + secrets: inherit + + prod: + name: Complete prod deployment + needs: beta + permissions: + contents: read + id-token: write + uses: ./.github/workflows/simulation-deploy.reusable.yml + with: + release_environment: prod + modal_environment: main + entrypoint_service: policyengine-simulation-entry + entrypoint_revision_prefix: p + entrypoint_min_instances: 1 + entrypoint_max_instances: 20 + promote_entrypoint: true + force_latest: ${{ inputs.force_latest || false }} + secrets: inherit + + summary: + name: Deployment summary + needs: [beta, prod] + if: always() + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Report promotion status + run: | + .github/scripts/simulation-deployment-summary.sh \ + "${{ needs.beta.result }}" \ + "${{ needs.beta.outputs.simulation_api_url }}" \ + "${{ needs.prod.result }}" \ + "${{ needs.prod.outputs.stable_url }}" diff --git a/Makefile b/Makefile index c54ad07ee..4c14462a4 100644 --- a/Makefile +++ b/Makefile @@ -79,6 +79,7 @@ publish-clients: generate-clients test: @echo "Running unit tests for all projects and libs..." @for proj in projects/policyengine-simulation-executor \ + projects/policyengine-simulation-entry \ projects/policyengine-simulation-gateway \ libs/policyengine-simulation-contract \ libs/policyengine-simulation-observability; do \ diff --git a/README.md b/README.md index 6e8302c5d..4ec56987d 100644 --- a/README.md +++ b/README.md @@ -73,13 +73,34 @@ make generate-clients # regenerate the OpenAPI Python client ## Deployment -Deployment targets Modal and is automated through GitHub Actions — there is no -manual deploy step. On merge to `main`, the Modal deploy workflow -(`.github/workflows/modal-deploy.yml`): - -1. Deploys to the beta (staging) Modal environment and runs integration tests. -2. Deploys to the production Modal environment and runs integration tests. -3. Publishes the API client to PyPI (`.github/workflows/publish-clients.yml`). +Simulation workers and the old gateway continue to deploy to Modal. The +permanent Simulation Entrypoint deploys to Cloud Run as tagged no-traffic +candidates. Beta qualification uses Cloud Run's generated candidate URLs and +does not require a custom beta domain. On merge to `main`, +`.github/workflows/simulation-deploy.yml` releases one complete stack at a +time: + +1. prepare the environment's deployment inputs and runtime secrets; +2. deploy and unit test the Entrypoint, Modal gateway, and Modal executor in + three parallel service jobs; +3. publish routing only after all three service jobs pass; +4. run generated-client and live authentication tests through the complete + three-service request path; and +5. begin the `prod` deployment only after every `beta` deployment and + qualification job succeeds; +6. assign stable production traffic to the exact tested entrypoint revision + after the complete production suite passes; and +7. verify the stable endpoint, restoring its previous revision if immediate + post-promotion checks fail. + +A successful push-triggered full-stack deployment publishes the API client from +the exact deployed commit. + +The stable production service may have a separately managed custom hostname. +Domain ownership, DNS, TLS, and other one-time cloud setup remain operator +responsibilities outside committed deployment automation. Entrypoint traffic +promotion does not change API v1 revision traffic or its simulation-entrypoint +migration flag. The stable gateway app is `policyengine-simulation-gateway`; executors deploy as versioned `policyengine-simulation-py{version}` apps. For Modal image and deploy diff --git a/libs/policyengine-simulation-contract/pyproject.toml b/libs/policyengine-simulation-contract/pyproject.toml index a1708d7b9..6ed6b0f14 100644 --- a/libs/policyengine-simulation-contract/pyproject.toml +++ b/libs/policyengine-simulation-contract/pyproject.toml @@ -9,14 +9,24 @@ license = {file = "../../LICENSE"} requires-python = ">=3.13" dependencies = [ "pydantic>=2.0", - "modal>=1.4,<2", # gateway_models embeds the TelemetryEnvelope from the observability lib. "policyengine-simulation-observability", ] [project.optional-dependencies] -test = [ "pytest>=8.3.4", "pytest-asyncio>=0.25.3", "pytest-cov>=6.1.1",] -build = [ "pyright>=1.1.401", "black>=25.1.0",] +modal = [ + "modal>=1.4,<2", +] +test = [ + "pytest>=8.3.4", + "pytest-asyncio>=0.25.3", + "pytest-cov>=6.1.1", + "modal>=1.4,<2", +] +build = [ + "pyright>=1.1.401", + "black>=25.1.0", +] [build-system] requires = ["hatchling >= 1.26"] diff --git a/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/gateway_models.py b/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/gateway_models.py index 4762619e4..9ba41c34a 100644 --- a/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/gateway_models.py +++ b/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/gateway_models.py @@ -3,10 +3,19 @@ """ import json -from typing import Any, ClassVar, Literal, Optional - -from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator - +from typing import ClassVar, Literal, Optional + +from pydantic import ( + BaseModel, + ConfigDict, + Field, + RootModel, + field_validator, + model_validator, +) + +from policyengine_simulation_contract.json_types import JsonObject +from policyengine_simulation_contract.macro_output import SingleYearMacroOutput from policyengine_simulation_observability.telemetry import TelemetryEnvelope @@ -23,6 +32,10 @@ INTERNAL_PASSTHROUGH_FIELDS = frozenset({"_metadata", "_runtime_bundle"}) +class PolicyParameterChanges(RootModel[JsonObject]): + """Policy parameter names mapped to their dated values.""" + + def _move_internal_telemetry_alias(value): if not isinstance(value, dict): return value @@ -90,9 +103,8 @@ class GatewayRequestBase(BaseModel): scope: Optional[str] = None data: Optional[str] = None time_period: Optional[str] = None - reform: Optional[dict[str, Any]] = None - baseline: Optional[dict[str, Any]] = None - region: Optional[str] = None + reform: Optional[PolicyParameterChanges] = None + baseline: Optional[PolicyParameterChanges] = None region_group: Optional[list[str]] = None title: Optional[str] = None include_cliffs: Optional[bool] = None @@ -123,18 +135,20 @@ def strip_internal_passthrough_fields(cls, value): def enforce_max_payload_size(cls, value): return _enforce_max_payload_size(value) + +class SimulationRequest(GatewayRequestBase): + """Request model for simulation submission.""" + + region: Optional[str] = None + @model_validator(mode="after") - def region_xor_region_group(self): + def region_xor_region_group(self) -> "SimulationRequest": """A request scopes to a single region OR a region group, never both.""" if self.region is not None and self.region_group is not None: raise ValueError("Provide `region` or `region_group`, not both.") return self -class SimulationRequest(GatewayRequestBase): - """Request model for simulation submission.""" - - class PolicyEngineBundle(BaseModel): """Resolved runtime provenance returned by the gateway.""" @@ -148,7 +162,7 @@ class JobSubmitResponse(BaseModel): """Response model for job submission.""" job_id: str - status: str + status: Literal["submitted"] poll_url: str country: str version: str @@ -160,8 +174,8 @@ class JobSubmitResponse(BaseModel): class JobStatusResponse(BaseModel): """Response model for job status polling.""" - status: str - result: Optional[dict] = None + status: Literal["running", "complete", "failed"] + result: Optional[SingleYearMacroOutput] = None error: Optional[str] = None resolved_app_name: Optional[str] = None policyengine_bundle: Optional[PolicyEngineBundle] = None @@ -191,6 +205,8 @@ def validate_start_year(cls, value: str) -> str: @model_validator(mode="after") def validate_end_year(self) -> "BudgetWindowBatchRequest": + if self.region_group is not None: + raise ValueError("Provide `region` or `region_group`, not both.") end_year = int(self.start_year) + self.window_size - 1 if end_year > self.MAX_END_YEAR: raise ValueError( @@ -241,7 +257,14 @@ class BatchChildJobStatus(BaseModel): """Per-year child simulation job tracking.""" job_id: str - status: str + status: Literal[ + "pending", + "queued", + "running", + "complete", + "failed", + "cancelled", + ] error: Optional[str] = None @@ -249,7 +272,7 @@ class BudgetWindowBatchSubmitResponse(BaseModel): """Response model for budget-window batch submission.""" batch_job_id: str - status: str + status: Literal["submitted"] poll_url: str country: str version: str @@ -261,7 +284,7 @@ class BudgetWindowBatchSubmitResponse(BaseModel): class BudgetWindowBatchStatusResponse(BaseModel): """Response model for budget-window batch polling.""" - status: str + status: Literal["submitted", "running", "complete", "failed"] progress: Optional[int] = None completed_years: list[str] = Field(default_factory=list) running_years: list[str] = Field(default_factory=list) @@ -279,7 +302,7 @@ class BudgetWindowBatchState(BaseModel): """Internal state persisted for a budget-window parent batch job.""" batch_job_id: str - status: str + status: Literal["submitted", "running", "complete", "failed"] country: str region: str version: str @@ -289,7 +312,7 @@ class BudgetWindowBatchState(BaseModel): start_year: str window_size: int max_parallel: int - request_payload: dict[str, Any] = Field(default_factory=dict) + request_payload: JsonObject = Field(default_factory=dict) years: list[str] = Field(default_factory=list) queued_years: list[str] = Field(default_factory=list) running_years: list[str] = Field(default_factory=list) @@ -316,3 +339,27 @@ class PingResponse(BaseModel): """Response model for ping endpoint.""" incremented: int + + +class VersionMap(RootModel[dict[str, str]]): + """Version alias or exact version mapped to a deployed worker app.""" + + +class VersionsResponse(BaseModel): + """All supported simulation routing version maps.""" + + policyengine: VersionMap + us: VersionMap + uk: VersionMap + + +class HealthResponse(BaseModel): + """Local process health response.""" + + status: Literal["healthy"] = "healthy" + + +class ReadinessResponse(BaseModel): + """Dependency readiness response.""" + + status: Literal["ready", "not_ready"] diff --git a/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/json_types.py b/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/json_types.py new file mode 100644 index 000000000..1f6668bc6 --- /dev/null +++ b/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/json_types.py @@ -0,0 +1,8 @@ +"""JSON value types shared by simulation API contracts.""" + +from __future__ import annotations + +from pydantic import JsonValue + + +type JsonObject = dict[str, JsonValue] diff --git a/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/macro_output.py b/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/macro_output.py new file mode 100644 index 000000000..229131bb8 --- /dev/null +++ b/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/macro_output.py @@ -0,0 +1,207 @@ +"""Public schemas for completed single-year macro simulation results.""" + +from __future__ import annotations + +from typing import Generic, TypeVar + +from pydantic import BaseModel, ConfigDict, RootModel + + +T = TypeVar("T") + + +class MacroOutputModel(BaseModel): + """Base model for closed macro-output objects.""" + + model_config = ConfigDict(extra="forbid") + + +class MacroRootModel(RootModel[T], Generic[T]): + """Base model for named map and list output schemas.""" + + +class BudgetaryImpact(MacroOutputModel): + tax_revenue_impact: float + state_tax_revenue_impact: float + benefit_spending_impact: float + budgetary_impact: float + households: float + baseline_net_income: float + + +BudgetaryOutput = BudgetaryImpact + + +class DetailedBudgetProgramOutput(MacroOutputModel): + baseline: float + reform: float + difference: float + + +class DetailedBudgetOutput(MacroRootModel[dict[str, DetailedBudgetProgramOutput]]): + """Program name mapped to its baseline and reform totals.""" + + +class DecileOutput(MacroOutputModel): + average: dict[str, float] + relative: dict[str, float] + + +class IntraDecileOutput(MacroOutputModel): + deciles: dict[str, list[float]] + all: dict[str, float] + + +class BaselineReformValue(MacroOutputModel): + baseline: float + reform: float + + +class AgePovertyOutput(MacroOutputModel): + child: BaselineReformValue + adult: BaselineReformValue + senior: BaselineReformValue + all: BaselineReformValue + + +class GenderPovertyOutput(MacroOutputModel): + male: BaselineReformValue + female: BaselineReformValue + + +class RacePovertyOutput(MacroOutputModel): + white: BaselineReformValue + black: BaselineReformValue + hispanic: BaselineReformValue + other: BaselineReformValue + + +class PovertyOutput(MacroOutputModel): + poverty: AgePovertyOutput + deep_poverty: AgePovertyOutput + + +class PovertyByGenderOutput(MacroOutputModel): + poverty: GenderPovertyOutput + deep_poverty: GenderPovertyOutput + + +class PovertyByRaceOutput(MacroOutputModel): + poverty: RacePovertyOutput + + +class PovertyModuleOutputs(MacroOutputModel): + poverty: PovertyOutput + poverty_by_gender: PovertyByGenderOutput + poverty_by_race: PovertyByRaceOutput | None + + +class InequalityOutput(MacroOutputModel): + gini: BaselineReformValue + top_10_pct_share: BaselineReformValue + top_1_pct_share: BaselineReformValue + + +class LaborSupplyRelativeResponse(MacroOutputModel): + income: float + substitution: float + + +class LaborSupplyDecileMetric(MacroOutputModel): + income: dict[int, float] + substitution: dict[int, float] + + +class LaborSupplyDecileOutput(MacroOutputModel): + average: LaborSupplyDecileMetric + relative: LaborSupplyDecileMetric + + +class LaborSupplyHoursOutput(MacroOutputModel): + baseline: float + reform: float + change: float + income_effect: float + substitution_effect: float + + +class LaborSupplyResponseOutput(MacroOutputModel): + substitution_lsr: float + income_lsr: float + relative_lsr: LaborSupplyRelativeResponse + total_change: float + revenue_change: float + decile: LaborSupplyDecileOutput + hours: LaborSupplyHoursOutput + + +class CliffImpactInSimulation(MacroOutputModel): + cliff_gap: float + cliff_share: float + + +class CliffImpactOutput(MacroOutputModel): + baseline: CliffImpactInSimulation + reform: CliffImpactInSimulation + + +class ConstituencyImpactRecord(MacroOutputModel): + constituency_code: str + constituency_name: str + x: int | None + y: int | None + average_household_income_change: float + relative_household_income_change: float + population: float + + +class LocalAuthorityImpactRecord(MacroOutputModel): + local_authority_code: str + local_authority_name: str + x: int | None + y: int | None + average_household_income_change: float + relative_household_income_change: float + population: float + + +class GeographicImpactOutput( + MacroRootModel[list[ConstituencyImpactRecord | LocalAuthorityImpactRecord]] +): + """UK constituency or local-authority impact records.""" + + +class CongressionalDistrictImpactRecord(MacroOutputModel): + district: str + average_household_income_change: float + relative_household_income_change: float + winner_percentage: float + loser_percentage: float + no_change_percentage: float + population: float + + +class CongressionalDistrictImpactOutput(MacroOutputModel): + districts: list[CongressionalDistrictImpactRecord] + + +class SingleYearMacroOutput(MacroOutputModel): + """Completed response returned by a single-year macro simulation.""" + + model_version: str + data_version: str + budget: BudgetaryImpact + detailed_budget: DetailedBudgetOutput + decile: DecileOutput + inequality: InequalityOutput + poverty: PovertyOutput + poverty_by_gender: PovertyByGenderOutput + poverty_by_race: PovertyByRaceOutput | None + intra_decile: IntraDecileOutput + wealth_decile: DecileOutput | None + intra_wealth_decile: IntraDecileOutput | None + labor_supply_response: LaborSupplyResponseOutput | None + constituency_impact: GeographicImpactOutput | None + local_authority_impact: GeographicImpactOutput | None + congressional_district_impact: CongressionalDistrictImpactOutput | None + cliff_impact: CliffImpactOutput | None = None diff --git a/libs/policyengine-simulation-contract/tests/test_gateway_models.py b/libs/policyengine-simulation-contract/tests/test_gateway_models.py index b9f9855f9..a6bd60dd3 100644 --- a/libs/policyengine-simulation-contract/tests/test_gateway_models.py +++ b/libs/policyengine-simulation-contract/tests/test_gateway_models.py @@ -13,12 +13,16 @@ BudgetWindowBatchSubmitResponse, BudgetWindowResult, BudgetWindowTotals, + HealthResponse, JobStatusResponse, JobSubmitResponse, MAX_GATEWAY_REQUEST_BYTES, PingRequest, PingResponse, + ReadinessResponse, SimulationRequest, + VersionMap, + VersionsResponse, ) @@ -265,7 +269,8 @@ def test_simulation_request_accepts_payload_just_below_256kb(self): # Must not raise — this is the just-under-cap happy path. request = SimulationRequest(**payload) assert request.country == "us" - assert request.reform == payload["reform"] + assert request.reform is not None + assert request.reform.root == payload["reform"] def test_simulation_request_rejects_payload_just_above_256kb(self): """The cap is strict: a payload that crosses 262_144 bytes by even a @@ -340,22 +345,17 @@ def test_job_submit_response_creates_with_all_fields(self): class TestJobStatusResponse: """Tests for JobStatusResponse model.""" - def test_job_status_response_complete_with_result(self): + def test_job_status_response_rejects_an_unstructured_result(self): """ - Given a completed job with result + Given a completed job with a result that does not match the macro schema When creating a JobStatusResponse - Then the model contains the result. + Then validation fails instead of silently accepting an opaque mapping. """ - # Given - result = {"budget": {"total": 1000000}} - - # When - response = JobStatusResponse(status="complete", result=result) - - # Then - assert response.status == "complete" - assert response.result == {"budget": {"total": 1000000}} - assert response.error is None + with pytest.raises(ValidationError): + JobStatusResponse( + status="complete", + result={"budget": {"total": 1000000}}, + ) def test_job_status_response_running_without_result(self): """ @@ -391,7 +391,6 @@ def test_job_status_response_failed_with_error(self): def test_job_status_response_accepts_bundle_metadata(self): response = JobStatusResponse( status="complete", - result={"budget": {"total": 1000000}}, resolved_app_name="policyengine-simulation-py3-9-0", policyengine_bundle={ "model_version": "1.459.0", @@ -408,6 +407,28 @@ def test_job_status_response_accepts_bundle_metadata(self): ) +class TestOperationalResponses: + def test_versions_response_has_named_maps(self): + response = VersionsResponse( + policyengine=VersionMap({"latest": "4.10.0"}), + us=VersionMap({"latest": "1.500.0"}), + uk=VersionMap({"latest": "2.66.0"}), + ) + + assert response.model_dump(mode="json") == { + "policyengine": {"latest": "4.10.0"}, + "us": {"latest": "1.500.0"}, + "uk": {"latest": "2.66.0"}, + } + + def test_health_and_readiness_statuses_are_constrained(self): + assert HealthResponse().status == "healthy" + assert ReadinessResponse(status="ready").status == "ready" + + with pytest.raises(ValidationError): + ReadinessResponse(status="unknown") + + class TestBudgetWindowBatchRequest: """Tests for budget-window batch request validation.""" diff --git a/libs/policyengine-simulation-contract/uv.lock b/libs/policyengine-simulation-contract/uv.lock index 9fa978c54..693babea7 100644 --- a/libs/policyengine-simulation-contract/uv.lock +++ b/libs/policyengine-simulation-contract/uv.lock @@ -967,7 +967,6 @@ name = "policyengine-simulation-contract" version = "0.1.0" source = { editable = "." } dependencies = [ - { name = "modal" }, { name = "policyengine-simulation-observability" }, { name = "pydantic" }, ] @@ -977,7 +976,11 @@ build = [ { name = "black" }, { name = "pyright" }, ] +modal = [ + { name = "modal" }, +] test = [ + { name = "modal" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, @@ -986,7 +989,8 @@ test = [ [package.metadata] requires-dist = [ { name = "black", marker = "extra == 'build'", specifier = ">=25.1.0" }, - { name = "modal", specifier = ">=1.4,<2" }, + { name = "modal", marker = "extra == 'modal'", specifier = ">=1.4,<2" }, + { name = "modal", marker = "extra == 'test'", specifier = ">=1.4,<2" }, { name = "policyengine-simulation-observability", editable = "../policyengine-simulation-observability" }, { name = "pydantic", specifier = ">=2.0" }, { name = "pyright", marker = "extra == 'build'", specifier = ">=1.1.401" }, @@ -994,7 +998,7 @@ requires-dist = [ { name = "pytest-asyncio", marker = "extra == 'test'", specifier = ">=0.25.3" }, { name = "pytest-cov", marker = "extra == 'test'", specifier = ">=6.1.1" }, ] -provides-extras = ["test", "build"] +provides-extras = ["modal", "test", "build"] [[package]] name = "policyengine-simulation-observability" diff --git a/projects/policyengine-apis-integ/pyproject.toml b/projects/policyengine-apis-integ/pyproject.toml index eb54ad383..f3cca65c2 100644 --- a/projects/policyengine-apis-integ/pyproject.toml +++ b/projects/policyengine-apis-integ/pyproject.toml @@ -27,7 +27,7 @@ markers = [ ] [tool.uv.sources] -policyengine_api_simulation_client = { path = "../policyengine-simulation-gateway/artifacts/clients/python" } +policyengine_api_simulation_client = { path = "../policyengine-simulation-entry/artifacts/clients/python" } [tool.pyright] #The generated clients do not do the "public export" convention diff --git a/projects/policyengine-apis-integ/uv.lock b/projects/policyengine-apis-integ/uv.lock index 1f867d4a6..d5cbf664c 100644 --- a/projects/policyengine-apis-integ/uv.lock +++ b/projects/policyengine-apis-integ/uv.lock @@ -248,7 +248,7 @@ requires-dist = [ { name = "backoff", marker = "extra == 'test'", specifier = ">=2.2.1" }, { name = "black", marker = "extra == 'build'", specifier = ">=25.1.0" }, { name = "httpx", marker = "extra == 'test'", specifier = ">=0.27.0" }, - { name = "policyengine-api-simulation-client", directory = "../policyengine-simulation-gateway/artifacts/clients/python" }, + { name = "policyengine-api-simulation-client", directory = "../policyengine-simulation-entry/artifacts/clients/python" }, { name = "pydantic-settings", specifier = ">=2.8.1,<3.0.0" }, { name = "pyright", marker = "extra == 'build'", specifier = ">=1.1.401" }, { name = "pytest", specifier = ">=8.3.4,<9.0.0" }, @@ -263,7 +263,7 @@ test = [{ name = "backoff", specifier = ">=2.2.1" }] [[package]] name = "policyengine-api-simulation-client" version = "1.0.0" -source = { directory = "../policyengine-simulation-gateway/artifacts/clients/python" } +source = { directory = "../policyengine-simulation-entry/artifacts/clients/python" } dependencies = [ { name = "attrs" }, { name = "httpx" }, diff --git a/projects/policyengine-simulation-entry/DEPLOYMENT.md b/projects/policyengine-simulation-entry/DEPLOYMENT.md new file mode 100644 index 000000000..44efb8492 --- /dev/null +++ b/projects/policyengine-simulation-entry/DEPLOYMENT.md @@ -0,0 +1,75 @@ +# Simulation deployment + +The Simulation Entry service is deployed to Cloud Run as one layer of the +simulation API deployment. This document describes the deployment model only. +Concrete cloud projects, identities, IAM bindings, domains, and secret +resources are managed through private operator documentation. + +## Infrastructure + +Initial cloud infrastructure is configured once by an authorized operator. It +is intentionally not provisioned by a script in this repository. + +The deployment environment must provide: + +- an image registry and Cloud Run services; +- separate `beta` and `prod` runtime identities; +- GitHub workload identity federation with narrowly scoped trust; +- protected environment configuration and secret storage; and +- domain and traffic-management configuration. + +Credential values belong in the cloud secret store. They must not be committed +to the repository or stored as plain GitHub variables. The deployment workflow +receives only the configuration needed to reference those protected resources. + +## Candidate qualification + +For each eligible revision, the workflow first deploys `beta` +candidates for the Cloud Run entrypoint, Modal gateway, and versioned Modal +executor. Each deployment job runs its own service unit tests. Only after all +three jobs pass does the workflow publish routing and run integration and +authentication checks through the complete request path. Beta entrypoint +qualification uses Cloud Run's generated tagged revision URL and does not +require a custom domain. + +The `prod` environment follows the same sequence automatically, but cannot +begin until every `beta` deployment, routing, integration, and authentication +job has succeeded. Production tests also use the tagged candidate URL, so the +currently serving revision remains unchanged during qualification. + +## Promotion and rollback + +After the complete production suite passes, the workflow verifies that the +exact tested revision belongs to the production service and is ready. It also +checks that stable traffic still points to the revision recorded before +deployment. If those guards pass, it assigns all stable service traffic to the +tested production revision. + +The workflow then checks the stable generated Cloud Run URL. It uses the +runtime revision header to prove that the stable URL reaches the exact revision +that passed candidate tests, and it repeats the deployed authentication checks. +If an immediate post-promotion check fails, the workflow restores the exact +previous revision recorded before deployment and fails visibly. + +Deployment concurrency prevents two releases from changing the entrypoint +service simultaneously. Promotion never targets `LATEST`. + +This traffic change applies only to the Simulation Entrypoint Cloud Run +service. API v1 revision traffic and its choice between direct Modal access and +the Cloud Run entrypoint remain separately controlled. + +## Service URLs and domains + +Cloud Run provides a stable service URL and tagged candidate URLs. Beta uses +those generated URLs; it does not require a custom hostname. + +Production may use a persistent custom hostname mapped to its stable Cloud Run +service. The mapping follows the service's active traffic assignment and is +not recreated for each release. When a custom hostname is configured as +protected environment metadata, post-promotion checks verify it in addition +to the generated stable URL. + +Domain ownership, mapping creation, DNS publication, managed TLS activation, +and infrastructure administration are one-time operator actions governed by +private runbooks rather than committed bootstrap scripts in this public +repository. diff --git a/projects/policyengine-simulation-entry/Dockerfile b/projects/policyengine-simulation-entry/Dockerfile new file mode 100644 index 000000000..2f500cd3d --- /dev/null +++ b/projects/policyengine-simulation-entry/Dockerfile @@ -0,0 +1,26 @@ +FROM --platform=$BUILDPLATFORM python:3.13-slim AS runtime + +COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + UV_PROJECT_ENVIRONMENT=/opt/venv \ + PORT=8080 + +WORKDIR /app + +COPY LICENSE ./LICENSE +COPY libs ./libs/ +COPY projects/policyengine-simulation-entry ./projects/policyengine-simulation-entry/ + +WORKDIR /app/projects/policyengine-simulation-entry +RUN uv sync --frozen --no-dev --no-editable + +RUN addgroup --system simulation-entry \ + && adduser --system --ingroup simulation-entry simulation-entry + +USER simulation-entry + +EXPOSE 8080 + +CMD ["/opt/venv/bin/uvicorn", "policyengine_simulation_entry.app:app", "--host", "0.0.0.0", "--port", "8080"] diff --git a/projects/policyengine-simulation-entry/Dockerfile.dockerignore b/projects/policyengine-simulation-entry/Dockerfile.dockerignore new file mode 100644 index 000000000..0740fe81c --- /dev/null +++ b/projects/policyengine-simulation-entry/Dockerfile.dockerignore @@ -0,0 +1,23 @@ +.git + +projects/* +!projects/policyengine-simulation-entry/ +!projects/policyengine-simulation-entry/** +projects/policyengine-simulation-entry/artifacts +projects/policyengine-simulation-entry/authenticated_tests +projects/policyengine-simulation-entry/tests + +libs/* +!libs/policyengine-simulation-contract/ +!libs/policyengine-simulation-contract/** +!libs/policyengine-simulation-observability/ +!libs/policyengine-simulation-observability/** +libs/**/tests + +# Keep these after the negated project/library rules so local build artifacts +# cannot be re-included by the broader allowlists above. +**/.venv +**/__pycache__ +**/.pytest_cache +**/.ruff_cache +**/*.pyc diff --git a/projects/policyengine-simulation-entry/README.md b/projects/policyengine-simulation-entry/README.md new file mode 100644 index 000000000..00b406408 --- /dev/null +++ b/projects/policyengine-simulation-entry/README.md @@ -0,0 +1,25 @@ +# policyengine-simulation-entry + +The permanent Cloud Run entrypoint for PolicyEngine simulation submission and +polling. + +During migration Stage 5 this service is a contract-compatible control-plane +proxy backed by the existing Modal-hosted simulation gateway. It authenticates +callers, uses its own machine identity for the Modal hop, and preserves existing +job identifiers. It does not yet own job persistence, version routing, or +compute dispatch. + +## Local development + + uv sync --extra test + uv run pytest tests/ -v + +The production app is policyengine_simulation_entry.app:app. Required runtime +configuration is documented in policyengine_simulation_entry.config.Settings. +The service does not require its own public URL as runtime configuration. +Cloud Run's generated revision name is returned in a response header so +deployment checks can prove which revision a tagged or stable URL serves. + +Live caller/backend authentication checks are isolated in `authenticated_tests`. +They qualify tagged Cloud Run candidates before production traffic changes and +verify the stable production endpoint immediately after promotion. diff --git a/projects/policyengine-simulation-entry/authenticated_tests/test_deployed_auth.py b/projects/policyengine-simulation-entry/authenticated_tests/test_deployed_auth.py new file mode 100644 index 000000000..e2c8accd3 --- /dev/null +++ b/projects/policyengine-simulation-entry/authenticated_tests/test_deployed_auth.py @@ -0,0 +1,86 @@ +"""Live authentication checks for a deployed Simulation Entrypoint candidate.""" + +from __future__ import annotations + +import os + +import httpx +import pytest + + +PROTECTED_PATH = "/simulate/economy/comparison" +PROBE_PAYLOAD = { + "country": "__authentication_probe__", + "scope": "macro", + "reform": {}, +} + + +def _required(name: str) -> str: + value = os.environ.get(name, "").strip() + if not value: + raise RuntimeError(f"{name} is required for authenticated deployment tests.") + return value + + +@pytest.fixture(scope="session") +def base_url() -> str: + return _required("SIMULATION_ENTRYPOINT_TEST_BASE_URL").rstrip("/") + + +@pytest.fixture(scope="session") +def access_token() -> str: + issuer = _required("SIMULATION_ENTRYPOINT_TEST_AUTH_ISSUER").rstrip("/") + response = httpx.post( + f"{issuer}/oauth/token", + json={ + "client_id": _required("SIMULATION_ENTRYPOINT_TEST_AUTH_CLIENT_ID"), + "client_secret": _required("SIMULATION_ENTRYPOINT_TEST_AUTH_CLIENT_SECRET"), + "audience": _required("SIMULATION_ENTRYPOINT_TEST_AUTH_AUDIENCE"), + "grant_type": "client_credentials", + }, + timeout=30, + ) + response.raise_for_status() + token = response.json().get("access_token") + if not isinstance(token, str) or not token: + raise RuntimeError("Auth0 response omitted access_token.") + return token + + +def test_protected_route_rejects_missing_token(base_url: str): + response = httpx.post( + f"{base_url}{PROTECTED_PATH}", + json=PROBE_PAYLOAD, + timeout=30, + ) + + assert response.status_code == 403 + + +def test_protected_route_rejects_invalid_token(base_url: str): + response = httpx.post( + f"{base_url}{PROTECTED_PATH}", + json=PROBE_PAYLOAD, + headers={"Authorization": "Bearer not-a-valid-jwt"}, + timeout=30, + ) + + assert response.status_code == 403 + + +def test_valid_token_reaches_authenticated_old_gateway( + base_url: str, + access_token: str, +): + response = httpx.post( + f"{base_url}{PROTECTED_PATH}", + json=PROBE_PAYLOAD, + headers={"Authorization": f"Bearer {access_token}"}, + timeout=30, + ) + + # The deliberately unknown country is rejected before any simulation is + # submitted. Reaching this 400 proves both authentication hops succeeded. + assert response.status_code == 400 + assert response.headers["x-policyengine-simulation-backend"] == "old_gateway" diff --git a/projects/policyengine-simulation-entry/openapi-python-client.yaml b/projects/policyengine-simulation-entry/openapi-python-client.yaml new file mode 100644 index 000000000..a5a231b90 --- /dev/null +++ b/projects/policyengine-simulation-entry/openapi-python-client.yaml @@ -0,0 +1,2 @@ +project_name_override: policyengine-api-simulation-client +package_name_override: policyengine_api_simulation_client diff --git a/projects/policyengine-simulation-entry/pyproject.toml b/projects/policyengine-simulation-entry/pyproject.toml new file mode 100644 index 000000000..568ba3849 --- /dev/null +++ b/projects/policyengine-simulation-entry/pyproject.toml @@ -0,0 +1,48 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "policyengine-simulation-entry" +version = "0.1.0" +readme = "README.md" +authors = [ + {name = "PolicyEngine", email = "hello@policyengine.org"}, +] +license = {file = "../../LICENSE"} +requires-python = ">=3.13,<3.14" +dependencies = [ + "cryptography>=41.0.0", + "fastapi>=0.115.0,<1", + "httpx>=0.28.0,<1", + "policyengine-observability[fastapi]>=1.3.0,<2", + "policyengine-simulation-contract", + "policyengine-simulation-observability", + "pyjwt>=2.10.1,<3", + "uvicorn>=0.35.0,<1", +] + +[tool.hatch.build.targets.wheel] +packages = ["src/policyengine_simulation_entry"] + +[tool.uv.sources] +policyengine-simulation-contract = { path = "../../libs/policyengine-simulation-contract", editable = true } +policyengine-simulation-observability = { path = "../../libs/policyengine-simulation-observability", editable = true } + +[project.optional-dependencies] +test = [ + "pytest>=8.3.4", + "pytest-asyncio>=0.25.3", + "pytest-cov>=6.1.1", +] +build = [ + "openapi-python-client>=0.21.6", + "pyright>=1.1.401", +] + +[tool.pytest.ini_options] +pythonpath = ["src"] +testpaths = ["tests"] + +[tool.pyright] +include = ["src"] diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/__init__.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/__init__.py new file mode 100644 index 000000000..722f82b54 --- /dev/null +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/__init__.py @@ -0,0 +1,5 @@ +"""PolicyEngine Cloud Run Simulation Entrypoint.""" + +from policyengine_simulation_entry.app import create_app + +__all__ = ["create_app"] diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/app.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/app.py new file mode 100644 index 000000000..2bca5473e --- /dev/null +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/app.py @@ -0,0 +1,407 @@ +"""FastAPI application for the Cloud Run Simulation Entrypoint.""" + +from __future__ import annotations + +import logging +import time +import uuid +from collections.abc import Callable +from contextlib import asynccontextmanager +from typing import Literal + +from fastapi import Depends, FastAPI, Request +from fastapi.responses import JSONResponse, Response +from pydantic import BaseModel, TypeAdapter, ValidationError +from policyengine_observability import record_event +from policyengine_simulation_contract.gateway_models import ( + BudgetWindowBatchRequest, + BudgetWindowBatchStatusResponse, + BudgetWindowBatchSubmitResponse, + HealthResponse, + JobStatusResponse, + JobSubmitResponse, + PingRequest, + PingResponse, + ReadinessResponse, + SimulationRequest, + VersionMap, + VersionsResponse, +) +from policyengine_simulation_contract.json_types import JsonObject +from policyengine_simulation_observability.observability import ( + configure_process_observability, + init_simulation_observability, +) + +from policyengine_simulation_entry.auth import CallerAuthenticator +from policyengine_simulation_entry.backend import ( + BackendAuthenticationError, + BackendResponse, + BackendTimeout, + BackendUnavailable, + OldGatewayBackend, + SimulationBackend, +) +from policyengine_simulation_entry.config import Settings +from policyengine_simulation_entry.schemas import ( + BackendTelemetryAttributes, + BackendTelemetryPayload, + CallerIdentity, + RequestIdentifiers, +) + + +logger = logging.getLogger(__name__) +BACKEND_RESPONSE_HEADER = { + "X-PolicyEngine-Simulation-Backend": "old_gateway", +} +_json_object_adapter = TypeAdapter(JsonObject) +type AuthenticationDependency = Callable[[], CallerIdentity | None] +type ResponseIdentifier = Literal["job_id", "batch_job_id"] + + +def _model_json(model: BaseModel) -> JsonObject: + return _json_object_adapter.validate_python( + model.model_dump(mode="json", by_alias=True, exclude_none=True) + ) + + +def _response(result: BackendResponse) -> Response: + headers = { + **result.headers, + **BACKEND_RESPONSE_HEADER, + } + return Response( + content=result.content, + status_code=result.status_code, + headers=headers, + ) + + +def _route_template(request: Request) -> str: + return getattr(request.scope.get("route"), "path", request.url.path) + + +def _request_identifiers(request: Request) -> RequestIdentifiers: + identifiers: RequestIdentifiers = {} + job_id = request.path_params.get("job_id") + batch_job_id = request.path_params.get("batch_job_id") + if isinstance(job_id, str): + identifiers["job_id"] = job_id + if isinstance(batch_job_id, str): + identifiers["batch_job_id"] = batch_job_id + return identifiers + + +def create_app( + *, + settings: Settings | None = None, + backend: SimulationBackend | None = None, + auth_dependency: AuthenticationDependency | None = None, +) -> FastAPI: + """Build the app with injectable auth/backend seams for hermetic tests.""" + + runtime_settings = settings or Settings.from_env() + runtime_backend = backend or OldGatewayBackend(runtime_settings) + authenticate = auth_dependency or CallerAuthenticator(runtime_settings) + + @asynccontextmanager + async def lifespan(_: FastAPI): + runtime_settings.validate() + await runtime_backend.start() + try: + yield + finally: + await runtime_backend.close() + + app = FastAPI( + title="PolicyEngine Simulation Entrypoint", + description=("Authenticated simulation submission and polling control plane."), + version="1.0.0", + lifespan=lifespan, + ) + + configure_process_observability( + platform="cloud_run", + service_role="simulation_entry", + ) + init_simulation_observability(app, service_role="simulation_entry") + + @app.middleware("http") + async def request_context(request: Request, call_next): + request_id = request.headers.get("x-request-id") or str(uuid.uuid4()) + request.state.request_id = request_id + started = time.monotonic() + response = await call_next(request) + elapsed_ms = round((time.monotonic() - started) * 1000, 2) + response.headers["X-Request-ID"] = request_id + if runtime_settings.revision: + response.headers["X-PolicyEngine-Simulation-Revision"] = ( + runtime_settings.revision + ) + logger.info( + "simulation_entry_request", + extra={ + "request_id": request_id, + "method": request.method, + "path": _route_template(request), + "status_code": response.status_code, + "elapsed_ms": elapsed_ms, + "backend": "old_gateway", + **_request_identifiers(request), + }, + ) + return response + + async def forward( + request: Request, + method: str, + path: str, + body: JsonObject | None = None, + *, + identifiers: RequestIdentifiers | None = None, + response_identifier_key: ResponseIdentifier | None = None, + ) -> Response: + backend_started = time.monotonic() + route = _route_template(request) + event_identifiers: RequestIdentifiers = identifiers or {} + try: + result = await runtime_backend.request( + method, + path, + json_body=body, + request_id=request.state.request_id, + ) + attributes: BackendTelemetryAttributes = { + "request_id": request.state.request_id, + "route": route, + "method": method, + "status_code": result.status_code, + "elapsed_ms": round( + (time.monotonic() - backend_started) * 1000, + 2, + ), + **event_identifiers, + } + try: + response_payload = BackendTelemetryPayload.model_validate_json( + result.content + ) + except ValidationError: + response_payload = None + job_state = ( + response_payload.status if response_payload is not None else None + ) + if job_state is not None: + attributes["job_state"] = job_state + response_identifier = ( + getattr(response_payload, response_identifier_key) + if response_payload is not None and response_identifier_key + else None + ) + if response_identifier_key and response_identifier is not None: + attributes[response_identifier_key] = response_identifier + record_event("simulation_entry_backend_response", **attributes) + return _response(result) + except BackendTimeout: + record_event( + "simulation_entry_backend_timeout", + request_id=request.state.request_id, + route=route, + **event_identifiers, + ) + return JSONResponse( + status_code=504, + content={"detail": "Simulation backend timed out."}, + headers={ + "Retry-After": "10", + **BACKEND_RESPONSE_HEADER, + }, + ) + except (BackendUnavailable, BackendAuthenticationError): + record_event( + "simulation_entry_backend_unavailable", + request_id=request.state.request_id, + route=route, + **event_identifiers, + ) + return JSONResponse( + status_code=503, + content={"detail": "Simulation backend is unavailable."}, + headers={ + "Retry-After": "10", + **BACKEND_RESPONSE_HEADER, + }, + ) + + protected = [Depends(authenticate)] + + @app.post( + "/simulate/economy/comparison", + summary="Submit Simulation", + description=( + "Submit a simulation job.\n\n" + "Routes to the appropriate simulation app based on country and version.\n" + "Returns immediately with a job_id for polling." + ), + operation_id="submit_simulation_simulate_economy_comparison_post", + response_model=JobSubmitResponse, + response_model_exclude_none=True, + responses={ + 200: {"description": "Job submitted successfully"}, + 400: {"description": "Invalid request (unknown country/version)"}, + }, + dependencies=protected, + ) + async def submit_comparison( + body: SimulationRequest, + request: Request, + ) -> Response: + return await forward( + request, + "POST", + "/simulate/economy/comparison", + _model_json(body), + response_identifier_key="job_id", + ) + + @app.post( + "/simulate/economy/budget-window", + summary="Submit Budget Window Batch", + description=( + "Submit a budget-window batch job.\n\n" + "Returns immediately with a parent batch job ID for polling." + ), + operation_id=("submit_budget_window_batch_simulate_economy_budget_window_post"), + response_model=BudgetWindowBatchSubmitResponse, + response_model_exclude_none=True, + responses={ + 200: {"description": "Budget-window batch submitted successfully"}, + 400: {"description": "Invalid request (unknown country/version/year)"}, + }, + dependencies=protected, + ) + async def submit_budget_window( + body: BudgetWindowBatchRequest, + request: Request, + ) -> Response: + return await forward( + request, + "POST", + "/simulate/economy/budget-window", + _model_json(body), + response_identifier_key="batch_job_id", + ) + + @app.get( + "/jobs/{job_id}", + summary="Get Job Status", + description=( + "Poll for job status.\n\n" + "Returns:\n" + ' - 200 with status="complete" and result when done\n' + ' - 202 with status="running" while in progress\n' + " - 404 if job_id not found\n" + ' - 500 with status="failed" and error on failure' + ), + operation_id="get_job_status_jobs__job_id__get", + response_model=JobStatusResponse, + response_model_exclude_none=True, + responses={ + 200: {"description": "Job complete", "model": JobStatusResponse}, + 202: {"description": "Job still running"}, + 404: {"description": "Job not found"}, + 500: {"description": "Job failed"}, + }, + dependencies=protected, + ) + async def get_job(job_id: str, request: Request) -> Response: + return await forward( + request, + "GET", + f"/jobs/{job_id}", + identifiers={"job_id": job_id}, + ) + + @app.get( + "/budget-window-jobs/{batch_job_id}", + summary="Get Budget Window Job Status", + description="Poll for budget-window batch status.", + operation_id=( + "get_budget_window_job_status_budget_window_jobs__batch_job_id__get" + ), + response_model=BudgetWindowBatchStatusResponse, + response_model_exclude_none=True, + responses={ + 200: { + "description": "Batch complete", + "model": BudgetWindowBatchStatusResponse, + }, + 202: {"description": "Batch submitted or running"}, + 404: {"description": "Batch job not found"}, + 500: {"description": "Batch failed"}, + }, + dependencies=protected, + ) + async def get_budget_window_job( + batch_job_id: str, + request: Request, + ) -> Response: + return await forward( + request, + "GET", + f"/budget-window-jobs/{batch_job_id}", + identifiers={"batch_job_id": batch_job_id}, + ) + + @app.get( + "/versions", + summary="List Versions", + description="List all available routing versions.", + operation_id="list_versions_versions_get", + response_model=VersionsResponse, + ) + async def list_versions(request: Request) -> Response: + return await forward(request, "GET", "/versions") + + @app.get( + "/versions/{kind}", + summary="Get Country Versions", + description="Get available versions for policyengine, US, or UK routing.", + operation_id="get_country_versions_versions__kind__get", + response_model=VersionMap, + ) + async def get_country_versions(kind: str, request: Request) -> Response: + return await forward(request, "GET", f"/versions/{kind}") + + @app.get( + "/health", + description="Health check endpoint.", + operation_id="health_health_get", + response_model=HealthResponse, + ) + async def health() -> HealthResponse: + return HealthResponse() + + @app.get("/ready", response_model=ReadinessResponse) + async def ready() -> Response: + if await runtime_backend.ready(): + return JSONResponse(ReadinessResponse(status="ready").model_dump()) + return JSONResponse( + ReadinessResponse(status="not_ready").model_dump(), + status_code=503, + ) + + @app.post( + "/ping", + description="Verify the API is able to receive and process requests.", + operation_id="ping_ping_post", + response_model=PingResponse, + ) + async def ping(body: PingRequest, request: Request) -> Response: + return await forward(request, "POST", "/ping", _model_json(body)) + + return app + + +app = create_app() diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/auth.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/auth.py new file mode 100644 index 000000000..2d72d5f24 --- /dev/null +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/auth.py @@ -0,0 +1,86 @@ +"""Inbound bearer authentication for protected Simulation Entrypoint routes.""" + +from __future__ import annotations + +from functools import lru_cache +import logging + +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +import jwt +from policyengine_observability import record_event + +from policyengine_simulation_entry.config import Settings +from policyengine_simulation_entry.schemas import CallerIdentity + + +_bearer = HTTPBearer(auto_error=False) +logger = logging.getLogger(__name__) + + +class JWTDecoder: + """Validate Auth0 RS256 access tokens without database-layer dependencies.""" + + def __init__(self, issuer: str, audience: str): + self.issuer = issuer + self.audience = audience + self.jwks_client = jwt.PyJWKClient(f"{issuer}.well-known/jwks.json") + + def __call__( + self, + token: HTTPAuthorizationCredentials | None, + ) -> CallerIdentity: + if token is None: + record_event("simulation_entry_auth_rejected", reason="missing_token") + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) + + try: + signing_key = self.jwks_client.get_signing_key_from_jwt( + token.credentials + ).key + return CallerIdentity.model_validate( + jwt.decode( + token.credentials, + signing_key, + algorithms=["RS256"], + audience=self.audience, + issuer=self.issuer, + ) + ) + except Exception as error: + reason = type(error).__name__ + logger.info( + "invalid_simulation_entry_bearer_token", + extra={"error_type": reason}, + ) + record_event("simulation_entry_auth_rejected", reason=reason) + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) from error + + +@lru_cache(maxsize=8) +def _decoder(issuer: str, audience: str) -> JWTDecoder: + return JWTDecoder(issuer=issuer, audience=audience) + + +class CallerAuthenticator: + """FastAPI dependency that preserves the gateway's JWT contract.""" + + def __init__(self, settings: Settings): + self.settings = settings + + def __call__( + self, + token: HTTPAuthorizationCredentials | None = Depends(_bearer), + ) -> CallerIdentity | None: + if not self.settings.auth_required: + return None + return _decoder( + self.settings.auth_issuer, + self.settings.auth_audience, + )(token) + + +def reset_decoder_cache() -> None: + """Clear the decoder cache for tests and credential rotations.""" + + _decoder.cache_clear() diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/backend.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/backend.py new file mode 100644 index 000000000..67343e470 --- /dev/null +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/backend.py @@ -0,0 +1,223 @@ +"""Backend protocol and Stage 5 old-gateway implementation.""" + +from __future__ import annotations + +import asyncio +import time +from dataclasses import dataclass +from typing import Protocol + +import httpx +from pydantic import ValidationError +from policyengine_simulation_contract.json_types import JsonObject + +from policyengine_simulation_entry.config import Settings +from policyengine_simulation_entry.schemas import BackendServiceToken + + +SAFE_RESPONSE_HEADERS = frozenset( + { + "cache-control", + "content-type", + "etag", + "retry-after", + "x-request-id", + } +) + + +class BackendUnavailable(RuntimeError): + """The backend could not be reached.""" + + +class BackendTimeout(RuntimeError): + """The backend did not answer within the configured timeout.""" + + +class BackendAuthenticationError(BackendUnavailable): + """The service could not authenticate to the backend.""" + + +@dataclass(frozen=True) +class BackendResponse: + status_code: int + content: bytes + headers: dict[str, str] + + +class SimulationBackend(Protocol): + async def start(self) -> None: ... + + async def close(self) -> None: ... + + async def ready(self) -> bool: ... + + async def request( + self, + method: str, + path: str, + *, + json_body: JsonObject | None = None, + request_id: str | None = None, + ) -> BackendResponse: ... + + +class ClientCredentialsTokenProvider: + """Fetch and cache the Simulation Entrypoint's old-gateway M2M token.""" + + REFRESH_MARGIN_SECONDS = 60 + + def __init__(self, settings: Settings, client: httpx.AsyncClient): + self.settings = settings + self.client = client + self._token: str | None = None + self._expires_at = 0.0 + self._lock = asyncio.Lock() + + async def get_token(self) -> str: + async with self._lock: + now = time.time() + if ( + self._token is None + or now >= self._expires_at - self.REFRESH_MARGIN_SECONDS + ): + await self._fetch() + if self._token is None: # pragma: no cover + raise BackendAuthenticationError("Auth0 returned no token.") + return self._token + + def invalidate(self) -> None: + self._token = None + self._expires_at = 0.0 + + async def _fetch(self) -> None: + try: + response = await self.client.post( + f"{self.settings.old_gateway_auth_issuer}oauth/token", + json={ + "client_id": self.settings.old_gateway_auth_client_id, + "client_secret": self.settings.old_gateway_auth_client_secret, + "audience": self.settings.old_gateway_auth_audience, + "grant_type": "client_credentials", + }, + ) + response.raise_for_status() + token = BackendServiceToken.model_validate(response.json()) + self._token = token.access_token + self._expires_at = time.time() + token.expires_in + except BackendAuthenticationError: + raise + except (httpx.HTTPError, TypeError, ValueError, ValidationError) as exc: + raise BackendAuthenticationError( + "Unable to obtain old-gateway credentials." + ) from exc + + +class OldGatewayBackend: + """Forward compatibility requests to the existing Modal gateway.""" + + def __init__( + self, + settings: Settings, + *, + transport: httpx.AsyncBaseTransport | None = None, + ): + self.settings = settings + self.transport = transport + self.client: httpx.AsyncClient | None = None + self.token_provider: ClientCredentialsTokenProvider | None = None + + async def start(self) -> None: + if self.client is not None: + return + timeout = httpx.Timeout( + connect=self.settings.connect_timeout_seconds, + read=self.settings.request_timeout_seconds, + write=self.settings.request_timeout_seconds, + pool=self.settings.connect_timeout_seconds, + ) + self.client = httpx.AsyncClient( + timeout=timeout, + transport=self.transport, + follow_redirects=False, + ) + self.token_provider = ClientCredentialsTokenProvider( + self.settings, + self.client, + ) + + async def close(self) -> None: + if self.client is not None: + await self.client.aclose() + self.client = None + self.token_provider = None + + def _runtime(self) -> tuple[httpx.AsyncClient, ClientCredentialsTokenProvider]: + if self.client is None or self.token_provider is None: + raise BackendUnavailable("Old-gateway backend is not started.") + return self.client, self.token_provider + + async def ready(self) -> bool: + try: + response = await self.request("GET", "/health") + except (BackendUnavailable, BackendTimeout): + return False + return response.status_code == 200 + + async def request( + self, + method: str, + path: str, + *, + json_body: JsonObject | None = None, + request_id: str | None = None, + ) -> BackendResponse: + client, token_provider = self._runtime() + method = method.upper() + connection_retries_remaining = 1 if method == "GET" else 0 + auth_refreshes_remaining = 1 + + while True: + token = await token_provider.get_token() + headers = {"Authorization": f"Bearer {token}"} + if request_id: + headers["X-Request-ID"] = request_id + + try: + response = await client.request( + method, + f"{self.settings.old_gateway_url}{path}", + json=json_body, + headers=headers, + ) + except (httpx.ConnectError, httpx.ConnectTimeout) as exc: + if connection_retries_remaining: + connection_retries_remaining -= 1 + continue + raise BackendUnavailable("Old gateway is unavailable.") from exc + except httpx.TimeoutException as exc: + raise BackendTimeout("Old gateway timed out.") from exc + except httpx.RequestError as exc: + raise BackendUnavailable("Old gateway is unavailable.") from exc + + if response.status_code in {401, 403}: + if auth_refreshes_remaining: + auth_refreshes_remaining -= 1 + token_provider.invalidate() + # The old gateway rejects invalid bearer credentials with + # 403 before endpoint processing. One credential-refresh + # replay is therefore safe for POST as well as GET. + continue + raise BackendAuthenticationError( + "Old gateway rejected refreshed service credentials." + ) + + return BackendResponse( + status_code=response.status_code, + content=response.content, + headers={ + key: value + for key, value in response.headers.items() + if key.lower() in SAFE_RESPONSE_HEADERS + }, + ) diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/config.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/config.py new file mode 100644 index 000000000..009daba4b --- /dev/null +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/config.py @@ -0,0 +1,116 @@ +"""Runtime configuration for the Cloud Run Simulation Entrypoint.""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from urllib.parse import urlparse + + +PRODUCTION_ENVIRONMENTS = frozenset({"main", "prod", "production"}) +MODAL_GATEWAY_HOST_SUFFIX = ".modal.run" + + +class ConfigurationError(RuntimeError): + """Raised when the service cannot start safely.""" + + +def _truthy(value: str | None, *, default: bool = False) -> bool: + if value is None: + return default + return value.lower() in {"1", "true", "yes", "on"} + + +def _normalized_issuer(value: str) -> str: + return f"{value.rstrip('/')}/" if value else "" + + +@dataclass(frozen=True) +class Settings: + """All configuration required by the Stage 5 control-plane proxy.""" + + environment: str + revision: str + auth_required: bool + auth_issuer: str + auth_audience: str + old_gateway_url: str + old_gateway_auth_issuer: str + old_gateway_auth_audience: str + old_gateway_auth_client_id: str + old_gateway_auth_client_secret: str + connect_timeout_seconds: float = 5.0 + request_timeout_seconds: float = 25.0 + + @classmethod + def from_env(cls) -> "Settings": + return cls( + environment=os.getenv("APP_ENVIRONMENT", "local").lower(), + revision=os.getenv("K_REVISION", ""), + auth_required=_truthy( + os.getenv("SIMULATION_ENTRYPOINT_AUTH_REQUIRED"), + default=True, + ), + auth_issuer=_normalized_issuer( + os.getenv("SIMULATION_ENTRYPOINT_AUTH_ISSUER", "") + ), + auth_audience=os.getenv("SIMULATION_ENTRYPOINT_AUTH_AUDIENCE", ""), + old_gateway_url=os.getenv("OLD_GATEWAY_URL", "").rstrip("/"), + old_gateway_auth_issuer=_normalized_issuer( + os.getenv("OLD_GATEWAY_AUTH_ISSUER", "") + ), + old_gateway_auth_audience=os.getenv("OLD_GATEWAY_AUTH_AUDIENCE", ""), + old_gateway_auth_client_id=os.getenv("OLD_GATEWAY_AUTH_CLIENT_ID", ""), + old_gateway_auth_client_secret=os.getenv( + "OLD_GATEWAY_AUTH_CLIENT_SECRET", "" + ), + connect_timeout_seconds=float( + os.getenv("OLD_GATEWAY_CONNECT_TIMEOUT_SECONDS", "5") + ), + request_timeout_seconds=float( + os.getenv("OLD_GATEWAY_REQUEST_TIMEOUT_SECONDS", "25") + ), + ) + + @property + def production(self) -> bool: + return self.environment in PRODUCTION_ENVIRONMENTS + + def validate(self) -> None: + if self.production and not self.auth_required: + raise ConfigurationError( + "Caller authentication cannot be disabled in production." + ) + + if bool(self.auth_issuer) != bool(self.auth_audience): + raise ConfigurationError( + "Set both SIMULATION_ENTRYPOINT_AUTH_ISSUER and SIMULATION_ENTRYPOINT_AUTH_AUDIENCE." + ) + if self.auth_required and not self.auth_issuer: + raise ConfigurationError( + "Caller authentication is required but issuer/audience are missing." + ) + + required_backend = { + "OLD_GATEWAY_URL": self.old_gateway_url, + "OLD_GATEWAY_AUTH_ISSUER": self.old_gateway_auth_issuer, + "OLD_GATEWAY_AUTH_AUDIENCE": self.old_gateway_auth_audience, + "OLD_GATEWAY_AUTH_CLIENT_ID": self.old_gateway_auth_client_id, + "OLD_GATEWAY_AUTH_CLIENT_SECRET": self.old_gateway_auth_client_secret, + } + missing = [name for name, value in required_backend.items() if not value] + if missing: + raise ConfigurationError( + f"Missing old-gateway configuration: {', '.join(missing)}." + ) + + if self.connect_timeout_seconds <= 0 or self.request_timeout_seconds <= 0: + raise ConfigurationError("Old-gateway timeouts must be positive.") + + upstream = urlparse(self.old_gateway_url) + if upstream.scheme != "https" or not upstream.hostname: + raise ConfigurationError("OLD_GATEWAY_URL must be an absolute HTTPS URL.") + if not upstream.hostname.endswith(MODAL_GATEWAY_HOST_SUFFIX): + raise ConfigurationError( + "OLD_GATEWAY_URL must point to the existing Modal gateway." + ) diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/generate_openapi.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/generate_openapi.py new file mode 100644 index 000000000..732ed5167 --- /dev/null +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/generate_openapi.py @@ -0,0 +1,22 @@ +"""Generate the Cloud Run Simulation Entrypoint OpenAPI document.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from policyengine_simulation_entry.app import create_app + + +def main() -> None: + output = Path(__file__).resolve().parents[2] / "artifacts" / "openapi.json" + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text( + json.dumps(create_app().openapi(), indent=2) + "\n", + encoding="utf-8", + ) + print(f"OpenAPI spec written to {output}") + + +if __name__ == "__main__": + main() diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/schemas.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/schemas.py new file mode 100644 index 000000000..cb353da3b --- /dev/null +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/schemas.py @@ -0,0 +1,68 @@ +"""Simulation Entry schemas that are not part of the public gateway contract.""" + +from __future__ import annotations + +from typing import Literal, TypedDict + +from pydantic import BaseModel, ConfigDict, Field + + +class CallerIdentity(BaseModel): + """Validated identity claims from an inbound Auth0 access token.""" + + subject: str | None = Field(default=None, alias="sub") + issuer: str = Field(alias="iss") + audience: str | list[str] = Field(alias="aud") + expires_at: int = Field(alias="exp") + issued_at: int | None = Field(default=None, alias="iat") + scope: str | None = None + permissions: list[str] | None = None + authorized_party: str | None = Field(default=None, alias="azp") + + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + +class BackendServiceToken(BaseModel): + """Auth0 client-credentials response used for old-gateway calls.""" + + access_token: str = Field(min_length=1) + expires_in: int = Field(ge=1) + + +type BackendJobState = Literal[ + "submitted", + "pending", + "queued", + "running", + "complete", + "failed", + "cancelled", +] + + +class BackendTelemetryPayload(BaseModel): + """Response fields that are safe and useful for proxy telemetry.""" + + status: BackendJobState | None = None + job_id: str | None = None + batch_job_id: str | None = None + + +class RequestIdentifiers(TypedDict, total=False): + """Identifiers captured from a templated request route.""" + + job_id: str + batch_job_id: str + + +class BackendTelemetryAttributes(TypedDict, total=False): + """Structured fields emitted for one old-gateway response.""" + + request_id: str + route: str + method: str + status_code: int + elapsed_ms: float + job_id: str + batch_job_id: str + job_state: BackendJobState diff --git a/projects/policyengine-simulation-entry/tests/conftest.py b/projects/policyengine-simulation-entry/tests/conftest.py new file mode 100644 index 000000000..53cc19b70 --- /dev/null +++ b/projects/policyengine-simulation-entry/tests/conftest.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +from dataclasses import dataclass + +import pytest +from fastapi.testclient import TestClient +from policyengine_simulation_contract.json_types import JsonObject + +from policyengine_simulation_entry.app import create_app +from policyengine_simulation_entry.backend import BackendResponse +from policyengine_simulation_entry.config import Settings + + +def make_settings(**overrides) -> Settings: + values = { + "environment": "test", + "revision": "simulation-entry-test-revision", + "auth_required": True, + "auth_issuer": "https://issuer.example/", + "auth_audience": "simulation-entry", + "old_gateway_url": ( + "https://policyengine--policyengine-simulation-gateway-web-app.modal.run" + ), + "old_gateway_auth_issuer": "https://issuer.example/", + "old_gateway_auth_audience": "simulation-api", + "old_gateway_auth_client_id": "simulation-entry-service", + "old_gateway_auth_client_secret": "secret", + } + values.update(overrides) + return Settings(**values) + + +@dataclass(frozen=True) +class BackendRequest: + """One request captured by the fake simulation backend.""" + + method: str + path: str + json_body: JsonObject | None + request_id: str | None + + +class FakeBackend: + def __init__(self): + self.requests: list[BackendRequest] = [] + self.responses: dict[tuple[str, str], BackendResponse] = {} + self.is_ready = True + self.started = False + + async def start(self) -> None: + self.started = True + + async def close(self) -> None: + self.started = False + + async def ready(self) -> bool: + return self.is_ready + + async def request( + self, + method: str, + path: str, + *, + json_body: JsonObject | None = None, + request_id: str | None = None, + ) -> BackendResponse: + self.requests.append( + BackendRequest( + method=method, + path=path, + json_body=json_body, + request_id=request_id, + ) + ) + return self.responses.get( + (method, path), + BackendResponse( + status_code=200, + content=b"{}", + headers={"content-type": "application/json"}, + ), + ) + + +@pytest.fixture +def backend() -> FakeBackend: + return FakeBackend() + + +@pytest.fixture +def client(backend: FakeBackend): + app = create_app( + settings=make_settings(), + backend=backend, + auth_dependency=lambda: None, + ) + with TestClient(app) as test_client: + yield test_client diff --git a/projects/policyengine-simulation-entry/tests/test_app.py b/projects/policyengine-simulation-entry/tests/test_app.py new file mode 100644 index 000000000..424ac3697 --- /dev/null +++ b/projects/policyengine-simulation-entry/tests/test_app.py @@ -0,0 +1,241 @@ +from __future__ import annotations + +import json +import logging + +import pytest +from policyengine_simulation_contract.json_types import JsonObject + +from policyengine_simulation_entry import app as app_module +from policyengine_simulation_entry.app import create_app +from policyengine_simulation_entry.backend import ( + BackendResponse, + BackendTimeout, + BackendUnavailable, +) + +from conftest import FakeBackend, make_settings + + +def response(status: int, payload: JsonObject) -> BackendResponse: + return BackendResponse( + status_code=status, + content=json.dumps(payload).encode(), + headers={"content-type": "application/json", "retry-after": "3"}, + ) + + +def test_health_is_local_and_compatible(client, backend): + result = client.get("/health") + + assert result.status_code == 200 + assert result.json() == {"status": "healthy"} + assert ( + result.headers["x-policyengine-simulation-revision"] + == "simulation-entry-test-revision" + ) + assert backend.requests == [] + + +def test_readiness_tracks_backend(client, backend): + assert client.get("/ready").json() == {"status": "ready"} + + backend.is_ready = False + result = client.get("/ready") + + assert result.status_code == 503 + assert result.json() == {"status": "not_ready"} + + +def test_comparison_submission_preserves_upstream_response(client, backend): + payload = { + "job_id": "fc-123", + "status": "submitted", + "poll_url": "/jobs/fc-123", + "country": "us", + "version": "1.0.0", + "resolved_app_name": "worker", + "policyengine_bundle": {"model_version": "1.0.0"}, + } + backend.responses[("POST", "/simulate/economy/comparison")] = response( + 202, + payload, + ) + + result = client.post( + "/simulate/economy/comparison", + json={"country": "us", "scope": "macro", "reform": {}}, + ) + + assert result.status_code == 202 + assert result.json() == payload + assert result.headers["x-policyengine-simulation-backend"] == "old_gateway" + assert backend.requests[-1].path == "/simulate/economy/comparison" + + +def test_job_status_preserves_id_and_status(client, backend): + backend.responses[("GET", "/jobs/fc-123")] = response( + 202, + {"status": "running", "run_id": "run-1"}, + ) + + result = client.get("/jobs/fc-123") + + assert result.status_code == 202 + assert result.json() == {"status": "running", "run_id": "run-1"} + assert backend.requests[-1].path == "/jobs/fc-123" + + +def test_job_status_records_structured_backend_telemetry( + client, + backend, + monkeypatch, +): + events = [] + monkeypatch.setattr( + app_module, + "record_event", + lambda name, **attributes: events.append((name, attributes)), + ) + backend.responses[("GET", "/jobs/fc-123")] = response( + 202, + {"status": "running", "job_id": "must-not-be-recorded"}, + ) + + client.get("/jobs/fc-123") + + name, attributes = events[-1] + assert name == "simulation_entry_backend_response" + assert attributes["job_state"] == "running" + assert attributes["status_code"] == 202 + assert attributes["route"] == "/jobs/{job_id}" + assert attributes["job_id"] == "fc-123" + + +def test_request_log_templates_route_and_keeps_structured_job_id( + client, + backend, + caplog, +): + backend.responses[("GET", "/jobs/fc-123")] = BackendResponse( + 202, + b'{"job_id":"fc-123","status":"running"}', + {"content-type": "application/json"}, + ) + + with caplog.at_level(logging.INFO): + result = client.get( + "/jobs/fc-123", + headers={"Authorization": "Bearer caller"}, + ) + + assert result.status_code == 202 + request_record = next( + record + for record in caplog.records + if record.getMessage() == "simulation_entry_request" + ) + assert request_record.path == "/jobs/{job_id}" + assert request_record.job_id == "fc-123" + + +def test_budget_window_routes_use_original_batch_id(client, backend): + backend.responses[("POST", "/simulate/economy/budget-window")] = response( + 202, + { + "batch_job_id": "batch-123", + "status": "submitted", + "poll_url": "/budget-window-jobs/batch-123", + }, + ) + backend.responses[("GET", "/budget-window-jobs/batch-123")] = response( + 200, + {"status": "complete", "completed_years": ["2026"]}, + ) + + submitted = client.post( + "/simulate/economy/budget-window", + json={ + "country": "us", + "region": "us", + "scope": "macro", + "reform": {}, + "start_year": "2026", + "window_size": 1, + }, + ) + polled = client.get("/budget-window-jobs/batch-123") + + assert submitted.status_code == 202 + assert submitted.json()["batch_job_id"] == "batch-123" + assert polled.status_code == 200 + assert backend.requests[-1].path == "/budget-window-jobs/batch-123" + + +def test_versions_and_ping_are_public_proxy_routes(client, backend): + backend.responses[("GET", "/versions/us")] = response( + 200, + {"latest": "1.2.3"}, + ) + backend.responses[("POST", "/ping")] = response(200, {"incremented": 2}) + + assert client.get("/versions/us").json() == {"latest": "1.2.3"} + assert client.post("/ping", json={"value": 1}).json() == {"incremented": 2} + + +def test_request_id_is_propagated_and_returned(client, backend): + result = client.get("/versions", headers={"X-Request-ID": "request-123"}) + + assert result.headers["x-request-id"] == "request-123" + assert backend.requests[-1].request_id == "request-123" + + +def test_request_validation_matches_shared_contract(client): + result = client.post( + "/simulate/economy/comparison", + json={"country": "us", "unknown": True}, + ) + + assert result.status_code == 422 + + +@pytest.mark.parametrize( + ("error", "expected_status", "expected_detail"), + [ + (BackendUnavailable("unavailable"), 503, "Simulation backend is unavailable."), + (BackendTimeout("timed out"), 504, "Simulation backend timed out."), + ], +) +def test_backend_failures_are_sanitized(error, expected_status, expected_detail): + class FailingBackend(FakeBackend): + async def request(self, *args, **kwargs): + raise error + + app = create_app( + settings=make_settings(), + backend=FailingBackend(), + auth_dependency=lambda: None, + ) + + from fastapi.testclient import TestClient + + with TestClient(app) as client: + result = client.get("/jobs/job-1") + + assert result.status_code == expected_status + assert result.json() == {"detail": expected_detail} + assert result.headers["retry-after"] == "10" + assert result.headers["x-policyengine-simulation-backend"] == "old_gateway" + + +@pytest.mark.parametrize("status_code", [400, 404, 409, 500]) +def test_upstream_error_status_and_body_are_preserved(client, backend, status_code): + backend.responses[("GET", "/jobs/job-1")] = response( + status_code, + {"detail": "upstream response"}, + ) + + result = client.get("/jobs/job-1") + + assert result.status_code == status_code + assert result.json() == {"detail": "upstream response"} diff --git a/projects/policyengine-simulation-entry/tests/test_auth.py b/projects/policyengine-simulation-entry/tests/test_auth.py new file mode 100644 index 000000000..22f8edfed --- /dev/null +++ b/projects/policyengine-simulation-entry/tests/test_auth.py @@ -0,0 +1,160 @@ +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from types import SimpleNamespace + +import jwt +import pytest +from cryptography.hazmat.primitives.asymmetric import rsa +from fastapi.security import HTTPAuthorizationCredentials +from fastapi.testclient import TestClient + +from policyengine_simulation_entry import auth as auth_module +from policyengine_simulation_entry.app import create_app +from policyengine_simulation_entry.schemas import CallerIdentity + +from conftest import FakeBackend, make_settings + + +@pytest.fixture +def signing_keys(): + private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + return private_key, private_key.public_key() + + +def _token(private_key, **overrides) -> str: + now = datetime.now(timezone.utc) + claims = { + "sub": "api-v1", + "iss": "https://issuer.example/", + "aud": "simulation-entry", + "iat": now, + "exp": now + timedelta(minutes=5), + } + claims.update(overrides) + return jwt.encode(claims, private_key, algorithm="RS256") + + +def _use_static_signing_key(monkeypatch, public_key) -> None: + decoder = auth_module.JWTDecoder( + issuer="https://issuer.example/", + audience="simulation-entry", + ) + decoder.jwks_client = SimpleNamespace( + get_signing_key_from_jwt=lambda _: SimpleNamespace(key=public_key) + ) + monkeypatch.setattr(auth_module, "_decoder", lambda issuer, audience: decoder) + + +def test_missing_token_preserves_403_contract(monkeypatch): + class Decoder: + def __call__(self, token): + from fastapi import HTTPException + + raise HTTPException(status_code=403) + + monkeypatch.setattr(auth_module, "_decoder", lambda issuer, audience: Decoder()) + app = create_app(settings=make_settings(), backend=FakeBackend()) + + with TestClient(app) as client: + result = client.get("/jobs/job-1") + + assert result.status_code == 403 + + +def test_public_routes_do_not_require_token(monkeypatch): + class Decoder: + def __call__(self, token): + raise AssertionError("public route attempted authentication") + + monkeypatch.setattr(auth_module, "_decoder", lambda issuer, audience: Decoder()) + app = create_app(settings=make_settings(), backend=FakeBackend()) + + with TestClient(app) as client: + assert client.get("/health").status_code == 200 + assert client.get("/versions").status_code == 200 + assert client.post("/ping", json={"value": 1}).status_code == 200 + + +def test_valid_api_v1_token_can_poll(monkeypatch, signing_keys): + private_key, public_key = signing_keys + _use_static_signing_key(monkeypatch, public_key) + app = create_app(settings=make_settings(), backend=FakeBackend()) + + with TestClient(app) as client: + result = client.get( + "/jobs/job-1", + headers={"Authorization": f"Bearer {_token(private_key)}"}, + ) + + assert result.status_code == 200 + + +def test_decoder_returns_only_the_declared_caller_identity(signing_keys): + private_key, public_key = signing_keys + decoder = auth_module.JWTDecoder( + issuer="https://issuer.example/", + audience="simulation-entry", + ) + decoder.jwks_client = SimpleNamespace( + get_signing_key_from_jwt=lambda _: SimpleNamespace(key=public_key) + ) + + identity = decoder( + HTTPAuthorizationCredentials( + scheme="Bearer", + credentials=_token( + private_key, + scope="simulate:read", + undocumented_claim="ignored", + ), + ) + ) + + assert isinstance(identity, CallerIdentity) + assert identity.subject == "api-v1" + assert identity.scope == "simulate:read" + assert "undocumented_claim" not in identity.model_dump() + + +@pytest.mark.parametrize( + "claim_overrides", + [ + {"iss": "https://wrong-issuer.example/"}, + {"aud": "wrong-audience"}, + {"exp": datetime.now(timezone.utc) - timedelta(minutes=1)}, + ], + ids=["wrong-issuer", "wrong-audience", "expired"], +) +def test_invalid_api_v1_token_claims_return_403( + monkeypatch, + signing_keys, + claim_overrides, +): + private_key, public_key = signing_keys + _use_static_signing_key(monkeypatch, public_key) + app = create_app(settings=make_settings(), backend=FakeBackend()) + + with TestClient(app) as client: + result = client.get( + "/jobs/job-1", + headers={ + "Authorization": f"Bearer {_token(private_key, **claim_overrides)}" + }, + ) + + assert result.status_code == 403 + + +def test_malformed_token_returns_403(monkeypatch, signing_keys): + _, public_key = signing_keys + _use_static_signing_key(monkeypatch, public_key) + app = create_app(settings=make_settings(), backend=FakeBackend()) + + with TestClient(app) as client: + result = client.get( + "/jobs/job-1", + headers={"Authorization": "Bearer not-a-jwt"}, + ) + + assert result.status_code == 403 diff --git a/projects/policyengine-simulation-entry/tests/test_backend.py b/projects/policyengine-simulation-entry/tests/test_backend.py new file mode 100644 index 000000000..3b66da246 --- /dev/null +++ b/projects/policyengine-simulation-entry/tests/test_backend.py @@ -0,0 +1,248 @@ +from __future__ import annotations + +import json + +import httpx +import pytest + +from policyengine_simulation_entry.backend import ( + BackendAuthenticationError, + BackendUnavailable, + OldGatewayBackend, +) + +from conftest import make_settings + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "token_payload", + [ + {}, + {"access_token": "", "expires_in": 3600}, + {"access_token": "service-token", "expires_in": 0}, + ], + ids=["missing-fields", "empty-token", "non-positive-expiry"], +) +async def test_backend_rejects_an_invalid_service_token_schema(token_payload): + def handler(request: httpx.Request) -> httpx.Response: + assert request.url.path == "/oauth/token" + return httpx.Response(200, json=token_payload) + + backend = OldGatewayBackend( + make_settings(), + transport=httpx.MockTransport(handler), + ) + await backend.start() + try: + with pytest.raises(BackendAuthenticationError): + await backend.request("GET", "/jobs/fc-123") + finally: + await backend.close() + + +@pytest.mark.asyncio +async def test_backend_uses_own_token_and_preserves_safe_response_headers(): + requests: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + if request.url.path == "/oauth/token": + return httpx.Response( + 200, + json={"access_token": "service-token", "expires_in": 3600}, + ) + return httpx.Response( + 202, + json={"job_id": "fc-123", "status": "submitted"}, + headers={ + "Retry-After": "2", + "Set-Cookie": "must-not-pass", + "X-Request-ID": "upstream-request", + }, + ) + + backend = OldGatewayBackend( + make_settings(), + transport=httpx.MockTransport(handler), + ) + await backend.start() + try: + result = await backend.request( + "POST", + "/simulate/economy/comparison", + json_body={"country": "us"}, + request_id="caller-request", + ) + finally: + await backend.close() + + upstream = requests[-1] + assert upstream.headers["authorization"] == "Bearer service-token" + assert upstream.headers["x-request-id"] == "caller-request" + assert json.loads(result.content) == { + "job_id": "fc-123", + "status": "submitted", + } + assert result.headers["retry-after"] == "2" + assert "set-cookie" not in result.headers + + +@pytest.mark.asyncio +@pytest.mark.parametrize("rejection_status", [401, 403]) +async def test_backend_refreshes_token_once_after_gateway_auth_rejection( + rejection_status, +): + token_fetches = 0 + gateway_calls = 0 + + def handler(request: httpx.Request) -> httpx.Response: + nonlocal token_fetches, gateway_calls + if request.url.path == "/oauth/token": + token_fetches += 1 + return httpx.Response( + 200, + json={ + "access_token": f"token-{token_fetches}", + "expires_in": 3600, + }, + ) + gateway_calls += 1 + if gateway_calls == 1: + return httpx.Response(rejection_status) + return httpx.Response(200, json={"status": "complete"}) + + backend = OldGatewayBackend( + make_settings(), + transport=httpx.MockTransport(handler), + ) + await backend.start() + try: + result = await backend.request("GET", "/jobs/fc-123") + finally: + await backend.close() + + assert result.status_code == 200 + assert token_fetches == 2 + assert gateway_calls == 2 + + +@pytest.mark.asyncio +async def test_repeated_gateway_auth_rejection_is_backend_unavailable(): + token_fetches = 0 + + def handler(request: httpx.Request) -> httpx.Response: + nonlocal token_fetches + if request.url.path == "/oauth/token": + token_fetches += 1 + return httpx.Response( + 200, + json={ + "access_token": f"token-{token_fetches}", + "expires_in": 3600, + }, + ) + return httpx.Response(403) + + backend = OldGatewayBackend( + make_settings(), + transport=httpx.MockTransport(handler), + ) + await backend.start() + try: + with pytest.raises(BackendAuthenticationError): + await backend.request("POST", "/simulate/economy/comparison") + finally: + await backend.close() + + assert token_fetches == 2 + + +@pytest.mark.asyncio +async def test_short_lived_backend_token_is_never_cached_past_expiry(): + token_fetches = 0 + + def handler(request: httpx.Request) -> httpx.Response: + nonlocal token_fetches + if request.url.path == "/oauth/token": + token_fetches += 1 + return httpx.Response( + 200, + json={"access_token": f"token-{token_fetches}", "expires_in": 1}, + ) + return httpx.Response(200, json={"status": "complete"}) + + backend = OldGatewayBackend( + make_settings(), + transport=httpx.MockTransport(handler), + ) + await backend.start() + try: + await backend.request("GET", "/jobs/fc-123") + await backend.request("GET", "/jobs/fc-123") + finally: + await backend.close() + + assert token_fetches == 2 + + +@pytest.mark.asyncio +async def test_post_connection_error_is_not_retried(): + gateway_attempts = 0 + + def handler(request: httpx.Request) -> httpx.Response: + nonlocal gateway_attempts + if request.url.path == "/oauth/token": + return httpx.Response( + 200, + json={"access_token": "token", "expires_in": 3600}, + ) + gateway_attempts += 1 + raise httpx.ConnectError("unavailable", request=request) + + backend = OldGatewayBackend( + make_settings(), + transport=httpx.MockTransport(handler), + ) + await backend.start() + try: + with pytest.raises(BackendUnavailable): + await backend.request( + "POST", + "/simulate/economy/comparison", + json_body={"country": "us"}, + ) + finally: + await backend.close() + + assert gateway_attempts == 1 + + +@pytest.mark.asyncio +async def test_get_connection_error_is_retried_once(): + gateway_attempts = 0 + + def handler(request: httpx.Request) -> httpx.Response: + nonlocal gateway_attempts + if request.url.path == "/oauth/token": + return httpx.Response( + 200, + json={"access_token": "token", "expires_in": 3600}, + ) + gateway_attempts += 1 + if gateway_attempts == 1: + raise httpx.ConnectError("unavailable", request=request) + return httpx.Response(200, json={"status": "complete"}) + + backend = OldGatewayBackend( + make_settings(), + transport=httpx.MockTransport(handler), + ) + await backend.start() + try: + result = await backend.request("GET", "/jobs/fc-123") + finally: + await backend.close() + + assert result.status_code == 200 + assert gateway_attempts == 2 diff --git a/projects/policyengine-simulation-entry/tests/test_config.py b/projects/policyengine-simulation-entry/tests/test_config.py new file mode 100644 index 000000000..8820509d0 --- /dev/null +++ b/projects/policyengine-simulation-entry/tests/test_config.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +import pytest + +from policyengine_simulation_entry.config import ConfigurationError + +from conftest import make_settings + + +def test_production_refuses_disabled_auth(): + with pytest.raises(ConfigurationError, match="cannot be disabled"): + make_settings(environment="production", auth_required=False).validate() + + +def test_partial_caller_auth_is_rejected(): + with pytest.raises(ConfigurationError, match="Set both"): + make_settings(auth_audience="").validate() + + +@pytest.mark.parametrize( + "old_gateway_url", + [ + "https://policyengine-simulation-entry-abc-uc.a.run.app", + "http://policyengine--policyengine-simulation-gateway-web-app.modal.run", + "not-a-url", + ], +) +def test_old_gateway_must_be_the_https_modal_service(old_gateway_url): + with pytest.raises(ConfigurationError, match="OLD_GATEWAY_URL must"): + make_settings(old_gateway_url=old_gateway_url).validate() + + +def test_complete_configuration_is_valid(): + make_settings().validate() diff --git a/projects/policyengine-simulation-entry/tests/test_deployment_assets.py b/projects/policyengine-simulation-entry/tests/test_deployment_assets.py new file mode 100644 index 000000000..4bff5147f --- /dev/null +++ b/projects/policyengine-simulation-entry/tests/test_deployment_assets.py @@ -0,0 +1,297 @@ +from __future__ import annotations + +import os +import subprocess +import textwrap +import tomllib +from pathlib import Path + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[3] +TRAFFIC_SCRIPT = ( + REPOSITORY_ROOT + / ".github" + / "scripts" + / "set-cloud-run-simulation-entry-revision.sh" +) +SMOKE_SCRIPT = ( + REPOSITORY_ROOT / ".github" / "scripts" / "cloud-run-simulation-entry-smoke.sh" +) +DEPLOY_WORKFLOW = REPOSITORY_ROOT / ".github" / "workflows" / "simulation-deploy.yml" +REUSABLE_DEPLOY_WORKFLOW = ( + REPOSITORY_ROOT / ".github" / "workflows" / "simulation-deploy.reusable.yml" +) +PUBLISH_WORKFLOW = REPOSITORY_ROOT / ".github" / "workflows" / "publish-clients.yml" +AUTHENTICATED_TESTS = ( + REPOSITORY_ROOT + / "projects" + / "policyengine-simulation-entry" + / "authenticated_tests" +) +DOCKERIGNORE = ( + REPOSITORY_ROOT + / "projects" + / "policyengine-simulation-entry" + / "Dockerfile.dockerignore" +) + + +def test_deployment_scripts_have_valid_shell_syntax(): + subprocess.run(["bash", "-n", TRAFFIC_SCRIPT], check=True) + subprocess.run(["bash", "-n", SMOKE_SCRIPT], check=True) + assert os.access(SMOKE_SCRIPT, os.X_OK) + + +def test_deployment_uses_gcloud_workflow_without_terraform(): + deploy_workflow = DEPLOY_WORKFLOW.read_text(encoding="utf-8") + reusable_workflow = REUSABLE_DEPLOY_WORKFLOW.read_text(encoding="utf-8") + + assert "gcloud run deploy" in reusable_workflow + assert "set-cloud-run-simulation-entry-revision.sh" in reusable_workflow + assert "terraform" not in reusable_workflow.lower() + assert "deploy_entrypoint:" in reusable_workflow + assert "deploy_gateway:" in reusable_workflow + assert "deploy_executor:" in reusable_workflow + assert "update_routing:" in reusable_workflow + assert "integration:" in reusable_workflow + assert "authenticated_test:" in reusable_workflow + assert "promote_entrypoint:" in reusable_workflow + assert ( + "needs: [prepare, deploy_entrypoint, deploy_gateway, deploy_executor]" + in reusable_workflow + ) + assert "needs: beta" in deploy_workflow + assert deploy_workflow.index("beta:") < deploy_workflow.index("prod:") + assert "release_environment: beta" in deploy_workflow + assert "release_environment: prod" in deploy_workflow + assert "promote_entrypoint: false" in deploy_workflow + assert "promote_entrypoint: true" in deploy_workflow + assert "entrypoint_environment" not in deploy_workflow + assert "entrypoint_environment" not in reusable_workflow + assert "entrypoint_public_url" not in deploy_workflow + assert "entrypoint_public_url" not in reusable_workflow + assert "SIMULATION_ENTRYPOINT_PUBLIC_URL" not in reusable_workflow + assert "staging.simulation.api.policyengine.org" not in deploy_workflow + assert ( + reusable_workflow.count("environment: ${{ inputs.release_environment }}") == 8 + ) + assert "APP_ENVIRONMENT=${{ inputs.release_environment }}" in reusable_workflow + assert "id-token: write" in reusable_workflow + assert ( + reusable_workflow.count("vars.OLD_GATEWAY_AUTH_CLIENT_SECRET_SECRET_NAME") == 1 + ) + assert "simulation-entry-old-gateway-client-secret" not in reusable_workflow + assert AUTHENTICATED_TESTS.joinpath("test_deployed_auth.py").is_file() + assert not list( + (REPOSITORY_ROOT / "projects" / "policyengine-simulation-entry" / "infra").glob( + "*.tf" + ) + ) + assert not ( + REPOSITORY_ROOT + / ".github" + / "scripts" + / "configure-cloud-run-simulation-entry-domains.sh" + ).exists() + + +def test_full_stack_promotion_order_is_explicit(): + deploy_workflow = DEPLOY_WORKFLOW.read_text(encoding="utf-8") + reusable_workflow = REUSABLE_DEPLOY_WORKFLOW.read_text(encoding="utf-8") + + for deployment_step, unit_test_step in ( + ("Deploy Cloud Run entrypoint candidate", "Run entrypoint unit tests"), + ("Deploy stable Modal gateway", "Run gateway unit tests"), + ("Deploy versioned Modal executor", "Run executor unit tests"), + ): + assert reusable_workflow.index(deployment_step) < reusable_workflow.index( + unit_test_step + ) + + routing_dependencies = ( + "needs: [prepare, deploy_entrypoint, deploy_gateway, deploy_executor]" + ) + assert routing_dependencies in reusable_workflow + assert reusable_workflow.index("update_routing:") < reusable_workflow.index( + "integration:" + ) + assert reusable_workflow.index("integration:") < reusable_workflow.index( + "authenticated_test:" + ) + assert reusable_workflow.index("\n authenticated_test:") < reusable_workflow.index( + "\n promote_entrypoint:" + ) + assert "needs: [deploy_entrypoint, authenticated_test]" in reusable_workflow + assert ( + 'cloud-run-simulation-entry-smoke.sh "${STABLE_URL}" "${TARGET_REVISION}"' + in reusable_workflow + ) + assert "failure() && steps.promote.outcome == 'success'" in reusable_workflow + assert "needs: beta" in deploy_workflow + assert "skip_beta" not in deploy_workflow + + +def test_traffic_changes_are_exact_revision_validated_and_reversible(tmp_path): + script = TRAFFIC_SCRIPT.read_text(encoding="utf-8") + + assert "run revisions describe" in script + assert 'select(.type == "Ready" and .status == "True")' in script + assert '["serving.knative.dev/service"]' in script + assert 'run services update-traffic "${service}"' in script + assert '--to-revisions "${target_revision}=100"' in script + assert "expected_current_revision" in script + assert '--to-revisions "LATEST=100"' not in script + + state_file = tmp_path / "active-revision" + state_file.write_text("policyengine-simulation-entry-00001-old", encoding="utf-8") + fake_gcloud = tmp_path / "gcloud" + fake_gcloud.write_text( + textwrap.dedent( + """\ + #!/usr/bin/env bash + set -euo pipefail + case "$1 $2 $3" in + "run services describe") + active="$(cat "${FAKE_GCLOUD_STATE}")" + printf '{"status":{"traffic":[{"revisionName":"%s","percent":100}]}}\\n' "${active}" + ;; + "run revisions describe") + revision="$4" + printf '{"metadata":{"labels":{"serving.knative.dev/service":"policyengine-simulation-entry"}},"status":{"conditions":[{"type":"Ready","status":"True"}]},"revision":"%s"}\\n' "${revision}" + ;; + "run services update-traffic") + target="" + while [ "$#" -gt 0 ]; do + if [ "$1" = "--to-revisions" ]; then + target="${2%=100}" + break + fi + shift + done + test -n "${target}" + printf '%s' "${target}" > "${FAKE_GCLOUD_STATE}" + ;; + *) + printf 'Unexpected fake gcloud command: %s\\n' "$*" >&2 + exit 2 + ;; + esac + """ + ), + encoding="utf-8", + ) + fake_gcloud.chmod(0o755) + + base_env = { + **os.environ, + "GCLOUD_BIN": str(fake_gcloud), + "FAKE_GCLOUD_STATE": str(state_file), + "SIMULATION_ENTRYPOINT_GCP_PROJECT_ID": "simulation-entry-test", + "SIMULATION_ENTRYPOINT_SERVICE": "policyengine-simulation-entry", + } + + subprocess.run( + ["bash", TRAFFIC_SCRIPT], + check=True, + capture_output=True, + text=True, + env={ + **base_env, + "SIMULATION_ENTRYPOINT_TARGET_REVISION": "policyengine-simulation-entry-00002-new", + "SIMULATION_ENTRYPOINT_EXPECTED_CURRENT_REVISION": "policyengine-simulation-entry-00001-old", + }, + ) + assert ( + state_file.read_text(encoding="utf-8") + == "policyengine-simulation-entry-00002-new" + ) + + subprocess.run( + ["bash", TRAFFIC_SCRIPT], + check=True, + capture_output=True, + text=True, + env={ + **base_env, + "SIMULATION_ENTRYPOINT_TARGET_REVISION": "policyengine-simulation-entry-00001-old", + "SIMULATION_ENTRYPOINT_EXPECTED_CURRENT_REVISION": "policyengine-simulation-entry-00002-new", + }, + ) + assert ( + state_file.read_text(encoding="utf-8") + == "policyengine-simulation-entry-00001-old" + ) + + +def test_traffic_change_refuses_an_intervening_promotion(tmp_path): + state_file = tmp_path / "active-revision" + state_file.write_text( + "policyengine-simulation-entry-00003-intervening", + encoding="utf-8", + ) + fake_gcloud = tmp_path / "gcloud" + fake_gcloud.write_text( + textwrap.dedent( + """\ + #!/usr/bin/env bash + set -euo pipefail + active="$(cat "${FAKE_GCLOUD_STATE}")" + printf '{"status":{"traffic":[{"revisionName":"%s","percent":100}]}}\\n' "${active}" + """ + ), + encoding="utf-8", + ) + fake_gcloud.chmod(0o755) + + result = subprocess.run( + ["bash", TRAFFIC_SCRIPT], + check=False, + capture_output=True, + text=True, + env={ + **os.environ, + "GCLOUD_BIN": str(fake_gcloud), + "FAKE_GCLOUD_STATE": str(state_file), + "SIMULATION_ENTRYPOINT_GCP_PROJECT_ID": "simulation-entry-test", + "SIMULATION_ENTRYPOINT_SERVICE": "policyengine-simulation-entry", + "SIMULATION_ENTRYPOINT_TARGET_REVISION": "policyengine-simulation-entry-00002-new", + "SIMULATION_ENTRYPOINT_EXPECTED_CURRENT_REVISION": "policyengine-simulation-entry-00001-old", + }, + ) + + assert result.returncode == 2 + assert "Stable traffic changed after deployment" in result.stderr + + +def test_container_context_excludes_local_environments_and_unrelated_projects(): + ignore_rules = DOCKERIGNORE.read_text(encoding="utf-8") + + assert "**/.venv" in ignore_rules + assert "projects/*" in ignore_rules + assert "!projects/policyengine-simulation-entry/**" in ignore_rules + assert "projects/policyengine-simulation-entry/authenticated_tests" in ignore_rules + assert "libs/*" in ignore_rules + assert ignore_rules.index("**/.venv") > ignore_rules.index( + "!projects/policyengine-simulation-entry/**" + ) + assert ignore_rules.index("**/.venv") > ignore_rules.index( + "!libs/policyengine-simulation-contract/**" + ) + + +def test_production_lock_excludes_modal_and_database_runtime_packages(): + lockfile = tomllib.loads( + ( + REPOSITORY_ROOT / "projects" / "policyengine-simulation-entry" / "uv.lock" + ).read_text(encoding="utf-8") + ) + resolved_packages = {package["name"] for package in lockfile["package"]} + + for package in ("modal", "policyengine-fastapi", "sqlalchemy", "sqlmodel"): + assert package not in resolved_packages + + +def test_client_publication_checks_out_the_deployed_commit(): + workflow = PUBLISH_WORKFLOW.read_text(encoding="utf-8") + + assert "github.event.workflow_run.head_sha" in workflow diff --git a/projects/policyengine-simulation-entry/tests/test_openapi.py b/projects/policyengine-simulation-entry/tests/test_openapi.py new file mode 100644 index 000000000..9b1c16afb --- /dev/null +++ b/projects/policyengine-simulation-entry/tests/test_openapi.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +from copy import deepcopy +import json +from pathlib import Path +from typing import Literal, TypedDict, cast + +from policyengine_simulation_entry.app import create_app + + +type HttpMethod = Literal["get", "post", "put", "patch", "delete"] + + +class OpenAPIOperation(TypedDict, total=False): + operationId: str + security: list[object] + + +type OpenAPIPathMap = dict[str, dict[str, OpenAPIOperation]] + + +class OpenAPIComponents(TypedDict): + schemas: dict[str, object] + + +class OpenAPIDocument(TypedDict): + paths: OpenAPIPathMap + components: OpenAPIComponents + + +EXPECTED_ROUTES = { + ("GET", "/budget-window-jobs/{batch_job_id}"), + ("GET", "/health"), + ("GET", "/jobs/{job_id}"), + ("GET", "/ready"), + ("GET", "/versions"), + ("GET", "/versions/{kind}"), + ("POST", "/ping"), + ("POST", "/simulate/economy/budget-window"), + ("POST", "/simulate/economy/comparison"), +} + + +def methods(spec: OpenAPIDocument) -> set[tuple[str, str]]: + return { + (method.upper(), path) + for path, operations in spec["paths"].items() + for method in operations + if method.lower() in {"get", "post", "put", "patch", "delete"} + } + + +def operation_ids(spec: OpenAPIDocument) -> dict[tuple[str, str], str]: + return { + (method.upper(), path): operation["operationId"] + for path, operations in spec["paths"].items() + for method, operation in operations.items() + if method.lower() in {"get", "post", "put", "patch", "delete"} + } + + +def normalized_compatibility_paths(spec: OpenAPIDocument) -> OpenAPIPathMap: + """Remove the two intentional Cloud Run-only OpenAPI additions.""" + paths = deepcopy(spec["paths"]) + paths.pop("/ready", None) + for operations in paths.values(): + for method, operation in operations.items(): + if method.lower() in {"get", "post", "put", "patch", "delete"}: + operation.pop("security", None) + return paths + + +def test_route_table_is_frozen(): + assert methods(cast(OpenAPIDocument, create_app().openapi())) == EXPECTED_ROUTES + + +def test_old_gateway_routes_are_all_present(): + gateway_spec_path = ( + Path(__file__).resolve().parents[2] + / "policyengine-simulation-gateway" + / "tests" + / "golden" + / "openapi.json" + ) + gateway_spec = cast( + OpenAPIDocument, + json.loads(gateway_spec_path.read_text()), + ) + cloud_run_routes = methods(cast(OpenAPIDocument, create_app().openapi())) + + assert methods(gateway_spec) <= cloud_run_routes + + +def test_normalized_contract_matches_old_gateway(): + gateway_spec_path = ( + Path(__file__).resolve().parents[2] + / "policyengine-simulation-gateway" + / "tests" + / "golden" + / "openapi.json" + ) + gateway_spec = cast( + OpenAPIDocument, + json.loads(gateway_spec_path.read_text()), + ) + cloud_run_spec = cast(OpenAPIDocument, create_app().openapi()) + + assert normalized_compatibility_paths( + cloud_run_spec + ) == normalized_compatibility_paths(gateway_spec) + cloud_run_schemas = deepcopy(cloud_run_spec["components"]["schemas"]) + cloud_run_schemas.pop("ReadinessResponse") + assert cloud_run_schemas == gateway_spec["components"]["schemas"] + gateway_operation_ids = operation_ids(gateway_spec) + cloud_run_operation_ids = operation_ids(cloud_run_spec) + assert { + key: cloud_run_operation_ids[key] for key in gateway_operation_ids + } == gateway_operation_ids diff --git a/projects/policyengine-simulation-entry/uv.lock b/projects/policyengine-simulation-entry/uv.lock new file mode 100644 index 000000000..d72a01a7f --- /dev/null +++ b/projects/policyengine-simulation-entry/uv.lock @@ -0,0 +1,1047 @@ +version = 1 +revision = 3 +requires-python = "==3.13.*" + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, +] + +[[package]] +name = "asgiref" +version = "3.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/26/3b59f2bdae5f640389becb1f673cded775287f5fc4f816309d9ca9a3f93d/asgiref-3.12.1.tar.gz", hash = "sha256:59dcb51c272ad209d59bed5708a64a333083e86017d7fcdd67498eeab7784340", size = 42378, upload-time = "2026-07-14T09:56:18.087Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/1b/54f4ad77cd8a584fa70746c47df988e002cf1ee1eba43364d46f87803647/asgiref-3.12.1-py3-none-any.whl", hash = "sha256:fe386d1c2bff7259ea95929266d12a8cf9a8b5a1c2598402967d8792e7a7c094", size = 25478, upload-time = "2026-07-14T09:56:16.926Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "cffi" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/5f/ff100cae70ebe9d8df1c01a00e510e45d9adb5c1fdda84791b199141de97/cffi-2.1.0.tar.gz", hash = "sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9", size = 531036, upload-time = "2026-07-06T21:34:30.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/88/a996879e2eeccb815f6e3a5967b12a308257412acec882039d386bd2aa7b/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda", size = 194331, upload-time = "2026-07-06T21:33:03.697Z" }, + { url = "https://files.pythonhosted.org/packages/58/85/7ae00d5c8dd6266f4e944c3db630f3c5c9a98b61d469c714d848b1d8138a/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b", size = 196966, upload-time = "2026-07-06T21:33:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e9/45c3a76ad8d43ad9261f4c95436da61128d3ca545d72b9612c0ab5be0b1c/cffi-2.1.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a", size = 184795, upload-time = "2026-07-06T21:33:06.699Z" }, + { url = "https://files.pythonhosted.org/packages/84/4c/82f132cb4418ee6d953d982b19191e87e2a6372c8a4ce36e50b69d6ade4a/cffi-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea", size = 184746, upload-time = "2026-07-06T21:33:08.071Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1c/4ed5a0e5bdca6cbc275556de3328dd1b76fd0c11cc13c88fe66d1d8715f2/cffi-2.1.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db", size = 214747, upload-time = "2026-07-06T21:33:09.671Z" }, + { url = "https://files.pythonhosted.org/packages/3a/a6/e879bb68cc23a2bc9ba8f4b7d8019f0c2694bad2ab6c4a3701d429439f58/cffi-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f", size = 222392, upload-time = "2026-07-06T21:33:10.896Z" }, + { url = "https://files.pythonhosted.org/packages/88/f6/01890cfd63c08f8eb96a8319b0443690197d240a8bd6346048cf7bde9190/cffi-2.1.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d", size = 210285, upload-time = "2026-07-06T21:33:12.251Z" }, + { url = "https://files.pythonhosted.org/packages/a6/cf/2b684132056f438567b61e19d690dd31cd0921ace051e0a458be6074369e/cffi-2.1.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0", size = 208801, upload-time = "2026-07-06T21:33:13.617Z" }, + { url = "https://files.pythonhosted.org/packages/6f/08/f2e7d62c460faae0926f2d6e423694aa409ced3bc1fe2927a0a6e5f05416/cffi-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224", size = 221808, upload-time = "2026-07-06T21:33:15.466Z" }, + { url = "https://files.pythonhosted.org/packages/38/37/04f54b8e63a02f3d908332c9effbf8c366167c6f733ed8a3d4f79b7e2a1e/cffi-2.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c", size = 225241, upload-time = "2026-07-06T21:33:16.869Z" }, + { url = "https://files.pythonhosted.org/packages/a9/d6/c72eecca433cd3e681c65ed313ab4835d9d4a379704d0f628a6a05f51c2e/cffi-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a", size = 223588, upload-time = "2026-07-06T21:33:18.239Z" }, + { url = "https://files.pythonhosted.org/packages/c6/4b/e706f67279140f92939da3475ad610df18bfd52d50f14953a8e5fede71d5/cffi-2.1.0-cp313-cp313-win32.whl", hash = "sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2", size = 175248, upload-time = "2026-07-06T21:33:19.799Z" }, + { url = "https://files.pythonhosted.org/packages/5a/47/59eb7975cb0e4ef0afa764ea945b29a5bb4537a9f771cb7d6c8a5dd74c95/cffi-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512", size = 185717, upload-time = "2026-07-06T21:33:21.47Z" }, + { url = "https://files.pythonhosted.org/packages/5a/af/34fee85c48f8d94efc8597bc09470c9dd274c145f1c12e0fbc6ab6d38d74/cffi-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f", size = 180114, upload-time = "2026-07-06T21:33:22.515Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", size = 152439, upload-time = "2026-07-07T14:34:58.454Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/06/97ec2aeae780b31d742b6352218b43841a6871e2564578ca522dce4a45c3/charset_normalizer-3.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614", size = 317688, upload-time = "2026-07-07T14:33:35.408Z" }, + { url = "https://files.pythonhosted.org/packages/d0/39/8ff066c672434225f8d25f8b739f992af250944392173dcc88362681c9bf/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698", size = 214982, upload-time = "2026-07-07T14:33:36.996Z" }, + { url = "https://files.pythonhosted.org/packages/92/8f/3a47a3667c83c2df9483d91644c6c107de3bf8874aa1793da9d3012eb986/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b", size = 236460, upload-time = "2026-07-07T14:33:38.536Z" }, + { url = "https://files.pythonhosted.org/packages/f1/60/b22cdbee7e4013dab8b0d7647fc6181120fbbbc8f7025c226d15bd5a47fc/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9", size = 232003, upload-time = "2026-07-07T14:33:40.059Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f8/72eb13dcabe7257035cea8aefd922caad2f110d252bf9f67c4c2ca763aee/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33", size = 223149, upload-time = "2026-07-07T14:33:41.631Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3e/faee8f9de92b14ee1198e9163252bb15efee7301b31256a3b6d9ebfdd0dd/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63", size = 207901, upload-time = "2026-07-07T14:33:43.209Z" }, + { url = "https://files.pythonhosted.org/packages/3a/25/45f30093ae27dd7b92a793b61882a38685f993700113ca36e0c9c14965e1/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0", size = 219176, upload-time = "2026-07-07T14:33:44.725Z" }, + { url = "https://files.pythonhosted.org/packages/48/18/c8f397329c35e32f6a837e488986f4ae03bd2abebc453b48714991630c2f/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe", size = 217356, upload-time = "2026-07-07T14:33:46.192Z" }, + { url = "https://files.pythonhosted.org/packages/86/7e/5ce0bba863470fd1902d5e5843968951bddf38abe4742fc97116ef4598b3/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35", size = 209614, upload-time = "2026-07-07T14:33:47.705Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ef/2473d3c4d869155be4af1191111d59c4d5c4e0173026f7e85b176e23bf65/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8", size = 224991, upload-time = "2026-07-07T14:33:49.238Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a3/53ddae3db108a088156aa8ddfafd411ebbc1340f48c5573f697b27f69a39/charset_normalizer-3.4.9-cp313-cp313-win32.whl", hash = "sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9", size = 150622, upload-time = "2026-07-07T14:33:50.711Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ef/6953a77c7cf2c2ff9998e6f575ab3e380119f100223381565a4f94c1f836/charset_normalizer-3.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115", size = 161947, upload-time = "2026-07-07T14:33:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/6e/fb/d560d1d1555debbfe7849d9cac6145c1b537709d79576bf22557ed803b82/charset_normalizer-3.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012", size = 152594, upload-time = "2026-07-07T14:33:53.486Z" }, + { url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" }, +] + +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.15.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/76/d0/55fe630f4cf94e3fcba868240fad8c8cdd1f764e2a932f8926347e6ec4cd/coverage-7.15.2.tar.gz", hash = "sha256:3df60dc267f0a2ca23cb7a9ab1109c62b9335ffbf519fcfe167157c28c09b81d", size = 927741, upload-time = "2026-07-15T18:56:19.558Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/d5/f8c838e6b7282976f7c918884b792df7a0c42c5bba5d99c60ad2d221d56d/coverage-7.15.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1121caa19159a38b5463eaae4b1e1fde81e525b15ecc5e000cd5b1a108f743a8", size = 221606, upload-time = "2026-07-15T18:54:45.448Z" }, + { url = "https://files.pythonhosted.org/packages/bf/37/97c926376364f66298cc44893b89cdf17b8bc406376497c4061ae4b8a8ff/coverage-7.15.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a300c6934e0989c327b9e8a1e110329da4641149f872bbe9f70168be66da76c1", size = 221982, upload-time = "2026-07-15T18:54:47.341Z" }, + { url = "https://files.pythonhosted.org/packages/b7/30/a36050a6e83c2135ee0776f452ca3948224befc6d7f26acecc082d0c106a/coverage-7.15.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2617f8799d268fabdeef42a7e89ac3a23e1deee9025427db2df970f99a89a578", size = 252972, upload-time = "2026-07-15T18:54:49.2Z" }, + { url = "https://files.pythonhosted.org/packages/31/d3/06b5f1daf95f0f15ab05bd75f26ba5f3c8b33d0bb72f3aaa3cf41d1bad3a/coverage-7.15.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7dc2950a2992cd676d35c20ae63522836deeb034f08874699d14068710af3dc1", size = 255569, upload-time = "2026-07-15T18:54:51.098Z" }, + { url = "https://files.pythonhosted.org/packages/81/1c/9afb3f8de2b8d36960391c48559a2e3ff96594b58099f115921549ea8d0d/coverage-7.15.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9e36686f7a442185db2400b3df171aac520869faf9deb59df687d28659eda2a6", size = 256806, upload-time = "2026-07-15T18:54:53.145Z" }, + { url = "https://files.pythonhosted.org/packages/64/d8/b989f96061a5e32d82fddd1b1b9ff48a7c8f8ae7606f0e80fd9de54b1e33/coverage-7.15.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d29ca7bd67af6e12e74632d65f026eabc1364da5c254494cd914446a28a3ef7", size = 258936, upload-time = "2026-07-15T18:54:55.015Z" }, + { url = "https://files.pythonhosted.org/packages/b8/fa/f99771f5110457c7b511c1935ca49ddf288218eaa84322e028b9334146ae/coverage-7.15.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:db9c8438057e5b0f6a22a0af99c0c1d26b57fbbdbd1be5861ddb8f897fcc3a2d", size = 253178, upload-time = "2026-07-15T18:54:57.527Z" }, + { url = "https://files.pythonhosted.org/packages/f6/96/c098a6044d119c751ceede7be91035fa8310170ec24a6523aff72f0a5793/coverage-7.15.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:63022c4c8dec1d0342f05c3ede99842fe3d007689acc45e86f123a1746e4a026", size = 254934, upload-time = "2026-07-15T18:54:59.41Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a2/1457b3a7a50c8d77500103b97a046db863e2f59a1cf6d2f814595f349885/coverage-7.15.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6c0be82b4d4aa5b2704e08518e2252f3e3d110164bcca826816801052e48a7aa", size = 252898, upload-time = "2026-07-15T18:55:01.338Z" }, + { url = "https://files.pythonhosted.org/packages/6c/0e/76958874c471ecfcdde0d2b2747bb2c61bdbf34a40636f4ce9db9923e643/coverage-7.15.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4510fb9cdf6bb02dfa6af0be4a534b8102d086e22e4a33f8836df663da3d660d", size = 257056, upload-time = "2026-07-15T18:55:03.243Z" }, + { url = "https://files.pythonhosted.org/packages/7c/7c/3d7c4e3bf58baa40327dc7edc2272b17cf02299366d52763db1b0ca1556a/coverage-7.15.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:42ec3d989421b174a2ab607c1539f24127ad362757b7f1c0c0d7a2993f7eb37b", size = 252718, upload-time = "2026-07-15T18:55:05.029Z" }, + { url = "https://files.pythonhosted.org/packages/c8/b8/1cecffed9ce14fb25be9ba42d37b6bb61485c9a3ddd43cd3dde36b6087d8/coverage-7.15.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e8f91bce78e32343af184c3b7fa28fcf5a9e2641f4b6623d392038f804939188", size = 254490, upload-time = "2026-07-15T18:55:06.889Z" }, + { url = "https://files.pythonhosted.org/packages/6c/2c/42984561bc7f4c045dca67516a0c50ee5ef8d84352dbeb5559dc86c4823e/coverage-7.15.2-cp313-cp313-win32.whl", hash = "sha256:434e68d531858205895eb0d74b73d20b84260de426387d53c422a5acda2cf050", size = 223647, upload-time = "2026-07-15T18:55:08.941Z" }, + { url = "https://files.pythonhosted.org/packages/41/9f/39c7c9245efc583beddf89a87683574e663ed93637f3afb6cd7b88405676/coverage-7.15.2-cp313-cp313-win_amd64.whl", hash = "sha256:26c3b04a6377fd7c09800921fa934e3a17c0020439cd59df73e73ae1d4b6a78c", size = 224190, upload-time = "2026-07-15T18:55:10.789Z" }, + { url = "https://files.pythonhosted.org/packages/c7/de/3a2883cf8a213659280ef4b403059e17a9acaeb7fc7fd4105e1226ff2e6d/coverage-7.15.2-cp313-cp313-win_arm64.whl", hash = "sha256:3ed010aa1b69cda8e827aabfca9866216c980e2dca82ab9a78c5f83689964c8b", size = 223583, upload-time = "2026-07-15T18:55:12.678Z" }, + { url = "https://files.pythonhosted.org/packages/ec/82/32e3bd191d498e64f6f911ad55d14006a0861e54869d2d32452326399e65/coverage-7.15.2-py3-none-any.whl", hash = "sha256:eb6bcae8d1a9d305351ecb108232441d11c5cfe9de840a04388ba5d2db8d735c", size = 213375, upload-time = "2026-07-15T18:56:17.305Z" }, +] + +[[package]] +name = "cryptography" +version = "49.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" }, + { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" }, + { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" }, + { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" }, + { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" }, + { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" }, + { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" }, + { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" }, + { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" }, + { url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" }, + { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" }, + { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" }, + { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" }, + { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" }, + { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" }, + { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" }, + { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" }, + { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" }, +] + +[[package]] +name = "deprecated" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523, upload-time = "2025-10-30T08:19:02.757Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" }, +] + +[[package]] +name = "executing" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488, upload-time = "2025-09-01T09:48:10.866Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, +] + +[[package]] +name = "fastapi" +version = "0.115.14" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ca/53/8c38a874844a8b0fa10dd8adf3836ac154082cf88d3f22b544e9ceea0a15/fastapi-0.115.14.tar.gz", hash = "sha256:b1de15cdc1c499a4da47914db35d0e4ef8f1ce62b624e94e0e5824421df99739", size = 296263, upload-time = "2025-06-26T15:29:08.21Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/50/b1222562c6d270fea83e9c9075b8e8600b8479150a18e4516a6138b980d1/fastapi-0.115.14-py3-none-any.whl", hash = "sha256:6c0c8bf9420bd58f565e585036d971872472b4f7d3f6c73b698e10cffdefb3ca", size = 95514, upload-time = "2025-06-26T15:29:06.49Z" }, +] + +[[package]] +name = "googleapis-common-protos" +version = "1.75.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b5/c8/f439cffde755cffa462bfbb156278fa6f9d09119719af9814b858fd4f81f/googleapis_common_protos-1.75.0.tar.gz", hash = "sha256:53a062ff3c32552fbd62c11fe23768b78e4ddf0494d5e5fd97d3f4689c75fbbd", size = 151035, upload-time = "2026-05-07T08:04:49.423Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/c8/e2645aa8ed02fd4c7a2f59d68783b65b1f3cbdfe39a6308e156509d1fee8/googleapis_common_protos-1.75.0-py3-none-any.whl", hash = "sha256:961ed60399c457ceb0ee8f285a84c870aabc9c6a832b9d37bb281b5bebde43ed", size = 300631, upload-time = "2026-05-07T08:03:30.345Z" }, +] + +[[package]] +name = "grpcio" +version = "1.82.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/bc/656b89387d6f4ed7e0686c7b64c2ae7e554a759aa58122c8e5fb99392c32/grpcio-1.82.1.tar.gz", hash = "sha256:707b24abd90fcb1e45bcc080577da1dbf9971d107490589b9539af8e1e77b4b5", size = 13187300, upload-time = "2026-07-08T12:36:16.588Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/3e/496992d08c0aaa11272eb6228dc8ab947da01fe835de243cd00521bce4c4/grpcio-1.82.1-cp313-cp313-linux_armv7l.whl", hash = "sha256:b454a2d97bfab7565683a02345f86bd182ab69fd7c2bdb7414171e7538f266b1", size = 6146068, upload-time = "2026-07-08T12:35:21.365Z" }, + { url = "https://files.pythonhosted.org/packages/e7/8f/f263d6f14fdba6b56cfadd91fd3e158a52682b72c6016d1f8723d435659f/grpcio-1.82.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:3dde70abfc80b3be11de53ba0d601c439e7fb2afd3583ad1788d1146bec92fdc", size = 11948600, upload-time = "2026-07-08T12:35:24.312Z" }, + { url = "https://files.pythonhosted.org/packages/8c/14/3a02e6ee49c2d85bc15eaae321e0e11ab3542cad3c5b2de121ecce0c4296/grpcio-1.82.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5523099c98c292ea1ae08e617249db760c56a78f8deae879027fe7d1ffbcbf6", size = 6714591, upload-time = "2026-07-08T12:35:27.027Z" }, + { url = "https://files.pythonhosted.org/packages/69/80/58e3738696f48ab7645347b98d8a7f93d10e00e6218388fbfcd6c9310e3d/grpcio-1.82.1-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:5e5c4dc0a59b0f8490a6bdfd6fc8395b9d8ad8a8407c7d67ca7b5bba15c0877f", size = 7454995, upload-time = "2026-07-08T12:35:29.599Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6c/2557c1a889363072fbf2285ecd0e8c44860d4dbd60f017a32537c5b863e2/grpcio-1.82.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c40d94ba820329cc191981bc22fa6f6eed0799c6d921f3c6709521d59d4a2fd7", size = 6888621, upload-time = "2026-07-08T12:35:32.38Z" }, + { url = "https://files.pythonhosted.org/packages/d2/66/907706ccaff1223f1e10fd5b37fc16faead43392fccb4e786e7e390ac141/grpcio-1.82.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4c816180e31e273caaec6f8bd86a8392499d5bbb26f41da44e3dce48bde69095", size = 7505069, upload-time = "2026-07-08T12:35:35.072Z" }, + { url = "https://files.pythonhosted.org/packages/b3/7c/ff97b0d0f635987ee5ec80dfedafa1aad629303745d48e8637d10eec5b80/grpcio-1.82.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e31fd780b261830720cb70b0fd8f0aa51d49e75a66d7464ad2e31d4b765f2580", size = 8535384, upload-time = "2026-07-08T12:35:37.954Z" }, + { url = "https://files.pythonhosted.org/packages/62/9e/a97fddd970a8d1588cade06eca20443761c1858b0ad6590a5c835aa18062/grpcio-1.82.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9d76152d7c31d7210d4a106e5d8b64da5bba5d6abf11be30e2f7b0a0c59bbcbf", size = 7910707, upload-time = "2026-07-08T12:35:40.797Z" }, + { url = "https://files.pythonhosted.org/packages/20/e4/eaba1517888af483a88d449eb7566f0f7f63446d46f339c5891798435875/grpcio-1.82.1-cp313-cp313-win32.whl", hash = "sha256:38e9dcb5258226fb3282630b31b16a968df52c8c6ad514af540646e0a4578f8a", size = 4240363, upload-time = "2026-07-08T12:35:43.298Z" }, + { url = "https://files.pythonhosted.org/packages/b0/42/66a98d47732e35290bef722f6149fed3709cd4cf61166f6f53a12f417302/grpcio-1.82.1-cp313-cp313-win_amd64.whl", hash = "sha256:3dbfb52c36d9511ac2b8e6c94fdde837b393ae520cc321f52a333a2deedf5a90", size = 5000980, upload-time = "2026-07-08T12:35:46.262Z" }, +] + +[[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 = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "importlib-metadata" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/12/33e59336dca5be0c398a7482335911a33aa0e20776128f038019f1a95f1b/importlib_metadata-8.5.0.tar.gz", hash = "sha256:71522656f0abace1d072b9e5481a48f07c138e00f079c38c8f883823f9c26bd7", size = 55304, upload-time = "2024-09-11T14:56:08.937Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/d9/a1e041c5e7caa9a05c925f4bdbdfb7f006d1f74996af53467bc394c97be7/importlib_metadata-8.5.0-py3-none-any.whl", hash = "sha256:45e54197d28b7a7f1559e60b95e7c567032b602131fbd588f1497f47880aa68b", size = 26514, upload-time = "2024-09-11T14:56:07.019Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "logfire" +version = "4.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "executing" }, + { name = "opentelemetry-exporter-otlp-proto-http" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-sdk" }, + { name = "protobuf" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/fa/14cbd976e8ff77290c0c87f93c45029785537ffb84e2f5215416786cda11/logfire-4.6.0.tar.gz", hash = "sha256:974bc8f4efd3aa9df2d0c7b1d53b35dc4612ff5cee2be6c40dd65e5c26eab694", size = 533174, upload-time = "2025-09-10T14:56:42.168Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/9c/6384dc69601bfba19c1f5909a472b00cc4b5863f411ecf85be308beacf8f/logfire-4.6.0-py3-none-any.whl", hash = "sha256:d322844db7a2f1935976f2852a07e3753489ecc4e83fa288173b05a453bb1cbf", size = 219556, upload-time = "2025-09-10T14:56:37.278Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + +[[package]] +name = "openapi-python-client" +version = "0.29.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "httpx" }, + { name = "jinja2" }, + { name = "pydantic" }, + { name = "ruamel-yaml" }, + { name = "ruff" }, + { name = "shellingham" }, + { name = "typer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/f6/67dfe73b5073251b6e2aba3d67191819798dd33dd09fcb2d12242662a960/openapi_python_client-0.29.0.tar.gz", hash = "sha256:4ff439a63765aaef548e69c4eedd86dfad57891dc322709450af0c2c9a72a23e", size = 125697, upload-time = "2026-05-30T20:32:10.533Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/d1/7391a205622c40b8e9d1b79b892b4944d6e3637674b47a56196e2c96c5d1/openapi_python_client-0.29.0-py3-none-any.whl", hash = "sha256:1085864c1e0a42fb50e1f5eb84363b19de07ebfb8a3f82a146c8529b948b8f12", size = 182954, upload-time = "2026-05-30T20:32:09.037Z" }, +] + +[[package]] +name = "opentelemetry-api" +version = "1.30.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "deprecated" }, + { name = "importlib-metadata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2b/6d/bbbf879826b7f3c89a45252010b5796fb1f1a0d45d9dc4709db0ef9a06c8/opentelemetry_api-1.30.0.tar.gz", hash = "sha256:375893400c1435bf623f7dfb3bcd44825fe6b56c34d0667c542ea8257b1a1240", size = 63703, upload-time = "2025-02-04T18:17:13.789Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/0a/eea862fae6413d8181b23acf8e13489c90a45f17986ee9cf4eab8a0b9ad9/opentelemetry_api-1.30.0-py3-none-any.whl", hash = "sha256:d5f5284890d73fdf47f843dda3210edf37a38d66f44f2b5aedc1e89ed455dc09", size = 64955, upload-time = "2025-02-04T18:16:46.167Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-common" +version = "1.30.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-proto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a2/d7/44098bf1ef89fc5810cdbda05faa2ae9322a0dbda4921cdc965dc68a9856/opentelemetry_exporter_otlp_proto_common-1.30.0.tar.gz", hash = "sha256:ddbfbf797e518411857d0ca062c957080279320d6235a279f7b64ced73c13897", size = 19640, upload-time = "2025-02-04T18:17:16.234Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/54/f4b3de49f8d7d3a78fd6e6e1a6fd27dd342eb4d82c088b9078c6a32c3808/opentelemetry_exporter_otlp_proto_common-1.30.0-py3-none-any.whl", hash = "sha256:5468007c81aa9c44dc961ab2cf368a29d3475977df83b4e30aeed42aa7bc3b38", size = 18747, upload-time = "2025-02-04T18:16:51.512Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-grpc" +version = "1.30.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "deprecated" }, + { name = "googleapis-common-protos" }, + { name = "grpcio" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-common" }, + { name = "opentelemetry-proto" }, + { name = "opentelemetry-sdk" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/86/3e/c7246df92c25e6ce95c349ad21597b4471b01ec9471e95d5261f1629fe92/opentelemetry_exporter_otlp_proto_grpc-1.30.0.tar.gz", hash = "sha256:d0f10f0b9b9a383b7d04a144d01cb280e70362cccc613987e234183fd1f01177", size = 26256, upload-time = "2025-02-04T18:17:16.956Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/35/d9f63fd84c2ed8dbd407bcbb933db4ed6e1b08e7fbdaca080b9ac309b927/opentelemetry_exporter_otlp_proto_grpc-1.30.0-py3-none-any.whl", hash = "sha256:2906bcae3d80acc54fd1ffcb9e44d324e8631058b502ebe4643ca71d1ff30830", size = 18550, upload-time = "2025-02-04T18:16:52.532Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-http" +version = "1.30.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "deprecated" }, + { name = "googleapis-common-protos" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-common" }, + { name = "opentelemetry-proto" }, + { name = "opentelemetry-sdk" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/f9/abb9191d536e6a2e2b7903f8053bf859a76bf784e3ca19a5749550ef19e4/opentelemetry_exporter_otlp_proto_http-1.30.0.tar.gz", hash = "sha256:c3ae75d4181b1e34a60662a6814d0b94dd33b628bee5588a878bed92cee6abdc", size = 15073, upload-time = "2025-02-04T18:17:18.446Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/3c/cdf34bc459613f2275aff9b258f35acdc4c4938dad161d17437de5d4c034/opentelemetry_exporter_otlp_proto_http-1.30.0-py3-none-any.whl", hash = "sha256:9578e790e579931c5ffd50f1e6975cbdefb6a0a0a5dea127a6ae87df10e0a589", size = 17245, upload-time = "2025-02-04T18:16:53.514Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation" +version = "0.51b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "packaging" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ec/5a/4c7f02235ac1269b48f3855f6be1afc641f31d4888d28b90b732fbce7141/opentelemetry_instrumentation-0.51b0.tar.gz", hash = "sha256:4ca266875e02f3988536982467f7ef8c32a38b8895490ddce9ad9604649424fa", size = 27760, upload-time = "2025-02-04T18:21:09.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/2c/48fa93f1acca9f79a06da0df7bfe916632ecc7fce1971067b3e46bcae55b/opentelemetry_instrumentation-0.51b0-py3-none-any.whl", hash = "sha256:c6de8bd26b75ec8b0e54dff59e198946e29de6a10ec65488c357d4b34aa5bdcf", size = 30923, upload-time = "2025-02-04T18:19:37.829Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation-asgi" +version = "0.51b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asgiref" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "opentelemetry-util-http" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/67/8aa6e1129f641f0f3f8786e6c5d18c1f2bbe490bd4b0e91a6879e85154d2/opentelemetry_instrumentation_asgi-0.51b0.tar.gz", hash = "sha256:b3fe97c00f0bfa934371a69674981d76591c68d937b6422a5716ca21081b4148", size = 24201, upload-time = "2025-02-04T18:21:14.321Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/7e/0a95ab37302729543631a789ba8e71dea75c520495739dbbbdfdc580b401/opentelemetry_instrumentation_asgi-0.51b0-py3-none-any.whl", hash = "sha256:e8072993db47303b633c6ec1bc74726ba4d32bd0c46c28dfadf99f79521a324c", size = 16340, upload-time = "2025-02-04T18:19:49.924Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation-fastapi" +version = "0.51b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-instrumentation-asgi" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "opentelemetry-util-http" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2d/dc/8db4422b5084177d1ef6c7855c69bf2e9e689f595a4a9b59e60588e0d427/opentelemetry_instrumentation_fastapi-0.51b0.tar.gz", hash = "sha256:1624e70f2f4d12ceb792d8a0c331244cd6723190ccee01336273b4559bc13abc", size = 19249, upload-time = "2025-02-04T18:21:28.379Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/1c/ec2d816b78edf2404d7b3df6d09eefb690b70bfd191b7da06f76634f1bdc/opentelemetry_instrumentation_fastapi-0.51b0-py3-none-any.whl", hash = "sha256:10513bbc11a1188adb9c1d2c520695f7a8f2b5f4de14e8162098035901cd6493", size = 12117, upload-time = "2025-02-04T18:20:15.267Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation-httpx" +version = "0.51b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "opentelemetry-util-http" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b7/d5/4a3990c461ae7e55212115e0f8f3aa412b5ce6493579e85c292245ac69ea/opentelemetry_instrumentation_httpx-0.51b0.tar.gz", hash = "sha256:061d426a04bf5215a859fea46662e5074f920e5cbde7e6ad6825a0a1b595802c", size = 17700, upload-time = "2025-02-04T18:21:31.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/ba/23d4ab6402408c01f1c3f32e0c04ea6dae575bf19bcb9a0049c9e768c983/opentelemetry_instrumentation_httpx-0.51b0-py3-none-any.whl", hash = "sha256:2e3fdf755ba6ead6ab43031497c3d55d4c796d0368eccc0ce48d304b7ec6486a", size = 14109, upload-time = "2025-02-04T18:20:19.947Z" }, +] + +[[package]] +name = "opentelemetry-proto" +version = "1.30.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/31/6e/c1ff2e3b0cd3a189a6be03fd4d63441d73d7addd9117ab5454e667b9b6c7/opentelemetry_proto-1.30.0.tar.gz", hash = "sha256:afe5c9c15e8b68d7c469596e5b32e8fc085eb9febdd6fb4e20924a93a0389179", size = 34362, upload-time = "2025-02-04T18:17:28.099Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/d7/85de6501f7216995295f7ec11e470142e6a6e080baacec1753bbf272e007/opentelemetry_proto-1.30.0-py3-none-any.whl", hash = "sha256:c6290958ff3ddacc826ca5abbeb377a31c2334387352a259ba0df37c243adc11", size = 55854, upload-time = "2025-02-04T18:17:08.024Z" }, +] + +[[package]] +name = "opentelemetry-sdk" +version = "1.30.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/93/ee/d710062e8a862433d1be0b85920d0c653abe318878fef2d14dfe2c62ff7b/opentelemetry_sdk-1.30.0.tar.gz", hash = "sha256:c9287a9e4a7614b9946e933a67168450b9ab35f08797eb9bc77d998fa480fa18", size = 158633, upload-time = "2025-02-04T18:17:28.908Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/28/64d781d6adc6bda2260067ce2902bd030cf45aec657e02e28c5b4480b976/opentelemetry_sdk-1.30.0-py3-none-any.whl", hash = "sha256:14fe7afc090caad881addb6926cec967129bd9260c4d33ae6a217359f6b61091", size = 118717, upload-time = "2025-02-04T18:17:09.353Z" }, +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.51b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "deprecated" }, + { name = "opentelemetry-api" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1e/c0/0f9ef4605fea7f2b83d55dd0b0d7aebe8feead247cd6facd232b30907b4f/opentelemetry_semantic_conventions-0.51b0.tar.gz", hash = "sha256:3fabf47f35d1fd9aebcdca7e6802d86bd5ebc3bc3408b7e3248dde6e87a18c47", size = 107191, upload-time = "2025-02-04T18:17:29.903Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/75/d7bdbb6fd8630b4cafb883482b75c4fc276b6426619539d266e32ac53266/opentelemetry_semantic_conventions-0.51b0-py3-none-any.whl", hash = "sha256:fdc777359418e8d06c86012c3dc92c88a6453ba662e941593adb062e48c2eeae", size = 177416, upload-time = "2025-02-04T18:17:11.305Z" }, +] + +[[package]] +name = "opentelemetry-util-http" +version = "0.51b0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/64/32510c0a803465eb6ef1f5bd514d0f5627f8abc9444ed94f7240faf6fcaa/opentelemetry_util_http-0.51b0.tar.gz", hash = "sha256:05edd19ca1cc3be3968b1e502fd94816901a365adbeaab6b6ddb974384d3a0b9", size = 8043, upload-time = "2025-02-04T18:21:59.811Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/dd/c371eeb9cc78abbdad231a27ce1a196a37ef96328d876ccbb381dea4c8ee/opentelemetry_util_http-0.51b0-py3-none-any.whl", hash = "sha256:0561d7a6e9c422b9ef9ae6e77eafcfcd32a2ab689f5e801475cbb67f189efa20", size = 7304, upload-time = "2025-02-04T18:21:05.483Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "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-observability" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-grpc" }, + { name = "opentelemetry-exporter-otlp-proto-http" }, + { name = "opentelemetry-instrumentation-fastapi" }, + { name = "opentelemetry-instrumentation-httpx" }, + { name = "opentelemetry-sdk" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/2a/5e0e4c0f30b20a59ffff14364e15374f9eee494f658f41559aba97c152a2/policyengine_observability-1.3.0.tar.gz", hash = "sha256:4c2f3bb2ee59886aef6c90f1edb9c0b957ace75b1518616c0e61905b8be431e0", size = 107961, upload-time = "2026-07-01T21:10:53.084Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/a2/80e4bc1c4923e8f4c04483bfd22df2500b0d132c4ce1a6dec6749c94d947/policyengine_observability-1.3.0-py3-none-any.whl", hash = "sha256:7a9444e2cb8ddd7f6d54d601bd1e0a7651bd44783b78627b293a27e741f91930", size = 31087, upload-time = "2026-07-01T21:10:51.793Z" }, +] + +[package.optional-dependencies] +fastapi = [ + { name = "fastapi" }, +] + +[[package]] +name = "policyengine-simulation-contract" +version = "0.1.0" +source = { editable = "../../libs/policyengine-simulation-contract" } +dependencies = [ + { name = "policyengine-simulation-observability" }, + { name = "pydantic" }, +] + +[package.metadata] +requires-dist = [ + { name = "black", marker = "extra == 'build'", specifier = ">=25.1.0" }, + { name = "modal", marker = "extra == 'modal'", specifier = ">=1.4,<2" }, + { name = "modal", marker = "extra == 'test'", specifier = ">=1.4,<2" }, + { name = "policyengine-simulation-observability", editable = "../../libs/policyengine-simulation-observability" }, + { name = "pydantic", specifier = ">=2.0" }, + { name = "pyright", marker = "extra == 'build'", specifier = ">=1.1.401" }, + { name = "pytest", marker = "extra == 'test'", specifier = ">=8.3.4" }, + { name = "pytest-asyncio", marker = "extra == 'test'", specifier = ">=0.25.3" }, + { name = "pytest-cov", marker = "extra == 'test'", specifier = ">=6.1.1" }, +] +provides-extras = ["modal", "test", "build"] + +[[package]] +name = "policyengine-simulation-entry" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "cryptography" }, + { name = "fastapi" }, + { name = "httpx" }, + { name = "policyengine-observability", extra = ["fastapi"] }, + { name = "policyengine-simulation-contract" }, + { name = "policyengine-simulation-observability" }, + { name = "pyjwt" }, + { name = "uvicorn" }, +] + +[package.optional-dependencies] +build = [ + { name = "openapi-python-client" }, + { name = "pyright" }, +] +test = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, +] + +[package.metadata] +requires-dist = [ + { name = "cryptography", specifier = ">=41.0.0" }, + { name = "fastapi", specifier = ">=0.115.0,<1" }, + { name = "httpx", specifier = ">=0.28.0,<1" }, + { name = "openapi-python-client", marker = "extra == 'build'", specifier = ">=0.21.6" }, + { name = "policyengine-observability", extras = ["fastapi"], specifier = ">=1.3.0,<2" }, + { name = "policyengine-simulation-contract", editable = "../../libs/policyengine-simulation-contract" }, + { name = "policyengine-simulation-observability", editable = "../../libs/policyengine-simulation-observability" }, + { name = "pyjwt", specifier = ">=2.10.1,<3" }, + { name = "pyright", marker = "extra == 'build'", specifier = ">=1.1.401" }, + { name = "pytest", marker = "extra == 'test'", specifier = ">=8.3.4" }, + { name = "pytest-asyncio", marker = "extra == 'test'", specifier = ">=0.25.3" }, + { name = "pytest-cov", marker = "extra == 'test'", specifier = ">=6.1.1" }, + { name = "uvicorn", specifier = ">=0.35.0,<1" }, +] +provides-extras = ["test", "build"] + +[[package]] +name = "policyengine-simulation-observability" +version = "0.1.0" +source = { editable = "../../libs/policyengine-simulation-observability" } +dependencies = [ + { name = "fastapi" }, + { name = "importlib-metadata" }, + { name = "logfire" }, + { name = "policyengine-observability", extra = ["fastapi"] }, + { name = "pydantic" }, +] + +[package.metadata] +requires-dist = [ + { name = "black", marker = "extra == 'build'", specifier = ">=25.1.0" }, + { name = "fastapi", specifier = ">=0.115.0" }, + { name = "httpx2", marker = "extra == 'test'" }, + { name = "importlib-metadata", specifier = ">=8" }, + { name = "logfire", specifier = ">=3.0.0" }, + { name = "policyengine-observability", extras = ["fastapi"], specifier = ">=1.3.0,<2" }, + { name = "pydantic", specifier = ">=2.0" }, + { name = "pyright", marker = "extra == 'build'", specifier = ">=1.1.401" }, + { name = "pytest", marker = "extra == 'test'", specifier = ">=8.3.4" }, + { name = "pytest-asyncio", marker = "extra == 'test'", specifier = ">=0.25.3" }, + { name = "pytest-cov", marker = "extra == 'test'", specifier = ">=6.1.1" }, +] +provides-extras = ["test", "build"] + +[[package]] +name = "protobuf" +version = "5.29.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/57/394a763c103e0edf87f0938dafcd918d53b4c011dfc5c8ae80f3b0452dbb/protobuf-5.29.6.tar.gz", hash = "sha256:da9ee6a5424b6b30fd5e45c5ea663aef540ca95f9ad99d1e887e819cdf9b8723", size = 425623, upload-time = "2026-02-04T22:54:40.584Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/88/9ee58ff7863c479d6f8346686d4636dd4c415b0cbeed7a6a7d0617639c2a/protobuf-5.29.6-cp310-abi3-win32.whl", hash = "sha256:62e8a3114992c7c647bce37dcc93647575fc52d50e48de30c6fcb28a6a291eb1", size = 423357, upload-time = "2026-02-04T22:54:25.805Z" }, + { url = "https://files.pythonhosted.org/packages/1c/66/2dc736a4d576847134fb6d80bd995c569b13cdc7b815d669050bf0ce2d2c/protobuf-5.29.6-cp310-abi3-win_amd64.whl", hash = "sha256:7e6ad413275be172f67fdee0f43484b6de5a904cc1c3ea9804cb6fe2ff366eda", size = 435175, upload-time = "2026-02-04T22:54:28.592Z" }, + { url = "https://files.pythonhosted.org/packages/06/db/49b05966fd208ae3f44dcd33837b6243b4915c57561d730a43f881f24dea/protobuf-5.29.6-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:b5a169e664b4057183a34bdc424540e86eea47560f3c123a0d64de4e137f9269", size = 418619, upload-time = "2026-02-04T22:54:30.266Z" }, + { url = "https://files.pythonhosted.org/packages/b7/d7/48cbf6b0c3c39761e47a99cb483405f0fde2be22cf00d71ef316ce52b458/protobuf-5.29.6-cp38-abi3-manylinux2014_aarch64.whl", hash = "sha256:a8866b2cff111f0f863c1b3b9e7572dc7eaea23a7fae27f6fc613304046483e6", size = 320284, upload-time = "2026-02-04T22:54:31.782Z" }, + { url = "https://files.pythonhosted.org/packages/e3/dd/cadd6ec43069247d91f6345fa7a0d2858bef6af366dbd7ba8f05d2c77d3b/protobuf-5.29.6-cp38-abi3-manylinux2014_x86_64.whl", hash = "sha256:e3387f44798ac1106af0233c04fb8abf543772ff241169946f698b3a9a3d3ab9", size = 320478, upload-time = "2026-02-04T22:54:32.909Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cb/e3065b447186cb70aa65acc70c86baf482d82bf75625bf5a2c4f6919c6a3/protobuf-5.29.6-py3-none-any.whl", hash = "sha256:6b9edb641441b2da9fa8f428760fc136a49cf97a52076010cf22a2ff73438a86", size = 173126, upload-time = "2026-02-04T22:54:39.462Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pyjwt" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, +] + +[[package]] +name = "pyright" +version = "1.1.411" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7e/ab/265f7dc69d28113ebba19092e57b075f41543b2ed048429c5f56e2b88eac/pyright-1.1.411.tar.gz", hash = "sha256:d885a0551f2e763b089a02702174e7f4ba77548cddabc972ab86d1f7f1b0f998", size = 4112861, upload-time = "2026-06-25T02:14:06.37Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/49/385be530a6a5b78d1cbcd5c2e38debc8959a2fc6bdb716f4e581002979fc/pyright-1.1.411-py3-none-any.whl", hash = "sha256:dc7c72a8e2700c55baa127554040e067041ea53ccfd50bf96308cc4291c7d5d9", size = 6181526, upload-time = "2026-06-25T02:14:04.691Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "ruamel-yaml" +version = "0.19.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/3b/ebda527b56beb90cb7652cb1c7e4f91f48649fbcd8d2eb2fb6e77cd3329b/ruamel_yaml-0.19.1.tar.gz", hash = "sha256:53eb66cd27849eff968ebf8f0bf61f46cdac2da1d1f3576dd4ccee9b25c31993", size = 142709, upload-time = "2026-01-02T16:50:31.84Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/0c/51f6841f1d84f404f92463fc2b1ba0da357ca1e3db6b7fbda26956c3b82a/ruamel_yaml-0.19.1-py3-none-any.whl", hash = "sha256:27592957fedf6e0b62f281e96effd28043345e0e66001f97683aa9a40c667c93", size = 118102, upload-time = "2026-01-02T16:50:29.201Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/06/ae069393fc66e8ff33036d4b368003833bf6e88ccf182e17e7a2f1c754fd/ruff-0.15.22.tar.gz", hash = "sha256:3f15175b1fb580126f58285a5dae6b2ea89000136d980c64499211f116b54809", size = 4785063, upload-time = "2026-07-16T15:14:13.244Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/23/18/ee54b7ae1e121be7a28ea6da4b67564ebb0530e183a54415ab7e3bcd2c4e/ruff-0.15.22-py3-none-linux_armv6l.whl", hash = "sha256:44423e73493737f5e7c5b41d475483898ff37afcdae38bc3da5085e29af1c2d8", size = 10781258, upload-time = "2026-07-16T15:13:19.452Z" }, + { url = "https://files.pythonhosted.org/packages/2f/d2/2520cb14761ddbeaf57642a76942fc36adcbdbe53b4532241995f6fc485c/ruff-0.15.22-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b82c6482946e9eda7ff2e091d25b8bad3f718684e1916d41bd56873cee05b697", size = 10999477, upload-time = "2026-07-16T15:13:23.318Z" }, + { url = "https://files.pythonhosted.org/packages/c9/10/74e53572aa758dfaa678c2a2646b5c5515d884b7ca56be4d2ce03ca4b560/ruff-0.15.22-py3-none-macosx_11_0_arm64.whl", hash = "sha256:11c1c715af53a09f714e011106bffc419751ec8232fcb5da42173284ea3fec6f", size = 10466716, upload-time = "2026-07-16T15:13:26.162Z" }, + { url = "https://files.pythonhosted.org/packages/1e/cc/44eaaf0844e028182f2d0a8f2190d0f359159aed0a9e5ab861d892f1ae2a/ruff-0.15.22-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:742a29cf29bddb7c8327895d6a10e0e6c5b38a96dd407af9b5d0857f809c0576", size = 10892644, upload-time = "2026-07-16T15:13:29.229Z" }, + { url = "https://files.pythonhosted.org/packages/9f/21/8edf559014d2b0f82beea19cfb713993ad802ccda16868769979c6090a84/ruff-0.15.22-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72af58b951b0ae395935ae79763dc349bc0eb706319d28f7a33ad2cfb3cfc178", size = 10576719, upload-time = "2026-07-16T15:13:32.35Z" }, + { url = "https://files.pythonhosted.org/packages/bf/1e/3a13abd392a3b50b62e5938a831f9ab6e588358cacad5c18545b716d2182/ruff-0.15.22-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:62d425005c1835eb24e2ee4161cb90e8db263415f4a71c8c72c33abaa6c0c224", size = 11376494, upload-time = "2026-07-16T15:13:35.958Z" }, + { url = "https://files.pythonhosted.org/packages/bf/3e/422d3d95bcf04dd78e1aeac22184d4f9a8fb2c01865d39d44618484a0317/ruff-0.15.22-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8b9b3f8779a4f08c969defc3c8c35abffaa757e601ed5ae66d6d1db6519969a", size = 12208370, upload-time = "2026-07-16T15:13:39.185Z" }, + { url = "https://files.pythonhosted.org/packages/1e/91/5d065a0e0a02bf4813f5119ad278462eed081d2b832eb7c021ade0ec9e65/ruff-0.15.22-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1e0dd1b2e4d3d585f897a0d137cbf4eaf6223bef4e8ce34d6bb12556c5f9249e", size = 11581098, upload-time = "2026-07-16T15:13:42.132Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f9/a0d4871d12fae702eb1f41b686caf05f1f8b124dc6db6f784f53d74918fa/ruff-0.15.22-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:365523eb91d9224e1bcb03b022fbf0facb8f9e23792a2c53d9d4b3924bdbdebb", size = 11399422, upload-time = "2026-07-16T15:13:45.2Z" }, + { url = "https://files.pythonhosted.org/packages/18/80/c843a5176cddbceb0b7e8dd41cf9993490796c1c469348d384f5a5c13c56/ruff-0.15.22-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:fabfd168afdf29fee5be98b831efa9683c94d7c5a3b58b9ce5a2e38444589a74", size = 11381683, upload-time = "2026-07-16T15:13:48.46Z" }, + { url = "https://files.pythonhosted.org/packages/d4/00/8485de0ae92239438a36cfc51350db9b9e85c9ebdfaea91b18e422706662/ruff-0.15.22-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:225dbf095a87f1d9f90f5fd7924d2613ee452a75a4308c63a8f50f761787aa7c", size = 10850295, upload-time = "2026-07-16T15:13:51.655Z" }, + { url = "https://files.pythonhosted.org/packages/fa/91/24977ec2ec72eaf15e4394ace2959fdff2dd1e14f03e005e838023407169/ruff-0.15.22-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1877d63b9d24ed278744f1523fd11b85540566d54641f97c566d7d9dc5ca5296", size = 10579640, upload-time = "2026-07-16T15:13:54.79Z" }, + { url = "https://files.pythonhosted.org/packages/9c/47/9b51216951974df1f263ac19da550d34252e0ed7218c25f10c5ef9ed7517/ruff-0.15.22-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a1606c510bd7215680d32efab38965f7cdec3ef69f5170a3f4791404ffdd5262", size = 11105077, upload-time = "2026-07-16T15:13:57.915Z" }, + { url = "https://files.pythonhosted.org/packages/c2/47/20e9d4a3b8016778acea5fc32bb50d35d207500a17ddb529ffa6996feef8/ruff-0.15.22-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:630479b18625f5ffc373f77603a22a9f8ac0acd7ff0501178b5db28ec71e9c64", size = 11490980, upload-time = "2026-07-16T15:14:01.032Z" }, + { url = "https://files.pythonhosted.org/packages/4d/76/3f72d8fc38c1cb77b38c56a70da9d0c17700cc1cc50f9649c9d3c8f5ba71/ruff-0.15.22-py3-none-win32.whl", hash = "sha256:e5ba0e4a13fd14abbed2a77b517a3911290c6c6c59ef67784328d1668fab76cf", size = 10789165, upload-time = "2026-07-16T15:14:04.16Z" }, + { url = "https://files.pythonhosted.org/packages/cb/46/4965251734c2b6fcdca1b1b187d20bcac3af0ee5b083b89c910bb961ce3a/ruff-0.15.22-py3-none-win_amd64.whl", hash = "sha256:9be63ba1eb936acd2d1342fb8337c356353706fce233b2a15a09a97037e6acde", size = 11938297, upload-time = "2026-07-16T15:14:07.316Z" }, + { url = "https://files.pythonhosted.org/packages/57/c9/e69b1ff4c8b69093ef08b8919ab767af0569666865b39c30a8795d88d3c6/ruff-0.15.22-py3-none-win_arm64.whl", hash = "sha256:e1168075b72158510839f250027659cdd78476f40507dd517892304c41318661", size = 11298172, upload-time = "2026-07-16T15:14:10.51Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "starlette" +version = "0.46.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/20/08dfcd9c983f6a6f4a1000d934b9e6d626cff8d2eeb77a89a68eef20a2b7/starlette-0.46.2.tar.gz", hash = "sha256:7f7361f34eed179294600af672f565727419830b54b7b084efe44bb82d2fccd5", size = 2580846, upload-time = "2025-04-13T13:56:17.942Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/0c/9d30a4ebeb6db2b25a841afbb80f6ef9a854fc3b41be131d249a977b4959/starlette-0.46.2-py3-none-any.whl", hash = "sha256:595633ce89f8ffa71a015caed34a5b2dc1c0cdb3f0f1fbd1e69339cf2abeec35", size = 72037, upload-time = "2025-04-13T13:56:16.21Z" }, +] + +[[package]] +name = "typer" +version = "0.26.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7c/f7/68adc395201b20b872d68e975386832e8005ffeacedd43a1d837a32815be/typer-0.26.8.tar.gz", hash = "sha256:c244a6bd558886fe3f8780efb6bdd28bb9aff005a94eedebaa5cb32926fe2f7e", size = 202097, upload-time = "2026-06-26T09:22:45.705Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/87/b9fd69c92c6102a066e1b86a35243f53e70bd4c709f2a26d9f4fee4f4dc0/typer-0.26.8-py3-none-any.whl", hash = "sha256:3512ca79ac5c11113414b36e80281b872884477722440691c89d1112e321a49c", size = 122564, upload-time = "2026-06-26T09:22:44.72Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.51.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a2/65/b7c6c443ccc58678c91e1e973bbe2a878591538655d6e1d47f24ba1c51f3/uvicorn-0.51.0.tar.gz", hash = "sha256:f6f4b69b657c312f516dd2d268ab9ae6f254b11e4bac504f37b2ab58b24dd0b0", size = 94412, upload-time = "2026-07-08T10:59:05.962Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/ec/dbb7e5a6b91f86bfb9eb7d2988a2730907b6a729875b949c7f022e8b88fa/uvicorn-0.51.0-py3-none-any.whl", hash = "sha256:5d38af6cd620f2ae3849fb44fd4879e0890aa1febe8d47eb355fb45d93fe6a5b", size = 73219, upload-time = "2026-07-08T10:59:04.44Z" }, +] + +[[package]] +name = "wrapt" +version = "1.17.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/8f/aeb76c5b46e273670962298c23e7ddde79916cb74db802131d49a85e4b7d/wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0", size = 55547, upload-time = "2025-08-12T05:53:21.714Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/f6/759ece88472157acb55fc195e5b116e06730f1b651b5b314c66291729193/wrapt-1.17.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a47681378a0439215912ef542c45a783484d4dd82bac412b71e59cf9c0e1cea0", size = 54003, upload-time = "2025-08-12T05:51:48.627Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a9/49940b9dc6d47027dc850c116d79b4155f15c08547d04db0f07121499347/wrapt-1.17.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:54a30837587c6ee3cd1a4d1c2ec5d24e77984d44e2f34547e2323ddb4e22eb77", size = 39025, upload-time = "2025-08-12T05:51:37.156Z" }, + { url = "https://files.pythonhosted.org/packages/45/35/6a08de0f2c96dcdd7fe464d7420ddb9a7655a6561150e5fc4da9356aeaab/wrapt-1.17.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:16ecf15d6af39246fe33e507105d67e4b81d8f8d2c6598ff7e3ca1b8a37213f7", size = 39108, upload-time = "2025-08-12T05:51:58.425Z" }, + { url = "https://files.pythonhosted.org/packages/0c/37/6faf15cfa41bf1f3dba80cd3f5ccc6622dfccb660ab26ed79f0178c7497f/wrapt-1.17.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6fd1ad24dc235e4ab88cda009e19bf347aabb975e44fd5c2fb22a3f6e4141277", size = 88072, upload-time = "2025-08-12T05:52:37.53Z" }, + { url = "https://files.pythonhosted.org/packages/78/f2/efe19ada4a38e4e15b6dff39c3e3f3f73f5decf901f66e6f72fe79623a06/wrapt-1.17.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ed61b7c2d49cee3c027372df5809a59d60cf1b6c2f81ee980a091f3afed6a2d", size = 88214, upload-time = "2025-08-12T05:52:15.886Z" }, + { url = "https://files.pythonhosted.org/packages/40/90/ca86701e9de1622b16e09689fc24b76f69b06bb0150990f6f4e8b0eeb576/wrapt-1.17.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:423ed5420ad5f5529db9ce89eac09c8a2f97da18eb1c870237e84c5a5c2d60aa", size = 87105, upload-time = "2025-08-12T05:52:17.914Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e0/d10bd257c9a3e15cbf5523025252cc14d77468e8ed644aafb2d6f54cb95d/wrapt-1.17.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e01375f275f010fcbf7f643b4279896d04e571889b8a5b3f848423d91bf07050", size = 87766, upload-time = "2025-08-12T05:52:39.243Z" }, + { url = "https://files.pythonhosted.org/packages/e8/cf/7d848740203c7b4b27eb55dbfede11aca974a51c3d894f6cc4b865f42f58/wrapt-1.17.3-cp313-cp313-win32.whl", hash = "sha256:53e5e39ff71b3fc484df8a522c933ea2b7cdd0d5d15ae82e5b23fde87d44cbd8", size = 36711, upload-time = "2025-08-12T05:53:10.074Z" }, + { url = "https://files.pythonhosted.org/packages/57/54/35a84d0a4d23ea675994104e667ceff49227ce473ba6a59ba2c84f250b74/wrapt-1.17.3-cp313-cp313-win_amd64.whl", hash = "sha256:1f0b2f40cf341ee8cc1a97d51ff50dddb9fcc73241b9143ec74b30fc4f44f6cb", size = 38885, upload-time = "2025-08-12T05:53:08.695Z" }, + { url = "https://files.pythonhosted.org/packages/01/77/66e54407c59d7b02a3c4e0af3783168fff8e5d61def52cda8728439d86bc/wrapt-1.17.3-cp313-cp313-win_arm64.whl", hash = "sha256:7425ac3c54430f5fc5e7b6f41d41e704db073309acfc09305816bc6a0b26bb16", size = 36896, upload-time = "2025-08-12T05:52:55.34Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591, upload-time = "2025-08-12T05:53:20.674Z" }, +] + +[[package]] +name = "zipp" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/d8/eab98a517c14134c0b2eb4e2387bc5f457334293ec5d2dd3857ec2966802/zipp-4.1.0.tar.gz", hash = "sha256:4cb57381f544315db7688e976e922a2b18cdb513d21cc194eb42232ba2a3e602", size = 26214, upload-time = "2026-05-18T20:08:57.967Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/13/547360d81e6d88d58492968ffda9f9542854f11310ee556fef14260cc886/zipp-4.1.0-py3-none-any.whl", hash = "sha256:25ad4e16390cd314347dd8f1de67a2ac538ae658ed4ab9db16029c07c188e97f", size = 10238, upload-time = "2026-05-18T20:08:57.045Z" }, +] diff --git a/projects/policyengine-simulation-executor/Dockerfile b/projects/policyengine-simulation-executor/Dockerfile index a5f4779dc..f8255bd2a 100644 --- a/projects/policyengine-simulation-executor/Dockerfile +++ b/projects/policyengine-simulation-executor/Dockerfile @@ -18,7 +18,7 @@ COPY libs ./libs/ FROM base AS final # Service-specific environment -ENV OT_SERVICE_NAME=policyengine_simulation_api \ +ENV OT_SERVICE_NAME=policyengine_simulation_executor \ OT_SERVICE_INSTANCE_ID=instance # Copy service-specific code @@ -31,5 +31,5 @@ RUN uv sync --frozen --no-dev EXPOSE 8080 -# Run with uvicorn (with 2 workers for simulation API) -CMD ["uv", "run", "--frozen", "--no-dev", "uvicorn", "policyengine_simulation_executor.main:app", "--host", "0.0.0.0", "--port", "8080", "--workers", "2"] \ No newline at end of file +# Run the executor with two uvicorn workers. +CMD ["uv", "run", "--frozen", "--no-dev", "uvicorn", "policyengine_simulation_executor.main:app", "--host", "0.0.0.0", "--port", "8080", "--workers", "2"] diff --git a/projects/policyengine-simulation-executor/fixtures/test_modal_scripts.py b/projects/policyengine-simulation-executor/fixtures/test_modal_scripts.py index 6a6bb311f..9e54267f9 100644 --- a/projects/policyengine-simulation-executor/fixtures/test_modal_scripts.py +++ b/projects/policyengine-simulation-executor/fixtures/test_modal_scripts.py @@ -28,5 +28,8 @@ def temp_github_step_summary(): @pytest.fixture def all_modal_scripts(): - """Return all ``modal-*.sh`` scripts in the repository.""" - return list(SCRIPTS_DIR.glob("modal-*.sh")) + """Return Modal and full-stack simulation deployment scripts.""" + return [ + *SCRIPTS_DIR.glob("modal-*.sh"), + *SCRIPTS_DIR.glob("simulation-*.sh"), + ] diff --git a/projects/policyengine-simulation-executor/pyproject.toml b/projects/policyengine-simulation-executor/pyproject.toml index 507616d70..c8dff4a7a 100644 --- a/projects/policyengine-simulation-executor/pyproject.toml +++ b/projects/policyengine-simulation-executor/pyproject.toml @@ -17,7 +17,7 @@ dependencies = [ "opentelemetry-instrumentation-fastapi (>=0.51b0,<0.52)", "policyengine-fastapi", "policyengine-simulation-observability", - "policyengine-simulation-contract", + "policyengine-simulation-contract[modal]", "policyengine==4.22.0", "policyengine-core==3.30.0", "policyengine-uk==2.89.2", diff --git a/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_macro_output.py b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_macro_output.py index 43685e575..cfcd2e367 100644 --- a/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_macro_output.py +++ b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_macro_output.py @@ -1,158 +1,67 @@ -"""Internal schemas for the simulation API single-year macro output. - -These models define the legacy dictionary contract the simulation API returns -without exposing that schema through the gateway OpenAPI surface. The gateway -still treats job results as unstructured dictionaries for older callers. -""" - -from __future__ import annotations - -from typing import Any, Generic, TypeVar - -from pydantic import BaseModel, ConfigDict, RootModel - -T = TypeVar("T") - - -class MacroOutputModel(BaseModel): - """Base model for internal macro output schemas.""" - - model_config = ConfigDict(extra="forbid") - - -class MacroRootModel(RootModel[T], Generic[T]): - """Base model for internal root schemas that dump to dict/list values.""" - - -class BudgetaryImpact(MacroOutputModel): - tax_revenue_impact: float - state_tax_revenue_impact: float - benefit_spending_impact: float - budgetary_impact: float - households: float - baseline_net_income: float - - -BudgetaryOutput = BudgetaryImpact - - -class DetailedBudgetProgramOutput(MacroOutputModel): - baseline: float - reform: float - difference: float - - -class DetailedBudgetOutput(MacroRootModel[dict[str, DetailedBudgetProgramOutput]]): - pass - - -class DecileOutput(MacroOutputModel): - average: dict[str, float] - relative: dict[str, float] - - -class IntraDecileOutput(MacroOutputModel): - deciles: dict[str, list[float]] - all: dict[str, float] - - -class BaselineReformValue(MacroOutputModel): - baseline: float - reform: float - - -class AgePovertyOutput(MacroOutputModel): - child: BaselineReformValue - adult: BaselineReformValue - senior: BaselineReformValue - all: BaselineReformValue - - -class GenderPovertyOutput(MacroOutputModel): - male: BaselineReformValue - female: BaselineReformValue - - -class RacePovertyOutput(MacroOutputModel): - white: BaselineReformValue - black: BaselineReformValue - hispanic: BaselineReformValue - other: BaselineReformValue - - -class PovertyOutput(MacroOutputModel): - poverty: AgePovertyOutput - deep_poverty: AgePovertyOutput - - -class PovertyByGenderOutput(MacroOutputModel): - poverty: GenderPovertyOutput - deep_poverty: GenderPovertyOutput - - -class PovertyByRaceOutput(MacroOutputModel): - poverty: RacePovertyOutput - - -class PovertyModuleOutputs(MacroOutputModel): - poverty: PovertyOutput - poverty_by_gender: PovertyByGenderOutput - poverty_by_race: PovertyByRaceOutput | None - - -class InequalityOutput(MacroOutputModel): - gini: BaselineReformValue - top_10_pct_share: BaselineReformValue - top_1_pct_share: BaselineReformValue - - -class LaborSupplyResponseOutput(MacroRootModel[dict[str, Any]]): - pass - - -class CliffImpactInSimulation(MacroOutputModel): - cliff_gap: float - cliff_share: float - - -class CliffImpactOutput(MacroOutputModel): - baseline: CliffImpactInSimulation - reform: CliffImpactInSimulation - - -class GeographicImpactOutput(MacroRootModel[list[dict[str, Any]]]): - pass - - -class CongressionalDistrictImpactRecord(MacroOutputModel): - district: str - average_household_income_change: float - relative_household_income_change: float - winner_percentage: float - loser_percentage: float - no_change_percentage: float - population: float - - -class CongressionalDistrictImpactOutput(MacroOutputModel): - districts: list[CongressionalDistrictImpactRecord] - - -class SingleYearMacroOutput(MacroOutputModel): - model_version: str - data_version: str - budget: BudgetaryImpact - detailed_budget: DetailedBudgetOutput - decile: DecileOutput - inequality: InequalityOutput - poverty: PovertyOutput - poverty_by_gender: PovertyByGenderOutput - poverty_by_race: PovertyByRaceOutput | None - intra_decile: IntraDecileOutput - wealth_decile: DecileOutput | None - intra_wealth_decile: IntraDecileOutput | None - labor_supply_response: LaborSupplyResponseOutput | None - constituency_impact: GeographicImpactOutput | None - local_authority_impact: GeographicImpactOutput | None - congressional_district_impact: CongressionalDistrictImpactOutput | None - cliff_impact: CliffImpactOutput | None = None +"""Compatibility imports for the shared single-year macro result schemas.""" + +from policyengine_simulation_contract.macro_output import ( + AgePovertyOutput, + BaselineReformValue, + BudgetaryImpact, + BudgetaryOutput, + CliffImpactInSimulation, + CliffImpactOutput, + CongressionalDistrictImpactOutput, + CongressionalDistrictImpactRecord, + ConstituencyImpactRecord, + DecileOutput, + DetailedBudgetOutput, + DetailedBudgetProgramOutput, + GenderPovertyOutput, + GeographicImpactOutput, + InequalityOutput, + IntraDecileOutput, + LaborSupplyDecileMetric, + LaborSupplyDecileOutput, + LaborSupplyHoursOutput, + LaborSupplyRelativeResponse, + LaborSupplyResponseOutput, + LocalAuthorityImpactRecord, + MacroOutputModel, + MacroRootModel, + PovertyByGenderOutput, + PovertyByRaceOutput, + PovertyModuleOutputs, + PovertyOutput, + RacePovertyOutput, + SingleYearMacroOutput, +) + +__all__ = [ + "AgePovertyOutput", + "BaselineReformValue", + "BudgetaryImpact", + "BudgetaryOutput", + "CliffImpactInSimulation", + "CliffImpactOutput", + "CongressionalDistrictImpactOutput", + "CongressionalDistrictImpactRecord", + "ConstituencyImpactRecord", + "DecileOutput", + "DetailedBudgetOutput", + "DetailedBudgetProgramOutput", + "GenderPovertyOutput", + "GeographicImpactOutput", + "InequalityOutput", + "IntraDecileOutput", + "LaborSupplyDecileMetric", + "LaborSupplyDecileOutput", + "LaborSupplyHoursOutput", + "LaborSupplyRelativeResponse", + "LaborSupplyResponseOutput", + "LocalAuthorityImpactRecord", + "MacroOutputModel", + "MacroRootModel", + "PovertyByGenderOutput", + "PovertyByRaceOutput", + "PovertyModuleOutputs", + "PovertyOutput", + "RacePovertyOutput", + "SingleYearMacroOutput", +] diff --git a/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_output_labor.py b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_output_labor.py index 545219395..447ba3e1f 100644 --- a/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_output_labor.py +++ b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_output_labor.py @@ -15,4 +15,8 @@ def build_labor_supply_response(analysis: Any) -> LaborSupplyResponseOutput | No if isinstance(labor_supply_response, LaborSupplyResponseOutput): return labor_supply_response output = _output_model_dump(labor_supply_response) - return LaborSupplyResponseOutput(output) if isinstance(output, dict) else None + return ( + LaborSupplyResponseOutput.model_validate(output) + if isinstance(output, dict) + else None + ) diff --git a/projects/policyengine-simulation-executor/tests/test_modal_scripts.py b/projects/policyengine-simulation-executor/tests/test_modal_scripts.py index 613aab495..15b26d23f 100644 --- a/projects/policyengine-simulation-executor/tests/test_modal_scripts.py +++ b/projects/policyengine-simulation-executor/tests/test_modal_scripts.py @@ -64,30 +64,30 @@ def test_extracts_versions_from_policyengine_bundle(self, temp_github_output): def test_deploy_workflow_passes_core_version_to_modal(self): """Deploy workflow should pass core version into the Modal app build.""" workflow = ( - REPO_ROOT / ".github" / "workflows" / "modal-deploy.reusable.yml" + REPO_ROOT / ".github" / "workflows" / "simulation-deploy.reusable.yml" ).read_text(encoding="utf-8") assert "policyengine_core_version" in workflow assert ( - "POLICYENGINE_CORE_VERSION: ${{ steps.versions.outputs.policyengine_core_version }}" + "POLICYENGINE_CORE_VERSION: ${{ needs.prepare.outputs.policyengine_core_version }}" in workflow ) - def test_deploy_workflow_threads_force_latest_to_script(self): - """Manual rollback flag should reach the deploy script.""" + def test_deploy_workflow_threads_force_latest_to_routing_publisher(self): + """Manual rollback flag should reach the post-deployment routing job.""" deploy_workflow = ( - REPO_ROOT / ".github" / "workflows" / "modal-deploy.yml" + REPO_ROOT / ".github" / "workflows" / "simulation-deploy.yml" ).read_text(encoding="utf-8") reusable_workflow = ( - REPO_ROOT / ".github" / "workflows" / "modal-deploy.reusable.yml" + REPO_ROOT / ".github" / "workflows" / "simulation-deploy.reusable.yml" ).read_text(encoding="utf-8") assert "force_latest:" in deploy_workflow assert "force_latest: ${{ inputs.force_latest || false }}" in deploy_workflow assert "force_latest:" in reusable_workflow - assert ( - 'modal-deploy-app.sh "${{ inputs.modal_environment }}" "${{ inputs.force_latest }}"' - in reusable_workflow + assert "args+=(--force-latest)" in reusable_workflow + assert reusable_workflow.index("deploy_executor:") < reusable_workflow.index( + "update_routing:" ) @@ -128,10 +128,10 @@ def test_fails_on_unreachable_url(self): assert result.returncode != 0, "Should fail on unreachable URL" -class TestModalDeploymentSummary: - """Tests for modal-deployment-summary.sh""" +class TestSimulationDeploymentSummary: + """Tests for simulation-deployment-summary.sh""" - script = SCRIPTS_DIR / "modal-deployment-summary.sh" + script = SCRIPTS_DIR / "simulation-deployment-summary.sh" def test_script_exists(self): """Script file should exist.""" @@ -170,9 +170,11 @@ def test_generates_success_summary(self, temp_github_step_summary): with open(temp_github_step_summary) as f: summary = f.read() - assert "Modal Deployment Summary" in summary + assert "Simulation API Deployment Summary" in summary assert "Beta deployment" in summary - assert "Production deployment" in summary + assert "Prod deployment" in summary + assert "Candidate URL: https://beta.example.com" in summary + assert "Stable URL: https://prod.example.com" in summary assert "https://beta.example.com" in summary assert "https://prod.example.com" in summary @@ -341,93 +343,6 @@ def test_creates_gateway_secret_with_normalized_issuer(self, tmp_path): assert "GATEWAY_AUTH_CLIENT_SECRET" not in calls -class TestModalDeployApp: - """Tests for modal-deploy-app.sh""" - - script = SCRIPTS_DIR / "modal-deploy-app.sh" - - def _run_with_fake_uv(self, tmp_path, *args): - bin_dir = tmp_path / "bin" - bin_dir.mkdir() - log_path = tmp_path / "uv-calls.log" - uv_path = bin_dir / "uv" - uv_path.write_text( - '#!/bin/bash\nprintf \'%s\\n\' "$*" >> "$UV_FAKE_LOG"\n', - encoding="utf-8", - ) - uv_path.chmod(0o755) - - env = os.environ.copy() - env.update( - { - "PATH": f"{bin_dir}{os.pathsep}{env['PATH']}", - "UV_FAKE_LOG": str(log_path), - "POLICYENGINE_VERSION": "4.18.3", - "POLICYENGINE_CORE_VERSION": "3.27.1", - "POLICYENGINE_US_VERSION": "1.729.0", - "POLICYENGINE_UK_VERSION": "2.89.2", - } - ) - - result = subprocess.run( - ["bash", str(self.script), *args], - capture_output=True, - text=True, - env=env, - ) - calls = log_path.read_text(encoding="utf-8").splitlines() - return result, calls - - def test_script_exists(self): - """Script file should exist.""" - assert self.script.exists(), f"Script not found at {self.script}" - - def test_script_syntax(self): - """Script should have valid bash syntax.""" - result = subprocess.run( - ["bash", "-n", str(self.script)], - capture_output=True, - text=True, - ) - assert result.returncode == 0, f"Syntax error: {result.stderr}" - - def test_requires_modal_environment_argument(self): - """Should fail when no modal environment is provided.""" - result = subprocess.run( - ["bash", str(self.script)], - capture_output=True, - text=True, - ) - assert result.returncode != 0, "Should fail without modal environment" - - def test_defaults_to_not_forcing_latest(self, tmp_path): - result, calls = self._run_with_fake_uv(tmp_path, "main") - - assert result.returncode == 0, result.stderr - assert result.stdout.count("Force latest: false") == 1 - registry_call = next( - call for call in calls if "update_version_registry" in call - ) - assert "--force-latest" not in registry_call - # The gateway deploys from its own project (uv_sync image). - gateway_call = next( - call - for call in calls - if "policyengine_simulation_gateway/app.py" in call - ) - assert "modal deploy" in gateway_call - - def test_passes_force_latest_when_requested(self, tmp_path): - result, calls = self._run_with_fake_uv(tmp_path, "main", "true") - - assert result.returncode == 0, result.stderr - assert result.stdout.count("Force latest: true") == 1 - registry_call = next( - call for call in calls if "update_version_registry" in call - ) - assert registry_call.endswith("--force-latest") - - class TestModalGetUrl: """Tests for modal-get-url.sh""" @@ -475,10 +390,10 @@ def test_script_syntax(self): assert result.returncode == 0, f"Syntax error: {result.stderr}" -class TestModalRunIntegTests: - """Tests for modal-run-integ-tests.sh""" +class TestSimulationRunIntegTests: + """Tests for simulation-run-integ-tests.sh""" - script = SCRIPTS_DIR / "modal-run-integ-tests.sh" + script = SCRIPTS_DIR / "simulation-run-integ-tests.sh" def test_script_exists(self): """Script file should exist.""" @@ -511,13 +426,13 @@ def test_requires_base_url_argument(self): ) assert result.returncode != 0, "Should fail without base URL" - def test_fails_when_gateway_auth_config_is_partial(self): + def test_fails_when_auth_config_is_partial(self): """Should fail before running tests if token-mint config is partial.""" env = os.environ.copy() - env["GATEWAY_AUTH_ISSUER"] = "https://tenant.auth0.com/" - env.pop("GATEWAY_AUTH_AUDIENCE", None) - env.pop("GATEWAY_AUTH_CLIENT_ID", None) - env.pop("GATEWAY_AUTH_CLIENT_SECRET", None) + env["SIMULATION_TEST_AUTH_ISSUER"] = "https://tenant.auth0.com/" + env.pop("SIMULATION_TEST_AUTH_AUDIENCE", None) + env.pop("SIMULATION_TEST_AUTH_CLIENT_ID", None) + env.pop("SIMULATION_TEST_AUTH_CLIENT_SECRET", None) result = subprocess.run( ["bash", str(self.script), "beta", "https://example.com"], @@ -527,17 +442,17 @@ def test_fails_when_gateway_auth_config_is_partial(self): ) assert result.returncode != 0 - assert "Gateway auth integration-test config is partial." in result.stderr + assert "Simulation integration-test auth config is partial." in result.stderr - def test_fails_when_auth_required_but_gateway_auth_vars_missing(self): + def test_fails_when_auth_required_but_auth_vars_missing(self): """Required auth must not run tests unauthenticated.""" env = os.environ.copy() - env["GATEWAY_AUTH_REQUIRED"] = "1" + env["SIMULATION_TEST_AUTH_REQUIRED"] = "1" for key in ( - "GATEWAY_AUTH_ISSUER", - "GATEWAY_AUTH_AUDIENCE", - "GATEWAY_AUTH_CLIENT_ID", - "GATEWAY_AUTH_CLIENT_SECRET", + "SIMULATION_TEST_AUTH_ISSUER", + "SIMULATION_TEST_AUTH_AUDIENCE", + "SIMULATION_TEST_AUTH_CLIENT_ID", + "SIMULATION_TEST_AUTH_CLIENT_SECRET", ): env.pop(key, None) @@ -549,7 +464,7 @@ def test_fails_when_auth_required_but_gateway_auth_vars_missing(self): ) assert result.returncode != 0 - assert "GATEWAY_AUTH_REQUIRED is enabled" in result.stderr + assert "SIMULATION_TEST_AUTH_REQUIRED is enabled" in result.stderr def test_exports_us_and_uk_model_versions_to_integration_tests(self, tmp_path): """Deploy-extracted model versions should reach the pytest settings.""" @@ -570,11 +485,11 @@ def test_exports_us_and_uk_model_versions_to_integration_tests(self, tmp_path): env["PATH"] = f"{fake_bin}:{env['PATH']}" env["UV_CALLS_LOG"] = str(uv_calls_log) for key in ( - "GATEWAY_AUTH_REQUIRED", - "GATEWAY_AUTH_ISSUER", - "GATEWAY_AUTH_AUDIENCE", - "GATEWAY_AUTH_CLIENT_ID", - "GATEWAY_AUTH_CLIENT_SECRET", + "SIMULATION_TEST_AUTH_REQUIRED", + "SIMULATION_TEST_AUTH_ISSUER", + "SIMULATION_TEST_AUTH_AUDIENCE", + "SIMULATION_TEST_AUTH_CLIENT_ID", + "SIMULATION_TEST_AUTH_CLIENT_SECRET", ): env.pop(key, None) diff --git a/projects/policyengine-simulation-executor/tests/test_simulation_api_contracts.py b/projects/policyengine-simulation-executor/tests/test_simulation_api_contracts.py index a5f93583e..bd249e401 100644 --- a/projects/policyengine-simulation-executor/tests/test_simulation_api_contracts.py +++ b/projects/policyengine-simulation-executor/tests/test_simulation_api_contracts.py @@ -7,7 +7,9 @@ BudgetWindowTotals, JobStatusResponse, ) -from policyengine_simulation_executor.simulation_macro_output import SingleYearMacroOutput +from policyengine_simulation_executor.simulation_macro_output import ( + SingleYearMacroOutput, +) from fixtures.test_simulation_api_contracts import ( CURRENT_REQUIRED_BUDGET_KEYS, @@ -23,8 +25,9 @@ def test_job_status_result_preserves_current_single_year_macro_dict_contract(): ) assert response.result is not None - assert set(response.result) == CURRENT_SINGLE_YEAR_MACRO_KEYS - assert set(response.result["budget"]) == CURRENT_REQUIRED_BUDGET_KEYS + dumped_result = response.result.model_dump(mode="json") + assert set(dumped_result) == CURRENT_SINGLE_YEAR_MACRO_KEYS + assert set(dumped_result["budget"]) == CURRENT_REQUIRED_BUDGET_KEYS assert ( response.model_dump(mode="json")["result"] == CURRENT_SINGLE_YEAR_MACRO_RESULT ) @@ -40,23 +43,24 @@ def test_internal_single_year_macro_schema_serializes_current_public_contract(): assert output.model_dump(mode="json") == CURRENT_SINGLE_YEAR_MACRO_RESULT -def test_openapi_keeps_job_status_result_as_unstructured_dict(): +def test_openapi_publishes_the_single_year_macro_result_schema(): spec = create_openapi_app().openapi() schemas = spec["components"]["schemas"] - assert "SingleYearMacroOutput" not in schemas + assert "SingleYearMacroOutput" in schemas + assert set(schemas["SingleYearMacroOutput"]["properties"]) == ( + CURRENT_SINGLE_YEAR_MACRO_KEYS + ) result_schema = schemas["JobStatusResponse"]["properties"]["result"] assert result_schema == { "anyOf": [ { - "additionalProperties": True, - "type": "object", + "$ref": "#/components/schemas/SingleYearMacroOutput", }, { "type": "null", }, - ], - "title": "Result", + ] } diff --git a/projects/policyengine-simulation-executor/tests/test_simulation_output_builder.py b/projects/policyengine-simulation-executor/tests/test_simulation_output_builder.py index 487043943..0ae46005f 100644 --- a/projects/policyengine-simulation-executor/tests/test_simulation_output_builder.py +++ b/projects/policyengine-simulation-executor/tests/test_simulation_output_builder.py @@ -47,13 +47,14 @@ BaselineReformValue, BudgetaryImpact, BudgetaryOutput, + ConstituencyImpactRecord, DecileOutput, DetailedBudgetOutput, CongressionalDistrictImpactOutput, GenderPovertyOutput, InequalityOutput, IntraDecileOutput, - LaborSupplyResponseOutput, + LocalAuthorityImpactRecord, PovertyByGenderOutput, PovertyModuleOutputs, PovertyOutput, @@ -189,6 +190,30 @@ def _congressional_district_output() -> CongressionalDistrictImpactOutput: ) +def _constituency_impact_record() -> ConstituencyImpactRecord: + return ConstituencyImpactRecord( + constituency_code="E14000530", + constituency_name="Birmingham, Ladywood", + x=None, + y=None, + average_household_income_change=10.0, + relative_household_income_change=0.01, + population=1000.0, + ) + + +def _local_authority_impact_record() -> LocalAuthorityImpactRecord: + return LocalAuthorityImpactRecord( + local_authority_code="E06000001", + local_authority_name="Hartlepool", + x=None, + y=None, + average_household_income_change=12.0, + relative_household_income_change=0.02, + population=900.0, + ) + + def _stub_policyengine_output_calls(monkeypatch, baseline, reform) -> None: def fake_poverty_module_function(name): def compute(simulation): @@ -236,7 +261,9 @@ def compute(simulation): SimulationOutputBuilder, "_build_uk_constituency_impact", lambda self: ( - self._build_geographic_impact_output([{"constituency_code": "E14000530"}]) + self._build_geographic_impact_output( + [_constituency_impact_record().model_dump(mode="json")] + ) if self.country == "uk" else None ), @@ -246,7 +273,7 @@ def compute(simulation): "_build_uk_local_authority_impact", lambda self: ( self._build_geographic_impact_output( - [{"local_authority_code": "E06000001"}] + [_local_authority_impact_record().model_dump(mode="json")] ) if self.country == "uk" else None @@ -626,7 +653,7 @@ def wrapper(*args, **kwargs): ) monkeypatch.setattr( "policyengine_simulation_executor.simulation_output_labor.build_labor_supply_response", - record("labor_supply", LaborSupplyResponseOutput({})), + record("labor_supply", None), ) monkeypatch.setattr( "policyengine_simulation_executor.simulation_output_cliff.build_cliff_impact", @@ -751,8 +778,8 @@ def test_builder_maps_uk_wealth_outputs_and_omits_us_only_race(monkeypatch): "relative": {"1": 0.03}, } assert output["intra_wealth_decile"]["deciles"]["Lose more than 5%"] == [0.1] - assert output["constituency_impact"] == [{"constituency_code": "E14000530"}] - assert output["local_authority_impact"] == [{"local_authority_code": "E06000001"}] + assert output["constituency_impact"][0]["constituency_code"] == "E14000530" + assert output["local_authority_impact"][0]["local_authority_code"] == "E06000001" def test_builder_calls_policyengine_economic_impact_analysis(): @@ -1334,7 +1361,7 @@ def fail_change_output_variable(*args, **kwargs): def test_uk_constituency_impact_uses_policyengine_output_function(monkeypatch): baseline = object() reform = object() - expected = [{"constituency_code": "E14000530"}] + expected = [_constituency_impact_record().model_dump(mode="json")] def fake_output_module_function(module_name, name): assert module_name == "constituency_impact" @@ -1352,12 +1379,11 @@ def compute(baseline_simulation, reform_simulation): fake_output_module_function, ) - assert ( - _simulation_output_builder("uk", baseline, reform) - ._build_uk_constituency_impact() - .root - == expected - ) + result = _simulation_output_builder( + "uk", baseline, reform + )._build_uk_constituency_impact() + assert result is not None + assert result.model_dump(mode="json") == expected assert ( _simulation_output_builder( "us", baseline, reform @@ -1369,7 +1395,7 @@ def compute(baseline_simulation, reform_simulation): def test_uk_local_authority_impact_uses_policyengine_output_function(monkeypatch): baseline = object() reform = object() - expected = [{"local_authority_code": "E06000001"}] + expected = [_local_authority_impact_record().model_dump(mode="json")] def fake_output_module_function(module_name, name): assert module_name == "local_authority_impact" @@ -1387,12 +1413,11 @@ def compute(baseline_simulation, reform_simulation): fake_output_module_function, ) - assert ( - _simulation_output_builder("uk", baseline, reform) - ._build_uk_local_authority_impact() - .root - == expected - ) + result = _simulation_output_builder( + "uk", baseline, reform + )._build_uk_local_authority_impact() + assert result is not None + assert result.model_dump(mode="json") == expected assert ( _simulation_output_builder( "us", baseline, reform diff --git a/projects/policyengine-simulation-executor/uv.lock b/projects/policyengine-simulation-executor/uv.lock index 385c77a75..8f0f76fdc 100644 --- a/projects/policyengine-simulation-executor/uv.lock +++ b/projects/policyengine-simulation-executor/uv.lock @@ -1830,15 +1830,20 @@ name = "policyengine-simulation-contract" version = "0.1.0" source = { editable = "../../libs/policyengine-simulation-contract" } dependencies = [ - { name = "modal" }, { name = "policyengine-simulation-observability" }, { name = "pydantic" }, ] +[package.optional-dependencies] +modal = [ + { name = "modal" }, +] + [package.metadata] requires-dist = [ { name = "black", marker = "extra == 'build'", specifier = ">=25.1.0" }, - { name = "modal", specifier = ">=1.4,<2" }, + { name = "modal", marker = "extra == 'modal'", specifier = ">=1.4,<2" }, + { name = "modal", marker = "extra == 'test'", specifier = ">=1.4,<2" }, { name = "policyengine-simulation-observability", editable = "../../libs/policyengine-simulation-observability" }, { name = "pydantic", specifier = ">=2.0" }, { name = "pyright", marker = "extra == 'build'", specifier = ">=1.1.401" }, @@ -1846,7 +1851,7 @@ requires-dist = [ { name = "pytest-asyncio", marker = "extra == 'test'", specifier = ">=0.25.3" }, { name = "pytest-cov", marker = "extra == 'test'", specifier = ">=6.1.1" }, ] -provides-extras = ["test", "build"] +provides-extras = ["modal", "test", "build"] [[package]] name = "policyengine-simulation-executor" @@ -1861,7 +1866,7 @@ dependencies = [ { name = "policyengine-core" }, { name = "policyengine-fastapi" }, { name = "policyengine-observability", extra = ["fastapi"] }, - { name = "policyengine-simulation-contract" }, + { name = "policyengine-simulation-contract", extra = ["modal"] }, { name = "policyengine-simulation-observability" }, { name = "policyengine-uk" }, { name = "policyengine-us" }, @@ -1907,7 +1912,7 @@ requires-dist = [ { name = "policyengine-core", specifier = "==3.30.0" }, { name = "policyengine-fastapi", editable = "../../libs/policyengine-fastapi" }, { name = "policyengine-observability", extras = ["fastapi"], specifier = ">=1.3.0,<2" }, - { name = "policyengine-simulation-contract", editable = "../../libs/policyengine-simulation-contract" }, + { name = "policyengine-simulation-contract", extras = ["modal"], editable = "../../libs/policyengine-simulation-contract" }, { name = "policyengine-simulation-observability", editable = "../../libs/policyengine-simulation-observability" }, { name = "policyengine-uk", specifier = "==2.89.2" }, { name = "policyengine-us", specifier = "==1.764.6" }, @@ -1970,7 +1975,7 @@ provides-extras = ["test", "build"] [package.metadata.requires-dev] dev = [ { name = "policyengine-fastapi", editable = "../../libs/policyengine-fastapi" }, - { name = "policyengine-simulation-contract", editable = "../../libs/policyengine-simulation-contract" }, + { name = "policyengine-simulation-contract", extras = ["modal"], editable = "../../libs/policyengine-simulation-contract" }, { name = "policyengine-simulation-observability", editable = "../../libs/policyengine-simulation-observability" }, ] diff --git a/projects/policyengine-simulation-gateway/fixtures/gateway_endpoints.py b/projects/policyengine-simulation-gateway/fixtures/gateway_endpoints.py index ef5eaa844..33840850e 100644 --- a/projects/policyengine-simulation-gateway/fixtures/gateway_endpoints.py +++ b/projects/policyengine-simulation-gateway/fixtures/gateway_endpoints.py @@ -3,6 +3,19 @@ from copy import deepcopy import pytest +from policyengine_simulation_contract.macro_output import ( + AgePovertyOutput, + BaselineReformValue, + BudgetaryImpact, + DecileOutput, + DetailedBudgetOutput, + GenderPovertyOutput, + InequalityOutput, + IntraDecileOutput, + PovertyByGenderOutput, + PovertyOutput, + SingleYearMacroOutput, +) TEST_APP_RELEASE_BUNDLE = { "app_name": "policyengine-simulation-py4-10-0", @@ -171,7 +184,50 @@ class MockFunctionCall: def __init__(self, object_id: str = "mock-job-id-123"): self.object_id = object_id - self.result = {"budget": {"total": 1000000}} + value = BaselineReformValue(baseline=0.0, reform=0.0) + age_poverty = AgePovertyOutput( + child=value, + adult=value, + senior=value, + all=value, + ) + gender_poverty = GenderPovertyOutput(male=value, female=value) + self.result = SingleYearMacroOutput( + model_version="1.500.0", + data_version="1.0.0", + budget=BudgetaryImpact( + tax_revenue_impact=0.0, + state_tax_revenue_impact=0.0, + benefit_spending_impact=0.0, + budgetary_impact=0.0, + households=0.0, + baseline_net_income=0.0, + ), + detailed_budget=DetailedBudgetOutput({}), + decile=DecileOutput(average={}, relative={}), + inequality=InequalityOutput( + gini=value, + top_10_pct_share=value, + top_1_pct_share=value, + ), + poverty=PovertyOutput( + poverty=age_poverty, + deep_poverty=age_poverty, + ), + poverty_by_gender=PovertyByGenderOutput( + poverty=gender_poverty, + deep_poverty=gender_poverty, + ), + poverty_by_race=None, + intra_decile=IntraDecileOutput(deciles={}, all={}), + wealth_decile=None, + intra_wealth_decile=None, + labor_supply_response=None, + constituency_impact=None, + local_authority_impact=None, + congressional_district_impact=None, + cliff_impact=None, + ).model_dump(mode="json") self.error = None self.running = False self.__class__.registry[object_id] = self diff --git a/projects/policyengine-simulation-gateway/pyproject.toml b/projects/policyengine-simulation-gateway/pyproject.toml index dbe323f0f..ba54553a3 100644 --- a/projects/policyengine-simulation-gateway/pyproject.toml +++ b/projects/policyengine-simulation-gateway/pyproject.toml @@ -42,7 +42,7 @@ packages = ["src/policyengine_simulation_gateway"] [dependency-groups] dev = [ "policyengine-fastapi", - "policyengine-simulation-contract", + "policyengine-simulation-contract[modal]", "policyengine-simulation-observability", ] diff --git a/projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py b/projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py index b1a11e2e6..67bf347b7 100644 --- a/projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py +++ b/projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py @@ -4,7 +4,7 @@ import logging from dataclasses import dataclass -from typing import Optional +from typing import Optional, TypedDict import modal from fastapi import APIRouter, Depends, HTTPException @@ -29,12 +29,15 @@ BudgetWindowBatchRequest, BudgetWindowBatchStatusResponse, BudgetWindowBatchSubmitResponse, + HealthResponse, JobStatusResponse, JobSubmitResponse, PingRequest, PingResponse, PolicyEngineBundle, SimulationRequest, + VersionMap, + VersionsResponse, ) from policyengine_simulation_gateway.responses import ( batch_status_response, @@ -60,6 +63,13 @@ SUPPORTED_ROUTE_KINDS = ("policyengine", "us", "uk") +class VersionRoutingState(TypedDict, total=False): + """Routing-state fields required by the public version endpoints.""" + + latest: dict[str, str] + routes: dict[str, dict[str, str]] + + @dataclass(frozen=True) class RouteResolution: app_name: str @@ -924,15 +934,17 @@ async def get_budget_window_job_status(batch_job_id: str): return batch_status_response(response) -@router.get("/versions") -async def list_versions(): +@router.get("/versions", response_model=VersionsResponse) +async def list_versions() -> VersionsResponse: """List all available routing versions.""" with segment(SegmentName.ROUTE_RESOLUTION): state = _active_routing_state() if state: - return { - kind: _version_map_from_state(state, kind) for kind in SUPPORTED_ROUTE_KINDS - } + return VersionsResponse( + policyengine=_version_map_from_state(state, "policyengine"), + us=_version_map_from_state(state, "us"), + uk=_version_map_from_state(state, "uk"), + ) # ``Dict.from_name`` is a lazy handle; the RPCs fire during the # ``dict(...)`` iterations, so those are what the segment must cover. @@ -940,25 +952,25 @@ async def list_versions(): policyengine_dict = _optional_modal_dict(POLICYENGINE_VERSION_DICT_NAME) us_dict = modal.Dict.from_name("simulation-api-us-versions") uk_dict = modal.Dict.from_name("simulation-api-uk-versions") - return { - "policyengine": ( + return VersionsResponse( + policyengine=VersionMap( dict(policyengine_dict) if policyengine_dict is not None else {} ), - "us": dict(us_dict), - "uk": dict(uk_dict), - } + us=VersionMap(dict(us_dict)), + uk=VersionMap(dict(uk_dict)), + ) -def _version_map_from_state(state: dict, kind: str) -> dict: - versions = dict(_routing_state_routes(state, kind)) +def _version_map_from_state(state: VersionRoutingState, kind: str) -> VersionMap: + versions: dict[str, str] = dict(_routing_state_routes(state, kind)) latest = _routing_state_latest(state, kind) if latest is not None: versions["latest"] = latest - return versions + return VersionMap(versions) -@router.get("/versions/{kind}") -async def get_country_versions(kind: str): +@router.get("/versions/{kind}", response_model=VersionMap) +async def get_country_versions(kind: str) -> VersionMap: """Get available versions for policyengine, US, or UK routing.""" kind_lower = kind.lower() if kind_lower not in SUPPORTED_ROUTE_KINDS: @@ -975,17 +987,17 @@ async def get_country_versions(kind: str): if kind_lower == "policyengine": with segment(SegmentName.MODAL_DICT_READ): version_dict = _optional_modal_dict(POLICYENGINE_VERSION_DICT_NAME) - return dict(version_dict) if version_dict is not None else {} + return VersionMap(dict(version_dict) if version_dict is not None else {}) with segment(SegmentName.MODAL_DICT_READ): version_dict = modal.Dict.from_name(f"simulation-api-{kind_lower}-versions") - return dict(version_dict) + return VersionMap(dict(version_dict)) -@router.get("/health") -async def health(): +@router.get("/health", response_model=HealthResponse) +async def health() -> HealthResponse: """Health check endpoint.""" - return {"status": "healthy"} + return HealthResponse() @router.post("/ping", response_model=PingResponse) diff --git a/projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/generate_openapi.py b/projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/generate_openapi.py index 59cca2635..ae3181740 100644 --- a/projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/generate_openapi.py +++ b/projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/generate_openapi.py @@ -18,11 +18,14 @@ BudgetWindowBatchRequest, BudgetWindowBatchStatusResponse, BudgetWindowBatchSubmitResponse, + HealthResponse, JobStatusResponse, JobSubmitResponse, PingRequest, PingResponse, SimulationRequest, + VersionMap, + VersionsResponse, ) @@ -112,20 +115,20 @@ async def get_budget_window_job_status( """ raise NotImplementedError("Stub for OpenAPI generation") - @app.get("/versions") - async def list_versions() -> dict: + @app.get("/versions", response_model=VersionsResponse) + async def list_versions() -> VersionsResponse: """List all available routing versions.""" raise NotImplementedError("Stub for OpenAPI generation") - @app.get("/versions/{kind}") - async def get_country_versions(kind: str) -> dict: + @app.get("/versions/{kind}", response_model=VersionMap) + async def get_country_versions(kind: str) -> VersionMap: """Get available versions for policyengine, US, or UK routing.""" raise NotImplementedError("Stub for OpenAPI generation") - @app.get("/health") - async def health() -> dict: + @app.get("/health", response_model=HealthResponse) + async def health() -> HealthResponse: """Health check endpoint.""" - return {"status": "healthy"} + return HealthResponse() @app.post("/ping", response_model=PingResponse) async def ping(request: PingRequest) -> PingResponse: diff --git a/projects/policyengine-simulation-gateway/tests/golden/openapi.json b/projects/policyengine-simulation-gateway/tests/golden/openapi.json index 60fe1b929..606d51a3d 100644 --- a/projects/policyengine-simulation-gateway/tests/golden/openapi.json +++ b/projects/policyengine-simulation-gateway/tests/golden/openapi.json @@ -199,9 +199,7 @@ "content": { "application/json": { "schema": { - "additionalProperties": true, - "type": "object", - "title": "Response List Versions Versions Get" + "$ref": "#/components/schemas/VersionsResponse" } } } @@ -231,9 +229,7 @@ "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": true, - "title": "Response Get Country Versions Versions Kind Get" + "$ref": "#/components/schemas/VersionMap" } } } @@ -262,9 +258,7 @@ "content": { "application/json": { "schema": { - "additionalProperties": true, - "type": "object", - "title": "Response Health Health Get" + "$ref": "#/components/schemas/HealthResponse" } } } @@ -314,6 +308,50 @@ }, "components": { "schemas": { + "AgePovertyOutput": { + "properties": { + "child": { + "$ref": "#/components/schemas/BaselineReformValue" + }, + "adult": { + "$ref": "#/components/schemas/BaselineReformValue" + }, + "senior": { + "$ref": "#/components/schemas/BaselineReformValue" + }, + "all": { + "$ref": "#/components/schemas/BaselineReformValue" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "child", + "adult", + "senior", + "all" + ], + "title": "AgePovertyOutput" + }, + "BaselineReformValue": { + "properties": { + "baseline": { + "type": "number", + "title": "Baseline" + }, + "reform": { + "type": "number", + "title": "Reform" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "baseline", + "reform" + ], + "title": "BaselineReformValue" + }, "BatchChildJobStatus": { "properties": { "job_id": { @@ -322,6 +360,14 @@ }, "status": { "type": "string", + "enum": [ + "pending", + "queued", + "running", + "complete", + "failed", + "cancelled" + ], "title": "Status" }, "error": { @@ -457,30 +503,22 @@ "reform": { "anyOf": [ { - "additionalProperties": true, - "type": "object" + "$ref": "#/components/schemas/PolicyParameterChanges" }, { "type": "null" } - ], - "title": "Reform" + ] }, "baseline": { "anyOf": [ { - "additionalProperties": true, - "type": "object" + "$ref": "#/components/schemas/PolicyParameterChanges" }, { "type": "null" } - ], - "title": "Baseline" - }, - "region": { - "type": "string", - "title": "Region" + ] }, "region_group": { "anyOf": [ @@ -551,6 +589,10 @@ ], "title": "Segmented" }, + "region": { + "type": "string", + "title": "Region" + }, "start_year": { "type": "string", "title": "Start Year" @@ -590,6 +632,12 @@ "properties": { "status": { "type": "string", + "enum": [ + "submitted", + "running", + "complete", + "failed" + ], "title": "Status" }, "progress": { @@ -707,6 +755,7 @@ }, "status": { "type": "string", + "const": "submitted", "title": "Status" }, "poll_url": { @@ -834,6 +883,289 @@ "title": "BudgetWindowTotals", "description": "Aggregate totals for a completed budget-window response." }, + "BudgetaryImpact": { + "properties": { + "tax_revenue_impact": { + "type": "number", + "title": "Tax Revenue Impact" + }, + "state_tax_revenue_impact": { + "type": "number", + "title": "State Tax Revenue Impact" + }, + "benefit_spending_impact": { + "type": "number", + "title": "Benefit Spending Impact" + }, + "budgetary_impact": { + "type": "number", + "title": "Budgetary Impact" + }, + "households": { + "type": "number", + "title": "Households" + }, + "baseline_net_income": { + "type": "number", + "title": "Baseline Net Income" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "tax_revenue_impact", + "state_tax_revenue_impact", + "benefit_spending_impact", + "budgetary_impact", + "households", + "baseline_net_income" + ], + "title": "BudgetaryImpact" + }, + "CliffImpactInSimulation": { + "properties": { + "cliff_gap": { + "type": "number", + "title": "Cliff Gap" + }, + "cliff_share": { + "type": "number", + "title": "Cliff Share" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "cliff_gap", + "cliff_share" + ], + "title": "CliffImpactInSimulation" + }, + "CliffImpactOutput": { + "properties": { + "baseline": { + "$ref": "#/components/schemas/CliffImpactInSimulation" + }, + "reform": { + "$ref": "#/components/schemas/CliffImpactInSimulation" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "baseline", + "reform" + ], + "title": "CliffImpactOutput" + }, + "CongressionalDistrictImpactOutput": { + "properties": { + "districts": { + "items": { + "$ref": "#/components/schemas/CongressionalDistrictImpactRecord" + }, + "type": "array", + "title": "Districts" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "districts" + ], + "title": "CongressionalDistrictImpactOutput" + }, + "CongressionalDistrictImpactRecord": { + "properties": { + "district": { + "type": "string", + "title": "District" + }, + "average_household_income_change": { + "type": "number", + "title": "Average Household Income Change" + }, + "relative_household_income_change": { + "type": "number", + "title": "Relative Household Income Change" + }, + "winner_percentage": { + "type": "number", + "title": "Winner Percentage" + }, + "loser_percentage": { + "type": "number", + "title": "Loser Percentage" + }, + "no_change_percentage": { + "type": "number", + "title": "No Change Percentage" + }, + "population": { + "type": "number", + "title": "Population" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "district", + "average_household_income_change", + "relative_household_income_change", + "winner_percentage", + "loser_percentage", + "no_change_percentage", + "population" + ], + "title": "CongressionalDistrictImpactRecord" + }, + "ConstituencyImpactRecord": { + "properties": { + "constituency_code": { + "type": "string", + "title": "Constituency Code" + }, + "constituency_name": { + "type": "string", + "title": "Constituency Name" + }, + "x": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "X" + }, + "y": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Y" + }, + "average_household_income_change": { + "type": "number", + "title": "Average Household Income Change" + }, + "relative_household_income_change": { + "type": "number", + "title": "Relative Household Income Change" + }, + "population": { + "type": "number", + "title": "Population" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "constituency_code", + "constituency_name", + "x", + "y", + "average_household_income_change", + "relative_household_income_change", + "population" + ], + "title": "ConstituencyImpactRecord" + }, + "DecileOutput": { + "properties": { + "average": { + "additionalProperties": { + "type": "number" + }, + "type": "object", + "title": "Average" + }, + "relative": { + "additionalProperties": { + "type": "number" + }, + "type": "object", + "title": "Relative" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "average", + "relative" + ], + "title": "DecileOutput" + }, + "DetailedBudgetOutput": { + "additionalProperties": { + "$ref": "#/components/schemas/DetailedBudgetProgramOutput" + }, + "type": "object", + "title": "DetailedBudgetOutput", + "description": "Program name mapped to its baseline and reform totals." + }, + "DetailedBudgetProgramOutput": { + "properties": { + "baseline": { + "type": "number", + "title": "Baseline" + }, + "reform": { + "type": "number", + "title": "Reform" + }, + "difference": { + "type": "number", + "title": "Difference" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "baseline", + "reform", + "difference" + ], + "title": "DetailedBudgetProgramOutput" + }, + "GenderPovertyOutput": { + "properties": { + "male": { + "$ref": "#/components/schemas/BaselineReformValue" + }, + "female": { + "$ref": "#/components/schemas/BaselineReformValue" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "male", + "female" + ], + "title": "GenderPovertyOutput" + }, + "GeographicImpactOutput": { + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/ConstituencyImpactRecord" + }, + { + "$ref": "#/components/schemas/LocalAuthorityImpactRecord" + } + ] + }, + "type": "array", + "title": "GeographicImpactOutput", + "description": "UK constituency or local-authority impact records." + }, "HTTPValidationError": { "properties": { "detail": { @@ -847,23 +1179,88 @@ "type": "object", "title": "HTTPValidationError" }, + "HealthResponse": { + "properties": { + "status": { + "type": "string", + "const": "healthy", + "title": "Status", + "default": "healthy" + } + }, + "type": "object", + "title": "HealthResponse", + "description": "Local process health response." + }, + "InequalityOutput": { + "properties": { + "gini": { + "$ref": "#/components/schemas/BaselineReformValue" + }, + "top_10_pct_share": { + "$ref": "#/components/schemas/BaselineReformValue" + }, + "top_1_pct_share": { + "$ref": "#/components/schemas/BaselineReformValue" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "gini", + "top_10_pct_share", + "top_1_pct_share" + ], + "title": "InequalityOutput" + }, + "IntraDecileOutput": { + "properties": { + "deciles": { + "additionalProperties": { + "items": { + "type": "number" + }, + "type": "array" + }, + "type": "object", + "title": "Deciles" + }, + "all": { + "additionalProperties": { + "type": "number" + }, + "type": "object", + "title": "All" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "deciles", + "all" + ], + "title": "IntraDecileOutput" + }, "JobStatusResponse": { "properties": { "status": { "type": "string", + "enum": [ + "running", + "complete", + "failed" + ], "title": "Status" }, "result": { "anyOf": [ { - "additionalProperties": true, - "type": "object" + "$ref": "#/components/schemas/SingleYearMacroOutput" }, { "type": "null" } - ], - "title": "Result" + ] }, "error": { "anyOf": [ @@ -924,6 +1321,7 @@ }, "status": { "type": "string", + "const": "submitted", "title": "Status" }, "poll_url": { @@ -970,6 +1368,207 @@ "title": "JobSubmitResponse", "description": "Response model for job submission." }, + "JsonObject": { + "additionalProperties": { + "$ref": "#/components/schemas/JsonValue" + }, + "type": "object" + }, + "JsonValue": {}, + "LaborSupplyDecileMetric": { + "properties": { + "income": { + "additionalProperties": { + "type": "number" + }, + "type": "object", + "title": "Income" + }, + "substitution": { + "additionalProperties": { + "type": "number" + }, + "type": "object", + "title": "Substitution" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "income", + "substitution" + ], + "title": "LaborSupplyDecileMetric" + }, + "LaborSupplyDecileOutput": { + "properties": { + "average": { + "$ref": "#/components/schemas/LaborSupplyDecileMetric" + }, + "relative": { + "$ref": "#/components/schemas/LaborSupplyDecileMetric" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "average", + "relative" + ], + "title": "LaborSupplyDecileOutput" + }, + "LaborSupplyHoursOutput": { + "properties": { + "baseline": { + "type": "number", + "title": "Baseline" + }, + "reform": { + "type": "number", + "title": "Reform" + }, + "change": { + "type": "number", + "title": "Change" + }, + "income_effect": { + "type": "number", + "title": "Income Effect" + }, + "substitution_effect": { + "type": "number", + "title": "Substitution Effect" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "baseline", + "reform", + "change", + "income_effect", + "substitution_effect" + ], + "title": "LaborSupplyHoursOutput" + }, + "LaborSupplyRelativeResponse": { + "properties": { + "income": { + "type": "number", + "title": "Income" + }, + "substitution": { + "type": "number", + "title": "Substitution" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "income", + "substitution" + ], + "title": "LaborSupplyRelativeResponse" + }, + "LaborSupplyResponseOutput": { + "properties": { + "substitution_lsr": { + "type": "number", + "title": "Substitution Lsr" + }, + "income_lsr": { + "type": "number", + "title": "Income Lsr" + }, + "relative_lsr": { + "$ref": "#/components/schemas/LaborSupplyRelativeResponse" + }, + "total_change": { + "type": "number", + "title": "Total Change" + }, + "revenue_change": { + "type": "number", + "title": "Revenue Change" + }, + "decile": { + "$ref": "#/components/schemas/LaborSupplyDecileOutput" + }, + "hours": { + "$ref": "#/components/schemas/LaborSupplyHoursOutput" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "substitution_lsr", + "income_lsr", + "relative_lsr", + "total_change", + "revenue_change", + "decile", + "hours" + ], + "title": "LaborSupplyResponseOutput" + }, + "LocalAuthorityImpactRecord": { + "properties": { + "local_authority_code": { + "type": "string", + "title": "Local Authority Code" + }, + "local_authority_name": { + "type": "string", + "title": "Local Authority Name" + }, + "x": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "X" + }, + "y": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Y" + }, + "average_household_income_change": { + "type": "number", + "title": "Average Household Income Change" + }, + "relative_household_income_change": { + "type": "number", + "title": "Relative Household Income Change" + }, + "population": { + "type": "number", + "title": "Population" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "local_authority_code", + "local_authority_name", + "x", + "y", + "average_household_income_change", + "relative_household_income_change", + "population" + ], + "title": "LocalAuthorityImpactRecord" + }, "PingRequest": { "properties": { "value": { @@ -1045,6 +1644,83 @@ "title": "PolicyEngineBundle", "description": "Resolved runtime provenance returned by the gateway." }, + "PolicyParameterChanges": { + "$ref": "#/components/schemas/JsonObject", + "title": "PolicyParameterChanges", + "description": "Policy parameter names mapped to their dated values." + }, + "PovertyByGenderOutput": { + "properties": { + "poverty": { + "$ref": "#/components/schemas/GenderPovertyOutput" + }, + "deep_poverty": { + "$ref": "#/components/schemas/GenderPovertyOutput" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "poverty", + "deep_poverty" + ], + "title": "PovertyByGenderOutput" + }, + "PovertyByRaceOutput": { + "properties": { + "poverty": { + "$ref": "#/components/schemas/RacePovertyOutput" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "poverty" + ], + "title": "PovertyByRaceOutput" + }, + "PovertyOutput": { + "properties": { + "poverty": { + "$ref": "#/components/schemas/AgePovertyOutput" + }, + "deep_poverty": { + "$ref": "#/components/schemas/AgePovertyOutput" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "poverty", + "deep_poverty" + ], + "title": "PovertyOutput" + }, + "RacePovertyOutput": { + "properties": { + "white": { + "$ref": "#/components/schemas/BaselineReformValue" + }, + "black": { + "$ref": "#/components/schemas/BaselineReformValue" + }, + "hispanic": { + "$ref": "#/components/schemas/BaselineReformValue" + }, + "other": { + "$ref": "#/components/schemas/BaselineReformValue" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "white", + "black", + "hispanic", + "other" + ], + "title": "RacePovertyOutput" + }, "SimulationRequest": { "properties": { "country": { @@ -1119,37 +1795,22 @@ "reform": { "anyOf": [ { - "additionalProperties": true, - "type": "object" + "$ref": "#/components/schemas/PolicyParameterChanges" }, { "type": "null" } - ], - "title": "Reform" + ] }, "baseline": { "anyOf": [ { - "additionalProperties": true, - "type": "object" - }, - { - "type": "null" - } - ], - "title": "Baseline" - }, - "region": { - "anyOf": [ - { - "type": "string" + "$ref": "#/components/schemas/PolicyParameterChanges" }, { "type": "null" } - ], - "title": "Region" + ] }, "region_group": { "anyOf": [ @@ -1219,6 +1880,17 @@ } ], "title": "Segmented" + }, + "region": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Region" } }, "additionalProperties": false, @@ -1229,6 +1901,141 @@ "title": "SimulationRequest", "description": "Request model for simulation submission." }, + "SingleYearMacroOutput": { + "properties": { + "model_version": { + "type": "string", + "title": "Model Version" + }, + "data_version": { + "type": "string", + "title": "Data Version" + }, + "budget": { + "$ref": "#/components/schemas/BudgetaryImpact" + }, + "detailed_budget": { + "$ref": "#/components/schemas/DetailedBudgetOutput" + }, + "decile": { + "$ref": "#/components/schemas/DecileOutput" + }, + "inequality": { + "$ref": "#/components/schemas/InequalityOutput" + }, + "poverty": { + "$ref": "#/components/schemas/PovertyOutput" + }, + "poverty_by_gender": { + "$ref": "#/components/schemas/PovertyByGenderOutput" + }, + "poverty_by_race": { + "anyOf": [ + { + "$ref": "#/components/schemas/PovertyByRaceOutput" + }, + { + "type": "null" + } + ] + }, + "intra_decile": { + "$ref": "#/components/schemas/IntraDecileOutput" + }, + "wealth_decile": { + "anyOf": [ + { + "$ref": "#/components/schemas/DecileOutput" + }, + { + "type": "null" + } + ] + }, + "intra_wealth_decile": { + "anyOf": [ + { + "$ref": "#/components/schemas/IntraDecileOutput" + }, + { + "type": "null" + } + ] + }, + "labor_supply_response": { + "anyOf": [ + { + "$ref": "#/components/schemas/LaborSupplyResponseOutput" + }, + { + "type": "null" + } + ] + }, + "constituency_impact": { + "anyOf": [ + { + "$ref": "#/components/schemas/GeographicImpactOutput" + }, + { + "type": "null" + } + ] + }, + "local_authority_impact": { + "anyOf": [ + { + "$ref": "#/components/schemas/GeographicImpactOutput" + }, + { + "type": "null" + } + ] + }, + "congressional_district_impact": { + "anyOf": [ + { + "$ref": "#/components/schemas/CongressionalDistrictImpactOutput" + }, + { + "type": "null" + } + ] + }, + "cliff_impact": { + "anyOf": [ + { + "$ref": "#/components/schemas/CliffImpactOutput" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "model_version", + "data_version", + "budget", + "detailed_budget", + "decile", + "inequality", + "poverty", + "poverty_by_gender", + "poverty_by_race", + "intra_decile", + "wealth_decile", + "intra_wealth_decile", + "labor_supply_response", + "constituency_impact", + "local_authority_impact", + "congressional_district_impact" + ], + "title": "SingleYearMacroOutput", + "description": "Completed response returned by a single-year macro simulation." + }, "TelemetryEnvelope": { "properties": { "run_id": { @@ -1377,6 +2184,35 @@ "type" ], "title": "ValidationError" + }, + "VersionMap": { + "additionalProperties": { + "type": "string" + }, + "type": "object", + "title": "VersionMap", + "description": "Version alias or exact version mapped to a deployed worker app." + }, + "VersionsResponse": { + "properties": { + "policyengine": { + "$ref": "#/components/schemas/VersionMap" + }, + "us": { + "$ref": "#/components/schemas/VersionMap" + }, + "uk": { + "$ref": "#/components/schemas/VersionMap" + } + }, + "type": "object", + "required": [ + "policyengine", + "us", + "uk" + ], + "title": "VersionsResponse", + "description": "All supported simulation routing version maps." } } } diff --git a/projects/policyengine-simulation-gateway/uv.lock b/projects/policyengine-simulation-gateway/uv.lock index f8a6b87cc..3234a9920 100644 --- a/projects/policyengine-simulation-gateway/uv.lock +++ b/projects/policyengine-simulation-gateway/uv.lock @@ -1289,15 +1289,20 @@ name = "policyengine-simulation-contract" version = "0.1.0" source = { editable = "../../libs/policyengine-simulation-contract" } dependencies = [ - { name = "modal" }, { name = "policyengine-simulation-observability" }, { name = "pydantic" }, ] +[package.optional-dependencies] +modal = [ + { name = "modal" }, +] + [package.metadata] requires-dist = [ { name = "black", marker = "extra == 'build'", specifier = ">=25.1.0" }, - { name = "modal", specifier = ">=1.4,<2" }, + { name = "modal", marker = "extra == 'modal'", specifier = ">=1.4,<2" }, + { name = "modal", marker = "extra == 'test'", specifier = ">=1.4,<2" }, { name = "policyengine-simulation-observability", editable = "../../libs/policyengine-simulation-observability" }, { name = "pydantic", specifier = ">=2.0" }, { name = "pyright", marker = "extra == 'build'", specifier = ">=1.1.401" }, @@ -1305,7 +1310,7 @@ requires-dist = [ { name = "pytest-asyncio", marker = "extra == 'test'", specifier = ">=0.25.3" }, { name = "pytest-cov", marker = "extra == 'test'", specifier = ">=6.1.1" }, ] -provides-extras = ["test", "build"] +provides-extras = ["modal", "test", "build"] [[package]] name = "policyengine-simulation-gateway" @@ -1338,7 +1343,7 @@ test = [ [package.dev-dependencies] dev = [ { name = "policyengine-fastapi" }, - { name = "policyengine-simulation-contract" }, + { name = "policyengine-simulation-contract", extra = ["modal"] }, { name = "policyengine-simulation-observability" }, ] @@ -1365,7 +1370,7 @@ provides-extras = ["test", "build"] [package.metadata.requires-dev] dev = [ { name = "policyengine-fastapi", editable = "../../libs/policyengine-fastapi" }, - { name = "policyengine-simulation-contract", editable = "../../libs/policyengine-simulation-contract" }, + { name = "policyengine-simulation-contract", extras = ["modal"], editable = "../../libs/policyengine-simulation-contract" }, { name = "policyengine-simulation-observability", editable = "../../libs/policyengine-simulation-observability" }, ] diff --git a/scripts/generate-clients.sh b/scripts/generate-clients.sh index f0aa95718..3130a6364 100755 --- a/scripts/generate-clients.sh +++ b/scripts/generate-clients.sh @@ -19,9 +19,9 @@ generate_client() { # Install build dependencies if not already installed uv sync --extra build - # Use gateway's OpenAPI generator for simulation service (Modal-based) + # The permanent Cloud Run Simulation Entrypoint owns the public schema. if [ "$SERVICE" = "simulation" ]; then - uv run python -m policyengine_simulation_gateway.generate_openapi + uv run python -m policyengine_simulation_entry.generate_openapi else uv run python -m policyengine_api_${SERVICE//-/_}.generate_openapi fi @@ -60,8 +60,8 @@ generate_client() { cd ../.. } -# Generate client for simulation service (Modal gateway) -generate_client "simulation" "projects/policyengine-simulation-gateway" +# Generate the client from the permanent Cloud Run entrypoint. +generate_client "simulation" "projects/policyengine-simulation-entry" echo "✅ Client generated successfully!" echo "" diff --git a/scripts/publish-clients.sh b/scripts/publish-clients.sh index 518b4fbc9..1fda73086 100755 --- a/scripts/publish-clients.sh +++ b/scripts/publish-clients.sh @@ -12,7 +12,7 @@ publish_client() { case "$SERVICE" in simulation) - CLIENT_DIR="projects/policyengine-simulation-gateway/artifacts/clients/python" + CLIENT_DIR="projects/policyengine-simulation-entry/artifacts/clients/python" ;; *) echo "❌ Unsupported API client service: ${SERVICE}"