From 2e10ae227fe3ab8ceb0da79ad0d0710cbb6a42cf Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 23 Oct 2026 06:47:57 -0400 Subject: [PATCH 1/3] Remove the v1 user-profile and saved-policy routes POST, GET and PUT /{country_id}/user-profile and POST, GET and PUT /{country_id}/user-policy read and changed user profiles and saved policies by a user_id, auth0_id or record id taken from the request, and checked no credential. App v2 does not call them, and #4017 reports no production requests to them in the 30 days to 2026-10-19. Signed-in users reach their saved work through the v2 routes, which take the owner from the verified credential. Found by the 2026-10-22 security review. The deletions match #4017's: the user-profile blueprint and v1 user service go, the saved-policy routes and their helpers leave policy_routes.py, the app-v2 contract registry drops the saved-policy workflow and the generated contract documents are refreshed. As there, the v1 saved-policy service and copying modules stay until dual-write is deleted. GET /{country_id}/policies stays; #4017 removes it with the legacy site. New tests state the invariant that every legacy user record operation is owner-scoped: the deployed application serves neither path, no module an HTTP route can import touches the v1 user-record tables, and a Hypothesis property over generated requests to the removed paths (any method, country, identifier, ownership fields and credentials) gets 404 or 405 and leaves every stored user record unchanged. The Cloud Run candidate check asserts the removed paths answer 404. hypothesis joins the dev extra. Tests (failed first on origin/master: the routes were registered and reachable from the route modules, and requests to them reached the handlers instead of answering 404): the new invariant tests, then the route boundary, contract and service-boundary tests. Fixes #4028 Co-Authored-By: Claude Opus 5.5 --- .gitignore | 3 + .../remove-legacy-user-routes.removed.md | 1 + docs/engineering/migration-contracts.md | 15 +- docs/engineering/skills/testing.md | 4 +- docs/generated/migration_contracts.json | 51 +- docs/migration/stage-10-v2-policies.md | 13 + policyengine_api/api.py | 6 - policyengine_api/routes/policy_routes.py | 363 +-------- .../routes/user_profile_routes.py | 162 ---- policyengine_api/services/user_service.py | 140 ---- pyproject.toml | 1 + tests/contract/registry.py | 40 - .../test_app_v2_workflow_contracts.py | 1 - tests/contract/test_v1_route_contracts.py | 104 --- tests/fixtures/services/user_service.py | 26 - tests/integration/test_cloud_run_candidate.py | 7 + .../python/test_user_profile_routes.py | 99 --- .../routes/test_flask_route_boundaries.py | 3 - ...test_household_under_policy_orm_routes.py} | 45 +- .../test_legacy_user_records_owner_scoped.py | 334 ++++++++ .../routes/test_set_user_policy_dataset.py | 40 - tests/unit/routes/test_update_user_policy.py | 133 ---- .../test_user_policy_dual_write_routes.py | 736 ------------------ .../routes/test_user_policy_route_outcomes.py | 491 ------------ tests/unit/services/test_direct_orm_users.py | 46 -- .../services/test_orm_service_boundaries.py | 4 +- ...test_service_owned_session_architecture.py | 2 - .../services/test_service_owned_sessions.py | 3 - .../unit/services/test_user_policy_service.py | 7 +- tests/unit/services/test_user_service.py | 88 --- .../unit/test_migration_contract_artifacts.py | 5 +- uv.lock | 99 ++- 32 files changed, 473 insertions(+), 2599 deletions(-) create mode 100644 changelog.d/remove-legacy-user-routes.removed.md delete mode 100644 policyengine_api/routes/user_profile_routes.py delete mode 100644 policyengine_api/services/user_service.py delete mode 100644 tests/fixtures/services/user_service.py delete mode 100644 tests/to_refactor/python/test_user_profile_routes.py rename tests/unit/routes/{test_household_and_user_policy_orm_routes.py => test_household_under_policy_orm_routes.py} (77%) create mode 100644 tests/unit/routes/test_legacy_user_records_owner_scoped.py delete mode 100644 tests/unit/routes/test_set_user_policy_dataset.py delete mode 100644 tests/unit/routes/test_update_user_policy.py delete mode 100644 tests/unit/routes/test_user_policy_dual_write_routes.py delete mode 100644 tests/unit/routes/test_user_policy_route_outcomes.py delete mode 100644 tests/unit/services/test_direct_orm_users.py delete mode 100644 tests/unit/services/test_user_service.py diff --git a/.gitignore b/.gitignore index d69167e9d..ac56a2191 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,6 @@ gha-creds-*.json ## vscode settings /.vscode *.db.init.lock + +# Hypothesis example database +.hypothesis/ diff --git a/changelog.d/remove-legacy-user-routes.removed.md b/changelog.d/remove-legacy-user-routes.removed.md new file mode 100644 index 000000000..7e41d247f --- /dev/null +++ b/changelog.d/remove-legacy-user-routes.removed.md @@ -0,0 +1 @@ +Removed the v1 user-profile routes (`/{country_id}/user-profile`) and saved-policy routes (`/{country_id}/user-policy`), which read and changed records by a caller-supplied user or record ID without a credential; signed-in users keep their saved work through the `/v2` routes, which take the owner from the verified credential. diff --git a/docs/engineering/migration-contracts.md b/docs/engineering/migration-contracts.md index f39a5e369..a84e323a6 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 | 16 | -| request count | 89 | +| workflow count | 15 | +| request count | 86 | | db entity count | 6 | | sim flow count | 3 | @@ -50,17 +50,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` | | `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 51c36817d..8377dc49b 100644 --- a/docs/engineering/skills/testing.md +++ b/docs/engineering/skills/testing.md @@ -187,7 +187,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 @@ -221,8 +220,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 7a0f1b67d..73c49d01e 100644 --- a/docs/generated/migration_contracts.json +++ b/docs/generated/migration_contracts.json @@ -1,10 +1,10 @@ { "metadata": { "db_entity_count": 6, - "request_count": 89, + "request_count": 86, "route_group_count": 9, "sim_flow_count": 3, - "workflow_count": 16 + "workflow_count": 15 }, "route_groups": [ { @@ -203,53 +203,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/stage-10-v2-policies.md b/docs/migration/stage-10-v2-policies.md index 252bc940e..f21348499 100644 --- a/docs/migration/stage-10-v2-policies.md +++ b/docs/migration/stage-10-v2-policies.md @@ -207,3 +207,16 @@ 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. They read and changed saved +policies by a caller-supplied `user_id` or record `id` with no credential, and +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. Signed-in users keep their saved +policies through `/v2/user-policies`, which takes the owner from the verified +credential. diff --git a/policyengine_api/api.py b/policyengine_api/api.py index f2beba2fb..df63eee59 100644 --- a/policyengine_api/api.py +++ b/policyengine_api/api.py @@ -50,9 +50,6 @@ 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 @@ -129,9 +126,6 @@ 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") diff --git a/policyengine_api/routes/policy_routes.py b/policyengine_api/routes/policy_routes.py index 1eef717b2..ab8dd2d23 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, ) @@ -25,17 +22,6 @@ mirror_policy_after_commit, ) from policyengine_api.services.policy_service import 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, @@ -44,9 +30,8 @@ policy_bp = Blueprint("policy", __name__) policy_service = PolicyService() -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 @@ -119,50 +104,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_category="missing_mirror_revision", - ) - 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_category="unexpected", - ) - - 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) @@ -172,62 +115,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, @@ -327,26 +214,6 @@ def set_policy(country_id: str) -> Response: 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: @@ -388,225 +255,3 @@ def get_policy_search(country_id: str) -> 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/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/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/pyproject.toml b/pyproject.toml index 1b0745528..84ece29ac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,6 +67,7 @@ dependencies = [ dev = [ "build", "coverage", + "hypothesis>=6.100", "mypy>=1.15,<2", "pytest", "pytest-snapshot", diff --git a/tests/contract/registry.py b/tests/contract/registry.py index 778c3da26..66bd2c11d 100644 --- a/tests/contract/registry.py +++ b/tests/contract/registry.py @@ -216,46 +216,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 ff861db14..cedfa70fd 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..8819c8518 100644 --- a/tests/contract/test_v1_route_contracts.py +++ b/tests/contract/test_v1_route_contracts.py @@ -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, @@ -214,21 +208,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 +253,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,32 +266,6 @@ 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", @@ -521,27 +438,6 @@ def _expected_metadata_subset(country_id: str) -> dict: "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, 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..0430d0fe2 100644 --- a/tests/integration/test_cloud_run_candidate.py +++ b/tests/integration/test_cloud_run_candidate.py @@ -36,6 +36,13 @@ def test_cloud_run_candidate_metadata_and_policy( assert policy_payload["result"]["id"] == current_law_id +def test_cloud_run_candidate_serves_no_legacy_user_routes(api_client): + # Only reads: a candidate that still served these routes would answer 200. + for path in ("/us/user-profile?user_id=1", "/us/user-policy/candidate-probe"): + response = api_client.get(path) + assert response.status_code == 404, (path, response.text) + + 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 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/routes/test_flask_route_boundaries.py b/tests/unit/routes/test_flask_route_boundaries.py index a9b5ccac5..888ecd2ce 100644 --- a/tests/unit/routes/test_flask_route_boundaries.py +++ b/tests/unit/routes/test_flask_route_boundaries.py @@ -32,9 +32,6 @@ def test_legacy_flask_urls_are_owned_by_blueprints(): ("//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"), 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 46efdcd1b..a3fe6f9f2 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_legacy_user_records_owner_scoped.py b/tests/unit/routes/test_legacy_user_records_owner_scoped.py new file mode 100644 index 000000000..4b4e72363 --- /dev/null +++ b/tests/unit/routes/test_legacy_user_records_owner_scoped.py @@ -0,0 +1,334 @@ +"""Every operation on a legacy (v1) user record is owner-scoped. + +The v1 user records are ``user_profiles``, ``user_policies`` and the +``user_policy_mirror_events`` that copy saved policies to v2. The routes that +read and changed them (``/{country_id}/user-profile`` and +``/{country_id}/user-policy``) took the owner from the request (a ``user_id``, +``auth0_id`` or record ``id``) and checked no credential, so they are removed. +Signed-in users reach their saved work through the v2 routes, which take the +owner from the verified credential. What these tests hold: + +1. No route of the deployed application, Flask or native, serves the removed + paths. +2. No module an HTTP route can import reads or writes those tables: the v1 + user-record models and the v1 saved-policy service are out of reach. +3. For every request to the removed paths, whatever its method, country, + identifier, query, body or credentials, the answer is 404 or 405, and no + stored user record is changed or echoed back. +""" + +import ast +import json +from pathlib import Path +from unittest.mock import patch + +import pytest +from fastapi.testclient import TestClient +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st +from sqlalchemy import select + +from policyengine_api.asgi_factory import NativeRouteDependencies, create_asgi_app +from policyengine_api.constants import COUNTRIES +from policyengine_api.data.v1_models import ( + UserPolicy, + UserPolicyMirrorEvent, + UserProfile, +) +from policyengine_api.libs.user_auth import UNAUTHENTICATED_USER_HEADER +from policyengine_api.migration_flags import ( + RouteImplementation, + RouteImplementationSettings, +) + +PACKAGE_ROOT = Path(__file__).parents[3] / "policyengine_api" +LEGACY_USER_PATH_SEGMENTS = ("user-profile", "user-policy") +# Modules that read or write the v1 user-record tables. +LEGACY_USER_RECORD_MODULES = frozenset( + { + "policyengine_api.services.user_policy_service", + "policyengine_api.services.user_policy_mirroring", + "policyengine_api.services.user_service", + "policyengine_api.routes.user_profile_routes", + } +) +LEGACY_USER_RECORD_MODELS = frozenset( + {"UserProfile", "UserPolicy", "UserPolicyMirrorEvent"} +) +# Everything that serves HTTP: the two application modules and every route +# module, Flask and native. +HTTP_ENTRY_POINTS = ( + "policyengine_api.api", + "policyengine_api.asgi", + "policyengine_api.asgi_factory", +) +SEEDED_SECRETS = ( + "auth0|seeded-owner", + "seeded-username", + "Seeded saved reform", + "auth0|seeded-saved-policy-owner", +) + + +class _HealthyGateway: + def check_health(self) -> None: + return None + + +class _MetadataReader: + def get_metadata(self, country_id: str) -> dict: + return {"country_id": country_id} + + +def _module_name(path: Path) -> str: + relative = path.relative_to(PACKAGE_ROOT.parent).with_suffix("") + parts = relative.parts + if parts[-1] == "__init__": + parts = parts[:-1] + return ".".join(parts) + + +def _package_modules() -> dict[str, ast.Module]: + return { + _module_name(path): ast.parse(path.read_text(encoding="utf-8")) + for path in PACKAGE_ROOT.rglob("*.py") + } + + +def _imported_names(module_name: str, tree: ast.Module) -> set[str]: + """Every module this one imports anywhere in its body, function-local included.""" + package = module_name.rsplit(".", 1)[0] + imported: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + imported.update(alias.name for alias in node.names) + elif isinstance(node, ast.ImportFrom): + base = node.module or "" + if node.level: + anchor = package.rsplit(".", node.level - 1)[0] + base = f"{anchor}.{base}" if base else anchor + imported.add(base) + imported.update(f"{base}.{alias.name}" for alias in node.names) + return imported + + +def _http_reachable_modules() -> set[str]: + modules = _package_modules() + roots = { + name + for name in modules + if name in HTTP_ENTRY_POINTS + or name.startswith( + ("policyengine_api.routes.", "policyengine_api.fastapi_routes.") + ) + } + reachable: set[str] = set() + pending = list(roots) + while pending: + name = pending.pop() + if name in reachable or name not in modules: + continue + reachable.add(name) + for imported in _imported_names(name, modules[name]): + # Importing a.b.c also runs a and a.b. + parts = imported.split(".") + pending.extend(".".join(parts[:end]) for end in range(1, len(parts) + 1)) + return reachable + + +def _imports_legacy_user_model(tree: ast.Module) -> set[str]: + return { + alias.name + for node in ast.walk(tree) + if isinstance(node, ast.ImportFrom) + and (node.module or "").endswith("data.v1_models") + for alias in node.names + if alias.name in LEGACY_USER_RECORD_MODELS + } + + +@pytest.fixture +def asgi_client(mock_v2_runtime_settings): + from policyengine_api.api import app as flask_app + + app = create_asgi_app( + flask_app, + route_settings=RouteImplementationSettings( + health=RouteImplementation.FASTAPI_NATIVE, + specification=RouteImplementation.FASTAPI_NATIVE, + metadata=RouteImplementation.FASTAPI_NATIVE, + ), + dependencies=NativeRouteDependencies( + readiness_probe=lambda: True, + gateway_client_factory=_HealthyGateway, + metadata_reader_factory=_MetadataReader, + specification_provider=lambda: {"openapi": "3.0.0"}, + ), + ) + with TestClient(app) as client: + yield client + + +@pytest.fixture +def seeded_user_records(orm_session_factory): + with orm_session_factory.begin() as session: + session.add( + UserProfile( + user_id=1, + auth0_id="auth0|seeded-owner", + username="seeded-username", + primary_country="us", + user_since=1, + ) + ) + session.add( + UserPolicy( + id=1, + country_id="us", + reform_id=2, + reform_label="Seeded saved reform", + baseline_id=1, + baseline_label="Current law", + user_id="auth0|seeded-saved-policy-owner", + year="2026", + geography="us", + dataset=None, + number_of_provisions=1, + api_version="1", + added_date=1, + updated_date=1, + budgetary_impact=None, + type=None, + ) + ) + return orm_session_factory + + +def _user_record_snapshot(session_factory) -> tuple: + """Every stored v1 user record, by column value.""" + with session_factory() as session: + return tuple( + tuple( + tuple(getattr(row, column.name) for column in model.__table__.columns) + for row in session.scalars(select(model)) + ) + for model in (UserProfile, UserPolicy, UserPolicyMirrorEvent) + ) + + +def test_deployed_application_serves_no_legacy_user_route(asgi_client): + from policyengine_api.api import app as flask_app + + flask_rules = {rule.rule for rule in flask_app.url_map.iter_rules()} + native_paths = { + getattr(route, "path", "") for route in asgi_client.app.app.router.routes + } + + for path in flask_rules | native_paths: + segments = path.strip("/").split("/") + assert not set(segments) & set(LEGACY_USER_PATH_SEGMENTS), path + + +def test_no_http_route_can_reach_the_legacy_user_record_tables(): + modules = _package_modules() + reachable = _http_reachable_modules() + + # The walk must see the application, or the check proves nothing. + assert { + "policyengine_api.api", + "policyengine_api.routes.policy_routes", + "policyengine_api.fastapi_routes.v2.user_policies.routes", + } <= reachable + assert reachable & LEGACY_USER_RECORD_MODULES == set() + assert { + name: sorted(models) + for name in sorted(reachable) + if (models := _imports_legacy_user_model(modules[name])) + } == {} + + +def test_reachability_check_detects_a_route_that_imports_the_saved_policy_service(): + tree = ast.parse( + "from policyengine_api.services.user_policy_service import UserPolicyService\n" + "from policyengine_api.data.v1_models import UserProfile\n" + ) + + assert "policyengine_api.services.user_policy_service" in _imported_names( + "policyengine_api.routes.example_routes", tree + ) + assert _imports_legacy_user_model(tree) == {"UserProfile"} + + +_identifiers = st.one_of( + st.integers(min_value=-1, max_value=2**31), + st.sampled_from( + ["auth0|seeded-owner", "auth0|seeded-saved-policy-owner", "me", "1"] + ), + st.text( + alphabet=st.characters( + whitelist_categories=("Ll", "Lu", "Nd"), whitelist_characters="|-_." + ), + min_size=1, + max_size=24, + ), +) +_ownership_fields = st.dictionaries( + st.sampled_from( + ["id", "user_id", "auth0_id", "username", "reform_label", "primary_country"] + ), + st.one_of(_identifiers, st.none()), + max_size=6, +) +_credentials = st.one_of( + st.just({}), + st.builds( + lambda token: {"Authorization": f"Bearer {token}"}, + st.text(alphabet="abcdefghijklmnopqrstuvwxyz0123456789.", min_size=1), + ), + st.builds( + lambda value: {UNAUTHENTICATED_USER_HEADER: str(value)}, + st.uuids(), + ), +) + + +@st.composite +def legacy_user_requests(draw) -> dict: + country = draw(st.one_of(st.sampled_from(COUNTRIES), st.just("zz"))) + segment = draw(st.sampled_from(LEGACY_USER_PATH_SEGMENTS)) + suffix = draw(st.one_of(st.just(""), st.builds(lambda i: f"/{i}", _identifiers))) + method = draw(st.sampled_from(["GET", "POST", "PUT", "PATCH", "DELETE"])) + query = draw(_ownership_fields) + body = draw(st.one_of(st.none(), _ownership_fields)) + return { + "method": method, + "url": f"/{country}/{segment}{suffix}", + "params": {key: str(value) for key, value in query.items()}, + "json": body, + "headers": draw(_credentials), + } + + +@settings( + max_examples=150, + deadline=None, + suppress_health_check=[HealthCheck.function_scoped_fixture], +) +@given(request=legacy_user_requests()) +def test_no_request_to_a_removed_path_reads_or_changes_a_user_record( + asgi_client, seeded_user_records, request +): + before = _user_record_snapshot(seeded_user_records) + + with patch("policyengine_api.migration_logging.logger"): + response = asgi_client.request( + request["method"], + request["url"], + params=request["params"], + content=None if request["json"] is None else json.dumps(request["json"]), + headers={"Content-Type": "application/json", **request["headers"]}, + ) + + assert response.status_code in {404, 405}, (request, response.text) + assert not any(secret in response.text for secret in SEEDED_SECRETS) + assert _user_record_snapshot(seeded_user_records) == before 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 7233002b4..000000000 --- a/tests/unit/routes/test_user_policy_dual_write_routes.py +++ /dev/null @@ -1,736 +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["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["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/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_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_service_owned_session_architecture.py b/tests/unit/services/test_service_owned_session_architecture.py index a2829777a..8395158fa 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" @@ -43,7 +42,6 @@ "update_user_policy", ), ), - (UserService, ("get_profile", "create_profile", "update_profile")), ( SimulationService, ( diff --git a/tests/unit/services/test_service_owned_sessions.py b/tests/unit/services/test_service_owned_sessions.py index 5e372b51c..d35eee1f4 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" @@ -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_migration_contract_artifacts.py b/tests/unit/test_migration_contract_artifacts.py index d08090feb..82baa5fe3 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": 16, - "request_count": 89, + "workflow_count": 15, + "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/uv.lock b/uv.lock index 6fa0db364..869aef00c 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 5 +revision = 3 requires-python = ">=3.11" resolution-markers = [ "python_full_version >= '3.15' and sys_platform == 'win32'", @@ -1435,6 +1435,99 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/71/c1/1fa4162f6dd53259daf2ad31385273341821fa0acce164cd03971937a60e/huggingface_hub-1.12.2-py3-none-any.whl", hash = "sha256:7968e897fdbc6343c871c240d87d4434efe0ad9f80d57daa1cc5678c6d148529", size = 647757, upload-time = "2026-04-29T09:45:07.63Z" }, ] +[[package]] +name = "hypothesis" +version = "6.170.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d7/1f/d08bc6d4548cf5f6a022895d2f32c23e981fb417517358aebe612544a2ad/hypothesis-6.170.8.tar.gz", hash = "sha256:dfc56ceac35d825fb6a85fa3ac36a598fee3101d264a39895a62f78c1cc95521", size = 518977, upload-time = "2026-10-22T23:01:19.151Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/51/f92725033fcbe58da360e7ab5fa28e6eb18f2b1b61380d9fbb0db4f25e76/hypothesis-6.170.8-cp311-abi3-macosx_10_12_x86_64.whl", hash = "sha256:1df9dc7e7e2592d23d7b23146608606d017f56b0e3a35fa96bcae972b834ee5c", size = 797597, upload-time = "2026-10-22T23:00:37.287Z" }, + { url = "https://files.pythonhosted.org/packages/fe/b8/af62021a917aa96820405743638c900acecec93b09414e832c18093635f2/hypothesis-6.170.8-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:2e90a5eb5e669788659ed3a2752e45d87c8efe95d578f22cd7657196b67de273", size = 792728, upload-time = "2026-10-22T22:59:36.553Z" }, + { url = "https://files.pythonhosted.org/packages/4c/00/d736082ba98514bc1e10ba9fbbb85e36dce0586ad159a9f4aee976728a34/hypothesis-6.170.8-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c9fd6bff853e7c9bce11b22d426f533a7d82ac38f5503ee3615b8b2580692bf", size = 1121647, upload-time = "2026-10-22T22:59:59.096Z" }, + { url = "https://files.pythonhosted.org/packages/76/d4/0cf4d8eaa23677aea550d6dd60c3827d1fc0dde1693d43cc8ad42707b086/hypothesis-6.170.8-cp311-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8814ab69b474b216b6540e77c6df23a5d66852c32398a74efd47f4435615094a", size = 1152210, upload-time = "2026-10-22T23:01:00.305Z" }, + { url = "https://files.pythonhosted.org/packages/d6/cd/06eed98acee61e4bc926c8cc6d4cfdeabd0a96adb1c6301070189607791e/hypothesis-6.170.8-cp311-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:44eb8b223128239bd7ad94988abc288b406ef4ebf4d366a3a3a1900977259256", size = 1146065, upload-time = "2026-10-22T22:59:39.145Z" }, + { url = "https://files.pythonhosted.org/packages/85/ca/f5e95c9f49f72caf52287e50b761414209654fdda28cb0ec0dc5d44d36e4/hypothesis-6.170.8-cp311-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:73b106f4365c6cbe6e388e96e063344c8ed0de3428b7332c370274d50302ec57", size = 1198776, upload-time = "2026-10-22T22:59:41.683Z" }, + { url = "https://files.pythonhosted.org/packages/6c/29/d3df029d4edd2beca446ba4fc2cfd2b60d2b5ea11cb2172ecec444055e1b/hypothesis-6.170.8-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17f665953a850fa99358a3ee9b36f8b99d2b8743be9f76b247711fba4fd53912", size = 1165074, upload-time = "2026-10-22T23:00:48.8Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a2/0363b0024cb6d1902b4e79d447262c99bd5def0eafdd3ef18ad8ba7df90d/hypothesis-6.170.8-cp311-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:234528678cc3124347afb0d794047c16ffc775412524e5315794e6785a53c1d3", size = 1121184, upload-time = "2026-10-22T23:00:21.871Z" }, + { url = "https://files.pythonhosted.org/packages/b6/21/63963a74763a12919591a4faae8c2314a3ae596e3d8497239a9c1570374f/hypothesis-6.170.8-cp311-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3a9a5e5a68b04436777d52e9d22b1b9b91d4a5df37970464c31d585916e9b6eb", size = 1159494, upload-time = "2026-10-22T23:00:54.06Z" }, + { url = "https://files.pythonhosted.org/packages/1a/41/ad311877f9939ec00b434e0a14ff5c3b5e19451f0197364d69bf343872af/hypothesis-6.170.8-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:035f28c2e3cbd6938ba543114ce4f8bb3397af5df9691c5a6284fc2b3f74b090", size = 1296619, upload-time = "2026-10-22T22:59:44.497Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ab/f0f9fe37c2eff19524d1b3bfe01e61f77d2124f51790180e944939d18c0a/hypothesis-6.170.8-cp311-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:d26f10b438326fc60360643e20512d6ce7e975004f548d19f9daa8788db2f933", size = 1425261, upload-time = "2026-10-22T23:01:01.898Z" }, + { url = "https://files.pythonhosted.org/packages/25/9b/bd6abe8c0d6fa675f29bf47c52c4ef72ee1887030af58b8bc4fe00523f0e/hypothesis-6.170.8-cp311-abi3-musllinux_1_2_i686.whl", hash = "sha256:306a46be8490ef9017f984215be4456e12781095cc3c2d156c8d91c08b2f5ea3", size = 1379343, upload-time = "2026-10-22T23:01:05.948Z" }, + { url = "https://files.pythonhosted.org/packages/c1/24/26fc0009a8ab65f94db5389adc98c8dac24433d7cc6770bf37bfbe16b955/hypothesis-6.170.8-cp311-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:f8703a60a5be8156a34c26aa441080741c06ee5c4e20101a3c4b1ea6dbbfe530", size = 1275282, upload-time = "2026-10-22T23:00:43.289Z" }, + { url = "https://files.pythonhosted.org/packages/96/7f/e8235d843a037c1eed2448e1590205477853e7f5b4acd2a8242332aaabb8/hypothesis-6.170.8-cp311-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:753a97a89d1db29652bb64b58b65aa38e12ed74a547f84490970a0730e531477", size = 1291047, upload-time = "2026-10-22T23:00:19.098Z" }, + { url = "https://files.pythonhosted.org/packages/85/41/494265befb96abe58023640d537750eb7f9c15cf7ae6183ba4ccf274d281/hypothesis-6.170.8-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b8a2addb49a77871fe67611e0b062fab7618f2faa151a5d8f2558947a876fdfa", size = 1330783, upload-time = "2026-10-22T23:00:39.506Z" }, + { url = "https://files.pythonhosted.org/packages/1f/88/b6928822b6cfe88c7765c7151bdcc3238b0bfc37074fbe6fe7c70b913190/hypothesis-6.170.8-cp311-abi3-win32.whl", hash = "sha256:126f7b7ad551394835ade8ad4c3467af27c4e6a25e566a10c644cd27f4b668b0", size = 684777, upload-time = "2026-10-22T23:00:44.754Z" }, + { url = "https://files.pythonhosted.org/packages/85/e7/d9db34e28733bcfb6802486bb87ece82a4b046705aeeecff24d4e746f5f6/hypothesis-6.170.8-cp311-abi3-win_amd64.whl", hash = "sha256:a74c01e413f51ebb9d3ef701e5e0487945f5515e0f26411d0178f25918bdd7de", size = 690852, upload-time = "2026-10-22T23:01:04.398Z" }, + { url = "https://files.pythonhosted.org/packages/95/45/13e0ce6686954006aff190227473a12282557c99f44d397c3fe026cc0eea/hypothesis-6.170.8-cp311-abi3-win_arm64.whl", hash = "sha256:2340d0be6409eede4b96bea1f12f69aad48cb3cce3bd96b28e998b0e4fc4ca13", size = 689167, upload-time = "2026-10-22T23:00:29.335Z" }, + { url = "https://files.pythonhosted.org/packages/86/3e/347ab6986aa6d53f9cbaae26e8739285b3292c3c372d0d060378e7569046/hypothesis-6.170.8-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:1f0b15eed167561ffe014cfe8b64ccddd14451643bf1fddc2d85a456f10b42a8", size = 798316, upload-time = "2026-10-22T22:59:55.059Z" }, + { url = "https://files.pythonhosted.org/packages/d2/57/4d98be1f0877b0d4ac00e1f7f82fb7994bde4a278a0ff2f603a72aa9c0b7/hypothesis-6.170.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f5997df11d3c65b73b81e91cb884d997bdfb50b964ac78ce346639a925844987", size = 793992, upload-time = "2026-10-22T23:00:16.797Z" }, + { url = "https://files.pythonhosted.org/packages/18/65/9f09153a94d36515305be7760bc1f18fbc7d33feb9b8e8b9a60649fd39de/hypothesis-6.170.8-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8ff32395e1b14a2dcbe10cb6a82dbdac51b480d7fdd0c085d9ba3c131a1ba3aa", size = 1122616, upload-time = "2026-10-22T22:59:37.699Z" }, + { url = "https://files.pythonhosted.org/packages/23/2c/c12aa7e546b80ea3b205692a77b2a4bfa93094d18277d2b25e1bfa71d2d8/hypothesis-6.170.8-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a30e50ae2ea5800083ac22dede1f3cbdebfe21bface71256ebf1bc8d95b5ad4", size = 1165319, upload-time = "2026-10-22T22:59:34.769Z" }, + { url = "https://files.pythonhosted.org/packages/a6/75/823f940c4d445dee8a9664868b055c4e22d91b5c91956a6dbc8fac86b415/hypothesis-6.170.8-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b7cf89166db8e79998f9db644d6e14a29a2feb190ed46fc8d0761f4a3d3e0fe", size = 1297384, upload-time = "2026-10-22T23:01:03.146Z" }, + { url = "https://files.pythonhosted.org/packages/f5/07/10fe91204c32d0a457a4757e165dc505a8dfac1969f5a672e1e68501ae1c/hypothesis-6.170.8-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9854a3727e2aa0b2b7750b2065a4ecd8dc6656a2645e6a12e1cd23c4d2ac4ebb", size = 1331363, upload-time = "2026-10-22T22:59:45.974Z" }, + { url = "https://files.pythonhosted.org/packages/2f/df/a489ae4936218a66274bf420733fba3edc04f323d617be59e08cc0532c1e/hypothesis-6.170.8-cp311-cp311-win_amd64.whl", hash = "sha256:23d11a342eb539b458566ef102949e074be5be48bc173f7e9076ae3bf8f0c7c1", size = 690961, upload-time = "2026-10-22T23:00:51.3Z" }, + { url = "https://files.pythonhosted.org/packages/ea/0c/067773a7285aa019eaa8d795379f44276a4f8434a61d96e2a05796b627a0/hypothesis-6.170.8-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:d8ec59dbd3fcf50e80296044f8eed5bbd4ac41d500e49e7889623e8d84345f9d", size = 799373, upload-time = "2026-10-22T23:00:35.002Z" }, + { url = "https://files.pythonhosted.org/packages/9d/e5/1936319d990eb29f636241cfe43a28e8e324f73e52bcaa628655d7db3a11/hypothesis-6.170.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:93df677e9c289c4aa61468446558c7f34a8985a870d373feaa3fb48095c03bce", size = 791385, upload-time = "2026-10-22T23:00:14.176Z" }, + { url = "https://files.pythonhosted.org/packages/af/a4/882bdbae3c60a91dc0713e34c43006f87780eb37775f3d0c56052ee149ca/hypothesis-6.170.8-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf55c53055f6f3effb1503903d5939eea221004daefa1e12a66176358eb47bfb", size = 1121045, upload-time = "2026-10-22T22:59:31.96Z" }, + { url = "https://files.pythonhosted.org/packages/5f/8d/051d191bc91e23847a388126adbe02dfbb40ab74124aac804cfab961b428/hypothesis-6.170.8-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76e2d62bfac25fba0d42072a0bdf5b2a0b34ca4fde247871fb807f6a8b1c9d6d", size = 1164722, upload-time = "2026-10-22T23:00:52.894Z" }, + { url = "https://files.pythonhosted.org/packages/cf/f3/31f7d8d41a901d43faa74afd0a4f8b7085dcf82aa312d3ad48878c93b4ee/hypothesis-6.170.8-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c4803e04cebefb00007ee44ede19f2bd7bfedc69dd925425640d843136caac55", size = 1295920, upload-time = "2026-10-22T22:59:56.408Z" }, + { url = "https://files.pythonhosted.org/packages/08/65/8b30ce8b0ca34e0259ba24db741b5650171425260596f0d202eb9ec72226/hypothesis-6.170.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:04a3c4c1c08fb70cc318681198908479d1c0203c3840dc0fae00f3a79f8d1a78", size = 1330362, upload-time = "2026-10-22T23:01:17.6Z" }, + { url = "https://files.pythonhosted.org/packages/c8/a6/3741c5f9c9b0b3765c40da2f9909850eda48eb05057eca9153f4a5409e4b/hypothesis-6.170.8-cp312-cp312-win_amd64.whl", hash = "sha256:e9ae7a6605419ced1a8d38f2209c92c72c58c711c46f9f17b14f15daf65b1159", size = 688477, upload-time = "2026-10-22T22:59:48.416Z" }, + { url = "https://files.pythonhosted.org/packages/4c/94/6705efab6e1a56937e23d1240d7a960a567096ae61170052d62f781883b0/hypothesis-6.170.8-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:ae7f6f8922ba0b591ac7a0e49b11a8998506341c37aa7342d8780f3493fc6c2c", size = 799354, upload-time = "2026-10-22T23:00:30.904Z" }, + { url = "https://files.pythonhosted.org/packages/49/8f/ec15e409880ee6a3d6521876b3e1b6c9e74cda95cb09991adc584fadffa2/hypothesis-6.170.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:974154681ed759a6f25690d05d4b869d81566e1eab7aad5cc2fa20f430c29185", size = 791378, upload-time = "2026-10-22T23:01:07.218Z" }, + { url = "https://files.pythonhosted.org/packages/04/dc/00a1dc37ae049f682c6ac67e13b0157b606b82d243149fe132ecaefef1d5/hypothesis-6.170.8-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e17be3656d93c23b322bb394f80cd82ab9fb5f9d46b6c10205239a156adc9066", size = 1120962, upload-time = "2026-10-22T23:01:12.647Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ae/de4882a3d23b7ee886b051b7ba34bf6b094df3764b55514b9905afcba6a4/hypothesis-6.170.8-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:37e21ec7d3fbea8cb6cf99147a843cfcd6bef83d2940a392eea50dab082ae3df", size = 1164624, upload-time = "2026-10-22T23:00:46.015Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ec/010e5580b4073e7cb7cb6a78f7029a501860687c2ad70283d3a155c4f090/hypothesis-6.170.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b9a4063b725f48daf3de53d2ad07bf23ad4901280bdc56153537656fe66a0da0", size = 1295934, upload-time = "2026-10-22T23:00:57.936Z" }, + { url = "https://files.pythonhosted.org/packages/7e/42/c6d58ffb97ddc4f8d65094d6b2280ca44fef42025814ccf0dc09e39c8bad/hypothesis-6.170.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:27d5e4a0f53cca67db109c79abf2dbd6a166812b48b76449155cfcfc9856cda2", size = 1330401, upload-time = "2026-10-22T23:00:33.701Z" }, + { url = "https://files.pythonhosted.org/packages/1c/d7/47b5796abccccd534bae98b523a18de9e466fe984f0c82f8e413a936c0fd/hypothesis-6.170.8-cp313-cp313-win_amd64.whl", hash = "sha256:ed44a53628385acf09e0ecf2372dc6ccfeba4cd77bd03455277b4667c7ad659d", size = 688457, upload-time = "2026-10-22T23:00:38.381Z" }, + { url = "https://files.pythonhosted.org/packages/af/7b/6751518dae51237094587ca6d31383df6d4f5f1e66d5a10c12ba4000d6be/hypothesis-6.170.8-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:327b00d562bbb64853a688ff87968276536191dbb3de8ffdd98d5db94150533f", size = 799559, upload-time = "2026-10-22T23:00:50.111Z" }, + { url = "https://files.pythonhosted.org/packages/d2/57/b53ce8e4dd347989efc2e445ced86929d5da6244e9e4463f1b94d489b8b5/hypothesis-6.170.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:aae9c503f7fe3ddbf42f24b9348ab9a8494afba130f7cc17165ef171ff4e1e18", size = 791463, upload-time = "2026-10-22T23:00:11.663Z" }, + { url = "https://files.pythonhosted.org/packages/89/94/fe388e54ace3af5cc5ab6112b3da5c4475d73f0cdd390de4f2a9c4442d24/hypothesis-6.170.8-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be2f3e27efca1570bf8171e7061787b8bcc09d419c85057b53d55a2bc13981c3", size = 1121476, upload-time = "2026-10-22T22:59:53.835Z" }, + { url = "https://files.pythonhosted.org/packages/3d/17/3c2c91235b322a3fcd071478074f1c2b6503d6942f258b34dcf3710b383d/hypothesis-6.170.8-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:259327b5a24701b8e00e15e9bbaa51685d74265fa06fcf8928bc67bd01a47f2a", size = 1164845, upload-time = "2026-10-22T23:00:25.641Z" }, + { url = "https://files.pythonhosted.org/packages/65/e7/e517da75a0d27602a856055cda75ac3c6be9b3cce43068aa011cee7d98bc/hypothesis-6.170.8-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1063ee7dd92de0042d43955153af976ba2478883dd2122e5caa692e853d0ab74", size = 1296407, upload-time = "2026-10-22T23:00:56.72Z" }, + { url = "https://files.pythonhosted.org/packages/ca/10/55bdc6e5ea4220a2d85e1d234982360ac564a88f0a17157b2ab295d1c643/hypothesis-6.170.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cec7597a34a08c0d9f0eccef68b5a2aff571aa954401aa7f51612c5298e5c0e0", size = 1330577, upload-time = "2026-10-22T23:00:27.968Z" }, + { url = "https://files.pythonhosted.org/packages/db/1c/a0bfca233cab3715eddb478058b7fb42a6189f0b64c0760063b705e0e185/hypothesis-6.170.8-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:b292ed7e66bb8441dcfb06bc582dab3bee4bf8add604c55ec6017d775ac5ead2", size = 631236, upload-time = "2026-10-22T23:00:26.914Z" }, + { url = "https://files.pythonhosted.org/packages/a2/58/5adc7768c469a513093504443470b2e9c5bd1b10e7e950a1f83782364662/hypothesis-6.170.8-cp314-cp314-win_amd64.whl", hash = "sha256:0e27c224e5f5de54aa986e4d16e3f29d3b148ec34f553a09514eb36dc6683191", size = 688178, upload-time = "2026-10-22T23:00:07.278Z" }, + { url = "https://files.pythonhosted.org/packages/fe/5f/08c7d2bfd47dbb59f9f6690ca28f369eac71bbec1619b808f81e40b7c645/hypothesis-6.170.8-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:5d9438271cc748aa452a17756241a3cd37631d2d97df186322a5c9f451a45448", size = 797728, upload-time = "2026-10-22T23:00:10.117Z" }, + { url = "https://files.pythonhosted.org/packages/90/06/822ba15f39556e69909d76d2d820113b1dfe0085cc5aaeb6ce31d671a703/hypothesis-6.170.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:92831d7e8b6612bea9b5b0a685b8834b1d7a857681eef7721c229eb63912fa04", size = 789956, upload-time = "2026-10-22T23:00:42.12Z" }, + { url = "https://files.pythonhosted.org/packages/c5/e9/51c61d11d6e76aee474644c6e7e2186434e2fb0697a3d87af75ae39564b0/hypothesis-6.170.8-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:75db8f59174934f2ee955fb927dc6b259f5d7217217905bb6105c1d5c2b086c5", size = 1119917, upload-time = "2026-10-22T22:59:49.864Z" }, + { url = "https://files.pythonhosted.org/packages/ea/66/9b1cae99b6196d7a0ff0b5c96e869a49b952ab6c54f91f27cbfd02127fc8/hypothesis-6.170.8-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3252ad50fdf354a373e9771f2bf03f7f67fd5b23e6329d6dbf98f9866ac1f447", size = 1163795, upload-time = "2026-10-22T22:59:57.639Z" }, + { url = "https://files.pythonhosted.org/packages/62/62/1141dff626401cf97dad8427510bbeea87a6a3e66c7e880a18f17b58fbcd/hypothesis-6.170.8-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1af65f1e22bf05be7909d15d8594eec69c9bd02703339c4a0c635e151064a18e", size = 1294652, upload-time = "2026-10-22T23:00:01.946Z" }, + { url = "https://files.pythonhosted.org/packages/01/05/fb83e496234da392fa723f081a4cc055b1834b09cdb5cf367b10d12ebe40/hypothesis-6.170.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fe3175068fe99decd7258cd38bb5421b11c3d4d1dd2ea992df54ce1198735c07", size = 1329602, upload-time = "2026-10-22T23:00:00.647Z" }, + { url = "https://files.pythonhosted.org/packages/86/99/95121b961e344f56afdfd8e8c742675cf6ec585063f7fe17358b2929a247/hypothesis-6.170.8-cp314-cp314t-win_amd64.whl", hash = "sha256:f916823042dbe19aa82a810236464dd9ec5627fbe8ef1baf32b490cb6c920ac3", size = 688287, upload-time = "2026-10-22T23:00:05.719Z" }, + { url = "https://files.pythonhosted.org/packages/e9/b5/df1dd66e9d3e290fa3ff14519079f816e3cb88b9ebb1f1c18dc1f6bbe15b/hypothesis-6.170.8-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:f36941223a586d7e5951b3382fd7f69bdcc716d7038117033440a7b0db2f3e40", size = 797311, upload-time = "2026-10-22T22:59:40.45Z" }, + { url = "https://files.pythonhosted.org/packages/fc/e6/c55c969c2ebb52ed395cfea236a39fd88ef0a16f7daaece14f4113d25e66/hypothesis-6.170.8-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:90182a1a239d2c994ef32b3226491962e58f59f85e76058dd98c952448946a41", size = 789779, upload-time = "2026-10-22T23:00:15.315Z" }, + { url = "https://files.pythonhosted.org/packages/b6/5f/5570efede11a80d572b437ec5a558bd72afae53478e85d54a40a6fbf6272/hypothesis-6.170.8-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:00ef96c93eb67ffcb474da90fdf003c6a2f53b2f2d9c1d3f7096e51f2b2a81be", size = 1118664, upload-time = "2026-10-22T23:01:11.39Z" }, + { url = "https://files.pythonhosted.org/packages/08/ce/ee4cc383cea5e4a94b3f0e0ad441aaa7def9acd0d67459bd9edba962927b/hypothesis-6.170.8-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f8301a943c08d7da734ca8f5c5c5e19f97ff2cf84dd06d682a0f55d05f3b50ed", size = 1149609, upload-time = "2026-10-22T23:00:03.221Z" }, + { url = "https://files.pythonhosted.org/packages/8b/78/09f5069d0d43756f740dcfc4e79f4e299b0edc69dd9580b5b69b9ffd9157/hypothesis-6.170.8-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dd15e45f93eb33d3470b5e33424689678ee83f09ebee065ed89925b6a065b9db", size = 1143252, upload-time = "2026-10-22T22:59:43.065Z" }, + { url = "https://files.pythonhosted.org/packages/de/c9/76ba4c9c803f71499f496055c98724bc6d1f8289fc7d451b54663581da9b/hypothesis-6.170.8-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:08a68d91a3d403a67bee3808c4875f054d9681c86f6894b8df9b719276b5960c", size = 1197119, upload-time = "2026-10-22T22:59:52.657Z" }, + { url = "https://files.pythonhosted.org/packages/de/ba/cbed478c8b40c386688072d1ce8a608565224825737d811f374f3550d105/hypothesis-6.170.8-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad9f025e38c8d07b58f9b5c33b123496a5fc578884884fa914356a299718d3ca", size = 1162933, upload-time = "2026-10-22T23:00:24.478Z" }, + { url = "https://files.pythonhosted.org/packages/18/b3/025339bf4474f7a08e8f8268c50a6f18278fe48ba5d2fd29d99e2b677c00/hypothesis-6.170.8-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:a0f6a806a8a4a436820f00a505df7b1d9bff9e5b6d8e182f5ba22602a90cc93f", size = 1117270, upload-time = "2026-10-22T23:00:12.996Z" }, + { url = "https://files.pythonhosted.org/packages/44/79/8b968b314a3c1e66a7e225cce83030714fc5ec4ba77a5b810e916cbc03f6/hypothesis-6.170.8-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:416e49611582c93786fc2aab0f81f0f384bf256d9fb1c467fe9df7090d9fbe0d", size = 1155780, upload-time = "2026-10-22T23:01:15.163Z" }, + { url = "https://files.pythonhosted.org/packages/4b/d6/c9a035915e11c1af1df7a6258a668894939306f798e7b08b016214578b91/hypothesis-6.170.8-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:2a6dc0931eb1a576fa73a74fa4e822c31f1cbc710f89e6bbb38bd7f16517d50a", size = 1293467, upload-time = "2026-10-22T23:00:20.324Z" }, + { url = "https://files.pythonhosted.org/packages/63/1c/c6e43e6a16e4aa2b07dc962aaf02674fa24e1566d9b8358aa32498f7bb22/hypothesis-6.170.8-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:7209862749c245ca94198562dfd4701d9f8358c554fbd53d9a21759dc126c817", size = 1422560, upload-time = "2026-10-22T23:01:09.972Z" }, + { url = "https://files.pythonhosted.org/packages/b3/f3/ea31e924576e67fa1f5e0fa21a2659921cdbdb38c50bd1e58c99329ffa55/hypothesis-6.170.8-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:d5d3ab5fdf8e3a0f233667201b306dd0eb89b5c35c40e8eafc54954a3571d7d0", size = 1376185, upload-time = "2026-10-22T23:00:40.71Z" }, + { url = "https://files.pythonhosted.org/packages/18/e0/e871338e20d177417a8d94ffbab541c6a64af3a1e9365d4cf95f7941fd43/hypothesis-6.170.8-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:af3deb2de8c9d40d7e15dd4703995618c11bf47f4789d6287a165c45b1175854", size = 1271901, upload-time = "2026-10-22T23:00:47.258Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e8/ddea7bb6da4c88333c8f3a7669af028e0883b00406f17b45dd6fac18ec14/hypothesis-6.170.8-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:80e84369bae69a439a71f05b91786bae627a30a1db79eb3a7d8adb8d3be6fd3a", size = 1289027, upload-time = "2026-10-22T23:00:32.184Z" }, + { url = "https://files.pythonhosted.org/packages/ce/0e/a8a3a37f8a9232c9361e5dade7091cf95dda822789aca954b79d3dca3977/hypothesis-6.170.8-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:a43cd04fe6469e6668ff7276f901a7c45b0dbdd63ec621fd77834a6ab9eaee3a", size = 1328939, upload-time = "2026-10-22T23:00:55.497Z" }, + { url = "https://files.pythonhosted.org/packages/8b/be/8b6e1bf3cd31d60436eeb81f32a3c7049a93ec78c2233048eeac7c963004/hypothesis-6.170.8-cp315-abi3.abi3t-win32.whl", hash = "sha256:fbed3d33ed19488e9d1eba5bf72101bd791c73b1155fc6af3f269a4dc107ddf8", size = 682097, upload-time = "2026-10-22T23:00:23.336Z" }, + { url = "https://files.pythonhosted.org/packages/e5/32/2e1e6078d82b5f0fe3294dd4afb8f9214416827c6584ddc7a5b6affd2a9f/hypothesis-6.170.8-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:039fb7c669b723afdeeeb77cb9cef7597a4f12861cf28b209d2d1c648b1f9bff", size = 688098, upload-time = "2026-10-22T22:59:47.111Z" }, + { url = "https://files.pythonhosted.org/packages/7f/00/73e1e47a59a44c37b025b7ce1b7d25dd97fd335040535974e10ac101ba1c/hypothesis-6.170.8-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:4cc942ca4712c876763330bcc28ccd254c0e6747a6380c81bdddb06cee50bf06", size = 686058, upload-time = "2026-10-22T23:01:13.958Z" }, + { url = "https://files.pythonhosted.org/packages/3c/5a/4468e1df0e9f78f62b998f57381c739c57ce899cde7d0aec2ee5e4eb1852/hypothesis-6.170.8-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:6376ebd23e7517f74f5994a0c03324b215961ffb3960af9617eb0b18dc5cfb85", size = 798187, upload-time = "2026-10-22T22:59:30.349Z" }, + { url = "https://files.pythonhosted.org/packages/53/39/4b5396b789631a732cf052e753e446b77f11d0879cb18f176097d1baef51/hypothesis-6.170.8-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:346cd8dc133bd779c3e5983cc127a686dc3c12a1ed0f447983d3f71f64875c8a", size = 793067, upload-time = "2026-10-22T23:00:17.952Z" }, + { url = "https://files.pythonhosted.org/packages/f7/2c/f60d35a3b1fb10b940ae288cd3676df685923c09a7411987f7853b5e7021/hypothesis-6.170.8-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3bdc756c009692412c39ca00d8925319f5e899727265f3bad518fb14bdc3d626", size = 1122120, upload-time = "2026-10-22T22:59:33.333Z" }, + { url = "https://files.pythonhosted.org/packages/db/5e/173b889bc521e01dd8a9e4393e46f97f9739e0dce4f05836ec9571ead29c/hypothesis-6.170.8-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9bf2419f8820bbb79a297f21ca54e2efe5171c6a62b5c7469befbb73c8775e4", size = 1165493, upload-time = "2026-10-22T23:00:08.715Z" }, + { url = "https://files.pythonhosted.org/packages/75/9e/eef4ab404cadff3147a23f2de02ceb1f98422586ad35ab667e1efd6d3a57/hypothesis-6.170.8-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b6a398dd92d775a90ad14e70cb420ab21345249ceba30eeb2faadf0723d2b33e", size = 691427, upload-time = "2026-10-22T23:00:59.121Z" }, + { url = "https://files.pythonhosted.org/packages/44/44/2b199137f71100a354cbbde0e62578a5258e42c48a1315115a3b5257c241/hypothesis-6.170.8-pp312-pypy312_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c3026eae9d3c328f8c50449728cdf4bb3464c63038131813526e16b28d2a54f2", size = 798498, upload-time = "2026-10-22T23:00:36.156Z" }, + { url = "https://files.pythonhosted.org/packages/95/48/1d9c0cdfd3cfcdce709afc4ba6311c2a1ef3638010d4c5b1fc636902bdeb/hypothesis-6.170.8-pp312-pypy312_pp73-macosx_11_0_arm64.whl", hash = "sha256:03c6863a3e325244c3137d8848ed4df0113536cf5ed1103d3727422510b5cd34", size = 790341, upload-time = "2026-10-22T23:01:08.553Z" }, + { url = "https://files.pythonhosted.org/packages/0d/04/c9f8e0a8006b8e0ce620ce39cfe0bc1fab3b6674c379e3221351a595455e/hypothesis-6.170.8-pp312-pypy312_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f0aad2b7322a3512a92e712a866df3b485b141f11556bc12a0242e66be4fb001", size = 1120326, upload-time = "2026-10-22T22:59:51.396Z" }, + { url = "https://files.pythonhosted.org/packages/d7/24/f086da2f159899b231baaf5dabe5091016c688900b14bc80f9d3a2f53c2f/hypothesis-6.170.8-pp312-pypy312_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ed5c7a653140dd77ff931a0cc0bd9d37a9effad375a1494ab61e551e3959325e", size = 1164478, upload-time = "2026-10-22T23:00:04.569Z" }, + { url = "https://files.pythonhosted.org/packages/6a/05/463e04b419c61715d9ec4f04d02bc80e465a9523f951fa0c66796438f263/hypothesis-6.170.8-pp312-pypy312_pp73-win_amd64.whl", hash = "sha256:7af04e2b2715f56c0aefec88658fb648e653ed74108c61ec3fc06991f528ae54", size = 689598, upload-time = "2026-10-22T23:01:16.358Z" }, +] + [[package]] name = "idna" version = "3.13" @@ -2616,7 +2709,7 @@ name = "pexpect" version = "4.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "ptyprocess" }, + { name = "ptyprocess", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } wheels = [ @@ -2815,6 +2908,7 @@ dependencies = [ dev = [ { name = "build" }, { name = "coverage" }, + { name = "hypothesis" }, { name = "mypy" }, { name = "pytest" }, { name = "pytest-snapshot" }, @@ -2841,6 +2935,7 @@ requires-dist = [ { name = "google-cloud-secret-manager", specifier = ">=2,<3" }, { name = "gunicorn" }, { name = "httpx", specifier = ">=0.27.0" }, + { name = "hypothesis", marker = "extra == 'dev'", specifier = ">=6.100" }, { name = "markupsafe", specifier = ">=3,<4" }, { name = "microdf-python", specifier = ">=1.0.0" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.15,<2" }, From 89a4993167e5c4b22f75d1974ffe6b03b807a677 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 23 Oct 2026 06:48:19 -0400 Subject: [PATCH 2/3] Keep a complete simulation or report result final PATCH /{country_id}/simulation and PATCH /{country_id}/report take no credential, and their records are shared: identical requests reuse one record. Any caller could replace a complete result with another output or an error, and every viewer of that record saw it. Found by the 2026-10-22 security review. There is no compute worker to restrict these updates to: the browser that ran a calculation stores its result. So the rule is on the record. Once it holds a complete result, an update that repeats it (a retried write, or a second browser finishing the same calculation, in either JSON encoding) answers 200 and writes nothing, and one that would change its status, output or error message answers 409 and leaves the record and its runs as they were. A completed US economy output is compared in its canonical SPM form. The services check this after locking the record and before any write, so the no-op path does not restamp the version or touch the runs. Three results stay open, because the server decided they run again: stale SPM simulation output, a report under an outdated cache version (reads resolve it to its current equivalent) and either record while a server-started rerun is pending or running. Marking a complete report running now answers 409 rather than 400; the old check still applies to a failed report. Tests (failed first on origin/master: a forged output and a status change replaced complete results with 200): regression tests for each anonymous change, the canonical economy comparison, the calculation path and the three open cases, and Hypothesis properties over update sequences for both records, checked step by step against a reference model. The OpenAPI document gains both 409 responses. Fixes #4028 Co-Authored-By: Claude Opus 5.5 --- .../final-simulation-report-results.fixed.md | 1 + policyengine_api/openapi_spec.yaml | 10 + .../routes/report_output_routes.py | 13 +- policyengine_api/routes/simulation_routes.py | 17 +- policyengine_api/services/final_results.py | 31 + .../services/report_output_service.py | 88 ++- .../services/simulation_service.py | 38 +- .../routes/test_result_updates_are_final.py | 556 ++++++++++++++++++ .../services/test_report_output_service.py | 18 +- 9 files changed, 747 insertions(+), 25 deletions(-) create mode 100644 changelog.d/final-simulation-report-results.fixed.md create mode 100644 policyengine_api/services/final_results.py create mode 100644 tests/unit/routes/test_result_updates_are_final.py diff --git a/changelog.d/final-simulation-report-results.fixed.md b/changelog.d/final-simulation-report-results.fixed.md new file mode 100644 index 000000000..f9bd794b0 --- /dev/null +++ b/changelog.d/final-simulation-report-results.fixed.md @@ -0,0 +1 @@ +`PATCH /{country_id}/simulation` and `PATCH /{country_id}/report` no longer change a complete result: an update that repeats it returns the record unchanged, and one that would change it answers 409. Stale SPM simulation output, reports under an outdated cache version and server-started reruns can still be completed. diff --git a/policyengine_api/openapi_spec.yaml b/policyengine_api/openapi_spec.yaml index 7925f6935..316b01342 100644 --- a/policyengine_api/openapi_spec.yaml +++ b/policyengine_api/openapi_spec.yaml @@ -623,6 +623,8 @@ paths: 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. + A complete result is final: an update that repeats it returns the record + unchanged, and one that would change it is rejected with HTTP 409. parameters: - name: country_id in: path @@ -645,6 +647,9 @@ paths: 404: description: The simulation does not exist in the selected country. content: *simulation_error_content + 409: + description: The simulation is complete, and the update would change its result. + content: *simulation_error_content /{country_id}/simulation/{simulation_id}: get: summary: Get a stored simulation record @@ -760,6 +765,9 @@ paths: Update a report output status, result, or error message. Completed US economy outputs require compact v2 comparison provenance. Each receipt contains the complete resolved configuration; a sibling spm_config is rejected. + A complete result under the current cache version is final: an update + that repeats it returns the record unchanged, and one that would change + it is rejected with HTTP 409. parameters: - name: country_id in: path @@ -809,6 +817,8 @@ paths: finished_at: type: string nullable: true + "409": + description: The report is complete, and the update would change its result. /{country_id}/report/{report_id}: get: summary: Get a report output diff --git a/policyengine_api/routes/report_output_routes.py b/policyengine_api/routes/report_output_routes.py index 98cefed86..6f686318a 100644 --- a/policyengine_api/routes/report_output_routes.py +++ b/policyengine_api/routes/report_output_routes.py @@ -6,11 +6,12 @@ import jsonschema import pydantic from flask import Blueprint, Response, current_app, request -from werkzeug.exceptions import BadRequest, HTTPException, NotFound +from werkzeug.exceptions import BadRequest, Conflict, HTTPException, NotFound from policyengine_api.constants import CURRENT_YEAR from policyengine_api.data.v1_models import ReportOutput from policyengine_api.services.report_output_service import ( + ReportOutputAlreadyCompleteError, ReportOutputService, ReportOutputView, ) @@ -173,6 +174,9 @@ def get_report_output(country_id: str, report_id: int) -> Response: def update_report_output(country_id: str) -> Response: """Update a report output record with results or error. + A complete result under the current cache version is final: repeating it + answers 200 with the record unchanged, and changing it answers 409. + Args: country_id (str): The country ID. @@ -238,6 +242,13 @@ def update_report_output(country_id: str) -> Response: except LookupError: msg = f"Report #{report_id} not found." raise NotFound(msg) from None + except ReportOutputAlreadyCompleteError as error: + current_app.logger.warning( + "Refused to change complete report #%s for country %s", + report_id, + country_id, + ) + raise Conflict(str(error)) from None except HTTPException: # Let explicit client-error responses (BadRequest/NotFound/etc.) pass # through without being logged as "Unexpected error". diff --git a/policyengine_api/routes/simulation_routes.py b/policyengine_api/routes/simulation_routes.py index 91ad8e0a0..cbebd5468 100644 --- a/policyengine_api/routes/simulation_routes.py +++ b/policyengine_api/routes/simulation_routes.py @@ -6,11 +6,14 @@ import jsonschema import pydantic from flask import Blueprint, Response, current_app, request -from werkzeug.exceptions import BadRequest, HTTPException, NotFound +from werkzeug.exceptions import BadRequest, Conflict, HTTPException, NotFound from policyengine_api.data.v1_models import Simulation from policyengine_api.response_factory import _make_error_response -from policyengine_api.services.simulation_service import SimulationService +from policyengine_api.services.simulation_service import ( + SimulationAlreadyCompleteError, + SimulationService, +) from policyengine_api.spm import SPMValidationError from policyengine_api.utils.payload_validators import validate_country @@ -194,6 +197,9 @@ def get_simulation(country_id: str, simulation_id: int) -> Response: def update_simulation(country_id: str) -> Response: """Update a simulation record with results or error. + A complete result is final: repeating it answers 200 with the record + unchanged, and changing it answers 409. + Args: country_id (str): The country ID. @@ -257,6 +263,13 @@ def update_simulation(country_id: str) -> Response: except LookupError: msg = f"Simulation #{simulation_id} not found." raise NotFound(msg) from None + except SimulationAlreadyCompleteError as error: + current_app.logger.warning( + "Refused to change complete simulation #%s for country %s", + simulation_id, + country_id, + ) + raise Conflict(str(error)) from None except HTTPException: # Let explicit client-error responses (BadRequest/NotFound/etc.) pass # through without being logged as "Unexpected error". diff --git a/policyengine_api/services/final_results.py b/policyengine_api/services/final_results.py new file mode 100644 index 000000000..02d1a0e99 --- /dev/null +++ b/policyengine_api/services/final_results.py @@ -0,0 +1,31 @@ +"""The rule that a complete v1 simulation or report result is final. + +Simulation and report records are shared: identical requests reuse one record, +and anyone can update one by its ID. So once a record holds a complete result, +an update may only repeat it (a retried write, or a second browser finishing +the same calculation) and never change it. +""" + +import json +from collections.abc import Mapping + + +def _comparable_output(value: object) -> object: + """Return stored output as JSON data, decoding a legacy JSON-string value.""" + if isinstance(value, str): + try: + return json.loads(value) + except ValueError: + return value + return value + + +def changes_stored_result(record: object, values: Mapping[str, object]) -> bool: + """Whether applying ``values`` would change any of those fields on ``record``.""" + for field, value in values.items(): + stored = getattr(record, field) + if field == "output": + stored = _comparable_output(stored) + if stored != value: + return True + return False diff --git a/policyengine_api/services/report_output_service.py b/policyengine_api/services/report_output_service.py index 94362152a..5cb68d81c 100644 --- a/policyengine_api/services/report_output_service.py +++ b/policyengine_api/services/report_output_service.py @@ -12,6 +12,7 @@ from policyengine_api.data.orm import get_v1_session_factory from policyengine_api.data.v1_models import ReportOutput, ReportOutputRun, Simulation from policyengine_api.json_types import JSONObject, JSONValue +from policyengine_api.services.final_results import changes_stored_result from policyengine_api.services.report_run_service import ( NewReportOutputRun, ReportRunService, @@ -95,6 +96,10 @@ class InvalidReportOutputIdError(Exception): """Raised when a report output ID is not a non-negative integer.""" +class ReportOutputAlreadyCompleteError(Exception): + """Raised when an update would change the result of a complete report output.""" + + @dataclass(frozen=True) class ReportOutputView: """A report output as served: the record, the run to display and the ID to report. @@ -717,9 +722,12 @@ def update_report_output( ) -> ReportOutputView | None: """Apply the given status, output or error message and sync the run history. - Return None when nothing was given. Raise LookupError if the report does not - exist, and ValueError when marking it running without an active run or - completing a US economy report whose SPM provenance is invalid. + Return None when nothing was given. A complete result under the current cache + version is final: an update that only repeats it leaves the report unchanged, + and one that would change it raises ReportOutputAlreadyCompleteError. Raise + LookupError if the report does not exist, and ValueError when marking it + running without an active run or completing a US economy report whose SPM + provenance is invalid. """ values = { key: value @@ -749,22 +757,33 @@ def update_report_output( ) return self._build_view(session, report_output) - def _update_report_output( - self, - session: Session, - report_output: ReportOutput, - values: dict, - *, - requested_status: str | None, + def _has_final_result(self, session: Session, report_output: ReportOutput) -> bool: + """Whether the report holds a complete, current result updates cannot change. + + A report under an outdated cache version is not served: reads resolve to its + current equivalent, so its stored result is not final. Nor is a result whose + rerun the server has started and not yet finished. + """ + if report_output.status != "complete" or not self._is_current_report_output( + report_output + ): + return False + if report_output.active_run_id is None: + return True + return not any( + run.id == report_output.active_run_id + and run.status in {"pending", "running"} + for run in self._list_runs_descending(session, report_output.id) + ) + + @staticmethod + def _canonicalize_completed_output( + report_output: ReportOutput, values: dict ) -> None: - if requested_status == "running": - runs = self._list_runs_descending(session, report_output.id) - if not self._has_mutable_running_run(report_output, runs): - msg = ( - "Cannot mark report output running without an active pending " - "or running report run" - ) - raise ValueError(msg) + """Replace a completed US economy output in ``values`` with its canonical form. + + Raise ValueError when its SPM provenance is invalid. + """ effective_status = values.get("status", report_output.status) effective_output = values.get("output", report_output.output) if ( @@ -783,6 +802,39 @@ def _update_report_output( f"{error_message(error)}" ) raise ValueError(msg) from error + + def _require_unchanged_final_result( + self, report_output: ReportOutput, values: dict + ) -> None: + proposed = dict(values) + if "output" in proposed: + self._canonicalize_completed_output(report_output, proposed) + if changes_stored_result(report_output, proposed): + msg = ( + f"Report #{report_output.id} is complete; its result cannot be changed" + ) + raise ReportOutputAlreadyCompleteError(msg) + + def _update_report_output( + self, + session: Session, + report_output: ReportOutput, + values: dict, + *, + requested_status: str | None, + ) -> None: + if self._has_final_result(session, report_output): + self._require_unchanged_final_result(report_output, values) + return + if requested_status == "running": + runs = self._list_runs_descending(session, report_output.id) + if not self._has_mutable_running_run(report_output, runs): + msg = ( + "Cannot mark report output running without an active pending " + "or running report run" + ) + raise ValueError(msg) + self._canonicalize_completed_output(report_output, values) for field, value in values.items(): setattr(report_output, field, value) self._ensure_report_output_dual_write_state( diff --git a/policyengine_api/services/simulation_service.py b/policyengine_api/services/simulation_service.py index ae5ffe667..f39d6dd2e 100644 --- a/policyengine_api/services/simulation_service.py +++ b/policyengine_api/services/simulation_service.py @@ -10,6 +10,7 @@ from policyengine_api.constants import COUNTRY_PACKAGE_VERSIONS from policyengine_api.data.orm import get_v1_session_factory from policyengine_api.data.v1_models import Simulation, SimulationRun +from policyengine_api.services.final_results import changes_stored_result from policyengine_api.spm import validate_spm_calculation_provenance from policyengine_api.utils.population_identity import ( canonical_numeric_household_id, @@ -21,6 +22,10 @@ class InvalidSimulationIdError(Exception): """Raised when a simulation ID is not a non-negative integer.""" +class SimulationAlreadyCompleteError(Exception): + """Raised when an update would change the result of a complete simulation.""" + + @dataclass(frozen=True) class SimulationCreateResult: """A simulation and whether this call created it.""" @@ -84,6 +89,24 @@ def _has_stale_spm_output(output: object) -> bool: return True return False + @classmethod + def _has_final_result(cls, session: Session, simulation: Simulation) -> bool: + """Whether the simulation holds a complete result that updates cannot change. + + Stale SPM output is not final: it is reset to pending and run again. Nor is + a result whose rerun the server has started and not yet finished. + """ + if simulation.status != "complete" or cls._has_stale_spm_output( + simulation.output + ): + return False + if simulation.active_run_id is None: + return True + active_run = session.scalar( + select(SimulationRun).where(SimulationRun.id == simulation.active_run_id) + ) + return active_run is None or active_run.status not in {"pending", "running"} + def ensure_simulation_dual_write_state( self, session: Session, @@ -293,8 +316,11 @@ def update_simulation( """Apply the given status, output or error message and sync the run history. Return None when nothing was given. The simulation is restamped with the current - country package version. Raise ValueError for output with legacy ``spm_config`` - or an invalid SPM receipt, and LookupError if the simulation does not exist. + country package version. A complete result is final: an update that only + repeats it returns the simulation unchanged, and one that would change it + raises SimulationAlreadyCompleteError. Raise ValueError for output with legacy + ``spm_config`` or an invalid SPM receipt, and LookupError if the simulation + does not exist. """ values = { key: value @@ -331,6 +357,14 @@ def update_simulation( if simulation is None: msg = f"Simulation #{simulation_id} not found" raise LookupError(msg) + if self._has_final_result(session, simulation): + if changes_stored_result(simulation, values): + msg = ( + f"Simulation #{simulation_id} is complete; " + "its result cannot be changed" + ) + raise SimulationAlreadyCompleteError(msg) + return simulation for key, value in values.items(): setattr(simulation, key, value) simulation.api_version = COUNTRY_PACKAGE_VERSIONS.get(country_id) diff --git a/tests/unit/routes/test_result_updates_are_final.py b/tests/unit/routes/test_result_updates_are_final.py new file mode 100644 index 000000000..eaf463a12 --- /dev/null +++ b/tests/unit/routes/test_result_updates_are_final.py @@ -0,0 +1,556 @@ +"""A complete simulation or report result is final. + +``PATCH /{country_id}/simulation`` and ``PATCH /{country_id}/report`` take no +credential: the browser that ran a calculation stores its result, and records +are shared, since identical requests reuse one record. So once a record holds +a complete result, an update may only repeat it (a retried write, or a second +browser finishing the same calculation) and never change it. Changing it +answers 409 and leaves the record as it was. + +Two results are not final, because the server, not a caller, decided they run +again: stale SPM output on a simulation, a report stored under an outdated +cache version, and either record while a rerun the server started is pending +or running. +""" + +import json +import uuid +from collections.abc import Callable +from dataclasses import dataclass +from unittest.mock import patch + +import pytest +from flask import Flask +from hypothesis import HealthCheck, event, given, settings +from hypothesis import strategies as st +from sqlalchemy import select + +from policyengine_api.data.v1_models import ( + ReportOutput, + ReportOutputRun, + Simulation, + SimulationRun, +) +from policyengine_api.routes.error_routes import error_bp +from policyengine_api.routes.report_output_routes import report_output_bp +from policyengine_api.routes.simulation_routes import simulation_bp +from policyengine_api.services.report_output_service import ReportOutputService +from policyengine_api.services.report_run_service import ( + NewReportOutputRun, + ReportRunService, +) +from policyengine_api.services.simulation_run_service import ( + NewSimulationRun, + SimulationRunService, +) +from policyengine_api.services.simulation_service import SimulationService + +SPM_RECEIPT = { + "schema_version": "canonical-spm-provenance-v2", + "forecast_id": "canonical-forecast", + "forecast_sha256": "a" * 64, + "scenario": "ce_trend", + "geography_kind": "national", + "geography_id": None, + "county_vintage": "2020", + "as_of": None, + "years": ["2025"], + "runtime_versions": { + "policyengine": "6.2.1", + "policyengine-core": "3.32.10", + "policyengine-us": "2.2.1", + "spm-calculator": "1.0.0", + }, +} +HOUSEHOLD_RESULT = {"household": {"people": {"you": {"net_income": {"2026": 1}}}}} +FORGED_RESULT = {"household": {"people": {"you": {"net_income": {"2026": 999}}}}} + + +def economy_output(budgetary_impact: int) -> dict: + return { + "spm_provenance": { + "schema_version": "canonical-spm-comparison-v2", + "baseline": {"receipt": SPM_RECEIPT, "execution_count": 2}, + "reform": {"receipt": SPM_RECEIPT, "execution_count": 2}, + }, + "budgetary_impact": budgetary_impact, + } + + +@pytest.fixture +def client(orm_session_factory): + app = Flask(__name__) + app.config["TESTING"] = True + app.register_blueprint(error_bp) + app.register_blueprint(simulation_bp) + app.register_blueprint(report_output_bp) + with ( + patch( + "policyengine_api.routes.simulation_routes.simulation_service", + SimulationService(orm_session_factory), + ), + patch( + "policyengine_api.routes.report_output_routes.report_output_service", + ReportOutputService(orm_session_factory), + ), + ): + yield app.test_client() + + +def _new_simulation(client, *, population_type="household", population_id=None): + response = client.post( + "/us/simulation", + json={ + "population_id": population_id or f"household-{uuid.uuid4()}", + "population_type": population_type, + "policy_id": 1, + }, + ) + assert response.status_code == 201, response.get_json() + return response.get_json()["result"]["id"] + + +def _new_report(client): + simulation_id = _new_simulation(client) + response = client.post("/us/report", json={"simulation_1_id": simulation_id}) + assert response.status_code == 201, response.get_json() + return response.get_json()["result"]["id"] + + +def _new_economy_report(client): + baseline = _new_simulation(client, population_type="geography", population_id="us") + reform_response = client.post( + "/us/simulation", + json={"population_id": "us", "population_type": "geography", "policy_id": 2}, + ) + reform = reform_response.get_json()["result"]["id"] + response = client.post( + "/us/report", + json={"simulation_1_id": baseline, "simulation_2_id": reform, "year": "2025"}, + ) + assert response.status_code == 201, response.get_json() + return response.get_json()["result"]["id"] + + +def _patch(client, resource, record_id, **fields): + return client.patch(f"/us/{resource}", json={"id": record_id, **fields}) + + +def _stored(session_factory, model, record_id): + with session_factory() as session: + record = session.get(model, record_id) + return { + "status": record.status, + "output": record.output, + "error_message": record.error_message, + "api_version": record.api_version, + } + + +def _run_rows(session_factory, model, foreign_key, record_id): + with session_factory() as session: + return [ + (run.id, run.status, run.output, run.error_message) + for run in session.scalars( + select(model).where(getattr(model, foreign_key) == record_id) + ) + ] + + +# Simulations + + +def test_anonymous_patch_cannot_change_a_complete_simulation( + client, orm_session_factory +): + simulation_id = _new_simulation(client) + assert ( + _patch( + client, + "simulation", + simulation_id, + status="complete", + output=json.dumps(HOUSEHOLD_RESULT), + ).status_code + == 200 + ) + before = _stored(orm_session_factory, Simulation, simulation_id) + runs_before = _run_rows( + orm_session_factory, SimulationRun, "simulation_id", simulation_id + ) + + for attack in ( + {"status": "complete", "output": FORGED_RESULT}, + {"output": FORGED_RESULT}, + {"status": "error", "error_message": "attacker-controlled failure"}, + {"error_message": "attacker-controlled note"}, + {"status": "pending"}, + ): + response = _patch(client, "simulation", simulation_id, **attack) + + assert response.status_code == 409, attack + body = response.get_json() + assert body["status"] == "error" + assert "is complete; its result cannot be changed" in body["message"] + assert _stored(orm_session_factory, Simulation, simulation_id) == before + assert ( + _run_rows( + orm_session_factory, SimulationRun, "simulation_id", simulation_id + ) + == runs_before + ) + + read = client.get(f"/us/simulation/{simulation_id}").get_json()["result"] + assert json.loads(read["output"]) == HOUSEHOLD_RESULT + + +def test_calculation_path_completes_and_may_repeat_its_result( + client, orm_session_factory +): + simulation_id = _new_simulation(client) + + completed = _patch( + client, + "simulation", + simulation_id, + status="complete", + output=json.dumps(HOUSEHOLD_RESULT), + ) + stored = _stored(orm_session_factory, Simulation, simulation_id) + # A retry, or a second browser that ran the same calculation, sends the + # same result again; either JSON encoding of it is a repeat. + repeats = [ + _patch( + client, + "simulation", + simulation_id, + status="complete", + output=output, + ) + for output in (json.dumps(HOUSEHOLD_RESULT), HOUSEHOLD_RESULT) + ] + + assert completed.status_code == 200 + assert stored["status"] == "complete" + assert stored["output"] == HOUSEHOLD_RESULT + for repeat in repeats: + assert repeat.status_code == 200 + assert json.loads(repeat.get_json()["result"]["output"]) == HOUSEHOLD_RESULT + assert _stored(orm_session_factory, Simulation, simulation_id) == stored + + +def test_failed_simulation_can_be_calculated_again(client, orm_session_factory): + simulation_id = _new_simulation(client) + + failed = _patch( + client, "simulation", simulation_id, status="error", error_message="timeout" + ) + completed = _patch( + client, "simulation", simulation_id, status="complete", output=HOUSEHOLD_RESULT + ) + + assert failed.status_code == 200 + assert completed.status_code == 200 + stored = _stored(orm_session_factory, Simulation, simulation_id) + assert stored["status"] == "complete" + assert stored["output"] == HOUSEHOLD_RESULT + + +def test_stale_spm_simulation_output_is_not_final(client, orm_session_factory): + with orm_session_factory.begin() as session: + simulation = Simulation( + country_id="us", + population_id="household-stale", + population_type="household", + policy_id=1, + api_version="historical", + status="complete", + output={"household": {}, "spm_config": {"geography_kind": "national"}}, + ) + session.add(simulation) + session.flush() + simulation_id = simulation.id + + response = _patch( + client, "simulation", simulation_id, status="complete", output=HOUSEHOLD_RESULT + ) + + assert response.status_code == 200 + assert ( + _stored(orm_session_factory, Simulation, simulation_id)["output"] + == HOUSEHOLD_RESULT + ) + + +def test_server_started_simulation_rerun_is_not_final(client, orm_session_factory): + simulation_id = _new_simulation(client) + _patch( + client, "simulation", simulation_id, status="complete", output=HOUSEHOLD_RESULT + ) + with orm_session_factory.begin() as session: + rerun = SimulationRunService().create_simulation_run( + session, simulation_id, NewSimulationRun(trigger_type="rerun") + ) + session.get(Simulation, simulation_id).active_run_id = rerun.id + + response = _patch( + client, "simulation", simulation_id, status="complete", output=FORGED_RESULT + ) + + assert response.status_code == 200 + assert ( + _stored(orm_session_factory, Simulation, simulation_id)["output"] + == FORGED_RESULT + ) + + +# Reports + + +def test_anonymous_patch_cannot_change_a_complete_report(client, orm_session_factory): + report_id = _new_report(client) + assert ( + _patch( + client, + "report", + report_id, + status="complete", + output=json.dumps({"household": 1}), + ).status_code + == 200 + ) + before = _stored(orm_session_factory, ReportOutput, report_id) + runs_before = _run_rows( + orm_session_factory, ReportOutputRun, "report_output_id", report_id + ) + + for attack in ( + {"status": "complete", "output": {"household": 2}}, + {"status": "error", "error_message": "attacker-controlled failure"}, + {"status": "running"}, + {"status": "pending"}, + {"output": {"household": 2}}, + ): + response = _patch(client, "report", report_id, **attack) + + assert response.status_code == 409, attack + assert ( + "is complete; its result cannot be changed" + in (response.get_json()["message"]) + ) + assert _stored(orm_session_factory, ReportOutput, report_id) == before + assert ( + _run_rows( + orm_session_factory, ReportOutputRun, "report_output_id", report_id + ) + == runs_before + ) + + repeat = _patch( + client, "report", report_id, status="complete", output={"household": 1} + ) + assert repeat.status_code == 200 + assert _stored(orm_session_factory, ReportOutput, report_id) == before + + +def test_completed_us_economy_report_compares_its_canonical_result( + client, orm_session_factory +): + report_id = _new_economy_report(client) + + completed = _patch( + client, "report", report_id, status="complete", output=economy_output(42) + ) + repeated = _patch( + client, + "report", + report_id, + status="complete", + output=json.dumps(economy_output(42)), + ) + changed = _patch( + client, "report", report_id, status="complete", output=economy_output(43) + ) + + assert completed.status_code == 200 + assert repeated.status_code == 200 + assert changed.status_code == 409 + assert _stored(orm_session_factory, ReportOutput, report_id)["output"] == ( + economy_output(42) + ) + + +def test_report_under_an_outdated_cache_version_is_not_final( + client, orm_session_factory +): + report_id = _new_report(client) + _patch(client, "report", report_id, status="complete", output={"household": 1}) + with orm_session_factory.begin() as session: + session.get(ReportOutput, report_id).api_version = "outdated" + + response = _patch( + client, "report", report_id, status="complete", output={"household": 2} + ) + + assert response.status_code == 200 + assert _stored(orm_session_factory, ReportOutput, report_id)["output"] == { + "household": 2 + } + + +def test_server_started_report_rerun_is_not_final(client, orm_session_factory): + report_id = _new_report(client) + _patch(client, "report", report_id, status="complete", output={"household": 1}) + with orm_session_factory.begin() as session: + rerun = ReportRunService().create_report_output_run( + session, report_id, NewReportOutputRun(trigger_type="rerun") + ) + session.get(ReportOutput, report_id).active_run_id = rerun.id + + running = _patch(client, "report", report_id, status="running") + completed = _patch( + client, "report", report_id, status="complete", output={"household": 2} + ) + + assert running.status_code == 200 + assert completed.status_code == 200 + assert _stored(orm_session_factory, ReportOutput, report_id)["output"] == { + "household": 2 + } + + +# Properties over every update sequence + + +_OUTPUTS = ( + {"value": 1}, + {"value": 2}, + {"nested": {"values": [1, 2.5, None]}}, +) +_output_values = st.one_of( + st.none(), + st.sampled_from(_OUTPUTS), + st.sampled_from(_OUTPUTS).map(json.dumps), +) + + +def _update(statuses): + return st.fixed_dictionaries( + {}, + optional={ + "status": st.one_of(st.none(), st.sampled_from(statuses)), + "output": _output_values, + "error_message": st.one_of(st.none(), st.sampled_from(["a", "b"])), + }, + ) + + +def _updates(statuses): + """Any updates, then a completion, then any updates or repeats of it.""" + + @st.composite + def sequences(draw): + completion = { + "status": "complete", + "output": draw(st.sampled_from(_OUTPUTS)), + } + later = st.one_of( + _update(statuses), + st.just(completion), + st.just({**completion, "output": json.dumps(completion["output"])}), + ) + return [ + *draw(st.lists(_update(statuses), max_size=5)), + completion, + *draw(st.lists(later, max_size=6)), + ] + + return sequences() + + +def _decoded(output): + return json.loads(output) if isinstance(output, str) else output + + +def _expected_response(model, update, *, running_allowed): + """Return the status code and next record state the route should produce.""" + values = {key: value for key, value in update.items() if value is not None} + if "output" in values: + values["output"] = _decoded(values["output"]) + if values.get("status") == "complete" and "output" not in values: + return 400, model + if not values: + return 400, model + if model["status"] == "complete": + changed = any(model[field] != value for field, value in values.items()) + return (409 if changed else 200), model + if values.get("status") == "running" and not running_allowed(model): + return 400, model + return 200, {**model, **values} + + +@dataclass(frozen=True) +class _UpdatedResource: + """A PATCH route, its record model and when its records may be marked running.""" + + path: str + model: type + running_allowed: Callable[[dict], bool] + + +SIMULATIONS = _UpdatedResource("simulation", Simulation, lambda _record: False) +# A report's one run mirrors its status, so it can be marked running only while +# that run is still pending or running. +REPORTS = _UpdatedResource( + "report", + ReportOutput, + lambda record: record["status"] in {"pending", "running"}, +) + + +def _check_update_sequence(client, session_factory, resource, record_id, updates): + model = {"status": "pending", "output": None, "error_message": None} + completed_record = None + for update in updates: + response = _patch(client, resource.path, record_id, **update) + expected_code, model = _expected_response( + model, update, running_allowed=resource.running_allowed + ) + stored = _stored(session_factory, resource.model, record_id) + + assert response.status_code == expected_code, (update, response.get_json()) + assert {field: stored[field] for field in model} == model + if completed_record is not None: + # Once complete, nothing an update sends changes the record. + assert stored == completed_record + elif stored["status"] == "complete": + completed_record = stored + event(f"{resource.path} completed") + + +@settings( + max_examples=200, + deadline=None, + suppress_health_check=[HealthCheck.function_scoped_fixture], +) +@given(updates=_updates(["pending", "complete", "error"])) +def test_every_simulation_update_sequence_keeps_a_complete_result( + client, orm_session_factory, updates +): + _check_update_sequence( + client, orm_session_factory, SIMULATIONS, _new_simulation(client), updates + ) + + +@settings( + max_examples=200, + deadline=None, + suppress_health_check=[HealthCheck.function_scoped_fixture], +) +@given(updates=_updates(["pending", "running", "complete", "error"])) +def test_every_report_update_sequence_keeps_a_complete_result( + client, orm_session_factory, updates +): + _check_update_sequence( + client, orm_session_factory, REPORTS, _new_report(client), updates + ) diff --git a/tests/unit/services/test_report_output_service.py b/tests/unit/services/test_report_output_service.py index ae0d85d8a..cbe9b9a5e 100644 --- a/tests/unit/services/test_report_output_service.py +++ b/tests/unit/services/test_report_output_service.py @@ -4,7 +4,10 @@ from policyengine_api import constants from policyengine_api.constants import get_report_output_cache_version from policyengine_api.data.v1_models import ReportOutput, ReportOutputRun -from policyengine_api.services.report_output_service import ReportOutputService +from policyengine_api.services.report_output_service import ( + ReportOutputAlreadyCompleteError, + ReportOutputService, +) from policyengine_api.services.report_run_service import ( NewReportOutputRun, ReportRunService, @@ -422,13 +425,24 @@ def test_update_running_requires_mutable_run(service, orm_session_factory): simulation = create_simulation(orm_session_factory) report = create_report(service, simulation.id) service.update_report_output( - "us", report.id, status="complete", output={"ok": True} + "us", report.id, status="error", error_message="failed" ) with pytest.raises(ValueError, match="without an active pending or running"): service.update_report_output("us", report.id, status="running") +def test_update_cannot_mark_a_complete_report_running(service, orm_session_factory): + simulation = create_simulation(orm_session_factory) + report = create_report(service, simulation.id) + service.update_report_output( + "us", report.id, status="complete", output={"ok": True} + ) + + with pytest.raises(ReportOutputAlreadyCompleteError): + service.update_report_output("us", report.id, status="running") + + def _add_rerun(orm_session_factory, report_id): with orm_session_factory.begin() as session: report = session.get(ReportOutput, report_id) From b9f0b1ff7740bbc4f0ebc359304244d778b31253 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 23 Oct 2026 07:36:33 -0400 Subject: [PATCH 3/3] Keep recalculation working and close the reviews' gaps in result finality Two independent reviews of the first head found that the finality rule broke one app-v2 flow and left three gaps. App v2 recalculates every simulation of a new household report, reused complete ones included, and stores the installed model's result. After a model release that result differs, so it got 409 and the shared simulation stayed on the older model. A simulation result is now final only when it came from the installed country model or a newer one (PEP 440 comparison of api_version; an unreadable version counts as older when it differs), so recalculation replaces older results as before, and an older revision cannot replace a newer one. The speculative simulation-rerun exception goes: nothing starts one. An update to an outdated report ID now goes to its current equivalent, the record GET already serves for that ID, and keeps the requested ID in the response. Before, PATCH wrote the outdated row and the current record stayed pending; and the outdated-cache-version exception let a revision with another bundle reopen current reports. That exception is gone. A completion whose output decodes to null, such as "null", stored a complete record with no result, which then refused the real one: it is rejected, and a complete record without output is not final. A body id that is not an integer is rejected. Stored outputs are compared as stored, so a JSON-string output repeats as itself. The Cloud Run candidate check reads IDs no record can have and never prints the body, since CI logs are public. uv.lock is relocked with uv 0.13.4, CI's pin, and now only adds hypothesis 6.168.5. The OpenAPI document and docs/canonical-spm.md describe the rules; the report 409 response has its schema. Tests: regressions for newer models, outdated report IDs, null completions, non-integer IDs and JSON-string outputs, which failed first on origin/master, and for older models, which must pass on both; the properties now compare every column of the record and its runs after completion, over a wider output domain; the import walk handles package-relative imports and models used through v1_models. Fixes #4028 Co-Authored-By: Claude Opus 5.5 --- .../final-simulation-report-results.fixed.md | 2 +- docs/canonical-spm.md | 10 +- policyengine_api/openapi_spec.yaml | 32 ++- .../routes/report_output_routes.py | 4 + policyengine_api/routes/simulation_routes.py | 27 +- policyengine_api/services/final_results.py | 25 +- .../services/report_output_service.py | 81 ++++-- .../services/simulation_service.py | 96 +++++-- tests/integration/test_cloud_run_candidate.py | 7 +- .../test_legacy_user_records_owner_scoped.py | 65 ++++- .../routes/test_result_updates_are_final.py | 269 ++++++++++++++---- .../unit/services/test_simulation_service.py | 2 +- uv.lock | 169 ++++++----- 13 files changed, 532 insertions(+), 257 deletions(-) diff --git a/changelog.d/final-simulation-report-results.fixed.md b/changelog.d/final-simulation-report-results.fixed.md index f9bd794b0..640be6f4d 100644 --- a/changelog.d/final-simulation-report-results.fixed.md +++ b/changelog.d/final-simulation-report-results.fixed.md @@ -1 +1 @@ -`PATCH /{country_id}/simulation` and `PATCH /{country_id}/report` no longer change a complete result: an update that repeats it returns the record unchanged, and one that would change it answers 409. Stale SPM simulation output, reports under an outdated cache version and server-started reruns can still be completed. +`PATCH /{country_id}/simulation` and `PATCH /{country_id}/report` no longer change a complete result: an update that repeats it returns the record unchanged, and one that would change it answers 409. A simulation result from an older country model can still be replaced by the installed model's, and an update to an outdated report ID now completes its current equivalent, the record reads serve for that ID, instead of the outdated row. Both routes reject a non-integer `id` and a completion whose output is null. diff --git a/docs/canonical-spm.md b/docs/canonical-spm.md index 9794be4bb..35c2a9697 100644 --- a/docs/canonical-spm.md +++ b/docs/canonical-spm.md @@ -251,9 +251,13 @@ the full calculation envelope inside `output`, including its `spm_provenance`. A JSON-encoded output string is also accepted. This endpoint validates the one compact SPM receipt strictly before storage. Completed output that includes the former sibling `spm_config`, an old receipt or a rich receipt -is rejected rather than translated. Null update fields are ignored. The legacy `api_version` input -is ignored; writes record the installed country model version. Top-level `spm`, -even null, returns HTTP 400 `SPM_SETTINGS_UNSUPPORTED` on POST and PATCH. +is rejected rather than translated. Null update fields, and output that decodes +to null, are ignored, and `id` must be an integer. The legacy `api_version` input +is ignored; an applied write records the installed country model version. A +complete result from the installed country model, or a newer one, is final: an +update that repeats it writes nothing, and one that would change it returns HTTP +409. Top-level `spm`, even null, returns HTTP 400 `SPM_SETTINGS_UNSUPPORTED` on +POST and PATCH. POST, PATCH and `GET /us/simulation/{id}` return the simulation record inside `result`. Non-string JSON `output` and `simulation_spec_json` are returned as diff --git a/policyengine_api/openapi_spec.yaml b/policyengine_api/openapi_spec.yaml index 316b01342..c3bd88ba5 100644 --- a/policyengine_api/openapi_spec.yaml +++ b/policyengine_api/openapi_spec.yaml @@ -616,15 +616,17 @@ paths: 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 + non-null output, and id must be an integer. Null update fields, and output + that decodes to null, are ignored; omitted fields retain their values. An + applied update records the installed country model version and the current + run state. 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. - A complete result is final: an update that repeats it returns the record - unchanged, and one that would change it is rejected with HTTP 409. + A complete result from the installed country model, or a newer one, is + final: an update that repeats it returns the record unchanged, and one that + would change it is rejected with HTTP 409. parameters: - name: country_id in: path @@ -642,7 +644,7 @@ paths: description: Updated simulation record. content: *simulation_record_content 400: - description: Invalid update, no fields to update, or independent SPM selection. + description: Invalid update or id, no fields to update, or independent SPM selection. content: *simulation_error_content 404: description: The simulation does not exist in the selected country. @@ -765,7 +767,9 @@ paths: Update a report output status, result, or error message. Completed US economy outputs require compact v2 comparison provenance. Each receipt contains the complete resolved configuration; a sibling spm_config is rejected. - A complete result under the current cache version is final: an update + The id must be an integer. An id stored under an outdated cache version + updates its current equivalent, the record reads serve for it, and the + response keeps the requested id. A complete result is final: an update that repeats it returns the record unchanged, and one that would change it is rejected with HTTP 409. parameters: @@ -819,6 +823,20 @@ paths: nullable: true "409": description: The report is complete, and the update would change its result. + content: + application/json: + schema: + type: object + required: [status, message, result] + properties: + status: + type: string + enum: [error] + message: + type: string + result: + type: object + nullable: true /{country_id}/report/{report_id}: get: summary: Get a report output diff --git a/policyengine_api/routes/report_output_routes.py b/policyengine_api/routes/report_output_routes.py index 6f686318a..2b3b5fa70 100644 --- a/policyengine_api/routes/report_output_routes.py +++ b/policyengine_api/routes/report_output_routes.py @@ -200,6 +200,10 @@ def update_report_output(country_id: str) -> Response: error_message = payload.get("error_message") logger.debug("Updating report #%s for country %s", report_id, country_id) + if isinstance(report_id, bool) or not isinstance(report_id, int): + msg = "id must be an integer" + raise BadRequest(msg) + # Validate status if provided if status is not None and status not in [ "pending", diff --git a/policyengine_api/routes/simulation_routes.py b/policyengine_api/routes/simulation_routes.py index cbebd5468..6e070fb97 100644 --- a/policyengine_api/routes/simulation_routes.py +++ b/policyengine_api/routes/simulation_routes.py @@ -83,6 +83,23 @@ def _require_simulation_identity( raise BadRequest(msg) +def _require_simulation_update( + simulation_id: object, + status: object, + output: object, +) -> None: + """Raise BadRequest for the first invalid field of a simulation update.""" + if isinstance(simulation_id, bool) or not isinstance(simulation_id, int): + msg = "id must be an integer" + raise BadRequest(msg) + if status is not None and status not in ["pending", "complete", "error"]: + msg = "status must be 'pending', 'complete', or 'error'" + raise BadRequest(msg) + if status == "complete" and output is None: + msg = "output is required when status is 'complete'" + raise BadRequest(msg) + + @simulation_bp.route("//simulation", methods=["POST"]) @validate_country def create_simulation(country_id: str) -> Response: @@ -225,15 +242,7 @@ def update_simulation(country_id: str) -> Response: error_message = payload.get("error_message") logger.debug("Updating simulation #%s for country %s", simulation_id, country_id) - # Validate status if provided - if status is not None and status not in ["pending", "complete", "error"]: - msg = "status must be 'pending', 'complete', or 'error'" - raise BadRequest(msg) - - # Validate that complete status has output - if status == "complete" and output is None: - msg = "output is required when status is 'complete'" - raise BadRequest(msg) + _require_simulation_update(simulation_id, status, output) try: simulation = _require_updated_simulation( diff --git a/policyengine_api/services/final_results.py b/policyengine_api/services/final_results.py index 02d1a0e99..63d951098 100644 --- a/policyengine_api/services/final_results.py +++ b/policyengine_api/services/final_results.py @@ -6,26 +6,13 @@ the same calculation) and never change it. """ -import json from collections.abc import Mapping -def _comparable_output(value: object) -> object: - """Return stored output as JSON data, decoding a legacy JSON-string value.""" - if isinstance(value, str): - try: - return json.loads(value) - except ValueError: - return value - return value - - def changes_stored_result(record: object, values: Mapping[str, object]) -> bool: - """Whether applying ``values`` would change any of those fields on ``record``.""" - for field, value in values.items(): - stored = getattr(record, field) - if field == "output": - stored = _comparable_output(stored) - if stored != value: - return True - return False + """Whether applying ``values`` would change any of those fields on ``record``. + + ``values`` holds decoded JSON, as stored, so a retry in either encoding of the + same output is a repeat. + """ + return any(getattr(record, field) != value for field, value in values.items()) diff --git a/policyengine_api/services/report_output_service.py b/policyengine_api/services/report_output_service.py index 5cb68d81c..fcad32e97 100644 --- a/policyengine_api/services/report_output_service.py +++ b/policyengine_api/services/report_output_service.py @@ -712,6 +712,35 @@ def resolve_report_output( response_id=response_id, ) + @staticmethod + def _update_values( + status: str | None, + output: dict | list | str | None, + error_message: str | None, + ) -> dict: + """Return the fields an update sets, with output decoded. + + Null fields, and output that decodes to null, set nothing. Raise ValueError + for a completion without output. + """ + values = { + key: value + for key, value in { + "status": status, + "output": output, + "error_message": error_message, + }.items() + if value is not None + } + if isinstance(values.get("output"), str): + values["output"] = json.loads(values["output"]) + if values["output"] is None: + del values["output"] + if values.get("status") == "complete" and "output" not in values: + msg = "output is required when status is 'complete'" + raise ValueError(msg) + return values + def update_report_output( self, country_id: str, @@ -722,51 +751,49 @@ def update_report_output( ) -> ReportOutputView | None: """Apply the given status, output or error message and sync the run history. - Return None when nothing was given. A complete result under the current cache - version is final: an update that only repeats it leaves the report unchanged, - and one that would change it raises ReportOutputAlreadyCompleteError. Raise - LookupError if the report does not exist, and ValueError when marking it - running without an active run or completing a US economy report whose SPM - provenance is invalid. + Return None when nothing was given; a JSON-encoded null output counts as + none. A report stored under an outdated cache version is updated as its + current equivalent, the record reads serve for that ID, with the requested + ID kept as ``response_id``. A complete result is final: an update that only + repeats it leaves the report unchanged, and one that would change it raises + ReportOutputAlreadyCompleteError. Raise LookupError if the report does not + exist, and ValueError for a completion without output, when marking it + running without an active run, or when completing a US economy report + whose SPM provenance is invalid. """ - values = { - key: value - for key, value in { - "status": status, - "output": output, - "error_message": error_message, - }.items() - if value is not None - } + values = self._update_values(status, output, error_message) if not values: return None - if isinstance(values.get("output"), str): - values["output"] = json.loads(values["output"]) with self._sessions.begin() as session: - report_output = self._select_report_output( + requested = self._select_report_output( session, report_id, country_id, for_update=True ) - if report_output is None: + if requested is None: msg = f"Report output #{report_id} not found" raise LookupError(msg) + report_output, response_id = requested, None + if not self._is_current_report_output(requested): + report_output = self._get_or_create_current_report_output( + session, requested + ) + response_id = report_id self._update_report_output( session, report_output, values, requested_status=status, ) - return self._build_view(session, report_output) + return self._build_view(session, report_output, response_id=response_id) def _has_final_result(self, session: Session, report_output: ReportOutput) -> bool: - """Whether the report holds a complete, current result updates cannot change. + """Whether the report holds a complete result that updates cannot change. - A report under an outdated cache version is not served: reads resolve to its - current equivalent, so its stored result is not final. Nor is a result whose - rerun the server has started and not yet finished. + A complete result is final unless the server has started a rerun of it that + is still pending or running. No production code starts one today; the run + service supports reruns, and its tests exercise them. A complete record + without output holds no result. """ - if report_output.status != "complete" or not self._is_current_report_output( - report_output - ): + if report_output.status != "complete" or report_output.output is None: return False if report_output.active_run_id is None: return True diff --git a/policyengine_api/services/simulation_service.py b/policyengine_api/services/simulation_service.py index f39d6dd2e..194404c70 100644 --- a/policyengine_api/services/simulation_service.py +++ b/policyengine_api/services/simulation_service.py @@ -4,6 +4,7 @@ import uuid from dataclasses import dataclass +from packaging.version import InvalidVersion, Version from sqlalchemy import select from sqlalchemy.orm import Session, sessionmaker @@ -89,23 +90,35 @@ def _has_stale_spm_output(output: object) -> bool: return True return False + @staticmethod + def _computed_by_older_model(simulation: Simulation) -> bool: + """Whether the stored result came from an older country model than this one. + + A version that cannot be compared counts as older when it differs. + """ + stored = simulation.api_version + installed = COUNTRY_PACKAGE_VERSIONS.get(simulation.country_id) + try: + return Version(str(stored)) < Version(str(installed)) + except InvalidVersion: + return stored != installed + @classmethod - def _has_final_result(cls, session: Session, simulation: Simulation) -> bool: + def _has_final_result(cls, simulation: Simulation) -> bool: """Whether the simulation holds a complete result that updates cannot change. - Stale SPM output is not final: it is reset to pending and run again. Nor is - a result whose rerun the server has started and not yet finished. + A complete result from the installed country model, or a newer one, is + final. A complete record without output holds no result. Stale SPM output + is not final: it is reset to pending and run again. Nor is a result from an + older model, which a browser that runs the calculation again replaces with + the installed model's. """ - if simulation.status != "complete" or cls._has_stale_spm_output( - simulation.output - ): - return False - if simulation.active_run_id is None: - return True - active_run = session.scalar( - select(SimulationRun).where(SimulationRun.id == simulation.active_run_id) + return ( + simulation.status == "complete" + and simulation.output is not None + and not cls._has_stale_spm_output(simulation.output) + and not cls._computed_by_older_model(simulation) ) - return active_run is None or active_run.status not in {"pending", "running"} def ensure_simulation_dual_write_state( self, @@ -305,22 +318,17 @@ def get_simulation( with self._sessions() as session: return self._select_simulation(session, simulation_id, country_id) - def update_simulation( - self, - country_id: str, - simulation_id: int, - status: str | None = None, - output: dict | list | str | None = None, - error_message: str | None = None, - ) -> Simulation | None: - """Apply the given status, output or error message and sync the run history. - - Return None when nothing was given. The simulation is restamped with the current - country package version. A complete result is final: an update that only - repeats it returns the simulation unchanged, and one that would change it - raises SimulationAlreadyCompleteError. Raise ValueError for output with legacy - ``spm_config`` or an invalid SPM receipt, and LookupError if the simulation - does not exist. + @staticmethod + def _update_values( + status: str | None, + output: dict | list | str | None, + error_message: str | None, + ) -> dict: + """Return the fields an update sets, with output decoded and its SPM checked. + + Null fields, and output that decodes to null, set nothing. Raise ValueError + for a completion without output, output with legacy ``spm_config`` or an + invalid SPM receipt. """ values = { key: value @@ -331,10 +339,13 @@ def update_simulation( }.items() if value is not None } - if not values: - return None if isinstance(values.get("output"), str): values["output"] = json.loads(values["output"]) + if values["output"] is None: + del values["output"] + if values.get("status") == "complete" and "output" not in values: + msg = "output is required when status is 'complete'" + raise ValueError(msg) output_value = values.get("output") if isinstance(output_value, dict) and "spm_config" in output_value: msg = "Completed simulation output must not include legacy spm_config" @@ -347,6 +358,29 @@ def update_simulation( **output_value, "spm_provenance": receipt.model_dump(mode="json", by_alias=True), } + return values + + def update_simulation( + self, + country_id: str, + simulation_id: int, + status: str | None = None, + output: dict | list | str | None = None, + error_message: str | None = None, + ) -> Simulation | None: + """Apply the given status, output or error message and sync the run history. + + Return None when nothing was given; a JSON-encoded null output counts as + none. An applied update restamps the simulation with the installed country + package version. A complete result from the installed model is final: an + update that only repeats it returns the simulation unchanged, and one that + would change it raises SimulationAlreadyCompleteError. Raise ValueError for + a completion without output, output with legacy ``spm_config`` or an + invalid SPM receipt, and LookupError if the simulation does not exist. + """ + values = self._update_values(status, output, error_message) + if not values: + return None with self._sessions.begin() as session: simulation = self._select_simulation( session, @@ -357,7 +391,7 @@ def update_simulation( if simulation is None: msg = f"Simulation #{simulation_id} not found" raise LookupError(msg) - if self._has_final_result(session, simulation): + if self._has_final_result(simulation): if changes_stored_result(simulation, values): msg = ( f"Simulation #{simulation_id} is complete; " diff --git a/tests/integration/test_cloud_run_candidate.py b/tests/integration/test_cloud_run_candidate.py index 0430d0fe2..f4b2d85fa 100644 --- a/tests/integration/test_cloud_run_candidate.py +++ b/tests/integration/test_cloud_run_candidate.py @@ -37,10 +37,11 @@ def test_cloud_run_candidate_metadata_and_policy( def test_cloud_run_candidate_serves_no_legacy_user_routes(api_client): - # Only reads: a candidate that still served these routes would answer 200. - for path in ("/us/user-profile?user_id=1", "/us/user-policy/candidate-probe"): + # Reads only, of IDs no record can have (v1 IDs start at 1), and the body is + # never printed: CI logs are public. + for path in ("/us/user-profile?user_id=0", "/us/user-policy/candidate-probe-0"): response = api_client.get(path) - assert response.status_code == 404, (path, response.text) + assert response.status_code == 404, (path, response.status_code) def test_cloud_run_candidate_stage6_read_route_contracts(api_client): diff --git a/tests/unit/routes/test_legacy_user_records_owner_scoped.py b/tests/unit/routes/test_legacy_user_records_owner_scoped.py index 4b4e72363..fad6940e6 100644 --- a/tests/unit/routes/test_legacy_user_records_owner_scoped.py +++ b/tests/unit/routes/test_legacy_user_records_owner_scoped.py @@ -95,9 +95,16 @@ def _package_modules() -> dict[str, ast.Module]: } -def _imported_names(module_name: str, tree: ast.Module) -> set[str]: - """Every module this one imports anywhere in its body, function-local included.""" - package = module_name.rsplit(".", 1)[0] +def _packages() -> set[str]: + return {_module_name(path) for path in PACKAGE_ROOT.rglob("__init__.py")} + + +def _imported_names( + module_name: str, tree: ast.Module, *, is_package: bool = False +) -> set[str]: + """Return every module this one imports, function-local imports included.""" + # A relative import in a package's __init__ is relative to that package. + package = module_name if is_package else module_name.rsplit(".", 1)[0] imported: set[str] = set() for node in ast.walk(tree): if isinstance(node, ast.Import): @@ -114,6 +121,7 @@ def _imported_names(module_name: str, tree: ast.Module) -> set[str]: def _http_reachable_modules() -> set[str]: modules = _package_modules() + packages = _packages() roots = { name for name in modules @@ -129,7 +137,9 @@ def _http_reachable_modules() -> set[str]: if name in reachable or name not in modules: continue reachable.add(name) - for imported in _imported_names(name, modules[name]): + for imported in _imported_names( + name, modules[name], is_package=name in packages + ): # Importing a.b.c also runs a and a.b. parts = imported.split(".") pending.extend(".".join(parts[:end]) for end in range(1, len(parts) + 1)) @@ -137,14 +147,33 @@ def _http_reachable_modules() -> set[str]: def _imports_legacy_user_model(tree: ast.Module) -> set[str]: - return { - alias.name + """Return the v1 user-record models a module uses, by name or through its module.""" + used: set[str] = set() + module_aliases: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom): + module = node.module or "" + for alias in node.names: + if module.endswith("v1_models") and alias.name in ( + LEGACY_USER_RECORD_MODELS + ): + used.add(alias.name) + if alias.name == "v1_models": + module_aliases.add(alias.asname or alias.name) + elif isinstance(node, ast.Import): + module_aliases.update( + alias.asname or alias.name + for alias in node.names + if alias.name.endswith(".v1_models") + ) + used.update( + node.attr for node in ast.walk(tree) - if isinstance(node, ast.ImportFrom) - and (node.module or "").endswith("data.v1_models") - for alias in node.names - if alias.name in LEGACY_USER_RECORD_MODELS - } + if isinstance(node, ast.Attribute) + and node.attr in LEGACY_USER_RECORD_MODELS + and ast.unparse(node.value) in module_aliases + ) + return used @pytest.fixture @@ -257,6 +286,20 @@ def test_reachability_check_detects_a_route_that_imports_the_saved_policy_servic "policyengine_api.routes.example_routes", tree ) assert _imports_legacy_user_model(tree) == {"UserProfile"} + by_module = ast.parse( + "from policyengine_api.data import v1_models\n" + "import policyengine_api.data.v1_models as models\n" + "v1_models.UserPolicy\n" + "models.UserPolicyMirrorEvent\n" + ) + assert _imports_legacy_user_model(by_module) == { + "UserPolicy", + "UserPolicyMirrorEvent", + } + package_init = ast.parse("from .services import user_policy_service\n") + assert "policyengine_api.routes.services.user_policy_service" in ( + _imported_names("policyengine_api.routes", package_init, is_package=True) + ) _identifiers = st.one_of( diff --git a/tests/unit/routes/test_result_updates_are_final.py b/tests/unit/routes/test_result_updates_are_final.py index eaf463a12..982a19959 100644 --- a/tests/unit/routes/test_result_updates_are_final.py +++ b/tests/unit/routes/test_result_updates_are_final.py @@ -5,12 +5,19 @@ are shared, since identical requests reuse one record. So once a record holds a complete result, an update may only repeat it (a retried write, or a second browser finishing the same calculation) and never change it. Changing it -answers 409 and leaves the record as it was. +answers 409 and leaves the record and its runs as they were. -Two results are not final, because the server, not a caller, decided they run -again: stale SPM output on a simulation, a report stored under an outdated -cache version, and either record while a rerun the server started is pending -or running. +These are not final: + +- a complete record without output, which holds no result; +- a simulation's stale SPM output, which the server resets and runs again; +- a simulation result from an older country model, which a browser that runs + the calculation again replaces with the installed model's; +- a report while a rerun the server started is pending or running. + +An update to a report stored under an outdated cache version goes to its +current equivalent, the record reads serve for that ID, and the rule applies +there. """ import json @@ -25,6 +32,7 @@ from hypothesis import strategies as st from sqlalchemy import select +from policyengine_api.constants import COUNTRY_PACKAGE_VERSIONS from policyengine_api.data.v1_models import ( ReportOutput, ReportOutputRun, @@ -39,10 +47,6 @@ NewReportOutputRun, ReportRunService, ) -from policyengine_api.services.simulation_run_service import ( - NewSimulationRun, - SimulationRunService, -) from policyengine_api.services.simulation_service import SimulationService SPM_RECEIPT = { @@ -147,14 +151,47 @@ def _stored(session_factory, model, record_id): } -def _run_rows(session_factory, model, foreign_key, record_id): +_RUNS = { + Simulation: (SimulationRun, "simulation_id"), + ReportOutput: (ReportOutputRun, "report_output_id"), +} + + +def _snapshot(session_factory, model, record_id): + """Return every column of the record and of each of its runs.""" + run_model, foreign_key = _RUNS[model] with session_factory() as session: - return [ - (run.id, run.status, run.output, run.error_message) - for run in session.scalars( - select(model).where(getattr(model, foreign_key) == record_id) + record = session.get(model, record_id) + runs = session.scalars( + select(run_model) + .where(getattr(run_model, foreign_key) == record_id) + .order_by(run_model.run_sequence) + ) + return ( + { + column.name: getattr(record, column.name) + for column in model.__table__.columns + }, + [ + tuple( + getattr(run, column.name) for column in run_model.__table__.columns + ) + for run in runs + ], + ) + + +def _current_report_for(session_factory, report_id): + """Return the ID of the current report with an outdated report's inputs.""" + with session_factory() as session: + outdated = session.get(ReportOutput, report_id) + return session.scalar( + select(ReportOutput.id).where( + ReportOutput.id != report_id, + ReportOutput.simulation_1_id == outdated.simulation_1_id, + ReportOutput.year == outdated.year, ) - ] + ) # Simulations @@ -174,10 +211,7 @@ def test_anonymous_patch_cannot_change_a_complete_simulation( ).status_code == 200 ) - before = _stored(orm_session_factory, Simulation, simulation_id) - runs_before = _run_rows( - orm_session_factory, SimulationRun, "simulation_id", simulation_id - ) + before = _snapshot(orm_session_factory, Simulation, simulation_id) for attack in ( {"status": "complete", "output": FORGED_RESULT}, @@ -192,13 +226,7 @@ def test_anonymous_patch_cannot_change_a_complete_simulation( body = response.get_json() assert body["status"] == "error" assert "is complete; its result cannot be changed" in body["message"] - assert _stored(orm_session_factory, Simulation, simulation_id) == before - assert ( - _run_rows( - orm_session_factory, SimulationRun, "simulation_id", simulation_id - ) - == runs_before - ) + assert _snapshot(orm_session_factory, Simulation, simulation_id) == before read = client.get(f"/us/simulation/{simulation_id}").get_json()["result"] assert json.loads(read["output"]) == HOUSEHOLD_RESULT @@ -217,6 +245,7 @@ def test_calculation_path_completes_and_may_repeat_its_result( output=json.dumps(HOUSEHOLD_RESULT), ) stored = _stored(orm_session_factory, Simulation, simulation_id) + snapshot = _snapshot(orm_session_factory, Simulation, simulation_id) # A retry, or a second browser that ran the same calculation, sends the # same result again; either JSON encoding of it is a repeat. repeats = [ @@ -236,7 +265,7 @@ def test_calculation_path_completes_and_may_repeat_its_result( for repeat in repeats: assert repeat.status_code == 200 assert json.loads(repeat.get_json()["result"]["output"]) == HOUSEHOLD_RESULT - assert _stored(orm_session_factory, Simulation, simulation_id) == stored + assert _snapshot(orm_session_factory, Simulation, simulation_id) == snapshot def test_failed_simulation_can_be_calculated_again(client, orm_session_factory): @@ -282,26 +311,51 @@ def test_stale_spm_simulation_output_is_not_final(client, orm_session_factory): ) -def test_server_started_simulation_rerun_is_not_final(client, orm_session_factory): +def _complete_simulation_from_model(client, session_factory, version): simulation_id = _new_simulation(client) _patch( client, "simulation", simulation_id, status="complete", output=HOUSEHOLD_RESULT ) - with orm_session_factory.begin() as session: - rerun = SimulationRunService().create_simulation_run( - session, simulation_id, NewSimulationRun(trigger_type="rerun") - ) - session.get(Simulation, simulation_id).active_run_id = rerun.id + with session_factory.begin() as session: + session.get(Simulation, simulation_id).api_version = version + return simulation_id + + +@pytest.mark.parametrize("version", ["0.0.1", "historical"]) +def test_result_from_an_older_model_can_be_calculated_again( + client, orm_session_factory, version +): + simulation_id = _complete_simulation_from_model( + client, orm_session_factory, version + ) response = _patch( client, "simulation", simulation_id, status="complete", output=FORGED_RESULT ) assert response.status_code == 200 - assert ( - _stored(orm_session_factory, Simulation, simulation_id)["output"] - == FORGED_RESULT + stored = _stored(orm_session_factory, Simulation, simulation_id) + assert stored["output"] == FORGED_RESULT + assert stored["api_version"] == COUNTRY_PACKAGE_VERSIONS["us"] + + +def test_result_from_a_newer_model_is_final(client, orm_session_factory): + # An older revision, such as a rollback, cannot replace a newer model's result. + simulation_id = _complete_simulation_from_model( + client, orm_session_factory, "99999.0.0" + ) + before = _snapshot(orm_session_factory, Simulation, simulation_id) + + changed = _patch( + client, "simulation", simulation_id, status="complete", output=FORGED_RESULT ) + repeated = _patch( + client, "simulation", simulation_id, status="complete", output=HOUSEHOLD_RESULT + ) + + assert changed.status_code == 409 + assert repeated.status_code == 200 + assert _snapshot(orm_session_factory, Simulation, simulation_id) == before # Reports @@ -319,10 +373,7 @@ def test_anonymous_patch_cannot_change_a_complete_report(client, orm_session_fac ).status_code == 200 ) - before = _stored(orm_session_factory, ReportOutput, report_id) - runs_before = _run_rows( - orm_session_factory, ReportOutputRun, "report_output_id", report_id - ) + before = _snapshot(orm_session_factory, ReportOutput, report_id) for attack in ( {"status": "complete", "output": {"household": 2}}, @@ -338,19 +389,13 @@ def test_anonymous_patch_cannot_change_a_complete_report(client, orm_session_fac "is complete; its result cannot be changed" in (response.get_json()["message"]) ) - assert _stored(orm_session_factory, ReportOutput, report_id) == before - assert ( - _run_rows( - orm_session_factory, ReportOutputRun, "report_output_id", report_id - ) - == runs_before - ) + assert _snapshot(orm_session_factory, ReportOutput, report_id) == before repeat = _patch( client, "report", report_id, status="complete", output={"household": 1} ) assert repeat.status_code == 200 - assert _stored(orm_session_factory, ReportOutput, report_id) == before + assert _snapshot(orm_session_factory, ReportOutput, report_id) == before def test_completed_us_economy_report_compares_its_canonical_result( @@ -380,22 +425,54 @@ def test_completed_us_economy_report_compares_its_canonical_result( ) -def test_report_under_an_outdated_cache_version_is_not_final( - client, orm_session_factory -): +def _outdated_complete_report(client, session_factory): report_id = _new_report(client) _patch(client, "report", report_id, status="complete", output={"household": 1}) - with orm_session_factory.begin() as session: + with session_factory.begin() as session: session.get(ReportOutput, report_id).api_version = "outdated" + return report_id + + +def test_update_to_an_outdated_report_id_completes_its_current_equivalent( + client, orm_session_factory +): + outdated_id = _outdated_complete_report(client, orm_session_factory) + outdated_before = _snapshot(orm_session_factory, ReportOutput, outdated_id) + # Reads serve the current equivalent, still pending, under the requested ID. + read = client.get(f"/us/report/{outdated_id}").get_json()["result"] + current_id = _current_report_for(orm_session_factory, outdated_id) response = _patch( - client, "report", report_id, status="complete", output={"household": 2} + client, "report", outdated_id, status="complete", output={"household": 2} ) + assert read["id"] == outdated_id + assert read["status"] == "pending" assert response.status_code == 200 - assert _stored(orm_session_factory, ReportOutput, report_id)["output"] == { + assert response.get_json()["result"]["id"] == outdated_id + assert _stored(orm_session_factory, ReportOutput, current_id)["output"] == { "household": 2 } + assert _snapshot(orm_session_factory, ReportOutput, outdated_id) == outdated_before + reread = client.get(f"/us/report/{outdated_id}").get_json()["result"] + assert reread["status"] == "complete" + assert json.loads(reread["output"]) == {"household": 2} + + +def test_outdated_report_id_cannot_reopen_its_complete_current_equivalent( + client, orm_session_factory +): + outdated_id = _outdated_complete_report(client, orm_session_factory) + _patch(client, "report", outdated_id, status="complete", output={"household": 2}) + current_id = _current_report_for(orm_session_factory, outdated_id) + before = _snapshot(orm_session_factory, ReportOutput, current_id) + + response = _patch( + client, "report", outdated_id, status="complete", output={"household": 3} + ) + + assert response.status_code == 409 + assert _snapshot(orm_session_factory, ReportOutput, current_id) == before def test_server_started_report_rerun_is_not_final(client, orm_session_factory): @@ -419,6 +496,75 @@ def test_server_started_report_rerun_is_not_final(client, orm_session_factory): } +# Malformed updates + + +@pytest.mark.parametrize("resource", ["simulation", "report"]) +def test_completion_needs_an_output(client, orm_session_factory, resource): + record_id = ( + _new_simulation(client) if resource == "simulation" else _new_report(client) + ) + model = Simulation if resource == "simulation" else ReportOutput + before = _snapshot(orm_session_factory, model, record_id) + + responses = [ + _patch(client, resource, record_id, status="complete", output=output) + for output in ("null", None) + ] + + assert [response.status_code for response in responses] == [400, 400] + assert _snapshot(orm_session_factory, model, record_id) == before + + +@pytest.mark.parametrize("resource", ["simulation", "report"]) +@pytest.mark.parametrize("record_id", ["1", 1.0, True, None]) +def test_update_needs_an_integer_id(client, resource, record_id): + response = _patch( + client, resource, record_id, status="complete", output={"value": 1} + ) + + assert response.status_code == 400 + assert response.get_json()["message"].endswith("id must be an integer") + + +@pytest.mark.parametrize("resource", ["simulation", "report"]) +def test_complete_record_without_output_can_be_completed( + client, orm_session_factory, resource +): + record_id = ( + _new_simulation(client) if resource == "simulation" else _new_report(client) + ) + model = Simulation if resource == "simulation" else ReportOutput + with orm_session_factory.begin() as session: + session.get(model, record_id).status = "complete" + + response = _patch( + client, resource, record_id, status="complete", output={"value": 1} + ) + + assert response.status_code == 200 + assert _stored(orm_session_factory, model, record_id)["output"] == {"value": 1} + + +@pytest.mark.parametrize("resource", ["simulation", "report"]) +def test_json_string_output_repeats_as_itself(client, orm_session_factory, resource): + record_id = ( + _new_simulation(client) if resource == "simulation" else _new_report(client) + ) + model = Simulation if resource == "simulation" else ReportOutput + output = json.dumps("1") + + completed = _patch(client, resource, record_id, status="complete", output=output) + snapshot = _snapshot(orm_session_factory, model, record_id) + repeated = _patch(client, resource, record_id, status="complete", output=output) + changed = _patch(client, resource, record_id, status="complete", output=1) + + assert (completed.status_code, repeated.status_code) == (200, 200) + assert changed.status_code == 409 + assert _stored(orm_session_factory, model, record_id)["output"] == "1" + assert _snapshot(orm_session_factory, model, record_id) == snapshot + + # Properties over every update sequence @@ -426,9 +572,13 @@ def test_server_started_report_rerun_is_not_final(client, orm_session_factory): {"value": 1}, {"value": 2}, {"nested": {"values": [1, 2.5, None]}}, + [1, 2], + "1", + 1, ) _output_values = st.one_of( st.none(), + st.just("null"), st.sampled_from(_OUTPUTS), st.sampled_from(_OUTPUTS).map(json.dumps), ) @@ -477,11 +627,13 @@ def _expected_response(model, update, *, running_allowed): values = {key: value for key, value in update.items() if value is not None} if "output" in values: values["output"] = _decoded(values["output"]) + if values["output"] is None: + del values["output"] if values.get("status") == "complete" and "output" not in values: return 400, model if not values: return 400, model - if model["status"] == "complete": + if model["status"] == "complete" and model["output"] is not None: changed = any(model[field] != value for field, value in values.items()) return (409 if changed else 200), model if values.get("status") == "running" and not running_allowed(model): @@ -517,14 +669,15 @@ def _check_update_sequence(client, session_factory, resource, record_id, updates model, update, running_allowed=resource.running_allowed ) stored = _stored(session_factory, resource.model, record_id) + snapshot = _snapshot(session_factory, resource.model, record_id) assert response.status_code == expected_code, (update, response.get_json()) assert {field: stored[field] for field in model} == model if completed_record is not None: - # Once complete, nothing an update sends changes the record. - assert stored == completed_record + # Once complete, nothing an update sends changes the record or its runs. + assert snapshot == completed_record elif stored["status"] == "complete": - completed_record = stored + completed_record = snapshot event(f"{resource.path} completed") diff --git a/tests/unit/services/test_simulation_service.py b/tests/unit/services/test_simulation_service.py index 1880ed1e7..baafec8f4 100644 --- a/tests/unit/services/test_simulation_service.py +++ b/tests/unit/services/test_simulation_service.py @@ -262,7 +262,7 @@ def test_update_simulation_without_values_is_a_noop(service): def test_update_missing_simulation_raises(service): with pytest.raises(LookupError, match="Simulation #999 not found"): - service.update_simulation("us", 999, status="complete") + service.update_simulation("us", 999, status="complete", output={"ok": True}) @pytest.mark.parametrize("population_id", ["1", "00001", 1, "household-1"]) diff --git a/uv.lock b/uv.lock index 869aef00c..0e9e86082 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 5 requires-python = ">=3.11" resolution-markers = [ "python_full_version >= '3.15' and sys_platform == 'win32'", @@ -1437,95 +1437,90 @@ wheels = [ [[package]] name = "hypothesis" -version = "6.170.8" +version = "6.168.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "sortedcontainers" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d7/1f/d08bc6d4548cf5f6a022895d2f32c23e981fb417517358aebe612544a2ad/hypothesis-6.170.8.tar.gz", hash = "sha256:dfc56ceac35d825fb6a85fa3ac36a598fee3101d264a39895a62f78c1cc95521", size = 518977, upload-time = "2026-10-22T23:01:19.151Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/51/f92725033fcbe58da360e7ab5fa28e6eb18f2b1b61380d9fbb0db4f25e76/hypothesis-6.170.8-cp311-abi3-macosx_10_12_x86_64.whl", hash = "sha256:1df9dc7e7e2592d23d7b23146608606d017f56b0e3a35fa96bcae972b834ee5c", size = 797597, upload-time = "2026-10-22T23:00:37.287Z" }, - { url = "https://files.pythonhosted.org/packages/fe/b8/af62021a917aa96820405743638c900acecec93b09414e832c18093635f2/hypothesis-6.170.8-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:2e90a5eb5e669788659ed3a2752e45d87c8efe95d578f22cd7657196b67de273", size = 792728, upload-time = "2026-10-22T22:59:36.553Z" }, - { url = "https://files.pythonhosted.org/packages/4c/00/d736082ba98514bc1e10ba9fbbb85e36dce0586ad159a9f4aee976728a34/hypothesis-6.170.8-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c9fd6bff853e7c9bce11b22d426f533a7d82ac38f5503ee3615b8b2580692bf", size = 1121647, upload-time = "2026-10-22T22:59:59.096Z" }, - { url = "https://files.pythonhosted.org/packages/76/d4/0cf4d8eaa23677aea550d6dd60c3827d1fc0dde1693d43cc8ad42707b086/hypothesis-6.170.8-cp311-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8814ab69b474b216b6540e77c6df23a5d66852c32398a74efd47f4435615094a", size = 1152210, upload-time = "2026-10-22T23:01:00.305Z" }, - { url = "https://files.pythonhosted.org/packages/d6/cd/06eed98acee61e4bc926c8cc6d4cfdeabd0a96adb1c6301070189607791e/hypothesis-6.170.8-cp311-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:44eb8b223128239bd7ad94988abc288b406ef4ebf4d366a3a3a1900977259256", size = 1146065, upload-time = "2026-10-22T22:59:39.145Z" }, - { url = "https://files.pythonhosted.org/packages/85/ca/f5e95c9f49f72caf52287e50b761414209654fdda28cb0ec0dc5d44d36e4/hypothesis-6.170.8-cp311-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:73b106f4365c6cbe6e388e96e063344c8ed0de3428b7332c370274d50302ec57", size = 1198776, upload-time = "2026-10-22T22:59:41.683Z" }, - { url = "https://files.pythonhosted.org/packages/6c/29/d3df029d4edd2beca446ba4fc2cfd2b60d2b5ea11cb2172ecec444055e1b/hypothesis-6.170.8-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17f665953a850fa99358a3ee9b36f8b99d2b8743be9f76b247711fba4fd53912", size = 1165074, upload-time = "2026-10-22T23:00:48.8Z" }, - { url = "https://files.pythonhosted.org/packages/5a/a2/0363b0024cb6d1902b4e79d447262c99bd5def0eafdd3ef18ad8ba7df90d/hypothesis-6.170.8-cp311-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:234528678cc3124347afb0d794047c16ffc775412524e5315794e6785a53c1d3", size = 1121184, upload-time = "2026-10-22T23:00:21.871Z" }, - { url = "https://files.pythonhosted.org/packages/b6/21/63963a74763a12919591a4faae8c2314a3ae596e3d8497239a9c1570374f/hypothesis-6.170.8-cp311-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3a9a5e5a68b04436777d52e9d22b1b9b91d4a5df37970464c31d585916e9b6eb", size = 1159494, upload-time = "2026-10-22T23:00:54.06Z" }, - { url = "https://files.pythonhosted.org/packages/1a/41/ad311877f9939ec00b434e0a14ff5c3b5e19451f0197364d69bf343872af/hypothesis-6.170.8-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:035f28c2e3cbd6938ba543114ce4f8bb3397af5df9691c5a6284fc2b3f74b090", size = 1296619, upload-time = "2026-10-22T22:59:44.497Z" }, - { url = "https://files.pythonhosted.org/packages/ee/ab/f0f9fe37c2eff19524d1b3bfe01e61f77d2124f51790180e944939d18c0a/hypothesis-6.170.8-cp311-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:d26f10b438326fc60360643e20512d6ce7e975004f548d19f9daa8788db2f933", size = 1425261, upload-time = "2026-10-22T23:01:01.898Z" }, - { url = "https://files.pythonhosted.org/packages/25/9b/bd6abe8c0d6fa675f29bf47c52c4ef72ee1887030af58b8bc4fe00523f0e/hypothesis-6.170.8-cp311-abi3-musllinux_1_2_i686.whl", hash = "sha256:306a46be8490ef9017f984215be4456e12781095cc3c2d156c8d91c08b2f5ea3", size = 1379343, upload-time = "2026-10-22T23:01:05.948Z" }, - { url = "https://files.pythonhosted.org/packages/c1/24/26fc0009a8ab65f94db5389adc98c8dac24433d7cc6770bf37bfbe16b955/hypothesis-6.170.8-cp311-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:f8703a60a5be8156a34c26aa441080741c06ee5c4e20101a3c4b1ea6dbbfe530", size = 1275282, upload-time = "2026-10-22T23:00:43.289Z" }, - { url = "https://files.pythonhosted.org/packages/96/7f/e8235d843a037c1eed2448e1590205477853e7f5b4acd2a8242332aaabb8/hypothesis-6.170.8-cp311-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:753a97a89d1db29652bb64b58b65aa38e12ed74a547f84490970a0730e531477", size = 1291047, upload-time = "2026-10-22T23:00:19.098Z" }, - { url = "https://files.pythonhosted.org/packages/85/41/494265befb96abe58023640d537750eb7f9c15cf7ae6183ba4ccf274d281/hypothesis-6.170.8-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b8a2addb49a77871fe67611e0b062fab7618f2faa151a5d8f2558947a876fdfa", size = 1330783, upload-time = "2026-10-22T23:00:39.506Z" }, - { url = "https://files.pythonhosted.org/packages/1f/88/b6928822b6cfe88c7765c7151bdcc3238b0bfc37074fbe6fe7c70b913190/hypothesis-6.170.8-cp311-abi3-win32.whl", hash = "sha256:126f7b7ad551394835ade8ad4c3467af27c4e6a25e566a10c644cd27f4b668b0", size = 684777, upload-time = "2026-10-22T23:00:44.754Z" }, - { url = "https://files.pythonhosted.org/packages/85/e7/d9db34e28733bcfb6802486bb87ece82a4b046705aeeecff24d4e746f5f6/hypothesis-6.170.8-cp311-abi3-win_amd64.whl", hash = "sha256:a74c01e413f51ebb9d3ef701e5e0487945f5515e0f26411d0178f25918bdd7de", size = 690852, upload-time = "2026-10-22T23:01:04.398Z" }, - { url = "https://files.pythonhosted.org/packages/95/45/13e0ce6686954006aff190227473a12282557c99f44d397c3fe026cc0eea/hypothesis-6.170.8-cp311-abi3-win_arm64.whl", hash = "sha256:2340d0be6409eede4b96bea1f12f69aad48cb3cce3bd96b28e998b0e4fc4ca13", size = 689167, upload-time = "2026-10-22T23:00:29.335Z" }, - { url = "https://files.pythonhosted.org/packages/86/3e/347ab6986aa6d53f9cbaae26e8739285b3292c3c372d0d060378e7569046/hypothesis-6.170.8-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:1f0b15eed167561ffe014cfe8b64ccddd14451643bf1fddc2d85a456f10b42a8", size = 798316, upload-time = "2026-10-22T22:59:55.059Z" }, - { url = "https://files.pythonhosted.org/packages/d2/57/4d98be1f0877b0d4ac00e1f7f82fb7994bde4a278a0ff2f603a72aa9c0b7/hypothesis-6.170.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f5997df11d3c65b73b81e91cb884d997bdfb50b964ac78ce346639a925844987", size = 793992, upload-time = "2026-10-22T23:00:16.797Z" }, - { url = "https://files.pythonhosted.org/packages/18/65/9f09153a94d36515305be7760bc1f18fbc7d33feb9b8e8b9a60649fd39de/hypothesis-6.170.8-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8ff32395e1b14a2dcbe10cb6a82dbdac51b480d7fdd0c085d9ba3c131a1ba3aa", size = 1122616, upload-time = "2026-10-22T22:59:37.699Z" }, - { url = "https://files.pythonhosted.org/packages/23/2c/c12aa7e546b80ea3b205692a77b2a4bfa93094d18277d2b25e1bfa71d2d8/hypothesis-6.170.8-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a30e50ae2ea5800083ac22dede1f3cbdebfe21bface71256ebf1bc8d95b5ad4", size = 1165319, upload-time = "2026-10-22T22:59:34.769Z" }, - { url = "https://files.pythonhosted.org/packages/a6/75/823f940c4d445dee8a9664868b055c4e22d91b5c91956a6dbc8fac86b415/hypothesis-6.170.8-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b7cf89166db8e79998f9db644d6e14a29a2feb190ed46fc8d0761f4a3d3e0fe", size = 1297384, upload-time = "2026-10-22T23:01:03.146Z" }, - { url = "https://files.pythonhosted.org/packages/f5/07/10fe91204c32d0a457a4757e165dc505a8dfac1969f5a672e1e68501ae1c/hypothesis-6.170.8-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9854a3727e2aa0b2b7750b2065a4ecd8dc6656a2645e6a12e1cd23c4d2ac4ebb", size = 1331363, upload-time = "2026-10-22T22:59:45.974Z" }, - { url = "https://files.pythonhosted.org/packages/2f/df/a489ae4936218a66274bf420733fba3edc04f323d617be59e08cc0532c1e/hypothesis-6.170.8-cp311-cp311-win_amd64.whl", hash = "sha256:23d11a342eb539b458566ef102949e074be5be48bc173f7e9076ae3bf8f0c7c1", size = 690961, upload-time = "2026-10-22T23:00:51.3Z" }, - { url = "https://files.pythonhosted.org/packages/ea/0c/067773a7285aa019eaa8d795379f44276a4f8434a61d96e2a05796b627a0/hypothesis-6.170.8-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:d8ec59dbd3fcf50e80296044f8eed5bbd4ac41d500e49e7889623e8d84345f9d", size = 799373, upload-time = "2026-10-22T23:00:35.002Z" }, - { url = "https://files.pythonhosted.org/packages/9d/e5/1936319d990eb29f636241cfe43a28e8e324f73e52bcaa628655d7db3a11/hypothesis-6.170.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:93df677e9c289c4aa61468446558c7f34a8985a870d373feaa3fb48095c03bce", size = 791385, upload-time = "2026-10-22T23:00:14.176Z" }, - { url = "https://files.pythonhosted.org/packages/af/a4/882bdbae3c60a91dc0713e34c43006f87780eb37775f3d0c56052ee149ca/hypothesis-6.170.8-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf55c53055f6f3effb1503903d5939eea221004daefa1e12a66176358eb47bfb", size = 1121045, upload-time = "2026-10-22T22:59:31.96Z" }, - { url = "https://files.pythonhosted.org/packages/5f/8d/051d191bc91e23847a388126adbe02dfbb40ab74124aac804cfab961b428/hypothesis-6.170.8-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76e2d62bfac25fba0d42072a0bdf5b2a0b34ca4fde247871fb807f6a8b1c9d6d", size = 1164722, upload-time = "2026-10-22T23:00:52.894Z" }, - { url = "https://files.pythonhosted.org/packages/cf/f3/31f7d8d41a901d43faa74afd0a4f8b7085dcf82aa312d3ad48878c93b4ee/hypothesis-6.170.8-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c4803e04cebefb00007ee44ede19f2bd7bfedc69dd925425640d843136caac55", size = 1295920, upload-time = "2026-10-22T22:59:56.408Z" }, - { url = "https://files.pythonhosted.org/packages/08/65/8b30ce8b0ca34e0259ba24db741b5650171425260596f0d202eb9ec72226/hypothesis-6.170.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:04a3c4c1c08fb70cc318681198908479d1c0203c3840dc0fae00f3a79f8d1a78", size = 1330362, upload-time = "2026-10-22T23:01:17.6Z" }, - { url = "https://files.pythonhosted.org/packages/c8/a6/3741c5f9c9b0b3765c40da2f9909850eda48eb05057eca9153f4a5409e4b/hypothesis-6.170.8-cp312-cp312-win_amd64.whl", hash = "sha256:e9ae7a6605419ced1a8d38f2209c92c72c58c711c46f9f17b14f15daf65b1159", size = 688477, upload-time = "2026-10-22T22:59:48.416Z" }, - { url = "https://files.pythonhosted.org/packages/4c/94/6705efab6e1a56937e23d1240d7a960a567096ae61170052d62f781883b0/hypothesis-6.170.8-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:ae7f6f8922ba0b591ac7a0e49b11a8998506341c37aa7342d8780f3493fc6c2c", size = 799354, upload-time = "2026-10-22T23:00:30.904Z" }, - { url = "https://files.pythonhosted.org/packages/49/8f/ec15e409880ee6a3d6521876b3e1b6c9e74cda95cb09991adc584fadffa2/hypothesis-6.170.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:974154681ed759a6f25690d05d4b869d81566e1eab7aad5cc2fa20f430c29185", size = 791378, upload-time = "2026-10-22T23:01:07.218Z" }, - { url = "https://files.pythonhosted.org/packages/04/dc/00a1dc37ae049f682c6ac67e13b0157b606b82d243149fe132ecaefef1d5/hypothesis-6.170.8-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e17be3656d93c23b322bb394f80cd82ab9fb5f9d46b6c10205239a156adc9066", size = 1120962, upload-time = "2026-10-22T23:01:12.647Z" }, - { url = "https://files.pythonhosted.org/packages/d6/ae/de4882a3d23b7ee886b051b7ba34bf6b094df3764b55514b9905afcba6a4/hypothesis-6.170.8-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:37e21ec7d3fbea8cb6cf99147a843cfcd6bef83d2940a392eea50dab082ae3df", size = 1164624, upload-time = "2026-10-22T23:00:46.015Z" }, - { url = "https://files.pythonhosted.org/packages/f3/ec/010e5580b4073e7cb7cb6a78f7029a501860687c2ad70283d3a155c4f090/hypothesis-6.170.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b9a4063b725f48daf3de53d2ad07bf23ad4901280bdc56153537656fe66a0da0", size = 1295934, upload-time = "2026-10-22T23:00:57.936Z" }, - { url = "https://files.pythonhosted.org/packages/7e/42/c6d58ffb97ddc4f8d65094d6b2280ca44fef42025814ccf0dc09e39c8bad/hypothesis-6.170.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:27d5e4a0f53cca67db109c79abf2dbd6a166812b48b76449155cfcfc9856cda2", size = 1330401, upload-time = "2026-10-22T23:00:33.701Z" }, - { url = "https://files.pythonhosted.org/packages/1c/d7/47b5796abccccd534bae98b523a18de9e466fe984f0c82f8e413a936c0fd/hypothesis-6.170.8-cp313-cp313-win_amd64.whl", hash = "sha256:ed44a53628385acf09e0ecf2372dc6ccfeba4cd77bd03455277b4667c7ad659d", size = 688457, upload-time = "2026-10-22T23:00:38.381Z" }, - { url = "https://files.pythonhosted.org/packages/af/7b/6751518dae51237094587ca6d31383df6d4f5f1e66d5a10c12ba4000d6be/hypothesis-6.170.8-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:327b00d562bbb64853a688ff87968276536191dbb3de8ffdd98d5db94150533f", size = 799559, upload-time = "2026-10-22T23:00:50.111Z" }, - { url = "https://files.pythonhosted.org/packages/d2/57/b53ce8e4dd347989efc2e445ced86929d5da6244e9e4463f1b94d489b8b5/hypothesis-6.170.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:aae9c503f7fe3ddbf42f24b9348ab9a8494afba130f7cc17165ef171ff4e1e18", size = 791463, upload-time = "2026-10-22T23:00:11.663Z" }, - { url = "https://files.pythonhosted.org/packages/89/94/fe388e54ace3af5cc5ab6112b3da5c4475d73f0cdd390de4f2a9c4442d24/hypothesis-6.170.8-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be2f3e27efca1570bf8171e7061787b8bcc09d419c85057b53d55a2bc13981c3", size = 1121476, upload-time = "2026-10-22T22:59:53.835Z" }, - { url = "https://files.pythonhosted.org/packages/3d/17/3c2c91235b322a3fcd071478074f1c2b6503d6942f258b34dcf3710b383d/hypothesis-6.170.8-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:259327b5a24701b8e00e15e9bbaa51685d74265fa06fcf8928bc67bd01a47f2a", size = 1164845, upload-time = "2026-10-22T23:00:25.641Z" }, - { url = "https://files.pythonhosted.org/packages/65/e7/e517da75a0d27602a856055cda75ac3c6be9b3cce43068aa011cee7d98bc/hypothesis-6.170.8-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1063ee7dd92de0042d43955153af976ba2478883dd2122e5caa692e853d0ab74", size = 1296407, upload-time = "2026-10-22T23:00:56.72Z" }, - { url = "https://files.pythonhosted.org/packages/ca/10/55bdc6e5ea4220a2d85e1d234982360ac564a88f0a17157b2ab295d1c643/hypothesis-6.170.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cec7597a34a08c0d9f0eccef68b5a2aff571aa954401aa7f51612c5298e5c0e0", size = 1330577, upload-time = "2026-10-22T23:00:27.968Z" }, - { url = "https://files.pythonhosted.org/packages/db/1c/a0bfca233cab3715eddb478058b7fb42a6189f0b64c0760063b705e0e185/hypothesis-6.170.8-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:b292ed7e66bb8441dcfb06bc582dab3bee4bf8add604c55ec6017d775ac5ead2", size = 631236, upload-time = "2026-10-22T23:00:26.914Z" }, - { url = "https://files.pythonhosted.org/packages/a2/58/5adc7768c469a513093504443470b2e9c5bd1b10e7e950a1f83782364662/hypothesis-6.170.8-cp314-cp314-win_amd64.whl", hash = "sha256:0e27c224e5f5de54aa986e4d16e3f29d3b148ec34f553a09514eb36dc6683191", size = 688178, upload-time = "2026-10-22T23:00:07.278Z" }, - { url = "https://files.pythonhosted.org/packages/fe/5f/08c7d2bfd47dbb59f9f6690ca28f369eac71bbec1619b808f81e40b7c645/hypothesis-6.170.8-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:5d9438271cc748aa452a17756241a3cd37631d2d97df186322a5c9f451a45448", size = 797728, upload-time = "2026-10-22T23:00:10.117Z" }, - { url = "https://files.pythonhosted.org/packages/90/06/822ba15f39556e69909d76d2d820113b1dfe0085cc5aaeb6ce31d671a703/hypothesis-6.170.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:92831d7e8b6612bea9b5b0a685b8834b1d7a857681eef7721c229eb63912fa04", size = 789956, upload-time = "2026-10-22T23:00:42.12Z" }, - { url = "https://files.pythonhosted.org/packages/c5/e9/51c61d11d6e76aee474644c6e7e2186434e2fb0697a3d87af75ae39564b0/hypothesis-6.170.8-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:75db8f59174934f2ee955fb927dc6b259f5d7217217905bb6105c1d5c2b086c5", size = 1119917, upload-time = "2026-10-22T22:59:49.864Z" }, - { url = "https://files.pythonhosted.org/packages/ea/66/9b1cae99b6196d7a0ff0b5c96e869a49b952ab6c54f91f27cbfd02127fc8/hypothesis-6.170.8-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3252ad50fdf354a373e9771f2bf03f7f67fd5b23e6329d6dbf98f9866ac1f447", size = 1163795, upload-time = "2026-10-22T22:59:57.639Z" }, - { url = "https://files.pythonhosted.org/packages/62/62/1141dff626401cf97dad8427510bbeea87a6a3e66c7e880a18f17b58fbcd/hypothesis-6.170.8-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1af65f1e22bf05be7909d15d8594eec69c9bd02703339c4a0c635e151064a18e", size = 1294652, upload-time = "2026-10-22T23:00:01.946Z" }, - { url = "https://files.pythonhosted.org/packages/01/05/fb83e496234da392fa723f081a4cc055b1834b09cdb5cf367b10d12ebe40/hypothesis-6.170.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fe3175068fe99decd7258cd38bb5421b11c3d4d1dd2ea992df54ce1198735c07", size = 1329602, upload-time = "2026-10-22T23:00:00.647Z" }, - { url = "https://files.pythonhosted.org/packages/86/99/95121b961e344f56afdfd8e8c742675cf6ec585063f7fe17358b2929a247/hypothesis-6.170.8-cp314-cp314t-win_amd64.whl", hash = "sha256:f916823042dbe19aa82a810236464dd9ec5627fbe8ef1baf32b490cb6c920ac3", size = 688287, upload-time = "2026-10-22T23:00:05.719Z" }, - { url = "https://files.pythonhosted.org/packages/e9/b5/df1dd66e9d3e290fa3ff14519079f816e3cb88b9ebb1f1c18dc1f6bbe15b/hypothesis-6.170.8-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:f36941223a586d7e5951b3382fd7f69bdcc716d7038117033440a7b0db2f3e40", size = 797311, upload-time = "2026-10-22T22:59:40.45Z" }, - { url = "https://files.pythonhosted.org/packages/fc/e6/c55c969c2ebb52ed395cfea236a39fd88ef0a16f7daaece14f4113d25e66/hypothesis-6.170.8-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:90182a1a239d2c994ef32b3226491962e58f59f85e76058dd98c952448946a41", size = 789779, upload-time = "2026-10-22T23:00:15.315Z" }, - { url = "https://files.pythonhosted.org/packages/b6/5f/5570efede11a80d572b437ec5a558bd72afae53478e85d54a40a6fbf6272/hypothesis-6.170.8-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:00ef96c93eb67ffcb474da90fdf003c6a2f53b2f2d9c1d3f7096e51f2b2a81be", size = 1118664, upload-time = "2026-10-22T23:01:11.39Z" }, - { url = "https://files.pythonhosted.org/packages/08/ce/ee4cc383cea5e4a94b3f0e0ad441aaa7def9acd0d67459bd9edba962927b/hypothesis-6.170.8-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f8301a943c08d7da734ca8f5c5c5e19f97ff2cf84dd06d682a0f55d05f3b50ed", size = 1149609, upload-time = "2026-10-22T23:00:03.221Z" }, - { url = "https://files.pythonhosted.org/packages/8b/78/09f5069d0d43756f740dcfc4e79f4e299b0edc69dd9580b5b69b9ffd9157/hypothesis-6.170.8-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dd15e45f93eb33d3470b5e33424689678ee83f09ebee065ed89925b6a065b9db", size = 1143252, upload-time = "2026-10-22T22:59:43.065Z" }, - { url = "https://files.pythonhosted.org/packages/de/c9/76ba4c9c803f71499f496055c98724bc6d1f8289fc7d451b54663581da9b/hypothesis-6.170.8-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:08a68d91a3d403a67bee3808c4875f054d9681c86f6894b8df9b719276b5960c", size = 1197119, upload-time = "2026-10-22T22:59:52.657Z" }, - { url = "https://files.pythonhosted.org/packages/de/ba/cbed478c8b40c386688072d1ce8a608565224825737d811f374f3550d105/hypothesis-6.170.8-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad9f025e38c8d07b58f9b5c33b123496a5fc578884884fa914356a299718d3ca", size = 1162933, upload-time = "2026-10-22T23:00:24.478Z" }, - { url = "https://files.pythonhosted.org/packages/18/b3/025339bf4474f7a08e8f8268c50a6f18278fe48ba5d2fd29d99e2b677c00/hypothesis-6.170.8-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:a0f6a806a8a4a436820f00a505df7b1d9bff9e5b6d8e182f5ba22602a90cc93f", size = 1117270, upload-time = "2026-10-22T23:00:12.996Z" }, - { url = "https://files.pythonhosted.org/packages/44/79/8b968b314a3c1e66a7e225cce83030714fc5ec4ba77a5b810e916cbc03f6/hypothesis-6.170.8-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:416e49611582c93786fc2aab0f81f0f384bf256d9fb1c467fe9df7090d9fbe0d", size = 1155780, upload-time = "2026-10-22T23:01:15.163Z" }, - { url = "https://files.pythonhosted.org/packages/4b/d6/c9a035915e11c1af1df7a6258a668894939306f798e7b08b016214578b91/hypothesis-6.170.8-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:2a6dc0931eb1a576fa73a74fa4e822c31f1cbc710f89e6bbb38bd7f16517d50a", size = 1293467, upload-time = "2026-10-22T23:00:20.324Z" }, - { url = "https://files.pythonhosted.org/packages/63/1c/c6e43e6a16e4aa2b07dc962aaf02674fa24e1566d9b8358aa32498f7bb22/hypothesis-6.170.8-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:7209862749c245ca94198562dfd4701d9f8358c554fbd53d9a21759dc126c817", size = 1422560, upload-time = "2026-10-22T23:01:09.972Z" }, - { url = "https://files.pythonhosted.org/packages/b3/f3/ea31e924576e67fa1f5e0fa21a2659921cdbdb38c50bd1e58c99329ffa55/hypothesis-6.170.8-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:d5d3ab5fdf8e3a0f233667201b306dd0eb89b5c35c40e8eafc54954a3571d7d0", size = 1376185, upload-time = "2026-10-22T23:00:40.71Z" }, - { url = "https://files.pythonhosted.org/packages/18/e0/e871338e20d177417a8d94ffbab541c6a64af3a1e9365d4cf95f7941fd43/hypothesis-6.170.8-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:af3deb2de8c9d40d7e15dd4703995618c11bf47f4789d6287a165c45b1175854", size = 1271901, upload-time = "2026-10-22T23:00:47.258Z" }, - { url = "https://files.pythonhosted.org/packages/2c/e8/ddea7bb6da4c88333c8f3a7669af028e0883b00406f17b45dd6fac18ec14/hypothesis-6.170.8-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:80e84369bae69a439a71f05b91786bae627a30a1db79eb3a7d8adb8d3be6fd3a", size = 1289027, upload-time = "2026-10-22T23:00:32.184Z" }, - { url = "https://files.pythonhosted.org/packages/ce/0e/a8a3a37f8a9232c9361e5dade7091cf95dda822789aca954b79d3dca3977/hypothesis-6.170.8-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:a43cd04fe6469e6668ff7276f901a7c45b0dbdd63ec621fd77834a6ab9eaee3a", size = 1328939, upload-time = "2026-10-22T23:00:55.497Z" }, - { url = "https://files.pythonhosted.org/packages/8b/be/8b6e1bf3cd31d60436eeb81f32a3c7049a93ec78c2233048eeac7c963004/hypothesis-6.170.8-cp315-abi3.abi3t-win32.whl", hash = "sha256:fbed3d33ed19488e9d1eba5bf72101bd791c73b1155fc6af3f269a4dc107ddf8", size = 682097, upload-time = "2026-10-22T23:00:23.336Z" }, - { url = "https://files.pythonhosted.org/packages/e5/32/2e1e6078d82b5f0fe3294dd4afb8f9214416827c6584ddc7a5b6affd2a9f/hypothesis-6.170.8-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:039fb7c669b723afdeeeb77cb9cef7597a4f12861cf28b209d2d1c648b1f9bff", size = 688098, upload-time = "2026-10-22T22:59:47.111Z" }, - { url = "https://files.pythonhosted.org/packages/7f/00/73e1e47a59a44c37b025b7ce1b7d25dd97fd335040535974e10ac101ba1c/hypothesis-6.170.8-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:4cc942ca4712c876763330bcc28ccd254c0e6747a6380c81bdddb06cee50bf06", size = 686058, upload-time = "2026-10-22T23:01:13.958Z" }, - { url = "https://files.pythonhosted.org/packages/3c/5a/4468e1df0e9f78f62b998f57381c739c57ce899cde7d0aec2ee5e4eb1852/hypothesis-6.170.8-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:6376ebd23e7517f74f5994a0c03324b215961ffb3960af9617eb0b18dc5cfb85", size = 798187, upload-time = "2026-10-22T22:59:30.349Z" }, - { url = "https://files.pythonhosted.org/packages/53/39/4b5396b789631a732cf052e753e446b77f11d0879cb18f176097d1baef51/hypothesis-6.170.8-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:346cd8dc133bd779c3e5983cc127a686dc3c12a1ed0f447983d3f71f64875c8a", size = 793067, upload-time = "2026-10-22T23:00:17.952Z" }, - { url = "https://files.pythonhosted.org/packages/f7/2c/f60d35a3b1fb10b940ae288cd3676df685923c09a7411987f7853b5e7021/hypothesis-6.170.8-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3bdc756c009692412c39ca00d8925319f5e899727265f3bad518fb14bdc3d626", size = 1122120, upload-time = "2026-10-22T22:59:33.333Z" }, - { url = "https://files.pythonhosted.org/packages/db/5e/173b889bc521e01dd8a9e4393e46f97f9739e0dce4f05836ec9571ead29c/hypothesis-6.170.8-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9bf2419f8820bbb79a297f21ca54e2efe5171c6a62b5c7469befbb73c8775e4", size = 1165493, upload-time = "2026-10-22T23:00:08.715Z" }, - { url = "https://files.pythonhosted.org/packages/75/9e/eef4ab404cadff3147a23f2de02ceb1f98422586ad35ab667e1efd6d3a57/hypothesis-6.170.8-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b6a398dd92d775a90ad14e70cb420ab21345249ceba30eeb2faadf0723d2b33e", size = 691427, upload-time = "2026-10-22T23:00:59.121Z" }, - { url = "https://files.pythonhosted.org/packages/44/44/2b199137f71100a354cbbde0e62578a5258e42c48a1315115a3b5257c241/hypothesis-6.170.8-pp312-pypy312_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c3026eae9d3c328f8c50449728cdf4bb3464c63038131813526e16b28d2a54f2", size = 798498, upload-time = "2026-10-22T23:00:36.156Z" }, - { url = "https://files.pythonhosted.org/packages/95/48/1d9c0cdfd3cfcdce709afc4ba6311c2a1ef3638010d4c5b1fc636902bdeb/hypothesis-6.170.8-pp312-pypy312_pp73-macosx_11_0_arm64.whl", hash = "sha256:03c6863a3e325244c3137d8848ed4df0113536cf5ed1103d3727422510b5cd34", size = 790341, upload-time = "2026-10-22T23:01:08.553Z" }, - { url = "https://files.pythonhosted.org/packages/0d/04/c9f8e0a8006b8e0ce620ce39cfe0bc1fab3b6674c379e3221351a595455e/hypothesis-6.170.8-pp312-pypy312_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f0aad2b7322a3512a92e712a866df3b485b141f11556bc12a0242e66be4fb001", size = 1120326, upload-time = "2026-10-22T22:59:51.396Z" }, - { url = "https://files.pythonhosted.org/packages/d7/24/f086da2f159899b231baaf5dabe5091016c688900b14bc80f9d3a2f53c2f/hypothesis-6.170.8-pp312-pypy312_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ed5c7a653140dd77ff931a0cc0bd9d37a9effad375a1494ab61e551e3959325e", size = 1164478, upload-time = "2026-10-22T23:00:04.569Z" }, - { url = "https://files.pythonhosted.org/packages/6a/05/463e04b419c61715d9ec4f04d02bc80e465a9523f951fa0c66796438f263/hypothesis-6.170.8-pp312-pypy312_pp73-win_amd64.whl", hash = "sha256:7af04e2b2715f56c0aefec88658fb648e653ed74108c61ec3fc06991f528ae54", size = 689598, upload-time = "2026-10-22T23:01:16.358Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/93/a8/bd70d7c2966e561228b9fdc075ee77c0ba577dcbbfbf921edf614db14f6a/hypothesis-6.168.5.tar.gz", hash = "sha256:76b9226962fe11d40858253a967eda95bb65811365286317e0118f4ec8f808c7", size = 511115, upload-time = "2026-10-05T23:26:35.416Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/0c/7f04c8d277dfc828ba584b7d9d10dbac5e91fce673fa5328f7bd5bf64609/hypothesis-6.168.5-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:ca43a751410a9c6685f029fd5126cc5507664cafaa76017922aa8ae2e17b6620", size = 791539, upload-time = "2026-10-05T23:24:25.544Z" }, + { url = "https://files.pythonhosted.org/packages/11/5c/660906d83db74eb86feda715d0f2df14836205b14a183332116676733e6f/hypothesis-6.168.5-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:c8b98707cbe9f430d100a945bbe17612fd3aa44eac1b0ac5299669fe3b8e4128", size = 787208, upload-time = "2026-10-05T23:25:14.028Z" }, + { url = "https://files.pythonhosted.org/packages/01/85/36e19492bc4ff354c2be9c8fa7c6ace0c65f9d2c7116656b741680c6ca55/hypothesis-6.168.5-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4dde52a0b696c642e7f988a03026c7c29f90daf21e74507b6f865c3ccc9d536e", size = 1115200, upload-time = "2026-10-05T23:25:53.064Z" }, + { url = "https://files.pythonhosted.org/packages/d4/82/3273fb0a3567c09b767bb8fe2824d65e16ae2abb92cf1f43762df723df94/hypothesis-6.168.5-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:42f02e4541fe0c17a1320617effc0ab8a8aca2a9af15e3358d4150acf3bbdc00", size = 1145113, upload-time = "2026-10-05T23:25:17.502Z" }, + { url = "https://files.pythonhosted.org/packages/74/59/5c5904555a0bbd4b2898d73ea90c6d03f5be0d8ff0756ac1d519ace6ae66/hypothesis-6.168.5-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bf6dd7e537a12763c9afa017f7a6159e5cda608e98670621fa44596a1e8e9288", size = 1139564, upload-time = "2026-10-05T23:25:56.681Z" }, + { url = "https://files.pythonhosted.org/packages/cb/ce/55654ff9575587a401e304f08ad1d43b7e6318f81c66bd866fdc5ab4665b/hypothesis-6.168.5-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df2c04cd30abf42c52580184216162a75b5508b214a472b86670f6dd50659a3b", size = 1192142, upload-time = "2026-10-05T23:26:06.565Z" }, + { url = "https://files.pythonhosted.org/packages/48/91/4cc9d6e8a950473e07e3ebf00cbb8ee0d76b14d193f94c3de20f1c09e2b1/hypothesis-6.168.5-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:278662eb21aaec9eaae71ea4dabd4fe390c2af11ec58a6a0606687cf6d7689b0", size = 1157262, upload-time = "2026-10-05T23:24:59.229Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3a/4b8aa3be788ea81b9a7bc6b673ed89edd72fd0645c6aa691d4c159ff971a/hypothesis-6.168.5-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:6bcedc4ab8ab92dd0f3af0cfe24dce184d225751d7bc870a9cddb9a557de847f", size = 1113779, upload-time = "2026-10-05T23:24:12.327Z" }, + { url = "https://files.pythonhosted.org/packages/f9/98/2eb4c79d1851195e6a083568b065235680ab984e984bbd472f2a7d02ba33/hypothesis-6.168.5-cp310-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8b58097cc3b98d8616f635ac73888fc9f859311875f2adc043f1544c40c3c466", size = 1152639, upload-time = "2026-10-05T23:25:43.635Z" }, + { url = "https://files.pythonhosted.org/packages/f0/9c/68f7e99b43c6f37c077669a4d3bd88f48c042444ced9e7cff0eaf44bc70a/hypothesis-6.168.5-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f8a387d9ee7f804e830b31f2e2e339ab5731665e922cfda4f6f6fbdb05e191b4", size = 1290068, upload-time = "2026-10-05T23:25:28.45Z" }, + { url = "https://files.pythonhosted.org/packages/b4/04/d4f87164a0d028ab102cea345b601d9dafb3196358df5448caa88ac3c1e2/hypothesis-6.168.5-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:326f6383fdf2e37ac69773589a8238a3bf396ca8ac8efacb0fb9ed42dd08e426", size = 1418551, upload-time = "2026-10-05T23:24:51.25Z" }, + { url = "https://files.pythonhosted.org/packages/a8/32/6b518a25514f0e643f95610c77e279bfbf0e0b3bd423aac0187d6f039b9a/hypothesis-6.168.5-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:5d33fc74e43bbd7c3a8f6f7161a8b93b676924286e97e70e828c6e0dcee5c01f", size = 1372130, upload-time = "2026-10-05T23:25:32.359Z" }, + { url = "https://files.pythonhosted.org/packages/48/c2/32538e14e63193ca894ba584696805d1eb45cfc27e15fccd47acfb87531c/hypothesis-6.168.5-cp310-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:1994923cf5e5220ae6bf19645302504b27c0289d83e5d8690df71dcae63d8416", size = 1268950, upload-time = "2026-10-05T23:25:02.544Z" }, + { url = "https://files.pythonhosted.org/packages/86/3b/e50e7e98af9489aa05203c2ab38c95d891dd8d1ed08fad972dcdb6955332/hypothesis-6.168.5-cp310-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:501038fd24d3bc95239cfd093a23cf1151f29dd82382a3554dac5dfdab9729ae", size = 1283907, upload-time = "2026-10-05T23:24:29.909Z" }, + { url = "https://files.pythonhosted.org/packages/71/46/41c460a7d2148a04b212b2d594d39992fb52e0b844e13bf6784573fc8dea/hypothesis-6.168.5-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e2292ddc24fe6d04b7d30fa6a7e2c9e280ad5078fe671d0bf4aa6df6e143b5ac", size = 1323762, upload-time = "2026-10-05T23:24:18.984Z" }, + { url = "https://files.pythonhosted.org/packages/68/4f/37a7fc1fe445e3589e0f56ff4573c28de1d6e6a03009cba2f99f04e46ffa/hypothesis-6.168.5-cp310-abi3-win32.whl", hash = "sha256:925d67c69b719d416334aa961c0cdfc4a58a471af1ebd2d7101bd515a70f4e5f", size = 677903, upload-time = "2026-10-05T23:25:07.129Z" }, + { url = "https://files.pythonhosted.org/packages/81/e6/7b25ca7845a60522ebc5f8054f6bba68d47126fb5d940c784fc528a4be4a/hypothesis-6.168.5-cp310-abi3-win_amd64.whl", hash = "sha256:2311590eccba452de863dfe3466daa86a05c25f072ab31ed8bb4d3313ee68439", size = 684616, upload-time = "2026-10-05T23:25:04.028Z" }, + { url = "https://files.pythonhosted.org/packages/c3/00/40e7c36b46c8788eddc7a322ad324e6db53c8ab9a8b9a95d6535ee7bdaaf/hypothesis-6.168.5-cp310-abi3-win_arm64.whl", hash = "sha256:222a6d23a2a824b0f9f73761c2fb9cd2aca96cf3e5b441617625bce4f7eb4fd4", size = 683061, upload-time = "2026-10-05T23:25:19.403Z" }, + { url = "https://files.pythonhosted.org/packages/de/95/f1149d913d685809c016b2a3ae9d727741ae22f52376c6d0ed51eecb5ac8/hypothesis-6.168.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:8c35e5d4a85d0d6071cc267a6cbb8fd7ae23ca8a0f745ea5a52c0064d7c1c4b8", size = 792108, upload-time = "2026-10-05T23:25:12.323Z" }, + { url = "https://files.pythonhosted.org/packages/bc/98/7e5ffb6bbfc033c85746243dc4d1541876082e136ee44c02f843bb77427e/hypothesis-6.168.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:244a8d14c0a8a3be0345ad0b120deafb94517cc1d74a961d14b5b5eb041b4c0c", size = 788035, upload-time = "2026-10-05T23:26:26.557Z" }, + { url = "https://files.pythonhosted.org/packages/38/df/022129d3e16d19a84e7a5a35ebf7baca07d3482fb34f0faaab865b14fe66/hypothesis-6.168.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e68e1d43b7c9c7a1aa659dfe1c0ecc2de79391b20db853c1e18ea7e3d2ce31f", size = 1115438, upload-time = "2026-10-05T23:24:52.639Z" }, + { url = "https://files.pythonhosted.org/packages/da/09/b3e45b0386d8f643a304105883c5bfce79fd530b2dfe3a70564e1d7aa0bd/hypothesis-6.168.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:01a4d3773f285e75551eeef12df058e6316b666bcc3ec187c5eb52a893fbb015", size = 1157503, upload-time = "2026-10-05T23:25:05.609Z" }, + { url = "https://files.pythonhosted.org/packages/ee/4a/aba5a74ddb20c9f41ba5b8f2918c5a12660146cab2120f14122122715060/hypothesis-6.168.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cc327005f2fbb55db81d132948ee7c6cec0589694bed04b1e45fc8fc317e12bd", size = 1290235, upload-time = "2026-10-05T23:25:08.982Z" }, + { url = "https://files.pythonhosted.org/packages/34/f4/7204aa6117a38085e6f1dbefd5cd98050a58c847f2bdecc917422cdb2b1c/hypothesis-6.168.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:62f21c74ad83fe77abc72e82c54114148fb01396769c234e26c9b9dbc21344a9", size = 1324204, upload-time = "2026-10-05T23:26:16.239Z" }, + { url = "https://files.pythonhosted.org/packages/a5/4b/15a46ced6d999148d1b718c5488c243bd56dfcd687a61404fe371192dfd5/hypothesis-6.168.5-cp311-cp311-win_amd64.whl", hash = "sha256:bd3ff6e53e29b86ec6078f123284e65e1c678fe7b30c2b52512244faf266502c", size = 684414, upload-time = "2026-10-05T23:26:18.231Z" }, + { url = "https://files.pythonhosted.org/packages/90/43/a04a727578cbef9f75c11fa6fbad66d13aaffc354f4f979506219814c7d4/hypothesis-6.168.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:ddee1ef4bab47e315b705e42d2f4354e789973d11f9620d2df242aef4cfa42b2", size = 793156, upload-time = "2026-10-05T23:25:49.433Z" }, + { url = "https://files.pythonhosted.org/packages/f4/91/55de4e2a12fe98ebd5bc8f35e59870c897ab360cbfe5aa63862cdbef56ad/hypothesis-6.168.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:81ceb49b0dc3a4b6126cd0d3bf2b634af4e91513c8f1e2daee16041414ed8e3d", size = 784788, upload-time = "2026-10-05T23:26:20.188Z" }, + { url = "https://files.pythonhosted.org/packages/f4/61/230abc6320540bdf73baf9a1c025fb0aa27cfd5a3791a2e0c95114239a70/hypothesis-6.168.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a09caa95d2d7e6546f727f703de606145835d9ca215fb3134a21353c69afaac", size = 1113853, upload-time = "2026-10-05T23:25:30.593Z" }, + { url = "https://files.pythonhosted.org/packages/f7/4d/3bf0a7806b3fa12ed076f2daeb3db0e6f9738994e879432ffd8dbcffd634/hypothesis-6.168.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:97ac1d516a42a3b1f13b36a1aa6a5f842e43d67e69d4dc664a9645b28de411ef", size = 1156695, upload-time = "2026-10-05T23:24:28.607Z" }, + { url = "https://files.pythonhosted.org/packages/7c/a0/603f918fcf8f74f81ea593b04e3a9a9fcd426bbf389ed52cb340249bdc14/hypothesis-6.168.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e4819fba78c6cbaa6e2f9fd5a69a413817446943f286763819b5ac52391bff3e", size = 1288792, upload-time = "2026-10-05T23:25:36.354Z" }, + { url = "https://files.pythonhosted.org/packages/69/7c/711ef5be6e889dcd40d9b03cdd85cd42ae39af75835bced3c374730291a9/hypothesis-6.168.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87334b95dfbc101652fa48a427a742b0715b814506d9a10f621c29e476b4a2c1", size = 1323090, upload-time = "2026-10-05T23:25:58.753Z" }, + { url = "https://files.pythonhosted.org/packages/66/66/0377d7d13ff3e2c16efd141942649edcdb568caec4576f86ac779545dd85/hypothesis-6.168.5-cp312-cp312-win_amd64.whl", hash = "sha256:2fcec23ff4eb526ee85d3510f564b938ca74f6011f1eec1050e4eb55280b0468", size = 681999, upload-time = "2026-10-05T23:25:41.86Z" }, + { url = "https://files.pythonhosted.org/packages/7b/b3/1f7f72cd28d02a5ca99c432fbffe4b750a375df2284af9d916943dd3aa4f/hypothesis-6.168.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:714337b25ca9137bc359c570b868269462307e120999412ca1946f997f4b9db5", size = 793075, upload-time = "2026-10-05T23:25:15.905Z" }, + { url = "https://files.pythonhosted.org/packages/8f/ba/5b0874828695c4d49e3858d0967254f783e563cd0e211a6db27d11d48a1f/hypothesis-6.168.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7f1c3617155fcf5b5259a1f2e4c775d3eec7bfa80b162b2f6f145b08f871ab08", size = 784693, upload-time = "2026-10-05T23:24:16.559Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5f/ca777becba5251b0d778bb9d83d15524c559a07e4b5d4e6211473855bae2/hypothesis-6.168.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ebee70b7a026210bb47c86c89e5bfb42effd5bd630080e76bc084f29c01c7f7a", size = 1113693, upload-time = "2026-10-05T23:24:44.262Z" }, + { url = "https://files.pythonhosted.org/packages/a7/e7/5a74bf329e405db3edc5639a2595eccf33ad6f5aaa191019e9f824d630f4/hypothesis-6.168.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cfb06b31cca005345b8ad63f88986d21fd359a7dc3dba2965dd3515b720e5c9", size = 1156530, upload-time = "2026-10-05T23:24:47.153Z" }, + { url = "https://files.pythonhosted.org/packages/34/7d/e79cf67f03f212a1394abac21053bd6887aa70f557be1da3f9c9c73e58ae/hypothesis-6.168.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4a4c244d7ab64963fb575f0ec2d813630e1d14cefc39e7c460d5d778e5af4118", size = 1288713, upload-time = "2026-10-05T23:25:22.763Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/df452159ac8d7b278071a3e81fafc69da833ec4302b8c85f5b6e530aea21/hypothesis-6.168.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8e59d519f6fb38b3fa4fcde046767b03a24740fe827d261ee7ff9a721c06169b", size = 1323134, upload-time = "2026-10-05T23:26:02.485Z" }, + { url = "https://files.pythonhosted.org/packages/af/fb/f07d8d09fb57eb14555cad64dfbe29bfdcecff3806f1e01268258088e741/hypothesis-6.168.5-cp313-cp313-win_amd64.whl", hash = "sha256:c103f655644afa4ef6bf7efbf86e44b78ee475fd0691da2db86e2cfe72c07234", size = 682019, upload-time = "2026-10-05T23:24:22.888Z" }, + { url = "https://files.pythonhosted.org/packages/de/e9/7c3c2262b8cfa825c4c1764d62aa15e628bae257ccfd2ee4f3ffa4f81eaa/hypothesis-6.168.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c4dc037d8001bc6eccb8636f4a38d16ea6b250d6bf0a89075aaa5e5069f751cc", size = 793253, upload-time = "2026-10-05T23:26:14.331Z" }, + { url = "https://files.pythonhosted.org/packages/3a/a6/7909ed7d29302491e9b7bc0e7ac3287c20736c05a0cc35bae65024aeec3b/hypothesis-6.168.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c90743321f29b65491d146adfc2ece85869bacb71ce18b47674795e896c81ee3", size = 784837, upload-time = "2026-10-05T23:25:24.728Z" }, + { url = "https://files.pythonhosted.org/packages/91/8c/57742c459349052e6a3e0c011855840f8cbbbadca91079d5b591f08b25ae/hypothesis-6.168.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:09debb7f7f0f229da5f7e2ad515a5be7a8dc607ec204074775f8ab6731a447f0", size = 1114268, upload-time = "2026-10-05T23:24:27.35Z" }, + { url = "https://files.pythonhosted.org/packages/55/80/07bd2449f91f9426f705fb689429bab6e26d1365f8ac4ef7d7c1cec9055e/hypothesis-6.168.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d227f8ac497eca0bde4e8562d32dd4e82fc9566526020bbd567f76b833b923b0", size = 1156838, upload-time = "2026-10-05T23:24:35.211Z" }, + { url = "https://files.pythonhosted.org/packages/fe/75/7f3dda517e5134f73e2ae41821bf40b3fd3ac6551a9a43ea9287471738a1/hypothesis-6.168.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cc6ebd35601c72c842e5899c3f760f9ed26c69e786ee40a9a64fb5a4a3058315", size = 1288864, upload-time = "2026-10-05T23:24:42.645Z" }, + { url = "https://files.pythonhosted.org/packages/c8/cd/4b1364140642cf3f1431ca59b5841fc322872dfa7197b2facb97692da234/hypothesis-6.168.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:503e103ad49e702bad200157d82778eebbc14d3045e9700a8e8fe5db40912953", size = 1323300, upload-time = "2026-10-05T23:24:14.826Z" }, + { url = "https://files.pythonhosted.org/packages/20/e7/47d7cffcaf15318a4308516b6b3d2fd0db599f18eacc0f2dc553be2206a7/hypothesis-6.168.5-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:bc5cc310f9f86ec62f0d0dd7eea5a4788f18ec793b70ee2c7163b916768e1057", size = 624239, upload-time = "2026-10-05T23:24:48.453Z" }, + { url = "https://files.pythonhosted.org/packages/97/6e/2ca0f68150be175b7cfa7bfb6692260638d86aeb9313478ba82e198186e6/hypothesis-6.168.5-cp314-cp314-win_amd64.whl", hash = "sha256:71ce0599e806ce3a68f9f118edf450bf091e11b134f6bcc5f8dd706b42c91ebc", size = 681884, upload-time = "2026-10-05T23:25:00.757Z" }, + { url = "https://files.pythonhosted.org/packages/bd/4b/4fc2b5970df0c27668ec08abc505f1d01314a69953f89dc0edc6528ff5a0/hypothesis-6.168.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f66b02c9e95e916a2c58f725a92377ec988146ed7b5aeccd5e78ceecac1eae6f", size = 791636, upload-time = "2026-10-05T23:26:22.365Z" }, + { url = "https://files.pythonhosted.org/packages/04/b2/03cdf5f052dcb441e045be1fd0aa531e85cde1a1cbabab60968625c570a3/hypothesis-6.168.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bab27926e1d1575fb43b70d4aeece05b74a5e477af0509b56cb6fd778070dd93", size = 783280, upload-time = "2026-10-05T23:26:24.333Z" }, + { url = "https://files.pythonhosted.org/packages/7d/d8/615557af244e2f3ce4763029c03a62ed82dcbbd646b72a8c479ef0408b33/hypothesis-6.168.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:edeb42c3009b5652dc1c44907ec91bfe9284100ad5e57993dfebabb76f2961a1", size = 1112149, upload-time = "2026-10-05T23:24:13.526Z" }, + { url = "https://files.pythonhosted.org/packages/9f/67/a6707fcd51dc5f2531bf88ac072e99f31ab9d8020488b01349a6d2981081/hypothesis-6.168.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8977456328147c521a16a089325017b2c728fddc23351693a4fd924cc7fc7001", size = 1155637, upload-time = "2026-10-05T23:24:24.047Z" }, + { url = "https://files.pythonhosted.org/packages/85/d4/ac2e852d2f163afd398854662bcbb0b849a767abbf2f95a75de6f685f821/hypothesis-6.168.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:36ecf7ac351f9c0b5489ba800884b607da754e88ef40713fbfcc170d2151e6eb", size = 1287040, upload-time = "2026-10-05T23:25:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/23/07/f77b1602704bda6ff3d9d0817120bd7fb94fd792bd25508b36ce4b8bd2a2/hypothesis-6.168.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0333aa5129ba3019a83fb81a7f0fc238180e415a9edddd9a15101f8deaaa517e", size = 1322166, upload-time = "2026-10-05T23:25:45.39Z" }, + { url = "https://files.pythonhosted.org/packages/6d/2e/94138a73e0906b31cb5968d20be58688f582a09e5958f2c75d45a7049545/hypothesis-6.168.5-cp314-cp314t-win_amd64.whl", hash = "sha256:2fcb87341d76ae0183e8219c9a14d55957c50d14973879db5fea3e81da45ba1a", size = 681599, upload-time = "2026-10-05T23:24:37.827Z" }, + { url = "https://files.pythonhosted.org/packages/92/13/92cb8092b680be2b6ec5ffe83b9f1a98dbf414117566f9e3ba4e8b569214/hypothesis-6.168.5-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:453ab7d0a1fadbaa54ae8722d22463cc2046fa8ef25b9b88715d28279bf79fc1", size = 790950, upload-time = "2026-10-05T23:24:45.852Z" }, + { url = "https://files.pythonhosted.org/packages/e6/22/78aea12694e3d1177e2980d44798b6d93e191faf59155b18bf5ae315f6a2/hypothesis-6.168.5-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:bbdbc43d1f9dad595b249b7bbe8ee5102bc94a4fcb0a79ff76d20e41fcfe342a", size = 782939, upload-time = "2026-10-05T23:25:39.961Z" }, + { url = "https://files.pythonhosted.org/packages/bd/12/5ef9947b2d149f773428e555bdf66688405aa5167510bdbe97c8ec5c6090/hypothesis-6.168.5-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2bc36194d7b6083591060836c7872711a6820217b325bf432dd7e10b3d4af5cb", size = 1111380, upload-time = "2026-10-05T23:24:39.087Z" }, + { url = "https://files.pythonhosted.org/packages/e1/65/7e668e203fb2659c6214dc0c24cc09b7dea8a02c7c8d0ad338f644a054c4/hypothesis-6.168.5-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:22425e2b1543a43c157a81472c713ba8f291cbaf054c70ffe128e2cacc294f65", size = 1142644, upload-time = "2026-10-05T23:26:30.697Z" }, + { url = "https://files.pythonhosted.org/packages/c0/77/b112978676e795658d58c4294bf90cdbb8cb56cb8292c8c4874650468cf9/hypothesis-6.168.5-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:eea0bc513d0e38d1d5ddfb581132928871cd02dc54dfe4511a5396727c48e9d0", size = 1135966, upload-time = "2026-10-05T23:24:49.806Z" }, + { url = "https://files.pythonhosted.org/packages/e6/27/cd3bf01e8246c4318ec3df15f5eeeee3214f444df0129a6c7f9a62859ee8/hypothesis-6.168.5-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eb142bc70bbf6645e15c7ca72de3f7c8dae198aa2743a609f4f3e3bb4f9c3a52", size = 1189646, upload-time = "2026-10-05T23:26:04.471Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a6/4d3e882f31c289e432dfec34dbb9029296038a8c69e8b28cebb0a5fb7ea8/hypothesis-6.168.5-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a27b758707bd37f5a1759cca6eef83fe1a212c38dc4ca0a203434004c5647d15", size = 1154770, upload-time = "2026-10-05T23:25:10.814Z" }, + { url = "https://files.pythonhosted.org/packages/7f/89/96f5455e1b3d0409cbbb1434c98e792bcceefd614a4b11e600072520b487/hypothesis-6.168.5-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:77a111cb50c330fa7098f65852fa17a01ecd781a85be3cf5e5871bdeeeb0ecbc", size = 1109925, upload-time = "2026-10-05T23:25:26.369Z" }, + { url = "https://files.pythonhosted.org/packages/3d/64/0758985d9d36f0c5ec981a1457ea1c8173f62d46a94531417aec117df4d7/hypothesis-6.168.5-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cdd0afc13e86ec76cae3d3659569c1f601f4e9ca52b5cf91c1685979eae64d7b", size = 1148476, upload-time = "2026-10-05T23:24:54.552Z" }, + { url = "https://files.pythonhosted.org/packages/43/5c/a9b8953e1d8aefcd3c22cf8d10dd8acf93e602b903278e2e51cf8544ccea/hypothesis-6.168.5-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:5fefb02035864c3d322e3b0969b296250923fdcfb574ea1ad4374f1a6333f663", size = 1285856, upload-time = "2026-10-05T23:25:38.239Z" }, + { url = "https://files.pythonhosted.org/packages/bf/37/66098444dc832523ddc4f2e05723662834e5f99bba3c759615d059f6420e/hypothesis-6.168.5-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:9db8aa1f5529e1b577ec18b775c2fb4225821712e946f7762b90c966604faf83", size = 1415500, upload-time = "2026-10-05T23:24:21.682Z" }, + { url = "https://files.pythonhosted.org/packages/0c/d3/e971b6fe20ef8d7c2019cbf24b4f6149468efc88c42a744f5bc99e6ca0ed/hypothesis-6.168.5-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:59e07d2f62b5ff573b0059959ae9cef9edfb0f5393fdb35ea81fce1ee77b27ac", size = 1368623, upload-time = "2026-10-05T23:26:11.292Z" }, + { url = "https://files.pythonhosted.org/packages/e6/ac/b279dfbd2c06cdb3030ba7eea042cb2cf0171d0013563d103d5207dde63b/hypothesis-6.168.5-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:8a03ca128bea29d6826fc545f1f6289fb1ea2e83a5bb811321761b2d515ca575", size = 1264457, upload-time = "2026-10-05T23:25:21.073Z" }, + { url = "https://files.pythonhosted.org/packages/55/57/16ac9f8ddfada1cd278bd2185234d0d36ebd304926b69ad0497c210c6fed/hypothesis-6.168.5-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:5c03f2d3f84f626f3fd07f54573ab40455e1a1996e98a4f4971caf8b7e796afe", size = 1281744, upload-time = "2026-10-05T23:24:31.263Z" }, + { url = "https://files.pythonhosted.org/packages/3b/d1/99a44430b82998fdef0ffd7d353f64ee5f078c2805ff70f8677ee102cb6c/hypothesis-6.168.5-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:2bdf8ce9b72a620cd5ec4dd6b1c1837ff6971489a863851d11d9b0f58dd4062a", size = 1321504, upload-time = "2026-10-05T23:26:00.583Z" }, + { url = "https://files.pythonhosted.org/packages/bb/6a/58ef2564d1985a5c1a1dc57906b8363a767094abca180e80a0aca4cb635f/hypothesis-6.168.5-cp315-abi3.abi3t-win32.whl", hash = "sha256:5c3abbef7b17571fd713b0922407d9cd8cbc652254c0f462875f15199fcb29f7", size = 674959, upload-time = "2026-10-05T23:24:36.482Z" }, + { url = "https://files.pythonhosted.org/packages/a3/90/153414f55eb0c85bd9d891bd7811d746978c7ad3de81ea79eeb4e62e088b/hypothesis-6.168.5-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:38172199abab94a04bc017613e055faa796d7175fbc6221aac504d406c960b60", size = 681329, upload-time = "2026-10-05T23:26:08.897Z" }, + { url = "https://files.pythonhosted.org/packages/6d/63/117c82f08ab3ba1dcfbf6562ac43b8deb8efa8106646494fadd15122cc1b/hypothesis-6.168.5-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:0600ddc24c32dab5ca8e780630ab6e2561df6d7f594f781d0608b38e04c4da91", size = 679632, upload-time = "2026-10-05T23:24:33.753Z" }, + { url = "https://files.pythonhosted.org/packages/73/25/5c38b739fb778d4de48aab6509b9cf0afd0317bb0459741afdcd0ad44aed/hypothesis-6.168.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:6786049db92275e0c5cfac7dfcda6d4bbc80bdf84cbc8c9c7171ca17f47b5aac", size = 792028, upload-time = "2026-10-05T23:24:56.365Z" }, + { url = "https://files.pythonhosted.org/packages/7b/3f/91071d53240f5f13ab1dda286e3ddb33177537dbf55cede76e7f4a3856db/hypothesis-6.168.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ffbde24430dcd73231fd03324a934e0f638f7c0899fc566f3ef8c851534f8030", size = 787324, upload-time = "2026-10-05T23:24:17.822Z" }, + { url = "https://files.pythonhosted.org/packages/10/ef/eb262e50d7741de6c49d27923e2c282d079273b8bcdacde33165ea39488d/hypothesis-6.168.5-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ea967baaedfd532f1a521aaedafc66bb9de09795071492b0e7252139df38479f", size = 1115118, upload-time = "2026-10-05T23:24:20.43Z" }, + { url = "https://files.pythonhosted.org/packages/87/67/a655a8666164aa896516f919af272fa3a3a00d2786be880c31bb638e79e2/hypothesis-6.168.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b2f98289a5da876c08b9eeb68d1cfdfbd0fcc110cf364d33c3cc32cf229ffe8", size = 1157894, upload-time = "2026-10-05T23:26:28.641Z" }, + { url = "https://files.pythonhosted.org/packages/57/4d/71c422a29446c03e9a052f10b8ee527044242e71e3c0139100991f721e16/hypothesis-6.168.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:e313a01ce580180dc3bb8fa98ddd0ffb20e51e108d9fa747ba6c1596790dc3fa", size = 685030, upload-time = "2026-10-05T23:24:09.964Z" }, ] [[package]] @@ -2709,7 +2704,7 @@ name = "pexpect" version = "4.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "ptyprocess", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "ptyprocess" }, ] sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } wheels = [