diff --git a/.github/scripts/cloud_run_env.sh b/.github/scripts/cloud_run_env.sh index 3a0743fec..d00198e1a 100755 --- a/.github/scripts/cloud_run_env.sh +++ b/.github/scripts/cloud_run_env.sh @@ -29,13 +29,14 @@ cloud_run_set_defaults() { CLOUD_RUN_CONCURRENCY="${CLOUD_RUN_CONCURRENCY:-6}" CLOUD_RUN_WEB_CONCURRENCY="${CLOUD_RUN_WEB_CONCURRENCY:-2}" CLOUD_RUN_PORT="${CLOUD_RUN_PORT:-8080}" - # Readiness gate: /readiness-check stays 503 until the worker has built the - # tax-benefit systems AND run the startup warmup calculate, so boot-to-ready is + # Startup readiness: /health stays 503 until the worker has built the + # tax-benefit systems AND run the startup warmup calculate, and while the + # runtime configuration is invalid, so boot-to-ready is # now the ~371s p90 import plus the warmup. Window sized to the platform maximum # (240s + 24x10s = 480s); both halves are capped at 240s. initialDelay is high # but adds no real scale-out delay because boot exceeds it either way. Sizing # rationale in docs/migration/cloud-run-operations.md. - CLOUD_RUN_STARTUP_PROBE="${CLOUD_RUN_STARTUP_PROBE:-httpGet.path=/readiness-check,httpGet.port=${CLOUD_RUN_PORT},initialDelaySeconds=240,periodSeconds=10,failureThreshold=24,timeoutSeconds=5}" + CLOUD_RUN_STARTUP_PROBE="${CLOUD_RUN_STARTUP_PROBE:-httpGet.path=/health,httpGet.port=${CLOUD_RUN_PORT},initialDelaySeconds=240,periodSeconds=10,failureThreshold=24,timeoutSeconds=5}" CLOUD_RUN_POLICYENGINE_DB_PASSWORD_SECRET="${CLOUD_RUN_POLICYENGINE_DB_PASSWORD_SECRET:-}" CLOUD_RUN_GITHUB_MICRODATA_TOKEN_SECRET="${CLOUD_RUN_GITHUB_MICRODATA_TOKEN_SECRET:-policyengine-api-prod-github-microdata-token:latest}" CLOUD_RUN_OPENAI_API_KEY_SECRET="${CLOUD_RUN_OPENAI_API_KEY_SECRET:-policyengine-api-prod-openai-api-key:latest}" diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index 38be9209c..5dbc160ae 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -501,7 +501,7 @@ jobs: env: CLOUD_RUN_TAG: ${{ steps.cloud_run.outputs.revision_tag }} - name: Wait for Cloud Run staging health - run: bash .github/scripts/health_check.sh "${{ steps.candidate.outputs.url }}/readiness-check" + run: bash .github/scripts/health_check.sh "${{ steps.candidate.outputs.url }}/health" - name: Run staging integration tests run: uv run --frozen --extra dev python -m pytest tests/integration/test_cloud_run_candidate.py tests/integration/test_live_v2_metadata.py tests/integration/test_live_v2_policies.py -v env: @@ -543,7 +543,7 @@ jobs: - name: Install locked live test dependencies run: make install - name: Wait for Cloud Run staging candidate health before live tests - run: bash .github/scripts/health_check.sh "${{ needs.deploy-cloud-run-staging.outputs.url }}/readiness-check" + run: bash .github/scripts/health_check.sh "${{ needs.deploy-cloud-run-staging.outputs.url }}/health" - name: Run live staging tests, one pytest worker per test run: bash .github/scripts/run_live_staging_tests.sh env: @@ -610,10 +610,10 @@ jobs: id: stable_health if: ${{ steps.promote.outcome == 'success' }} continue-on-error: true - run: bash .github/scripts/health_check.sh "${{ needs.deploy-cloud-run-staging.outputs.stable_url }}/readiness-check" + run: bash .github/scripts/health_check.sh "${{ needs.deploy-cloud-run-staging.outputs.stable_url }}/health" - name: Restore previous Cloud Run staging revision if: ${{ steps.promote.outcome == 'failure' || steps.stable_health.outcome == 'failure' }} - run: bash .github/scripts/restore_cloud_run_revision.sh "${{ needs.deploy-cloud-run-staging.outputs.stable_url }}/readiness-check" + run: bash .github/scripts/restore_cloud_run_revision.sh "${{ needs.deploy-cloud-run-staging.outputs.stable_url }}/health" env: CLOUD_RUN_TARGET_REVISION: ${{ needs.deploy-cloud-run-staging.outputs.previous_revision }} CLOUD_RUN_EXPECTED_CURRENT_REVISION: ${{ needs.deploy-cloud-run-staging.outputs.revision }} @@ -788,7 +788,7 @@ jobs: env: CLOUD_RUN_TAG: ${{ steps.cloud_run.outputs.revision_tag }} - name: Wait for Cloud Run candidate health - run: bash .github/scripts/health_check.sh "${{ steps.candidate.outputs.url }}/readiness-check" + run: bash .github/scripts/health_check.sh "${{ steps.candidate.outputs.url }}/health" - name: Install locked Cloud Run smoke test dependencies run: make install - name: Run Cloud Run candidate smoke tests @@ -813,10 +813,10 @@ jobs: id: stable_health if: ${{ steps.promote.outcome == 'success' }} continue-on-error: true - run: bash .github/scripts/health_check.sh "${{ steps.previous.outputs.stable_url }}/readiness-check" + run: bash .github/scripts/health_check.sh "${{ steps.previous.outputs.stable_url }}/health" - name: Restore previous Cloud Run production revision if: ${{ steps.promote.outcome == 'failure' || steps.stable_health.outcome == 'failure' }} - run: bash .github/scripts/restore_cloud_run_revision.sh "${{ steps.previous.outputs.stable_url }}/readiness-check" + run: bash .github/scripts/restore_cloud_run_revision.sh "${{ steps.previous.outputs.stable_url }}/health" env: CLOUD_RUN_TARGET_REVISION: ${{ steps.previous.outputs.revision }} CLOUD_RUN_EXPECTED_CURRENT_REVISION: ${{ steps.candidate.outputs.revision }} diff --git a/changelog.d/health-reports-readiness.changed.md b/changelog.d/health-reports-readiness.changed.md new file mode 100644 index 000000000..56f4939b6 --- /dev/null +++ b/changelog.d/health-reports-readiness.changed.md @@ -0,0 +1 @@ +`/health` now answers 503 `{"status": "not_ready"}` until the startup warmup has finished and while the runtime configuration is invalid, and 200 `{"status": "healthy"}` after. The Cloud Run startup probe and the deployment health checks poll it instead of `/readiness-check`, and `/liveness-check` and `/readiness-check` are removed. diff --git a/changelog.d/remove-legacy-site-routes.removed.md b/changelog.d/remove-legacy-site-routes.removed.md new file mode 100644 index 000000000..f1f1b8470 --- /dev/null +++ b/changelog.d/remove-legacy-site-routes.removed.md @@ -0,0 +1,2 @@ +Removed the routes that only legacy.policyengine.org or the API documentation used: the v1 saved-policy routes (`/{country_id}/user-policy`), the user-profile routes (`/{country_id}/user-profile`), the policy search (`GET /{country_id}/policies`), the recent-simulations list (`GET /simulations`), the `GET /` landing page and `GET /specification`; `/v2/openapi.json` documents the v2 resources. +Stopped serving Canada (`ca`), Nigeria (`ng`) and Israel (`il`): requests for them now get the unknown-country 400 response, and `policyengine_canada`, `policyengine-ng` and `policyengine-il` are no longer installed. diff --git a/docs/canonical-spm.md b/docs/canonical-spm.md index 0f4c5c958..4ae8fb4eb 100644 --- a/docs/canonical-spm.md +++ b/docs/canonical-spm.md @@ -13,7 +13,7 @@ its existing behavior when settings are omitted and rejects explicit SPM setting with `SPM_SETTINGS_UNSUPPORTED`, whatever its version. A bundle whose US model does implement the constructor but ships no certified `measurements.spm` configuration fails closed with `SPM_CONFIGURATION_UNAVAILABLE`: such a -deployment also fails `/readiness-check`, so the release checks report the +deployment also fails `/health`, so the release checks report the condition rather than leaving it to individual requests. Other countries retain their behavior and reject US-only SPM settings. @@ -131,7 +131,7 @@ whose measurement configuration this build cannot resolve — no certified configuration for a model that implements the constructor, a manifest this API cannot read, or an artifact that will not load — fails every US calculation with a configuration code, whatever the caller sent, and also fails -`/readiness-check`: it is meant to stop a deployment rather than to be met on a +`/health`: it is meant to stop a deployment rather than to be met on a request. `GET /us/metadata` still answers, reporting `spm.available` false. A country receipt this API cannot read in full is the same class of failure and @@ -263,8 +263,8 @@ calculation result and SPM receipts. `active_run_id` identifies a pending/runnin run and becomes null when inactive; `latest_successful_run_id` identifies the latest successful run. These run fields and specification metadata may be null on historical records. The -served `/specification` documents these request and response shapes through both -Flask and the native specification route. +legacy `GET /specification` document, which also described these request and +response shapes, has been removed; this section describes them. HTTP response cache identity includes the normalized selection and model/bundle versions, and validates certification before reading the cache. The stored @@ -321,9 +321,9 @@ annual requests require `time_period`, while budget-window requests require `start_year` and `window_size` (1–75, ending no later than 2099). Years use four digits. Optional fields are `version` (installed country model version), `target` (`general`, or annual-only `cliff`) and the annual-only `breakdown`. -Omitted `spm` inherits certified bundle defaults. The served `/specification` -generates these query declarations from the same typed models as the Flask -parser. +Omitted `spm` inherits certified bundle defaults. The Flask parser's typed +models define these query rules; the legacy `GET /specification` document +that also declared them has been removed. `breakdown=local_areas` asks the annual route for the UK constituency and local-authority impacts only. It is a calculation option, so the request has diff --git a/docs/engineering/migration-contracts.md b/docs/engineering/migration-contracts.md index dbdad6c18..ab3846926 100644 --- a/docs/engineering/migration-contracts.md +++ b/docs/engineering/migration-contracts.md @@ -7,8 +7,8 @@ Generated from `policyengine_api/migration_registry.py` and `tests/contract/regi | Metric | Count | | --- | ---: | | route group count | 9 | -| workflow count | 17 | -| request count | 90 | +| workflow count | 16 | +| request count | 86 | | db entity count | 6 | | sim flow count | 3 | @@ -37,7 +37,6 @@ Generated from `policyengine_api/migration_registry.py` and `tests/contract/regi | --- | --- | ---: | --- | --- | --- | | `POST` | `/us/policy` | 201 | `policy` | none | `status`, `message`, `result.policy_id` | | `GET` | `/us/policy/{policy_id}` | 200 | `policy` | none | `status`, `message`, `result` | -| `GET` | `/us/policies` | 200 | `policy` | none | `result` | ### `policy_resources_v2` @@ -50,17 +49,6 @@ Generated from `policyengine_api/migration_registry.py` and `tests/contract/regi | `GET` | `/v2/policies/{policy_id}?country_id=us` | 200 | `policy` | none | `status`, `message`, `result.item.id`, `result.item.country_id`, `result.item.tax_benefit_model_id`, `result.item.parameter_values`, `result.item.created_at`, `result.item.updated_at`, `result.item.missing_parameters` | | `GET` | `/v2/policies?country_id=us` | 200 | `policy` | none | `status`, `message`, `result.items`, `result.offset`, `result.limit`, `result.has_more` | -### `saved_policy_v1_compatibility` - -- Current contract: `api_v1_compatible` -- Future owner: PR 10: Policy Migration - -| Method | Path | Status | Route group | Required headers | Stable response fields | -| --- | --- | ---: | --- | --- | --- | -| `POST` | `/us/user-policy` | 201 | `policy` | none | `status`, `message`, `result.id`, `result.reform_id`, `result.reform_label`, `result.baseline_id`, `result.user_id` | -| `GET` | `/us/user-policy/{user_id}` | 200 | `policy` | none | `status`, `message`, `result` | -| `PUT` | `/us/user-policy` | 200 | `policy` | none | `status`, `message`, `result.id` | - ### `user_policy_associations_v2` - Current contract: `typed_v2_resources` diff --git a/docs/engineering/skills/testing.md b/docs/engineering/skills/testing.md index 7331988d9..7c11efffb 100644 --- a/docs/engineering/skills/testing.md +++ b/docs/engineering/skills/testing.md @@ -261,7 +261,6 @@ uv run pytest \ tests/unit/services/test_policy_mirroring.py \ tests/unit/services/test_user_policy_mirroring.py \ tests/unit/routes/test_policy_dual_write_routes.py \ - tests/unit/routes/test_user_policy_dual_write_routes.py \ tests/unit/test_migration_flags.py \ tests/unit/test_readiness.py \ tests/contract -q @@ -295,8 +294,7 @@ uv run pytest \ tests/contract/test_v1_route_contracts.py \ tests/unit/services/test_policy_service.py \ tests/unit/services/test_user_policy_service.py \ - tests/unit/routes/test_policy_dual_write_routes.py \ - tests/unit/routes/test_user_policy_dual_write_routes.py -q + tests/unit/routes/test_policy_dual_write_routes.py -q ``` Finally regenerate and validate migration contracts: diff --git a/docs/generated/migration_contracts.json b/docs/generated/migration_contracts.json index fd80a7380..686275c75 100644 --- a/docs/generated/migration_contracts.json +++ b/docs/generated/migration_contracts.json @@ -1,10 +1,10 @@ { "metadata": { "db_entity_count": 6, - "request_count": 90, + "request_count": 86, "route_group_count": 9, "sim_flow_count": 3, - "workflow_count": 17 + "workflow_count": 16 }, "route_groups": [ { @@ -136,16 +136,6 @@ "message", "result" ] - }, - { - "expected_status": 200, - "headers": [], - "method": "GET", - "path": "/us/policies", - "route_group": "policy", - "stable_response_fields": [ - "result" - ] } ] }, @@ -207,53 +197,6 @@ } ] }, - { - "current_contract": "api_v1_compatible", - "future_owner_pr": "PR 10: Policy Migration", - "name": "saved_policy_v1_compatibility", - "requests": [ - { - "expected_status": 201, - "headers": [], - "method": "POST", - "path": "/us/user-policy", - "route_group": "policy", - "stable_response_fields": [ - "status", - "message", - "result.id", - "result.reform_id", - "result.reform_label", - "result.baseline_id", - "result.user_id" - ] - }, - { - "expected_status": 200, - "headers": [], - "method": "GET", - "path": "/us/user-policy/{user_id}", - "route_group": "policy", - "stable_response_fields": [ - "status", - "message", - "result" - ] - }, - { - "expected_status": 200, - "headers": [], - "method": "PUT", - "path": "/us/user-policy", - "route_group": "policy", - "stable_response_fields": [ - "status", - "message", - "result.id" - ] - } - ] - }, { "current_contract": "typed_v2_resources", "future_owner_pr": "PR 12A: User Login and v1-Anchored User Associations", diff --git a/docs/migration/cloud-run-operations.md b/docs/migration/cloud-run-operations.md index a69209d24..6ef654ebc 100644 --- a/docs/migration/cloud-run-operations.md +++ b/docs/migration/cloud-run-operations.md @@ -70,14 +70,16 @@ Values measured and justified in gcloud inherits unspecified template fields (a mid-campaign CI deploy once shipped an inherited test concurrency), and `--concurrency default` resolves to the *platform* default (640), not the historical 80. -- **`--startup-probe httpGet.path=/readiness-check` — pinned on every deploy.** +- **`--startup-probe httpGet.path=/health` — pinned on every deploy.** Cloud Run's default is a *TCP* probe, which passes the instant gunicorn's master binds the port, long before a worker finishes importing (the import runs post-fork; `--preload` is deliberately unset). Cloud Run therefore treated a booting instance as "started" and routed live traffic onto it, where requests queued until the 300s timeout — the "no available instance" 500s and 504s seen on - scale-out bursts. Probing `/readiness-check` over HTTP makes Cloud Run withhold - traffic until the app can actually serve. + scale-out bursts. Probing `/health` over HTTP makes Cloud Run withhold + traffic until the app can actually serve. (The probe used `/readiness-check` + until that route and `/liveness-check` were removed; `/health` now has the + readiness behaviour.) **Sizing it is constrained by measured boot times.** Boot-to-ready across 48 boots over 7 days (2026-07-14→21), measured from each instance's @@ -115,14 +117,14 @@ Values measured and justified in **Startup warmup — why readiness depends on more than the import.** Building the tax-benefit systems at import does not compile the per-simulation machinery (parameter-tree materialisation, the formula graph). The **first** calculate on a - fresh worker paid that cost — measured at ~121s — and `/readiness-check` returned + fresh worker paid that cost — measured at ~121s — and the readiness check returned 200 before it, so the probe (and health checks, and smoke tests) saw a "ready" instance whose first real request still took two minutes. `policyengine_api.warmup.run_startup_warmup` now runs a throwaway calculate per country (default US; override with `POLICYENGINE_API_WARMUP_COUNTRIES`, disable with `POLICYENGINE_API_STARTUP_WARMUP=0`) from `asgi.py` before the worker serves, - and `/readiness-check` returns 503 until it completes - (`policyengine_api.readiness`). Verified locally: after the warmup a cold + and `/health` returns 503 until it completes, or while the runtime + configuration is invalid (`policyengine_api.readiness`). Verified locally: after the warmup a cold test-payload calculate drops from ~121s to ~10s. This is the fix for the note that used to live here — deferring the build lazily relocated the cost onto the first request, where readiness lied; warming it at startup pays the cost off the request @@ -208,11 +210,11 @@ Consequences to keep in mind: - An instance is "started" when its port is bound, before the application can answer. The HTTP startup probe prevents Cloud Run from routing requests to - that instance until `/readiness-check` succeeds. -- **Cold-start and readiness measurements must be taken from `/readiness-check` turning + that instance until `/health` succeeds. +- **Cold-start and readiness measurements must be taken from `/health` turning healthy**, not from instance start or port bind — the bind time is no longer meaningful. - Readiness in CI is governed by `.github/scripts/health_check.sh` polling - `/readiness-check` (defaults: 900s budget, 5s interval, 15s per-request `--max-time`; + `/health` (defaults: 900s budget, 5s interval, 15s per-request `--max-time`; all env-overridable). ## Automated revision promotion and rollback @@ -299,7 +301,7 @@ gcloud run services get-iam-policy policyengine-api-staging \ --project policyengine-api --region us-central1 # Readiness -curl -s -o /dev/null -w '%{http_code}\n' "$SERVICE_URL/readiness-check" +curl -s -o /dev/null -w '%{http_code}\n' "$SERVICE_URL/health" ``` ## Public load-balancer routing @@ -313,7 +315,7 @@ complete URL map before and after any intended routing change because gcloud compute url-maps describe lb-api --project policyengine-api \ --format='yaml(defaultService,defaultRouteAction,pathMatchers)' curl -s -o /dev/null -w '%{http_code}\n' \ - https://api.policyengine.org/readiness-check + https://api.policyengine.org/health ``` Application rollback restores the previously serving Cloud Run revision. It diff --git a/docs/migration/pr4-app-engine-rollback-runbook.md b/docs/migration/pr4-app-engine-rollback-runbook.md index eeedcbe82..1b8628f91 100644 --- a/docs/migration/pr4-app-engine-rollback-runbook.md +++ b/docs/migration/pr4-app-engine-rollback-runbook.md @@ -39,7 +39,7 @@ gcloud compute url-maps describe lb-api --project policyengine-api \ ```bash for i in $(seq 1 200); do - curl -sI --max-time 10 https://api.policyengine.org/liveness-check | tr -d '\r' \ + curl -sI --max-time 10 https://api.policyengine.org/health | tr -d '\r' \ | awk -F': ' 'tolower($1)=="x-policyengine-backend" {print $2}' done | sort | uniq -c # expect 200 app_engine after settle ``` diff --git a/docs/migration/pr4-ramp-runbook.md b/docs/migration/pr4-ramp-runbook.md index 76129f288..be34d9ba2 100644 --- a/docs/migration/pr4-ramp-runbook.md +++ b/docs/migration/pr4-ramp-runbook.md @@ -44,7 +44,7 @@ it; sampling is curl/server-side only. ```bash for i in $(seq 1 "$N"); do - curl -sI --max-time 10 "https://$HOST/liveness-check" | tr -d '\r' \ + curl -sI --max-time 10 "https://$HOST/health" | tr -d '\r' \ | awk -F': ' 'tolower($1)=="x-policyengine-backend" {print $2}' done | sort | uniq -c ``` diff --git a/docs/migration/stage-10-v2-policies.md b/docs/migration/stage-10-v2-policies.md index 1d85250fe..d83aa2187 100644 --- a/docs/migration/stage-10-v2-policies.md +++ b/docs/migration/stage-10-v2-policies.md @@ -252,3 +252,17 @@ The normal staging promotion still restores the previously serving Cloud Run revision when promotion or the stable-URL health check fails. It does not create persistent policy fixtures or change the configured write selection as part of each release. + +## Removal of the v1 saved-policy routes + +`POST /{country_id}/user-policy`, `GET /{country_id}/user-policy/{user_id}` +and `PUT /{country_id}/user-policy` are removed, along with +`GET /{country_id}/policies`. Only legacy.policyengine.org called them; Cloud +Run request logs for the 30 days to 2026-10-19 showed no saved-policy +requests. No new v1 saved policies are created, so none are copied to v2. The +saved-policy copying and v1 saved-policy service modules stay until dual-write +is deleted, and existing Cloud SQL `user_policies` rows are kept. + +The Public API also no longer serves Canada, Nigeria and Israel: their v1 +policy requests are rejected as unknown countries before any write, so every +v1 policy write is now for a country that v2 supports. diff --git a/docs/migration/stage-11-v2-households.md b/docs/migration/stage-11-v2-households.md index c9a68eb1c..e5d0fc90c 100644 --- a/docs/migration/stage-11-v2-households.md +++ b/docs/migration/stage-11-v2-households.md @@ -256,3 +256,11 @@ Apply the reviewed schema revisions and application image only to independently verified production targets. Enable native v2 use and v1 create copying as separate operational decisions and retain the preceding application revision for immediate rollback. + +## Removal of Canada, Nigeria and Israel + +The Public API no longer serves Canada, Nigeria and Israel, and their country +packages are no longer installed. Their v1 household requests are rejected as +unknown countries before any write, so the Cloud SQL-only path described +above for those countries no longer runs. Existing Cloud SQL rows for them +are kept. diff --git a/docs/migration/stage6-native-read-routes.md b/docs/migration/stage6-native-read-routes.md index bb83c60a9..a9b855c21 100644 --- a/docs/migration/stage6-native-read-routes.md +++ b/docs/migration/stage6-native-read-routes.md @@ -71,3 +71,14 @@ ROUTE_IMPL_METADATA=fastapi_native Record the tested staging and production revision names, the production observation window, and any rollback action in the tracking issue or pull request. + +## Later removals + +`GET /specification` and the `GET /` landing page are removed: only the API +documentation used them, and `/v2/openapi.json` documents the v2 resources. +`/liveness-check` and `/readiness-check` are removed too. `/health` takes the +readiness behaviour: it answers 503 until the startup warmup has finished and +while the runtime configuration is invalid, and the Cloud Run startup probe and +the deployment health checks poll it. `ROUTE_IMPL_HEALTH` and +`ROUTE_IMPL_SPECIFICATION` no longer select a route, but the deployment +pipeline still requires and verifies them until the Flask fallback is removed. diff --git a/policyengine_api/api.py b/policyengine_api/api.py index f2beba2fb..93aa1018d 100644 --- a/policyengine_api/api.py +++ b/policyengine_api/api.py @@ -35,9 +35,6 @@ def log_timing(message: str) -> None: from policyengine_api.routes.error_routes import error_bp log_timing("Error routes import completed") -from policyengine_api.routes.home_routes import home_bp - -log_timing("Home routes import completed") from policyengine_api.routes.economy_routes import economy_bp log_timing("Economy routes import completed") @@ -50,13 +47,8 @@ def log_timing(message: str) -> None: from policyengine_api.routes.metadata_routes import metadata_bp log_timing("Metadata routes import completed") -from policyengine_api.routes.user_profile_routes import user_profile_bp - -log_timing("User profile routes import completed") -from policyengine_api.routes.reform_impact_routes import reform_impact_bp from policyengine_api.routes.report_output_routes import report_output_bp from policyengine_api.routes.simulation_routes import simulation_bp -from policyengine_api.routes.system_routes import system_bp log_timing("Initialising API...") @@ -112,9 +104,6 @@ def log_timing(message: str) -> None: app.register_blueprint(error_bp) log_timing("Error routes registered") -app.register_blueprint(home_bp) -log_timing("Home routes registered") - app.register_blueprint(metadata_bp) log_timing("Metadata routes registered") @@ -129,17 +118,9 @@ def log_timing(message: str) -> None: app.register_blueprint(economy_bp) log_timing("Economy routes registered") -app.register_blueprint(user_profile_bp) -log_timing("User profile routes registered") - -app.register_blueprint(reform_impact_bp) -log_timing("Simulations endpoint registered") - app.register_blueprint(simulation_bp) app.register_blueprint(report_output_bp) -app.register_blueprint(system_bp) -log_timing("System routes registered") log_timing("API initialised.") diff --git a/policyengine_api/asgi_factory.py b/policyengine_api/asgi_factory.py index 1b986188e..5c4a591bd 100644 --- a/policyengine_api/asgi_factory.py +++ b/policyengine_api/asgi_factory.py @@ -19,14 +19,8 @@ from policyengine_api.constants import VERSION from policyengine_api.fastapi_routes.dependencies import NativeRouteDependencies -from policyengine_api.fastapi_routes.health import ( - build_core_health_router, - build_readiness_router, -) +from policyengine_api.fastapi_routes.health import build_core_health_router from policyengine_api.fastapi_routes.metadata import build_metadata_router -from policyengine_api.fastapi_routes.specification import ( - build_specification_router, -) from policyengine_api.fastapi_routes.v2.errors import ( V2AuthenticationRequiredError, V2AuthenticationUnavailableError, @@ -420,10 +414,6 @@ def _include_native_routers( """Include the always-native routers and those the settings select.""" app.include_router(build_core_health_router(dependencies)) app.include_router(build_v2_router(dependencies)) - if route_settings.health is RouteImplementation.FASTAPI_NATIVE: - app.include_router(build_readiness_router(dependencies)) - if route_settings.specification is RouteImplementation.FASTAPI_NATIVE: - app.include_router(build_specification_router(dependencies)) if route_settings.metadata is RouteImplementation.FASTAPI_NATIVE: app.include_router(build_metadata_router(dependencies)) diff --git a/policyengine_api/constants.py b/policyengine_api/constants.py index 5d40d198d..cfa040317 100644 --- a/policyengine_api/constants.py +++ b/policyengine_api/constants.py @@ -11,6 +11,7 @@ DEFAULT_DATASET_DISPLAY_LABEL, get_dataset_display_label, ) +from policyengine_api.supported_countries import SUPPORTED_COUNTRY_IDS REPO = Path(__file__).parents[1] GET = "GET" @@ -19,17 +20,12 @@ LIST = "LIST" VERSION = "3.38.1" CURRENT_YEAR = str(datetime.now().year) # noqa: DTZ005 # the default year is the host's local calendar year -COUNTRIES = ("uk", "us", "ca", "ng", "il") -COUNTRY_PACKAGE_NAMES = ( - "policyengine_uk", - "policyengine_us", - "policyengine_canada", - "policyengine_ng", - "policyengine_il", -) +# The countries the v1 routes serve: the one country list, in the +# alphabetical order the unknown-country error lists them. +COUNTRIES: tuple[str, ...] = tuple(sorted(SUPPORTED_COUNTRY_IDS)) +COUNTRY_PACKAGE_NAMES = tuple(f"policyengine_{country_id}" for country_id in COUNTRIES) BUNDLED_COUNTRY_PACKAGE_NAMES = { - "uk": "policyengine-uk", - "us": "policyengine-us", + country_id: f"policyengine-{country_id}" for country_id in COUNTRIES } DEFAULT_BUNDLE_DATASET_LABEL = DEFAULT_DATASET_DISPLAY_LABEL diff --git a/policyengine_api/country.py b/policyengine_api/country.py index 1c090ceda..da7262285 100644 --- a/policyengine_api/country.py +++ b/policyengine_api/country.py @@ -28,6 +28,7 @@ from policyengine_core.taxbenefitsystems import TaxBenefitSystem from policyengine_core.types import ArrayLike +from policyengine_api import constants from policyengine_api.constants import ( UK_COUNTRY_REGIONS, get_bundle_default_dataset_option, @@ -46,6 +47,7 @@ spm_metadata, ) from policyengine_api.utils import get_safe_json +from policyengine_api.utils.get_current_law import get_current_law_policy_id logger = logging.getLogger(__name__) @@ -174,13 +176,7 @@ def build_metadata(self) -> None: "entities": self.build_entities(), "variableModules": self.tax_benefit_system.variable_module_metadata, "economy_options": self.build_microsimulation_options(), - "current_law_id": { - "uk": 1, - "us": 2, - "ca": 3, - "ng": 4, - "il": 5, - }[self.country_id], + "current_law_id": get_current_law_policy_id(self.country_id), "basicInputs": self.tax_benefit_system.basic_inputs, "modelled_policies": self.tax_benefit_system.modelled_policies, "spm": spm_metadata(self.country_id), @@ -315,39 +311,6 @@ def build_microsimulation_options(self) -> dict: options["region"] = region options["time_period"] = time_period options["datasets"] = datasets - elif self.country_id == "ca": - region = [ - {"name": "ca", "label": "Canada"}, - ] - time_period = [ - {"name": 2023, "label": "2023"}, - ] - datasets = [{}] - options["region"] = region - options["time_period"] = time_period - options["datasets"] = datasets - elif self.country_id == "ng": - region = [ - {"name": "ng", "label": "Nigeria"}, - ] - time_period = [ - {"name": 2023, "label": "2023"}, - ] - datasets = [{}] - options["region"] = region - options["time_period"] = time_period - options["datasets"] = datasets - elif self.country_id == "il": - region = [ - {"name": "il", "label": "Israel"}, - ] - time_period = [ - {"name": 2023, "label": "2023"}, - ] - datasets = [{}] - options["region"] = region - options["time_period"] = time_period - options["datasets"] = datasets return options def build_variables(self) -> dict: @@ -743,10 +706,10 @@ def get_requested_computations( return requested_computation_data +# One loaded model for each country in the one country list. COUNTRIES = { - "uk": PolicyEngineCountry("policyengine_uk", "uk"), - "us": PolicyEngineCountry("policyengine_us", "us"), - "ca": PolicyEngineCountry("policyengine_canada", "ca"), - "ng": PolicyEngineCountry("policyengine_ng", "ng"), - "il": PolicyEngineCountry("policyengine_il", "il"), + country_id: PolicyEngineCountry(package_name, country_id) + for country_id, package_name in zip( + constants.COUNTRIES, constants.COUNTRY_PACKAGE_NAMES, strict=True + ) } diff --git a/policyengine_api/fastapi_routes/dependencies.py b/policyengine_api/fastapi_routes/dependencies.py index af59af533..084c7d170 100644 --- a/policyengine_api/fastapi_routes/dependencies.py +++ b/policyengine_api/fastapi_routes/dependencies.py @@ -31,7 +31,6 @@ if TYPE_CHECKING: from collections.abc import Callable - from policyengine_api.json_types import JSONObject from policyengine_api.services.v2.associations.types import ( LegacyReferenceChecker, ) @@ -83,14 +82,6 @@ def _default_metadata_reader_factory() -> MetadataReader: return MetadataService() -def _default_specification_provider() -> JSONObject: - from policyengine_api.specification import ( # noqa: PLC0415 # reads and builds the OpenAPI document at import - OPENAPI_SPECIFICATION, - ) - - return OPENAPI_SPECIFICATION - - @lru_cache(maxsize=1) def _running_policyengine_version() -> str: return importlib_metadata.version("policyengine") @@ -296,7 +287,6 @@ class NativeRouteDependencies: readiness_probe: Callable[[], bool] gateway_client_factory: Callable[[], SimulationGatewayProbe] metadata_reader_factory: Callable[[], MetadataReader] - specification_provider: Callable[[], JSONObject] v2_metadata_reader_factory: Callable[[], V2MetadataResourceReader] | None = None v2_country_service_factory: Callable[[], V2CountryResourceService] | None = None v2_policy_service_factory: Callable[[], V2PolicyResourceService] | None = None @@ -327,7 +317,6 @@ def defaults(cls) -> NativeRouteDependencies: readiness_probe=_default_readiness_probe, gateway_client_factory=_default_gateway_client_factory, metadata_reader_factory=_default_metadata_reader_factory, - specification_provider=_default_specification_provider, v2_metadata_reader_factory=_default_v2_metadata_reader_factory, v2_country_service_factory=_default_v2_country_service_factory, v2_policy_service_factory=_default_v2_policy_service_factory, diff --git a/policyengine_api/fastapi_routes/health.py b/policyengine_api/fastapi_routes/health.py index edff74500..3ef96685b 100644 --- a/policyengine_api/fastapi_routes/health.py +++ b/policyengine_api/fastapi_routes/health.py @@ -1,4 +1,4 @@ -"""Native health, liveness, and readiness routes.""" +"""Native health, deployment and simulation gateway health routes.""" from __future__ import annotations @@ -8,18 +8,24 @@ from fastapi import APIRouter, HTTPException from pydantic import BaseModel -from starlette.responses import PlainTextResponse +from starlette.responses import JSONResponse if TYPE_CHECKING: from policyengine_api.fastapi_routes.dependencies import NativeRouteDependencies class HealthResponse(BaseModel): - """Health-check result showing that the API is up and serving requests.""" + """Health-check result showing that the API has warmed up and can serve requests.""" status: Literal["healthy"] +class HealthNotReadyResponse(BaseModel): + """Health-check result while warmup runs or the configuration is invalid.""" + + status: Literal["not_ready"] + + class SimulationGatewayHealthResponse(BaseModel): """Health-check result showing that the API and its simulation gateway are up.""" @@ -44,11 +50,36 @@ class DeploymentResponse(BaseModel): def build_core_health_router( dependencies: NativeRouteDependencies, ) -> APIRouter: - """Build health routes that were native before Stage 6.""" + """Build the health, deployment and simulation gateway health routes. + + ``/health`` answers 503 until the readiness probe passes: the startup + warmup has finished and the runtime configuration is valid. The Cloud Run + startup probe and the deployment health checks poll it, so a revision + receives no traffic, and is not promoted, before it can serve. + """ router = APIRouter() - @router.get("/health", response_model=HealthResponse) - def health() -> HealthResponse: + # HEAD is accepted too: uptime monitors and `curl -I` send it. + @router.api_route( + "/health", + methods=["GET", "HEAD"], + response_model=HealthResponse, + responses={ + 503: { + "model": HealthNotReadyResponse, + "description": ( + "Startup warmup has not finished, or the runtime " + "configuration is invalid." + ), + } + }, + ) + def health() -> HealthResponse | JSONResponse: + if not dependencies.readiness_probe(): + return JSONResponse( + status_code=503, + content=HealthNotReadyResponse(status="not_ready").model_dump(), + ) return HealthResponse(status="healthy") @router.get("/deployment", response_model=DeploymentResponse) @@ -92,30 +123,3 @@ def simulation_gateway_health() -> SimulationGatewayHealthResponse: ) return router - - -def build_readiness_router( - dependencies: NativeRouteDependencies, -) -> APIRouter: - """Build selectable native liveness and readiness routes.""" - router = APIRouter() - - @router.get( - "/liveness-check", - response_class=PlainTextResponse, - include_in_schema=False, - ) - def liveness_check() -> PlainTextResponse: - return PlainTextResponse("OK", status_code=200) - - @router.get( - "/readiness-check", - response_class=PlainTextResponse, - include_in_schema=False, - ) - def readiness_check() -> PlainTextResponse: - if not dependencies.readiness_probe(): - return PlainTextResponse("NOT READY", status_code=503) - return PlainTextResponse("OK", status_code=200) - - return router diff --git a/policyengine_api/fastapi_routes/specification.py b/policyengine_api/fastapi_routes/specification.py deleted file mode 100644 index 974f99d45..000000000 --- a/policyengine_api/fastapi_routes/specification.py +++ /dev/null @@ -1,28 +0,0 @@ -"""Native route for the static legacy OpenAPI specification.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING - -from fastapi import APIRouter -from starlette.responses import JSONResponse - -if TYPE_CHECKING: - from policyengine_api.fastapi_routes.dependencies import NativeRouteDependencies - - -def build_specification_router( - dependencies: NativeRouteDependencies, -) -> APIRouter: - """Build the selectable native specification route.""" - router = APIRouter() - - @router.get( - "/specification", - response_class=JSONResponse, - include_in_schema=False, - ) - def specification() -> JSONResponse: - return JSONResponse(dependencies.specification_provider()) - - return router diff --git a/policyengine_api/migration_flags.py b/policyengine_api/migration_flags.py index e5d9a555f..4fe011389 100644 --- a/policyengine_api/migration_flags.py +++ b/policyengine_api/migration_flags.py @@ -44,6 +44,9 @@ class RouteImplementation(StrEnum): DEFAULT_SIM_ENTRYPOINT = "old_gateway_direct" DEFAULT_SIM_COMPUTE_BACKEND = "old_gateway" +# Removed paths ("/", the liveness and readiness checks, "/specification") +# keep their groups, so requests that still arrive for them are labelled in +# the request logs. ROUTE_GROUP_BY_PATH = { "": "home", "/": "home", @@ -147,7 +150,15 @@ def get_route_impl(route_group: str) -> RouteImplementation: @dataclass(frozen=True) class RouteImplementationSettings: - """Startup route selections for the read groups migrated in Stage 6.""" + """Startup route selections for the read groups migrated in Stage 6. + + ``health`` and ``specification`` no longer select a route: the liveness + and readiness checks and ``GET /specification`` are removed, and + ``/health`` is always native. They are still read because the deployment + pipeline requires and verifies ``ROUTE_IMPL_HEALTH`` and + ``ROUTE_IMPL_SPECIFICATION``; all of them go when the Flask fallback is + removed. + """ health: RouteImplementation specification: RouteImplementation diff --git a/policyengine_api/migration_registry.py b/policyengine_api/migration_registry.py index 5c02b6640..85eb1b333 100644 --- a/policyengine_api/migration_registry.py +++ b/policyengine_api/migration_registry.py @@ -19,6 +19,9 @@ class RouteGroupConfig: sim_flow: str | None = None +# Path segments of removed routes (the liveness and readiness checks, the v1 +# saved-policy, user-profile and policy search routes) stay listed, so +# requests that still arrive for them are labelled in the request logs. ROUTE_GROUPS: tuple[RouteGroupConfig, ...] = ( RouteGroupConfig( name="health", diff --git a/policyengine_api/openapi_spec.yaml b/policyengine_api/openapi_spec.yaml deleted file mode 100644 index 15962cd7f..000000000 --- a/policyengine_api/openapi_spec.yaml +++ /dev/null @@ -1,1429 +0,0 @@ -openapi: 3.0.0 -info: - title: PolicyEngine API - description: The PolicyEngine API provides programmatic access to PolicyEngine functionalities, including managing policies and retrieving policy-related information. - version: null - contact: - name: PolicyEngine Support - email: hello@policyengine.org - url: https://policyengine.org -servers: - - url: https://api.policyengine.org - description: Production server -paths: - /: - get: - summary: Get the home page of the PolicyEngine API - operationId: get_home - description: Returns the home page of the PolicyEngine API as an HTML string. - responses: - 200: - description: The home page. - content: - text/html: - schema: - type: string - /{country_id}/metadata: - get: - summary: Get metadata for a country - operationId: get_metadata - description: Get metadata for a country, including variables, parameters, entities, and other relevant information. - parameters: - - name: country_id - in: path - description: The country ID. - required: true - schema: - type: string - responses: - 200: - description: Metadata for the country. - content: - application/json: - schema: - type: object - properties: - status: - type: string - message: - type: string - nullable: true - result: - type: object - properties: - variables: - type: object - parameters: - type: object - entities: - type: object - variableModules: - type: object - economy_options: - type: object - spm: - $ref: '#/components/schemas/SPMDiscovery' - current_law_id: - type: integer - basicInputs: - type: array - modelled_policies: - type: object - version: - type: string - 404: - description: Invalid country ID. - content: - text/html: - schema: - type: object - properties: - status: - type: string - message: - type: string - /{country_id}/policy: - post: - summary: Set policy data for a given country and policy ID - operationId: set_policy - description: Set policy data for a given country and policy ID. - parameters: - - name: country_id - in: path - description: The country ID. - required: true - schema: - type: string - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - label: - type: string - data: - type: object - responses: - "201": - description: OK - content: - application/json: - schema: - type: object - properties: - status: - type: string - message: - type: string - nullable: true - result: - type: object - properties: - policy_id: - type: integer - "400": - description: >- - Invalid request body, or a new policy whose data names paths that - are not parameters in the country model, such as unknown paths and - parameter groups, or holds period keys or values that simulations - cannot read. A period key must be a date (YYYY-MM-DD) or an - inclusive date range (YYYY-MM-DD.YYYY-MM-DD) of real calendar dates - whose start is no later than its end. A value must be a finite - number, a boolean, null, a list, or the string "Infinity" or - "-Infinity"; numeric strings and objects such as {"value": 1} are - refused. The message and the errors list name every refused path, - period key and value. Re-saving a policy identical to a stored one - returns the stored policy without checking its data. - content: - application/json: - schema: - type: object - properties: - status: - type: string - message: - type: string - result: - type: object - nullable: true - errors: - type: array - description: >- - Present when data names paths that are not parameters, - or holds period keys or values that simulations cannot - read. Paths come first, sorted, then period keys and - values, by parameter path. - items: - type: object - required: [type, name, path, message] - properties: - type: - type: string - enum: [policy_parameter, policy_period, policy_value] - description: >- - policy_parameter for a path that is not a - parameter, policy_period for a period key or a - parameter whose value is not an object of period - keys, and policy_value for a value. - name: - type: string - description: The parameter path. - path: - type: string - description: >- - Where the entry is: data. or - data... - message: - type: string - 404: - description: Invalid country ID. - content: - text/html: - schema: - type: object - properties: - status: - type: string - message: - type: string - /{country_id}/policy/{policy_id}: - get: - summary: Get policy data for a given country and policy ID - operationId: get_policy - description: Get policy data for a given country and policy ID. - parameters: - - name: country_id - in: path - description: The country ID. - required: true - schema: - type: string - - name: policy_id - in: path - description: The policy ID. - required: true - schema: - type: integer - responses: - "200": - description: The policy record. - content: - application/json: - schema: - type: object - properties: - status: - type: string - message: - type: string - nullable: true - result: - type: object - properties: - id: - type: integer - country_id: - type: string - policy_json: - type: object - policy_hash: - type: string - label: - type: string - api_version: - type: string - missing_parameters: - type: array - nullable: true - items: - type: string - description: >- - The parameter paths this policy sets that the - country model the API runs no longer has, sorted; - empty when it has them all. Null when they cannot - be checked. A policy with missing parameters cannot - be run on the current model. - 404: - description: Policy not found. - content: - application/json: - schema: - type: object - properties: - status: - type: string - message: - type: string - 404: - description: Invalid country ID. - content: - text/html: - schema: - type: object - properties: - status: - type: string - message: - type: string - /{country_id}/policies: - get: - summary: Search for policies - operationId: get_policy_search - description: Search for policies based on the country ID and search query. - parameters: - - name: country_id - in: path - description: The country ID. - required: true - schema: - type: string - - name: query - in: query - description: The search query. - required: false - schema: - type: string - responses: - "200": - description: The search results. - content: - application/json: - schema: - type: object - properties: - status: - type: string - message: - type: string - nullable: true - result: - type: array - items: - type: object - properties: - id: - type: integer - label: - type: string - 404: - description: Invalid country ID. - content: - text/html: - schema: - type: object - properties: - status: - type: string - message: - type: string - /{country_id}/household: - post: - summary: Set a household's input data - operationId: post_household - description: >- - Set a household's input data for a given country ID. Certified US bundles - accept spm beside data and store the resolved selection with the household - when the caller sends one; a request that sends none stores none and keeps - the household_hash and response shape identical inputs had before the - bundle was certified. An explicit null spm is rejected with - SPM_SETTINGS_INVALID, and any spm on another country with - SPM_SETTINGS_UNSUPPORTED. - Saved household inputs and SPM selections are immutable. Create a new - household to change either; household PUT is not supported. Dual-write - creation preserves the stored selection in the v2 household content and - identity. National SPM requires an explicit selection; county SPM reads - county_fips from household inputs. A state alone does not identify an SPM - area. - parameters: - - name: country_id - in: path - description: The country ID. - required: true - schema: - type: string - requestBody: - required: true - content: - application/json: - schema: - type: object - required: [data] - properties: - label: - type: string - nullable: true - data: - type: object - spm: - $ref: '#/components/schemas/SPMSelection' - responses: - 400: - $ref: '#/components/responses/SPMValidationError' - 201: - description: OK - content: - application/json: - schema: - type: object - properties: - status: - type: string - message: - type: string - nullable: true - result: - type: object - properties: - household_id: - type: integer - 404: - description: Invalid country ID. - content: - text/html: - schema: - type: object - properties: - status: - type: string - message: - type: string - /{country_id}/household/{household_id}: - get: - summary: Get a household's input data with a given ID - operationId: get_household - description: Get a household's input data with a given country ID and household ID. - parameters: - - name: country_id - in: path - description: The country ID. - required: true - schema: - type: string - - name: household_id - in: path - description: The household ID. - required: true - schema: - type: string - responses: - 200: - description: The household data. - content: - application/json: - schema: - type: object - properties: - status: - type: string - message: - type: string - nullable: true - result: - type: object - properties: - id: - type: integer - country_id: - type: string - household_json: - type: object - household_hash: - type: string - spm: - $ref: '#/components/schemas/SPMSelection' - label: - type: string - nullable: true - api_version: - type: string - 404: - description: Household not found. - content: - application/json: - schema: - type: object - properties: - status: - type: string - message: - type: string - 404: - description: Invalid country ID. - content: - text/html: - schema: - type: object - properties: - status: - type: string - message: - type: string - /{country_id}/household/{household_id}/policy/{policy_id}: - get: - summary: Get a household's output data under a given policy - operationId: get_household_under_policy - description: >- - Get a household's output data under a given policy. Calculations replay - the household's stored SPM selection and return its actual provenance. - SPM geography/composition errors are structured 400 responses. - parameters: - - name: country_id - in: path - description: The country ID. - required: true - schema: - type: string - - name: household_id - in: path - description: The household ID. - required: true - schema: - type: string - - name: policy_id - in: path - description: The policy ID. - required: true - schema: - type: string - responses: - 400: - $ref: '#/components/responses/SPMValidationError' - 200: - description: The household data under a given policy. - content: - application/json: - schema: - type: object - properties: - status: - type: string - message: - type: string - nullable: true - result: - type: object - spm_provenance: - $ref: '#/components/schemas/SPMProvenance' - 404: - description: Household or policy not found. - content: - application/json: - schema: - type: object - properties: - status: - type: string - message: - type: string - 500: - description: Error calculating household under policy. - content: - application/json: - schema: - type: object - properties: - status: - type: string - message: - type: string - 404: - description: Invalid country ID. - content: - text/html: - schema: - type: object - properties: - status: - type: string - message: - type: string - /{country_id}/calculate: - post: &household_calculation_operation - summary: Calculate household and policy without storing data - operationId: get_calculate - description: >- - Calculate requested household variables without storing data. Certified - US bundles accept spm beside household and policy. National SPM requires - an explicit selection; county SPM uses household county_fips inputs. - Sending spm chooses that measurement: a missing county, area or classified - SPM adult is then a typed 400. Omitting spm inherits the certified - defaults without choosing them, so a dependency the model cannot compute - is returned null with HTTP 200 instead. Geography/composition errors occur - only when an SPM dependency is needed, so a state-only tax calculation can - succeed either way. Metro selection requires geography_id; national and - county selections forbid geography_id. The selected artifact and scenario - must belong to the certified bundle. An explicit null spm is rejected with - SPM_SETTINGS_INVALID, and any spm on another country with - SPM_SETTINGS_UNSUPPORTED. - parameters: - - name: country_id - in: path - description: The country ID. - required: true - schema: - type: string - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - household: - type: object - policy: - type: object - spm: - $ref: '#/components/schemas/SPMSelection' - responses: - 400: - $ref: '#/components/responses/SPMValidationError' - 200: - description: The calculation result. - content: - application/json: - schema: - type: object - properties: - status: - type: string - message: - type: string - nullable: true - result: - type: object - spm_provenance: - $ref: '#/components/schemas/SPMProvenance' - 500: - description: Error calculating household under policy. - content: - application/json: - schema: - type: object - properties: - status: - type: string - message: - type: string - 404: - description: Invalid country ID. - content: - text/html: - schema: - type: object - properties: - status: - type: string - message: - type: string - /{country_id}/calculate-full: - post: - <<: *household_calculation_operation - operationId: get_calculate_full - summary: Calculate all household variables without storing data - description: >- - Calculate household variables after adding omitted yearly variables. - Accepts the same household, policy, and certified US spm settings as - calculate, and requests the full output set, which includes SPM - dependencies. A request that sends spm needs household county_fips, an - explicit metro area, or an explicit national selection for them; state - alone returns the typed 400. A request that sends none returns HTTP 200 - with those dependencies null, so certifying a bundle never makes an - existing household uncalculable. - /{country_id}/simulation: - post: - summary: Create or retrieve a simulation record - operationId: create_simulation - description: >- - Persist the country, population and policy identity without running a - calculation. Returns 201 for a new pending record and 200 for an existing - record, preserving its saved status and output. Household calculations - inherit the SPM selection saved on the linked household. Select SPM there - before creating the simulation. All stored households have immutable inputs, - labels and selections, including those without saved spm or simulation links. - Changing any of these requires a new household. Household PUT is unsupported. - Top-level spm is rejected, - including null, with HTTP 400 SPM_SETTINGS_UNSUPPORTED. US numeric household aliases match - historical zero-padded IDs; existing records keep their saved spelling. - parameters: - - name: country_id - in: path - required: true - schema: - type: string - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/SimulationCreate' - responses: - 201: - description: New pending simulation record. - content: &simulation_record_content - application/json: - schema: - $ref: '#/components/schemas/SimulationResponse' - 200: - description: Existing simulation record; no new simulation was created. - content: *simulation_record_content - 400: - description: Invalid request or unsupported independent SPM selection. - content: &simulation_error_content - application/json: - schema: - $ref: '#/components/schemas/SimulationError' - patch: - summary: Store simulation output, status or error - operationId: update_simulation - description: >- - Update the record selected by body id within the path country. Supply at - least one non-null status, output or error_message; complete status requires - non-null output. Null update fields are ignored, and omitted fields retain - their values. The installed country model version and current run state - are updated with the record. Preserve the full household calculation - envelope, including spm_provenance, inside output. Its SPM selection is - derived from that receipt and belongs to the linked household; this - operation does not select a measurement. Top-level spm is - rejected, including null, with HTTP 400 SPM_SETTINGS_UNSUPPORTED. - parameters: - - name: country_id - in: path - required: true - schema: - type: string - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/SimulationUpdate' - responses: - 200: - description: Updated simulation record. - content: *simulation_record_content - 400: - description: Invalid update, no fields to update, or independent SPM selection. - content: *simulation_error_content - 404: - description: The simulation does not exist in the selected country. - content: *simulation_error_content - /{country_id}/simulation/{simulation_id}: - get: - summary: Get a stored simulation record - operationId: get_simulation - description: >- - Read a country-scoped simulation record without running a calculation. - Object and array outputs and simulation_spec_json use the legacy - JSON-encoded string representation, or null when absent. Stored scalar - strings are returned unchanged. Decode a saved household output envelope - to read its spm_provenance. Run identifiers and - specification metadata may be null on historical records. - parameters: - - name: country_id - in: path - required: true - schema: - type: string - - name: simulation_id - in: path - required: true - schema: - type: integer - minimum: 1 - responses: - 200: - description: Stored simulation; the response message is null. - content: *simulation_record_content - 400: - description: Invalid country or simulation ID. - content: *simulation_error_content - 404: - description: The simulation does not exist in the selected country. - content: *simulation_error_content - /{country_id}/report: - post: - summary: Create a report output - operationId: create_report_output - description: Create or retrieve a report output for the provided simulations and year. - parameters: - - name: country_id - in: path - description: The country ID. - required: true - schema: - type: string - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - simulation_1_id: - type: integer - simulation_2_id: - type: integer - nullable: true - year: - type: string - responses: - "200": - description: Existing report output. - content: - application/json: - schema: - type: object - properties: - status: - type: string - message: - type: string - nullable: true - result: - type: object - properties: - requested_at: - type: string - nullable: true - started_at: - type: string - nullable: true - finished_at: - type: string - nullable: true - "201": - description: Created report output. - content: - application/json: - schema: - type: object - properties: - status: - type: string - message: - type: string - nullable: true - result: - type: object - properties: - requested_at: - type: string - nullable: true - started_at: - type: string - nullable: true - finished_at: - type: string - nullable: true - patch: - summary: Update a report output - operationId: update_report_output - description: >- - Update a report output status, result, or error message. An ID stored - under an outdated cache version updates the current report that GET - serves for that ID, and the response keeps the requested ID. Completed US - economy outputs require compact v2 comparison provenance. Each receipt - contains the complete resolved configuration; a sibling spm_config is rejected. - parameters: - - name: country_id - in: path - description: The country ID. - required: true - schema: - type: string - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - id: - type: integer - status: - type: string - output: - type: object - nullable: true - error_message: - type: string - nullable: true - responses: - "200": - description: Updated report output. - content: - application/json: - schema: - type: object - properties: - status: - type: string - message: - type: string - nullable: true - result: - type: object - properties: - requested_at: - type: string - nullable: true - started_at: - type: string - nullable: true - finished_at: - type: string - nullable: true - /{country_id}/report/{report_id}: - get: - summary: Get a report output - operationId: get_report_output - description: >- - Get a report output by ID. An ID stored under an outdated cache version - serves the current report for the same simulations and year, under the - requested ID. Timestamp fields are projected from the selected base report run. - parameters: - - name: country_id - in: path - description: The country ID. - required: true - schema: - type: string - - name: report_id - in: path - description: The report output ID. - required: true - schema: - type: integer - responses: - "200": - description: Report output. - content: - application/json: - schema: - type: object - properties: - status: - type: string - message: - type: string - nullable: true - result: - type: object - properties: - requested_at: - type: string - nullable: true - started_at: - type: string - nullable: true - finished_at: - type: string - nullable: true - /{country_id}/economy/{policy_id}/over/{baseline_policy_id}: - get: - summary: Calculate the economic impact of a policy - operationId: get_economic_impact - description: Calculate the economic impact of a policy compared to a baseline policy. Specify the country ID, policy ID, and baseline policy ID. - parameters: - - name: country_id - in: path - description: The country ID. - required: true - schema: - type: string - - name: policy_id - in: path - description: The policy ID. - required: true - schema: - type: string - - name: baseline_policy_id - in: path - description: The baseline policy ID. - required: true - schema: - type: string - # Query parameters are generated from policyengine_api.query_parameters. - responses: - 200: - description: Annual economic impact, progress, or persisted error state. Canonical completed results include actual baseline/reform receipts containing the selected SPM configuration. While computing, the optional stage fields come from the simulation entrypoint and appear only when its worker reports stages. With breakdown=local_areas, a completed result is the simulation API's local-area breakdown instead, with kind local_areas, the constituency_impact and local_authority_impact lists, and the dataset and version fields. - content: - application/json: - schema: - type: object - properties: - status: - type: string - enum: [ok, computing, error] - message: - type: string - nullable: true - result: - type: object - nullable: true - properties: - spm_provenance: - $ref: '#/components/schemas/SPMComparisonProvenance' - stage: - type: string - description: Current stage of a computing run, such as queued, loading_data, baseline, reform, simulating or outputs. Later versions may add stages; clients must accept a value they do not know. - progress: - type: integer - minimum: 0 - maximum: 100 - description: Coarse completion percent at the start of the current stage. It changes only at stage boundaries. - progress_max: - type: integer - minimum: 0 - maximum: 100 - description: Highest percent the run reaches before its next stage starts. A client can move a time-based estimate between progress and progress_max. - submitted_at: - type: string - format: date-time - description: When the simulation entrypoint accepted the run. - started_at: - type: string - format: date-time - description: When a worker started the run. - stage_started_at: - type: string - format: date-time - description: When the current stage started. - segments: - type: object - description: Completed region segments of a segmented US national run. - properties: - completed: - type: integer - minimum: 0 - total: - type: integer - minimum: 1 - 400: - $ref: '#/components/responses/SPMValidationError' - 404: - description: Invalid country ID. - content: - text/html: - schema: - type: object - properties: - status: - type: string - message: - type: string - 502: - description: The calculation failed, or the simulation API refused a breakdown=local_areas request it cannot run. The message gives the reason. A failed breakdown=local_areas job, or one whose result is not kind local_areas, keeps its error for two minutes only; after that the next request submits a new job. - content: - application/json: - schema: - type: object - properties: - status: - type: string - enum: [error] - message: - type: string - result: - type: object - nullable: true - /{country_id}/economy/{policy_id}/over/{baseline_policy_id}/budget-window: - get: - summary: Calculate budget-window economic impacts - operationId: get_budget_window_economic_impact - description: Calculate annual and total budget impacts for a policy over a multi-year budget window. - parameters: - - name: country_id - in: path - description: The country ID. - required: true - schema: - type: string - - name: policy_id - in: path - description: The reform policy ID. - required: true - schema: - type: string - - name: baseline_policy_id - in: path - description: The baseline policy ID. - required: true - schema: - type: string - # Query parameters are generated from policyengine_api.query_parameters. - responses: - 200: - description: Budget-window economic impact, progress, or error state. - content: - application/json: - schema: - type: object - properties: - status: - type: string - enum: - - ok - - computing - - error - message: - type: string - nullable: true - result: - type: object - nullable: true - properties: - kind: - type: string - enum: [budgetWindow] - annualImpacts: - type: array - items: - type: object - properties: - year: - type: string - spm_provenance: - $ref: '#/components/schemas/SPMComparisonProvenance' - progress: - type: integer - nullable: true - completed_years: - type: array - items: - type: string - computing_years: - type: array - items: - type: string - queued_years: - type: array - items: - type: string - error: - type: string - nullable: true - 400: - $ref: '#/components/responses/SPMValidationError' - 404: - description: Invalid country ID. - content: - text/html: - schema: - type: object - properties: - status: - type: string - message: - type: string - /liveness-check: - get: - summary: Test for server liveness. - operationId: liveness_check - description: Determine whether or not the PolicyEngine server is live. - responses: - 200: - description: Server is live. - content: - text/plain: - schema: - type: string - /readiness-check: - get: - summary: Test for server readiness. - operationId: readiness_check - description: >- - Determine whether or not the PolicyEngine server is ready to serve - requests. Unlike /liveness-check, this returns 503 until the startup - warmup has compiled the simulation machinery. - responses: - 200: - description: Server is ready. - content: - text/plain: - schema: - type: string - 503: - description: Server is live but not yet ready (still warming up). - content: - text/plain: - schema: - type: string - /specification: - get: - summary: Get OpenAPI specs. - operationId: get_specification - description: Get OpenAPI-formatted specs of PolicyEngine API - responses: - 200: - description: OpenAPI specs. - content: - application/json: - schema: - type: object - properties: - info: - type: object - openapi: - type: string - paths: - type: object - servers: - type: array -components: - schemas: - SimulationCreate: - type: object - required: [population_id, population_type, policy_id] - not: - required: [spm] - properties: - population_id: - description: Household ID or geography identifier; US numeric household IDs also accept JSON integers. - oneOf: - - type: string - minLength: 1 - - type: integer - population_type: - type: string - enum: [household, geography] - policy_id: - type: integer - SimulationUpdate: - type: object - required: [id] - not: - required: [spm] - description: At least one non-null status, output or error_message is required. Null fields do not clear saved values. - anyOf: - - required: [status] - properties: - status: - not: - enum: [null] - - required: [output] - properties: - output: - not: - enum: [null] - - required: [error_message] - properties: - error_message: - not: - enum: [null] - properties: - id: - type: integer - description: Simulation ID within the path country. - status: - type: string - nullable: true - enum: [pending, complete, error, null] - output: - description: >- - Saved JSON output, or a JSON-encoded string which the service decodes - before storage. For household calculations preserve the complete - StoredHouseholdOutputEnvelope. Legacy outputs can have other JSON - shapes. Null is ignored, and complete status requires non-null output. - x-household-envelope-schema: - $ref: '#/components/schemas/StoredHouseholdOutputEnvelope' - error_message: - type: string - nullable: true - api_version: - deprecated: true - description: Ignored for every supplied JSON value; updates use the installed country model version. - oneOf: - - required: [status, output] - properties: - status: - enum: [complete] - output: - not: - enum: [null] - - not: - required: [status] - properties: - status: - enum: [complete] - SimulationResponse: - type: object - required: [status, message, result] - properties: - status: - type: string - enum: [ok] - message: - type: string - nullable: true - description: Creation/update confirmation; null for GET. - result: - $ref: '#/components/schemas/SimulationRecord' - SimulationRecord: - type: object - required: - - id - - country_id - - api_version - - population_id - - population_type - - policy_id - - status - - output - - error_message - - simulation_spec_json - - simulation_spec_schema_version - - active_run_id - - latest_successful_run_id - properties: - id: - type: integer - country_id: - type: string - api_version: - type: string - description: Country model version recorded on creation or latest update. - population_id: - type: string - description: Saved population spelling, including historical zero-padded household IDs. - population_type: - type: string - enum: [household, geography] - policy_id: - type: integer - status: - type: string - description: Stored calculation state, normally pending, running, complete or error. - output: - type: string - nullable: true - description: >- - Non-string JSON output is JSON-encoded, or null when absent. Stored - scalar strings are returned unchanged. Decode a saved household - calculation envelope once to recover spm_provenance - beside its result, not beside this record. - x-household-envelope-schema: - $ref: '#/components/schemas/StoredHouseholdOutputEnvelope' - error_message: - type: string - nullable: true - simulation_spec_json: - type: string - nullable: true - description: JSON-encoded country_id, population_id, population_type and policy_id; null on historical records without specification metadata. - simulation_spec_schema_version: - type: integer - nullable: true - description: Version 1 for the current identity specification; may be null on historical records. - active_run_id: - type: string - format: uuid - nullable: true - description: Current pending/running run ID; null when inactive or absent on a historical record. - latest_successful_run_id: - type: string - format: uuid - nullable: true - description: Most recent successful run ID; null until one exists or on a historical record without run metadata. - StoredHouseholdOutputEnvelope: - type: object - description: >- - Household calculation envelope saved as simulation output. Keep result - and SPM provenance together. Legacy calculations omit SPM provenance; this - descriptive schema does not add validation to the persistence endpoint. - properties: - status: - type: string - message: - type: string - nullable: true - result: - type: object - nullable: true - spm_provenance: - $ref: '#/components/schemas/SPMProvenance' - SimulationError: - type: object - required: [status, message, result] - properties: - status: - type: string - enum: [error] - message: - type: string - result: - type: object - nullable: true - errors: - type: array - description: Present for a rejected top-level SPM override. - items: - type: object - required: [code, message] - properties: - code: - type: string - enum: [SPM_SETTINGS_UNSUPPORTED] - message: - type: string - # The public loader replaces these with the public typed model schemas, - # converting optional fields to OpenAPI 3.0 nullable so validation stays aligned. - SPMDiscovery: - type: object - description: SPM availability for this installed country bundle. Available bundles also expose the selection schema and resolved defaults. - required: [available] - properties: - available: - type: boolean - settings_schema: - type: object - description: JSON Schema for SPMSelection, preserving omitted fields. - defaults: - $ref: '#/components/schemas/SPMSelection' - SPMSelection: - description: Generated from policyengine_api.spm.SPMSelection. - SPMRuntimeVersions: - description: Generated from policyengine_api.spm.SPMRuntimeVersions. - SPMProvenance: - description: Generated from policyengine_api.spm.SPMProvenance. - SPMExecutionProvenance: - description: Generated from policyengine_api.spm.SPMExecutionProvenance. - SPMComparisonProvenance: - description: Generated from policyengine_api.spm.SPMComparisonProvenance. - responses: - SPMValidationError: - description: >- - Household input or settings validation failed. SPM errors always include - result null and an errors array with code/message objects; other validation - errors may omit those fields. SPM household input codes - are SPM_GEOGRAPHY_REQUIRED, SPM_GEOGRAPHY_UNAVAILABLE, - SPM_COMPOSITION_REQUIRED, and SPM_YEAR_UNAVAILABLE. Unsupported or uncertified settings use - SPM_SETTINGS_INVALID, SPM_SETTINGS_UNSUPPORTED, or - SPM_CONFIGURATION_UNAVAILABLE. - content: - application/json: - schema: - type: object - required: [status, message] - properties: - status: - type: string - enum: [error] - message: - type: string - result: - type: object - nullable: true - errors: - type: array - items: - type: object - required: [message] - properties: - code: - type: string - message: - type: string diff --git a/policyengine_api/routes/home_routes.py b/policyengine_api/routes/home_routes.py deleted file mode 100644 index b550d8ddb..000000000 --- a/policyengine_api/routes/home_routes.py +++ /dev/null @@ -1,15 +0,0 @@ -"""Flask route for the API's HTML landing page.""" - -from flask import Blueprint - -home_bp = Blueprint("home", __name__) - - -@home_bp.route("/", methods=["GET"]) -def get_home() -> str: - """Get the home page of the PolicyEngine API.""" - return ( - "

PolicyEngine households API

" - "

Use this API to compute the impact of public policy on individual " - "households.

" - ) diff --git a/policyengine_api/routes/policy_routes.py b/policyengine_api/routes/policy_routes.py index 65ae62554..5b67aa52b 100644 --- a/policyengine_api/routes/policy_routes.py +++ b/policyengine_api/routes/policy_routes.py @@ -1,15 +1,12 @@ -"""Flask routes for v1 policies and saved user policies, optionally mirrored to v2.""" +"""Flask routes for v1 policies, optionally mirrored to v2.""" import contextlib import json -import time -from dataclasses import dataclass -from http import HTTPStatus from flask import Blueprint, Response, request from werkzeug.exceptions import BadRequest, NotFound -from policyengine_api.data.v1_models import Policy, UserPolicy +from policyengine_api.data.v1_models import Policy from policyengine_api.data.v2.catalog.catalog_selection import ( SUPPORTED_V2_COUNTRY_IDS, ) @@ -21,9 +18,7 @@ from policyengine_api.request_context import current_request_id from policyengine_api.response_factory import _make_error_response from policyengine_api.services.policy_mirroring import ( - MISSING_MIRROR_REVISION_FAILURE, MISSING_MIRROR_SNAPSHOT_FAILURE, - UNEXPECTED_FAILURE, PolicyCopyFailure, PolicyMirrorUnavailableError, mirror_policy_after_commit, @@ -36,17 +31,6 @@ InvalidPolicyDataError, PolicyService, ) -from policyengine_api.services.user_policy_mirroring import ( - UserPolicyMirrorCollaborators, - UserPolicyMirrorUnavailableError, - mirror_pending_user_policy_events_after_commit, -) -from policyengine_api.services.user_policy_service import ( - USER_POLICY_MUTABLE_FIELDS, - UserPolicyPersistenceError, - UserPolicyService, - UserPolicyUpdateResult, -) from policyengine_api.services.v2.policies.types import LegacyPolicySnapshot from policyengine_api.utils.payload_validators import ( validate_country, @@ -56,9 +40,8 @@ policy_bp = Blueprint("policy", __name__) policy_service = PolicyService(model_parameter_names=loaded_model_parameter_names) policy_parameter_service = PolicyParameterService() -user_policy_service = UserPolicyService() -# v1 policy and saved-policy IDs are MySQL signed INT values. +# v1 policy IDs are MySQL signed INT values. MAXIMUM_LEGACY_INTEGER_ID = 2_147_483_647 @@ -132,50 +115,8 @@ def _copy_policy_to_v2( mirror_policy_after_commit(snapshot) -def _copy_saved_policy_events_to_v2( - country_id: str, - legacy_user_policy_id: int, - mirror_revision: int | None, -) -> None: - """Copy a committed saved policy's pending events to v2, never failing the request. - - A failed copy leaves its events pending, and the next change to the same - saved policy replays them in order. - """ - if mirror_revision is None: - _log_uncopied_v1_write( - metric_name="v1_user_policy_mirror_operations", - legacy_id_field="legacy_user_policy_id", - legacy_id=legacy_user_policy_id, - country_id=country_id, - failure=MISSING_MIRROR_REVISION_FAILURE, - ) - return - try: - mirror_pending_user_policy_events_after_commit( - country_id, - legacy_user_policy_id, - through_revision=mirror_revision, - collaborators=UserPolicyMirrorCollaborators( - event_service=user_policy_service, - reform_snapshot_loader=policy_service.get_policy_snapshot, - ), - ) - except UserPolicyMirrorUnavailableError: - # The mirroring service has already logged the failure and its category. - return - except Exception: # noqa: BLE001 # the v1 write is committed; an unexpected mirror failure is logged instead of failing the request - _log_uncopied_v1_write( - metric_name="v1_user_policy_mirror_operations", - legacy_id_field="legacy_user_policy_id", - legacy_id=legacy_user_policy_id, - country_id=country_id, - failure=UNEXPECTED_FAILURE, - ) - - def _safe_legacy_integer_id(value: object) -> int | None: - """Return a v1 policy or saved-policy ID for a log record, or None if invalid.""" + """Return a v1 policy ID for a log record, or None if invalid.""" if ( isinstance(value, int) and not isinstance(value, bool) @@ -185,62 +126,6 @@ def _safe_legacy_integer_id(value: object) -> int | None: return None -@dataclass(frozen=True) -class _UserPolicyWriteAttempt: - """One saved-policy write, as the record of its failure describes it.""" - - operation: str - country_id: str - configured_write_source: str - started_at: float - legacy_user_policy_id: object | None = None - - -def _user_policy_persistence_failure( - error: UserPolicyPersistenceError, - attempt: _UserPolicyWriteAttempt, -) -> Response: - """Return and record a failure without serializing exception details.""" - status_code = 503 if error.retryable else 500 - # A logging failure must not replace the persistence response. - with contextlib.suppress(Exception): - logger.log_struct( - { - "message": "V1 saved-policy persistence failed", - "metric_name": "v1_user_policy_persistence_failures", - "metric_value": 1, - "resource": "user_policy", - "operation": attempt.operation, - "database_source": "cloud_sql", - "configured_write_source": attempt.configured_write_source, - "country_id": attempt.country_id, - "legacy_user_policy_id": _safe_legacy_integer_id( - attempt.legacy_user_policy_id - ), - "request_id": current_request_id(), - "outcome": "error", - "failure_category": error.category.value, - "http_status": status_code, - "duration_ms": round( - (time.perf_counter() - attempt.started_at) * 1000, - 3, - ), - }, - severity="ERROR", - ) - - message = ( - "Policy database is temporarily unavailable; please try again later." - if status_code == HTTPStatus.SERVICE_UNAVAILABLE - else "Internal database error; please try again later." - ) - return _make_error_response( - message, - status_code, - include_status=False, - ) - - def _serialize_policy(policy: Policy) -> dict: return { "id": policy.id, @@ -356,288 +241,3 @@ def set_policy(country_id: str) -> Response: code = 200 if is_existing_policy else 201 return Response(json.dumps(response_body), status=code, mimetype="application/json") - - -def _user_policy_write_configuration(country_id: str) -> tuple[str, bool]: - """Return the configured write source and whether a write mirrors to v2. - - Raise ValueError when a source setting the write needs is invalid. - """ - write_source = get_v1_policy_write_source() - mirror_to_v2 = _should_mirror_to_v2(country_id, write_source) - if mirror_to_v2: - get_v1_policy_read_source() - return write_source, mirror_to_v2 - - -def _serialize_user_policy(user_policy: UserPolicy) -> dict: - return { - column.name: getattr(user_policy, column.name) - for column in UserPolicy.__table__.columns - if column.name != "mirror_revision" - } - - -@policy_bp.route("//policies", methods=["GET"]) -@validate_country -def get_policy_search(country_id: str) -> Response: - """Search policies for a country.""" - query = request.args.get("query", "") - unique_only = request.args.get("unique_only", default=False, type=json.loads) - - try: - get_v1_policy_read_source() - except ValueError: - return _policy_configuration_unavailable() - - try: - results = policy_service.search_policies( - country_id, - query, - unique_only=unique_only, - ) - if not results: - return _make_error_response( - f"No policies found for country {country_id} for query '{query}", - 404, - ) - - policies = [{"id": result.id, "label": result.label} for result in results] - return Response( - json.dumps( - { - "status": "ok", - "message": "Policies found", - "result": policies, - } - ), - status=200, - mimetype="application/json", - ) - except Exception: # noqa: BLE001 # any search failure returns the JSON 500 response - return _make_error_response( - "Internal server error; please try again later.", - 500, - ) - - -@policy_bp.route("//user-policy", methods=["POST"]) -@validate_country -def set_user_policy(country_id: str) -> Response: - """Create a saved policy for a user, or return its existing ID.""" - payload = request.json - reform_label = payload.pop("reform_label", None) - reform_id = payload.pop("reform_id") - baseline_label = payload.pop("baseline_label", None) - baseline_id = payload.pop("baseline_id") - user_id = payload.pop("user_id") - year = payload.pop("year") - geography = payload.pop("geography") - dataset = payload.pop("dataset", None) - number_of_provisions = payload.pop("number_of_provisions") - api_version = payload.pop("api_version") - added_date = payload.pop("added_date") - updated_date = payload.pop("updated_date") - budgetary_impact = payload.pop("budgetary_impact", None) - policy_type = payload.pop("type", None) - - values = { - "country_id": country_id, - "reform_id": reform_id, - "reform_label": reform_label, - "baseline_id": baseline_id, - "baseline_label": baseline_label, - "user_id": user_id, - "year": year, - "geography": geography, - "dataset": dataset, - "number_of_provisions": number_of_provisions, - "api_version": api_version, - "added_date": added_date, - "updated_date": updated_date, - "budgetary_impact": budgetary_impact, - "type": policy_type, - } - - try: - write_source, mirror_to_v2 = _user_policy_write_configuration(country_id) - except ValueError: - return _policy_configuration_unavailable() - - persistence_started_at = time.perf_counter() - try: - creation = user_policy_service.create_or_get_user_policy( - values, - record_mirror_event=mirror_to_v2, - ) - user_policy = creation.user_policy - except UserPolicyPersistenceError as error: - return _user_policy_persistence_failure( - error, - _UserPolicyWriteAttempt( - operation="create", - country_id=country_id, - configured_write_source=write_source, - started_at=persistence_started_at, - ), - ) - - if mirror_to_v2: - _copy_saved_policy_events_to_v2( - country_id, - creation.user_policy.id, - creation.mirror_revision, - ) - - if not creation.created: - return Response( - json.dumps( - { - "status": "ok", - "message": ( - f"The reform #{reform_id} / baseline #{baseline_id} pair " - f"already exists for user {user_id}" - ), - "result": {"id": user_policy.id}, - } - ), - status=200, - mimetype="application/json", - ) - - return Response( - json.dumps( - { - "status": "ok", - "message": "Record created successfully", - "result": _serialize_user_policy(user_policy), - } - ), - status=201, - mimetype="application/json", - ) - - -@policy_bp.route("//user-policy/", methods=["GET"]) -@validate_country -def get_user_policy(country_id: str, user_id: str) -> dict | Response: - """Fetch all saved policies for a user.""" - try: - get_v1_policy_read_source() - except ValueError: - return _policy_configuration_unavailable() - user_policies = user_policy_service.list_user_policies(country_id, user_id) - return { - "status": "ok", - "message": None, - "result": [_serialize_user_policy(row) for row in user_policies], - } - - -UPDATE_USER_POLICY_ALLOWED_FIELDS = USER_POLICY_MUTABLE_FIELDS - - -@policy_bp.route("//user-policy", methods=["PUT"]) -@validate_country -def update_user_policy(country_id: str) -> Response: - """Update mutable fields on a saved user policy.""" - payload = request.json - if not isinstance(payload, dict) or "id" not in payload: - return _make_error_response( - "Request body must include an 'id' field.", - 400, - include_status=False, - ) - - user_policy_id = payload.pop("id") - field_error = _user_policy_update_field_error(payload) - if field_error is not None: - return field_error - - try: - write_source, mirror_to_v2 = _user_policy_write_configuration(country_id) - except ValueError: - return _policy_configuration_unavailable() - - persistence_started_at = time.perf_counter() - try: - update = user_policy_service.update_user_policy( - country_id, - user_policy_id, - payload, - record_mirror_event=mirror_to_v2, - ) - except UserPolicyPersistenceError as error: - return _user_policy_persistence_failure( - error, - _UserPolicyWriteAttempt( - operation="update", - country_id=country_id, - configured_write_source=write_source, - started_at=persistence_started_at, - legacy_user_policy_id=user_policy_id, - ), - ) - - return _committed_update_response( - country_id, - user_policy_id, - update, - mirror_to_v2=mirror_to_v2, - ) - - -def _user_policy_update_field_error(payload: dict) -> Response | None: - """Return the 400 for update fields that are unsupported or absent, else None.""" - unknown_keys = [ - key for key in payload if key not in UPDATE_USER_POLICY_ALLOWED_FIELDS - ] - if unknown_keys: - return _make_error_response( - f"Request body contains unsupported fields: {sorted(unknown_keys)}", - 400, - include_status=False, - ) - - if not payload: - return _make_error_response( - "Request body must include at least one field to update.", - 400, - include_status=False, - ) - return None - - -def _committed_update_response( - country_id: str, - user_policy_id: object, - update: UserPolicyUpdateResult | None, - *, - mirror_to_v2: bool, -) -> Response: - """Answer a committed saved-policy update, mirroring it to v2 if selected.""" - if update is None: - return _make_error_response( - f"User policy #{user_policy_id} not found.", - 404, - include_status=False, - ) - - if mirror_to_v2: - _copy_saved_policy_events_to_v2( - country_id, - update.user_policy.id, - update.mirror_revision, - ) - - return Response( - json.dumps( - { - "status": "ok", - "message": "Record updated successfully", - "result": {"id": user_policy_id}, - } - ), - status=200, - mimetype="application/json", - ) diff --git a/policyengine_api/routes/reform_impact_routes.py b/policyengine_api/routes/reform_impact_routes.py deleted file mode 100644 index 21bbf6df9..000000000 --- a/policyengine_api/routes/reform_impact_routes.py +++ /dev/null @@ -1,71 +0,0 @@ -"""Flask route listing recent cached reform impact calculations.""" - -import json -from datetime import datetime -from typing import Any - -from flask import Blueprint, Response, request - -from policyengine_api.runtime_cache.reform_impacts import CachedReformImpact -from policyengine_api.services.reform_impacts_service import ReformImpactsService - -reform_impact_bp = Blueprint("reform_impact", __name__) -reform_impacts_service = ReformImpactsService() - -_MAX_SIMULATION_RESULTS = 1000 -_DEFAULT_SIMULATION_RESULTS = 100 - - -def _serialize_v1_json(value: dict[str, Any] | None) -> str | None: - return None if value is None else json.dumps(value) - - -def _serialize_v1_datetime(value: datetime | None) -> str | None: - return None if value is None else str(value) - - -def _serialize_v1_reform_impact(impact: CachedReformImpact) -> dict[str, object]: - """Project a cached impact onto the explicit historical v1 response shape.""" - return { - "reform_impact_id": impact.reform_impact_id, - "baseline_policy_id": impact.baseline_policy_id, - "reform_policy_id": impact.reform_policy_id, - "country_id": impact.country_id, - "region": impact.region, - "dataset": impact.dataset, - "time_period": impact.time_period, - "options_json": _serialize_v1_json(impact.options_json), - "options_hash": impact.options_hash, - "api_version": impact.api_version, - "reform_impact_json": _serialize_v1_json(impact.reform_impact_json), - "status": impact.status, - "message": impact.message, - "start_time": _serialize_v1_datetime(impact.start_time), - "end_time": _serialize_v1_datetime(impact.end_time), - "execution_id": impact.execution_id, - } - - -def _parse_result_limit(value: str | None) -> int: - if value is None: - return _DEFAULT_SIMULATION_RESULTS - try: - result_limit = int(value) - except (TypeError, ValueError): - return _DEFAULT_SIMULATION_RESULTS - return max(1, min(result_limit, _MAX_SIMULATION_RESULTS)) - - -@reform_impact_bp.route("/simulations", methods=["GET"]) -def get_simulations() -> Response: - """Return recent reform impacts through a bounded cache-index lookup.""" - result_limit = _parse_result_limit(request.args.get("max_results")) - impacts = reform_impacts_service.get_recent_reform_impacts(result_limit) - - return Response( - json.dumps( - {"result": [_serialize_v1_reform_impact(impact) for impact in impacts]} - ), - status=200, - mimetype="application/json", - ) diff --git a/policyengine_api/routes/system_routes.py b/policyengine_api/routes/system_routes.py deleted file mode 100644 index 67b7959d1..000000000 --- a/policyengine_api/routes/system_routes.py +++ /dev/null @@ -1,34 +0,0 @@ -"""Flask routes for the liveness and readiness probes and the legacy OpenAPI spec.""" - -from flask import Blueprint, Response, jsonify - -from policyengine_api.readiness import is_ready -from policyengine_api.specification import OPENAPI_SPECIFICATION - -system_bp = Blueprint("system", __name__) - - -@system_bp.route("/liveness-check", methods=["GET"]) -def liveness_check() -> Response: - """Return 200 "OK" whenever the process can serve a request, even during warmup.""" - return Response("OK", status=200, headers={"Content-Type": "text/plain"}) - - -@system_bp.route("/readiness-check", methods=["GET"]) -def readiness_check() -> Response: - """Return 200 "OK" once startup warmup has finished, and 503 "NOT READY" before.""" - # The service is not ready until startup warmup compiles the simulation - # machinery. Liveness remains unconditional so the worker is not restarted. - if not is_ready(): - return Response( - "NOT READY", - status=503, - headers={"Content-Type": "text/plain"}, - ) - return Response("OK", status=200, headers={"Content-Type": "text/plain"}) - - -@system_bp.route("/specification", methods=["GET"]) -def get_specification() -> Response: - """Return the legacy public OpenAPI specification as JSON.""" - return jsonify(OPENAPI_SPECIFICATION) diff --git a/policyengine_api/routes/user_profile_routes.py b/policyengine_api/routes/user_profile_routes.py deleted file mode 100644 index 59ff29f85..000000000 --- a/policyengine_api/routes/user_profile_routes.py +++ /dev/null @@ -1,162 +0,0 @@ -"""Flask routes for creating, reading and updating v1 user profiles.""" - -import json - -from flask import Blueprint, Response, request -from werkzeug.exceptions import BadRequest, NotFound - -from policyengine_api.data.v1_models import UserProfile -from policyengine_api.services.user_service import UserService -from policyengine_api.utils.payload_validators import validate_country - -user_profile_bp = Blueprint("user_profile", __name__) -user_service = UserService() - - -def _serialize_user_profile( - profile: UserProfile, - *, - include_auth0_id: bool, -) -> dict: - result = { - "user_id": profile.user_id, - "primary_country": profile.primary_country, - "username": profile.username, - "user_since": profile.user_since, - } - if include_auth0_id: - result["auth0_id"] = profile.auth0_id - return result - - -@user_profile_bp.route("//user-profile", methods=["POST"]) -@validate_country -def set_user_profile(country_id: str) -> Response: - """Create a user profile for an Auth0 ID, or return the one it already has. - - Returns 201 for a new profile and 200 for an existing one; the Auth0 ID is - never echoed back. - """ - payload = request.json - if payload is None: - msg = "Payload missing from request" - raise BadRequest(msg) - - auth0_id = payload.pop("auth0_id") - username = payload.pop("username", None) - user_since = payload.pop("user_since") - - created, profile = user_service.create_profile( - primary_country=country_id, - auth0_id=auth0_id, - username=username, - user_since=user_since, - ) - result = _serialize_user_profile(profile, include_auth0_id=False) - - response = { - "status": "ok", - "message": "Record created successfully" if created else "Record exists", - "result": result, - } - return Response( - json.dumps(response), - status=201 if created else 200, - mimetype="application/json", - ) - - -@user_profile_bp.route("//user-profile", methods=["GET"]) -@validate_country -def get_user_profile(country_id: str) -> Response: # noqa: ARG001 # Flask passes the URL variable by keyword - """Return a user profile looked up by ``auth0_id`` or, failing that, ``user_id``. - - The Auth0 ID is included only when the caller looked the profile up by it. - Neither identifier gives 400 and an unknown user gives 404. - """ - auth0_id = request.args.get("auth0_id") - user_id = request.args.get("user_id") - - if (auth0_id is None) and (user_id is None): - msg = "auth0_id or user_id must be provided" - raise BadRequest(msg) - - profile = ( - user_service.get_profile(user_id=user_id) - if auth0_id is None - else user_service.get_profile(auth0_id=auth0_id) - ) - readable_row = ( - None - if profile is None - else _serialize_user_profile( - profile, - include_auth0_id=auth0_id is not None, - ) - ) - - if readable_row is None: - msg = "No such user" - raise NotFound(msg) - - response_body = { - "status": "ok", - "message": f"User #{readable_row['user_id']} found successfully", - "result": readable_row, - } - - return Response( - json.dumps(response_body), - status=200, - mimetype="application/json", - ) - - -@user_profile_bp.route("//user-profile", methods=["PUT"]) -@validate_country -def update_user_profile(country_id: str) -> Response: # noqa: ARG001 # Flask passes the URL variable by keyword - """Update the username, primary country or start date of profile ``user_id``. - - The Auth0 ID cannot be changed: an ``auth0_id`` in the payload is ignored. - A null ``user_id`` gives 400 and an unknown one gives 404. - """ - payload = request.json - - if payload is None: - msg = "No user data provided in request" - raise BadRequest(msg) - - # The payload's field types are not validated here. Request validation was - # proposed in https://github.com/PolicyEngine/policyengine-api/issues/2054, - # which was closed as not planned. - user_id = payload.pop("user_id") - username = payload.pop("username", None) - primary_country = payload.pop("primary_country", None) - user_since = payload.pop("user_since", None) - - if user_id is None: - msg = "Payload must include user_id" - raise BadRequest(msg) - - updated = user_service.update_profile( - user_id=user_id, - primary_country=primary_country, - username=username, - user_since=user_since, - ) - - if not updated: - msg = "No such user id" - raise NotFound(msg) - - response_body = { - "status": "ok", - "message": f"User profile #{user_id} updated successfully", - "result": {"user_id": user_id}, - } - - return Response( - json.dumps(response_body), - status=200, - mimetype="application/json", - ) diff --git a/policyengine_api/services/policy_service.py b/policyengine_api/services/policy_service.py index 371c33d60..461090958 100644 --- a/policyengine_api/services/policy_service.py +++ b/policyengine_api/services/policy_service.py @@ -219,39 +219,6 @@ def get_policy_snapshot( source_policy_hash=policy.policy_hash, ) - def search_policies( - self, - country_id: str, - query: str = "", - *, - unique_only: bool = False, - ) -> list[Policy]: - """Return this country's policies whose label contains ``query``. - - With ``unique_only``, keep only the first policy for each label and content - hash. - """ - with self._sessions() as session: - results = list( - session.scalars( - select(Policy).where( - Policy.country_id == country_id, - Policy.label.contains(query, autoescape=True), - ) - ) - ) - if not unique_only: - return results - - unique_results = [] - processed_values = set() - for policy in results: - identity = policy.label, policy.policy_hash - if identity not in processed_values: - unique_results.append(policy) - processed_values.add(identity) - return unique_results - def set_policy( self, country_id: str, diff --git a/policyengine_api/services/reform_impacts_service.py b/policyengine_api/services/reform_impacts_service.py index 6d10a6765..df0c6f206 100644 --- a/policyengine_api/services/reform_impacts_service.py +++ b/policyengine_api/services/reform_impacts_service.py @@ -45,13 +45,6 @@ def __init__(self, cache: ReformImpactCache | None = None) -> None: cache = ReformImpactCache(context.client, context.namespace) self._cache = cache - def get_recent_reform_impacts( - self, - max_results: int, - ) -> list[CachedReformImpact]: - """Return up to ``max_results`` cached impacts, most recently started first.""" - return self._cache.recent(max_results) - def get_all_reform_impacts( self, identity: ReformImpactIdentity, diff --git a/policyengine_api/services/user_service.py b/policyengine_api/services/user_service.py deleted file mode 100644 index 11bea8be9..000000000 --- a/policyengine_api/services/user_service.py +++ /dev/null @@ -1,140 +0,0 @@ -"""v1 user profiles, keyed by internal user ID and by Auth0 ID.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING - -from sqlalchemy import select - -from policyengine_api.data.orm import get_v1_session_factory -from policyengine_api.data.v1_models import UserProfile - -if TYPE_CHECKING: - from sqlalchemy.orm import Session, sessionmaker - - -class UserService: - """User-profile operations with service-owned ORM transaction boundaries.""" - - def __init__( - self, - session_factory: sessionmaker[Session] | None = None, - ) -> None: - """Use this session factory, or the application's v1 one if omitted.""" - self._injected_session_factory = session_factory - - @property - def _sessions(self) -> sessionmaker[Session]: - return self._injected_session_factory or get_v1_session_factory() - - def create_profile( - self, - primary_country: str, - auth0_id: str, - username: str | None, - user_since: int, - ) -> tuple[bool, UserProfile]: - """Create a profile for this Auth0 ID, or return the one that already exists. - - Return ``(created, profile)``. - """ - with self._sessions.begin() as session: - return self._create_profile( - session, - primary_country, - auth0_id, - username, - user_since, - ) - - @classmethod - def _create_profile( - cls, - session: Session, - primary_country: str, - auth0_id: str, - username: str | None, - user_since: int, - ) -> tuple[bool, UserProfile]: - existing = cls._get_profile(session, auth0_id=auth0_id) - if existing is not None: - return False, existing - profile = UserProfile( - auth0_id=auth0_id, - username=username, - primary_country=primary_country, - user_since=user_since, - ) - session.add(profile) - session.flush() - return True, profile - - def get_profile( - self, - auth0_id: str | None = None, - user_id: int | str | None = None, - ) -> UserProfile | None: - """Return the profile with this user ID, or else this Auth0 ID, or None. - - ``user_id`` wins when both are given. Raise ValueError if neither is given. - """ - if auth0_id is None and user_id is None: - msg = "you must specify either auth0_id or user_id" - raise ValueError(msg) - with self._sessions() as session: - return self._get_profile(session, auth0_id, user_id) - - @staticmethod - def _get_profile( - session: Session, - auth0_id: str | None = None, - user_id: int | str | None = None, - ) -> UserProfile | None: - condition = ( - UserProfile.user_id == user_id - if user_id is not None - else UserProfile.auth0_id == auth0_id - ) - return session.scalar(select(UserProfile).where(condition)) - - def update_profile( - self, - user_id: int, - primary_country: str | None, - username: str | None, - user_since: int | None, - ) -> UserProfile | None: - """Set the given non-None fields on a profile; return None if it does not exist. - - Raise ValueError if ``user_id`` is None. - """ - if user_id is None: - msg = "you must specify either auth0_id or user_id" - raise ValueError(msg) - with self._sessions.begin() as session: - return self._update_profile( - session, - user_id, - primary_country, - username, - user_since, - ) - - @staticmethod - def _update_profile( - session: Session, - user_id: int, - primary_country: str | None, - username: str | None, - user_since: int | None, - ) -> UserProfile | None: - profile = session.get(UserProfile, user_id) - if profile is None: - return None - if primary_country is not None: - profile.primary_country = primary_country - if username is not None: - profile.username = username - if user_since is not None: - profile.user_since = user_since - return profile diff --git a/policyengine_api/specification.py b/policyengine_api/specification.py deleted file mode 100644 index 7d25e31d0..000000000 --- a/policyengine_api/specification.py +++ /dev/null @@ -1,126 +0,0 @@ -"""Shared loader for the legacy public OpenAPI specification.""" - -from __future__ import annotations - -import json -from pathlib import Path -from typing import TYPE_CHECKING, Any, cast - -import yaml - -from policyengine_api.constants import VERSION -from policyengine_api.query_parameters import ( - AnnualEconomyQuery, - BudgetWindowEconomyQuery, -) -from policyengine_api.spm import ( - SPMComparisonProvenance, - SPMExecutionProvenance, - SPMProvenance, - SPMRuntimeVersions, - SPMSelection, -) - -if TYPE_CHECKING: - from pydantic import BaseModel - - from policyengine_api.json_types import JSONObject, JSONValue - -DEFAULT_SPECIFICATION_PATH = Path(__file__).with_name("openapi_spec.yaml") - - -def _openapi_30_schema(value: JSONValue) -> JSONValue: - """Convert Pydantic's optional JSON Schema fields to OpenAPI 3.0 nullable.""" - if isinstance(value, list): - return [_openapi_30_schema(item) for item in value] - if not isinstance(value, dict): - return value - result: dict[str, Any] = { - key: _openapi_30_schema(item) for key, item in value.items() - } - if "const" in result: - result["enum"] = [result.pop("const")] - variants = result.get("anyOf", []) - if {"type": "null"} in variants: - non_null = [item for item in variants if item != {"type": "null"}] - if len(non_null) != 1: - msg = "SPM optional schemas must have one non-null type" - raise ValueError(msg) - result.pop("anyOf") - result.update(non_null[0]) - result["nullable"] = True - return result - - -def _economy_query_parameters(model: type[BaseModel]) -> list[dict[str, object]]: - """Publish the same complete scalar query contract used by Flask.""" - schema = model.model_json_schema() - result = [] - for name, field in model.model_fields.items(): - parameter = {"name": name, "in": "query", "required": field.is_required()} - description = field.description - if description: - parameter["description"] = description - if field.deprecated: - parameter["deprecated"] = True - if name == "spm": - parameter["content"] = { - "application/json": { - "schema": {"$ref": "#/components/schemas/SPMSelection"} - } - } - else: - parameter["schema"] = _openapi_30_schema(schema["properties"][name]) - result.append(parameter) - return result - - -def _component_schema( - model: Any, # noqa: ANN401 # mypy joins the model-class dict to ModelMetaclass -) -> JSONValue: - """Generate one OpenAPI component and publish nested models separately.""" - schema = model.model_json_schema( - ref_template="#/components/schemas/{model}", - ) - schema.pop("$defs", None) - return _openapi_30_schema(schema) - - -def load_specification( - path: Path = DEFAULT_SPECIFICATION_PATH, - version: str = VERSION, -) -> JSONObject: - """Load the static specification and set the deployed package version.""" - document = yaml.safe_load(path.read_text(encoding="utf-8")) - if not isinstance(document, dict) or not isinstance(document.get("info"), dict): - msg = "OpenAPI specification must contain an info object" - raise TypeError(msg) - document["info"]["version"] = version - schemas = document.get("components", {}).get("schemas", {}) - for name, model in { - "SPMSelection": SPMSelection, - "SPMRuntimeVersions": SPMRuntimeVersions, - "SPMProvenance": SPMProvenance, - "SPMExecutionProvenance": SPMExecutionProvenance, - "SPMComparisonProvenance": SPMComparisonProvenance, - }.items(): - if name in schemas: - schemas[name] = _component_schema(model) - economy_path = "/{country_id}/economy/{policy_id}/over/{baseline_policy_id}" - for suffix, query_model in ( - ("", AnnualEconomyQuery), - ("/budget-window", BudgetWindowEconomyQuery), - ): - operation = document.get("paths", {}).get(economy_path + suffix, {}).get("get") - if operation is not None: - operation["parameters"] = [ - parameter - for parameter in operation.get("parameters", []) - if parameter["in"] != "query" - ] + _economy_query_parameters(query_model) - # YAML accepts both quoted and numeric response codes. JSON object keys are - # strings; normalize before Flask sorts them while serializing the document. - return cast("JSONObject", json.loads(json.dumps(document))) - - -OPENAPI_SPECIFICATION = load_specification() diff --git a/policyengine_api/spm.py b/policyengine_api/spm.py index 47009abbe..d93f041e3 100644 --- a/policyengine_api/spm.py +++ b/policyengine_api/spm.py @@ -638,7 +638,7 @@ def _resolve_certified_selection( except (AttributeError, ImportError, ValueError, TypeError, OSError) as error: # A country package installed without its Simulation raises AttributeError # here. The capability probe already reads that as "no canonical model"; - # letting it escape instead would make /readiness-check raise rather than + # letting it escape instead would make /health raise rather than # report not-ready, and every US request a 500 rather than a typed 400. error_code = "SPM_CONFIGURATION_UNAVAILABLE" raise SPMValidationError(error_code, error_message(error)) from error diff --git a/policyengine_api/supported_countries.py b/policyengine_api/supported_countries.py index 0c5ad01a1..878d62e80 100644 --- a/policyengine_api/supported_countries.py +++ b/policyengine_api/supported_countries.py @@ -13,6 +13,11 @@ release that start afterwards, or a rollback to it, refuse to start until the revision is downgraded. +The v1 routes load the ``policyengine_`` package of every listed country +(``policyengine_api.country``) and look up its current-law policy ID +(``policyengine_api.utils.get_current_law``), so a new country also needs its +package installed and that ID, or API v1 fails while it loads. + A new country also needs the US-and-UK checks widened on the tables that pair v1 records with their v2 copies (``legacy_policy_mappings``, ``legacy_user_policy_mappings``, ``legacy_household_mappings``) and on the diff --git a/policyengine_api/utils/get_current_law.py b/policyengine_api/utils/get_current_law.py index 1853570fc..3b44305c1 100644 --- a/policyengine_api/utils/get_current_law.py +++ b/policyengine_api/utils/get_current_law.py @@ -4,11 +4,9 @@ def get_current_law_policy_id(country_id: str) -> int: """Return the ID of the stored policy that represents current law in a country. - Raises KeyError for a country without one (Israel has none here). + Raises KeyError for any other country. """ return { "uk": 1, "us": 2, - "ca": 3, - "ng": 4, }[country_id] diff --git a/policyengine_api/warmup.py b/policyengine_api/warmup.py index f947bebfc..ff9151ee5 100644 --- a/policyengine_api/warmup.py +++ b/policyengine_api/warmup.py @@ -3,7 +3,7 @@ Building a tax-benefit system (at import) does not compile the per-simulation machinery, so the first calculate on a fresh worker pays a large one-time cost (~2 minutes for US on Cloud Run). Running a throwaway calculate here moves that -off the first user request, so /readiness-check (via policyengine_api.readiness) +off the first user request, so /health (via policyengine_api.readiness) only reports ready once a calculate is actually fast. Only POLICYENGINE_API_WARMUP_COUNTRIES (default "us") are warmed, to keep the added boot time inside the startup-probe window. diff --git a/pyproject.toml b/pyproject.toml index 84ae0edea..fd157d2ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,9 +40,6 @@ dependencies = [ "openai", "packaging>=24,<27", "policyengine-observability[flask,google,httpx,otlp-grpc]>=3.0.3,<4", - "policyengine_canada==0.96.3", - "policyengine-ng==0.5.1", - "policyengine-il==0.1.0", # The models extra pins Core, US, UK, and the SPM calculator to the exact # versions certified by this PolicyEngine bundle. Cloud Run installs the # complete frozen environment from uv.lock; the generic image installs diff --git a/rules/registry.toml b/rules/registry.toml index 6269ff8ad..b5cd93208 100644 --- a/rules/registry.toml +++ b/rules/registry.toml @@ -417,7 +417,7 @@ kind = "pytest" nodes = [ "tests/unit/services/test_orm_service_boundaries.py::test_migrated_services_use_sessions_and_mapped_models", "tests/unit/services/test_local_data_service_boundaries.py::test_local_service_public_methods_do_not_accept_persistence", - "tests/unit/services/test_local_data_service_boundaries.py::test_reform_impact_route_does_not_manage_sessions", + "tests/unit/services/test_local_data_service_boundaries.py::test_flask_routes_do_not_manage_sessions", "tests/unit/services/test_local_data_service_boundaries.py::test_economy_service_delegates_persistence_without_opening_sessions", "tests/unit/services/test_local_data_service_boundaries.py::test_economy_service_modules_delegate_persistence_without_opening_sessions", ] diff --git a/scripts/capture_migration_baseline.py b/scripts/capture_migration_baseline.py index ef590103b..099d9685d 100644 --- a/scripts/capture_migration_baseline.py +++ b/scripts/capture_migration_baseline.py @@ -53,9 +53,7 @@ class ProbeResult: DEFAULT_PROBES = ( - RouteProbe("liveness", "GET", "/liveness-check", (200,)), - RouteProbe("readiness", "GET", "/readiness-check", (200,)), - RouteProbe("specification", "GET", "/specification", (200,)), + RouteProbe("health", "GET", "/health", (200,)), RouteProbe("us_metadata", "GET", "/us/metadata", (200,)), RouteProbe("uk_metadata", "GET", "/uk/metadata", (200,)), ) diff --git a/scripts/measure_cloud_run_runtime.py b/scripts/measure_cloud_run_runtime.py index 6fef7d20c..94eb87751 100644 --- a/scripts/measure_cloud_run_runtime.py +++ b/scripts/measure_cloud_run_runtime.py @@ -38,7 +38,7 @@ METADATA_PATH = "/us/metadata" CALCULATE_PATH = "/us/calculate" -READINESS_PATH = "/readiness-check" +READINESS_PATH = "/health" OUTCOME_OK = "ok" OUTCOME_HTTP_4XX = "http_4xx" @@ -269,7 +269,7 @@ def main() -> int: "--readiness-timeout", type=float, default=420.0, - help="seconds to wait for /readiness-check (cold imports take ~3 min)", + help="seconds to wait for /health (cold imports take ~3 min)", ) args = parser.parse_args() diff --git a/scripts/release_test_shards.py b/scripts/release_test_shards.py index be7fca3e1..60990d9da 100644 --- a/scripts/release_test_shards.py +++ b/scripts/release_test_shards.py @@ -65,7 +65,6 @@ "tests/unit/data/test_v1_database_migration.py": 15, "tests/unit/routes/test_economy_spm_query.py": 15, "tests/to_refactor/api/test_api.py": 15, - "tests/unit/routes/test_user_policy_route_outcomes.py": 15, "tests/unit/routes/test_policy_dual_write_routes.py": 14, "tests/unit/libs/test_simulation_entrypoint.py": 14, "tests/unit/test_relock_pull_request_script.py": 13, diff --git a/scripts/smoke_test_local_boot.py b/scripts/smoke_test_local_boot.py index 9ce8d3185..bf4a037fb 100755 --- a/scripts/smoke_test_local_boot.py +++ b/scripts/smoke_test_local_boot.py @@ -1,9 +1,11 @@ #!/usr/bin/env python3 """Boot smoke test for the local PolicyEngine API app. -Starts the real Flask app in debug/local-db mode, waits for it to come up, -hits health endpoints, then shuts it down. If startup fails, prints captured -stdout/stderr to make the failure actionable. +Starts the real ASGI app (the FastAPI routes with the Flask app mounted) in +debug mode, waits for it to answer, checks that ``/health`` reports ready, +then shuts it down. Ready needs a valid runtime configuration, as on Cloud +Run. If startup fails, prints captured stdout/stderr to make the failure +actionable. """ from __future__ import annotations @@ -22,7 +24,8 @@ from typing import NamedTuple REPO_ROOT = Path(__file__).resolve().parents[1] -HEALTH_PATHS = ("/liveness-check", "/readiness-check") +HEALTH_PATH = "/health" +READY_BODY = '{"status":"healthy"}' BOOT_TIMEOUT_SECONDS = 600 HTTP_TIMEOUT_SECONDS = 2 @@ -96,7 +99,7 @@ def _policyengine_version() -> str: def _start_app(port: int, logs: _AppLogs, env: dict[str, str]) -> subprocess.Popen[str]: - """Start the Flask app on ``port``, writing its output to ``logs``.""" + """Start the ASGI app on ``port``, writing its output to ``logs``.""" with ( logs.stdout.open("w", encoding="utf-8") as stdout_handle, logs.stderr.open("w", encoding="utf-8") as stderr_handle, @@ -105,12 +108,10 @@ def _start_app(port: int, logs: _AppLogs, env: dict[str, str]) -> subprocess.Pop [ sys.executable, "-m", - "flask", - "--app", - "policyengine_api.api", - "run", - "--without-threads", - "--no-reload", + "uvicorn", + "policyengine_api.asgi:app", + "--host", + "127.0.0.1", "--port", str(port), ], @@ -122,27 +123,36 @@ def _start_app(port: int, logs: _AppLogs, env: dict[str, str]) -> subprocess.Pop ) -def _is_ok(status: int, body: str) -> bool: - """Whether a health endpoint answered 200 with the body "OK".""" - return status == HTTPStatus.OK and body.strip() == "OK" +def _fetch_health(port: int) -> tuple[int, str]: + """Return the status and body of ``/health``, including an error status. + ``/health`` answers 503 until the app is ready, which urllib raises. + """ + try: + return fetch(f"http://127.0.0.1:{port}{HEALTH_PATH}") + except urllib.error.HTTPError as error: + body = error.read().decode("utf-8", errors="replace") if error.fp else "" + return error.code, body + + +def _is_ready(status: int, body: str) -> bool: + """Whether ``/health`` answered 200 with the ready body.""" + return status == HTTPStatus.OK and body.strip() == READY_BODY -def _wait_until_live(process: subprocess.Popen[str], port: int) -> bool: - """Poll the liveness check until it passes, the app exits, or time runs out.""" + +def _wait_until_up(process: subprocess.Popen[str], port: int) -> bool: + """Poll ``/health`` until the app answers at all, exits, or time runs out.""" deadline = time.time() + BOOT_TIMEOUT_SECONDS while time.time() < deadline: if process.poll() is not None: return False try: - status, body = fetch(f"http://127.0.0.1:{port}/liveness-check") - if _is_ok(status, body): - return True + _fetch_health(port) except (urllib.error.URLError, TimeoutError, ConnectionError): time.sleep(0.5) continue - - time.sleep(0.5) + return True return False @@ -157,13 +167,10 @@ def _stop_unready_app(process: subprocess.Popen[str]) -> None: def _probe_health(port: int) -> bool: - """Print each health endpoint's answer, stopping at the first that is not OK.""" - for path in HEALTH_PATHS: - status, body = fetch(f"http://127.0.0.1:{port}{path}") - _report(f"{path}: {status} {body.strip()}") - if not _is_ok(status, body): - return False - return True + """Print the answer of ``/health`` and return whether it reports ready.""" + status, body = _fetch_health(port) + _report(f"{HEALTH_PATH}: {status} {body.strip()}".rstrip()) + return _is_ready(status, body) def _report_failure(message: str, logs: _AppLogs) -> int: @@ -186,7 +193,7 @@ def _stop_app(process: subprocess.Popen[str]) -> None: def main() -> int: - """Boot the Flask app on a free port and return 1 unless both health checks pass. + """Boot the ASGI app on a free port and return 1 unless ``/health`` reports ready. On failure print the app's captured stdout and stderr; always stop the app. """ @@ -204,15 +211,13 @@ def main() -> int: process = _start_app(port, logs, env) try: - if not _wait_until_live(process, port): + if not _wait_until_up(process, port): _stop_unready_app(process) - return _report_failure( - "\nBoot smoke test failed: app did not become ready.", logs - ) + return _report_failure("\nBoot smoke test failed: app did not start.", logs) - _report("\nBoot smoke test reached a live app. Probing health endpoints:") + _report("\nBoot smoke test reached a running app. Checking /health:") if not _probe_health(port): - return _report_failure("\nHealth endpoint probe failed.", logs) + return _report_failure("\nHealth check did not report ready.", logs) _report("\nBoot smoke test passed.") return 0 diff --git a/tests/contract/registry.py b/tests/contract/registry.py index ddf01f664..3028ca43e 100644 --- a/tests/contract/registry.py +++ b/tests/contract/registry.py @@ -154,13 +154,6 @@ class WorkflowContract: stable_response_fields=("status", "message", "result"), route_group="policy", ), - ContractRequest( - method="GET", - path="/us/policies", - expected_status=200, - stable_response_fields=("result",), - route_group="policy", - ), ), ), WorkflowContract( @@ -218,46 +211,6 @@ class WorkflowContract: ), ), ), - WorkflowContract( - name="saved_policy_v1_compatibility", - current_contract="api_v1_compatible", - future_owner_pr="PR 10: Policy Migration", - requests=( - ContractRequest( - method="POST", - path="/us/user-policy", - expected_status=201, - stable_response_fields=( - "status", - "message", - "result.id", - "result.reform_id", - "result.reform_label", - "result.baseline_id", - "result.user_id", - ), - route_group="policy", - ), - ContractRequest( - method="GET", - path="/us/user-policy/{user_id}", - expected_status=200, - stable_response_fields=( - "status", - "message", - "result", - ), - route_group="policy", - ), - ContractRequest( - method="PUT", - path="/us/user-policy", - expected_status=200, - stable_response_fields=("status", "message", "result.id"), - route_group="policy", - ), - ), - ), WorkflowContract( name="user_policy_associations_v2", current_contract="typed_v2_resources", diff --git a/tests/contract/test_app_v2_workflow_contracts.py b/tests/contract/test_app_v2_workflow_contracts.py index f74640678..6be078e41 100644 --- a/tests/contract/test_app_v2_workflow_contracts.py +++ b/tests/contract/test_app_v2_workflow_contracts.py @@ -10,7 +10,6 @@ def test_app_v2_workflow_contract_registry_is_complete(): assert {workflow.name for workflow in APP_V2_WORKFLOW_CONTRACTS} == { "policy_save_search", "policy_resources_v2", - "saved_policy_v1_compatibility", "user_policy_associations_v2", "household_save_read", "household_resources_v2", diff --git a/tests/contract/test_v1_route_contracts.py b/tests/contract/test_v1_route_contracts.py index 8bdc272fc..652b7fd36 100644 --- a/tests/contract/test_v1_route_contracts.py +++ b/tests/contract/test_v1_route_contracts.py @@ -7,7 +7,7 @@ from unittest.mock import patch import pytest -from flask import Flask, Response +from flask import Flask from policyengine_api.constants import get_report_output_cache_version from policyengine_api.data.v1_models import ( @@ -16,7 +16,6 @@ ReportOutput, ReportOutputRun, Simulation, - UserPolicy, ) from policyengine_api.extensions import cache from policyengine_api.routes.household_routes import household_bp @@ -32,11 +31,6 @@ ReportOutputView, ) from policyengine_api.services.simulation_service import SimulationCreateResult -from policyengine_api.services.user_policy_service import ( - UserPolicyCreateResult, - UserPolicyUpdateResult, -) -from policyengine_api.services.v2.user_policies.types import LegacyUserPolicySnapshot from tests.contract.clients import ( ASGIContractClient, ContractClient, @@ -175,14 +169,6 @@ def create_contract_flask_app() -> Flask: app.register_blueprint(simulation_bp) app.register_blueprint(report_output_bp) - @app.route("/liveness-check") - def liveness_check(): - return Response("OK", status=200, mimetype="text/plain") - - @app.route("/readiness-check") - def readiness_check(): - return Response("OK", status=200, mimetype="text/plain") - return app @@ -214,21 +200,6 @@ def _resolved_path(path: str) -> str: "label": "Utah reform", "data": {"gov.example.parameter": 1}, }, - ("/us/user-policy", "POST"): { - "reform_id": 22, - "reform_label": "Tax reform", - "baseline_id": 2, - "baseline_label": "Current law", - "user_id": "auth0|one", - "year": "2026", - "geography": "us", - "dataset": "enhanced_cps_2024", - "number_of_provisions": 3, - "api_version": "1", - "added_date": 1, - "updated_date": 2, - }, - ("/us/user-policy", "PUT"): {"id": 10, "reform_label": "Updated tax reform"}, ("/us/household", None): {"label": "Empty household", "data": {}}, ("/us/household/{household_id}", None): { "label": "Updated household", @@ -274,42 +245,6 @@ def _fake_country(): def _patched_route_dependencies(): stack = ExitStack() - saved_policy = UserPolicy( - id=10, - country_id="us", - reform_id=22, - reform_label="Tax reform", - baseline_id=2, - baseline_label="Current law", - user_id="auth0|one", - year="2026", - geography="us", - dataset="enhanced_cps_2024", - number_of_provisions=3, - api_version="1", - added_date=1, - updated_date=2, - budgetary_impact=None, - type=None, - ) - saved_snapshot = LegacyUserPolicySnapshot( - country_id="us", - legacy_user_policy_id=10, - reform_id=22, - reform_label="Tax reform", - baseline_id=2, - baseline_label="Current law", - user_id="auth0|one", - year="2026", - geography="us", - dataset="enhanced_cps_2024", - number_of_provisions=3, - api_version="1", - added_date=1, - updated_date=2, - budgetary_impact=None, - type=None, - ) stack.enter_context( patch( "policyengine_api.routes.policy_routes.policy_service.get_policy", @@ -323,61 +258,12 @@ def _patched_route_dependencies(): ), ) ) - stack.enter_context( - patch( - "policyengine_api.routes.policy_routes.user_policy_service.create_or_get_user_policy", - return_value=UserPolicyCreateResult( - user_policy=saved_policy, - created=True, - snapshot=saved_snapshot, - ), - ) - ) - stack.enter_context( - patch( - "policyengine_api.routes.policy_routes.user_policy_service.list_user_policies", - return_value=[saved_policy], - ) - ) - stack.enter_context( - patch( - "policyengine_api.routes.policy_routes.user_policy_service.update_user_policy", - return_value=UserPolicyUpdateResult( - user_policy=saved_policy, - snapshot=saved_snapshot, - changed_fields=frozenset({"reform_label"}), - ), - ) - ) stack.enter_context( patch( "policyengine_api.routes.policy_routes.policy_service.set_policy", return_value=(123, "Policy created successfully", False), ) ) - stack.enter_context( - patch( - "policyengine_api.routes.policy_routes.policy_service.search_policies", - return_value=[ - Policy( - id=123, - country_id="us", - label="Tax reform", - api_version="1", - policy_json={}, - policy_hash="hash-1", - ), - Policy( - id=124, - country_id="us", - label="Tax reform", - api_version="1", - policy_json={}, - policy_hash="hash-1", - ), - ], - ) - ) stack.enter_context( patch( "policyengine_api.routes.household_routes.household_service.create_household", @@ -516,32 +402,6 @@ def _expected_metadata_subset(country_id: str) -> dict: "message": None, "result": {"label": "Current law"}, }, - ("/us/policies", None): { - "status": "ok", - "message": "Policies found", - "result": [{"id": 123, "label": "Tax reform"}], - }, - ("/us/user-policy", "POST"): { - "status": "ok", - "message": "Record created successfully", - "result": { - "id": 10, - "reform_id": 22, - "reform_label": "Tax reform", - "baseline_id": 2, - "user_id": "auth0|one", - }, - }, - ("/us/user-policy/{user_id}", None): { - "status": "ok", - "message": None, - "result": [{"id": 10, "reform_label": "Tax reform"}], - }, - ("/us/user-policy", "PUT"): { - "status": "ok", - "message": "Record updated successfully", - "result": {"id": 10}, - }, ("/us/household", None): { "status": "ok", "message": None, @@ -625,18 +485,6 @@ def test_app_v2_api_v1_route_contract( _assert_no_stage12_private_fields(payload) -def test_health_routes_contract(contract_client: ContractClient): - liveness = contract_client.open("/liveness-check", method="GET") - readiness = contract_client.open("/readiness-check", method="GET") - - assert liveness.status_code == 200 - assert liveness.data == b"OK" - assert "text/plain" in liveness.content_type - assert readiness.status_code == 200 - assert readiness.data == b"OK" - assert "text/plain" in readiness.content_type - - def test_invalid_country_contract(contract_client: ContractClient): response = contract_client.open("/zz/metadata", method="GET") @@ -645,8 +493,6 @@ def test_invalid_country_contract(contract_client: ContractClient): response_json(response), { "status": "error", - "message": ( - "Country zz not found. Available countries are: uk, us, ca, ng, il" - ), + "message": "Country zz not found. Available countries are: uk, us", }, ) diff --git a/tests/fixtures/services/user_service.py b/tests/fixtures/services/user_service.py deleted file mode 100644 index 1520cad85..000000000 --- a/tests/fixtures/services/user_service.py +++ /dev/null @@ -1,26 +0,0 @@ -import pytest - -from policyengine_api.data.v1_models import UserProfile - -valid_user_record = { - "user_id": 1, - "auth0_id": "123", - "username": "person1", - "primary_country": "US", - "user_since": 1678658906, -} - - -@pytest.fixture -def existing_user_profile(orm_session): - """Insert an existing user record into the database.""" - profile = UserProfile( - user_id=valid_user_record["user_id"], - auth0_id=valid_user_record["auth0_id"], - username=valid_user_record["username"], - primary_country=valid_user_record["primary_country"], - user_since=valid_user_record["user_since"], - ) - orm_session.add(profile) - orm_session.commit() - return profile diff --git a/tests/integration/test_cloud_run_candidate.py b/tests/integration/test_cloud_run_candidate.py index 25eb77aab..331dcc089 100644 --- a/tests/integration/test_cloud_run_candidate.py +++ b/tests/integration/test_cloud_run_candidate.py @@ -3,13 +3,9 @@ def test_cloud_run_candidate_health_routes(api_client): assert health_response.status_code == 200, health_response.text assert health_response.json() == {"status": "healthy"} - liveness_response = api_client.get("/liveness-check") - assert liveness_response.status_code == 200, liveness_response.text - assert liveness_response.text == "OK" - - readiness_response = api_client.get("/readiness-check") - assert readiness_response.status_code == 200, readiness_response.text - assert readiness_response.text == "OK" + for removed_probe_path in ("/liveness-check", "/readiness-check"): + removed_probe_response = api_client.get(removed_probe_path) + assert removed_probe_response.status_code == 404, removed_probe_path simulation_gateway_response = api_client.get("/simulation-gateway-check") assert simulation_gateway_response.status_code == 200, ( @@ -36,14 +32,13 @@ def test_cloud_run_candidate_metadata_and_policy( assert policy_payload["result"]["id"] == current_law_id -def test_cloud_run_candidate_stage6_read_route_contracts(api_client): - specification_response = api_client.get("/specification") - assert specification_response.status_code == 200, specification_response.text - specification = specification_response.json() - assert specification["openapi"] == "3.0.0" - assert specification["info"]["title"] == "PolicyEngine API" - assert specification["info"]["version"] +def test_cloud_run_candidate_serves_no_legacy_documentation_routes(api_client): + for path in ("/", "/specification", "/simulations"): + response = api_client.get(path) + assert response.status_code == 404, (path, response.text) + +def test_cloud_run_candidate_stage6_read_route_contracts(api_client): uk_metadata_response = api_client.get( "/uk/metadata", headers={"X-PolicyEngine-Request-Id": "stage6-uk-metadata"}, @@ -62,9 +57,7 @@ def test_cloud_run_candidate_stage6_read_route_contracts(api_client): assert invalid_country_response.status_code == 400 assert invalid_country_response.json() == { "status": "error", - "message": ( - "Country zz not found. Available countries are: uk, us, ca, ng, il" - ), + "message": ("Country zz not found. Available countries are: uk, us"), } diff --git a/tests/integration/test_v1_household_dual_write.py b/tests/integration/test_v1_household_dual_write.py index 2e8442a1f..a8bdceeaf 100644 --- a/tests/integration/test_v1_household_dual_write.py +++ b/tests/integration/test_v1_household_dual_write.py @@ -347,8 +347,8 @@ def test_changed_retained_event_fingerprint_cannot_mutate_destination( v2_engine.dispose() -@pytest.mark.parametrize("country_id", ["ca", "ng", "il"]) -def test_cloud_sql_only_countries_create_no_event_or_destination_mapping( +@pytest.mark.parametrize("country_id", ["us", "uk"]) +def test_cloud_sql_only_writes_create_no_event_or_destination_mapping( country_id: str, disposable_v1_database_url: str, disposable_v2_database_url: str, diff --git a/tests/integration/test_v2_country_routes.py b/tests/integration/test_v2_country_routes.py index ee6408ddc..804b9f6f2 100644 --- a/tests/integration/test_v2_country_routes.py +++ b/tests/integration/test_v2_country_routes.py @@ -46,7 +46,6 @@ def test_postgres_countries_list_the_rows_the_revision_inserted( readiness_probe=lambda: True, gateway_client_factory=lambda: None, metadata_reader_factory=lambda: None, - specification_provider=dict, v2_country_service_factory=lambda: V2CountryService( CountryDatabaseSession(sessions) ), diff --git a/tests/integration/test_v2_metadata_routes.py b/tests/integration/test_v2_metadata_routes.py index 326eb78d9..558f92047 100644 --- a/tests/integration/test_v2_metadata_routes.py +++ b/tests/integration/test_v2_metadata_routes.py @@ -79,7 +79,6 @@ def v1_metadata(country_id: str): readiness_probe=lambda: True, gateway_client_factory=lambda: None, metadata_reader_factory=lambda: None, - specification_provider=dict, v2_metadata_reader_factory=lambda: V2MetadataService( MetadataDatabaseSession(Session(engine)), running_policyengine_version=POLICYENGINE_VERSION, diff --git a/tests/integration/test_warmup_household.py b/tests/integration/test_warmup_household.py index 770c09992..e97e75f97 100644 --- a/tests/integration/test_warmup_household.py +++ b/tests/integration/test_warmup_household.py @@ -26,9 +26,6 @@ _COUNTRY_PACKAGES = { "us": "policyengine_us", "uk": "policyengine_uk", - "ca": "policyengine_canada", - "ng": "policyengine_ng", - "il": "policyengine_il", } diff --git a/tests/to_refactor/api/test_api_client_context.py b/tests/to_refactor/api/test_api_client_context.py index 0c7b17871..fcef48560 100644 --- a/tests/to_refactor/api/test_api_client_context.py +++ b/tests/to_refactor/api/test_api_client_context.py @@ -4,7 +4,7 @@ def test_api_client_does_not_preserve_request_context(api_client): assert not flask.has_request_context() - response = api_client.get("/readiness-check") + response = api_client.get("/an-unknown-path") - assert response.status_code == 200 + assert response.status_code == 404 assert not flask.has_request_context() diff --git a/tests/to_refactor/api/test_hello_world.yaml b/tests/to_refactor/api/test_hello_world.yaml deleted file mode 100644 index 3aa6fa67b..000000000 --- a/tests/to_refactor/api/test_hello_world.yaml +++ /dev/null @@ -1,4 +0,0 @@ -name: Hello World -endpoint: / -response: - status: 200 diff --git a/tests/to_refactor/api/test_liveness.yaml b/tests/to_refactor/api/test_liveness.yaml deleted file mode 100644 index caab16326..000000000 --- a/tests/to_refactor/api/test_liveness.yaml +++ /dev/null @@ -1,4 +0,0 @@ -name: Liveness -endpoint: /liveness-check -response: - status: 200 diff --git a/tests/to_refactor/api/test_readiness.yaml b/tests/to_refactor/api/test_readiness.yaml deleted file mode 100644 index ee57608ac..000000000 --- a/tests/to_refactor/api/test_readiness.yaml +++ /dev/null @@ -1,4 +0,0 @@ -name: Readiness -endpoint: /readiness-check -response: - status: 200 diff --git a/tests/to_refactor/python/test_user_profile_routes.py b/tests/to_refactor/python/test_user_profile_routes.py deleted file mode 100644 index 57a10bdf7..000000000 --- a/tests/to_refactor/python/test_user_profile_routes.py +++ /dev/null @@ -1,99 +0,0 @@ -import json -import time -from typing import ClassVar - -from sqlalchemy import delete, select - -from policyengine_api.data.orm import get_v1_session_factory -from policyengine_api.data.v1_models import UserProfile - - -class TestUserProfiles: - # Define the profile to test against - auth0_id = "dworkin" - primary_country = "us" - # Simulate JS's Date.now() - user_since = int(time.time()) - - test_profile: ClassVar[dict] = { - "auth0_id": auth0_id, - "primary_country": primary_country, - "user_since": user_since, - } - - """ - Test adding a record to user_profiles - """ - - def test_set_and_get_record(self, api_client): - with get_v1_session_factory().begin() as session: - session.execute( - delete(UserProfile).where( - UserProfile.auth0_id == self.auth0_id, - UserProfile.primary_country == self.primary_country, - ) - ) - - res = api_client.post("/us/user-profile", json=self.test_profile) - return_object = json.loads(res.text) - - assert return_object["status"] == "ok" - assert res.status_code == 201 - - res = api_client.get(f"/us/user-profile?auth0_id={self.auth0_id}") - return_object = json.loads(res.text) - - assert res.status_code == 200 - assert return_object["status"] == "ok" - assert return_object["result"]["auth0_id"] == self.auth0_id - assert return_object["result"]["primary_country"] == self.primary_country - assert return_object["result"]["username"] is None - - user_id = return_object["result"]["user_id"] - - res = api_client.get(f"/us/user-profile?user_id={user_id}") - return_object = json.loads(res.text) - - assert res.status_code == 200 - assert return_object["status"] == "ok" - assert return_object["result"]["primary_country"] == self.primary_country - assert return_object["result"].get("auth0_id") is None - assert return_object["result"]["username"] is None - - test_username = "maxwell" - updated_profile = {"user_id": user_id, "username": test_username} - - res = api_client.put("/us/user-profile", json=updated_profile) - return_object = json.loads(res.text) - - assert return_object["status"] == "ok" - assert res.status_code == 200 - - with get_v1_session_factory()() as session: - row = session.scalar( - select(UserProfile).where( - UserProfile.user_id == user_id, - UserProfile.username == test_username, - ) - ) - assert row is not None - - malicious_updated_profile = {**updated_profile, "auth0_id": "BOGUS"} - - res = api_client.put("/us/user-profile", json=malicious_updated_profile) - return_object = json.loads(res.text) - - assert res.status_code == 200 - - with get_v1_session_factory().begin() as session: - row = session.scalar( - select(UserProfile).where(UserProfile.username == test_username) - ) - assert row.auth0_id == self.auth0_id - session.delete(row) - - def test_non_existent_record(self, api_client): - non_existent_auth0_id = "non-existent-auth0-id" - - res = api_client.get(f"/us/user-profile?auth0_id={non_existent_auth0_id}") - assert res.status_code == 404 diff --git a/tests/unit/data/test_runtime_sql_boundaries.py b/tests/unit/data/test_runtime_sql_boundaries.py index 874285b15..667f3242a 100644 --- a/tests/unit/data/test_runtime_sql_boundaries.py +++ b/tests/unit/data/test_runtime_sql_boundaries.py @@ -28,7 +28,6 @@ def test_ordinary_runtime_modules_no_longer_use_raw_sql_facade(): relative_paths = ( "routes/household_routes.py", "routes/policy_routes.py", - "routes/reform_impact_routes.py", "country.py", "services/reform_impacts_service.py", ) diff --git a/tests/unit/routes/test_economy_spm_query.py b/tests/unit/routes/test_economy_spm_query.py index c871237d7..9a57270ab 100644 --- a/tests/unit/routes/test_economy_spm_query.py +++ b/tests/unit/routes/test_economy_spm_query.py @@ -584,9 +584,7 @@ def test_annual_route_stores_and_returns_a_completed_breakdown_unchanged( assert polled.get_json()["result"] == stored.get_json()["result"] gateway.get_execution_by_id.assert_called_once_with("submitted-job") - records = economy_routes.economy_service._reform_impacts.get_recent_reform_impacts( - 5 - ) + records = economy_routes.economy_service._reform_impacts._cache.recent(5) assert len(records) == 1 assert records[0].status == "ok" assert records[0].options_json == {"breakdown": "local_areas"} diff --git a/tests/unit/routes/test_flask_route_boundaries.py b/tests/unit/routes/test_flask_route_boundaries.py index a9b5ccac5..73f3faf4c 100644 --- a/tests/unit/routes/test_flask_route_boundaries.py +++ b/tests/unit/routes/test_flask_route_boundaries.py @@ -2,44 +2,74 @@ from flask import Flask -from policyengine_api.routes.home_routes import home_bp +from policyengine_api.routes.economy_routes import economy_bp +from policyengine_api.routes.error_routes import error_bp from policyengine_api.routes.household_routes import household_bp +from policyengine_api.routes.metadata_routes import metadata_bp from policyengine_api.routes.policy_routes import policy_bp -from policyengine_api.routes.reform_impact_routes import reform_impact_bp -from policyengine_api.routes.system_routes import system_bp +from policyengine_api.routes.report_output_routes import report_output_bp +from policyengine_api.routes.simulation_routes import simulation_bp PACKAGE_ROOT = Path(__file__).parents[3] / "policyengine_api" - - -def test_legacy_flask_urls_are_owned_by_blueprints(): +REGISTERED_BLUEPRINTS = ( + error_bp, + metadata_bp, + household_bp, + policy_bp, + economy_bp, + simulation_bp, + report_output_bp, +) +# Routes that only legacy.policyengine.org or the API documentation used, and +# the probe routes that /health replaces. +REMOVED_LEGACY_ROUTES = { + ("/", "GET"), + ("/liveness-check", "GET"), + ("/readiness-check", "GET"), + ("/specification", "GET"), + ("//policies", "GET"), + ("//user-policy", "POST"), + ("//user-policy", "PUT"), + ("//user-policy/", "GET"), + ("//user-profile", "POST"), + ("//user-profile", "PUT"), + ("//user-profile", "GET"), + ("/simulations", "GET"), +} +REMOVED_BLUEPRINT_MODULES = ( + "home_routes", + "reform_impact_routes", + "system_routes", + "user_profile_routes", +) + + +def _flask_rules() -> set[tuple[str, str]]: app = Flask(__name__) - app.register_blueprint(home_bp) - app.register_blueprint(household_bp) - app.register_blueprint(policy_bp) - app.register_blueprint(reform_impact_bp) - app.register_blueprint(system_bp) - - rules = { + for blueprint in REGISTERED_BLUEPRINTS: + app.register_blueprint(blueprint) + return { (rule.rule, method) for rule in app.url_map.iter_rules() for method in rule.methods if method not in {"HEAD", "OPTIONS"} } + +def test_legacy_flask_urls_are_owned_by_blueprints(): + rules = _flask_rules() + assert { - ("/", "GET"), - ("//policies", "GET"), + ("//policy", "POST"), + ("//policy/", "GET"), ("//household//policy/", "GET"), ("//calculate", "POST"), ("//calculate-full", "POST"), - ("//user-policy", "POST"), - ("//user-policy", "PUT"), - ("//user-policy/", "GET"), - ("/simulations", "GET"), - ("/liveness-check", "GET"), - ("/readiness-check", "GET"), - ("/specification", "GET"), } <= rules + # Routes that only legacy.policyengine.org or the documentation used. + assert rules & REMOVED_LEGACY_ROUTES == set() + for module_name in REMOVED_BLUEPRINT_MODULES: + assert not (PACKAGE_ROOT / "routes" / f"{module_name}.py").exists() def test_flask_app_assembles_blueprints_without_legacy_endpoint_wiring(): @@ -57,6 +87,10 @@ def test_flask_app_assembles_blueprints_without_legacy_endpoint_wiring(): assert "get_simulations" not in source assert "@app.route" not in source assert "app.route(" not in source + for module_name in REMOVED_BLUEPRINT_MODULES: + assert module_name not in source + for blueprint in REGISTERED_BLUEPRINTS: + assert f"app.register_blueprint({blueprint.name}_bp)" in source def test_legacy_endpoints_package_is_removed(): diff --git a/tests/unit/routes/test_household_dual_write_routes.py b/tests/unit/routes/test_household_dual_write_routes.py index ae35cfe68..a6e55af0a 100644 --- a/tests/unit/routes/test_household_dual_write_routes.py +++ b/tests/unit/routes/test_household_dual_write_routes.py @@ -131,7 +131,7 @@ def test_dual_write_processes_exact_selected_event_before_success( @pytest.mark.parametrize("country_id", ["ca", "ng", "il"]) -def test_dual_write_keeps_other_v1_countries_entirely_on_cloud_sql( +def test_removed_countries_are_rejected_before_any_household_write( monkeypatch, country_id: str, ) -> None: @@ -139,7 +139,6 @@ def test_dual_write_keeps_other_v1_countries_entirely_on_cloud_sql( with ( patch( "policyengine_api.routes.household_routes.household_service.create_household", - return_value=_creation(country_id=country_id, mirrored=False), ) as create, patch( "policyengine_api.routes.household_routes.process_household_event_after_commit" @@ -147,8 +146,8 @@ def test_dual_write_keeps_other_v1_countries_entirely_on_cloud_sql( ): response = _client().post(f"/{country_id}/household", json=_body()) - assert response.status_code == 201 - assert create.call_args.kwargs == {"record_mirror_event": False} + assert response.status_code == 400 + create.assert_not_called() copy_event.assert_not_called() diff --git a/tests/unit/routes/test_household_and_user_policy_orm_routes.py b/tests/unit/routes/test_household_under_policy_orm_routes.py similarity index 77% rename from tests/unit/routes/test_household_and_user_policy_orm_routes.py rename to tests/unit/routes/test_household_under_policy_orm_routes.py index bdccbe327..cf3d70478 100644 --- a/tests/unit/routes/test_household_and_user_policy_orm_routes.py +++ b/tests/unit/routes/test_household_under_policy_orm_routes.py @@ -1,22 +1,14 @@ -"""ORM integration behavior for household and saved-policy routes.""" +"""ORM integration behavior for the household-under-policy route.""" from types import SimpleNamespace from unittest.mock import Mock, patch -from flask import Flask - from policyengine_api.constants import COUNTRY_PACKAGE_VERSIONS, POLICYENGINE_VERSION from policyengine_api.data.v1_models import ( Household, Policy, - UserPolicy, ) from policyengine_api.routes.household_routes import get_household_under_policy -from policyengine_api.routes.policy_routes import ( - get_user_policy, - set_user_policy, - update_user_policy, -) from policyengine_api.runtime_cache.core import CacheNamespace from policyengine_api.runtime_cache.fake import InMemoryCacheBackend from policyengine_api.runtime_cache.household_calculations import ( @@ -148,38 +140,3 @@ def test_household_under_policy_calculates_and_caches_json_as_an_object( spm=INSTALLED_SPM_SELECTION, spm_requested=False, ) - - -def test_user_policy_endpoints_round_trip_through_orm_session_factory( - orm_session_factory, -): - app = Flask(__name__) - payload = { - "reform_label": "Reform", - "reform_id": 2, - "baseline_label": "Current law", - "baseline_id": 1, - "user_id": "auth0|one", - "year": "2026", - "geography": "us", - "dataset": "default", - "number_of_provisions": 3, - "api_version": "1", - "added_date": 1, - "updated_date": 1, - "budgetary_impact": None, - "type": None, - } - - with app.test_request_context(json=payload): - created = set_user_policy("us") - listed = get_user_policy("us", "auth0|one") - with app.test_request_context(json={"id": 1, "reform_label": "Updated"}): - updated = update_user_policy("us") - - assert created.status_code == 201 - assert created.get_json()["result"]["dataset"] == "default" - assert listed["result"][0]["reform_label"] == "Reform" - assert updated.status_code == 200 - with orm_session_factory() as session: - assert session.get(UserPolicy, 1).reform_label == "Updated" diff --git a/tests/unit/routes/test_migration_context_logging.py b/tests/unit/routes/test_migration_context_logging.py index 641c8c34b..97d280da7 100644 --- a/tests/unit/routes/test_migration_context_logging.py +++ b/tests/unit/routes/test_migration_context_logging.py @@ -81,12 +81,19 @@ def get_metadata(self, country_id: str): raise RuntimeError(msg) +@pytest.fixture(autouse=True) +def default_readiness_probe_reports_ready(monkeypatch): + """Report ready from the default probe, which needs deployed configuration.""" + monkeypatch.setattr( + "policyengine_api.fastapi_routes.dependencies.is_ready", lambda: True + ) + + def _dependencies(): return NativeRouteDependencies( readiness_probe=lambda: True, gateway_client_factory=_HealthySimulationGateway, metadata_reader_factory=_MetadataReader, - specification_provider=lambda: {"openapi": "3.0.0"}, ) @@ -343,7 +350,7 @@ def test_request_logging_runs_for_fastapi_native_health_routes(): assert log_payload["migration"]["route_impl"] == "fastapi_native" -def test_native_readiness_logs_the_implementation_that_served_it(): +def test_native_health_logs_the_implementation_that_served_it(): with patch("policyengine_api.migration_logging.logger") as mock_logger: response = TestClient( create_asgi_app( @@ -351,12 +358,12 @@ def test_native_readiness_logs_the_implementation_that_served_it(): route_settings=_settings(health=RouteImplementation.FASTAPI_NATIVE), dependencies=_dependencies(), ) - ).get("/readiness-check") + ).get("/health") assert response.status_code == 200 assert mock_logger.log_struct.call_count == 1 log_payload = mock_logger.log_struct.call_args.args[0] - assert log_payload["path"] == "/readiness-check" + assert log_payload["path"] == "/health" assert log_payload["migration"]["route_group"] == "health" assert log_payload["migration"]["route_impl"] == "fastapi_native" @@ -597,7 +604,6 @@ def test_native_route_failure_logs_country_and_actual_implementation(): readiness_probe=lambda: True, gateway_client_factory=_HealthySimulationGateway, metadata_reader_factory=_FailingMetadataReader, - specification_provider=lambda: {"openapi": "3.0.0"}, ) with patch("policyengine_api.migration_logging.logger") as mock_logger: diff --git a/tests/unit/routes/test_policy_dual_write_routes.py b/tests/unit/routes/test_policy_dual_write_routes.py index 0f5be68e3..30198c89e 100644 --- a/tests/unit/routes/test_policy_dual_write_routes.py +++ b/tests/unit/routes/test_policy_dual_write_routes.py @@ -128,21 +128,14 @@ def test_dual_write_mirrors_new_and_existing_rows_before_success( @pytest.mark.parametrize("country_id", ["ca", "ng", "il"]) -def test_dual_write_preserves_v1_only_policy_writes_for_non_v2_countries( +def test_removed_countries_are_rejected_before_any_policy_write( monkeypatch, country_id, ) -> None: monkeypatch.setenv("DB_WRITE_POLICY", "dual_write") - creation = PolicySetResult( - policy_id=42, - message="Policy created", - is_existing_policy=False, - snapshot=None, - ) with ( patch( "policyengine_api.routes.policy_routes.policy_service.set_policy", - return_value=creation, ) as set_policy, patch( "policyengine_api.routes.policy_routes.mirror_policy_after_commit" @@ -150,14 +143,8 @@ def test_dual_write_preserves_v1_only_policy_writes_for_non_v2_countries( ): response = _client().post(f"/{country_id}/policy", json=_body()) - assert response.status_code == 201 - assert response.json["result"] == {"policy_id": 42} - set_policy.assert_called_once_with( - country_id, - "Legacy label", - {"gov.example.rate": {"2026": 0.2}}, - prepare_for_mirroring=False, - ) + assert response.status_code == 400 + set_policy.assert_not_called() mirror.assert_not_called() @@ -395,21 +382,3 @@ def test_v1_policy_read_reports_the_parameters_the_model_lacks( "missing_parameters": missing_parameters, } check.assert_called_once_with("us", policy.policy_json) - - -def test_policy_search_failure_does_not_expose_exception_details( - monkeypatch, -) -> None: - monkeypatch.setenv("DB_READ_POLICY", "cloud_sql") - with patch( - "policyengine_api.routes.policy_routes.policy_service.search_policies", - side_effect=RuntimeError("database-credential-secret"), - ): - response = _client().get("/us/policies?query=example") - - assert response.status_code == 500 - assert response.json == { - "status": "error", - "message": "Internal server error; please try again later.", - } - assert "database-credential-secret" not in response.text diff --git a/tests/unit/routes/test_reform_impact_routes.py b/tests/unit/routes/test_reform_impact_routes.py deleted file mode 100644 index efc3f070d..000000000 --- a/tests/unit/routes/test_reform_impact_routes.py +++ /dev/null @@ -1,160 +0,0 @@ -"""Regression tests for the bounded recent-simulations route. - -The route originally built a SQL LIMIT through an f-string. Reform impacts now -use the shared runtime cache, but the public limit, input-safety, ordering, and -v1 response-shape contracts remain unchanged. -""" - -import json -from datetime import datetime - -import pytest -from fastapi.testclient import TestClient -from flask import Flask - -from policyengine_api.asgi_factory import create_asgi_app -from policyengine_api.routes import reform_impact_routes -from policyengine_api.routes.reform_impact_routes import reform_impact_bp -from policyengine_api.runtime_cache.core import CacheNamespace -from policyengine_api.runtime_cache.fake import InMemoryCacheBackend -from policyengine_api.runtime_cache.reform_impacts import ( - ReformImpactCache, - ReformImpactIdentity, - ReformImpactScope, - reform_impact_id, -) -from policyengine_api.services.reform_impacts_service import ( - ReformImpactExecution, - ReformImpactsService, -) - -COMPLETED_AT = datetime(2026, 1, 1, 1) - - -@pytest.fixture -def reform_impacts_service(monkeypatch: pytest.MonkeyPatch) -> ReformImpactsService: - service = ReformImpactsService( - ReformImpactCache( - InMemoryCacheBackend(), - CacheNamespace("test", "api"), - ) - ) - monkeypatch.setattr(service, "_now", lambda: COMPLETED_AT) - monkeypatch.setattr(reform_impact_routes, "reform_impacts_service", service) - return service - - -def _get_simulations(max_results=100): - app = _create_app() - query = "" if max_results is None else f"?max_results={max_results}" - return app.test_client().get(f"/simulations{query}").get_json() - - -def _create_app() -> Flask: - app = Flask(__name__) - app.config["TESTING"] = True - app.register_blueprint(reform_impact_bp) - return app - - -def _seed_reform_impacts(service: ReformImpactsService, n: int) -> None: - for i in range(n): - execution_id = f"exec-{i}" - service.set_reform_impact( - ReformImpactIdentity( - scope=ReformImpactScope( - country_id="us", - reform_policy_id=i + 2, - baseline_policy_id=i + 1, - region="us", - dataset="custom_dataset", - time_period="2025", - ), - api_version="1.0.0", - options_hash=f"hash-{i}", - ), - ReformImpactExecution( - execution_id=execution_id, - start_time=datetime(2026, 1, 1, 0, i // 60, i % 60), - ), - options={}, - status="computing", - reform_impact_json={}, - ) - service.set_complete_reform_impact(execution_id, reform_impact_json={}) - - -def test_get_simulations_default_limit_caps_at_100(reform_impacts_service): - _seed_reform_impacts(reform_impacts_service, 150) - result = _get_simulations() - assert len(result["result"]) == 100 - assert result["result"][0]["execution_id"] == "exec-149" - assert result["result"][-1]["execution_id"] == "exec-50" - - -def test_get_simulations_clamps_huge_max_results(reform_impacts_service): - _seed_reform_impacts(reform_impacts_service, 50) - # A caller passing an absurdly large value must not crash and - # must not cause a full scan; the value is clamped at 1000. - result = _get_simulations(max_results=10**9) - assert len(result["result"]) == 50 # only 50 seeded - - -def test_get_simulations_clamps_negative_max_results(reform_impacts_service): - _seed_reform_impacts(reform_impacts_service, 5) - # max_results of 0 or negative must still return something sane. - result = _get_simulations(max_results=0) - assert 1 <= len(result["result"]) <= 5 - - -def test_get_simulations_defaults_when_none(reform_impacts_service): - _seed_reform_impacts(reform_impacts_service, 10) - result = _get_simulations(max_results=None) - assert len(result["result"]) == 10 # fewer than the default 100 - - -def test_get_simulations_rejects_non_integer_gracefully(reform_impacts_service): - _seed_reform_impacts(reform_impacts_service, 5) - # An invalid value must not become a cache-index bound; it falls back to - # the default without altering the stored values. - result = _get_simulations(max_results="100; DROP TABLE reform_impact") - assert len(result["result"]) == 5 - assert len(reform_impacts_service.get_recent_reform_impacts(100)) == 5 - - -def test_get_simulations_preserves_complete_v1_response_shape( - reform_impacts_service, -): - _seed_reform_impacts(reform_impacts_service, 1) - - impact = _get_simulations(max_results=1)["result"][0] - - assert impact == { - "reform_impact_id": reform_impact_id("exec-0"), - "baseline_policy_id": 1, - "reform_policy_id": 2, - "country_id": "us", - "region": "us", - "dataset": "custom_dataset", - "time_period": "2025", - "options_json": json.dumps({}), - "options_hash": "hash-0", - "api_version": "1.0.0", - "reform_impact_json": json.dumps({}), - "status": "ok", - "message": "Completed", - "start_time": "2026-01-01 00:00:00", - "end_time": "2026-01-01 01:00:00", - "execution_id": "exec-0", - } - - -def test_get_simulations_matches_through_fastapi_fallback(reform_impacts_service): - _seed_reform_impacts(reform_impacts_service, 1) - app = _create_app() - - flask_response = app.test_client().get("/simulations?max_results=1") - asgi_response = TestClient(create_asgi_app(app)).get("/simulations?max_results=1") - - assert asgi_response.status_code == flask_response.status_code == 200 - assert asgi_response.json() == flask_response.get_json() diff --git a/tests/unit/routes/test_set_user_policy_dataset.py b/tests/unit/routes/test_set_user_policy_dataset.py deleted file mode 100644 index 887dd653d..000000000 --- a/tests/unit/routes/test_set_user_policy_dataset.py +++ /dev/null @@ -1,40 +0,0 @@ -import time - -import pytest -from flask import Flask -from sqlalchemy import select - -from policyengine_api.data.v1_models import UserPolicy -from policyengine_api.routes.policy_routes import policy_bp - - -def create_client(): - app = Flask(__name__) - app.config["TESTING"] = True - app.register_blueprint(policy_bp) - return app.test_client() - - -@pytest.mark.parametrize("dataset", ["custom_dataset", None]) -def test_set_user_policy_persists_dataset_with_orm(orm_session, dataset): - now = int(time.time()) - response = create_client().post( - "/us/user-policy", - json={ - "reform_id": 2, - "baseline_id": 1, - "user_id": "user1", - "year": "2025", - "geography": "us", - "dataset": dataset, - "number_of_provisions": 3, - "api_version": "1.0.0", - "added_date": now, - "updated_date": now, - }, - ) - - assert response.status_code == 201 - orm_session.expire_all() - policy = orm_session.scalar(select(UserPolicy).where(UserPolicy.user_id == "user1")) - assert policy.dataset == dataset diff --git a/tests/unit/routes/test_update_user_policy.py b/tests/unit/routes/test_update_user_policy.py deleted file mode 100644 index 262f6137e..000000000 --- a/tests/unit/routes/test_update_user_policy.py +++ /dev/null @@ -1,133 +0,0 @@ -"""Regression tests for issue #3445. - -update_user_policy (policy.py) previously interpolated untrusted -payload keys directly into an UPDATE statement, allowing arbitrary -SQL fragments (and identity-column tampering) via the JSON body. - -The fix rejects unknown keys with a 400 response and restricts -writable columns to a static whitelist. -""" - -import time - -from flask import Flask - -from policyengine_api.data.v1_models import UserPolicy -from policyengine_api.routes.policy_routes import policy_bp - - -def _create_test_client() -> Flask: - app = Flask(__name__) - app.config["TESTING"] = True - app.register_blueprint(policy_bp) - return app.test_client() - - -def _insert_user_policy(orm_session, *, country_id: str = "us") -> int: - now = int(time.time()) - policy = UserPolicy( - country_id=country_id, - reform_label="old label", - reform_id=2, - baseline_label=None, - baseline_id=1, - user_id="user1", - year="2025", - geography="us", - dataset="custom_dataset", - number_of_provisions=3, - api_version="1.0.0", - added_date=now, - updated_date=now, - ) - orm_session.add(policy) - orm_session.commit() - return policy.id - - -def test_update_user_policy_rejects_sql_injection_key(orm_session): - """Unknown keys (including SQL injection attempts) must be rejected.""" - policy_id = _insert_user_policy(orm_session) - - client = _create_test_client() - response = client.put( - "/us/user-policy", - json={ - "id": policy_id, - "username; DROP TABLE x --": "x", - }, - ) - - assert response.status_code == 400 - body = response.get_json() - assert "unsupported fields" in body["message"] - - # The row must be untouched. - orm_session.expire_all() - assert orm_session.get(UserPolicy, policy_id).reform_label == "old label" - - -def test_update_user_policy_rejects_identity_column(orm_session): - """Identity columns (user_id, country_id, ...) must not be writable.""" - policy_id = _insert_user_policy(orm_session) - - client = _create_test_client() - response = client.put( - "/us/user-policy", - json={"id": policy_id, "user_id": "attacker"}, - ) - - assert response.status_code == 400 - orm_session.expire_all() - assert orm_session.get(UserPolicy, policy_id).user_id == "user1" - - -def test_update_user_policy_allows_whitelisted_field(orm_session): - """Whitelisted fields (e.g. reform_label) can still be updated.""" - policy_id = _insert_user_policy(orm_session) - - client = _create_test_client() - response = client.put( - "/us/user-policy", - json={"id": policy_id, "reform_label": "new label"}, - ) - - assert response.status_code == 200 - orm_session.expire_all() - assert orm_session.get(UserPolicy, policy_id).reform_label == "new label" - - -def test_update_user_policy_requires_id(): - client = _create_test_client() - response = client.put("/us/user-policy", json={"reform_label": "x"}) - assert response.status_code == 400 - - -def test_update_user_policy_requires_at_least_one_field(orm_session): - policy_id = _insert_user_policy(orm_session) - client = _create_test_client() - response = client.put("/us/user-policy", json={"id": policy_id}) - assert response.status_code == 400 - - -def test_update_user_policy_returns_not_found_for_missing_id(): - response = _create_test_client().put( - "/us/user-policy", - json={"id": 999_999, "reform_label": "missing"}, - ) - - assert response.status_code == 404 - assert response.get_json()["message"] == "User policy #999999 not found." - - -def test_update_user_policy_does_not_update_another_country(orm_session): - policy_id = _insert_user_policy(orm_session, country_id="uk") - - response = _create_test_client().put( - "/us/user-policy", - json={"id": policy_id, "reform_label": "wrong country"}, - ) - - assert response.status_code == 404 - orm_session.expire_all() - assert orm_session.get(UserPolicy, policy_id).reform_label == "old label" diff --git a/tests/unit/routes/test_user_policy_dual_write_routes.py b/tests/unit/routes/test_user_policy_dual_write_routes.py deleted file mode 100644 index c36f57436..000000000 --- a/tests/unit/routes/test_user_policy_dual_write_routes.py +++ /dev/null @@ -1,738 +0,0 @@ -"""V1 saved-policy route tests for immediate v2 association mirroring.""" - -from __future__ import annotations - -import json -from typing import NamedTuple -from unittest.mock import MagicMock, patch - -import pytest -from flask import Flask -from sqlalchemy import select -from sqlalchemy.exc import ( - IntegrityError, - OperationalError, - SQLAlchemyError, -) -from sqlalchemy.exc import ( - TimeoutError as SQLAlchemyTimeoutError, -) - -from policyengine_api.data.v1_models import Policy, UserPolicy, UserPolicyMirrorEvent -from policyengine_api.routes.policy_routes import policy_bp -from policyengine_api.services.user_policy_mirroring import ( - UserPolicyMirrorUnavailableError, -) -from policyengine_api.services.user_policy_service import ( - UserPolicyCreateResult, - UserPolicyPersistenceError, - UserPolicyUpdateResult, -) -from policyengine_api.services.v2.policies.types import LegacyPolicySnapshot -from policyengine_api.services.v2.user_policies.types import LegacyUserPolicySnapshot - - -def _client(): - app = Flask(__name__) - app.config["TESTING"] = True - app.register_blueprint(policy_bp) - return app.test_client() - - -def _row( - *, - country_id: str = "us", - reform_label: str | None = "Reform", - year: str = "2026", -): - return UserPolicy( - id=10, - country_id=country_id, - reform_id=2, - reform_label=reform_label, - baseline_id=1, - baseline_label="Current law", - user_id="auth0|one", - year=year, - geography="us", - dataset="enhanced_cps_2024", - number_of_provisions=3, - api_version="1.0.0", - added_date=1, - updated_date=2, - budgetary_impact=None, - type=None, - ) - - -def _snapshot( - *, - country_id: str = "us", - reform_label: str | None = "Reform", - year: str = "2026", -): - return LegacyUserPolicySnapshot( - country_id=country_id, - legacy_user_policy_id=10, - reform_id=2, - reform_label=reform_label, - baseline_id=1, - baseline_label="Current law", - user_id="auth0|one", - year=year, - geography="us", - dataset="enhanced_cps_2024", - number_of_provisions=3, - api_version="1.0.0", - added_date=1, - updated_date=2, - budgetary_impact=None, - type=None, - ) - - -def _reform_snapshot(country_id: str = "us"): - return LegacyPolicySnapshot( - country_id=country_id, - legacy_policy_id=2, - label="Ignored core label", - api_version="1.0.0", - policy_json={"gov.example.rate": {"2026": 0.2}}, - source_policy_hash="legacy/base64+hash=", - ) - - -def _creation( - *, - created=True, - country_id="us", - reform_label="Reform", - mirror_revision=1, -): - return UserPolicyCreateResult( - user_policy=_row(country_id=country_id, reform_label=reform_label), - created=created, - snapshot=_snapshot(country_id=country_id, reform_label=reform_label), - mirror_revision=mirror_revision, - ) - - -def _body(*, reform_label="Reform") -> dict[str, object]: - return { - "reform_id": 2, - "reform_label": reform_label, - "baseline_id": 1, - "baseline_label": "Current law", - "user_id": "auth0|one", - "year": "2026", - "geography": "us", - "dataset": "enhanced_cps_2024", - "number_of_provisions": 3, - "api_version": "1.0.0", - "added_date": 1, - "updated_date": 2, - "budgetary_impact": None, - "type": None, - } - - -def test_cloud_sql_mode_preserves_create_without_association_mirror( - monkeypatch, -) -> None: - monkeypatch.setenv("DB_WRITE_POLICY", "cloud_sql") - with ( - patch( - "policyengine_api.routes.policy_routes.user_policy_service.create_or_get_user_policy", - return_value=_creation(), - ), - patch( - "policyengine_api.routes.policy_routes." - "mirror_pending_user_policy_events_after_commit" - ) as mirror, - patch( - "policyengine_api.routes.policy_routes.policy_service.get_policy_snapshot" - ) as get_reform, - ): - response = _client().post("/us/user-policy", json=_body()) - - assert response.status_code == 201 - assert response.json["result"]["id"] == 10 - assert "v2" not in response.json["result"] - mirror.assert_not_called() - get_reform.assert_not_called() - - -@pytest.mark.parametrize("country_id", ["us", "uk"]) -def test_dual_write_mirrors_new_existing_and_unlabeled_saved_policies( - monkeypatch, - country_id, -) -> None: - monkeypatch.setenv("DB_WRITE_POLICY", "dual_write") - for created, label, expected_status in ( - (True, "Reform", 201), - (False, "Reform", 200), - (True, None, 201), - ): - creation = _creation( - created=created, - country_id=country_id, - reform_label=label, - ) - with ( - patch( - "policyengine_api.routes.policy_routes.user_policy_service.create_or_get_user_policy", - return_value=creation, - ), - patch( - "policyengine_api.routes.policy_routes.policy_service.get_policy_snapshot", - return_value=_reform_snapshot(country_id), - ), - patch( - "policyengine_api.routes.policy_routes." - "mirror_pending_user_policy_events_after_commit" - ) as mirror, - ): - response = _client().post( - f"/{country_id}/user-policy", - json=_body(reform_label=label), - ) - - assert response.status_code == expected_status - assert response.json["result"]["id"] == 10 - assert "v2" not in response.json["result"] - mirror.assert_called_once() - assert mirror.call_args.args == (country_id, 10) - assert mirror.call_args.kwargs["through_revision"] == 1 - - -@pytest.mark.parametrize("country_id", ["ca", "ng", "il"]) -def test_dual_write_preserves_v1_only_saved_policy_mutations_for_non_v2_countries( - monkeypatch, - country_id, -) -> None: - monkeypatch.setenv("DB_WRITE_POLICY", "dual_write") - creation = UserPolicyCreateResult( - user_policy=_row(country_id=country_id), - created=True, - snapshot=None, - mirror_revision=None, - ) - update = UserPolicyUpdateResult( - user_policy=_row(country_id=country_id, year="2027"), - snapshot=None, - changed_fields=frozenset({"year"}), - mirror_revision=None, - ) - - with ( - patch( - "policyengine_api.routes.policy_routes." - "user_policy_service.create_or_get_user_policy", - return_value=creation, - ) as create, - patch( - "policyengine_api.routes.policy_routes." - "mirror_pending_user_policy_events_after_commit" - ) as create_mirror, - ): - create_response = _client().post( - f"/{country_id}/user-policy", - json=_body(), - ) - - assert create_response.status_code == 201 - assert create.call_args.kwargs == {"record_mirror_event": False} - create_mirror.assert_not_called() - - with ( - patch( - "policyengine_api.routes.policy_routes." - "user_policy_service.update_user_policy", - return_value=update, - ) as update_saved_policy, - patch( - "policyengine_api.routes.policy_routes." - "mirror_pending_user_policy_events_after_commit" - ) as update_mirror, - ): - update_response = _client().put( - f"/{country_id}/user-policy", - json={"id": 10, "year": "2027"}, - ) - - assert update_response.status_code == 200 - update_saved_policy.assert_called_once_with( - country_id, - 10, - {"year": "2027"}, - record_mirror_event=False, - ) - update_mirror.assert_not_called() - - -def test_saved_policy_mirror_failure_returns_the_committed_record_and_a_retry_copies_it( - monkeypatch, -) -> None: - monkeypatch.setenv("DB_WRITE_POLICY", "dual_write") - creation = _creation(created=False) - with ( - patch( - "policyengine_api.routes.policy_routes.user_policy_service.create_or_get_user_policy", - return_value=creation, - ) as create, - patch( - "policyengine_api.routes.policy_routes.policy_service.get_policy_snapshot", - return_value=_reform_snapshot(), - ), - patch( - "policyengine_api.routes.policy_routes." - "mirror_pending_user_policy_events_after_commit", - side_effect=[ - UserPolicyMirrorUnavailableError("database credential secret"), - MagicMock(), - ], - ) as mirror, - ): - first = _client().post("/us/user-policy", json=_body()) - retry = _client().post("/us/user-policy", json=_body()) - - assert first.status_code == 200 - assert first.json["status"] == "ok" - assert first.json["result"] == {"id": 10} - assert "secret" not in first.text - assert retry.status_code == 200 - assert retry.json == first.json - assert create.call_count == mirror.call_count == 2 - - -def test_mirror_failure_for_a_new_saved_policy_still_returns_201( - monkeypatch, -) -> None: - monkeypatch.setenv("DB_WRITE_POLICY", "dual_write") - with ( - patch( - "policyengine_api.routes.policy_routes.user_policy_service.create_or_get_user_policy", - return_value=_creation(created=True), - ), - patch( - "policyengine_api.routes.policy_routes.policy_service.get_policy_snapshot", - return_value=_reform_snapshot(), - ), - patch( - "policyengine_api.routes.policy_routes." - "mirror_pending_user_policy_events_after_commit", - side_effect=UserPolicyMirrorUnavailableError("database credential secret"), - ), - ): - response = _client().post("/us/user-policy", json=_body()) - - assert response.status_code == 201 - assert response.json["message"] == "Record created successfully" - assert response.json["result"]["id"] == 10 - assert "secret" not in response.text - - -def test_destination_failure_keeps_the_saved_policy_event_pending_and_logs_an_error( - monkeypatch, - orm_session_factory, -) -> None: - """Run the real saved-policy and copy services, with only the v2 write failing.""" - monkeypatch.setenv("DB_WRITE_POLICY", "dual_write") - reform_snapshot = _reform_snapshot() - with orm_session_factory.begin() as session: - session.add( - Policy( - id=reform_snapshot.legacy_policy_id, - country_id=reform_snapshot.country_id, - label=reform_snapshot.label, - api_version=reform_snapshot.api_version, - policy_json=reform_snapshot.policy_json, - policy_hash=reform_snapshot.source_policy_hash, - ) - ) - failing_destination = MagicMock() - failing_destination.mirror_legacy_user_policy.side_effect = OperationalError( - "statement secret", {}, Exception("credential") - ) - with ( - patch( - "policyengine_api.services.user_policy_mirroring._default_mirror_factory", - return_value=failing_destination, - ), - patch("policyengine_api.services.user_policy_mirroring.logger") as copy_logger, - ): - response = _client().post("/us/user-policy", json=_body()) - - assert response.status_code == 201 - assert response.json["message"] == "Record created successfully" - stored_user_policy_id = response.json["result"]["id"] - failing_destination.mirror_legacy_user_policy.assert_called_once() - with orm_session_factory() as session: - stored_events = session.scalars( - select(UserPolicyMirrorEvent).where( - UserPolicyMirrorEvent.legacy_user_policy_id == stored_user_policy_id - ) - ).all() - assert [event.processed_at for event in stored_events] == [None] - logged_record = copy_logger.log_struct.call_args.args[0] - assert logged_record["metric_name"] == "v1_user_policy_mirror_operations" - assert logged_record["outcome"] == "error" - assert logged_record["failure_category"] == "database" - assert logged_record["legacy_user_policy_id"] == stored_user_policy_id - assert "secret" not in json.dumps(logged_record) - assert "credential" not in json.dumps(logged_record) - - -def test_saved_policy_update_mirror_failure_returns_the_updated_record( - monkeypatch, -) -> None: - monkeypatch.setenv("DB_WRITE_POLICY", "dual_write") - update = UserPolicyUpdateResult( - user_policy=_row(reform_label="Renamed"), - snapshot=_snapshot(reform_label="Renamed"), - changed_fields=frozenset({"reform_label"}), - mirror_revision=2, - ) - with ( - patch( - "policyengine_api.routes.policy_routes.user_policy_service.update_user_policy", - return_value=update, - ), - patch( - "policyengine_api.routes.policy_routes.policy_service.get_policy_snapshot", - return_value=_reform_snapshot(), - ), - patch( - "policyengine_api.routes.policy_routes." - "mirror_pending_user_policy_events_after_commit", - side_effect=UserPolicyMirrorUnavailableError("database credential secret"), - ), - ): - response = _client().put( - "/us/user-policy", - json={"id": 10, "reform_label": "Renamed"}, - ) - - assert response.status_code == 200 - assert response.json == { - "status": "ok", - "message": "Record updated successfully", - "result": {"id": 10}, - } - assert "secret" not in response.text - - -def test_unexpected_saved_policy_mirror_error_is_logged_and_the_record_returned( - monkeypatch, -) -> None: - monkeypatch.setenv("DB_WRITE_POLICY", "dual_write") - with ( - patch( - "policyengine_api.routes.policy_routes.user_policy_service.create_or_get_user_policy", - return_value=_creation(created=False), - ), - patch( - "policyengine_api.routes.policy_routes.policy_service.get_policy_snapshot", - return_value=_reform_snapshot(), - ), - patch( - "policyengine_api.routes.policy_routes." - "mirror_pending_user_policy_events_after_commit", - side_effect=RuntimeError("driver-secret"), - ), - patch("policyengine_api.routes.policy_routes.logger") as route_logger, - ): - response = _client().post("/us/user-policy", json=_body()) - - assert response.status_code == 200 - assert response.json["result"] == {"id": 10} - assert "secret" not in response.text - logged_record = route_logger.log_struct.call_args.args[0] - assert logged_record["metric_name"] == "v1_user_policy_mirror_operations" - assert logged_record["outcome"] == "error" - assert logged_record["failure_category"] == "unexpected" - assert logged_record["failure_reason"] == "unexpected" - assert logged_record["legacy_user_policy_id"] == 10 - assert "secret" not in json.dumps(logged_record) - assert route_logger.log_struct.call_args.kwargs["severity"] == "ERROR" - - -def test_saved_policy_without_a_mirror_revision_is_returned_and_logged( - monkeypatch, -) -> None: - monkeypatch.setenv("DB_WRITE_POLICY", "dual_write") - with ( - patch( - "policyengine_api.routes.policy_routes.user_policy_service.create_or_get_user_policy", - return_value=_creation(created=False, mirror_revision=None), - ), - patch( - "policyengine_api.routes.policy_routes." - "mirror_pending_user_policy_events_after_commit" - ) as mirror, - patch("policyengine_api.routes.policy_routes.logger") as route_logger, - ): - response = _client().post("/us/user-policy", json=_body()) - - assert response.status_code == 200 - assert response.json["result"] == {"id": 10} - mirror.assert_not_called() - logged_record = route_logger.log_struct.call_args.args[0] - assert logged_record["metric_name"] == "v1_user_policy_mirror_operations" - assert logged_record["outcome"] == "error" - assert logged_record["failure_category"] == "missing_mirror_revision" - assert logged_record["failure_reason"] == "missing_mirror_revision" - assert logged_record["legacy_user_policy_id"] == 10 - - -class _PersistenceFailure(NamedTuple): - """A saved-policy request whose service call fails, and the response expected.""" - - method: str - service_method: str - body: dict[str, object] - error: Exception - expected_status: int - expected_category: str - expected_operation: str - - -_PERSISTENCE_FAILURES = ( - _PersistenceFailure( - "post", - "create_or_get_user_policy", - _body(), - SQLAlchemyTimeoutError("credential=timeout-secret"), - 503, - "timeout", - "create", - ), - _PersistenceFailure( - "put", - "update_user_policy", - {"id": 10, "reform_label": "Renamed"}, - OperationalError( - "UPDATE user_policies SET secret=:secret", - {"secret": "bound-parameter-secret"}, - RuntimeError("driver-secret"), - ), - 503, - "unavailable", - "update", - ), - _PersistenceFailure( - "post", - "create_or_get_user_policy", - _body(), - IntegrityError( - "INSERT caller-private-data", - {"user_id": "caller-private-data"}, - RuntimeError("integrity-secret"), - ), - 500, - "integrity", - "create", - ), - _PersistenceFailure( - "put", - "update_user_policy", - {"id": 10, "year": "2027"}, - SQLAlchemyError("database-secret"), - 500, - "database", - "update", - ), - _PersistenceFailure( - "post", - "create_or_get_user_policy", - _body(), - RuntimeError("unexpected-secret"), - 500, - "unexpected", - "create", - ), - _PersistenceFailure( - "put", - "update_user_policy", - {"id": "caller-id-secret", "year": "2027"}, - RuntimeError("unexpected-secret"), - 500, - "unexpected", - "update", - ), -) - - -def _persistence_failure_id(index: int, failure: _PersistenceFailure) -> str: - """Return the test ID pytest gave this case when each field was a parameter.""" - return "-".join( - ( - failure.method, - failure.service_method, - f"body{index}", - f"error{index}", - str(failure.expected_status), - failure.expected_category, - failure.expected_operation, - ) - ) - - -@pytest.mark.parametrize( - "failure", - [ - pytest.param(failure, id=_persistence_failure_id(index, failure)) - for index, failure in enumerate(_PERSISTENCE_FAILURES) - ], -) -def test_saved_policy_persistence_failures_use_allowlisted_records( - monkeypatch, - failure, -) -> None: - monkeypatch.setenv("DB_WRITE_POLICY", "cloud_sql") - with ( - patch( - f"policyengine_api.routes.policy_routes.user_policy_service.{failure.service_method}", - side_effect=UserPolicyPersistenceError.from_exception(failure.error), - ), - patch( - "policyengine_api.routes.policy_routes.current_request_id", - return_value="request-123", - ), - patch("policyengine_api.routes.policy_routes.logger.log_struct") as log_struct, - ): - response = getattr(_client(), failure.method)( - "/us/user-policy", json=failure.body - ) - - expected_message = ( - "Policy database is temporarily unavailable; please try again later." - if failure.expected_status == 503 - else "Internal database error; please try again later." - ) - assert response.status_code == failure.expected_status - assert response.json == {"message": expected_message} - - log_struct.assert_called_once() - assert log_struct.call_args.kwargs == {"severity": "ERROR"} - payload = log_struct.call_args.args[0] - assert set(payload) == { - "message", - "metric_name", - "metric_value", - "resource", - "operation", - "database_source", - "configured_write_source", - "country_id", - "legacy_user_policy_id", - "request_id", - "outcome", - "failure_category", - "http_status", - "duration_ms", - } - assert payload["operation"] == failure.expected_operation - assert payload["failure_category"] == failure.expected_category - assert payload["http_status"] == failure.expected_status - assert payload["request_id"] == "request-123" - supplied_id = failure.body.get("id") - expected_logged_id = ( - supplied_id - if isinstance(supplied_id, int) - and not isinstance(supplied_id, bool) - and 0 <= supplied_id <= 2_147_483_647 - else None - ) - assert payload["legacy_user_policy_id"] == expected_logged_id - - serialized_record = json.dumps(payload, sort_keys=True) - for private_value in ( - "timeout-secret", - "bound-parameter-secret", - "driver-secret", - "caller-private-data", - "integrity-secret", - "database-secret", - "unexpected-secret", - "caller-id-secret", - ): - assert private_value not in response.text - assert private_value not in serialized_record - - -def test_update_mirrors_projected_and_v1_only_changes_before_success( - monkeypatch, -) -> None: - monkeypatch.setenv("DB_WRITE_POLICY", "dual_write") - for payload, snapshot in ( - ({"reform_label": "Renamed"}, _snapshot(reform_label="Renamed")), - ({"year": "2027"}, _snapshot(year="2027")), - ): - update = UserPolicyUpdateResult( - user_policy=_row( - reform_label=snapshot.reform_label, - year=snapshot.year, - ), - snapshot=snapshot, - changed_fields=frozenset(payload), - mirror_revision=1, - ) - with ( - patch( - "policyengine_api.routes.policy_routes.user_policy_service.update_user_policy", - return_value=update, - ), - patch( - "policyengine_api.routes.policy_routes.policy_service.get_policy_snapshot", - return_value=_reform_snapshot(), - ), - patch( - "policyengine_api.routes.policy_routes." - "mirror_pending_user_policy_events_after_commit" - ) as mirror, - ): - response = _client().put( - "/us/user-policy", - json={"id": 10, **payload}, - ) - - assert response.status_code == 200 - assert response.json["result"] == {"id": 10} - assert mirror.call_args.args == ("us", 10) - assert mirror.call_args.kwargs["through_revision"] == 1 - - -def test_saved_policy_reads_remain_cloud_sql_only(monkeypatch) -> None: - monkeypatch.setenv("DB_READ_POLICY", "cloud_sql") - with ( - patch( - "policyengine_api.routes.policy_routes.user_policy_service.list_user_policies", - return_value=[_row()], - ) as list_rows, - patch( - "policyengine_api.routes.policy_routes." - "mirror_pending_user_policy_events_after_commit" - ) as mirror, - ): - response = _client().get("/us/user-policy/auth0|one") - - assert response.status_code == 200 - assert response.json["result"][0]["id"] == 10 - list_rows.assert_called_once_with("us", "auth0|one") - mirror.assert_not_called() - - monkeypatch.setenv("DB_READ_POLICY", "supabase") - with patch( - "policyengine_api.routes.policy_routes.user_policy_service.list_user_policies" - ) as invalid_list: - invalid = _client().get("/us/user-policy/auth0|one") - - assert invalid.status_code == 503 - invalid_list.assert_not_called() - - -def test_v1_saved_policy_delete_is_not_added() -> None: - response = _client().delete("/us/user-policy", json={"id": 10}) - - assert response.status_code == 405 diff --git a/tests/unit/routes/test_user_policy_route_outcomes.py b/tests/unit/routes/test_user_policy_route_outcomes.py deleted file mode 100644 index 38ec71cc9..000000000 --- a/tests/unit/routes/test_user_policy_route_outcomes.py +++ /dev/null @@ -1,491 +0,0 @@ -"""Pin every response and failure record of the v1 saved-policy write routes.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING, NamedTuple -from unittest.mock import MagicMock, patch - -import pytest -from flask import Flask - -from policyengine_api.data.v1_models import UserPolicy -from policyengine_api.routes import policy_routes -from policyengine_api.services.user_policy_mirroring import ( - UserPolicyMirrorCollaborators, - UserPolicyMirrorUnavailableError, -) -from policyengine_api.services.user_policy_service import ( - UserPolicyCreateResult, - UserPolicyPersistenceError, - UserPolicyPersistenceFailureCategory, - UserPolicyUpdateResult, -) - -if TYPE_CHECKING: - from collections.abc import Callable - - from flask.testing import FlaskClient - from werkzeug.test import TestResponse - -UNAVAILABLE_CONFIGURATION = { - "status": "error", - "message": "Policy persistence configuration is unavailable.", -} - - -def _row() -> UserPolicy: - return UserPolicy( - id=10, - country_id="us", - reform_id=2, - reform_label="Reform", - baseline_id=1, - baseline_label="Current law", - user_id="auth0|one", - year="2026", - geography="us", - dataset=None, - number_of_provisions=3, - api_version="1.0.0", - added_date=1, - updated_date=2, - budgetary_impact=None, - type=None, - ) - - -def _body() -> dict[str, object]: - return { - "reform_id": 2, - "baseline_id": 1, - "user_id": "auth0|one", - "year": "2026", - "geography": "us", - "number_of_provisions": 3, - "api_version": "1.0.0", - "added_date": 1, - "updated_date": 2, - } - - -@pytest.fixture -def client(): - app = Flask(__name__) - app.config["TESTING"] = True - app.register_blueprint(policy_routes.policy_bp) - return app.test_client() - - -@pytest.fixture -def write_source(monkeypatch) -> MagicMock: - source = MagicMock(return_value="dual_write") - monkeypatch.setattr(policy_routes, "get_v1_policy_write_source", source) - monkeypatch.setattr(policy_routes, "get_v1_policy_read_source", MagicMock()) - return source - - -@pytest.fixture -def mirror(monkeypatch) -> MagicMock: - mirror_events = MagicMock() - monkeypatch.setattr( - policy_routes, - "mirror_pending_user_policy_events_after_commit", - mirror_events, - ) - return mirror_events - - -def _create(client): - return client.post("/us/user-policy", json=_body()) - - -def _update(client): - return client.put("/us/user-policy", json={"id": 10, "reform_label": "Renamed"}) - - -@pytest.mark.parametrize("send_request", [_create, _update]) -@pytest.mark.parametrize("failing_source", ["write", "read"]) -def test_invalid_persistence_configuration_is_unavailable( - client, - write_source: MagicMock, - mirror: MagicMock, - send_request, - failing_source: str, -) -> None: - if failing_source == "write": - write_source.side_effect = ValueError("invalid") - else: - policy_routes.get_v1_policy_read_source.side_effect = ValueError("invalid") - - with ( - patch.object( - policy_routes.user_policy_service, "create_or_get_user_policy" - ) as create, - patch.object(policy_routes.user_policy_service, "update_user_policy") as update, - ): - response = send_request(client) - - assert response.status_code == 503 - assert response.get_json() == UNAVAILABLE_CONFIGURATION - create.assert_not_called() - update.assert_not_called() - mirror.assert_not_called() - - -class _PersistenceFailure(NamedTuple): - category: UserPolicyPersistenceFailureCategory - status_code: int - message: str - - -class _SavedPolicyWrite(NamedTuple): - send_request: object - service_method: str - operation: str - legacy_id: int | None - - -@pytest.mark.parametrize( - "failure", - [ - _PersistenceFailure( - UserPolicyPersistenceFailureCategory.TIMEOUT, - 503, - "Policy database is temporarily unavailable; please try again later.", - ), - _PersistenceFailure( - UserPolicyPersistenceFailureCategory.INTEGRITY, - 500, - "Internal database error; please try again later.", - ), - ], -) -@pytest.mark.parametrize( - "write", - [ - _SavedPolicyWrite(_create, "create_or_get_user_policy", "create", None), - _SavedPolicyWrite(_update, "update_user_policy", "update", 10), - ], -) -def test_persistence_failures_log_one_record_and_return_a_safe_error( - client, - write_source: MagicMock, - mirror: MagicMock, - failure: _PersistenceFailure, - write: _SavedPolicyWrite, -) -> None: - write_source.return_value = "cloud_sql" - category, status_code, message = failure - - with ( - patch.object( - policy_routes.user_policy_service, - write.service_method, - side_effect=UserPolicyPersistenceError(category), - ), - patch.object(policy_routes, "logger") as logger, - patch.object(policy_routes, "current_request_id", return_value="request-1"), - ): - response = write.send_request(client) - - assert response.status_code == status_code - assert response.get_json() == {"message": message} - record = dict(logger.log_struct.call_args.args[0]) - assert isinstance(record.pop("duration_ms"), float) - expected = { - "message": "V1 saved-policy persistence failed", - "metric_name": "v1_user_policy_persistence_failures", - "metric_value": 1, - "resource": "user_policy", - "operation": write.operation, - "database_source": "cloud_sql", - "configured_write_source": "cloud_sql", - "country_id": "us", - "legacy_user_policy_id": write.legacy_id, - "request_id": "request-1", - "outcome": "error", - "failure_category": category.value, - "http_status": status_code, - } - assert list(record) == list(expected) - assert record == expected - assert logger.log_struct.call_args.kwargs == {"severity": "ERROR"} - mirror.assert_not_called() - - -@pytest.mark.usefixtures("write_source") -@pytest.mark.parametrize("send_request", [_create, _update]) -def test_persistence_failure_survives_a_logging_failure( - client, - mirror: MagicMock, - send_request, -) -> None: - error = UserPolicyPersistenceError(UserPolicyPersistenceFailureCategory.DATABASE) - - with ( - patch.object( - policy_routes.user_policy_service, - "create_or_get_user_policy", - side_effect=error, - ), - patch.object( - policy_routes.user_policy_service, - "update_user_policy", - side_effect=error, - ), - patch.object(policy_routes.logger, "log_struct", side_effect=RuntimeError), - ): - response = send_request(client) - - assert response.status_code == 500 - assert response.get_json() == { - "message": "Internal database error; please try again later." - } - mirror.assert_not_called() - - -@pytest.mark.usefixtures("write_source") -@pytest.mark.parametrize( - "legacy_user_policy_id", [-1, 2_147_483_648, True, "10", 2_147_483_647, 0] -) -def test_failure_record_reports_only_a_safe_legacy_id( - client, - legacy_user_policy_id: object, -) -> None: - error = UserPolicyPersistenceError(UserPolicyPersistenceFailureCategory.DATABASE) - - with ( - patch.object( - policy_routes.user_policy_service, - "update_user_policy", - side_effect=error, - ), - patch.object(policy_routes, "logger") as logger, - ): - client.put( - "/us/user-policy", - json={"id": legacy_user_policy_id, "reform_label": "Renamed"}, - ) - - record = logger.log_struct.call_args.args[0] - expected = ( - legacy_user_policy_id - if legacy_user_policy_id in {2_147_483_647, 0} - and not isinstance(legacy_user_policy_id, bool) - else None - ) - assert record["legacy_user_policy_id"] == expected - - -def _creation(*, created: bool, mirror_revision: int | None): - return UserPolicyCreateResult( - user_policy=_row(), - created=created, - snapshot=None, - mirror_revision=mirror_revision, - ) - - -def _update_result(mirror_revision: int | None): - return UserPolicyUpdateResult( - user_policy=_row(), - snapshot=None, - changed_fields=frozenset({"reform_label"}), - mirror_revision=mirror_revision, - ) - - -class _UncopiedWrite(NamedTuple): - """A committed write whose v2 copy fails, and the category the route logs.""" - - mirror_revision: int | None - mirror_error: Exception | None - logged_categories: list[str] - - -class _SavedPolicyWrite(NamedTuple): - """A saved-policy write request and the success response it returns.""" - - send_request: Callable[[FlaskClient], TestResponse] - status_code: int - message: str - - -@pytest.mark.usefixtures("write_source") -@pytest.mark.parametrize( - "uncopied_write", - [ - _UncopiedWrite(None, None, ["missing_mirror_revision"]), - # The mirroring service logs its own failures, so the route adds none. - _UncopiedWrite(3, UserPolicyMirrorUnavailableError("database secret"), []), - _UncopiedWrite(3, RuntimeError("unexpected secret"), ["unexpected"]), - ], -) -@pytest.mark.parametrize( - "saved_policy_write", - [ - pytest.param( - _SavedPolicyWrite(_create, 201, "Record created successfully"), - id="create", - ), - pytest.param( - _SavedPolicyWrite(_update, 200, "Record updated successfully"), - id="update", - ), - ], -) -def test_committed_write_without_a_v2_copy_is_logged_and_still_succeeds( - client, - mirror: MagicMock, - uncopied_write: _UncopiedWrite, - saved_policy_write: _SavedPolicyWrite, -) -> None: - mirror.side_effect = uncopied_write.mirror_error - - with ( - patch.object( - policy_routes.user_policy_service, - "create_or_get_user_policy", - return_value=_creation( - created=True, mirror_revision=uncopied_write.mirror_revision - ), - ), - patch.object( - policy_routes.user_policy_service, - "update_user_policy", - return_value=_update_result(uncopied_write.mirror_revision), - ), - patch.object(policy_routes, "logger") as route_logger, - ): - response = saved_policy_write.send_request(client) - - assert response.status_code == saved_policy_write.status_code - assert response.get_json()["message"] == saved_policy_write.message - assert "secret" not in response.get_data(as_text=True) - assert mirror.call_count == int(uncopied_write.mirror_revision is not None) - copy_records = [ - logged_call.args[0] - for logged_call in route_logger.log_struct.call_args_list - if logged_call.args[0]["metric_name"] == "v1_user_policy_mirror_operations" - ] - assert [ - record["failure_category"] for record in copy_records - ] == uncopied_write.logged_categories - for record in copy_records: - assert record["legacy_user_policy_id"] == 10 - assert record["outcome"] == "error" - - -@pytest.mark.usefixtures("write_source") -@pytest.mark.parametrize( - ("created", "status_code", "message"), - [ - (True, 201, "Record created successfully"), - ( - False, - 200, - "The reform #2 / baseline #1 pair already exists for user auth0|one", - ), - ], -) -def test_mirrored_creation_returns_the_saved_policy( - client, - mirror: MagicMock, - created, - status_code: int, - message: str, -) -> None: - with patch.object( - policy_routes.user_policy_service, - "create_or_get_user_policy", - return_value=_creation(created=created, mirror_revision=4), - ): - response = _create(client) - - assert response.status_code == status_code - body = response.get_json() - assert body["status"] == "ok" - assert body["message"] == message - if created: - assert body["result"]["id"] == 10 - assert body["result"]["reform_label"] == "Reform" - else: - assert body["result"] == {"id": 10} - assert mirror.call_args.args == ("us", 10) - assert mirror.call_args.kwargs == { - "through_revision": 4, - "collaborators": UserPolicyMirrorCollaborators( - event_service=policy_routes.user_policy_service, - reform_snapshot_loader=policy_routes.policy_service.get_policy_snapshot, - ), - } - - -@pytest.mark.usefixtures("write_source") -def test_mirrored_update_returns_the_updated_id( - client, - mirror: MagicMock, -) -> None: - with patch.object( - policy_routes.user_policy_service, - "update_user_policy", - return_value=_update_result(5), - ) as update: - response = _update(client) - - assert response.status_code == 200 - assert response.get_json() == { - "status": "ok", - "message": "Record updated successfully", - "result": {"id": 10}, - } - assert update.call_args.args == ("us", 10, {"reform_label": "Renamed"}) - assert update.call_args.kwargs == {"record_mirror_event": True} - assert mirror.call_args.args == ("us", 10) - assert mirror.call_args.kwargs == { - "through_revision": 5, - "collaborators": UserPolicyMirrorCollaborators( - event_service=policy_routes.user_policy_service, - reform_snapshot_loader=policy_routes.policy_service.get_policy_snapshot, - ), - } - - -@pytest.mark.parametrize( - ("body", "message"), - [ - ([], "Request body must include an 'id' field."), - ({"reform_label": "x"}, "Request body must include an 'id' field."), - ( - {"id": 10, "user_id": "x", "reform_id": 3}, - "Request body contains unsupported fields: ['reform_id', 'user_id']", - ), - ({"id": 10}, "Request body must include at least one field to update."), - ], -) -def test_invalid_update_requests_are_rejected_before_configuration( - client, - write_source: MagicMock, - body: object, - message: str, -) -> None: - response = client.put("/us/user-policy", json=body) - - assert response.status_code == 400 - assert response.get_json() == {"message": message} - write_source.assert_not_called() - - -@pytest.mark.usefixtures("write_source") -def test_update_of_an_unknown_saved_policy_is_not_found( - client, - mirror: MagicMock, -) -> None: - with patch.object( - policy_routes.user_policy_service, "update_user_policy", return_value=None - ): - response = _update(client) - - assert response.status_code == 404 - assert response.get_json() == {"message": "User policy #10 not found."} - mirror.assert_not_called() diff --git a/tests/unit/runtime_cache/test_runtime_cache_contracts.py b/tests/unit/runtime_cache/test_runtime_cache_contracts.py index 4667926ed..be8a49b76 100644 --- a/tests/unit/runtime_cache/test_runtime_cache_contracts.py +++ b/tests/unit/runtime_cache/test_runtime_cache_contracts.py @@ -167,7 +167,7 @@ def test_set_reform_impact_stores_every_field_from_its_arguments(): ) assert stored == _stored_impact() - assert service.get_recent_reform_impacts(5) == [_stored_impact()] + assert service._cache.recent(5) == [_stored_impact()] def test_lookups_select_by_every_scope_field(): @@ -237,7 +237,7 @@ def test_delete_keeps_records_differing_in_any_field(changes): service.delete_reform_impact(ReformImpactScope(**SCOPE), OPTIONS_HASH) - assert len(service.get_recent_reform_impacts(5)) == 1 + assert len(service._cache.recent(5)) == 1 def test_delete_ignores_the_api_version(): @@ -246,7 +246,7 @@ def test_delete_ignores_the_api_version(): service.delete_reform_impact(ReformImpactScope(**SCOPE), OPTIONS_HASH) - assert service.get_recent_reform_impacts(5) == [] + assert service._cache.recent(5) == [] def test_delete_swallows_a_failed_cache_write(): diff --git a/tests/unit/services/test_direct_orm_users.py b/tests/unit/services/test_direct_orm_users.py deleted file mode 100644 index d06282d81..000000000 --- a/tests/unit/services/test_direct_orm_users.py +++ /dev/null @@ -1,46 +0,0 @@ -from policyengine_api.data.v1_models import UserProfile -from policyengine_api.services.user_service import UserService - - -def test_user_service_reads_and_writes_mapped_profiles(orm_session_factory): - service = UserService(orm_session_factory) - - created, profile = service.create_profile( - primary_country="us", - auth0_id="auth0|direct", - username="direct-user", - user_since=123, - ) - duplicate_created, duplicate = service.create_profile( - primary_country="us", - auth0_id="auth0|direct", - username="ignored", - user_since=456, - ) - - assert created is True - assert duplicate_created is False - assert isinstance(profile, UserProfile) - assert duplicate.user_id == profile.user_id - assert service.get_profile(auth0_id="auth0|direct").user_id == profile.user_id - - -def test_user_service_updates_the_mapped_profile(orm_session_factory): - service = UserService(orm_session_factory) - _, profile = service.create_profile( - primary_country="us", - auth0_id="auth0|update", - username=None, - user_since=123, - ) - - updated = service.update_profile( - user_id=profile.user_id, - primary_country="uk", - username="updated-user", - user_since=456, - ) - - assert updated.user_id == profile.user_id - assert updated.primary_country == "uk" - assert updated.username == "updated-user" diff --git a/tests/unit/services/test_economy_service_request_plumbing.py b/tests/unit/services/test_economy_service_request_plumbing.py index 29e640323..99ffcc4bb 100644 --- a/tests/unit/services/test_economy_service_request_plumbing.py +++ b/tests/unit/services/test_economy_service_request_plumbing.py @@ -226,7 +226,7 @@ def test_annual_submission_stores_record_and_sends_payload( "capture_mode": "disabled", }, } - stored = economy_service._reform_impacts.get_recent_reform_impacts(5) + stored = economy_service._reform_impacts._cache.recent(5) assert stored == [ CachedReformImpact( reform_impact_id=reform_impact_id(MOCK_MODAL_JOB_ID), @@ -470,7 +470,7 @@ def test_uk_breakdown_submission_has_its_own_hash_and_sends_breakdown( "policyengine_version": MOCK_POLICYENGINE_VERSION, "breakdown": "local_areas", } - stored = economy_service._reform_impacts.get_recent_reform_impacts(5) + stored = economy_service._reform_impacts._cache.recent(5) assert [impact.options_hash for impact in stored] == [ _stored_options_hash(UK_BREAKDOWN_LOOKUP_HASH) ] @@ -533,7 +533,7 @@ def test_uk_national_submission_sends_no_breakdown_and_keeps_its_hash( payload = mock_simulation_entrypoint.run.call_args.args[0] assert "breakdown" not in payload - stored = economy_service._reform_impacts.get_recent_reform_impacts(5) + stored = economy_service._reform_impacts._cache.recent(5) assert [impact.options_hash for impact in stored] == [ _stored_options_hash(UK_NATIONAL_LOOKUP_HASH) ] @@ -608,7 +608,7 @@ def test_a_northern_ireland_breakdown_sends_and_stores_the_registry_code( mock_simulation_entrypoint.get_execution_by_id.assert_called_once_with( MOCK_MODAL_JOB_ID ) - stored = economy_service._reform_impacts.get_recent_reform_impacts(5) + stored = economy_service._reform_impacts._cache.recent(5) assert [(impact.region, impact.options_json) for impact in stored] == [ ("country/northern_ireland", {"breakdown": "local_areas"}) ] @@ -675,7 +675,7 @@ def test_a_failed_breakdown_is_returned_for_two_minutes_then_resubmitted( # After that the record has expired and the next request submits again. assert retried == EconomicImpactResult.computing() assert mock_simulation_entrypoint.run.call_count == 2 - stored = economy_service._reform_impacts.get_recent_reform_impacts(5) + stored = economy_service._reform_impacts._cache.recent(5) assert [(impact.execution_id, impact.status) for impact in stored] == [ ("second-breakdown-job", "computing") ] @@ -787,7 +787,7 @@ def test_a_failed_national_job_is_still_replayed( assert replayed == expected mock_simulation_entrypoint.run.assert_called_once() mock_simulation_entrypoint.get_execution_by_id.assert_called_once() - stored = economy_service._reform_impacts.get_recent_reform_impacts(5) + stored = economy_service._reform_impacts._cache.recent(5) assert [impact.status for impact in stored] == ["error"] @@ -832,7 +832,7 @@ def test_a_breakdown_job_without_a_local_areas_result_is_not_stored( assert within_two_minutes == expected assert retried == EconomicImpactResult.computing() assert mock_simulation_entrypoint.run.call_count == 2 - stored = economy_service._reform_impacts.get_recent_reform_impacts(5) + stored = economy_service._reform_impacts._cache.recent(5) assert [(impact.execution_id, impact.status) for impact in stored] == [ ("second-breakdown-job", "computing") ] diff --git a/tests/unit/services/test_local_data_service_boundaries.py b/tests/unit/services/test_local_data_service_boundaries.py index 5086ff111..4aa1d3bc1 100644 --- a/tests/unit/services/test_local_data_service_boundaries.py +++ b/tests/unit/services/test_local_data_service_boundaries.py @@ -30,7 +30,6 @@ def test_local_data_services_do_not_issue_queries_directly(module_name): ( ReformImpactsService, ( - "get_recent_reform_impacts", "get_all_reform_impacts", "get_all_reform_impacts_by_options_hash_prefix", "set_reform_impact", @@ -51,12 +50,14 @@ def test_local_service_public_methods_do_not_accept_persistence( assert "session_factory" not in parameters -def test_reform_impact_route_does_not_manage_sessions(): - source = (SERVICE_ROOT.parent / "routes" / "reform_impact_routes.py").read_text( - encoding="utf-8" - ) - assert "get_v1_session_factory" not in source - assert "sqlalchemy" not in source +def test_flask_routes_do_not_manage_sessions(): + route_modules = sorted((SERVICE_ROOT.parent / "routes").glob("*.py")) + + assert route_modules + for route_module in route_modules: + source = route_module.read_text(encoding="utf-8") + assert "get_v1_session_factory" not in source, route_module.name + assert "sqlalchemy" not in source, route_module.name def test_economy_service_delegates_persistence_without_opening_sessions(): diff --git a/tests/unit/services/test_metadata_service.py b/tests/unit/services/test_metadata_service.py index 3a9b8afaa..e5cfe8de5 100644 --- a/tests/unit/services/test_metadata_service.py +++ b/tests/unit/services/test_metadata_service.py @@ -63,9 +63,6 @@ def test_get_metadata_empty_country_id(self): "state/fl", ], ), - ("ca", 3, ["ca"]), - ("ng", 4, ["ng"]), - ("il", 5, ["il"]), ], ) def test_verify_metadata_for_given_country( diff --git a/tests/unit/services/test_orm_service_boundaries.py b/tests/unit/services/test_orm_service_boundaries.py index b8d49a12a..48c114428 100644 --- a/tests/unit/services/test_orm_service_boundaries.py +++ b/tests/unit/services/test_orm_service_boundaries.py @@ -9,7 +9,7 @@ @pytest.mark.parametrize( "module_name", - ["household_service.py", "policy_service.py", "user_service.py"], + ["household_service.py", "policy_service.py"], ) def test_migrated_services_do_not_issue_queries_directly(module_name): source = (SERVICE_ROOT / module_name).read_text(encoding="utf-8") @@ -19,7 +19,7 @@ def test_migrated_services_do_not_issue_queries_directly(module_name): @pytest.mark.parametrize( "module_name", - ["household_service.py", "policy_service.py", "user_service.py"], + ["household_service.py", "policy_service.py"], ) def test_migrated_services_use_sessions_and_mapped_models(module_name): source = (SERVICE_ROOT / module_name).read_text(encoding="utf-8") diff --git a/tests/unit/services/test_policy_service.py b/tests/unit/services/test_policy_service.py index 8077073d6..a15230b3e 100644 --- a/tests/unit/services/test_policy_service.py +++ b/tests/unit/services/test_policy_service.py @@ -1,4 +1,5 @@ import pytest +from sqlalchemy import select from sqlalchemy.exc import SQLAlchemyError from policyengine_api.constants import COUNTRY_PACKAGE_VERSIONS @@ -53,47 +54,6 @@ def test_get_policy_json_returns_python_object(service): } -def test_search_policies_filters_and_deduplicates(service, orm_session_factory): - with orm_session_factory.begin() as session: - session.add_all( - [ - Policy( - id=31, - country_id="us", - label="Tax reform", - api_version="1", - policy_json={}, - policy_hash="same-hash", - ), - Policy( - id=32, - country_id="us", - label="Tax reform", - api_version="1", - policy_json={"different": True}, - policy_hash="same-hash", - ), - Policy( - id=33, - country_id="us", - label="Benefit reform", - api_version="1", - policy_json={}, - policy_hash="other-hash", - ), - ] - ) - - all_results = service.search_policies("us", "Tax", unique_only=False) - unique_results = service.search_policies("us", "Tax", unique_only=True) - escaped_wildcard_results = service.search_policies("us", "Tax%", unique_only=False) - - assert len(all_results) == 2 - assert len(unique_results) == 1 - assert unique_results[0].label == "Tax reform" - assert escaped_wildcard_results == [] - - def test_set_policy_adds_mapped_entity(service, monkeypatch): monkeypatch.setattr( "policyengine_api.services.policy_service.hash_object", @@ -196,13 +156,13 @@ def test_set_policy_does_not_build_v2_snapshot_unless_requested( ) result = service.set_policy( - "ca", - "Canadian policy", + "uk", + "UK policy", {"parameter": 1}, ) assert result.snapshot is None - assert service.get_policy("ca", result.policy_id) is not None + assert service.get_policy("uk", result.policy_id) is not None def test_set_policy_rejects_invalid_country(service): @@ -243,7 +203,11 @@ def test_mirror_snapshot_read_failure_rolls_back_new_v1_policy( with pytest.raises(SQLAlchemyError, match="snapshot read failed"): service.set_policy("us", "Failed snapshot", {}, prepare_for_mirroring=True) - assert service.search_policies("us", "Failed snapshot") == [] + with orm_session_factory() as session: + stored_labels = session.scalars( + select(Policy.label).where(Policy.label == "Failed snapshot") + ).all() + assert stored_labels == [] retry = service.set_policy("us", "Failed snapshot", {}, prepare_for_mirroring=True) assert retry.is_existing_policy is False assert retry.snapshot == service.get_policy_snapshot("us", retry.policy_id) diff --git a/tests/unit/services/test_policy_service_parameter_paths.py b/tests/unit/services/test_policy_service_parameter_paths.py index dbf2a893f..dfad9c664 100644 --- a/tests/unit/services/test_policy_service_parameter_paths.py +++ b/tests/unit/services/test_policy_service_parameter_paths.py @@ -1,6 +1,7 @@ """PolicyService checks a new policy's paths, period keys and values.""" import pytest +from sqlalchemy import select from policyengine_api.data.v1_models import Policy from policyengine_api.services.policy_service import ( @@ -23,6 +24,18 @@ def __call__(self, country_id: str) -> frozenset[str]: return SETTABLE_NAMES +def _stored_us_policy_labels(orm_session_factory, label: str) -> list[str]: + """Return the labels of every stored US policy with exactly this label.""" + with orm_session_factory() as session: + return list( + session.scalars( + select(Policy.label).where( + Policy.country_id == "us", Policy.label == label + ) + ) + ) + + @pytest.fixture def parameter_catalog(): return RecordingParameterCatalog() @@ -38,6 +51,7 @@ def service(orm_session_factory, parameter_catalog): def test_new_policy_with_paths_outside_the_catalog_is_refused_and_not_stored( service, + orm_session_factory, parameter_catalog, ): with pytest.raises(InvalidPolicyDataError) as raised: @@ -58,7 +72,7 @@ def test_new_policy_with_paths_outside_the_catalog_is_refused_and_not_stored( "data.gov.x", ] assert parameter_catalog.looked_up_country_ids == ["us"] - assert service.search_policies("us", "Refused") == [] + assert _stored_us_policy_labels(orm_session_factory, "Refused") == [] def test_new_policy_error_holds_refused_paths_then_refused_periods_and_values( @@ -165,4 +179,4 @@ def test_new_policy_for_a_country_without_a_loaded_model_is_refused( ) assert raised.value.parameter_paths == ("gov.tax.rate",) - assert service_without_model.search_policies("us", "No loaded model") == [] + assert _stored_us_policy_labels(orm_session_factory, "No loaded model") == [] diff --git a/tests/unit/services/test_reform_impacts_service.py b/tests/unit/services/test_reform_impacts_service.py index df938ba91..0ceface2d 100644 --- a/tests/unit/services/test_reform_impacts_service.py +++ b/tests/unit/services/test_reform_impacts_service.py @@ -77,26 +77,6 @@ def test_set_reform_impact_fails_closed_when_execution_pointer_is_not_stored(): ) -def test_get_recent_reform_impacts_orders_and_limits_results(service): - older = _create_impact( - service, - execution_id="older-job", - options_hash="older", - day=1, - ) - newer = _create_impact( - service, - execution_id="newer-job", - options_hash="newer", - day=2, - ) - - assert [ - impact.reform_impact_id for impact in service.get_recent_reform_impacts(1) - ] == [newer.reform_impact_id] - assert older.reform_impact_id != newer.reform_impact_id - - def test_reform_impact_start_claim_is_exclusive_and_releasable(service): arguments = { "identity": ReformImpactIdentity( @@ -190,7 +170,7 @@ def test_reform_impact_service_deletes_only_matching_computing_rows( service.delete_reform_impact(SCOPE, "delete-hash") - remaining = service.get_recent_reform_impacts(10) + remaining = service._cache.recent(10) assert all(impact.execution_id != "delete-job" for impact in remaining) assert any( impact.reform_impact_id == retained.reform_impact_id for impact in remaining diff --git a/tests/unit/services/test_service_owned_session_architecture.py b/tests/unit/services/test_service_owned_session_architecture.py index a2829777a..2e150e8b7 100644 --- a/tests/unit/services/test_service_owned_session_architecture.py +++ b/tests/unit/services/test_service_owned_session_architecture.py @@ -14,7 +14,6 @@ from policyengine_api.services.report_output_service import ReportOutputService from policyengine_api.services.simulation_service import SimulationService from policyengine_api.services.user_policy_service import UserPolicyService -from policyengine_api.services.user_service import UserService PROJECT_ROOT = Path(__file__).parents[3] PACKAGE_ROOT = PROJECT_ROOT / "policyengine_api" @@ -33,7 +32,7 @@ ), ( PolicyService, - ("get_policy", "get_policy_json", "search_policies", "set_policy"), + ("get_policy", "get_policy_json", "set_policy"), ), ( UserPolicyService, @@ -43,7 +42,6 @@ "update_user_policy", ), ), - (UserService, ("get_profile", "create_profile", "update_profile")), ( SimulationService, ( @@ -63,7 +61,6 @@ ( ReformImpactsService, ( - "get_recent_reform_impacts", "get_all_reform_impacts", "get_all_reform_impacts_by_options_hash_prefix", "set_reform_impact", diff --git a/tests/unit/services/test_service_owned_sessions.py b/tests/unit/services/test_service_owned_sessions.py index 5e372b51c..49c35effc 100644 --- a/tests/unit/services/test_service_owned_sessions.py +++ b/tests/unit/services/test_service_owned_sessions.py @@ -11,7 +11,6 @@ ) from policyengine_api.services.policy_service import PolicyService from policyengine_api.services.simulation_service import SimulationService -from policyengine_api.services.user_service import UserService ROUTE_ROOT = Path(__file__).parents[3] / "policyengine_api" / "routes" @@ -25,7 +24,7 @@ ), ( PolicyService, - ("get_policy", "get_policy_json", "search_policies", "set_policy"), + ("get_policy", "get_policy_json", "set_policy"), ), ( SimulationService, @@ -35,7 +34,6 @@ "update_simulation", ), ), - (UserService, ("get_profile", "create_profile", "update_profile")), ], ) def test_core_service_public_methods_do_not_accept_sessions( @@ -54,7 +52,6 @@ def test_core_service_public_methods_do_not_accept_sessions( "household_routes.py", "policy_routes.py", "simulation_routes.py", - "user_profile_routes.py", ], ) def test_core_routes_do_not_manage_sessions(module_name): diff --git a/tests/unit/services/test_user_policy_service.py b/tests/unit/services/test_user_policy_service.py index d3c7500d9..4a2444fd4 100644 --- a/tests/unit/services/test_user_policy_service.py +++ b/tests/unit/services/test_user_policy_service.py @@ -143,14 +143,13 @@ def test_saved_policy_event_processing_has_only_request_path_callers(): for path, source in sources.items() if ".process_pending_mirror_events(" in source } == {"services/user_policy_mirroring.py"} + # The v1 saved-policy routes were the only request path; with them removed, + # nothing outside the copying module processes saved-policy events. assert { path for path, source in sources.items() if "mirror_pending_user_policy_events_after_commit(" in source - } == { - "routes/policy_routes.py", - "services/user_policy_mirroring.py", - } + } == {"services/user_policy_mirroring.py"} def test_create_reuse_list_and_update_user_policy(orm_session_factory): diff --git a/tests/unit/services/test_user_service.py b/tests/unit/services/test_user_service.py deleted file mode 100644 index 421ff11a2..000000000 --- a/tests/unit/services/test_user_service.py +++ /dev/null @@ -1,88 +0,0 @@ -import pytest - -from policyengine_api.data.v1_models import UserProfile -from policyengine_api.services.user_service import UserService -from tests.fixtures.services.user_service import valid_user_record - -pytest_plugins = ["tests.fixtures.services.user_service"] - - -@pytest.fixture -def service(orm_session_factory): - return UserService(orm_session_factory) - - -def test_get_profile_requires_an_identifier(service): - with pytest.raises( - ValueError, - match="you must specify either auth0_id or user_id", - ): - service.get_profile() - - -def test_get_profile_returns_none_for_unknown_auth0_id(service): - assert service.get_profile(auth0_id="missing") is None - - -@pytest.mark.usefixtures("existing_user_profile") -def test_get_profile_returns_mapped_entity_by_either_identifier( - service, -): - by_auth0 = service.get_profile( - auth0_id=valid_user_record["auth0_id"], - ) - by_id = service.get_profile( - user_id=valid_user_record["user_id"], - ) - - assert isinstance(by_auth0, UserProfile) - assert by_auth0.user_id == by_id.user_id - assert by_auth0.username == valid_user_record["username"] - - -def test_create_profile_returns_existing_entity_for_duplicate_auth0_id(service): - created, profile = service.create_profile( - "us", - "auth0|duplicate", - "first", - 1, - ) - duplicate_created, duplicate = service.create_profile( - "uk", - "auth0|duplicate", - "second", - 2, - ) - - assert created is True - assert duplicate_created is False - assert duplicate.user_id == profile.user_id - assert duplicate.username == "first" - - -def test_update_profile_returns_none_for_missing_entity(service): - assert service.update_profile(999, "uk", "missing", 2) is None - - -@pytest.mark.usefixtures("existing_user_profile") -def test_update_profile_only_changes_non_null_fields( - service, -): - profile = service.update_profile( - valid_user_record["user_id"], - "uk", - None, - valid_user_record["user_since"] + 1, - ) - - assert profile.primary_country == "uk" - assert profile.username == valid_user_record["username"] - assert profile.user_since == valid_user_record["user_since"] + 1 - - -def test_update_profile_requires_user_id(service): - with pytest.raises( - ValueError, - match="you must specify either auth0_id or user_id", - ): - service.update_profile(None, "us", "name", 1) diff --git a/tests/unit/test_alembic_workflows.py b/tests/unit/test_alembic_workflows.py index de029defa..73f44fbf9 100644 --- a/tests/unit/test_alembic_workflows.py +++ b/tests/unit/test_alembic_workflows.py @@ -238,7 +238,9 @@ def test_reusable_v2_integration_check_uses_disposable_databases_and_coverage(): assert "test_v2_catalog_publication_qualification.py" in workflow assert "RUN_V2_CATALOG_PUBLICATION_QUALIFICATION" in workflow assert "test_runtime_cache_redis.py" in workflow - assert "uv sync --frozen" in workflow + # The steps run coverage and pytest, which only the dev extra declares; + # the default environment no longer brings coverage in. + assert "uv sync --frozen --extra dev" in workflow assert workflow.count("coverage run --branch") == 1 assert workflow.count("coverage run -a --branch") == 8 assert "coverage xml -i -o coverage-v2.xml" in workflow diff --git a/tests/unit/test_asgi_factory.py b/tests/unit/test_asgi_factory.py index 3f221e35c..6dbda05cb 100644 --- a/tests/unit/test_asgi_factory.py +++ b/tests/unit/test_asgi_factory.py @@ -1,5 +1,4 @@ import importlib -import json import sys import threading from concurrent.futures import ThreadPoolExecutor @@ -59,19 +58,15 @@ def stored_observability_id(): ) return response - @app.get("/readiness-check") - def readiness_check(): - return Response("OK", status=200, mimetype="text/plain") - - @app.get("/liveness-check") - def liveness_check(): - return Response("OK", status=200, mimetype="text/plain") + return app - @app.get("/specification") - def specification(): - return jsonify({"openapi": "3.0.0", "info": {"title": "fallback"}}) - return app +@pytest.fixture(autouse=True) +def default_readiness_probe_reports_ready(monkeypatch): + """Report ready from the default probe, which needs deployed configuration.""" + monkeypatch.setattr( + "policyengine_api.fastapi_routes.dependencies.is_ready", lambda: True + ) class _HealthySimulationGateway: @@ -92,20 +87,11 @@ def _stage6_settings( ) -def _stage6_dependencies( - *, - ready: bool = True, - specification: dict | None = None, -): - document = specification or { - "openapi": "3.1.0", - "info": {"title": "native specification"}, - } +def _stage6_dependencies(*, ready: bool = True): return asgi_factory.NativeRouteDependencies( readiness_probe=lambda: ready, gateway_client_factory=_HealthySimulationGateway, metadata_reader_factory=lambda: None, - specification_provider=lambda: document, ) @@ -126,102 +112,91 @@ def test_asgi_app_rejects_an_invalid_stage6_route_selector(monkeypatch): create_asgi_app(create_test_wsgi_app()) -def test_native_liveness_and_ready_readiness_preserve_legacy_contract(): +def test_health_returns_503_until_the_readiness_probe_passes(): client = TestClient( create_asgi_app( create_test_wsgi_app(), route_settings=_stage6_settings(health=RouteImplementation.FASTAPI_NATIVE), - dependencies=_stage6_dependencies(ready=True), + dependencies=_stage6_dependencies(ready=False), ) ) - liveness = client.get("/liveness-check") - readiness = client.get("/readiness-check") + response = client.get("/health") - assert liveness.status_code == 200 - assert liveness.content == b"OK" - assert liveness.headers["content-type"].startswith("text/plain") - assert readiness.status_code == 200 - assert readiness.content == b"OK" - assert readiness.headers["content-type"].startswith("text/plain") + assert response.status_code == 503 + assert response.json() == {"status": "not_ready"} + assert response.headers["content-type"].startswith("application/json") -def test_native_readiness_is_gated_while_liveness_remains_healthy(): +@pytest.mark.parametrize(("ready", "expected_status"), [(True, 200), (False, 503)]) +def test_health_answers_head_requests_like_get(ready, expected_status): + """Uptime monitors and `curl -I` send HEAD; the removed checks answered it.""" client = TestClient( create_asgi_app( create_test_wsgi_app(), - route_settings=_stage6_settings(health=RouteImplementation.FASTAPI_NATIVE), - dependencies=_stage6_dependencies(ready=False), + route_settings=_stage6_settings(), + dependencies=_stage6_dependencies(ready=ready), ) ) - liveness = client.get("/liveness-check") - readiness = client.get("/readiness-check") + response = client.head("/health") - assert liveness.status_code == 200 - assert liveness.content == b"OK" - assert readiness.status_code == 503 - assert readiness.content == b"NOT READY" + assert response.status_code == expected_status + assert response.content == b"" -def test_health_fallback_ignores_native_readiness_dependency(): - client = TestClient( - create_asgi_app( - create_test_wsgi_app(), - route_settings=_stage6_settings(), - dependencies=_stage6_dependencies(ready=False), - ) +def test_default_health_probe_reports_warmup_and_configuration_readiness( + monkeypatch, +): + monkeypatch.setattr( + "policyengine_api.fastapi_routes.dependencies.is_ready", lambda: False ) + client = TestClient(create_asgi_app(create_test_wsgi_app())) - assert client.get("/liveness-check").content == b"OK" - readiness = client.get("/readiness-check") - assert readiness.status_code == 200 - assert readiness.content == b"OK" + assert client.get("/health").status_code == 503 -def test_native_specification_returns_the_shared_document(): - document = { - "openapi": "3.0.0", - "info": {"title": "PolicyEngine API", "version": "test-version"}, - "paths": {"/us/metadata": {"get": {"summary": "Metadata"}}}, - } +@pytest.mark.parametrize( + "health_implementation", + [ + RouteImplementation.FLASK_FALLBACK, + RouteImplementation.FASTAPI_NATIVE, + ], +) +def test_liveness_and_readiness_checks_are_removed(health_implementation): client = TestClient( create_asgi_app( create_test_wsgi_app(), - route_settings=_stage6_settings( - specification=RouteImplementation.FASTAPI_NATIVE - ), - dependencies=_stage6_dependencies(specification=document), + route_settings=_stage6_settings(health=health_implementation), + dependencies=_stage6_dependencies(ready=True), ) ) - response = client.get("/specification") - - assert response.status_code == 200 - assert response.json() == document - assert response.headers["content-type"].startswith("application/json") + assert client.get("/liveness-check").status_code == 404 + assert client.get("/readiness-check").status_code == 404 -def test_specification_falls_through_to_flask_independently(): +@pytest.mark.parametrize( + "specification_implementation", + [ + RouteImplementation.FLASK_FALLBACK, + RouteImplementation.FASTAPI_NATIVE, + ], +) +def test_legacy_specification_and_home_routes_are_not_served( + specification_implementation, +): client = TestClient( create_asgi_app( create_test_wsgi_app(), - route_settings=_stage6_settings( - health=RouteImplementation.FASTAPI_NATIVE, - specification=RouteImplementation.FLASK_FALLBACK, - ), - dependencies=_stage6_dependencies(ready=False), + route_settings=_stage6_settings(specification=specification_implementation), + dependencies=_stage6_dependencies(), ) ) - readiness = client.get("/readiness-check") - specification = client.get("/specification") - - assert readiness.status_code == 503 - assert specification.json() == { - "openapi": "3.0.0", - "info": {"title": "fallback"}, - } + assert client.get("/specification").status_code == 404 + assert client.get("/").status_code == 404 + assert client.get("/v2/openapi.json").status_code == 200 @pytest.mark.parametrize( @@ -246,7 +221,7 @@ def test_preexisting_core_health_route_remains_native(health_implementation): assert response.json() == {"status": "healthy"} -def test_native_readiness_uses_shared_asgi_middleware(): +def test_health_uses_shared_asgi_middleware(): client = TestClient( create_asgi_app( create_test_wsgi_app(), @@ -256,7 +231,7 @@ def test_native_readiness_uses_shared_asgi_middleware(): ) response = client.get( - "/readiness-check", + "/health", headers={ REQUEST_ID_HEADER: "stage6-request", "Origin": "https://app.policyengine.org", @@ -526,7 +501,7 @@ def test_lifespan_closes_runtime_resources_on_shutdown(): shutdown_callback=close_runtime_resources, ) ) as client: - assert client.get("/liveness-check").status_code == 200 + assert client.get("/health").status_code == 200 close_runtime_resources.assert_not_called() close_runtime_resources.assert_called_once_with() @@ -778,7 +753,7 @@ def raise_unhandled_error() -> bool: raise_server_exceptions=False, ) response = client.get( - "/readiness-check", + "/health", headers={ "Origin": "https://app.policyengine.org", REQUEST_ID_HEADER: "failed-request-id", @@ -827,23 +802,3 @@ def test_public_simulation_gateway_health_probe_reports_failure(): response = client.get("/simulation-gateway-check") assert response.status_code == 503 - - -def test_existing_health_and_specification_paths_fall_back_to_flask(): - client = TestClient(create_asgi_app(create_test_wsgi_app())) - - readiness = client.get("/readiness-check") - liveness = client.get("/liveness-check") - specification = client.get("/specification") - - assert readiness.status_code == 200 - assert readiness.content == b"OK" - assert readiness.headers["content-type"].startswith("text/plain") - assert liveness.status_code == 200 - assert liveness.content == b"OK" - assert liveness.headers["content-type"].startswith("text/plain") - assert specification.status_code == 200 - assert json.loads(specification.content) == { - "openapi": "3.0.0", - "info": {"title": "fallback"}, - } diff --git a/tests/unit/test_asgi_request_lifecycle.py b/tests/unit/test_asgi_request_lifecycle.py index 3610caded..a664e61ee 100644 --- a/tests/unit/test_asgi_request_lifecycle.py +++ b/tests/unit/test_asgi_request_lifecycle.py @@ -39,7 +39,6 @@ def _native_health_app(runtime: Mock, readiness_probe=lambda: True): readiness_probe=lambda: True, gateway_client_factory=lambda: None, metadata_reader_factory=lambda: None, - specification_provider=dict, ), readiness_probe=readiness_probe, ) @@ -67,7 +66,7 @@ def test_native_request_calls_the_runtime_and_logger_in_a_fixed_order() -> None: ), ): response = TestClient(_native_health_app(timeline.runtime)).get( - "/readiness-check?country_id=uk", + "/health?country_id=uk", headers={REQUEST_ID_HEADER: "incoming-request-id"}, ) @@ -78,7 +77,7 @@ def test_native_request_calls_the_runtime_and_logger_in_a_fixed_order() -> None: begin_request = timeline.runtime.begin_request.call_args assert begin_request.kwargs["headers"][REQUEST_ID_HEADER] == "incoming-request-id" assert timeline.mock_calls == [ - call.runtime.begin_request(headers=ANY, method="GET", route="/readiness-check"), + call.runtime.begin_request(headers=ANY, method="GET", route="/health"), call.runtime.set_context(request_id="runtime-request-id"), call.runtime.set_context(observability_id=OBSERVABILITY_ID), call.runtime.response_headers(), @@ -86,14 +85,14 @@ def test_native_request_calls_the_runtime_and_logger_in_a_fixed_order() -> None: ServedRequest( request_id="runtime-request-id", method="GET", - path="/readiness-check", + path="/health", status_code=200, started_at=ANY, country_id="uk", route_impl=RouteImplementation.FASTAPI_NATIVE, ) ), - call.runtime.update_request_route("/readiness-check"), + call.runtime.update_request_route("/health"), call.runtime.update_request_status(200), call.runtime.end_request(status_code=200, error=None), ] @@ -106,7 +105,7 @@ def test_native_request_keeps_its_id_when_the_runtime_assigns_none() -> None: with patch.object(asgi_factory, "log_migration_request") as log_request: response = TestClient(_native_health_app(runtime)).get( - "/readiness-check", + "/health", headers={REQUEST_ID_HEADER: "incoming-request-id"}, ) @@ -128,26 +127,26 @@ def failing_probe() -> bool: response = TestClient( _native_health_app(timeline.runtime, readiness_probe=failing_probe), raise_server_exceptions=False, - ).get("/readiness-check", headers={REQUEST_ID_HEADER: "failed-request"}) + ).get("/health", headers={REQUEST_ID_HEADER: "failed-request"}) assert response.status_code == 500 assert response.text == "Internal Server Error" assert response.headers[REQUEST_ID_HEADER] == "failed-request" assert timeline.mock_calls == [ - call.runtime.begin_request(headers=ANY, method="GET", route="/readiness-check"), + call.runtime.begin_request(headers=ANY, method="GET", route="/health"), call.runtime.set_context(request_id="failed-request"), call.log( ServedRequest( request_id="failed-request", method="GET", - path="/readiness-check", + path="/health", status_code=500, started_at=ANY, country_id=None, route_impl=RouteImplementation.FASTAPI_NATIVE, ) ), - call.runtime.update_request_route("/readiness-check"), + call.runtime.update_request_route("/health"), call.runtime.update_request_status(500), call.runtime.end_request(status_code=500, error=route_error), ] @@ -165,7 +164,7 @@ def test_native_request_survives_a_failing_observability_context_update() -> Non asgi_factory, "current_observability_id", return_value=OBSERVABILITY_ID ), ): - response = TestClient(_native_health_app(runtime)).get("/readiness-check") + response = TestClient(_native_health_app(runtime)).get("/health") assert response.status_code == 200 assert response.headers[OBSERVABILITY_ID_HEADER] == OBSERVABILITY_ID diff --git a/tests/unit/test_capture_migration_baseline.py b/tests/unit/test_capture_migration_baseline.py index ca8537b36..3a5284bfc 100644 --- a/tests/unit/test_capture_migration_baseline.py +++ b/tests/unit/test_capture_migration_baseline.py @@ -11,7 +11,7 @@ def test_baseline_summary_computes_error_rate_and_latency(): capture_migration_baseline.ProbeResult( name="health", method="GET", - path="/readiness-check", + path="/health", status_code=200, latency_ms=10.0, ok=True, @@ -81,9 +81,7 @@ def handler(request): assert [(result.method, result.path) for result in results] == requests assert requests == [ - ("GET", "/liveness-check"), - ("GET", "/readiness-check"), - ("GET", "/specification"), + ("GET", "/health"), ("GET", "/us/metadata"), ("GET", "/uk/metadata"), ] @@ -113,9 +111,9 @@ def test_run_probes_treats_unexpected_4xx_as_failure(monkeypatch): capture_migration_baseline.run_probes("https://api.test", 1) ) - assert summary["request_count"] == 5 - assert summary["failure_count"] == 5 - assert summary["status_counts"] == {"404": 5} + assert summary["request_count"] == 3 + assert summary["failure_count"] == 3 + assert summary["status_counts"] == {"404": 3} def test_run_simulation_gateway_probe_records_completion(monkeypatch): diff --git a/tests/unit/test_cloud_run_candidate_deploy.py b/tests/unit/test_cloud_run_candidate_deploy.py index ae14259ca..7cceb7762 100644 --- a/tests/unit/test_cloud_run_candidate_deploy.py +++ b/tests/unit/test_cloud_run_candidate_deploy.py @@ -308,8 +308,9 @@ def test_deploy_cloud_run_candidate_pins_http_startup_probe(): gunicorn's master binds the port long before a worker finishes importing, so a TCP probe reports "started" while the app cannot answer and Cloud Run - routes live traffic onto it. Probing /readiness-check makes Cloud Run - withhold traffic until the app can actually serve. + routes live traffic onto it. Probing /health, which answers 503 until the + warmup has finished and the runtime configuration is valid, makes Cloud + Run withhold traffic until the app can actually serve. """ result = _run_script( ".github/scripts/deploy_cloud_run_candidate.sh", @@ -322,15 +323,13 @@ def test_deploy_cloud_run_candidate_pins_http_startup_probe(): assert result.returncode == 0, result.stderr assert "--startup-probe " in result.stdout - assert "httpGet.path=/readiness-check" in result.stdout + assert "httpGet.path=/health" in result.stdout assert "httpGet.port=8080" in result.stdout # A tcpSocket probe would reintroduce routing-before-ready. assert "tcpSocket" not in result.stdout probe = next( - part - for part in result.stdout.split() - if "httpGet.path=/readiness-check" in part + part for part in result.stdout.split() if "httpGet.path=/health" in part ) # The dry-run echoes the command shell-escaped, so commas arrive as "\,". settings = dict( diff --git a/tests/unit/test_cloud_run_push_workflow.py b/tests/unit/test_cloud_run_push_workflow.py index df24f2484..d300048fd 100644 --- a/tests/unit/test_cloud_run_push_workflow.py +++ b/tests/unit/test_cloud_run_push_workflow.py @@ -80,6 +80,45 @@ def test_staging_and_production_use_distinct_cloud_run_runtime_identities(): assert "GCP_CLOUD_RUN_RUNTIME_SERVICE_ACCOUNT" in production +WORKFLOW_HEALTH_CHECK_CALL = re.compile( + r"run: bash \.github/scripts/" + r"(?Phealth_check|restore_cloud_run_revision)\.sh " + r'"(?P[^"]+)"' +) +# Staging candidate, live-test candidate, staging stable URL and restore, +# production candidate, production stable URL and restore. +PUSH_WORKFLOW_HEALTH_CHECK_COUNT = 7 + + +def test_every_push_workflow_health_check_polls_health(): + """Every deployment health check and revision restore polls ``/health``. + + ``/liveness-check`` and ``/readiness-check`` are removed, so a check left + on either path would wait out its timeout and fail the deployment, and a + restore would report a healthy previous revision as failed. + """ + workflow = _push_workflow() + checked_urls = [ + (call.group("script_name"), call.group("checked_url")) + for call in WORKFLOW_HEALTH_CHECK_CALL.finditer(workflow) + ] + + assert len(checked_urls) == PUSH_WORKFLOW_HEALTH_CHECK_COUNT, checked_urls + assert {script_name for script_name, _url in checked_urls} == { + "health_check", + "restore_cloud_run_revision", + } + for script_name, checked_url in checked_urls: + assert checked_url.endswith("}}/health"), (script_name, checked_url) + for workflow_path in sorted((REPO / ".github/workflows").glob("*.yml")): + workflow_text = workflow_path.read_text(encoding="utf-8") + for removed_probe_path in ("/liveness-check", "/readiness-check"): + assert removed_probe_path not in workflow_text, ( + workflow_path.name, + removed_probe_path, + ) + + def test_push_workflow_pins_cloud_run_scaling_per_job(): workflow = _push_workflow() staging_deploy = _workflow_job_block(workflow, "deploy-cloud-run-staging") @@ -178,7 +217,7 @@ def _assert_live_tests_run_in_parallel_after_readiness( assert "run: make install" in parallel_live_tests live_readiness_command = ( 'bash .github/scripts/health_check.sh "' - '${{ needs.deploy-cloud-run-staging.outputs.url }}/readiness-check"' + '${{ needs.deploy-cloud-run-staging.outputs.url }}/health"' ) assert live_readiness_command in parallel_live_tests assert parallel_live_tests.index( @@ -224,7 +263,7 @@ def _assert_promotion_verifies_the_tested_candidate( ) assert "Restore previous Cloud Run staging revision" in cloud_run_promotion assert ( - "${{ needs.deploy-cloud-run-staging.outputs.stable_url }}/readiness-check" + "${{ needs.deploy-cloud-run-staging.outputs.stable_url }}/health" in cloud_run_promotion ) verify_index = cloud_run_promotion.index( @@ -405,10 +444,7 @@ def test_push_workflow_promotes_production_cloud_run_after_candidate_smoke(): "${{ steps.previous.outputs.revision }}" in cloud_run_production ) assert "Restore previous Cloud Run production revision" in cloud_run_production - assert ( - "${{ steps.previous.outputs.stable_url }}/readiness-check" - in cloud_run_production - ) + assert "${{ steps.previous.outputs.stable_url }}/health" in cloud_run_production verify_index = cloud_run_production.index( "Verify exact tested Cloud Run production candidate" ) diff --git a/tests/unit/test_deploy_pipeline_scripts.py b/tests/unit/test_deploy_pipeline_scripts.py index c2c0db5cb..5e193863d 100644 --- a/tests/unit/test_deploy_pipeline_scripts.py +++ b/tests/unit/test_deploy_pipeline_scripts.py @@ -259,7 +259,7 @@ def test_restore_rolls_back_then_checks_health(tmp_path: Path) -> None: result = _run( "restore_cloud_run_revision.sh", - "https://stable.example/readiness-check", + "https://stable.example/health", env={ "CLOUD_RUN_SCRIPTS_DIR": str(scripts), "CLOUD_RUN_TARGET_REVISION": "rev-1", @@ -269,7 +269,7 @@ def test_restore_rolls_back_then_checks_health(tmp_path: Path) -> None: assert result.returncode == 0, result.stderr assert _lines(log) == [ "set_cloud_run_revision.sh target=rev-1", - "health_check.sh https://stable.example/readiness-check target=rev-1", + "health_check.sh https://stable.example/health target=rev-1", ] @@ -278,7 +278,7 @@ def test_restore_skips_health_check_when_rollback_fails(tmp_path: Path) -> None: result = _run( "restore_cloud_run_revision.sh", - "https://stable.example/readiness-check", + "https://stable.example/health", env={"CLOUD_RUN_SCRIPTS_DIR": str(scripts)}, ) diff --git a/tests/unit/test_measure_cloud_run_runtime.py b/tests/unit/test_measure_cloud_run_runtime.py index 56d254db2..0e4efcd03 100644 --- a/tests/unit/test_measure_cloud_run_runtime.py +++ b/tests/unit/test_measure_cloud_run_runtime.py @@ -81,7 +81,7 @@ async def wait() -> float: return await load_generator._wait_for_readiness(client, 60.0) assert asyncio.run(wait()) == 3.0 - assert [path for path, _timeout in requests] == ["/readiness-check"] * 3 + assert [path for path, _timeout in requests] == ["/health"] * 3 assert requests[0][1]["read"] == 20.0 assert sleeps == [5.0, 5.0] @@ -98,7 +98,7 @@ async def wait() -> float: ) as client: return await load_generator._wait_for_readiness(client, 0.5) - with pytest.raises(RuntimeError, match=r"^/readiness-check not ready after 0s$"): + with pytest.raises(RuntimeError, match=r"^/health not ready after 0s$"): asyncio.run(wait()) @@ -157,7 +157,7 @@ def handler(request: httpx.Request) -> httpx.Response: request.extensions["timeout"]["read"], ) ) - if request.url.path == "/readiness-check": + if request.url.path == "/health": return httpx.Response(200) outcome = next(outcomes) if isinstance(outcome, Exception): @@ -209,7 +209,7 @@ def test_load_cell_runs_seeded_clients_and_summarizes_each_endpoint( # The mock transport never yields, so the first client runs to the deadline # before the second one starts. assert requests == [ - ("GET", "/readiness-check", None, None, 20.0), + ("GET", "/health", None, None, 20.0), ("POST", "/us/calculate", "01234567-cell-a-throwaway", 7, 290.0), ("POST", "/us/calculate", "01234567-cell-a-0-1", 7, 290.0), ("GET", "/us/metadata", None, None, 60.0), @@ -267,7 +267,7 @@ def test_load_cell_without_cache_busting_repeats_the_same_payload( ) assert requests == [ - ("GET", "/readiness-check", None, None, 20.0), + ("GET", "/health", None, None, 20.0), ("POST", "/us/calculate", "01234567-cell-a-throwaway", 7, 290.0), *[("POST", "/us/calculate", None, 7, 290.0)] * 7, ] diff --git a/tests/unit/test_migration_contract_artifacts.py b/tests/unit/test_migration_contract_artifacts.py index 62ddabd71..98f66265d 100644 --- a/tests/unit/test_migration_contract_artifacts.py +++ b/tests/unit/test_migration_contract_artifacts.py @@ -13,15 +13,14 @@ def test_migration_contract_payload_summarizes_route_contracts(): assert payload["version"] == 1 assert payload["metadata"] == { "route_group_count": 9, - "workflow_count": 17, - "request_count": 90, + "workflow_count": 16, + "request_count": 86, "db_entity_count": 6, "sim_flow_count": 3, } assert {workflow["name"] for workflow in payload["workflows"]} == { "policy_save_search", "policy_resources_v2", - "saved_policy_v1_compatibility", "user_policy_associations_v2", "household_save_read", "household_resources_v2", diff --git a/tests/unit/test_openapi_report_routes.py b/tests/unit/test_openapi_report_routes.py deleted file mode 100644 index 517c55d07..000000000 --- a/tests/unit/test_openapi_report_routes.py +++ /dev/null @@ -1,34 +0,0 @@ -from pathlib import Path - -import yaml - - -def _load_spec() -> dict: - spec_path = Path(__file__).parents[2] / "policyengine_api" / "openapi_spec.yaml" - return yaml.safe_load(spec_path.read_text(encoding="utf-8")) - - -def _result_properties(response_schema: dict) -> dict: - return response_schema["content"]["application/json"]["schema"]["properties"][ - "result" - ]["properties"] - - -def test_report_output_openapi_responses_include_run_timestamp_fields(): - spec = _load_spec() - paths = spec["paths"] - timestamp_fields = {"requested_at", "started_at", "finished_at"} - - response_schemas = [ - paths["/{country_id}/report"]["post"]["responses"]["200"], - paths["/{country_id}/report"]["post"]["responses"]["201"], - paths["/{country_id}/report"]["patch"]["responses"]["200"], - paths["/{country_id}/report/{report_id}"]["get"]["responses"]["200"], - ] - - for response_schema in response_schemas: - result_properties = _result_properties(response_schema) - assert timestamp_fields.issubset(result_properties) - for field in timestamp_fields: - assert result_properties[field]["type"] == "string" - assert result_properties[field]["nullable"] is True diff --git a/tests/unit/test_openapi_simulation.py b/tests/unit/test_openapi_simulation.py deleted file mode 100644 index 099a3ed43..000000000 --- a/tests/unit/test_openapi_simulation.py +++ /dev/null @@ -1,316 +0,0 @@ -"""Served simulation schemas agree with real Flask persistence envelopes.""" - -import json -from copy import deepcopy - -import jsonschema -import pytest -from fastapi import FastAPI -from fastapi.testclient import TestClient -from flask import Flask - -from policyengine_api.data.v1_models import Simulation -from policyengine_api.fastapi_routes.dependencies import NativeRouteDependencies -from policyengine_api.fastapi_routes.specification import build_specification_router -from policyengine_api.routes import simulation_routes -from policyengine_api.routes.error_routes import error_bp -from policyengine_api.routes.system_routes import system_bp -from policyengine_api.services.simulation_service import SimulationService - - -@pytest.fixture -def simulation_http(orm_session_factory, monkeypatch): - app = Flask(__name__) - app.config["TESTING"] = True - app.register_blueprint(error_bp) - app.register_blueprint(system_bp) - app.register_blueprint(simulation_routes.simulation_bp) - monkeypatch.setattr( - simulation_routes, - "simulation_service", - SimulationService(orm_session_factory), - ) - return app.test_client() - - -def _json_schema(value): - """Translate OpenAPI 3.0 nullable for the installed JSON Schema validator.""" - if isinstance(value, list): - return [_json_schema(item) for item in value] - if not isinstance(value, dict): - return value - result = {name: _json_schema(item) for name, item in value.items()} - if result.pop("nullable", False): - return {"anyOf": [result, {"type": "null"}]} - return result - - -def _validate(spec, schema, payload): - document = _json_schema({**deepcopy(schema), "components": spec["components"]}) - jsonschema.Draft7Validator(document).validate(payload) - - -def _response_schema(spec, path, method, status): - return spec["paths"][path][method]["responses"][str(status)]["content"][ - "application/json" - ]["schema"] - - -def test_actual_flask_and_native_specification_publish_simulation_operations( - simulation_http, -): - flask_response = simulation_http.get("/specification") - app = FastAPI() - app.include_router(build_specification_router(NativeRouteDependencies.defaults())) - native_response = TestClient(app).get("/specification") - assert flask_response.status_code == native_response.status_code == 200 - spec = flask_response.get_json() - assert spec == native_response.json() - collection = spec["paths"]["/{country_id}/simulation"] - detail = spec["paths"]["/{country_id}/simulation/{simulation_id}"] - assert set(collection) == {"post", "patch"} - assert set(detail) == {"get"} - assert collection["post"]["operationId"] == "create_simulation" - assert collection["patch"]["operationId"] == "update_simulation" - assert detail["get"]["operationId"] == "get_simulation" - for method in ("post", "patch"): - assert "linked household" in collection[method]["description"] - assert "SPM_SETTINGS_UNSUPPORTED" in collection[method]["description"] - assert collection[method]["requestBody"]["required"] is True - simulation_id = next( - item for item in detail["get"]["parameters"] if item["name"] == "simulation_id" - ) - assert simulation_id["in"] == "path" - assert simulation_id["required"] is True - assert simulation_id["schema"] == {"type": "integer", "minimum": 1} - - -@pytest.mark.parametrize("method", ["post", "patch"]) -@pytest.mark.parametrize("selection", [None, {}, {"geography_kind": "national"}]) -def test_simulation_schema_and_http_reject_independent_spm_selection( - simulation_http, method, selection -): - spec = simulation_http.get("/specification").get_json() - path = "/{country_id}/simulation" - operation = spec["paths"][path][method] - request_schema = operation["requestBody"]["content"]["application/json"]["schema"] - payload = ( - {"population_id": "1", "population_type": "household", "policy_id": 1} - if method == "post" - else {"id": 1, "status": "complete", "output": {}} - ) - _validate(spec, request_schema, payload) - payload["spm"] = selection - with pytest.raises(jsonschema.ValidationError): - _validate(spec, request_schema, payload) - response = simulation_http.open( - "/us/simulation", method=method.upper(), json=payload - ) - assert response.status_code == 400 - assert response.get_json()["errors"][0]["code"] == "SPM_SETTINGS_UNSUPPORTED" - _validate(spec, _response_schema(spec, path, method, 400), response.get_json()) - - -@pytest.mark.parametrize("encode_output", [False, True], ids=["object", "json-string"]) -def test_simulation_persistence_responses_preserve_spm_output_and_run_fields( - simulation_http, - encode_output, -): - spec = simulation_http.get("/specification").get_json() - path = "/{country_id}/simulation" - create_payload = { - "population_id": "1", - "population_type": "household", - "policy_id": 1, - } - created = simulation_http.post("/us/simulation", json=create_payload) - assert created.status_code == 201 - pending = created.get_json()["result"] - assert pending["status"] == "pending" - assert pending["output"] is None - assert pending["error_message"] is None - assert pending["latest_successful_run_id"] is None - assert pending["active_run_id"] is not None - assert pending["simulation_spec_schema_version"] == 1 - assert isinstance(pending["simulation_spec_json"], str) - assert set(pending) == {column.name for column in Simulation.__table__.columns} - record_schema = spec["components"]["schemas"]["SimulationRecord"] - assert set(record_schema["properties"]) == set(pending) - assert set(record_schema["required"]) == set(pending) - _validate(spec, _response_schema(spec, path, "post", 201), created.get_json()) - repeated = simulation_http.post("/us/simulation", json=create_payload) - assert repeated.status_code == 200 - assert repeated.get_json()["result"] == pending - _validate(spec, _response_schema(spec, path, "post", 200), repeated.get_json()) - - output = { - "status": "ok", - "message": None, - "result": {"people": {}}, - "spm_provenance": { - "schema_version": "canonical-spm-provenance-v2", - "forecast_id": "test-forecast", - "forecast_sha256": "a" * 64, - "scenario": "baseline", - "geography_kind": "national", - "geography_id": None, - "county_vintage": "2020", - "as_of": None, - "runtime_versions": { - "policyengine": "test", - "policyengine-core": "test", - "policyengine-us": "test", - "spm-calculator": "test", - }, - "years": ["2026"], - }, - } - update_payload = { - "id": pending["id"], - "status": "complete", - "output": json.dumps(output) if encode_output else output, - } - _validate(spec, {"$ref": "#/components/schemas/SimulationUpdate"}, update_payload) - updated = simulation_http.patch("/us/simulation", json=update_payload) - assert updated.status_code == 200 - complete = updated.get_json()["result"] - assert complete["active_run_id"] is None - assert complete["latest_successful_run_id"] == pending["active_run_id"] - assert isinstance(complete["output"], str) - assert json.loads(complete["output"]) == output - _validate(spec, _response_schema(spec, path, "patch", 200), updated.get_json()) - _validate( - spec, {"$ref": "#/components/schemas/StoredHouseholdOutputEnvelope"}, output - ) - fetched = simulation_http.get(f"/us/simulation/{pending['id']}") - assert fetched.status_code == 200 - assert fetched.get_json()["message"] is None - assert fetched.get_json()["result"] == complete - _validate( - spec, - _response_schema(spec, path + "/{simulation_id}", "get", 200), - fetched.get_json(), - ) - - -def test_simulation_schema_covers_existing_null_metadata_and_error_state( - simulation_http, orm_session_factory -): - with orm_session_factory.begin() as session: - historical = Simulation( - country_id="us", - api_version="test", - population_id="1", - population_type="household", - policy_id=1, - status="error", - error_message="Calculation failed", - ) - session.add(historical) - session.flush() - simulation_id = historical.id - response = simulation_http.get(f"/us/simulation/{simulation_id}") - assert response.status_code == 200 - result = response.get_json()["result"] - for field in ( - "output", - "simulation_spec_json", - "simulation_spec_schema_version", - "active_run_id", - "latest_successful_run_id", - ): - assert result[field] is None - spec = simulation_http.get("/specification").get_json() - _validate( - spec, - _response_schema(spec, "/{country_id}/simulation/{simulation_id}", "get", 200), - response.get_json(), - ) - - -@pytest.mark.parametrize( - "payload", - [ - {"id": 1}, - {"id": 1, "api_version": "ignored"}, - {"id": 1, "status": None, "output": None, "error_message": None}, - {"id": 1, "status": "complete"}, - {"id": 1, "status": "complete", "output": None}, - {"id": 1, "status": "running"}, - ], -) -def test_update_schema_matches_http_required_fields_and_status_checks( - simulation_http, payload -): - spec = simulation_http.get("/specification").get_json() - with pytest.raises(jsonschema.ValidationError): - _validate(spec, {"$ref": "#/components/schemas/SimulationUpdate"}, payload) - response = simulation_http.patch("/us/simulation", json=payload) - assert response.status_code == 400 - _validate( - spec, - _response_schema(spec, "/{country_id}/simulation", "patch", 400), - response.get_json(), - ) - - -@pytest.mark.parametrize("method", ["get", "patch"]) -def test_documented_simulation_not_found_envelope_matches_http(simulation_http, method): - response = ( - simulation_http.get("/us/simulation/999") - if method == "get" - else simulation_http.patch( - "/us/simulation", json={"id": 999, "status": "error"} - ) - ) - assert response.status_code == 404 - spec = simulation_http.get("/specification").get_json() - path = "/{country_id}/simulation" + ("/{simulation_id}" if method == "get" else "") - _validate(spec, _response_schema(spec, path, method, 404), response.get_json()) - - -@pytest.mark.parametrize("ignored_version", [None, 42, {"ignored": True}]) -def test_update_schema_accepts_any_ignored_api_version_value( - simulation_http, ignored_version -): - created = simulation_http.post( - "/us/simulation", - json={"population_id": "1", "population_type": "household", "policy_id": 1}, - ) - assert created.status_code == 201 - record = created.get_json()["result"] - payload = {"id": record["id"], "status": "pending", "api_version": ignored_version} - response = simulation_http.patch("/us/simulation", json=payload) - assert response.status_code == 200 - assert response.get_json()["result"]["api_version"] == record["api_version"] - spec = simulation_http.get("/specification").get_json() - _validate(spec, {"$ref": "#/components/schemas/SimulationUpdate"}, payload) - - -def test_stored_scalar_string_output_is_returned_unchanged(simulation_http): - created = simulation_http.post( - "/us/simulation", - json={"population_id": "1", "population_type": "household", "policy_id": 1}, - ) - assert created.status_code == 201 - simulation_id = created.get_json()["result"]["id"] - response = simulation_http.patch( - "/us/simulation", - json={"id": simulation_id, "status": "complete", "output": json.dumps("hello")}, - ) - assert response.status_code == 200 - assert response.get_json()["result"]["output"] == "hello" - fetched = simulation_http.get(f"/us/simulation/{simulation_id}") - assert fetched.status_code == 200 - assert fetched.get_json()["result"]["output"] == "hello" - spec = simulation_http.get("/specification").get_json() - _validate( - spec, - _response_schema(spec, "/{country_id}/simulation/{simulation_id}", "get", 200), - fetched.get_json(), - ) - description = spec["components"]["schemas"]["SimulationRecord"]["properties"][ - "output" - ]["description"] - assert "scalar strings" in description - assert "unchanged" in description diff --git a/tests/unit/test_openapi_spm.py b/tests/unit/test_openapi_spm.py deleted file mode 100644 index 66e06c3f5..000000000 --- a/tests/unit/test_openapi_spm.py +++ /dev/null @@ -1,271 +0,0 @@ -"""The served legacy specification describes the canonical SPM HTTP contract.""" - -import json -from typing import TYPE_CHECKING - -from flask import Flask - -from policyengine_api.query_parameters import ( - AnnualEconomyQuery, - BudgetWindowEconomyQuery, -) -from policyengine_api.routes.system_routes import system_bp -from policyengine_api.specification import load_specification -from policyengine_api.spm import ( - SPMComparisonProvenance, - SPMExecutionProvenance, - SPMProvenance, - SPMRuntimeVersions, - SPMSelection, -) - -if TYPE_CHECKING: - from pydantic import BaseModel - - -def test_economy_query_and_receipt_schemas_match_public_http_contract(): - spec = load_specification() - paths = spec["paths"] - base = "/{country_id}/economy/{policy_id}/over/{baseline_policy_id}" - for suffix, query_model in ( - ("", AnnualEconomyQuery), - ("/budget-window", BudgetWindowEconomyQuery), - ): - operation = paths[base + suffix]["get"] - parameters = { - item["name"]: item - for item in operation["parameters"] - if item["in"] == "query" - } - assert set(parameters) == set(query_model.model_fields) - for name, field in query_model.model_fields.items(): - assert parameters[name]["required"] is field.is_required() - assert parameters["spm"]["content"]["application/json"]["schema"] == { - "$ref": "#/components/schemas/SPMSelection" - } - assert "dataset" not in parameters - assert "include_district_breakdowns" not in parameters - assert parameters["target"]["schema"]["enum"] == ( - ["general"] if suffix else ["general", "cliff"] - ) - if suffix: - assert "breakdown" not in parameters - else: - breakdown = parameters["breakdown"] - assert breakdown["required"] is False - assert breakdown["schema"]["type"] == "string" - assert breakdown["schema"]["enum"] == ["local_areas"] - assert breakdown["schema"]["nullable"] is True - assert "constituency" in breakdown["description"] - assert ( - "region one of: uk, country/england, country/scotland, " - "country/wales, country/northern_ireland;" - ) in breakdown["description"] - if suffix: - assert parameters["window_size"]["schema"]["minimum"] == 1 - assert parameters["window_size"]["schema"]["maximum"] == 75 - result = operation["responses"]["200"]["content"]["application/json"]["schema"][ - "properties" - ]["result"] - if suffix: - result = result["properties"]["annualImpacts"]["items"] - assert "spm_config" not in result["properties"] - assert result["properties"]["spm_provenance"] == { - "$ref": "#/components/schemas/SPMComparisonProvenance" - } - assert operation["responses"]["400"]["$ref"].endswith("SPMValidationError") - comparison = spec["components"]["schemas"]["SPMComparisonProvenance"] - assert comparison["required"] == ["schema_version", "baseline", "reform"] - assert comparison["properties"]["schema_version"]["enum"] == [ - "canonical-spm-comparison-v2" - ] - for side in ("baseline", "reform"): - assert comparison["properties"][side] == { - "$ref": "#/components/schemas/SPMExecutionProvenance" - } - execution = spec["components"]["schemas"]["SPMExecutionProvenance"] - assert execution["required"] == ["receipt", "execution_count"] - assert execution["properties"]["receipt"] == { - "$ref": "#/components/schemas/SPMProvenance" - } - - -def test_metadata_discovery_and_selection_constraints_are_published(): - spec = load_specification() - metadata = spec["paths"]["/{country_id}/metadata"]["get"]["responses"]["200"][ - "content" - ]["application/json"]["schema"]["properties"]["result"]["properties"] - assert metadata["spm"] == {"$ref": "#/components/schemas/SPMDiscovery"} - discovery = spec["components"]["schemas"]["SPMDiscovery"] - assert discovery["required"] == ["available"] - assert set(discovery["properties"]) == {"available", "settings_schema", "defaults"} - selection = spec["components"]["schemas"]["SPMSelection"] - assert selection["properties"]["as_of"]["format"] == "date" - assert "oneOf" in selection - assert selection["oneOf"][1]["required"] == ["geography_kind", "geography_id"] - - -def test_household_specification_exposes_creation_without_content_update(): - paths = load_specification()["paths"] - assert "put" not in paths["/{country_id}/household/{household_id}"] - description = " ".join( - paths["/{country_id}/household"]["post"]["description"].split() - ) - assert "immutable" in description.lower() - assert "new household" in description.lower() - simulation_description = " ".join( - paths["/{country_id}/simulation"]["post"]["description"].split() - ) - assert "All stored households" in simulation_description - assert "Household PUT is unsupported" in simulation_description - assert "label remains editable" not in simulation_description - - -def _assert_spm_models_match_component_schemas(schemas: dict) -> None: - models: dict[str, type[BaseModel]] = { - "SPMSelection": SPMSelection, - "SPMRuntimeVersions": SPMRuntimeVersions, - "SPMProvenance": SPMProvenance, - "SPMExecutionProvenance": SPMExecutionProvenance, - "SPMComparisonProvenance": SPMComparisonProvenance, - } - for name, model in models.items(): - schema = schemas[name] - expected_fields = { - field.alias or field_name - for field_name, field in model.model_fields.items() - } - assert set(schema["properties"]) == expected_fields - assert schema["additionalProperties"] is False - assert '"type": "null"' not in json.dumps(schema) - - -def _assert_selection_and_provenance_schemas(schemas: dict) -> None: - selection = schemas["SPMSelection"]["properties"] - assert selection["geography_kind"]["enum"] == ["county", "national", "metro"] - assert all("default" not in field for field in selection.values()) - for field in ("forecast_content_sha256", "scenario", "geography_id", "as_of"): - assert selection[field]["type"] == "string" - assert selection[field]["nullable"] is True - provenance = schemas["SPMProvenance"] - assert provenance["required"] == [ - "schema_version", - "forecast_id", - "forecast_sha256", - "scenario", - "geography_kind", - "geography_id", - "county_vintage", - "as_of", - "years", - "runtime_versions", - ] - assert provenance["properties"]["runtime_versions"] == { - "$ref": "#/components/schemas/SPMRuntimeVersions" - } - assert set(schemas["SPMRuntimeVersions"]["properties"]) == { - "policyengine", - "policyengine-core", - "policyengine-us", - "spm-calculator", - } - assert schemas["SPMRuntimeVersions"]["required"] == [ - "policyengine", - "policyengine-core", - "policyengine-us", - "spm-calculator", - ] - assert all( - "nullable" not in field - for field in schemas["SPMRuntimeVersions"]["properties"].values() - ) - assert "SPMResolvedConfiguration" not in schemas - removed_rich_fields = { - "geographies", - "requested", - "effective", - "execution_count", - "sources", - "diagnostics", - "mappings", - } - assert not removed_rich_fields.intersection(provenance["properties"]) - - -def _assert_spm_route_envelopes( - paths: dict, selection_ref: dict, provenance_ref: dict -) -> None: - for route in ("calculate", "calculate-full"): - operation = paths[f"/{{country_id}}/{route}"]["post"] - request = operation["requestBody"]["content"]["application/json"]["schema"] - assert set(request["properties"]) == {"household", "policy", "spm"} - assert request["properties"]["spm"] == selection_ref - assert ( - paths["/{country_id}/calculate-full"]["post"]["operationId"] - == "get_calculate_full" - ) - - for path, method in ( - ("/{country_id}/calculate", "post"), - ("/{country_id}/calculate-full", "post"), - ("/{country_id}/household/{household_id}/policy/{policy_id}", "get"), - ): - operation = paths[path][method] - result = operation["responses"]["200"]["content"]["application/json"]["schema"][ - "properties" - ] - assert {"status", "message", "result"} <= set(result) - assert "spm_config" not in result - assert result["spm_provenance"] == provenance_ref - assert ( - operation["responses"]["400"]["$ref"] - == "#/components/responses/SPMValidationError" - ) - - for path, method in (("/{country_id}/household", "post"),): - operation = paths[path][method] - request = operation["requestBody"]["content"]["application/json"]["schema"] - assert set(request["properties"]) == {"data", "label", "spm"} - assert request["required"] == ["data"] - assert request["properties"]["spm"] == selection_ref - assert ( - operation["responses"]["400"]["$ref"] - == "#/components/responses/SPMValidationError" - ) - - for method in ("get",): - operation = paths["/{country_id}/household/{household_id}"][method] - result = operation["responses"]["200"]["content"]["application/json"]["schema"][ - "properties" - ]["result"] - assert result["properties"]["spm"] == selection_ref - assert result["properties"]["household_json"]["type"] == "object" - - -def _assert_spm_validation_error_response(spec: dict) -> None: - error = spec["components"]["responses"]["SPMValidationError"]["content"][ - "application/json" - ]["schema"] - assert set(error["required"]) == {"status", "message"} - assert {"result", "errors"} <= set(error["properties"]) - assert error["properties"]["errors"]["items"]["required"] == ["message"] - assert "code" in error["properties"]["errors"]["items"]["properties"] - assert error["properties"]["result"]["nullable"] is True - - -def test_spm_documentation_uses_public_models_and_actual_route_envelopes(): - app = Flask(__name__) - app.register_blueprint(system_bp) - response = app.test_client().get("/specification") - assert response.status_code == 200 - spec = response.get_json() - assert spec["openapi"] == "3.0.0" - paths = spec["paths"] - schemas = spec["components"]["schemas"] - selection_ref = {"$ref": "#/components/schemas/SPMSelection"} - provenance_ref = {"$ref": "#/components/schemas/SPMProvenance"} - - _assert_spm_models_match_component_schemas(schemas) - _assert_selection_and_provenance_schemas(schemas) - _assert_spm_route_envelopes(paths, selection_ref, provenance_ref) - _assert_spm_validation_error_response(spec) diff --git a/tests/unit/test_served_countries.py b/tests/unit/test_served_countries.py new file mode 100644 index 000000000..83156069f --- /dev/null +++ b/tests/unit/test_served_countries.py @@ -0,0 +1,83 @@ +"""The Public API serves only the US and the UK. + +Canada, Nigeria and Israel are no longer served: their country packages are +not installed, their requests are rejected as unknown countries, and their +country-specific metadata branches are gone. +""" + +import tomllib +from pathlib import Path + +import pytest + +from policyengine_api.constants import COUNTRIES, COUNTRY_PACKAGE_NAMES +from policyengine_api.country_validation import ( + InvalidCountryError, + ensure_supported_country, +) +from policyengine_api.utils.get_current_law import get_current_law_policy_id + +REPO = Path(__file__).resolve().parents[2] +REMOVED_COUNTRY_IDS = ("ca", "ng", "il") +REMOVED_COUNTRY_PACKAGES = ("policyengine-canada", "policyengine-ng", "policyengine-il") + + +def _normalized(package_name: str) -> str: + return package_name.replace("_", "-").lower() + + +def test_only_the_us_and_the_uk_are_served(): + assert COUNTRIES == ("uk", "us") + assert COUNTRY_PACKAGE_NAMES == ("policyengine_uk", "policyengine_us") + + +def test_removed_country_packages_are_not_dependencies(): + project = tomllib.loads((REPO / "pyproject.toml").read_text(encoding="utf-8")) + dependency_names = { + _normalized(dependency.split("==")[0].split(">=")[0].split("[")[0]) + for dependency in project["project"]["dependencies"] + } + locked_names = { + _normalized(package["name"]) + for package in tomllib.loads((REPO / "uv.lock").read_text(encoding="utf-8"))[ + "package" + ] + } + + for package_name in REMOVED_COUNTRY_PACKAGES: + assert package_name not in dependency_names + assert package_name not in locked_names + + +@pytest.mark.parametrize("country_id", REMOVED_COUNTRY_IDS) +def test_removed_countries_are_rejected_as_unknown(country_id): + with pytest.raises(InvalidCountryError) as error: + ensure_supported_country(country_id) + + assert str(error.value) == ( + f"Country {country_id} not found. Available countries are: uk, us" + ) + + +@pytest.mark.parametrize("country_id", REMOVED_COUNTRY_IDS) +def test_removed_countries_have_no_current_law_policy(country_id): + with pytest.raises(KeyError): + get_current_law_policy_id(country_id) + + +def test_loaded_country_models_are_the_served_countries(): + from policyengine_api.country import COUNTRIES as LOADED_COUNTRIES + + assert { + country_id: country.country_package_name + for country_id, country in LOADED_COUNTRIES.items() + } == dict(zip(COUNTRIES, COUNTRY_PACKAGE_NAMES, strict=True)) + + +def test_country_metadata_has_no_removed_country_branches(): + source = (REPO / "policyengine_api/country.py").read_text(encoding="utf-8") + + for label in ("Canada", "Nigeria", "Israel"): + assert label not in source + for package_name in ("policyengine_canada", "policyengine_ng", "policyengine_il"): + assert package_name not in source diff --git a/tests/unit/test_smoke_test_local_boot.py b/tests/unit/test_smoke_test_local_boot.py index efd6f2f93..1a718623d 100644 --- a/tests/unit/test_smoke_test_local_boot.py +++ b/tests/unit/test_smoke_test_local_boot.py @@ -33,7 +33,7 @@ def sleep(self, seconds: float) -> None: class _FakeApp: - """Stands in for the Flask child process and records how it is managed.""" + """Stands in for the app's child process and records how it is managed.""" def __init__( self, *, exits_after_polls: int | None = None, ignores_terminate: bool = False @@ -63,7 +63,7 @@ def terminate(self) -> None: def wait(self, timeout: float) -> int: self.calls.append(("wait", timeout)) if self.ignores_terminate and ("kill",) not in self.calls: - command_name = "flask" + command_name = "uvicorn" raise subprocess.TimeoutExpired(command_name, timeout) if self.returncode is None: self.returncode = -15 @@ -132,28 +132,33 @@ def _header(app: _FakeApp) -> list[str]: "=== stderr ===", "app stderr", ] -LIVENESS_URL = f"http://127.0.0.1:{PORT}/liveness-check" -READINESS_URL = f"http://127.0.0.1:{PORT}/readiness-check" +HEALTH_URL = f"http://127.0.0.1:{PORT}/health" +READY = (200, '{"status":"healthy"}') -def test_boot_passes_when_both_health_checks_answer_ok( +def _not_ready() -> urllib.error.HTTPError: + return urllib.error.HTTPError( + HEALTH_URL, 503, "Service Unavailable", Message(), None + ) + + +def test_boot_passes_when_health_reports_ready( boot: BootRunner, capsys: pytest.CaptureFixture[str] ) -> None: app = _FakeApp() - status, fetched, clock = boot(app, [(200, "OK\n"), (200, "OK\n"), (200, " OK ")]) + status, fetched, clock = boot(app, [READY, (200, ' {"status":"healthy"}\n')]) assert status == 0 assert capsys.readouterr().out.splitlines() == [ *_header(app), "", - "Boot smoke test reached a live app. Probing health endpoints:", - "/liveness-check: 200 OK", - "/readiness-check: 200 OK", + "Boot smoke test reached a running app. Checking /health:", + '/health: 200 {"status":"healthy"}', "", "Boot smoke test passed.", ] - assert fetched == [LIVENESS_URL, LIVENESS_URL, READINESS_URL] + assert fetched == [HEALTH_URL, HEALTH_URL] assert clock.sleeps == [] assert app.calls == [("terminate",), ("wait", 5)] assert app.popen_args is not None @@ -161,12 +166,10 @@ def test_boot_passes_when_both_health_checks_answer_ok( assert command == [ sys.executable, "-m", - "flask", - "--app", - "policyengine_api.api", - "run", - "--without-threads", - "--no-reload", + "uvicorn", + "policyengine_api.asgi:app", + "--host", + "127.0.0.1", "--port", str(PORT), ] @@ -178,7 +181,7 @@ def test_boot_passes_when_both_health_checks_answer_ok( assert kwargs["stderr"].closed -def test_boot_retries_until_the_app_is_live( +def test_boot_retries_until_the_app_answers( boot: BootRunner, capsys: pytest.CaptureFixture[str] ) -> None: app = _FakeApp() @@ -189,21 +192,18 @@ def test_boot_retries_until_the_app_is_live( urllib.error.URLError("refused"), TimeoutError(), ConnectionError(), - (503, "starting"), - (200, "not ok"), - (200, "OK"), - (200, "OK"), - (200, "OK"), + _not_ready(), + READY, ], ) assert status == 0 - assert fetched == [LIVENESS_URL] * 6 + [LIVENESS_URL, READINESS_URL] - assert clock.sleeps == [0.5] * 5 + assert fetched == [HEALTH_URL] * 5 + assert clock.sleeps == [0.5] * 3 assert "Boot smoke test passed." in capsys.readouterr().out -def test_boot_fails_when_the_app_exits_before_it_is_live( +def test_boot_fails_when_the_app_exits_before_it_answers( boot: BootRunner, capsys: pytest.CaptureFixture[str] ) -> None: app = _FakeApp(exits_after_polls=1) @@ -214,15 +214,15 @@ def test_boot_fails_when_the_app_exits_before_it_is_live( assert capsys.readouterr().out.splitlines() == [ *_header(app), "", - "Boot smoke test failed: app did not become ready.", + "Boot smoke test failed: app did not start.", *CAPTURED_OUTPUT, ] - assert fetched == [LIVENESS_URL] + assert fetched == [HEALTH_URL] assert clock.sleeps == [0.5] assert app.calls == [("wait", 5)] -def test_boot_stops_an_app_that_never_becomes_live( +def test_boot_stops_an_app_that_never_answers( boot: BootRunner, capsys: pytest.CaptureFixture[str] ) -> None: app = _FakeApp() @@ -234,7 +234,7 @@ def test_boot_stops_an_app_that_never_becomes_live( assert capsys.readouterr().out.splitlines() == [ *_header(app), "", - "Boot smoke test failed: app did not become ready.", + "Boot smoke test failed: app did not start.", *CAPTURED_OUTPUT, ] assert len(fetched) == 1200 @@ -242,34 +242,42 @@ def test_boot_stops_an_app_that_never_becomes_live( assert app.calls == [("terminate",), ("wait", 5)] -def test_boot_fails_when_a_health_probe_is_not_ok( - boot: BootRunner, capsys: pytest.CaptureFixture[str] +@pytest.mark.parametrize( + ("health_answer", "reported_line"), + [ + (_not_ready(), "/health: 503"), + ((200, '{"status":"starting"}'), '/health: 200 {"status":"starting"}'), + ], + ids=["not-ready", "unexpected-body"], +) +def test_boot_fails_when_health_does_not_report_ready( + boot: BootRunner, + capsys: pytest.CaptureFixture[str], + health_answer: FetchOutcome, + reported_line: str, ) -> None: app = _FakeApp() - status, fetched, _clock = boot( - app, [(200, "OK"), (200, "OK"), (503, " starting \n")] - ) + status, fetched, _clock = boot(app, [READY, health_answer]) assert status == 1 assert capsys.readouterr().out.splitlines() == [ *_header(app), "", - "Boot smoke test reached a live app. Probing health endpoints:", - "/liveness-check: 200 OK", - "/readiness-check: 503 starting", + "Boot smoke test reached a running app. Checking /health:", + reported_line, "", - "Health endpoint probe failed.", + "Health check did not report ready.", *CAPTURED_OUTPUT, ] - assert fetched == [LIVENESS_URL, LIVENESS_URL, READINESS_URL] + assert fetched == [HEALTH_URL, HEALTH_URL] assert app.calls == [("terminate",), ("wait", 5)] def test_boot_kills_an_app_that_ignores_terminate(boot: BootRunner) -> None: app = _FakeApp(ignores_terminate=True) - status, _fetched, _clock = boot(app, [(200, "OK"), (200, "OK"), (200, "OK")]) + status, _fetched, _clock = boot(app, [READY, READY]) assert status == 0 assert app.calls == [("terminate",), ("wait", 5), ("kill",), ("wait", 5)] @@ -279,10 +287,9 @@ def test_health_probe_errors_propagate_after_stopping_the_app( boot: BootRunner, ) -> None: app = _FakeApp() - error = urllib.error.HTTPError(READINESS_URL, 500, "boom", Message(), None) - with pytest.raises(urllib.error.HTTPError): - boot(app, [(200, "OK"), (200, "OK"), error]) + with pytest.raises(ConnectionError): + boot(app, [READY, ConnectionError()]) assert app.calls == [("terminate",), ("wait", 5)] @@ -297,6 +304,6 @@ def missing(_name: str) -> str: monkeypatch.setattr(importlib.metadata, "version", missing) - boot(_FakeApp(), [(200, "OK"), (200, "OK"), (200, "OK")]) + boot(_FakeApp(), [READY, READY]) assert "policyengine: not installed" in capsys.readouterr().out.splitlines() diff --git a/tests/unit/test_spm_settings.py b/tests/unit/test_spm_settings.py index 6ca8cf06e..8b010124a 100644 --- a/tests/unit/test_spm_settings.py +++ b/tests/unit/test_spm_settings.py @@ -575,7 +575,7 @@ def test_a_bundle_this_build_cannot_read_reports_no_validator_internals( @pytest.mark.usefixtures("certified_bundle") def test_a_country_package_without_a_simulation_is_typed_not_internal(monkeypatch): - """A partially installed model must not make /readiness-check raise. + """A partially installed model must not make /health raise. `is_ready` recovers from RuntimeError and ValueError only, so an escaping AttributeError turns the health check into a 500 and every US request with diff --git a/tests/unit/test_stage6_native_metadata.py b/tests/unit/test_stage6_native_metadata.py index c045f3802..8dcbb94aa 100644 --- a/tests/unit/test_stage6_native_metadata.py +++ b/tests/unit/test_stage6_native_metadata.py @@ -51,7 +51,6 @@ def _dependencies(reader: _MetadataReader) -> NativeRouteDependencies: readiness_probe=lambda: True, gateway_client_factory=_HealthySimulationGateway, metadata_reader_factory=lambda: reader, - specification_provider=dict, ) @@ -181,7 +180,7 @@ def test_native_invalid_country_matches_legacy_flask_error(): ) assert native_response.json() == { "status": "error", - "message": "Country zz not found. Available countries are: uk, us, ca, ng, il", + "message": "Country zz not found. Available countries are: uk, us", } assert reader.calls == [] @@ -196,7 +195,7 @@ def test_country_validation_is_shared_by_flask_and_fastapi(): assert raised.value.country_id == "zz" assert raised.value.available_country_ids == COUNTRIES assert str(raised.value) == ( - "Country zz not found. Available countries are: uk, us, ca, ng, il" + "Country zz not found. Available countries are: uk, us" ) diff --git a/tests/unit/test_supported_countries.py b/tests/unit/test_supported_countries.py index 429f27657..db9a42740 100644 --- a/tests/unit/test_supported_countries.py +++ b/tests/unit/test_supported_countries.py @@ -49,6 +49,7 @@ from pydantic import TypeAdapter +from policyengine_api import constants from policyengine_api.data.v2.catalog import catalog_selection, extraction from policyengine_api.query_parameters import CountryId from scripts import process_v1_household_mirror_event @@ -76,6 +77,9 @@ def capture_extraction(**arguments): "installed_models": captured_arguments["models"], "dual_write_countries": sorted(catalog_selection.SUPPORTED_V2_COUNTRY_IDS), "household_copy_countries": list(country_option.choices), + "v1_countries": list(constants.COUNTRIES), + "v1_country_packages": list(constants.COUNTRY_PACKAGE_NAMES), + "bundled_country_packages": constants.BUNDLED_COUNTRY_PACKAGE_NAMES, } ) ) @@ -126,6 +130,18 @@ def test_every_country_list_follows_a_country_added_to_the_one_list() -> None: }, "dual_write_countries": ["uk", "us", "zz"], "household_copy_countries": ["us", "uk", "zz"], + # The v1 routes list countries alphabetically. + "v1_countries": ["uk", "us", "zz"], + "v1_country_packages": [ + "policyengine_uk", + "policyengine_us", + "policyengine_zz", + ], + "bundled_country_packages": { + "uk": "policyengine-uk", + "us": "policyengine-us", + "zz": "policyengine-zz", + }, } diff --git a/tests/unit/test_warmup.py b/tests/unit/test_warmup.py index 8048cf367..323eda0e1 100644 --- a/tests/unit/test_warmup.py +++ b/tests/unit/test_warmup.py @@ -101,9 +101,9 @@ def test_asgi_runs_warmup_and_marks_ready(): assert "mark_ready" in src -def test_readiness_check_gates_on_readiness(): - src = (REPO / "policyengine_api/routes/system_routes.py").read_text( +def test_health_gates_on_readiness(): + src = (REPO / "policyengine_api/fastapi_routes/health.py").read_text( encoding="utf-8" ) - assert "is_ready" in src - assert "status=503" in src + assert "dependencies.readiness_probe()" in src + assert "status_code=503" in src diff --git a/tests/unit/v2/test_country_routes.py b/tests/unit/v2/test_country_routes.py index 7fd2e2b91..9dd4fc172 100644 --- a/tests/unit/v2/test_country_routes.py +++ b/tests/unit/v2/test_country_routes.py @@ -57,7 +57,6 @@ def fallback(resource: str): readiness_probe=lambda: True, gateway_client_factory=lambda: None, metadata_reader_factory=lambda: None, - specification_provider=dict, v2_country_service_factory=lambda: service, ) settings = RouteImplementationSettings( diff --git a/tests/unit/v2/test_household_routes.py b/tests/unit/v2/test_household_routes.py index 3932129b5..fa27d3783 100644 --- a/tests/unit/v2/test_household_routes.py +++ b/tests/unit/v2/test_household_routes.py @@ -120,7 +120,6 @@ def fallback(resource: str): readiness_probe=lambda: True, gateway_client_factory=lambda: None, metadata_reader_factory=lambda: None, - specification_provider=dict, v2_household_service_factory=lambda: service, ) settings = RouteImplementationSettings( diff --git a/tests/unit/v2/test_import_side_effects.py b/tests/unit/v2/test_import_side_effects.py index af6913897..36b62cecb 100644 --- a/tests/unit/v2/test_import_side_effects.py +++ b/tests/unit/v2/test_import_side_effects.py @@ -121,8 +121,8 @@ def test_import_startup_and_request_never_create_runtime_sqlite( import sys from policyengine_api.api import app -response = app.test_client().get('/liveness-check') -assert response.status_code == 200 +response = app.test_client().get('/an-unknown-path') +assert response.status_code == 404 assert "policyengine_api.data.v2.catalog.initialization" not in sys.modules assert not Path('policyengine.db').exists() assert not list(Path.cwd().glob('*.db')) diff --git a/tests/unit/v2/test_metadata_routes.py b/tests/unit/v2/test_metadata_routes.py index 1c2e542a5..75ba0326b 100644 --- a/tests/unit/v2/test_metadata_routes.py +++ b/tests/unit/v2/test_metadata_routes.py @@ -238,7 +238,6 @@ def v1_metadata(country_id: str): readiness_probe=lambda: True, gateway_client_factory=lambda: None, metadata_reader_factory=lambda: None, - specification_provider=dict, v2_metadata_reader_factory=factory, ) settings = RouteImplementationSettings( diff --git a/tests/unit/v2/test_policy_routes.py b/tests/unit/v2/test_policy_routes.py index c78a17a24..790b0d413 100644 --- a/tests/unit/v2/test_policy_routes.py +++ b/tests/unit/v2/test_policy_routes.py @@ -119,7 +119,6 @@ def fallback(resource: str): readiness_probe=lambda: True, gateway_client_factory=lambda: None, metadata_reader_factory=lambda: None, - specification_provider=dict, v2_policy_service_factory=lambda: service, ) settings = RouteImplementationSettings( diff --git a/tests/unit/v2/user_route_support.py b/tests/unit/v2/user_route_support.py index c60298418..34851c08c 100644 --- a/tests/unit/v2/user_route_support.py +++ b/tests/unit/v2/user_route_support.py @@ -64,7 +64,6 @@ def fallback(resource: str): readiness_probe=lambda: True, gateway_client_factory=lambda: None, metadata_reader_factory=lambda: None, - specification_provider=dict, **overrides, ) settings = RouteImplementationSettings( diff --git a/uv.lock b/uv.lock index 5ce141d55..ec2ae34c7 100644 --- a/uv.lock +++ b/uv.lock @@ -219,15 +219,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, ] -[[package]] -name = "argparse" -version = "1.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/18/dd/e617cfc3f6210ae183374cd9f6a26b20514bbb5a792af97949c5aacddf0f/argparse-1.4.0.tar.gz", hash = "sha256:62b089a55be1d8949cd2bc7e0df0bddb9e028faefc8c32038cc84862aefdd6e4", size = 70508, upload-time = "2015-09-12T20:22:16.217Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f2/94/3af39d34be01a24a6e65433d19e107099374224905f1e0cc6bbe1fd22a2f/argparse-1.4.0-py2.py3-none-any.whl", hash = "sha256:c31647edb69fd3d465a847ea3157d37bed1f95f19760b11a47aa91c04b666314", size = 23000, upload-time = "2015-09-14T16:03:16.137Z" }, -] - [[package]] name = "assertpy" version = "1.1" @@ -261,49 +252,6 @@ 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 = "black" -version = "26.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "mypy-extensions" }, - { name = "packaging" }, - { name = "pathspec" }, - { name = "platformdirs" }, - { name = "pytokens" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e1/c5/61175d618685d42b005847464b8fb4743a67b1b8fdb75e50e5a96c31a27a/black-26.3.1.tar.gz", hash = "sha256:2c50f5063a9641c7eed7795014ba37b0f5fa227f3d408b968936e24bc0566b07", size = 666155, upload-time = "2026-03-12T03:36:03.593Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/17/57/5f11c92861f9c92eb9dddf515530bc2d06db843e44bdcf1c83c1427824bc/black-26.3.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:28ef38aee69e4b12fda8dba75e21f9b4f979b490c8ac0baa7cb505369ac9e1ff", size = 1851987, upload-time = "2026-03-12T03:40:06.248Z" }, - { url = "https://files.pythonhosted.org/packages/54/aa/340a1463660bf6831f9e39646bf774086dbd8ca7fc3cded9d59bbdf4ad0a/black-26.3.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bf9bf162ed91a26f1adba8efda0b573bc6924ec1408a52cc6f82cb73ec2b142c", size = 1689499, upload-time = "2026-03-12T03:40:07.642Z" }, - { url = "https://files.pythonhosted.org/packages/f3/01/b726c93d717d72733da031d2de10b92c9fa4c8d0c67e8a8a372076579279/black-26.3.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:474c27574d6d7037c1bc875a81d9be0a9a4f9ee95e62800dab3cfaadbf75acd5", size = 1754369, upload-time = "2026-03-12T03:40:09.279Z" }, - { url = "https://files.pythonhosted.org/packages/e3/09/61e91881ca291f150cfc9eb7ba19473c2e59df28859a11a88248b5cbbc4d/black-26.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:5e9d0d86df21f2e1677cc4bd090cd0e446278bcbbe49bf3659c308c3e402843e", size = 1413613, upload-time = "2026-03-12T03:40:10.943Z" }, - { url = "https://files.pythonhosted.org/packages/16/73/544f23891b22e7efe4d8f812371ab85b57f6a01b2fc45e3ba2e52ba985b8/black-26.3.1-cp311-cp311-win_arm64.whl", hash = "sha256:9a5e9f45e5d5e1c5b5c29b3bd4265dcc90e8b92cf4534520896ed77f791f4da5", size = 1219719, upload-time = "2026-03-12T03:40:12.597Z" }, - { url = "https://files.pythonhosted.org/packages/dc/f8/da5eae4fc75e78e6dceb60624e1b9662ab00d6b452996046dfa9b8a6025b/black-26.3.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b5e6f89631eb88a7302d416594a32faeee9fb8fb848290da9d0a5f2903519fc1", size = 1895920, upload-time = "2026-03-12T03:40:13.921Z" }, - { url = "https://files.pythonhosted.org/packages/2c/9f/04e6f26534da2e1629b2b48255c264cabf5eedc5141d04516d9d68a24111/black-26.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:41cd2012d35b47d589cb8a16faf8a32ef7a336f56356babd9fcf70939ad1897f", size = 1718499, upload-time = "2026-03-12T03:40:15.239Z" }, - { url = "https://files.pythonhosted.org/packages/04/91/a5935b2a63e31b331060c4a9fdb5a6c725840858c599032a6f3aac94055f/black-26.3.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f76ff19ec5297dd8e66eb64deda23631e642c9393ab592826fd4bdc97a4bce7", size = 1794994, upload-time = "2026-03-12T03:40:17.124Z" }, - { url = "https://files.pythonhosted.org/packages/e7/0a/86e462cdd311a3c2a8ece708d22aba17d0b2a0d5348ca34b40cdcbea512e/black-26.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:ddb113db38838eb9f043623ba274cfaf7d51d5b0c22ecb30afe58b1bb8322983", size = 1420867, upload-time = "2026-03-12T03:40:18.83Z" }, - { url = "https://files.pythonhosted.org/packages/5b/e5/22515a19cb7eaee3440325a6b0d95d2c0e88dd180cb011b12ae488e031d1/black-26.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:dfdd51fc3e64ea4f35873d1b3fb25326773d55d2329ff8449139ebaad7357efb", size = 1230124, upload-time = "2026-03-12T03:40:20.425Z" }, - { url = "https://files.pythonhosted.org/packages/f5/77/5728052a3c0450c53d9bb3945c4c46b91baa62b2cafab6801411b6271e45/black-26.3.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:855822d90f884905362f602880ed8b5df1b7e3ee7d0db2502d4388a954cc8c54", size = 1895034, upload-time = "2026-03-12T03:40:21.813Z" }, - { url = "https://files.pythonhosted.org/packages/52/73/7cae55fdfdfbe9d19e9a8d25d145018965fe2079fa908101c3733b0c55a0/black-26.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8a33d657f3276328ce00e4d37fe70361e1ec7614da5d7b6e78de5426cb56332f", size = 1718503, upload-time = "2026-03-12T03:40:23.666Z" }, - { url = "https://files.pythonhosted.org/packages/e1/87/af89ad449e8254fdbc74654e6467e3c9381b61472cc532ee350d28cfdafb/black-26.3.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f1cd08e99d2f9317292a311dfe578fd2a24b15dbce97792f9c4d752275c1fa56", size = 1793557, upload-time = "2026-03-12T03:40:25.497Z" }, - { url = "https://files.pythonhosted.org/packages/43/10/d6c06a791d8124b843bf325ab4ac7d2f5b98731dff84d6064eafd687ded1/black-26.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:c7e72339f841b5a237ff14f7d3880ddd0fc7f98a1199e8c4327f9a4f478c1839", size = 1422766, upload-time = "2026-03-12T03:40:27.14Z" }, - { url = "https://files.pythonhosted.org/packages/59/4f/40a582c015f2d841ac24fed6390bd68f0fc896069ff3a886317959c9daf8/black-26.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:afc622538b430aa4c8c853f7f63bc582b3b8030fd8c80b70fb5fa5b834e575c2", size = 1232140, upload-time = "2026-03-12T03:40:28.882Z" }, - { url = "https://files.pythonhosted.org/packages/d5/da/e36e27c9cebc1311b7579210df6f1c86e50f2d7143ae4fcf8a5017dc8809/black-26.3.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2d6bfaf7fd0993b420bed691f20f9492d53ce9a2bcccea4b797d34e947318a78", size = 1889234, upload-time = "2026-03-12T03:40:30.964Z" }, - { url = "https://files.pythonhosted.org/packages/0e/7b/9871acf393f64a5fa33668c19350ca87177b181f44bb3d0c33b2d534f22c/black-26.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f89f2ab047c76a9c03f78d0d66ca519e389519902fa27e7a91117ef7611c0568", size = 1720522, upload-time = "2026-03-12T03:40:32.346Z" }, - { url = "https://files.pythonhosted.org/packages/03/87/e766c7f2e90c07fb7586cc787c9ae6462b1eedab390191f2b7fc7f6170a9/black-26.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b07fc0dab849d24a80a29cfab8d8a19187d1c4685d8a5e6385a5ce323c1f015f", size = 1787824, upload-time = "2026-03-12T03:40:33.636Z" }, - { url = "https://files.pythonhosted.org/packages/ac/94/2424338fb2d1875e9e83eed4c8e9c67f6905ec25afd826a911aea2b02535/black-26.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:0126ae5b7c09957da2bdbd91a9ba1207453feada9e9fe51992848658c6c8e01c", size = 1445855, upload-time = "2026-03-12T03:40:35.442Z" }, - { url = "https://files.pythonhosted.org/packages/86/43/0c3338bd928afb8ee7471f1a4eec3bdbe2245ccb4a646092a222e8669840/black-26.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:92c0ec1f2cc149551a2b7b47efc32c866406b6891b0ee4625e95967c8f4acfb1", size = 1258109, upload-time = "2026-03-12T03:40:36.832Z" }, - { url = "https://files.pythonhosted.org/packages/8e/0d/52d98722666d6fc6c3dd4c76df339501d6efd40e0ff95e6186a7b7f0befd/black-26.3.1-py3-none-any.whl", hash = "sha256:2bd5aa94fc267d38bb21a70d7410a89f1a1d318841855f698746f8e7f51acd1b", size = 207542, upload-time = "2026-03-12T03:36:01.668Z" }, -] - -[package.optional-dependencies] -jupyter = [ - { name = "ipython" }, - { name = "tokenize-rt" }, -] - [[package]] name = "blinker" version = "1.9.0" @@ -691,19 +639,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ab/9c/51f28c3550276bcf35660703ba0ab829a90b88be8cd98a71ef23c2413913/cryptography-47.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cffbba3392df0fa8629bb7f43454ee2925059ee158e23c54620b9063912b86c8", size = 3698916, upload-time = "2026-04-24T19:54:49.782Z" }, ] -[[package]] -name = "datetime" -version = "6.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pytz" }, - { name = "zope-interface" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/77/32/decbfd165e9985ba9d8c2d34a39afe5aeba2fc3fe390eb6e9ef1aab98fa8/datetime-6.0.tar.gz", hash = "sha256:c1514936d2f901e10c8e08d83bf04e6c9dbd7ca4f244da94fec980980a3bc4d5", size = 64167, upload-time = "2025-11-25T08:00:34.586Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cf/7a/ea0f3e3ea74be36fc7cf54f966cde732a3de72697983cdb5646b0a4dacde/datetime-6.0-py3-none-any.whl", hash = "sha256:d19988f0657a4e72c9438344157254a8dcad6aea8cd5ae70a5d1b5a75e5dc930", size = 52637, upload-time = "2025-11-25T08:00:33.077Z" }, -] - [[package]] name = "decorator" version = "5.2.1" @@ -1882,19 +1817,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/38/a6/800800bfed7b1fb10fc3f3d557785c3854e80d3f7a9800d784b176a1fc2d/librt-0.15.0-cp315-cp315t-win_arm64.whl", hash = "sha256:84d244b00604d17df3fc7736c327892d6bba66181254aa4087be807b6c342bdc", size = 110700, upload-time = "2026-08-07T10:49:15.499Z" }, ] -[[package]] -name = "linecheck" -version = "0.1.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "argparse" }, - { name = "pathlib" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/4b/18/02fd1fe0fe74f77066bb403786a3ad14dfad1fd12fd6121b6315f14dee6a/linecheck-0.1.0.tar.gz", hash = "sha256:7846eb11a69c818c5040cabe54b4b8e11dfe9b6995f7bd03cc64462d469f62f0", size = 2236, upload-time = "2022-07-16T13:06:19.007Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8a/91/6c074015990f4f656f7b69a5c2d15924906ce0bc19c7014ac953493c0cf0/linecheck-0.1.0-py3-none-any.whl", hash = "sha256:73c6b29790521fa711b00df7cd60af4caf7004337d8710606881fbecb0d1bc83", size = 2767, upload-time = "2022-07-16T13:06:17.01Z" }, -] - [[package]] name = "mako" version = "1.4.1" @@ -2330,18 +2252,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" }, ] -[[package]] -name = "nptyping" -version = "1.4.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, - { name = "typish" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/8f/d9/0514384fbad1c269d861f1dfe8ef8adc9d8ccaac1fdaad9f007063b9d92f/nptyping-1.4.4-py3-none-any.whl", hash = "sha256:8128473b8ba0e65f3d6edc727cd99024e162edcf7e8a0ea8f9dfa6b070934d14", size = 31918, upload-time = "2021-09-10T19:10:23.478Z" }, -] - [[package]] name = "numexpr" version = "2.14.1" @@ -2670,15 +2580,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b6/61/fae042894f4296ec49e3f193aff5d7c18440da9e48102c3315e1bc4519a7/parso-0.8.6-py2.py3-none-any.whl", hash = "sha256:2c549f800b70a5c4952197248825584cb00f033b29c692671d3bf08bf380baff", size = 106894, upload-time = "2026-02-09T15:45:21.391Z" }, ] -[[package]] -name = "pathlib" -version = "1.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ac/aa/9b065a76b9af472437a0059f77e8f962fe350438b927cb80184c32f075eb/pathlib-1.0.1.tar.gz", hash = "sha256:6940718dfc3eff4258203ad5021090933e5c04707d5ca8cc9e73c94a7894ea9f", size = 49298, upload-time = "2014-09-03T15:41:57.18Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/78/f9/690a8600b93c332de3ab4a344a4ac34f00c8f104917061f779db6a918ed6/pathlib-1.0.1-py3-none-any.whl", hash = "sha256:f35f95ab8b0f59e6d354090350b44a80a80635d22efdedfa84c7ad1cf0a74147", size = 14363, upload-time = "2022-05-04T13:37:20.585Z" }, -] - [[package]] name = "pathspec" version = "1.1.1" @@ -2787,15 +2688,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bc/60/5382c03e1970de634027cee8e1b7d39776b778b81812aaf45b694dfe9e28/pillow-12.2.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bfa9c230d2fe991bed5318a5f119bd6780cda2915cca595393649fc118ab895e", size = 7080946, upload-time = "2026-04-01T14:46:11.734Z" }, ] -[[package]] -name = "platformdirs" -version = "4.9.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" }, -] - [[package]] name = "plotly" version = "5.24.1" @@ -2869,9 +2761,6 @@ dependencies = [ { name = "openai" }, { name = "packaging" }, { name = "policyengine", extra = ["models"] }, - { name = "policyengine-canada" }, - { name = "policyengine-il" }, - { name = "policyengine-ng" }, { name = "policyengine-observability", extra = ["flask", "google", "httpx", "otlp-grpc"] }, { name = "psycopg", extra = ["binary"] }, { name = "pydantic" }, @@ -2930,9 +2819,6 @@ requires-dist = [ { name = "openai" }, { name = "packaging", specifier = ">=24,<27" }, { name = "policyengine", extras = ["models"], specifier = "==6.11.1" }, - { name = "policyengine-canada", specifier = "==0.96.3" }, - { name = "policyengine-il", specifier = "==0.1.0" }, - { name = "policyengine-ng", specifier = "==0.5.1" }, { name = "policyengine-observability", extras = ["flask", "google", "httpx", "otlp-grpc"], specifier = ">=3.0.3,<4" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.3,<4" }, { name = "pydantic" }, @@ -2959,34 +2845,6 @@ provides-extras = ["dev"] [package.metadata.requires-dev] telemetry-error-issues = [{ name = "google-cloud-bigquery", specifier = ">=3.30,<4" }] -[[package]] -name = "policyengine-canada" -version = "0.96.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "black", extra = ["jupyter"] }, - { name = "coverage" }, - { name = "dpath" }, - { name = "h5py" }, - { name = "linecheck" }, - { name = "microdf-python" }, - { name = "nptyping" }, - { name = "numexpr" }, - { name = "pandas" }, - { name = "plotly" }, - { name = "policyengine-core" }, - { name = "pytest" }, - { name = "requests" }, - { name = "sortedcontainers" }, - { name = "tqdm" }, - { name = "wheel" }, - { name = "yaml-changelog" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ea/fa/6bd506fa347a38e67af4ad2f42cbe8d74d2b244ce81b6950d9367e4b1d60/policyengine_canada-0.96.3.tar.gz", hash = "sha256:545fe58b813dd0d20b90498c8098ca4abfe957bcdd0b7ee51248e1b2240154eb", size = 162817, upload-time = "2025-07-24T12:39:55.193Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/57/52/1370d0b47a211ddd083cc163578682ac34538b2791ef30473a43785e8b0c/policyengine_canada-0.96.3-py3-none-any.whl", hash = "sha256:7f289422aef5c560bde5aab9438d90da305f922b80c42058865f633882787525", size = 600333, upload-time = "2025-07-24T12:39:53.606Z" }, -] - [[package]] name = "policyengine-core" version = "3.33.3" @@ -3014,32 +2872,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c8/d6/253cc99d853e36996f264138f4f8a2c662047da3eda8e37ddf39959397fc/policyengine_core-3.33.3-py3-none-any.whl", hash = "sha256:5847d9938412095fe9848c2ce535838a7dc8fac9238d8fd9e6c9d0a0648e0888", size = 301905, upload-time = "2026-10-12T20:53:15.706Z" }, ] -[[package]] -name = "policyengine-il" -version = "0.1.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, - { name = "policyengine-core" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c5/95/8ef0c26e4f21f28d0f424db6ac34111d966035f10501122c8de02944370b/policyengine-il-0.1.0.tar.gz", hash = "sha256:07ce41e50baeb538216f6a13f32a232bcb2a5bb2b9f37856e453b47de3117190", size = 20755, upload-time = "2023-07-06T16:37:46.229Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/59/55/ae00fc43b1e2b47075dc8bf7d259c837e043f82d23ac4909dfd5ed8367a6/policyengine_il-0.1.0-py3-none-any.whl", hash = "sha256:cc30402d1457862b1d1c3a2742eadfdb41dedc547c6508fa548b26db6e7658e8", size = 29342, upload-time = "2023-07-06T16:37:44.831Z" }, -] - -[[package]] -name = "policyengine-ng" -version = "0.5.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, - { name = "policyengine-core" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/2d/6a/2e60ecef7b3bb51073b6323c019211716ee0621638337d435bea1d919653/policyengine-ng-0.5.1.tar.gz", hash = "sha256:0d93d2c2728146385a4208ef3da516e26281b58459e2e1a538bbaa91d76f29d3", size = 36955, upload-time = "2023-04-19T13:13:41.012Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/03/9e/1040e63f72f3857d540e52c0f99115f8db04da5e94231959d4245a119bef/policyengine_ng-0.5.1-py3-none-any.whl", hash = "sha256:21fad6aae8d80a156142ac876cf1b7679e036c1640ca6cb375661701f10b9920", size = 31074, upload-time = "2023-04-19T13:14:28.242Z" }, -] - [[package]] name = "policyengine-observability" version = "3.0.3" @@ -3694,49 +3526,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/99/78/4126abcbdbd3c559d43e0db7f7b9173fc6befe45d39a2856cc0b8ec2a5a6/python_multipart-0.0.27-py3-none-any.whl", hash = "sha256:6fccfad17a27334bd0193681b369f476eda3409f17381a2d65aa7df3f7275645", size = 29254, upload-time = "2026-04-27T10:51:24.997Z" }, ] -[[package]] -name = "pytokens" -version = "0.4.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b6/34/b4e015b99031667a7b960f888889c5bd34ef585c85e1cb56a594b92836ac/pytokens-0.4.1.tar.gz", hash = "sha256:292052fe80923aae2260c073f822ceba21f3872ced9a68bb7953b348e561179a", size = 23015, upload-time = "2026-01-30T01:03:45.924Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3d/92/790ebe03f07b57e53b10884c329b9a1a308648fc083a6d4a39a10a28c8fc/pytokens-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d70e77c55ae8380c91c0c18dea05951482e263982911fc7410b1ffd1dadd3440", size = 160864, upload-time = "2026-01-30T01:02:57.882Z" }, - { url = "https://files.pythonhosted.org/packages/13/25/a4f555281d975bfdd1eba731450e2fe3a95870274da73fb12c40aeae7625/pytokens-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a58d057208cb9075c144950d789511220b07636dd2e4708d5645d24de666bdc", size = 248565, upload-time = "2026-01-30T01:02:59.912Z" }, - { url = "https://files.pythonhosted.org/packages/17/50/bc0394b4ad5b1601be22fa43652173d47e4c9efbf0044c62e9a59b747c56/pytokens-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b49750419d300e2b5a3813cf229d4e5a4c728dae470bcc89867a9ad6f25a722d", size = 260824, upload-time = "2026-01-30T01:03:01.471Z" }, - { url = "https://files.pythonhosted.org/packages/4e/54/3e04f9d92a4be4fc6c80016bc396b923d2a6933ae94b5f557c939c460ee0/pytokens-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d9907d61f15bf7261d7e775bd5d7ee4d2930e04424bab1972591918497623a16", size = 264075, upload-time = "2026-01-30T01:03:04.143Z" }, - { url = "https://files.pythonhosted.org/packages/d1/1b/44b0326cb5470a4375f37988aea5d61b5cc52407143303015ebee94abfd6/pytokens-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:ee44d0f85b803321710f9239f335aafe16553b39106384cef8e6de40cb4ef2f6", size = 103323, upload-time = "2026-01-30T01:03:05.412Z" }, - { url = "https://files.pythonhosted.org/packages/41/5d/e44573011401fb82e9d51e97f1290ceb377800fb4eed650b96f4753b499c/pytokens-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:140709331e846b728475786df8aeb27d24f48cbcf7bcd449f8de75cae7a45083", size = 160663, upload-time = "2026-01-30T01:03:06.473Z" }, - { url = "https://files.pythonhosted.org/packages/f0/e6/5bbc3019f8e6f21d09c41f8b8654536117e5e211a85d89212d59cbdab381/pytokens-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d6c4268598f762bc8e91f5dbf2ab2f61f7b95bdc07953b602db879b3c8c18e1", size = 255626, upload-time = "2026-01-30T01:03:08.177Z" }, - { url = "https://files.pythonhosted.org/packages/bf/3c/2d5297d82286f6f3d92770289fd439956b201c0a4fc7e72efb9b2293758e/pytokens-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24afde1f53d95348b5a0eb19488661147285ca4dd7ed752bbc3e1c6242a304d1", size = 269779, upload-time = "2026-01-30T01:03:09.756Z" }, - { url = "https://files.pythonhosted.org/packages/20/01/7436e9ad693cebda0551203e0bf28f7669976c60ad07d6402098208476de/pytokens-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5ad948d085ed6c16413eb5fec6b3e02fa00dc29a2534f088d3302c47eb59adf9", size = 268076, upload-time = "2026-01-30T01:03:10.957Z" }, - { url = "https://files.pythonhosted.org/packages/2e/df/533c82a3c752ba13ae7ef238b7f8cdd272cf1475f03c63ac6cf3fcfb00b6/pytokens-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:3f901fe783e06e48e8cbdc82d631fca8f118333798193e026a50ce1b3757ea68", size = 103552, upload-time = "2026-01-30T01:03:12.066Z" }, - { url = "https://files.pythonhosted.org/packages/cb/dc/08b1a080372afda3cceb4f3c0a7ba2bde9d6a5241f1edb02a22a019ee147/pytokens-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8bdb9d0ce90cbf99c525e75a2fa415144fd570a1ba987380190e8b786bc6ef9b", size = 160720, upload-time = "2026-01-30T01:03:13.843Z" }, - { url = "https://files.pythonhosted.org/packages/64/0c/41ea22205da480837a700e395507e6a24425151dfb7ead73343d6e2d7ffe/pytokens-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5502408cab1cb18e128570f8d598981c68a50d0cbd7c61312a90507cd3a1276f", size = 254204, upload-time = "2026-01-30T01:03:14.886Z" }, - { url = "https://files.pythonhosted.org/packages/e0/d2/afe5c7f8607018beb99971489dbb846508f1b8f351fcefc225fcf4b2adc0/pytokens-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29d1d8fb1030af4d231789959f21821ab6325e463f0503a61d204343c9b355d1", size = 268423, upload-time = "2026-01-30T01:03:15.936Z" }, - { url = "https://files.pythonhosted.org/packages/68/d4/00ffdbd370410c04e9591da9220a68dc1693ef7499173eb3e30d06e05ed1/pytokens-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b08dd6b86058b6dc07efe9e98414f5102974716232d10f32ff39701e841c4", size = 266859, upload-time = "2026-01-30T01:03:17.458Z" }, - { url = "https://files.pythonhosted.org/packages/a7/c9/c3161313b4ca0c601eeefabd3d3b576edaa9afdefd32da97210700e47652/pytokens-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:9bd7d7f544d362576be74f9d5901a22f317efc20046efe2034dced238cbbfe78", size = 103520, upload-time = "2026-01-30T01:03:18.652Z" }, - { url = "https://files.pythonhosted.org/packages/8f/a7/b470f672e6fc5fee0a01d9e75005a0e617e162381974213a945fcd274843/pytokens-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4a14d5f5fc78ce85e426aa159489e2d5961acf0e47575e08f35584009178e321", size = 160821, upload-time = "2026-01-30T01:03:19.684Z" }, - { url = "https://files.pythonhosted.org/packages/80/98/e83a36fe8d170c911f864bfded690d2542bfcfacb9c649d11a9e6eb9dc41/pytokens-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f50fd18543be72da51dd505e2ed20d2228c74e0464e4262e4899797803d7fa", size = 254263, upload-time = "2026-01-30T01:03:20.834Z" }, - { url = "https://files.pythonhosted.org/packages/0f/95/70d7041273890f9f97a24234c00b746e8da86df462620194cef1d411ddeb/pytokens-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc74c035f9bfca0255c1af77ddd2d6ae8419012805453e4b0e7513e17904545d", size = 268071, upload-time = "2026-01-30T01:03:21.888Z" }, - { url = "https://files.pythonhosted.org/packages/da/79/76e6d09ae19c99404656d7db9c35dfd20f2086f3eb6ecb496b5b31163bad/pytokens-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f66a6bbe741bd431f6d741e617e0f39ec7257ca1f89089593479347cc4d13324", size = 271716, upload-time = "2026-01-30T01:03:23.633Z" }, - { url = "https://files.pythonhosted.org/packages/79/37/482e55fa1602e0a7ff012661d8c946bafdc05e480ea5a32f4f7e336d4aa9/pytokens-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:b35d7e5ad269804f6697727702da3c517bb8a5228afa450ab0fa787732055fc9", size = 104539, upload-time = "2026-01-30T01:03:24.788Z" }, - { url = "https://files.pythonhosted.org/packages/30/e8/20e7db907c23f3d63b0be3b8a4fd1927f6da2395f5bcc7f72242bb963dfe/pytokens-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8fcb9ba3709ff77e77f1c7022ff11d13553f3c30299a9fe246a166903e9091eb", size = 168474, upload-time = "2026-01-30T01:03:26.428Z" }, - { url = "https://files.pythonhosted.org/packages/d6/81/88a95ee9fafdd8f5f3452107748fd04c24930d500b9aba9738f3ade642cc/pytokens-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79fc6b8699564e1f9b521582c35435f1bd32dd06822322ec44afdeba666d8cb3", size = 290473, upload-time = "2026-01-30T01:03:27.415Z" }, - { url = "https://files.pythonhosted.org/packages/cf/35/3aa899645e29b6375b4aed9f8d21df219e7c958c4c186b465e42ee0a06bf/pytokens-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d31b97b3de0f61571a124a00ffe9a81fb9939146c122c11060725bd5aea79975", size = 303485, upload-time = "2026-01-30T01:03:28.558Z" }, - { url = "https://files.pythonhosted.org/packages/52/a0/07907b6ff512674d9b201859f7d212298c44933633c946703a20c25e9d81/pytokens-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:967cf6e3fd4adf7de8fc73cd3043754ae79c36475c1c11d514fc72cf5490094a", size = 306698, upload-time = "2026-01-30T01:03:29.653Z" }, - { url = "https://files.pythonhosted.org/packages/39/2a/cbbf9250020a4a8dd53ba83a46c097b69e5eb49dd14e708f496f548c6612/pytokens-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:584c80c24b078eec1e227079d56dc22ff755e0ba8654d8383b2c549107528918", size = 116287, upload-time = "2026-01-30T01:03:30.912Z" }, - { url = "https://files.pythonhosted.org/packages/c6/78/397db326746f0a342855b81216ae1f0a32965deccfd7c830a2dbc66d2483/pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de", size = 13729, upload-time = "2026-01-30T01:03:45.029Z" }, -] - -[[package]] -name = "pytz" -version = "2026.1.post1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/56/db/b8721d71d945e6a8ac63c0fc900b2067181dbb50805958d4d4661cf7d277/pytz-2026.1.post1.tar.gz", hash = "sha256:3378dde6a0c3d26719182142c56e60c7f9af7e968076f31aae569d72a0358ee1", size = 321088, upload-time = "2026-03-03T07:47:50.683Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/10/99/781fe0c827be2742bcc775efefccb3b048a3a9c6ce9aec0cbf4a101677e5/pytz-2026.1.post1-py2.py3-none-any.whl", hash = "sha256:f2fd16142fda348286a75e1a524be810bb05d444e5a081f37f7affc635035f7a", size = 510489, upload-time = "2026-03-03T07:47:49.167Z" }, -] - [[package]] name = "pyvis" version = "0.3.2" @@ -4241,15 +4030,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, ] -[[package]] -name = "tokenize-rt" -version = "6.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/69/ed/8f07e893132d5051d86a553e749d5c89b2a4776eb3a579b72ed61f8559ca/tokenize_rt-6.2.0.tar.gz", hash = "sha256:8439c042b330c553fdbe1758e4a05c0ed460dbbbb24a606f11f0dee75da4cad6", size = 5476, upload-time = "2025-05-23T23:48:00.035Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/33/f0/3fe8c6e69135a845f4106f2ff8b6805638d4e85c264e70114e8126689587/tokenize_rt-6.2.0-py2.py3-none-any.whl", hash = "sha256:a152bf4f249c847a66497a4a95f63376ed68ac6abf092a2f7cfb29d044ecff44", size = 6004, upload-time = "2025-05-23T23:47:58.812Z" }, -] - [[package]] name = "toml" version = "0.10.2" @@ -4329,14 +4109,6 @@ 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 = "typish" -version = "1.9.3" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9d/d6/3f56c9c0c12adf61dfcf4ed5c8ffd2c431db8dd85592067a57e8e1968565/typish-1.9.3-py3-none-any.whl", hash = "sha256:03cfee5e6eb856dbf90244e18f4e4c41044c8790d5779f4e775f63f982e2f896", size = 45063, upload-time = "2021-08-05T20:36:28.702Z" }, -] - [[package]] name = "tzdata" version = "2026.2" @@ -4643,22 +4415,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/87/1b/9e33c09813d65e248f7f773119148a612516a4bea93e9c6f545f78455b7c/wheel-0.47.0-py3-none-any.whl", hash = "sha256:212281cab4dff978f6cedd499cd893e1f620791ca6ff7107cf270781e587eced", size = 32218, upload-time = "2026-04-22T15:51:26.296Z" }, ] -[[package]] -name = "yaml-changelog" -version = "0.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "argparse" }, - { name = "datetime" }, - { name = "pathlib" }, - { name = "pyyaml" }, - { name = "requests" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/28/49/1004cdb8f58f49e136927b6b82554720f8f290269c4a2fe00ddf84f95dc5/yaml-changelog-0.3.0.tar.gz", hash = "sha256:d3a0f6921f8702200b16ecc3dbe6de839b7838544e68af6437ae2ecc67d83819", size = 3937, upload-time = "2022-10-18T17:50:21.571Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/00/e5/b28588e1e05392c7d4bcf300673ba563323b02b217f78926f6347c461407/yaml_changelog-0.3.0-py3-none-any.whl", hash = "sha256:d9b5f325efb1c9fb8461c5fec3d94c7bc5259c8f8e37ba0a790b01a07e9487f3", size = 16993, upload-time = "2022-10-18T17:50:20.173Z" }, -] - [[package]] name = "yarl" version = "1.23.0" @@ -4780,39 +4536,3 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/51/47/3fa2286c3cb162c71cdb34c4224d5745a1ceceb391b2bd9b19b668a8d724/yarl-1.23.0-cp314-cp314t-win_arm64.whl", hash = "sha256:44bb7bef4ea409384e3f8bc36c063d77ea1b8d4a5b2706956c0d6695f07dcc25", size = 86041, upload-time = "2026-03-01T22:07:49.026Z" }, { url = "https://files.pythonhosted.org/packages/69/68/c8739671f5699c7dc470580a4f821ef37c32c4cb0b047ce223a7f115757f/yarl-1.23.0-py3-none-any.whl", hash = "sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f", size = 48288, upload-time = "2026-03-01T22:07:51.388Z" }, ] - -[[package]] -name = "zope-interface" -version = "8.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9f/65/34a6e6e4dfa260c4c55ee02bb2fc53625e126ff0181485286cf0c9d453d6/zope_interface-8.4.tar.gz", hash = "sha256:9dbee7925a23aa6349738892c911019d4095a96cff487b743482073ecbc174a8", size = 257736, upload-time = "2026-04-25T07:22:10.439Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/79/11/bd982648e1e62d7c06a56017fd88d1beea2ebc8d7a5972cce137e774aff2/zope_interface-8.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8b733af6e89a2b0b8edf5ff7a37988fe4e1788806e84e72127b88c47858f0da6", size = 210908, upload-time = "2026-04-25T07:27:53.363Z" }, - { url = "https://files.pythonhosted.org/packages/2d/4f/fa87d3bd69d22b93fa5b968597a3dd0a297e44aa87e4611b0ca74c4aeec1/zope_interface-8.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:265bad2df2ec070f23ff863249a89b408b11908fd4207662781fd18e3c6fc912", size = 211235, upload-time = "2026-04-25T07:27:55.392Z" }, - { url = "https://files.pythonhosted.org/packages/eb/74/67379f7df4400ee45299c5200f17ec6c493e8a120ff4e5e9d26b09e32956/zope_interface-8.4-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:e195e76767847afb5379ffd67690c17d3c6efdab58dc0e477cf81ac94d5a5a15", size = 259918, upload-time = "2026-04-25T07:27:57.705Z" }, - { url = "https://files.pythonhosted.org/packages/b2/4e/c5106672b5c0b9071ce988d54124277762c3085cf1bc72e965e7173f1c26/zope_interface-8.4-cp311-cp311-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5ec1a56b6cf9a757cbbce9da38284a01473b92b96c1517eabd99150f51f1bb69", size = 264343, upload-time = "2026-04-25T07:27:59.96Z" }, - { url = "https://files.pythonhosted.org/packages/fe/49/270c11e54e01b96d2efc59acbeb006a4171b8fafb75926c27d2184c32949/zope_interface-8.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:04c2c9b58e9c177628715d85e94834efa807c1f9f0a2f57ae0f7b553e8266ac4", size = 265629, upload-time = "2026-04-25T07:28:02.086Z" }, - { url = "https://files.pythonhosted.org/packages/b9/03/4ef05ada2230f05f08f579c45b60f127cce2bf379148cb7c21401052ca9d/zope_interface-8.4-cp311-cp311-win_amd64.whl", hash = "sha256:376d0ef005a131b349e2088e302aa094fa23c826d2ec8a7db4b00fb33c71e0d9", size = 214595, upload-time = "2026-04-25T07:28:04.408Z" }, - { url = "https://files.pythonhosted.org/packages/6a/f6/22a304f4061d7ec02e20816d804ab0e844564055b25d471371173c44d73e/zope_interface-8.4-cp311-cp311-win_arm64.whl", hash = "sha256:caffd033b27e311b45e15f01923cc9e73c6bfd8e843b4532e29b59ee432bf893", size = 212904, upload-time = "2026-04-25T07:28:06.045Z" }, - { url = "https://files.pythonhosted.org/packages/b8/96/0017b980424125cf98a9851d8fd3e24939818b7a82ecdd19ae672bb2413f/zope_interface-8.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:84064876ed96ddd0744e3ad5d37134c758d77885e54113567792671405a02bac", size = 211604, upload-time = "2026-04-25T07:28:08.13Z" }, - { url = "https://files.pythonhosted.org/packages/59/4c/2cf5c45477fdd58a2c786d0c0d1817cbaaff8743d98ae72c643c4fe3be7b/zope_interface-8.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:81ed23698bfb588c48b1756129814b890febac971ff6c8a414f82601773145bb", size = 211783, upload-time = "2026-04-25T07:28:10.028Z" }, - { url = "https://files.pythonhosted.org/packages/fa/8c/efabdafc25ed44ef9c1084aad9870bb6c2c9b78e542684efe6865c0f0067/zope_interface-8.4-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:e0b9d7e958657fad414f8272afcdf0b8a873fbbb2bb6a6287232d2f11a232bf8", size = 264752, upload-time = "2026-04-25T07:28:11.773Z" }, - { url = "https://files.pythonhosted.org/packages/53/5a/c4d52c58d5fee4ff67cc02f0dec24d0e84428520f67a52f1e4086f0e7779/zope_interface-8.4-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eef0a49e041f4dc4d2a6ab894b4fd0c5354e0e8037e731fb953531e59b0d3d33", size = 269829, upload-time = "2026-04-25T07:28:13.988Z" }, - { url = "https://files.pythonhosted.org/packages/16/d2/df8f339c93bb5adee695546ba90d0daa2917338a4792281f6b8e652a9328/zope_interface-8.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8b302f955c36e924e1f4fe70dd9105ff06235857861c6ae72c3b10b016aeee99", size = 269452, upload-time = "2026-04-25T07:28:16.403Z" }, - { url = "https://files.pythonhosted.org/packages/17/4b/bd97b1a21bb2c16d66a42f6c7a43c0a5afcfaf14c68d3b7d2ee6afb28e52/zope_interface-8.4-cp312-cp312-win_amd64.whl", hash = "sha256:4ae6a1e111642dbf724f635424dcaf5a5c8abbde49eac3f452f5323ffaa10232", size = 214420, upload-time = "2026-04-25T07:28:18.405Z" }, - { url = "https://files.pythonhosted.org/packages/7d/85/1477f23cf3b0476608ca987b4338f91439abb5b96564ac26b26d2cde38fd/zope_interface-8.4-cp312-cp312-win_arm64.whl", hash = "sha256:2e9e4aa33b76877af903d5532545e64d24ade0f6f80d9d1a31e6efcea76a60bc", size = 212992, upload-time = "2026-04-25T07:28:20.48Z" }, - { url = "https://files.pythonhosted.org/packages/8e/6a/a08c62bc1fa0e34fe7b8b401646cba4817427c716bfbef6cc88937cd327f/zope_interface-8.4-cp313-cp313-macosx_10_9_x86_64.whl", hash = "sha256:cd55965d715413038774aead54851bc3dbdd74a69f3ce30252182a94407b9905", size = 211924, upload-time = "2026-04-25T07:28:22.219Z" }, - { url = "https://files.pythonhosted.org/packages/50/30/2011f17e00ff078658bc317e1f7eccd7843fc1ce60695b665b0a52c45c1b/zope_interface-8.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0d88c1f106a4f06e074a3ada2d20f4a602e3f2871c4f55726ed5d91e94ec19b1", size = 211995, upload-time = "2026-04-25T07:28:24.107Z" }, - { url = "https://files.pythonhosted.org/packages/25/f3/a16fe884571cfa89271412dbb40def6d6865824428d1e14785a82795100c/zope_interface-8.4-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:36c575356732d59ffd3279ad67e302a6fe517e67db5b061b36b377ee0fa016c4", size = 264443, upload-time = "2026-04-25T07:28:26.401Z" }, - { url = "https://files.pythonhosted.org/packages/83/88/e08923fcd8a8c8704af05a90418b07cd897ac90865925b37d7ad8139adfa/zope_interface-8.4-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:29f09ec8bda65f7b30294328070070a2590b90f252f834ee0817cdb0e2c35f6a", size = 269626, upload-time = "2026-04-25T07:28:28.423Z" }, - { url = "https://files.pythonhosted.org/packages/27/67/96c94cd307f9946d0b0f03402a335f7aae7b4f0b129b5734cc56cc78cb65/zope_interface-8.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2bc388cebcb753d21eaf2a0481fd6f0ce6840a47300a40dcec0b56bac27d0f97", size = 269583, upload-time = "2026-04-25T07:28:30.434Z" }, - { url = "https://files.pythonhosted.org/packages/e2/d4/7e9fcc8bb0dba5d023b9fca92035d68c018457cc550e9d51746670b76a6b/zope_interface-8.4-cp313-cp313-win_amd64.whl", hash = "sha256:3e5866917ccb57d929e515a1136d729bd3fa4f367965fb16e38a4bc72cb05521", size = 214422, upload-time = "2026-04-25T07:28:32.201Z" }, - { url = "https://files.pythonhosted.org/packages/16/26/b0bcde302f6a4c155d047a8ab5cba1003363031919d6e8f3bcdc139c28a6/zope_interface-8.4-cp313-cp313-win_arm64.whl", hash = "sha256:f1f854bef8bc137519e4413bcc1322d55faad28b20b3ca39f7bec49d2f1b26df", size = 213029, upload-time = "2026-04-25T07:28:34.677Z" }, - { url = "https://files.pythonhosted.org/packages/f6/d5/ca60c8b404b303d9490e1417430a5198a77557dbeb17c1cb31616e432318/zope_interface-8.4-cp314-cp314-macosx_10_9_x86_64.whl", hash = "sha256:7cbb887fdbfaacb4c362dbb487033551646e28013ad5ffe72e96eb260003a1a1", size = 212012, upload-time = "2026-04-25T07:28:36.88Z" }, - { url = "https://files.pythonhosted.org/packages/83/64/6bb9f54250c817e24b39e986f173b6cd21ff658bec6c6cc0baad05d761e4/zope_interface-8.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a5638c6be715116d3453e6d099c299c6844d54810de7445ce116424e905ede06", size = 212071, upload-time = "2026-04-25T07:28:38.742Z" }, - { url = "https://files.pythonhosted.org/packages/c6/cf/42851262e102723058019dc7d0b48210b85a935f79ae32ce60ddccc2e8fb/zope_interface-8.4-cp314-cp314-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:b8147b40bfcd53803870a9519e0879ff066aeecc2fcff8295663c1b17fc38dc2", size = 266075, upload-time = "2026-04-25T07:28:41.084Z" }, - { url = "https://files.pythonhosted.org/packages/d2/a7/e48c79b836f6f0a2c219288e2ec343517f90e95c93de5435a8a23918bf20/zope_interface-8.4-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:049ba3c7b38cc400ae08e011617635706e0f442e1d075db1b015246fcbf6091e", size = 269127, upload-time = "2026-04-25T07:28:42.868Z" }, - { url = "https://files.pythonhosted.org/packages/6a/40/0e26f24d3a2f34f0de2cfeaab6458a865284d9d1fa317ab78913aa1f7322/zope_interface-8.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9c4ac009c2c8e43283842f80387c4d4b41bcbc293391c3b9ab71532ae1ccc301", size = 269446, upload-time = "2026-04-25T07:28:44.97Z" }, - { url = "https://files.pythonhosted.org/packages/91/d5/20310601450367fc35fa28b0544c98d0347b8cc25eaf106a2c4cc36841e1/zope_interface-8.4-cp314-cp314-win_amd64.whl", hash = "sha256:4713bf651ec36e7eea49d2ace4f0e89bec2b33a339674874b1121f2537edc62a", size = 215199, upload-time = "2026-04-25T07:28:47.146Z" }, - { url = "https://files.pythonhosted.org/packages/5b/00/0d22ce75126e31f81baa5889e2a40aad37c8e34d1220cf8b18d744f2b5d9/zope_interface-8.4-cp314-cp314-win_arm64.whl", hash = "sha256:d934497c4b72d5f528d2b5ebe9b8b5a7004b5877948ebd4ea00c2432fb27178f", size = 213178, upload-time = "2026-04-25T07:28:48.868Z" }, -]