From d59359c30bf3b4e015bb7410892b914a8c3ab0fa Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 29 Oct 2026 21:58:20 +0400 Subject: [PATCH 01/21] Call the Simulation Entrypoint's household report routes through its generated client The adapter submits a household report (a stored household under a baseline and an optional reform), reads it, and starts another run, and turns the entrypoint's declared answers into the errors the economy report adapter already uses: a 404 naming the report is not found, a 422 carries the entrypoint's reason, and a 400, a 503 or an unreadable answer means the entrypoint cannot be used. Co-Authored-By: Claude Opus 5.5 --- .../libs/simulation_economy_reports.py | 5 +- .../libs/simulation_household_reports.py | 378 ++++++++++++ .../services/v2/household_reports/__init__.py | 1 + .../services/v2/household_reports/types.py | 202 +++++++ pyproject.toml | 8 +- .../libs/test_simulation_household_reports.py | 563 ++++++++++++++++++ 6 files changed, 1153 insertions(+), 4 deletions(-) create mode 100644 policyengine_api/libs/simulation_household_reports.py create mode 100644 policyengine_api/services/v2/household_reports/__init__.py create mode 100644 policyengine_api/services/v2/household_reports/types.py create mode 100644 tests/unit/libs/test_simulation_household_reports.py diff --git a/policyengine_api/libs/simulation_economy_reports.py b/policyengine_api/libs/simulation_economy_reports.py index 193ff68da..2d0f52d89 100644 --- a/policyengine_api/libs/simulation_economy_reports.py +++ b/policyengine_api/libs/simulation_economy_reports.py @@ -1,7 +1,8 @@ """The Simulation Entrypoint's economy report routes, through its generated client. -This is the only module that imports ``policyengine_api_simulation_client``, -the client generated from the Simulation Entrypoint's OpenAPI document. +This module and ``policyengine_api.libs.simulation_household_reports`` are +the only modules that import ``policyengine_api_simulation_client``, the +client generated from the Simulation Entrypoint's OpenAPI document. Everything else uses ``EconomyReportSimulation``, which this module implements, so the rest of the code and its tests do not need the client. Nothing imports this module at module level, so the application starts diff --git a/policyengine_api/libs/simulation_household_reports.py b/policyengine_api/libs/simulation_household_reports.py new file mode 100644 index 000000000..2c5b2ea32 --- /dev/null +++ b/policyengine_api/libs/simulation_household_reports.py @@ -0,0 +1,378 @@ +"""The Simulation Entrypoint's household report routes, through its generated client. + +This module and ``policyengine_api.libs.simulation_economy_reports`` are the +only modules that import ``policyengine_api_simulation_client``, the client +generated from the Simulation Entrypoint's OpenAPI document. Everything else +uses ``HouseholdReportSimulation``, which this module implements, so the rest +of the code and its tests do not need the client. Nothing imports this +module at module level, so the application starts without the client +installed and household reports then answer 503. + +Requests go to ``SIMULATION_ENTRYPOINT_URL``, the Cloud Run Simulation +Entrypoint, the only one with these routes, with the gateway bearer token +(``simulation:submit`` to submit or run, ``simulation:read`` to read). + +The entrypoint names the Public API's stored household and policies by ID and +reads them itself. Its declared answers become the protocol's errors: + +- 404 with the entrypoint's own detail: no such report. A 404 whose detail is + exactly "Not Found" is FastAPI's answer for a route the entrypoint does not + serve, such as a revision without these routes, so it means the entrypoint + cannot be used. +- 422: the caller's data it cannot calculate (a parameter the catalog version + lacks, a stored policy value or household the household functions refuse), + with its reason, or the request fields it refused. +- 400: a household, policy or catalog version it does not have, or a + household without a default year. The Public API checks those itself + before sending, so a 400 means the two services disagree, and the + entrypoint cannot be used for this request. +- 503, any other answer, an answer that does not parse, no answer or a + gateway token that cannot be fetched: the entrypoint cannot be used now. +""" + +from __future__ import annotations + +from functools import cache +from typing import TYPE_CHECKING, Any, NoReturn, Protocol, TypeVar + +import httpx +from policyengine_api_simulation_client import AuthenticatedClient +from policyengine_api_simulation_client.api.default import ( + get_household_report, + run_household_report, + submit_household_report, +) +from policyengine_api_simulation_client.models import ( + HouseholdPolicyChoice as GeneratedPolicyChoice, +) +from policyengine_api_simulation_client.models import ( + HouseholdRefusalResponse, + HouseholdReportResponse, + HouseholdReportRunSubmission, + HouseholdReportSubmissionCountry, + HouseholdRequestFieldError, + HouseholdRouteError, +) +from policyengine_api_simulation_client.models import ( + HouseholdReportSubmission as GeneratedSubmission, +) +from policyengine_api_simulation_client.models import ( + RunTrigger as GeneratedRunTrigger, +) +from policyengine_observability import instrument_httpx + +from policyengine_api.libs.gateway_auth import ( + GatewayAuthError, + gateway_bearer_auth_from_environment, +) +from policyengine_api.libs.simulation_http import ( + attach_request_identifiers, + required_base_url, +) +from policyengine_api.observability import get_runtime +from policyengine_api.services.v2.household_reports.types import ( + HouseholdCalculationError, + HouseholdCalculationOutput, + HouseholdCalculationRecord, + HouseholdReportRecord, + HouseholdReportRunRecord, +) +from policyengine_api.services.v2.simulation_entrypoint.types import ( + ReportStatus, + SimulationRecordNotFoundError, + SimulationRequestRefusedError, + SimulationServiceUnavailableError, +) + +if TYPE_CHECKING: + from collections.abc import Callable + from uuid import UUID + + from policyengine_api_simulation_client.models import ( + HouseholdCalculationResponse, + ) + from policyengine_api_simulation_client.types import Response + + from policyengine_api.services.v2.household_reports.types import ( + HouseholdPolicyChoice, + HouseholdReportSubmission, + ) + from policyengine_api.services.v2.simulation_entrypoint.types import ( + RunTrigger, + ) + +SIMULATION_ENTRYPOINT_URL_ENV = "SIMULATION_ENTRYPOINT_URL" +# FastAPI's 404 detail for a route the application does not serve. +UNSERVED_ROUTE_DETAIL = "Not Found" +REQUEST_TIMEOUT_SECONDS = 30.0 +# A run claimed but not yet started on Modal is still waiting to run. +REPORT_STATUSES = { + "pending": ReportStatus.PENDING, + "dispatching": ReportStatus.PENDING, + "running": ReportStatus.RUNNING, + "succeeded": ReportStatus.SUCCEEDED, + "failed": ReportStatus.FAILED, +} +ParsedT = TypeVar("ParsedT") + + +def _raise_unusable(detail: str) -> NoReturn: + msg = f"the Simulation Entrypoint cannot serve household reports: {detail}" + raise SimulationServiceUnavailableError(msg) + + +def _refusal_reason(refusal: HouseholdRefusalResponse) -> str: + """Return a 422's reason: the entrypoint's text, or each refused field. + + A field's location is given without the request's ``body`` prefix. A + refusal of another shape means the entrypoint cannot be used. + """ + detail: object = refusal.detail + if isinstance(detail, str): + return detail + if not isinstance(detail, list) or not all( + isinstance(field_error, HouseholdRequestFieldError) for field_error in detail + ): + _raise_unusable("its refusal could not be read") + reasons = [] + for field_error in detail: + location = [str(part) for part in field_error.loc] + if location[:1] == ["body"]: + location = location[1:] + reasons.append(f"{'.'.join(location)}: {field_error.msg}") + return "; ".join(reasons) or "the request is invalid" + + +def _raise_declared_error(status_code: int, answer: object) -> NoReturn: + """Raise the protocol's error for one of the entrypoint's declared answers.""" + if isinstance(answer, HouseholdRefusalResponse): + raise SimulationRequestRefusedError(_refusal_reason(answer)) + if ( + isinstance(answer, HouseholdRouteError) + and status_code == httpx.codes.NOT_FOUND + and answer.detail != UNSERVED_ROUTE_DETAIL + ): + raise SimulationRecordNotFoundError(answer.detail) + detail = answer.detail[:200] if isinstance(answer, HouseholdRouteError) else "" + _raise_unusable(f"it answered HTTP {status_code} {detail}".rstrip()) + + +def _parsed(call: Callable[[], Response[Any]], expected: type[ParsedT]) -> ParsedT: + """Run one generated route call and return its parsed success answer.""" + try: + response = call() + except httpx.HTTPError as error: + msg = f"the Simulation Entrypoint could not be reached: {type(error).__name__}" + raise SimulationServiceUnavailableError(msg) from error + except GatewayAuthError as error: + msg = f"the gateway token for the Simulation Entrypoint is unavailable: {error}" + raise SimulationServiceUnavailableError(msg) from error + except (KeyError, TypeError, ValueError, AttributeError) as error: + msg = ( + "the Simulation Entrypoint's answer could not be read: " + f"{type(error).__name__}" + ) + raise SimulationServiceUnavailableError(msg) from error + if isinstance(response.parsed, expected): + return response.parsed + _raise_declared_error(int(response.status_code), response.parsed) + + +class _GeneratedObject(Protocol): + """A generated model of a free-form JSON object.""" + + def to_dict(self) -> dict[str, Any]: + """Return the object as decoded, in its key order.""" + ... + + +def _optional_object(value: _GeneratedObject | None) -> dict[str, Any] | None: + return None if value is None else value.to_dict() + + +def _calculation( + calculation: HouseholdCalculationResponse, +) -> HouseholdCalculationRecord: + result, failure = calculation.result, calculation.error + return HouseholdCalculationRecord( + id=calculation.calculation_id, + simulation_id=calculation.simulation_id, + policy_id=calculation.policy_id, + status=calculation.status.value, + attempt=calculation.attempt, + trigger=calculation.trigger.value, + error=( + None + if failure is None + else HouseholdCalculationError( + code=failure.code, + message=failure.message, + detail=_optional_object(failure.detail), + ) + ), + output=( + None + if result is None + else HouseholdCalculationOutput( + household=result.household.to_dict(), + warnings=tuple(result.warnings), + spm_config=_optional_object(result.spm_config), + spm_provenance=_optional_object(result.spm_provenance), + ) + ), + dispatched_at=calculation.dispatched_at, + completed_at=calculation.completed_at, + created_at=calculation.created_at, + ) + + +def _report_run(report: HouseholdReportResponse) -> HouseholdReportRunRecord | None: + """Return the report's run, or None before its first run.""" + if ( + report.report_run_id is None + or report.catalog_version_id is None + or report.trigger is None + or report.status is None + or report.baseline is None + ): + return None + return HouseholdReportRunRecord( + id=report.report_run_id, + report_id=report.report_id, + trigger=report.trigger.value, + tax_benefit_model_version_id=report.catalog_version_id, + policyengine_version=None, + year=report.year, + status=REPORT_STATUSES[report.status.value], + baseline=_calculation(report.baseline), + reform=None if report.reform is None else _calculation(report.reform), + ) + + +def _report(report: HouseholdReportResponse) -> HouseholdReportRecord: + run = _report_run(report) + return HouseholdReportRecord( + id=report.report_id, + country_id=report.country, + year=report.year, + household_id=report.household_id, + baseline_simulation_id=report.baseline_simulation_id, + reform_simulation_id=report.reform_simulation_id, + status=ReportStatus.NOT_RUN if run is None else run.status, + run=run, + ) + + +def _policy_choice(choice: HouseholdPolicyChoice) -> GeneratedPolicyChoice: + return GeneratedPolicyChoice(policy_id=choice.policy_id) + + +class SimulationEntrypointHouseholdReports: + """``HouseholdReportSimulation`` over the generated simulation client.""" + + def __init__(self, client: AuthenticatedClient, *, base_url: str) -> None: + """Send every request through ``client``, whose base URL is ``base_url``.""" + self._client = client + self.base_url = base_url + + def submit_report( + self, submission: HouseholdReportSubmission + ) -> HouseholdReportRecord: + """Create a report and its first run in the entrypoint, and start it.""" + body = GeneratedSubmission( + country=HouseholdReportSubmissionCountry(submission.country_id), + policyengine_version=submission.policyengine_version, + household_id=submission.household_id, + baseline=_policy_choice(submission.baseline), + reform=( + None if submission.reform is None else _policy_choice(submission.reform) + ), + trigger=GeneratedRunTrigger.INITIAL, + ) + return _report( + _parsed( + lambda: submit_household_report.sync_detailed( + client=self._client, body=body + ), + HouseholdReportResponse, + ) + ) + + def get_report(self, report_id: UUID) -> HouseholdReportRecord: + """Return a report with its newest run and that run's calculations.""" + return _report( + _parsed( + lambda: get_household_report.sync_detailed( + report_id, client=self._client + ), + HouseholdReportResponse, + ) + ) + + def run_report( + self, + report_id: UUID, + *, + policyengine_version: str, + trigger: RunTrigger, + force: bool, + ) -> HouseholdReportRecord: + """Start another run of a report and return the report with that run.""" + body = HouseholdReportRunSubmission( + policyengine_version=policyengine_version, + trigger=GeneratedRunTrigger(trigger.value), + force=force, + ) + return _report( + _parsed( + lambda: run_household_report.sync_detailed( + report_id, client=self._client, body=body + ), + HouseholdReportResponse, + ) + ) + + +def simulation_entrypoint_household_reports( + base_url: str, + *, + auth: httpx.Auth | None, + transport: httpx.BaseTransport | None = None, +) -> SimulationEntrypointHouseholdReports: + """Build the adapter for the entrypoint at ``base_url``. + + ``auth`` adds the bearer token; ``transport`` replaces the network in tests. + """ + http_client = httpx.Client( + base_url=base_url, + timeout=REQUEST_TIMEOUT_SECONDS, + auth=auth, + transport=transport, + event_hooks={"request": [attach_request_identifiers]}, + ) + instrument_httpx(http_client, get_runtime()) + # The httpx client carries the refreshing gateway token, so the generated + # client's own fixed token is never sent. + client = AuthenticatedClient(base_url=base_url, token="").set_httpx_client( + http_client + ) + return SimulationEntrypointHouseholdReports(client, base_url=base_url) + + +def household_reports_from_environment() -> SimulationEntrypointHouseholdReports: + """Build the adapter from ``SIMULATION_ENTRYPOINT_URL`` and the gateway auth. + + Raise ``SimulationServiceUnavailableError`` when either is unusable. + """ + try: + base_url = required_base_url(SIMULATION_ENTRYPOINT_URL_ENV) + auth = gateway_bearer_auth_from_environment() + except (ValueError, GatewayAuthError) as error: + raise SimulationServiceUnavailableError(str(error)) from error + return simulation_entrypoint_household_reports(base_url, auth=auth) + + +@cache +def shared_household_reports() -> SimulationEntrypointHouseholdReports: + """Return one adapter per process, so its gateway token is fetched once.""" + return household_reports_from_environment() diff --git a/policyengine_api/services/v2/household_reports/__init__.py b/policyengine_api/services/v2/household_reports/__init__.py new file mode 100644 index 000000000..43428b2b6 --- /dev/null +++ b/policyengine_api/services/v2/household_reports/__init__.py @@ -0,0 +1 @@ +"""v2 household reports, calculated and stored by the Simulation Entrypoint.""" diff --git a/policyengine_api/services/v2/household_reports/types.py b/policyengine_api/services/v2/household_reports/types.py new file mode 100644 index 000000000..1d7166070 --- /dev/null +++ b/policyengine_api/services/v2/household_reports/types.py @@ -0,0 +1,202 @@ +"""The Simulation Entrypoint's household report routes, as the Public API uses them. + +A household report calculates one stored household under a baseline and, +optionally, a reform, each a stored policy or current law, for the +household's ``default_year``. The Simulation Entrypoint owns household +reports: their definitions, runs and calculated households live in its +database, and it reads the household and the policies from the Public API's +tables itself. ``HouseholdReportSimulation`` is those routes, with the +records they answer; they raise the errors of +``policyengine_api.services.v2.simulation_entrypoint.types``. +``policyengine_api.libs.simulation_household_reports`` implements it with the +generated simulation client, and tests replace it with a fake. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Literal, Protocol + +from pydantic import BaseModel, ConfigDict + +from policyengine_api.query_parameters import ResourceId + +if TYPE_CHECKING: + from datetime import datetime + from uuid import UUID + + from policyengine_api.services.v2.simulation_entrypoint.types import ( + JSONObject, + ReportStatus, + RunTrigger, + ) + + +class StrictHouseholdReportInput(BaseModel): + """Reject fields outside the reviewed household report contract.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + +class HouseholdPolicyChoice(StrictHouseholdReportInput): + """A stored v2 policy of the report's country, or none for current law.""" + + policy_id: ResourceId | None = None + + +class HouseholdReportFields(StrictHouseholdReportInput): + """What a household report calculates, apart from its country. + + ``household_id`` names a household stored through ``/v2/households``. + ``reform`` is null for a report of the baseline alone. The report is for + the household's ``default_year``. + """ + + kind: Literal["household"] + household_id: ResourceId + baseline: HouseholdPolicyChoice + reform: HouseholdPolicyChoice | None = None + + +@dataclass(frozen=True) +class HouseholdReportSubmission: + """A new household report as the Simulation Entrypoint is asked to create it. + + ``policyengine_version`` selects the country's catalog version. + """ + + country_id: str + policyengine_version: str + household_id: UUID + baseline: HouseholdPolicyChoice + reform: HouseholdPolicyChoice | None + + +@dataclass(frozen=True) +class HouseholdCalculationError: + """Why a household calculation failed. + + ``code`` is the run's error code, such as ``household_calculation_failed``, + when the household function refused the inputs; ``detail`` then holds its + error, with the Public API's error codes and messages. + """ + + code: str + message: str + detail: JSONObject | None + + +@dataclass(frozen=True) +class HouseholdCalculationOutput: + """A succeeded calculation: the household with its requested values filled in. + + ``household`` keeps the stored household's key order. ``spm_config`` is + the SPM selection applied and ``spm_provenance`` its receipt, both None + for a country without SPM. + """ + + household: JSONObject + warnings: tuple[str, ...] + spm_config: JSONObject | None + spm_provenance: JSONObject | None + + +@dataclass(frozen=True) +class HouseholdCalculationRecord: + """One calculation of the household under one policy: a simulation run. + + ``status`` is ``pending``, ``dispatching``, ``running``, ``succeeded`` or + ``failed``; ``attempt`` counts forced recalculations. ``output`` is set + once it succeeded and ``error`` once it failed. + """ + + id: UUID + simulation_id: UUID + policy_id: UUID | None + status: str + attempt: int + trigger: str + error: HouseholdCalculationError | None + output: HouseholdCalculationOutput | None + dispatched_at: datetime | None + completed_at: datetime | None + created_at: datetime + + +@dataclass(frozen=True) +class HouseholdReportRunRecord: + """One calculation of a household report: the baseline's and the reform's. + + ``tax_benefit_model_version_id`` is the catalog version it ran on and + ``policyengine_version`` that version's PolicyEngine.py version, which the + Public API reads from its own catalog. ``reform`` is None for a report of + the baseline alone. + """ + + id: UUID + report_id: UUID + trigger: str + tax_benefit_model_version_id: UUID + policyengine_version: str | None + year: int + status: ReportStatus + baseline: HouseholdCalculationRecord + reform: HouseholdCalculationRecord | None + + +@dataclass(frozen=True) +class HouseholdReportRecord: + """A household report with its newest run, or the run a request started. + + ``status`` is the run's, or ``not_run`` before the first run, when + ``household_id`` is not known either. + """ + + id: UUID + country_id: str + year: int + household_id: UUID | None + baseline_simulation_id: UUID + reform_simulation_id: UUID | None + status: ReportStatus + run: HouseholdReportRunRecord | None + + +class HouseholdReportSimulation(Protocol): + """The Simulation Entrypoint's household report routes. + + Every method raises ``SimulationServiceUnavailableError`` when the + entrypoint cannot be used. + """ + + def submit_report( + self, submission: HouseholdReportSubmission + ) -> HouseholdReportRecord: + """Create the report and its first run, and start it. + + Raise ``SimulationRequestRefusedError`` when the entrypoint refuses the + caller's data, with its reason. + """ + ... + + def get_report(self, report_id: UUID) -> HouseholdReportRecord: + """Return a report; raise ``SimulationRecordNotFoundError`` if unknown.""" + ... + + def run_report( + self, + report_id: UUID, + *, + policyengine_version: str, + trigger: RunTrigger, + force: bool, + ) -> HouseholdReportRecord: + """Start another run on the catalog version of ``policyengine_version``. + + Return the report with the new run. Runs that are unfinished or + succeeded within the last 28 days are reused unless ``force`` is set. + Raise ``SimulationRecordNotFoundError`` for an unknown report and + ``SimulationRequestRefusedError`` when the entrypoint refuses the + caller's data. + """ + ... diff --git a/pyproject.toml b/pyproject.toml index 66c6a592d..dc597da3a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -207,8 +207,8 @@ showcontent = true # registers an exit hook at import, the v2 database sessions and services load # persistence only when a default factory runs, Auth0 Management and the # Google Secret Manager client load only when a secret is read, and the -# economy report adapter needs the generated simulation client, which is not -# yet a dependency, only when an economy report route runs. Ruff's PLC0415 +# economy and household report adapters need the generated simulation client, +# which is not yet a dependency, only when a report route runs. Ruff's PLC0415 # accepts in-function imports of these without a suppression. A module belongs # here only if no application module imports it at module level. banned-module-level-imports = [ @@ -217,6 +217,7 @@ banned-module-level-imports = [ "policyengine_api.data.v2.database", "policyengine_api.libs.auth0_management", "policyengine_api.libs.simulation_economy_reports", + "policyengine_api.libs.simulation_household_reports", "policyengine_api.services.v2.countries.database_session", "policyengine_api.services.v2.countries.services", "policyengine_api.services.v2.economy_reports.database_session", @@ -281,6 +282,9 @@ runtime-evaluated-base-classes = [ "policyengine_api.services.v2.economy_reports.types.EconomyReportFields", "policyengine_api.services.v2.economy_reports.types.EconomyReportRunFields", "policyengine_api.services.v2.economy_reports.types.StrictEconomyReportInput", + "policyengine_api.services.v2.household_reports.types.HouseholdPolicyChoice", + "policyengine_api.services.v2.household_reports.types.HouseholdReportFields", + "policyengine_api.services.v2.household_reports.types.StrictHouseholdReportInput", "policyengine_api.services.v2.households.types.HouseholdCreationInput", "policyengine_api.services.v2.households.types.StrictHouseholdInput", "policyengine_api.services.v2.metadata.types.MetadataModelVersion", diff --git a/tests/unit/libs/test_simulation_household_reports.py b/tests/unit/libs/test_simulation_household_reports.py new file mode 100644 index 000000000..67a514f2d --- /dev/null +++ b/tests/unit/libs/test_simulation_household_reports.py @@ -0,0 +1,563 @@ +"""The household report adapter against the generated simulation client. + +The client is generated from the Simulation Entrypoint's OpenAPI document and +is not a dependency until a release contains the household report routes, so +these tests skip when it is not installed. Requests go to an in-memory +transport that answers as the entrypoint does. +""" + +from __future__ import annotations + +import json +from datetime import UTC, datetime +from uuid import UUID + +import httpx +import pytest + +pytest.importorskip("policyengine_api_simulation_client") + +from policyengine_api.libs.gateway_auth import GatewayAuthError +from policyengine_api.libs.simulation_household_reports import ( + SimulationEntrypointHouseholdReports, + household_reports_from_environment, + simulation_entrypoint_household_reports, +) +from policyengine_api.request_context import _asgi_request_id +from policyengine_api.services.v2.household_reports.types import ( + HouseholdCalculationError, + HouseholdCalculationOutput, + HouseholdCalculationRecord, + HouseholdPolicyChoice, + HouseholdReportRecord, + HouseholdReportRunRecord, + HouseholdReportSubmission, +) +from policyengine_api.services.v2.simulation_entrypoint.types import ( + ReportStatus, + RunTrigger, + SimulationRecordNotFoundError, + SimulationRequestRefusedError, + SimulationServiceUnavailableError, +) + +BASE_URL = "https://simulation.example.test" +REPORT_ID = UUID("00000000-0000-4000-8000-000000000201") +HOUSEHOLD_ID = UUID("00000000-0000-4000-8000-000000000202") +BASELINE_SIMULATION_ID = UUID("00000000-0000-4000-8000-000000000203") +REFORM_SIMULATION_ID = UUID("00000000-0000-4000-8000-000000000204") +REPORT_RUN_ID = UUID("00000000-0000-4000-8000-000000000205") +BASELINE_RUN_ID = UUID("00000000-0000-4000-8000-000000000206") +REFORM_RUN_ID = UUID("00000000-0000-4000-8000-000000000207") +CATALOG_VERSION_ID = UUID("00000000-0000-4000-8000-000000000208") +REFORM_POLICY_ID = UUID("00000000-0000-4000-8000-000000000209") +CREATED_AT = "2026-10-29T12:00:00Z" +CREATED_AT_VALUE = datetime(2026, 10, 29, 12, tzinfo=UTC) +COMPLETED_AT = "2026-10-29T12:00:09Z" +COMPLETED_AT_VALUE = datetime(2026, 10, 29, 12, 0, 9, tzinfo=UTC) +# Keys deliberately out of alphabetical order: the household function lists +# calculated values in the household's own key order. +CALCULATED_HOUSEHOLD = { + "people": {"you": {"age": {"2026": 40}, "employment_income": {"2026": 50000}}}, + "households": {"your household": {"members": ["you"]}}, +} +SPM_CONFIG = {"geography": "national"} +SPM_PROVENANCE = {"forecast": "2026-certified"} + + +def _calculation_body( + run_id: UUID, + simulation_id: UUID, + policy_id: UUID | None, + *, + status: str = "succeeded", + **changes, +) -> dict: + succeeded = status == "succeeded" + return { + "calculation_id": str(run_id), + "simulation_id": str(simulation_id), + "country": "us", + "household_id": str(HOUSEHOLD_ID), + "policy_id": None if policy_id is None else str(policy_id), + "options": {}, + "catalog_version_id": str(CATALOG_VERSION_ID), + "year": 2026, + "attempt": 1, + "trigger": "initial", + "status": status, + "created_at": CREATED_AT, + "dispatched_at": CREATED_AT if status != "pending" else None, + "completed_at": COMPLETED_AT if succeeded else None, + "result": ( + { + "household": CALCULATED_HOUSEHOLD, + "warnings": ["employment_income was capped"], + "spm_config": SPM_CONFIG, + "spm_provenance": SPM_PROVENANCE, + "bundle": {"policyengine_version": "6.7.5"}, + } + if succeeded + else None + ), + "error": None, + **changes, + } + + +def _report_body(**changes) -> dict: + return { + "report_id": str(REPORT_ID), + "country": "us", + "year": 2026, + "household_id": str(HOUSEHOLD_ID), + "baseline_simulation_id": str(BASELINE_SIMULATION_ID), + "reform_simulation_id": str(REFORM_SIMULATION_ID), + "report_run_id": str(REPORT_RUN_ID), + "catalog_version_id": str(CATALOG_VERSION_ID), + "trigger": "initial", + "status": "succeeded", + "baseline": _calculation_body(BASELINE_RUN_ID, BASELINE_SIMULATION_ID, None), + "reform": _calculation_body( + REFORM_RUN_ID, REFORM_SIMULATION_ID, REFORM_POLICY_ID + ), + **changes, + } + + +def _calculation_record( + run_id: UUID, simulation_id: UUID, policy_id: UUID | None +) -> HouseholdCalculationRecord: + return HouseholdCalculationRecord( + id=run_id, + simulation_id=simulation_id, + policy_id=policy_id, + status="succeeded", + attempt=1, + trigger="initial", + error=None, + output=HouseholdCalculationOutput( + household=CALCULATED_HOUSEHOLD, + warnings=("employment_income was capped",), + spm_config=SPM_CONFIG, + spm_provenance=SPM_PROVENANCE, + ), + dispatched_at=CREATED_AT_VALUE, + completed_at=COMPLETED_AT_VALUE, + created_at=CREATED_AT_VALUE, + ) + + +SUCCEEDED_REPORT = HouseholdReportRecord( + id=REPORT_ID, + country_id="us", + year=2026, + household_id=HOUSEHOLD_ID, + baseline_simulation_id=BASELINE_SIMULATION_ID, + reform_simulation_id=REFORM_SIMULATION_ID, + status=ReportStatus.SUCCEEDED, + run=HouseholdReportRunRecord( + id=REPORT_RUN_ID, + report_id=REPORT_ID, + trigger="initial", + tax_benefit_model_version_id=CATALOG_VERSION_ID, + policyengine_version=None, + year=2026, + status=ReportStatus.SUCCEEDED, + baseline=_calculation_record(BASELINE_RUN_ID, BASELINE_SIMULATION_ID, None), + reform=_calculation_record( + REFORM_RUN_ID, REFORM_SIMULATION_ID, REFORM_POLICY_ID + ), + ), +) + + +class SimulationEntrypointStub: + """Answer each request with the next queued response and record the request.""" + + def __init__(self, *responses: httpx.Response) -> None: + self.responses = list(responses) + self.requests: list[httpx.Request] = [] + + def __call__(self, request: httpx.Request) -> httpx.Response: + self.requests.append(request) + return self.responses.pop(0) + + def adapter( + self, auth: httpx.Auth | None = None + ) -> SimulationEntrypointHouseholdReports: + return simulation_entrypoint_household_reports( + BASE_URL, auth=auth, transport=httpx.MockTransport(self) + ) + + +def _submission(*, reform: HouseholdPolicyChoice | None) -> HouseholdReportSubmission: + return HouseholdReportSubmission( + country_id="us", + policyengine_version="6.7.5", + household_id=HOUSEHOLD_ID, + baseline=HouseholdPolicyChoice(), + reform=reform, + ) + + +def test_submit_sends_the_household_and_policies_and_reads_the_new_report() -> None: + entrypoint = SimulationEntrypointStub(httpx.Response(202, json=_report_body())) + + report = entrypoint.adapter().submit_report( + _submission(reform=HouseholdPolicyChoice(policy_id=REFORM_POLICY_ID)) + ) + + (request,) = entrypoint.requests + assert request.method == "POST" + assert str(request.url) == f"{BASE_URL}/v2/household-reports" + assert json.loads(request.content) == { + "country": "us", + "policyengine_version": "6.7.5", + "household_id": str(HOUSEHOLD_ID), + "baseline": {"policy_id": None}, + "reform": {"policy_id": str(REFORM_POLICY_ID)}, + "force": False, + "trigger": "initial", + } + assert report == SUCCEEDED_REPORT + + +def test_a_baseline_only_report_is_sent_without_a_reform() -> None: + entrypoint = SimulationEntrypointStub( + httpx.Response(202, json=_report_body(reform_simulation_id=None, reform=None)) + ) + + report = entrypoint.adapter().submit_report(_submission(reform=None)) + + assert json.loads(entrypoint.requests[0].content)["reform"] is None + assert report.reform_simulation_id is None + assert report.run is not None + assert report.run.reform is None + + +def test_a_calculated_household_keeps_the_key_order_it_was_sent_in() -> None: + entrypoint = SimulationEntrypointStub(httpx.Response(200, json=_report_body())) + + report = entrypoint.adapter().get_report(REPORT_ID) + + assert report.run is not None + output = report.run.baseline.output + assert output is not None + assert list(output.household) == ["people", "households"] + assert list(output.household["people"]["you"]) == ["age", "employment_income"] + + +def test_a_report_before_its_first_run_reads_as_not_run() -> None: + entrypoint = SimulationEntrypointStub( + httpx.Response( + 200, + json=_report_body( + household_id=None, + report_run_id=None, + catalog_version_id=None, + trigger=None, + status=None, + baseline=None, + reform=None, + ), + ) + ) + + report = entrypoint.adapter().get_report(REPORT_ID) + + assert str(entrypoint.requests[0].url) == ( + f"{BASE_URL}/v2/household-reports/{REPORT_ID}" + ) + assert report.status is ReportStatus.NOT_RUN + assert report.run is None + assert report.household_id is None + + +@pytest.mark.parametrize( + ("entrypoint_status", "report_status"), + [ + ("pending", ReportStatus.PENDING), + ("dispatching", ReportStatus.PENDING), + ("running", ReportStatus.RUNNING), + ], +) +def test_an_unfinished_run_reads_without_outputs( + entrypoint_status: str, report_status: ReportStatus +) -> None: + entrypoint = SimulationEntrypointStub( + httpx.Response( + 200, + json=_report_body( + status=entrypoint_status, + baseline=_calculation_body( + BASELINE_RUN_ID, + BASELINE_SIMULATION_ID, + None, + status=entrypoint_status, + ), + reform=_calculation_body( + REFORM_RUN_ID, + REFORM_SIMULATION_ID, + REFORM_POLICY_ID, + status=entrypoint_status, + ), + ), + ) + ) + + report = entrypoint.adapter().get_report(REPORT_ID) + + assert report.status is report_status + assert report.run is not None + assert report.run.status is report_status + assert report.run.baseline.status == entrypoint_status + assert report.run.baseline.output is None + + +def test_a_failed_calculation_reads_with_its_error() -> None: + failure = { + "code": "household_calculation_failed", + "message": "The household could not be calculated.", + "detail": {"errors": [{"code": "unknown_variable"}]}, + } + entrypoint = SimulationEntrypointStub( + httpx.Response( + 200, + json=_report_body( + status="failed", + reform=_calculation_body( + REFORM_RUN_ID, + REFORM_SIMULATION_ID, + REFORM_POLICY_ID, + status="failed", + error=failure, + ), + ), + ) + ) + + report = entrypoint.adapter().get_report(REPORT_ID) + + assert report.status is ReportStatus.FAILED + assert report.run is not None + assert report.run.reform is not None + assert report.run.reform.error == HouseholdCalculationError( + code="household_calculation_failed", + message="The household could not be calculated.", + detail={"errors": [{"code": "unknown_variable"}]}, + ) + + +def test_a_run_sends_its_version_trigger_and_force() -> None: + entrypoint = SimulationEntrypointStub( + httpx.Response(202, json=_report_body(trigger="manual", status="pending")) + ) + + report = entrypoint.adapter().run_report( + REPORT_ID, + policyengine_version="6.8.0", + trigger=RunTrigger.MANUAL, + force=True, + ) + + (request,) = entrypoint.requests + assert request.method == "POST" + assert str(request.url) == f"{BASE_URL}/v2/household-reports/{REPORT_ID}/runs" + assert json.loads(request.content) == { + "policyengine_version": "6.8.0", + "trigger": "manual", + "force": True, + } + assert report.run is not None + assert report.run.trigger == "manual" + + +def test_an_unknown_report_is_not_found() -> None: + entrypoint = SimulationEntrypointStub( + httpx.Response(404, json={"detail": "No such household report."}) + ) + + with pytest.raises(SimulationRecordNotFoundError): + entrypoint.adapter().get_report(REPORT_ID) + + +def test_a_run_of_an_unknown_report_is_not_found() -> None: + entrypoint = SimulationEntrypointStub( + httpx.Response(404, json={"detail": "No such household report."}) + ) + adapter = entrypoint.adapter() + + with pytest.raises(SimulationRecordNotFoundError): + adapter.run_report( + REPORT_ID, + policyengine_version="6.7.5", + trigger=RunTrigger.MANUAL, + force=False, + ) + + +def test_a_route_the_entrypoint_does_not_serve_means_it_is_unavailable() -> None: + entrypoint = SimulationEntrypointStub( + httpx.Response(404, json={"detail": "Not Found"}) + ) + + with pytest.raises(SimulationServiceUnavailableError): + entrypoint.adapter().get_report(REPORT_ID) + + +def test_a_refusal_of_the_callers_data_carries_the_entrypoint_reason() -> None: + reason = "The policy changes gov.irs.unknown, which catalog 6.7.5 lacks." + entrypoint = SimulationEntrypointStub(httpx.Response(422, json={"detail": reason})) + + with pytest.raises(SimulationRequestRefusedError) as raised: + entrypoint.adapter().submit_report(_submission(reform=None)) + + assert str(raised.value) == reason + + +def test_a_request_validation_refusal_names_each_field() -> None: + entrypoint = SimulationEntrypointStub( + httpx.Response( + 422, + json={ + "detail": [ + { + "loc": ["body", "household_id"], + "msg": "Input should be a valid UUID", + "type": "uuid_parsing", + } + ] + }, + ) + ) + + with pytest.raises(SimulationRequestRefusedError) as raised: + entrypoint.adapter().submit_report(_submission(reform=None)) + + assert str(raised.value) == "household_id: Input should be a valid UUID" + + +@pytest.mark.parametrize( + "response", + [ + httpx.Response(400, json={"detail": "No household 0000 in country us."}), + httpx.Response(422, json={"detail": {"unexpected": "shape"}}), + httpx.Response( + 503, json={"detail": "The v2 version manifest could not be read."} + ), + httpx.Response(500, text="Internal Server Error"), + httpx.Response(403, json={"detail": "Forbidden"}), + httpx.Response(202, json={"unexpected": "shape"}), + httpx.Response(202, text="not json"), + ], + ids=[ + "refused", + "malformed-refusal", + "unavailable", + "server-error", + "forbidden", + "shape", + "text", + ], +) +def test_an_unusable_answer_means_the_entrypoint_is_unavailable( + response: httpx.Response, +) -> None: + entrypoint = SimulationEntrypointStub(response) + + with pytest.raises(SimulationServiceUnavailableError): + entrypoint.adapter().submit_report(_submission(reform=None)) + + +def test_a_network_failure_means_the_entrypoint_is_unavailable() -> None: + def refuse(request: httpx.Request) -> httpx.Response: + message = "connection refused" + raise httpx.ConnectError(message, request=request) + + adapter = simulation_entrypoint_household_reports( + BASE_URL, auth=None, transport=httpx.MockTransport(refuse) + ) + + with pytest.raises(SimulationServiceUnavailableError): + adapter.get_report(REPORT_ID) + + +class FailingTokenAuth(httpx.Auth): + """Fail to fetch a token, as the gateway auth does when Auth0 cannot answer.""" + + def auth_flow(self, request: httpx.Request): + message = "Auth0 token fetch failed: HTTP 503" + raise GatewayAuthError(message) + yield request # pragma: no cover - makes this a generator, as httpx expects + + +def test_a_failed_token_fetch_means_the_entrypoint_is_unavailable() -> None: + entrypoint = SimulationEntrypointStub(httpx.Response(200, json=_report_body())) + + with pytest.raises(SimulationServiceUnavailableError) as raised: + entrypoint.adapter(auth=FailingTokenAuth()).get_report(REPORT_ID) + + assert "token" in str(raised.value) + assert entrypoint.requests == [] + + +def test_requests_carry_the_current_request_id() -> None: + entrypoint = SimulationEntrypointStub(httpx.Response(200, json=_report_body())) + token = _asgi_request_id.set("api-request-id") + try: + entrypoint.adapter().get_report(REPORT_ID) + finally: + _asgi_request_id.reset(token) + + assert entrypoint.requests[0].headers["X-PolicyEngine-Request-Id"] == ( + "api-request-id" + ) + + +def _clear_gateway_auth(monkeypatch: pytest.MonkeyPatch) -> None: + for name in ( + "GATEWAY_AUTH_ISSUER", + "GATEWAY_AUTH_AUDIENCE", + "GATEWAY_AUTH_CLIENT_ID", + "GATEWAY_AUTH_CLIENT_SECRET", + "GATEWAY_AUTH_CLIENT_SECRET_RESOURCE", + "GATEWAY_AUTH_REQUIRED", + ): + monkeypatch.delenv(name, raising=False) + + +def test_the_environment_names_the_entrypoint_url( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _clear_gateway_auth(monkeypatch) + monkeypatch.setenv("SIMULATION_ENTRYPOINT_URL", f"{BASE_URL}/") + monkeypatch.setenv("SIM_ENTRYPOINT", "old_gateway_direct") + + adapter = household_reports_from_environment() + + assert isinstance(adapter, SimulationEntrypointHouseholdReports) + assert adapter.base_url == BASE_URL + + +def test_an_unusable_entrypoint_url_makes_reports_unavailable( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _clear_gateway_auth(monkeypatch) + monkeypatch.setenv("SIMULATION_ENTRYPOINT_URL", "") + + with pytest.raises(SimulationServiceUnavailableError) as raised: + household_reports_from_environment() + + assert "SIMULATION_ENTRYPOINT_URL" in str(raised.value) + + +def test_partial_gateway_auth_is_refused(monkeypatch: pytest.MonkeyPatch) -> None: + _clear_gateway_auth(monkeypatch) + monkeypatch.setenv("SIMULATION_ENTRYPOINT_URL", BASE_URL) + monkeypatch.setenv("GATEWAY_AUTH_ISSUER", "https://auth.example.test") + + with pytest.raises(SimulationServiceUnavailableError) as raised: + household_reports_from_environment() + + assert "partially configured" in str(raised.value) From 99b6c86d31e616666813111ff004a61098514b12 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 29 Oct 2026 22:17:07 +0400 Subject: [PATCH 02/21] Submit household reports at POST /v2/reports, checking the household and policies first A submission of kind household names a stored household, a baseline policy and an optional reform policy. Before the simulation service is asked, the country must have household calculations (a household-only country does), the household and each policy must be the country's, and the household must have a default year, the year the report calculates. The answer names the PolicyEngine.py version of the run's catalog version, read from the catalog. Economy submissions reach the economy report service as before. Co-Authored-By: Claude Opus 5.5 --- changelog.d/v2-household-reports.added.md | 1 + .../fastapi_routes/dependencies.py | 61 +++ .../v2/reports/household_response_models.py | 144 +++++++ .../v2/reports/request_models.py | 26 +- .../v2/reports/response_models.py | 33 +- .../fastapi_routes/v2/reports/routes.py | 95 ++++- .../libs/simulation_household_reports.py | 2 +- .../database_connectors/__init__.py | 1 + .../database_connectors/reads.py | 37 ++ .../v2/household_reports/database_session.py | 30 ++ .../services/v2/household_reports/services.py | 172 ++++++++ .../v2/household_reports/transformations.py | 61 +++ .../services/v2/household_reports/types.py | 29 +- .../v2/household_reports/validators.py | 57 +++ .../services/v2/report_kinds/__init__.py | 1 + .../services/v2/report_kinds/services.py | 112 ++++++ .../services/v2/report_kinds/types.py | 74 ++++ pyproject.toml | 3 + .../libs/test_simulation_household_reports.py | 14 +- tests/unit/v2/household_report_support.py | 379 ++++++++++++++++++ tests/unit/v2/test_economy_report_routes.py | 7 +- tests/unit/v2/test_household_report_routes.py | 353 ++++++++++++++++ .../unit/v2/test_household_report_service.py | 198 +++++++++ 23 files changed, 1849 insertions(+), 41 deletions(-) create mode 100644 changelog.d/v2-household-reports.added.md create mode 100644 policyengine_api/fastapi_routes/v2/reports/household_response_models.py create mode 100644 policyengine_api/services/v2/household_reports/database_connectors/__init__.py create mode 100644 policyengine_api/services/v2/household_reports/database_connectors/reads.py create mode 100644 policyengine_api/services/v2/household_reports/database_session.py create mode 100644 policyengine_api/services/v2/household_reports/services.py create mode 100644 policyengine_api/services/v2/household_reports/transformations.py create mode 100644 policyengine_api/services/v2/household_reports/validators.py create mode 100644 policyengine_api/services/v2/report_kinds/__init__.py create mode 100644 policyengine_api/services/v2/report_kinds/services.py create mode 100644 policyengine_api/services/v2/report_kinds/types.py create mode 100644 tests/unit/v2/household_report_support.py create mode 100644 tests/unit/v2/test_household_report_routes.py create mode 100644 tests/unit/v2/test_household_report_service.py diff --git a/changelog.d/v2-household-reports.added.md b/changelog.d/v2-household-reports.added.md new file mode 100644 index 000000000..76fc09d71 --- /dev/null +++ b/changelog.d/v2-household-reports.added.md @@ -0,0 +1 @@ +Household reports at /v2/reports: a household stored through /v2/households under a baseline and an optional reform, calculated by the simulation service for the household's default year, for any country with household calculations, including a household-only one. diff --git a/policyengine_api/fastapi_routes/dependencies.py b/policyengine_api/fastapi_routes/dependencies.py index 9657599ef..c28105b8f 100644 --- a/policyengine_api/fastapi_routes/dependencies.py +++ b/policyengine_api/fastapi_routes/dependencies.py @@ -31,6 +31,9 @@ ) from policyengine_api.services.v2.countries.types import CountryRead from policyengine_api.services.v2.economy_reports.types import EconomyReportFields + from policyengine_api.services.v2.household_reports.types import ( + HouseholdReportSimulation, + ) from policyengine_api.services.v2.households.types import ( HouseholdPage, HouseholdRead, @@ -62,6 +65,9 @@ PolicyPage, PolicyRead, ) + from policyengine_api.services.v2.report_kinds.types import ( + HouseholdReportOperations as V2HouseholdReportResourceService, + ) from policyengine_api.services.v2.simulation_entrypoint.types import ( EconomyReportRecord, EconomyReportRunRecord, @@ -901,6 +907,55 @@ def _default_v2_economy_report_service_factory() -> V2EconomyReportResourceServi ) +def _default_household_report_simulation() -> HouseholdReportSimulation: + """Return the process's adapter for the entrypoint's household report routes. + + Raise ``SimulationServiceUnavailableError`` when the generated client is + not installed, or the entrypoint URL or gateway auth is unusable. + """ + try: + from policyengine_api.libs.simulation_household_reports import ( + shared_household_reports, + ) + except ModuleNotFoundError as error: + if (error.name or "").split(".")[0] != SIMULATION_CLIENT_PACKAGE: + raise + msg = ( + "the generated simulation client (policyengine-api-simulation-client) " + "is not installed" + ) + raise SimulationServiceUnavailableError(msg) from error + return shared_household_reports() + + +def _default_v2_household_report_service_factory() -> V2HouseholdReportResourceService: + """Build the household report service; the adapter waits for its first call. + + Refusals the Public API decides itself, such as a household-only + country's missing household, then answer without the simulation client. + """ + from policyengine_api.data.v2.database import ( + get_v2_session_factory, + ) + from policyengine_api.services.v2.household_reports.database_session import ( + HouseholdReportDatabaseSession, + ) + from policyengine_api.services.v2.household_reports.services import ( + DeferredHouseholdReportSimulation, + V2HouseholdReportService, + ) + + return V2HouseholdReportService( + HouseholdReportDatabaseSession(get_v2_session_factory()), + households=_default_v2_household_service_factory(), + policies=_default_v2_policy_service_factory(), + simulation=DeferredHouseholdReportSimulation( + _default_household_report_simulation + ), + current_policyengine_version=_running_policyengine_version(), + ) + + def _default_v2_policy_service_factory() -> V2PolicyResourceService: from policyengine_api.data.v2.database import ( get_v2_session_factory, @@ -1091,6 +1146,9 @@ class NativeRouteDependencies: v2_economy_report_service_factory: ( Callable[[], V2EconomyReportResourceService] | None ) = None + v2_household_report_service_factory: ( + Callable[[], V2HouseholdReportResourceService] | None + ) = None v2_policy_service_factory: Callable[[], V2PolicyResourceService] | None = None v2_household_service_factory: Callable[[], V2HouseholdResourceService] | None = None v2_user_policy_service_factory: Callable[[], V2UserPolicyResourceService] | None = ( @@ -1125,6 +1183,9 @@ def defaults(cls) -> NativeRouteDependencies: v2_economy_report_service_factory=( _default_v2_economy_report_service_factory ), + v2_household_report_service_factory=( + _default_v2_household_report_service_factory + ), v2_policy_service_factory=_default_v2_policy_service_factory, v2_household_service_factory=_default_v2_household_service_factory, v2_user_policy_service_factory=(_default_v2_user_policy_service_factory), diff --git a/policyengine_api/fastapi_routes/v2/reports/household_response_models.py b/policyengine_api/fastapi_routes/v2/reports/household_response_models.py new file mode 100644 index 000000000..2cd3e142c --- /dev/null +++ b/policyengine_api/fastapi_routes/v2/reports/household_response_models.py @@ -0,0 +1,144 @@ +"""Strict HTTP response models for v2 household reports.""" + +from __future__ import annotations + +from datetime import datetime +from typing import TYPE_CHECKING, Literal +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, JsonValue + +from policyengine_api.services.v2.simulation_entrypoint.types import ReportStatus + +if TYPE_CHECKING: + from policyengine_api.services.v2.household_reports.types import ( + HouseholdReportRecord, + HouseholdReportRunRecord, + ) + + +class StrictHouseholdReportAPIModel(BaseModel): + """Strict response base that converts the service's dataclass records.""" + + model_config = ConfigDict( + extra="forbid", + allow_inf_nan=False, + from_attributes=True, + ) + + +class HouseholdCalculationErrorItem(StrictHouseholdReportAPIModel): + """Why a household calculation failed. + + ``code`` is ``household_calculation_failed`` when the household function + refused the inputs, with its error in ``detail``; ``worker_error``, + ``worker_timeout`` or ``dispatch_abandoned`` otherwise. + """ + + code: str + message: str + detail: dict[str, JsonValue] | None + + +class HouseholdCalculationResultItem(StrictHouseholdReportAPIModel): + """The household with each requested value filled in, in its own key order. + + ``spm_config`` is the SPM selection applied and ``spm_provenance`` its + receipt, both null for a country without SPM. + """ + + household: dict[str, JsonValue] + warnings: list[str] + spm_config: dict[str, JsonValue] | None + spm_provenance: dict[str, JsonValue] | None + + +class HouseholdCalculationItem(StrictHouseholdReportAPIModel): + """One calculation of the household under one policy (a simulation run). + + A null ``policy_id`` means current law. ``status`` is ``pending``, + ``dispatching``, ``running``, ``succeeded`` or ``failed``; ``attempt`` + counts forced recalculations. ``result`` is set once it succeeded and + ``error`` once it failed. + """ + + id: UUID + simulation_id: UUID + policy_id: UUID | None + status: str + attempt: int + trigger: str + error: HouseholdCalculationErrorItem | None + result: HouseholdCalculationResultItem | None + dispatched_at: datetime | None + completed_at: datetime | None + created_at: datetime + + +class HouseholdReportRunSummaryItem(StrictHouseholdReportAPIModel): + """One calculation of a household report, without its calculations. + + ``trigger`` is ``initial`` for the run a submission starts and ``manual`` + for a re-run. ``tax_benefit_model_version_id`` and ``policyengine_version`` + name the catalog version it ran on. + """ + + kind: Literal["household"] = "household" + id: UUID + report_id: UUID + trigger: str + tax_benefit_model_version_id: UUID + policyengine_version: str | None + year: int + status: ReportStatus + + +class HouseholdReportRunItem(HouseholdReportRunSummaryItem): + """One calculation of a household report: the baseline's and the reform's. + + ``reform`` is null for a report of the baseline alone. + """ + + baseline: HouseholdCalculationItem + reform: HouseholdCalculationItem | None + + @classmethod + def from_record(cls, record: HouseholdReportRunRecord) -> HouseholdReportRunItem: + """Build the response item from the service's report run record.""" + return cls.model_validate(record) + + +class HouseholdReportSummaryItem(StrictHouseholdReportAPIModel): + """A household report: a stored household under a baseline and a reform. + + The report is for the household's ``default_year``. ``status`` is the + newest run's, or ``not_run`` before the first run, when ``household_id`` is + not known either. ``reform_simulation_id`` is null for a report of the + baseline alone. + """ + + id: UUID + country_id: str + kind: Literal["household"] = "household" + year: int + household_id: UUID | None + baseline_simulation_id: UUID + reform_simulation_id: UUID | None + status: ReportStatus + run: HouseholdReportRunSummaryItem | None + + @classmethod + def from_record(cls, record: HouseholdReportRecord) -> HouseholdReportSummaryItem: + """Build the summary from the service's record, without its calculations.""" + return cls.model_validate(record) + + +class HouseholdReportItem(HouseholdReportSummaryItem): + """A household report with its newest run and that run's calculations.""" + + run: HouseholdReportRunItem | None + + @classmethod + def from_record(cls, record: HouseholdReportRecord) -> HouseholdReportItem: + """Build the response item from the service's report record.""" + return cls.model_validate(record) diff --git a/policyengine_api/fastapi_routes/v2/reports/request_models.py b/policyengine_api/fastapi_routes/v2/reports/request_models.py index 3fe27d938..28d274679 100644 --- a/policyengine_api/fastapi_routes/v2/reports/request_models.py +++ b/policyengine_api/fastapi_routes/v2/reports/request_models.py @@ -1,10 +1,17 @@ -"""Strict HTTP request models for v2 economy reports.""" +"""Strict HTTP request models for v2 economy and household reports.""" + +from typing import Annotated + +from pydantic import Field from policyengine_api.query_parameters import CountryId from policyengine_api.services.v2.economy_reports.types import ( EconomyReportFields, EconomyReportRunFields, ) +from policyengine_api.services.v2.household_reports.types import ( + HouseholdReportFields, +) class EconomyReportCreateRequest(EconomyReportFields): @@ -17,6 +24,23 @@ class EconomyReportCreateRequest(EconomyReportFields): country_id: CountryId +class HouseholdReportCreateRequest(HouseholdReportFields): + """A new household report; ``country_id`` must match the query's. + + ``baseline`` and ``reform`` each name a v2 policy of the country, and an + empty object means current law; ``reform`` is null for a report of the + baseline alone. It is calculated for the household's ``default_year``. + """ + + country_id: CountryId + + +ReportCreateRequest = Annotated[ + EconomyReportCreateRequest | HouseholdReportCreateRequest, + Field(discriminator="kind"), +] + + class EconomyReportRunRequest(EconomyReportRunFields): """A re-run on the current catalog version; ``{}`` reuses existing runs. diff --git a/policyengine_api/fastapi_routes/v2/reports/response_models.py b/policyengine_api/fastapi_routes/v2/reports/response_models.py index 398f76a05..66d576dba 100644 --- a/policyengine_api/fastapi_routes/v2/reports/response_models.py +++ b/policyengine_api/fastapi_routes/v2/reports/response_models.py @@ -1,14 +1,20 @@ -"""Strict HTTP response models for v2 economy reports.""" +"""Strict HTTP response models for v2 economy reports, and both kinds' envelopes. + +Household reports' own items are in ``household_response_models``. +""" from __future__ import annotations from datetime import datetime -from typing import TYPE_CHECKING, Any, Generic, Literal, TypeVar +from typing import TYPE_CHECKING, Annotated, Any, Generic, Literal, TypeVar from uuid import UUID -from pydantic import BaseModel, ConfigDict, JsonValue +from pydantic import BaseModel, ConfigDict, Field, JsonValue from policyengine_api.fastapi_routes.v2.errors import V2ErrorResponse +from policyengine_api.fastapi_routes.v2.reports.household_response_models import ( + HouseholdReportItem, +) from policyengine_api.services.v2.simulation_entrypoint.types import ReportStatus if TYPE_CHECKING: @@ -151,10 +157,15 @@ def from_record(cls, record: EconomyReportRecord) -> ReportItem: return cls.model_validate(record) +ReportDetailItem = Annotated[ + ReportItem | HouseholdReportItem, Field(discriminator="kind") +] + + class ReportDetailResult(StrictReportAPIModel): - """Result holding one report in ``item``.""" + """Result holding one report, of either kind, in ``item``.""" - item: ReportItem + item: ReportDetailItem class ReportListResult(StrictReportAPIModel): @@ -215,10 +226,12 @@ def _error(description: str) -> dict[str, Any]: # Submitting or re-running a report can also be refused for the report itself. REPORT_REFUSAL_RESPONSE: dict[int | str, dict[str, Any]] = { 422: _error( - "The request does not match the schema, the country does not have " - "economy reports (such as a household-only country), or the simulation " + "The request does not match the schema; the country does not have " + "economy reports (such as a household-only country) or household " + "calculations; the household has no default year; or the simulation " "service cannot run the report (for example, no such region or " - "dataset in the current catalog version); the message says which." + "dataset in the current catalog version, or a policy changing a " + "parameter that version lacks); the message says which." ), } REPORT_NOT_FOUND_RESPONSE: dict[int | str, dict[str, Any]] = { @@ -229,6 +242,10 @@ def _error(description: str) -> dict[str, Any]: } SUBMIT_RESPONSES: dict[int | str, dict[str, Any]] = { 400: _error("The body and query name different countries."), + 404: _error( + "A household report's household or one of its policies does not exist " + "in this country." + ), **REPORT_BODY_RESPONSE, **REPORT_REFUSAL_RESPONSE, } diff --git a/policyengine_api/fastapi_routes/v2/reports/routes.py b/policyengine_api/fastapi_routes/v2/reports/routes.py index 639b1c239..625d6ddd4 100644 --- a/policyengine_api/fastapi_routes/v2/reports/routes.py +++ b/policyengine_api/fastapi_routes/v2/reports/routes.py @@ -1,7 +1,9 @@ -"""Native FastAPI routes for v2 economy reports. +"""Native FastAPI routes for v2 reports: economy reports and household reports. Reports are calculated and stored by the Simulation Entrypoint; these routes need no user credential. A user keeps a report through ``/v2/user-reports``. +A submission names its kind; ``V2ReportService`` sends each operation to the +economy or household report service. """ from __future__ import annotations @@ -18,6 +20,7 @@ from policyengine_api.data.v2.settings import V2ConfigurationError from policyengine_api.fastapi_routes.dependencies import ( _default_v2_economy_report_service_factory, + _default_v2_household_report_service_factory, ) from policyengine_api.fastapi_routes.query_parameters import query_dependency from policyengine_api.fastapi_routes.v2.errors import ( @@ -26,9 +29,12 @@ run_v2_operation, v2_error_response, ) +from policyengine_api.fastapi_routes.v2.reports.household_response_models import ( + HouseholdReportItem, +) from policyengine_api.fastapi_routes.v2.reports.request_models import ( - EconomyReportCreateRequest, EconomyReportRunRequest, + ReportCreateRequest, ) from policyengine_api.fastapi_routes.v2.reports.response_models import ( READ_RESPONSES, @@ -59,6 +65,17 @@ EconomyReportNotFoundError, EconomyReportsUnsupportedError, ) +from policyengine_api.services.v2.household_reports.types import ( + HouseholdReportRecord, +) +from policyengine_api.services.v2.household_reports.validators import ( + HouseholdReportNotFoundError, + HouseholdReportsUnsupportedError, + HouseholdReportYearError, +) +from policyengine_api.services.v2.households.validators import HouseholdNotFoundError +from policyengine_api.services.v2.policies.validators import PolicyNotFoundError +from policyengine_api.services.v2.report_kinds.services import V2ReportService from policyengine_api.services.v2.simulation_entrypoint.types import ( SimulationRecordUnreadableError, SimulationRequestRefusedError, @@ -70,7 +87,6 @@ from policyengine_api.fastapi_routes.dependencies import ( NativeRouteDependencies, - V2EconomyReportResourceService, ) from policyengine_api.services.v2.simulation_entrypoint.types import ( EconomyReportRecord, @@ -107,16 +123,35 @@ EconomyReportsUnsupportedError, SimulationRequestRefusedError, EconomyReportNotFoundError, + HouseholdReportsUnsupportedError, + HouseholdReportYearError, + HouseholdReportNotFoundError, + HouseholdNotFoundError, + PolicyNotFoundError, ) REPORT_ERROR_MAPPINGS = ( - V2ErrorMapping((EconomyReportNotFoundError,), 404), + V2ErrorMapping( + ( + EconomyReportNotFoundError, + HouseholdReportNotFoundError, + HouseholdNotFoundError, + PolicyNotFoundError, + ), + 404, + ), V2ErrorMapping( (SimulationRecordUnreadableError,), 502, "The simulation service cannot read back this report's result", ), V2ErrorMapping( - (EconomyReportsUnsupportedError, SimulationRequestRefusedError), 422 + ( + EconomyReportsUnsupportedError, + HouseholdReportsUnsupportedError, + HouseholdReportYearError, + SimulationRequestRefusedError, + ), + 422, ), V2ErrorMapping( (SimulationServiceUnavailableError,), @@ -171,16 +206,29 @@ def logged() -> OperationT: def _service_factory( dependencies: NativeRouteDependencies, -) -> Callable[[], V2EconomyReportResourceService]: - if dependencies.v2_economy_report_service_factory is not None: - return dependencies.v2_economy_report_service_factory - return _default_v2_economy_report_service_factory +) -> Callable[[], V2ReportService]: + economy_factory = ( + dependencies.v2_economy_report_service_factory + or _default_v2_economy_report_service_factory + ) + household_factory = ( + dependencies.v2_household_report_service_factory + or _default_v2_household_report_service_factory + ) + return lambda: V2ReportService( + economy_reports=economy_factory, household_reports=household_factory + ) -def _report_detail(report: EconomyReportRecord) -> ReportDetailResponse: - return ReportDetailResponse( - result=ReportDetailResult(item=ReportItem.from_record(report)) +def _report_detail( + report: EconomyReportRecord | HouseholdReportRecord, +) -> ReportDetailResponse: + item = ( + HouseholdReportItem.from_record(report) + if isinstance(report, HouseholdReportRecord) + else ReportItem.from_record(report) ) + return ReportDetailResponse(result=ReportDetailResult(item=item)) def _report_list(reports: tuple[EconomyReportRecord, ...]) -> ReportListResponse: @@ -210,7 +258,7 @@ def _report_run_list( def build_v2_report_router( dependencies: NativeRouteDependencies, ) -> APIRouter: - """Build the economy report routes without opening a connection.""" + """Build the report routes without opening a connection.""" router = APIRouter(prefix="/v2", responses=REPORT_ERROR_RESPONSES) service_factory = _service_factory(dependencies) @@ -219,17 +267,24 @@ def build_v2_report_router( response_model=ReportDetailResponse, status_code=201, responses=SUBMIT_RESPONSES, - summary="Submit an economy report", + summary="Submit an economy or household report", description=( - "Creates the baseline and reform simulations, the report and its " - "first run in the simulation service, on the country's current " - "catalog version, and starts the run. Read the report to follow it. " - "A country without economy reports, such as a household-only one, " - "is refused with 422 before anything is created." + "Creates the report, its simulations and its first run in the " + "simulation service, on the country's current catalog version, and " + "starts the run. Read the report to follow it. An economy report " + "(kind economy) compares two policies across a region; a country " + "without economy reports, such as a household-only one, is refused " + "with 422 before anything is created. A household report (kind " + "household) calculates a household stored through /v2/households " + "under a baseline and an optional reform, for the household's " + "default year; it needs the country's household calculations, which " + "a household-only country has, and a household and policies of the " + "country (404 otherwise) with a default year (422 otherwise), all " + "checked before anything is created." ), ) def submit_report( - body: EconomyReportCreateRequest, + body: ReportCreateRequest, query: ReportCreateQueryDependency, _size: Annotated[None, Depends(enforce_report_request_size)], ) -> ReportDetailResponse | JSONResponse: diff --git a/policyengine_api/libs/simulation_household_reports.py b/policyengine_api/libs/simulation_household_reports.py index 2c5b2ea32..54c783530 100644 --- a/policyengine_api/libs/simulation_household_reports.py +++ b/policyengine_api/libs/simulation_household_reports.py @@ -210,7 +210,7 @@ def _calculation( detail=_optional_object(failure.detail), ) ), - output=( + result=( None if result is None else HouseholdCalculationOutput( diff --git a/policyengine_api/services/v2/household_reports/database_connectors/__init__.py b/policyengine_api/services/v2/household_reports/database_connectors/__init__.py new file mode 100644 index 000000000..feb12af58 --- /dev/null +++ b/policyengine_api/services/v2/household_reports/database_connectors/__init__.py @@ -0,0 +1 @@ +"""Database selections used by the v2 household report service.""" diff --git a/policyengine_api/services/v2/household_reports/database_connectors/reads.py b/policyengine_api/services/v2/household_reports/database_connectors/reads.py new file mode 100644 index 000000000..8bf3f3336 --- /dev/null +++ b/policyengine_api/services/v2/household_reports/database_connectors/reads.py @@ -0,0 +1,37 @@ +"""Database selections used by the v2 household report service.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from sqlmodel import col, select + +from policyengine_api.data.v2.models import Country, TaxBenefitModelVersion + +if TYPE_CHECKING: + from collections.abc import Collection + from uuid import UUID + + from sqlmodel import Session + + +def read_country(session: Session, country_id: str) -> Country | None: + """Return the country's countries row, or None when it has none.""" + return session.get(Country, country_id) + + +def read_policyengine_versions( + session: Session, catalog_version_ids: Collection[UUID] +) -> dict[UUID, str]: + """Return the PolicyEngine.py version of each of these catalog versions. + + A catalog version the Public API does not have is left out. + """ + if not catalog_version_ids: + return {} + rows = session.exec( + select(TaxBenefitModelVersion.id, TaxBenefitModelVersion.version).where( + col(TaxBenefitModelVersion.id).in_(catalog_version_ids) + ) + ) + return dict(rows.all()) diff --git a/policyengine_api/services/v2/household_reports/database_session.py b/policyengine_api/services/v2/household_reports/database_session.py new file mode 100644 index 000000000..ab7346c97 --- /dev/null +++ b/policyengine_api/services/v2/household_reports/database_session.py @@ -0,0 +1,30 @@ +"""Database session lifetime management for the v2 household report service.""" + +from __future__ import annotations + +from contextlib import contextmanager +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Iterator + + from sqlalchemy.orm import sessionmaker + from sqlmodel import Session + + +class HouseholdReportDatabaseSession: + """Open the read sessions the household report service uses. + + Reports themselves live in the Simulation Entrypoint; the Public API reads + what each country supports and its catalog versions. + """ + + def __init__(self, session_factory: sessionmaker[Session]) -> None: + """Use ``session_factory`` to open every session.""" + self._session_factory = session_factory + + @contextmanager + def read(self) -> Iterator[Session]: + """Yield a session for reads; nothing is committed.""" + with self._session_factory() as session: + yield session diff --git a/policyengine_api/services/v2/household_reports/services.py b/policyengine_api/services/v2/household_reports/services.py new file mode 100644 index 000000000..c7eb6fe62 --- /dev/null +++ b/policyengine_api/services/v2/household_reports/services.py @@ -0,0 +1,172 @@ +"""Application services for v2 household reports. + +The Simulation Entrypoint owns household reports and reads the stored +household and policies from the Public API's tables itself. Before asking +it, this service checks what the Public API decides, through the same +services as ``/v2/households`` and ``/v2/policies``: + +- A country's ``countries`` row must allow household calculations; a + household-only country does. +- The household, and each policy a report names, must be stored for the + country; one of another country, or none, is not found. +- The household must have a default year, the year the report calculates. + +Reports run on the current catalog version: the one published for the +PolicyEngine.py version the Public API runs, as economy reports do. A run's +PolicyEngine.py version is read from the Public API's own catalog. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from policyengine_api.services.v2.household_reports.database_connectors.reads import ( + read_country, + read_policyengine_versions, +) +from policyengine_api.services.v2.household_reports.transformations import ( + household_report_submission, + run_catalog_version_ids, + with_policyengine_version, +) +from policyengine_api.services.v2.household_reports.validators import ( + require_default_year, + require_household_reports_supported, +) + +if TYPE_CHECKING: + from collections.abc import Callable + from uuid import UUID + + from policyengine_api.services.v2.household_reports.database_session import ( + HouseholdReportDatabaseSession, + ) + from policyengine_api.services.v2.household_reports.types import ( + HouseholdReportFields, + HouseholdReportRecord, + HouseholdReportSimulation, + HouseholdReportSubmission, + HouseholdSource, + PolicySource, + ) + from policyengine_api.services.v2.simulation_entrypoint.types import RunTrigger + + +class DeferredHouseholdReportSimulation: + """``HouseholdReportSimulation`` that builds its adapter at its first call. + + The Public API decides some refusals itself, such as a country without + household calculations or an unknown household, and those must not depend + on the simulation client or its settings. A build that raises + ``SimulationServiceUnavailableError`` is tried again at the next call. + """ + + def __init__(self, build: Callable[[], HouseholdReportSimulation]) -> None: + """Call ``build`` at the first call and reuse its adapter afterwards.""" + self._build = build + self._simulation: HouseholdReportSimulation | None = None + + def _resolved(self) -> HouseholdReportSimulation: + if self._simulation is None: + self._simulation = self._build() + return self._simulation + + def submit_report( + self, submission: HouseholdReportSubmission + ) -> HouseholdReportRecord: + """Create a report and its first run in the entrypoint, and start it.""" + return self._resolved().submit_report(submission) + + def get_report(self, report_id: UUID) -> HouseholdReportRecord: + """Return a report.""" + return self._resolved().get_report(report_id) + + def run_report( + self, + report_id: UUID, + *, + policyengine_version: str, + trigger: RunTrigger, + force: bool, + ) -> HouseholdReportRecord: + """Start another run of a report on the catalog version of a version.""" + return self._resolved().run_report( + report_id, + policyengine_version=policyengine_version, + trigger=trigger, + force=force, + ) + + +class V2HouseholdReportService: + """Sequence household report operations between the v2 tables and sim.""" + + def __init__( + self, + database_session: HouseholdReportDatabaseSession, + *, + households: HouseholdSource, + policies: PolicySource, + simulation: HouseholdReportSimulation, + current_policyengine_version: str, + ) -> None: + """Read countries and catalog versions through ``database_session``. + + Stored households and policies are read through ``households`` and + ``policies``, reports through ``simulation``. + ``current_policyengine_version`` selects the catalog version new runs + use. + """ + self._database_session = database_session + self._households = households + self._policies = policies + self._simulation = simulation + self._current_policyengine_version = current_policyengine_version + + def _require_household_reports(self, country_id: str) -> None: + with self._database_session.read() as session: + country = read_country(session, country_id) + require_household_reports_supported(country, country_id=country_id) + + def _with_policyengine_versions( + self, reports: tuple[HouseholdReportRecord, ...] + ) -> tuple[HouseholdReportRecord, ...]: + catalog_version_ids = run_catalog_version_ids(reports) + if not catalog_version_ids: + return reports + with self._database_session.read() as session: + versions = read_policyengine_versions(session, catalog_version_ids) + return tuple(with_policyengine_version(report, versions) for report in reports) + + def submit_report( + self, *, country_id: str, report_input: HouseholdReportFields + ) -> HouseholdReportRecord: + """Create a household report and its first run in the entrypoint. + + Raise ``HouseholdReportsUnsupportedError`` for a country without + household calculations, ``HouseholdNotFoundError`` or + ``PolicyNotFoundError`` for a household or policy the country does not + have, and ``HouseholdReportYearError`` for a household without a + default year, all before the entrypoint is asked; and + ``SimulationRequestRefusedError`` when the entrypoint refuses the + household or a policy. + """ + self._require_household_reports(country_id) + household = self._households.get_household( + country_id=country_id, household_id=report_input.household_id + ) + for choice in (report_input.baseline, report_input.reform): + if choice is not None and choice.policy_id is not None: + self._policies.get_policy( + country_id=country_id, policy_id=choice.policy_id + ) + require_default_year(household.default_year) + report = self._simulation.submit_report( + household_report_submission( + report_input, + country_id=country_id, + policyengine_version=self._current_policyengine_version, + ) + ) + (report,) = self._with_policyengine_versions((report,)) + return report diff --git a/policyengine_api/services/v2/household_reports/transformations.py b/policyengine_api/services/v2/household_reports/transformations.py new file mode 100644 index 000000000..bfb1b8574 --- /dev/null +++ b/policyengine_api/services/v2/household_reports/transformations.py @@ -0,0 +1,61 @@ +"""Conversions between household report inputs and records.""" + +from __future__ import annotations + +from dataclasses import replace +from typing import TYPE_CHECKING + +from policyengine_api.services.v2.household_reports.types import ( + HouseholdReportSubmission, +) + +if TYPE_CHECKING: + from collections.abc import Iterable, Mapping + from uuid import UUID + + from policyengine_api.services.v2.household_reports.types import ( + HouseholdReportFields, + HouseholdReportRecord, + ) + + +def household_report_submission( + report_input: HouseholdReportFields, + *, + country_id: str, + policyengine_version: str, +) -> HouseholdReportSubmission: + """Return what the Simulation Entrypoint is asked to create.""" + return HouseholdReportSubmission( + country_id=country_id, + policyengine_version=policyengine_version, + household_id=report_input.household_id, + baseline=report_input.baseline, + reform=report_input.reform, + ) + + +def run_catalog_version_ids(reports: Iterable[HouseholdReportRecord]) -> set[UUID]: + """Return the catalog versions the reports' runs ran on.""" + return { + report.run.tax_benefit_model_version_id + for report in reports + if report.run is not None + } + + +def with_policyengine_version( + report: HouseholdReportRecord, policyengine_versions: Mapping[UUID, str] +) -> HouseholdReportRecord: + """Name the PolicyEngine.py version of the report's run's catalog version.""" + if report.run is None: + return report + return replace( + report, + run=replace( + report.run, + policyengine_version=policyengine_versions.get( + report.run.tax_benefit_model_version_id + ), + ), + ) diff --git a/policyengine_api/services/v2/household_reports/types.py b/policyengine_api/services/v2/household_reports/types.py index 1d7166070..3cc409d16 100644 --- a/policyengine_api/services/v2/household_reports/types.py +++ b/policyengine_api/services/v2/household_reports/types.py @@ -106,7 +106,7 @@ class HouseholdCalculationRecord: """One calculation of the household under one policy: a simulation run. ``status`` is ``pending``, ``dispatching``, ``running``, ``succeeded`` or - ``failed``; ``attempt`` counts forced recalculations. ``output`` is set + ``failed``; ``attempt`` counts forced recalculations. ``result`` is set once it succeeded and ``error`` once it failed. """ @@ -117,7 +117,7 @@ class HouseholdCalculationRecord: attempt: int trigger: str error: HouseholdCalculationError | None - output: HouseholdCalculationOutput | None + result: HouseholdCalculationOutput | None dispatched_at: datetime | None completed_at: datetime | None created_at: datetime @@ -200,3 +200,28 @@ def run_report( caller's data. """ ... + + +class StoredHousehold(Protocol): + """A household stored through ``/v2/households``, as far as a report needs it.""" + + @property + def default_year(self) -> int | None: + """The year the household's values name, which a report calculates.""" + ... + + +class HouseholdSource(Protocol): + """Read stored households, as ``/v2/households`` does (``V2HouseholdService``).""" + + def get_household(self, *, country_id: str, household_id: UUID) -> StoredHousehold: + """Return the country's household, or raise ``HouseholdNotFoundError``.""" + ... + + +class PolicySource(Protocol): + """Read stored policies, as ``/v2/policies`` does (``V2PolicyService``).""" + + def get_policy(self, *, country_id: str, policy_id: UUID) -> object: + """Return the country's policy, or raise ``PolicyNotFoundError``.""" + ... diff --git a/policyengine_api/services/v2/household_reports/validators.py b/policyengine_api/services/v2/household_reports/validators.py new file mode 100644 index 000000000..22209d547 --- /dev/null +++ b/policyengine_api/services/v2/household_reports/validators.py @@ -0,0 +1,57 @@ +"""Database-independent validation for v2 household report operations.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from policyengine_api.data.v2.models import Country + from policyengine_api.services.v2.household_reports.types import ( + HouseholdReportRecord, + ) + + +class HouseholdReportsUnsupportedError(ValueError): + """The country does not have household calculations. + + Its countries row leaves ``household_enabled`` unset, or it has no row. A + household-only country has them. + """ + + +class HouseholdReportYearError(ValueError): + """The stored household has no default year, the year a report calculates.""" + + +class HouseholdReportNotFoundError(LookupError): + """The simulation service has no such household report in the country.""" + + +def require_household_reports_supported( + country: Country | None, *, country_id: str +) -> None: + """Raise ``HouseholdReportsUnsupportedError`` unless the row allows them.""" + if country is None or not country.household_enabled: + msg = f"Household reports are not available for country {country_id!r}" + raise HouseholdReportsUnsupportedError(msg) + + +def require_default_year(default_year: int | None) -> int: + """Return the household's default year, or raise ``HouseholdReportYearError``.""" + if default_year is None: + msg = "This household has no default year to calculate." + raise HouseholdReportYearError(msg) + return default_year + + +def require_report_in_country( + report: HouseholdReportRecord | None, *, country_id: str, report_id: object +) -> HouseholdReportRecord: + """Return the report, or raise ``HouseholdReportNotFoundError``. + + A report of another country is not found, as for economy reports. + """ + if report is None or report.country_id != country_id: + msg = f"Household report {report_id} was not found in country {country_id!r}" + raise HouseholdReportNotFoundError(msg) + return report diff --git a/policyengine_api/services/v2/report_kinds/__init__.py b/policyengine_api/services/v2/report_kinds/__init__.py new file mode 100644 index 000000000..cb498aa49 --- /dev/null +++ b/policyengine_api/services/v2/report_kinds/__init__.py @@ -0,0 +1 @@ +"""Serve /v2/reports for both kinds of report: economy and household.""" diff --git a/policyengine_api/services/v2/report_kinds/services.py b/policyengine_api/services/v2/report_kinds/services.py new file mode 100644 index 000000000..965b84917 --- /dev/null +++ b/policyengine_api/services/v2/report_kinds/services.py @@ -0,0 +1,112 @@ +"""Serve ``/v2/reports`` for both kinds of report: economy and household. + +A submission names its kind. Each kind's service, and the entrypoint's +routes behind it, handle only reports of that kind. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from policyengine_api.services.v2.household_reports.types import ( + HouseholdReportFields, +) + +if TYPE_CHECKING: + from collections.abc import Callable, Sequence + from uuid import UUID + + from policyengine_api.services.v2.economy_reports.types import EconomyReportFields + from policyengine_api.services.v2.household_reports.types import ( + HouseholdReportRecord, + ) + from policyengine_api.services.v2.report_kinds.types import ( + EconomyReportOperations, + HouseholdReportOperations, + ) + from policyengine_api.services.v2.simulation_entrypoint.types import ( + EconomyReportRecord, + EconomyReportRunRecord, + ) + + +class V2ReportService: + """Send each ``/v2/reports`` operation to the economy or household service. + + Each service is built from its factory the first time it is needed. + """ + + def __init__( + self, + *, + economy_reports: Callable[[], EconomyReportOperations], + household_reports: Callable[[], HouseholdReportOperations], + ) -> None: + """Build each kind's service with its factory, at its first use.""" + self._economy_factory = economy_reports + self._household_factory = household_reports + self._economy: EconomyReportOperations | None = None + self._household: HouseholdReportOperations | None = None + + def _economy_reports(self) -> EconomyReportOperations: + if self._economy is None: + self._economy = self._economy_factory() + return self._economy + + def _household_reports(self) -> HouseholdReportOperations: + if self._household is None: + self._household = self._household_factory() + return self._household + + def submit_report( + self, + *, + country_id: str, + report_input: EconomyReportFields | HouseholdReportFields, + ) -> EconomyReportRecord | HouseholdReportRecord: + """Create a report of the submission's kind and start its first run.""" + if isinstance(report_input, HouseholdReportFields): + return self._household_reports().submit_report( + country_id=country_id, report_input=report_input + ) + return self._economy_reports().submit_report( + country_id=country_id, report_input=report_input + ) + + def get_report(self, *, country_id: str, report_id: UUID) -> EconomyReportRecord: + """Return an economy report of the country.""" + return self._economy_reports().get_report( + country_id=country_id, report_id=report_id + ) + + def get_report_by_legacy_number( + self, *, country_id: str, legacy_report_id: int + ) -> EconomyReportRecord: + """Return the economy report migrated from a v1 report output number.""" + return self._economy_reports().get_report_by_legacy_number( + country_id=country_id, legacy_report_id=legacy_report_id + ) + + def run_report( + self, *, country_id: str, report_id: UUID, force: bool + ) -> EconomyReportRunRecord: + """Run an economy report again on the current catalog version.""" + return self._economy_reports().run_report( + country_id=country_id, report_id=report_id, force=force + ) + + def list_reports( + self, *, country_id: str, report_ids: Sequence[UUID] + ) -> tuple[EconomyReportRecord, ...]: + """Return the country's economy reports among ``report_ids``.""" + return self._economy_reports().list_reports( + country_id=country_id, report_ids=report_ids + ) + + def list_report_runs( + self, *, country_id: str, report_id: UUID + ) -> tuple[EconomyReportRunRecord, ...]: + """Return an economy report's runs, newest first.""" + return self._economy_reports().list_report_runs( + country_id=country_id, report_id=report_id + ) diff --git a/policyengine_api/services/v2/report_kinds/types.py b/policyengine_api/services/v2/report_kinds/types.py new file mode 100644 index 000000000..225377bee --- /dev/null +++ b/policyengine_api/services/v2/report_kinds/types.py @@ -0,0 +1,74 @@ +"""The economy and household report operations that ``/v2/reports`` serves.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Protocol + +if TYPE_CHECKING: + from collections.abc import Sequence + from uuid import UUID + + from policyengine_api.services.v2.economy_reports.types import EconomyReportFields + from policyengine_api.services.v2.household_reports.types import ( + HouseholdReportFields, + HouseholdReportRecord, + ) + from policyengine_api.services.v2.simulation_entrypoint.types import ( + EconomyReportRecord, + EconomyReportRunRecord, + ) + + +class EconomyReportOperations(Protocol): + """Economy report operations (``V2EconomyReportService``).""" + + def submit_report( + self, *, country_id: str, report_input: EconomyReportFields + ) -> EconomyReportRecord: + """Create a report and its first run on the current catalog version.""" + ... + + def get_report(self, *, country_id: str, report_id: UUID) -> EconomyReportRecord: + """Return a report of the country, starting its first run if it has none.""" + ... + + def get_report_by_legacy_number( + self, *, country_id: str, legacy_report_id: int + ) -> EconomyReportRecord: + """Return the report migrated from a v1 report output number.""" + ... + + def run_report( + self, *, country_id: str, report_id: UUID, force: bool + ) -> EconomyReportRunRecord: + """Run a report again on the current catalog version.""" + ... + + def list_reports( + self, *, country_id: str, report_ids: Sequence[UUID] + ) -> tuple[EconomyReportRecord, ...]: + """Return the country's reports among ``report_ids``, in order, once each.""" + ... + + def list_report_runs( + self, *, country_id: str, report_id: UUID + ) -> tuple[EconomyReportRunRecord, ...]: + """Return a report's runs, newest first.""" + ... + + +class HouseholdReportOperations(Protocol): + """Household report operations (``V2HouseholdReportService``).""" + + def submit_report( + self, *, country_id: str, report_input: HouseholdReportFields + ) -> HouseholdReportRecord: + """Create a household report and its first run on the current version. + + Raise ``HouseholdReportsUnsupportedError`` for a country without + household calculations, ``HouseholdNotFoundError`` or + ``PolicyNotFoundError`` for a household or policy the country lacks, + ``HouseholdReportYearError`` for a household without a default year, + and ``SimulationRequestRefusedError`` when the entrypoint refuses it. + """ + ... diff --git a/pyproject.toml b/pyproject.toml index dc597da3a..81e04402f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -222,6 +222,8 @@ banned-module-level-imports = [ "policyengine_api.services.v2.countries.services", "policyengine_api.services.v2.economy_reports.database_session", "policyengine_api.services.v2.economy_reports.services", + "policyengine_api.services.v2.household_reports.database_session", + "policyengine_api.services.v2.household_reports.services", "policyengine_api.services.v2.households.database_session", "policyengine_api.services.v2.households.services", "policyengine_api.services.v2.legacy_refs.database_session", @@ -262,6 +264,7 @@ runtime-evaluated-base-classes = [ "policyengine_api.fastapi_routes.v2.metadata.response_models.MetadataResourceSuccessResponse", "policyengine_api.fastapi_routes.v2.policies.response_models.PolicySuccessResponse", "policyengine_api.fastapi_routes.v2.policies.response_models.StrictPolicyAPIModel", + "policyengine_api.fastapi_routes.v2.reports.household_response_models.StrictHouseholdReportAPIModel", "policyengine_api.fastapi_routes.v2.reports.response_models.ReportSuccessResponse", "policyengine_api.fastapi_routes.v2.reports.response_models.StrictReportAPIModel", "policyengine_api.fastapi_routes.v2.user_households.response_models.StrictUserHouseholdAPIModel", diff --git a/tests/unit/libs/test_simulation_household_reports.py b/tests/unit/libs/test_simulation_household_reports.py index 67a514f2d..25deabaad 100644 --- a/tests/unit/libs/test_simulation_household_reports.py +++ b/tests/unit/libs/test_simulation_household_reports.py @@ -136,7 +136,7 @@ def _calculation_record( attempt=1, trigger="initial", error=None, - output=HouseholdCalculationOutput( + result=HouseholdCalculationOutput( household=CALCULATED_HOUSEHOLD, warnings=("employment_income was capped",), spm_config=SPM_CONFIG, @@ -242,10 +242,10 @@ def test_a_calculated_household_keeps_the_key_order_it_was_sent_in() -> None: report = entrypoint.adapter().get_report(REPORT_ID) assert report.run is not None - output = report.run.baseline.output - assert output is not None - assert list(output.household) == ["people", "households"] - assert list(output.household["people"]["you"]) == ["age", "employment_income"] + calculated = report.run.baseline.result + assert calculated is not None + assert list(calculated.household) == ["people", "households"] + assert list(calculated.household["people"]["you"]) == ["age", "employment_income"] def test_a_report_before_its_first_run_reads_as_not_run() -> None: @@ -282,7 +282,7 @@ def test_a_report_before_its_first_run_reads_as_not_run() -> None: ("running", ReportStatus.RUNNING), ], ) -def test_an_unfinished_run_reads_without_outputs( +def test_an_unfinished_run_reads_without_results( entrypoint_status: str, report_status: ReportStatus ) -> None: entrypoint = SimulationEntrypointStub( @@ -312,7 +312,7 @@ def test_an_unfinished_run_reads_without_outputs( assert report.run is not None assert report.run.status is report_status assert report.run.baseline.status == entrypoint_status - assert report.run.baseline.output is None + assert report.run.baseline.result is None def test_a_failed_calculation_reads_with_its_error() -> None: diff --git a/tests/unit/v2/household_report_support.py b/tests/unit/v2/household_report_support.py new file mode 100644 index 000000000..ba0d5d596 --- /dev/null +++ b/tests/unit/v2/household_report_support.py @@ -0,0 +1,379 @@ +"""A fake Simulation Entrypoint, stored households and household report records. + +The household report service reads countries rows and catalog versions from an +SQLite v2 schema, and stored households and policies through fakes of the +``/v2/households`` and ``/v2/policies`` services. +""" + +from __future__ import annotations + +from dataclasses import dataclass, replace +from datetime import UTC, datetime +from typing import TYPE_CHECKING +from uuid import NAMESPACE_URL, UUID, uuid5 + +from policyengine_api.data.v2.models import ( + Country, + TaxBenefitModel, + TaxBenefitModelVersion, +) +from policyengine_api.services.v2.household_reports.database_session import ( + HouseholdReportDatabaseSession, +) +from policyengine_api.services.v2.household_reports.services import ( + V2HouseholdReportService, +) +from policyengine_api.services.v2.household_reports.types import ( + HouseholdCalculationOutput, + HouseholdCalculationRecord, + HouseholdReportRecord, + HouseholdReportRunRecord, +) +from policyengine_api.services.v2.households.validators import HouseholdNotFoundError +from policyengine_api.services.v2.policies.validators import PolicyNotFoundError +from policyengine_api.services.v2.simulation_entrypoint.types import ( + ReportStatus, + RunTrigger, + SimulationRecordNotFoundError, +) +from tests.unit.v2.economy_report_support import ( + CURRENT_POLICYENGINE_VERSION, + countries_store, +) + +if TYPE_CHECKING: + from sqlalchemy.orm import sessionmaker + from sqlmodel import Session + + from policyengine_api.services.v2.household_reports.types import ( + HouseholdReportSubmission, + ) + +HOUSEHOLD_REPORT_ID = UUID("00000000-0000-4000-8000-000000000301") +HOUSEHOLD_ID = UUID("00000000-0000-4000-8000-000000000302") +HOUSEHOLD_REPORT_RUN_ID = UUID("00000000-0000-4000-8000-000000000303") +BASELINE_CALCULATION_ID = UUID("00000000-0000-4000-8000-000000000304") +REFORM_CALCULATION_ID = UUID("00000000-0000-4000-8000-000000000305") +HOUSEHOLD_CATALOG_VERSION_ID = UUID("00000000-0000-4000-8000-000000000306") +OLDER_CATALOG_VERSION_ID = UUID("00000000-0000-4000-8000-000000000307") +UNKNOWN_CATALOG_VERSION_ID = UUID("00000000-0000-4000-8000-000000000308") +HOUSEHOLD_REFORM_POLICY_ID = UUID("00000000-0000-4000-8000-000000000309") +# A household of the household-only test country. +HOUSEHOLD_ONLY_HOUSEHOLD_ID = UUID("00000000-0000-4000-8000-000000000310") +OLDER_POLICYENGINE_VERSION = "6.5.0" +CALCULATED_AT = datetime(2026, 10, 29, 12, tzinfo=UTC) +CALCULATED_HOUSEHOLD = { + "people": {"you": {"age": {"2026": 40}, "household_net_income": {"2026": 41250}}}, + "households": {"your household": {"members": ["you"]}}, +} + + +@dataclass(frozen=True) +class StoredHouseholdRow: + """The part of a stored household the report service reads.""" + + id: UUID + country_id: str + default_year: int | None + + +class FakeHouseholds: + """The ``/v2/households`` service, holding ``households``.""" + + def __init__(self, *households: StoredHouseholdRow) -> None: + self.households = {household.id: household for household in households} + + def get_household( + self, *, country_id: str, household_id: UUID + ) -> StoredHouseholdRow: + household = self.households.get(household_id) + if household is None or household.country_id != country_id: + msg = f"Household {household_id} was not found" + raise HouseholdNotFoundError(msg) + return household + + +class FakePolicies: + """The ``/v2/policies`` service, holding policies by ID and country.""" + + def __init__(self, policies: dict[UUID, str]) -> None: + self.policies = policies + + def get_policy(self, *, country_id: str, policy_id: UUID) -> object: + if self.policies.get(policy_id) != country_id: + msg = f"Policy {policy_id} was not found" + raise PolicyNotFoundError(msg) + return policy_id + + +def stored_household( + *, country_id: str = "us", default_year: int | None = 2026 +) -> StoredHouseholdRow: + return StoredHouseholdRow( + id=HOUSEHOLD_ID, country_id=country_id, default_year=default_year + ) + + +def calculation_record( + calculation_id: UUID, + simulation_id: UUID, + policy_id: UUID | None, + *, + status: str = "pending", + trigger: str = "initial", +) -> HouseholdCalculationRecord: + succeeded = status == "succeeded" + return HouseholdCalculationRecord( + id=calculation_id, + simulation_id=simulation_id, + policy_id=policy_id, + status=status, + attempt=1, + trigger=trigger, + error=None, + result=( + HouseholdCalculationOutput( + household=CALCULATED_HOUSEHOLD, + warnings=(), + spm_config=None, + spm_provenance=None, + ) + if succeeded + else None + ), + dispatched_at=None, + completed_at=CALCULATED_AT if succeeded else None, + created_at=CALCULATED_AT, + ) + + +def household_simulation_ids(report_id: UUID) -> tuple[UUID, UUID]: + """Return a household report's baseline and reform simulation IDs.""" + return ( + uuid5(NAMESPACE_URL, f"{report_id}/household-baseline"), + uuid5(NAMESPACE_URL, f"{report_id}/household-reform"), + ) + + +def household_report_record( + *, + report_id: UUID = HOUSEHOLD_REPORT_ID, + country_id: str = "us", + status: ReportStatus | None = ReportStatus.PENDING, + with_reform: bool = True, + trigger: str = "initial", +) -> HouseholdReportRecord: + """Build a household report; ``status=None`` means before its first run. + + Each calculation's status is the run's, with a result once it succeeded. + """ + baseline_id, reform_id = household_simulation_ids(report_id) + run = None + if status is not None: + calculation_status = ( + "running" if status is ReportStatus.RUNNING else status.value + ) + run = HouseholdReportRunRecord( + id=HOUSEHOLD_REPORT_RUN_ID, + report_id=report_id, + trigger=trigger, + tax_benefit_model_version_id=HOUSEHOLD_CATALOG_VERSION_ID, + policyengine_version=None, + year=2026, + status=status, + baseline=calculation_record( + BASELINE_CALCULATION_ID, + baseline_id, + None, + status=calculation_status, + trigger=trigger, + ), + reform=( + calculation_record( + REFORM_CALCULATION_ID, + reform_id, + HOUSEHOLD_REFORM_POLICY_ID, + status=calculation_status, + trigger=trigger, + ) + if with_reform + else None + ), + ) + return HouseholdReportRecord( + id=report_id, + country_id=country_id, + year=2026, + household_id=None if run is None else HOUSEHOLD_ID, + baseline_simulation_id=baseline_id, + reform_simulation_id=reform_id if with_reform else None, + status=ReportStatus.NOT_RUN if run is None else run.status, + run=run, + ) + + +class FakeHouseholdReportSimulation: + """An in-memory Simulation Entrypoint for household reports. + + ``reports`` holds what it knows and ``calls`` records every call; ``error`` + is raised by every call while set. A submission or a run answers with + ``next_status`` and ``next_catalog_version_id``. + """ + + def __init__(self, *reports: HouseholdReportRecord) -> None: + self.reports = {report.id: report for report in reports} + self.calls: list[tuple[str, object]] = [] + self.error: Exception | None = None + self.next_status = ReportStatus.PENDING + self.next_catalog_version_id = HOUSEHOLD_CATALOG_VERSION_ID + + def _record(self, name: str, argument: object) -> None: + self.calls.append((name, argument)) + if self.error is not None: + raise self.error + + def _on_next_catalog_version( + self, report: HouseholdReportRecord + ) -> HouseholdReportRecord: + if report.run is None: + return report + return replace( + report, + run=replace( + report.run, tax_benefit_model_version_id=self.next_catalog_version_id + ), + ) + + def submit_report( + self, submission: HouseholdReportSubmission + ) -> HouseholdReportRecord: + self._record("submit_report", submission) + report = self._on_next_catalog_version( + household_report_record( + country_id=submission.country_id, + status=self.next_status, + with_reform=submission.reform is not None, + ) + ) + self.reports[report.id] = report + return report + + def get_report(self, report_id: UUID) -> HouseholdReportRecord: + self._record("get_report", report_id) + if report_id not in self.reports: + msg = "No such household report." + raise SimulationRecordNotFoundError(msg) + return self.reports[report_id] + + def run_report( + self, + report_id: UUID, + *, + policyengine_version: str, + trigger: RunTrigger, + force: bool, + ) -> HouseholdReportRecord: + self._record( + "run_report", + { + "report_id": report_id, + "policyengine_version": policyengine_version, + "trigger": trigger, + "force": force, + }, + ) + stored = self.reports.get(report_id) + if stored is None: + msg = "No such household report." + raise SimulationRecordNotFoundError(msg) + report = self._on_next_catalog_version( + household_report_record( + report_id=report_id, + country_id=stored.country_id, + status=self.next_status, + with_reform=stored.reform_simulation_id is not None, + trigger=trigger.value, + ) + ) + self.reports[report_id] = report + return report + + +def household_report_store() -> sessionmaker[Session]: + """Build the countries rows and two US catalog versions. + + Besides the US and UK, ``zz`` has only household calculations and ``xx`` + has neither household calculations nor economy reports. The catalog + versions are the current one and an older one. + """ + # New row objects: a row object another store committed is not inserted again. + sessions = countries_store( + Country(id="zz", household_enabled=True, society_wide_enabled=False), + Country(id="xx", household_enabled=False, society_wide_enabled=False), + ) + with sessions() as session: + model = TaxBenefitModel(name="policyengine-us") + session.add(model) + session.flush() + session.add_all( + TaxBenefitModelVersion( + id=version_id, + model_id=model.id, + version=version, + current_law_id=1, + metadata_time_periods=[2026], + ) + for version_id, version in ( + (HOUSEHOLD_CATALOG_VERSION_ID, CURRENT_POLICYENGINE_VERSION), + (OLDER_CATALOG_VERSION_ID, OLDER_POLICYENGINE_VERSION), + ) + ) + session.commit() + return sessions + + +def set_country_support( + sessions: sessionmaker[Session], + country_id: str, + *, + household_enabled: bool, + society_wide_enabled: bool, +) -> None: + """Change what a stored country supports, as for a household-only country.""" + with sessions() as session: + country = session.get(Country, country_id) + assert country is not None + country.household_enabled = household_enabled + country.society_wide_enabled = society_wide_enabled + session.commit() + + +def household_report_service( + simulation: FakeHouseholdReportSimulation, + *, + households: FakeHouseholds | None = None, + policies: FakePolicies | None = None, + sessions: sessionmaker[Session] | None = None, +) -> V2HouseholdReportService: + return V2HouseholdReportService( + HouseholdReportDatabaseSession( + household_report_store() if sessions is None else sessions + ), + households=( + FakeHouseholds( + stored_household(), + replace( + stored_household(country_id="zz"), id=HOUSEHOLD_ONLY_HOUSEHOLD_ID + ), + ) + if households is None + else households + ), + policies=( + FakePolicies({HOUSEHOLD_REFORM_POLICY_ID: "us"}) + if policies is None + else policies + ), + simulation=simulation, + current_policyengine_version=CURRENT_POLICYENGINE_VERSION, + ) diff --git a/tests/unit/v2/test_economy_report_routes.py b/tests/unit/v2/test_economy_report_routes.py index 1d5e7ed27..55b3a6ae2 100644 --- a/tests/unit/v2/test_economy_report_routes.py +++ b/tests/unit/v2/test_economy_report_routes.py @@ -342,7 +342,9 @@ def test_openapi_publishes_the_submit_contract() -> None: } assert set(operation["responses"]) >= {"201", "400", "422", "503"} request_schema = operation["requestBody"]["content"]["application/json"]["schema"] - assert request_schema["$ref"].endswith("/EconomyReportCreateRequest") + assert request_schema["discriminator"]["mapping"]["economy"].endswith( + "/EconomyReportCreateRequest" + ) body = schema["components"]["schemas"]["EconomyReportCreateRequest"] assert set(body["required"]) == { "country_id", @@ -698,7 +700,8 @@ def test_a_report_whose_result_cannot_be_read_back_is_a_bad_gateway() -> None: @pytest.mark.parametrize( ("method", "path", "statuses"), [ - ("post", "/v2/reports", {"201", "400", "413", "422", "500", "503"}), + # 404: a household report's household or policy is not the country's. + ("post", "/v2/reports", {"201", "400", "404", "413", "422", "500", "503"}), ("get", "/v2/reports", {"200", "422", "500", "503"}), ( "get", diff --git a/tests/unit/v2/test_household_report_routes.py b/tests/unit/v2/test_household_report_routes.py new file mode 100644 index 000000000..8846e1aa0 --- /dev/null +++ b/tests/unit/v2/test_household_report_routes.py @@ -0,0 +1,353 @@ +"""Route coverage for household reports at the native v2 report routes. + +The routes run on the real economy and household report services, with SQLite +countries and catalog rows, fake stored households and policies, and fake +Simulation Entrypoints. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +from fastapi.testclient import TestClient +from flask import Flask, jsonify + +from policyengine_api.asgi_factory import create_asgi_app +from policyengine_api.fastapi_routes.dependencies import NativeRouteDependencies +from policyengine_api.migration_flags import ( + RouteImplementation, + RouteImplementationSettings, +) +from policyengine_api.services.v2.simulation_entrypoint.types import ( + ReportStatus, + SimulationRequestRefusedError, + SimulationServiceUnavailableError, +) +from tests.unit.v2.economy_report_support import ( + CURRENT_POLICYENGINE_VERSION, + FakeEconomyReportSimulation, + economy_report_service, +) +from tests.unit.v2.household_report_support import ( + BASELINE_CALCULATION_ID, + CALCULATED_AT, + CALCULATED_HOUSEHOLD, + HOUSEHOLD_CATALOG_VERSION_ID, + HOUSEHOLD_ID, + HOUSEHOLD_REFORM_POLICY_ID, + HOUSEHOLD_REPORT_ID, + HOUSEHOLD_REPORT_RUN_ID, + REFORM_CALCULATION_ID, + FakeHouseholdReportSimulation, + FakeHouseholds, + household_report_service, + household_report_store, + household_simulation_ids, + set_country_support, + stored_household, +) + +if TYPE_CHECKING: + from uuid import UUID + + from sqlalchemy.orm import sessionmaker + from sqlmodel import Session + +SUBMISSION = { + "country_id": "us", + "kind": "household", + "household_id": str(HOUSEHOLD_ID), + "baseline": {}, + "reform": {"policy_id": str(HOUSEHOLD_REFORM_POLICY_ID)}, +} +CALCULATED_AT_TEXT = CALCULATED_AT.isoformat().replace("+00:00", "Z") +BASELINE_SIMULATION_ID, REFORM_SIMULATION_ID = household_simulation_ids( + HOUSEHOLD_REPORT_ID +) + + +def _client( + simulation: FakeHouseholdReportSimulation, + *, + economy_simulation: FakeEconomyReportSimulation | None = None, + households: FakeHouseholds | None = None, + sessions: sessionmaker[Session] | None = None, +) -> TestClient: + flask_app = Flask(__name__) + + @flask_app.route("/", methods=["GET", "POST"]) + def fallback(resource: str): + return jsonify({"source": "flask", "resource": resource}) + + sessions = household_report_store() if sessions is None else sessions + economy_service = economy_report_service( + economy_simulation or FakeEconomyReportSimulation(), sessions + ) + household_service = household_report_service( + simulation, households=households, sessions=sessions + ) + dependencies = NativeRouteDependencies( + readiness_probe=lambda: True, + gateway_client_factory=lambda: None, + metadata_reader_factory=lambda: None, + specification_provider=dict, + v2_economy_report_service_factory=lambda: economy_service, + v2_household_report_service_factory=lambda: household_service, + ) + settings = RouteImplementationSettings( + health=RouteImplementation.FLASK_FALLBACK, + specification=RouteImplementation.FLASK_FALLBACK, + metadata=RouteImplementation.FLASK_FALLBACK, + ) + return TestClient( + create_asgi_app(flask_app, dependencies=dependencies, route_settings=settings), + raise_server_exceptions=False, + ) + + +def _calculation_item(calculation_id: UUID, simulation_id: UUID, policy_id) -> dict: + return { + "id": str(calculation_id), + "simulation_id": str(simulation_id), + "policy_id": None if policy_id is None else str(policy_id), + "status": "pending", + "attempt": 1, + "trigger": "initial", + "error": None, + "result": None, + "dispatched_at": None, + "completed_at": None, + "created_at": CALCULATED_AT_TEXT, + } + + +PENDING_REPORT_ITEM = { + "id": str(HOUSEHOLD_REPORT_ID), + "country_id": "us", + "kind": "household", + "year": 2026, + "household_id": str(HOUSEHOLD_ID), + "baseline_simulation_id": str(BASELINE_SIMULATION_ID), + "reform_simulation_id": str(REFORM_SIMULATION_ID), + "status": "pending", + "run": { + "kind": "household", + "id": str(HOUSEHOLD_REPORT_RUN_ID), + "report_id": str(HOUSEHOLD_REPORT_ID), + "trigger": "initial", + "tax_benefit_model_version_id": str(HOUSEHOLD_CATALOG_VERSION_ID), + "policyengine_version": CURRENT_POLICYENGINE_VERSION, + "year": 2026, + "status": "pending", + "baseline": _calculation_item( + BASELINE_CALCULATION_ID, BASELINE_SIMULATION_ID, None + ), + "reform": _calculation_item( + REFORM_CALCULATION_ID, REFORM_SIMULATION_ID, HOUSEHOLD_REFORM_POLICY_ID + ), + }, +} + + +def test_a_household_report_is_submitted_and_answered_with_its_first_run() -> None: + simulation = FakeHouseholdReportSimulation() + + response = _client(simulation).post("/v2/reports?country_id=us", json=SUBMISSION) + + assert response.status_code == 201, response.text + assert response.json() == { + "status": "ok", + "message": None, + "result": {"item": PENDING_REPORT_ITEM}, + } + assert [name for name, _argument in simulation.calls] == ["submit_report"] + + +def test_a_succeeded_household_report_carries_each_calculated_household() -> None: + simulation = FakeHouseholdReportSimulation() + simulation.next_status = ReportStatus.SUCCEEDED + + response = _client(simulation).post("/v2/reports?country_id=us", json=SUBMISSION) + + run = response.json()["result"]["item"]["run"] + assert run["status"] == "succeeded" + assert run["baseline"]["result"] == { + "household": CALCULATED_HOUSEHOLD, + "warnings": [], + "spm_config": None, + "spm_provenance": None, + } + assert run["reform"]["result"]["household"] == CALCULATED_HOUSEHOLD + + +def test_a_baseline_only_household_report_has_no_reform() -> None: + simulation = FakeHouseholdReportSimulation() + + response = _client(simulation).post( + "/v2/reports?country_id=us", json={**SUBMISSION, "reform": None} + ) + + assert response.status_code == 201, response.text + item = response.json()["result"]["item"] + assert item["reform_simulation_id"] is None + assert item["run"]["reform"] is None + + +def test_an_economy_submission_still_reaches_the_economy_service() -> None: + simulation = FakeHouseholdReportSimulation() + economy_simulation = FakeEconomyReportSimulation() + + response = _client(simulation, economy_simulation=economy_simulation).post( + "/v2/reports?country_id=us", + json={ + "country_id": "us", + "kind": "economy", + "year": 2026, + "region_code": "state/ca", + "baseline": {}, + "reform": {}, + }, + ) + + assert response.status_code == 201, response.text + assert response.json()["result"]["item"]["kind"] == "economy" + assert simulation.calls == [] + assert [name for name, _argument in economy_simulation.calls] == ["submit_report"] + + +def test_a_household_only_country_accepts_household_reports() -> None: + sessions = household_report_store() + set_country_support( + sessions, "us", household_enabled=True, society_wide_enabled=False + ) + simulation = FakeHouseholdReportSimulation() + + response = _client(simulation, sessions=sessions).post( + "/v2/reports?country_id=us", json=SUBMISSION + ) + + assert response.status_code == 201, response.text + assert [name for name, _argument in simulation.calls] == ["submit_report"] + + +def test_a_country_without_household_calculations_is_refused_with_422() -> None: + sessions = household_report_store() + set_country_support( + sessions, "us", household_enabled=False, society_wide_enabled=False + ) + simulation = FakeHouseholdReportSimulation() + + response = _client(simulation, sessions=sessions).post( + "/v2/reports?country_id=us", json=SUBMISSION + ) + + assert response.status_code == 422, response.text + assert response.json()["message"] == ( + "Household reports are not available for country 'us'" + ) + assert simulation.calls == [] + + +@pytest.mark.parametrize( + ("changes", "households"), + [ + ({"household_id": "00000000-0000-4000-8000-0000000003ff"}, None), + ({"reform": {"policy_id": "00000000-0000-4000-8000-0000000003ff"}}, None), + ({}, FakeHouseholds(stored_household(country_id="uk"))), + ], + ids=["unknown-household", "unknown-policy", "other-countrys-household"], +) +def test_a_household_or_policy_the_country_lacks_is_not_found( + changes: dict, households: FakeHouseholds | None +) -> None: + simulation = FakeHouseholdReportSimulation() + + response = _client(simulation, households=households).post( + "/v2/reports?country_id=us", json={**SUBMISSION, **changes} + ) + + assert response.status_code == 404, response.text + assert simulation.calls == [] + + +def test_a_household_without_a_default_year_is_refused_with_422() -> None: + simulation = FakeHouseholdReportSimulation() + + response = _client( + simulation, households=FakeHouseholds(stored_household(default_year=None)) + ).post("/v2/reports?country_id=us", json=SUBMISSION) + + assert response.status_code == 422, response.text + assert "default year" in response.json()["message"] + assert simulation.calls == [] + + +def test_the_entrypoint_refusal_of_the_callers_data_is_422_with_its_reason() -> None: + simulation = FakeHouseholdReportSimulation() + simulation.error = SimulationRequestRefusedError( + "The policy changes gov.x, which the catalog version lacks." + ) + + response = _client(simulation).post("/v2/reports?country_id=us", json=SUBMISSION) + + assert response.status_code == 422, response.text + assert response.json()["message"] == ( + "The policy changes gov.x, which the catalog version lacks." + ) + + +def test_an_unavailable_entrypoint_is_503() -> None: + simulation = FakeHouseholdReportSimulation() + simulation.error = SimulationServiceUnavailableError("it answered HTTP 400") + + response = _client(simulation).post("/v2/reports?country_id=us", json=SUBMISSION) + + assert response.status_code == 503, response.text + assert response.json()["message"] == "The simulation service is unavailable" + + +@pytest.mark.parametrize( + "changes", + [ + {"year": 2026}, + {"options": {"axes": []}}, + {"baseline": {"dynamic_id": str(HOUSEHOLD_ID)}}, + {"household_id": "not-a-uuid"}, + ], + ids=["year", "options", "dynamic", "household-id"], +) +def test_fields_outside_the_household_report_contract_are_refused( + changes: dict, +) -> None: + simulation = FakeHouseholdReportSimulation() + + response = _client(simulation).post( + "/v2/reports?country_id=us", json={**SUBMISSION, **changes} + ) + + assert response.status_code == 422, response.text + assert simulation.calls == [] + + +def test_the_body_and_query_must_name_the_same_country() -> None: + simulation = FakeHouseholdReportSimulation() + + response = _client(simulation).post("/v2/reports?country_id=uk", json=SUBMISSION) + + assert response.status_code == 400, response.text + assert simulation.calls == [] + + +def test_openapi_describes_both_kinds_of_report() -> None: + schema = _client(FakeHouseholdReportSimulation()).get("/v2/openapi.json").json() + + body_schema = schema["paths"]["/v2/reports"]["post"]["requestBody"]["content"][ + "application/json" + ]["schema"] + assert body_schema["discriminator"]["propertyName"] == "kind" + assert set(body_schema["discriminator"]["mapping"]) == {"economy", "household"} + item_schema = schema["components"]["schemas"]["ReportDetailResult"]["properties"][ + "item" + ] + assert set(item_schema["discriminator"]["mapping"]) == {"economy", "household"} diff --git a/tests/unit/v2/test_household_report_service.py b/tests/unit/v2/test_household_report_service.py new file mode 100644 index 000000000..4aecf4d82 --- /dev/null +++ b/tests/unit/v2/test_household_report_service.py @@ -0,0 +1,198 @@ +"""The household report service. + +It runs with SQLite countries and catalog rows, fake stored households and +policies, and a fake Simulation Entrypoint. +""" + +from __future__ import annotations + +import pytest + +from policyengine_api.services.v2.household_reports.types import ( + HouseholdPolicyChoice, + HouseholdReportFields, + HouseholdReportSubmission, +) +from policyengine_api.services.v2.household_reports.validators import ( + HouseholdReportsUnsupportedError, + HouseholdReportYearError, +) +from policyengine_api.services.v2.households.validators import HouseholdNotFoundError +from policyengine_api.services.v2.policies.validators import PolicyNotFoundError +from policyengine_api.services.v2.simulation_entrypoint.types import ( + ReportStatus, + SimulationRequestRefusedError, +) +from tests.unit.v2.economy_report_support import CURRENT_POLICYENGINE_VERSION +from tests.unit.v2.household_report_support import ( + HOUSEHOLD_ID, + HOUSEHOLD_ONLY_HOUSEHOLD_ID, + HOUSEHOLD_REFORM_POLICY_ID, + OLDER_CATALOG_VERSION_ID, + OLDER_POLICYENGINE_VERSION, + UNKNOWN_CATALOG_VERSION_ID, + FakeHouseholdReportSimulation, + FakeHouseholds, + household_report_service, + stored_household, +) + + +def _fields(*, reform_policy_id=HOUSEHOLD_REFORM_POLICY_ID, household_id=HOUSEHOLD_ID): + return HouseholdReportFields( + kind="household", + household_id=household_id, + baseline=HouseholdPolicyChoice(), + reform=( + None + if reform_policy_id is None + else HouseholdPolicyChoice(policy_id=reform_policy_id) + ), + ) + + +def test_a_report_is_submitted_by_ids_on_the_current_version() -> None: + simulation = FakeHouseholdReportSimulation() + + report = household_report_service(simulation).submit_report( + country_id="us", report_input=_fields() + ) + + assert simulation.calls == [ + ( + "submit_report", + HouseholdReportSubmission( + country_id="us", + policyengine_version=CURRENT_POLICYENGINE_VERSION, + household_id=HOUSEHOLD_ID, + baseline=HouseholdPolicyChoice(), + reform=HouseholdPolicyChoice(policy_id=HOUSEHOLD_REFORM_POLICY_ID), + ), + ) + ] + assert report.status is ReportStatus.PENDING + assert report.run is not None + assert report.run.policyengine_version == CURRENT_POLICYENGINE_VERSION + + +def test_a_run_names_the_policyengine_version_of_its_catalog_version() -> None: + simulation = FakeHouseholdReportSimulation() + simulation.next_catalog_version_id = OLDER_CATALOG_VERSION_ID + + report = household_report_service(simulation).submit_report( + country_id="us", report_input=_fields() + ) + + assert report.run is not None + assert report.run.policyengine_version == OLDER_POLICYENGINE_VERSION + + +def test_a_run_on_a_catalog_version_the_public_api_lacks_names_no_version() -> None: + simulation = FakeHouseholdReportSimulation() + simulation.next_catalog_version_id = UNKNOWN_CATALOG_VERSION_ID + + report = household_report_service(simulation).submit_report( + country_id="us", report_input=_fields() + ) + + assert report.run is not None + assert report.run.policyengine_version is None + + +def test_a_baseline_only_report_sends_no_reform() -> None: + simulation = FakeHouseholdReportSimulation() + + report = household_report_service(simulation).submit_report( + country_id="us", report_input=_fields(reform_policy_id=None) + ) + + ((_name, submission),) = simulation.calls + assert isinstance(submission, HouseholdReportSubmission) + assert submission.reform is None + assert report.reform_simulation_id is None + + +def test_a_household_only_country_has_household_reports() -> None: + simulation = FakeHouseholdReportSimulation() + + report = household_report_service(simulation).submit_report( + country_id="zz", + report_input=_fields( + reform_policy_id=None, household_id=HOUSEHOLD_ONLY_HOUSEHOLD_ID + ), + ) + + assert report.country_id == "zz" + assert [name for name, _argument in simulation.calls] == ["submit_report"] + + +def test_a_country_without_household_calculations_is_refused_first() -> None: + simulation = FakeHouseholdReportSimulation() + + with pytest.raises(HouseholdReportsUnsupportedError, match="'xx'"): + household_report_service(simulation).submit_report( + country_id="xx", report_input=_fields() + ) + + assert simulation.calls == [] + + +def test_an_unknown_household_is_not_found_before_the_entrypoint() -> None: + simulation = FakeHouseholdReportSimulation() + + with pytest.raises(HouseholdNotFoundError): + household_report_service(simulation, households=FakeHouseholds()).submit_report( + country_id="us", report_input=_fields() + ) + + assert simulation.calls == [] + + +def test_another_countrys_household_is_not_found() -> None: + simulation = FakeHouseholdReportSimulation() + + with pytest.raises(HouseholdNotFoundError): + household_report_service(simulation).submit_report( + country_id="uk", report_input=_fields(reform_policy_id=None) + ) + + assert simulation.calls == [] + + +def test_an_unknown_policy_is_not_found_before_the_entrypoint() -> None: + simulation = FakeHouseholdReportSimulation() + + with pytest.raises(PolicyNotFoundError): + household_report_service(simulation).submit_report( + country_id="us", + report_input=HouseholdReportFields( + kind="household", + household_id=HOUSEHOLD_ID, + baseline=HouseholdPolicyChoice(policy_id=HOUSEHOLD_ID), + reform=None, + ), + ) + + assert simulation.calls == [] + + +def test_a_household_without_a_default_year_is_refused_before_the_entrypoint() -> None: + simulation = FakeHouseholdReportSimulation() + households = FakeHouseholds(stored_household(default_year=None)) + + with pytest.raises(HouseholdReportYearError): + household_report_service(simulation, households=households).submit_report( + country_id="us", report_input=_fields() + ) + + assert simulation.calls == [] + + +def test_the_entrypoint_refusal_reaches_the_caller_unchanged() -> None: + simulation = FakeHouseholdReportSimulation() + simulation.error = SimulationRequestRefusedError("gov.x is not in this catalog") + + with pytest.raises(SimulationRequestRefusedError, match=r"gov\.x"): + household_report_service(simulation).submit_report( + country_id="us", report_input=_fields() + ) From 5fcc26db0b173fcda6579a0ecc2c14c95b473228 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 29 Oct 2026 22:20:34 +0400 Subject: [PATCH 03/21] Read and list household reports through /v2/reports, beside economy reports A report read by its ID is looked for among the country's economy reports and then among its household reports; a report of neither kind is a 404 that names no kind. A listing holds both kinds in the order asked, household reports without their calculations. A failure other than "not found", such as an unavailable simulation service, is never taken for a missing report. Co-Authored-By: Claude Opus 5.5 --- .../v2/reports/response_models.py | 14 ++- .../fastapi_routes/v2/reports/routes.py | 41 ++++--- .../services/v2/household_reports/services.py | 45 +++++++- .../services/v2/report_kinds/services.py | 67 +++++++++-- .../services/v2/report_kinds/types.py | 13 +++ .../services/v2/report_kinds/validators.py | 14 +++ tests/unit/v2/test_economy_report_routes.py | 10 +- tests/unit/v2/test_household_report_routes.py | 67 +++++++++++ .../unit/v2/test_household_report_service.py | 71 ++++++++++++ tests/unit/v2/test_report_kinds_service.py | 107 ++++++++++++++++++ 10 files changed, 420 insertions(+), 29 deletions(-) create mode 100644 policyengine_api/services/v2/report_kinds/validators.py create mode 100644 tests/unit/v2/test_report_kinds_service.py diff --git a/policyengine_api/fastapi_routes/v2/reports/response_models.py b/policyengine_api/fastapi_routes/v2/reports/response_models.py index 66d576dba..0a9c187e9 100644 --- a/policyengine_api/fastapi_routes/v2/reports/response_models.py +++ b/policyengine_api/fastapi_routes/v2/reports/response_models.py @@ -14,6 +14,7 @@ from policyengine_api.fastapi_routes.v2.errors import V2ErrorResponse from policyengine_api.fastapi_routes.v2.reports.household_response_models import ( HouseholdReportItem, + HouseholdReportSummaryItem, ) from policyengine_api.services.v2.simulation_entrypoint.types import ReportStatus @@ -168,10 +169,19 @@ class ReportDetailResult(StrictReportAPIModel): item: ReportDetailItem +ReportSummary = Annotated[ + ReportSummaryItem | HouseholdReportSummaryItem, Field(discriminator="kind") +] + + class ReportListResult(StrictReportAPIModel): - """The listed reports in ``items``, in the order asked, without results.""" + """The listed reports of both kinds in ``items``, in the order asked. + + Economy reports are listed without their results and household reports + without their calculations. + """ - items: list[ReportSummaryItem] + items: list[ReportSummary] class ReportRunDetailResult(StrictReportAPIModel): diff --git a/policyengine_api/fastapi_routes/v2/reports/routes.py b/policyengine_api/fastapi_routes/v2/reports/routes.py index 625d6ddd4..d45ef4088 100644 --- a/policyengine_api/fastapi_routes/v2/reports/routes.py +++ b/policyengine_api/fastapi_routes/v2/reports/routes.py @@ -31,6 +31,7 @@ ) from policyengine_api.fastapi_routes.v2.reports.household_response_models import ( HouseholdReportItem, + HouseholdReportSummaryItem, ) from policyengine_api.fastapi_routes.v2.reports.request_models import ( EconomyReportRunRequest, @@ -76,6 +77,7 @@ from policyengine_api.services.v2.households.validators import HouseholdNotFoundError from policyengine_api.services.v2.policies.validators import PolicyNotFoundError from policyengine_api.services.v2.report_kinds.services import V2ReportService +from policyengine_api.services.v2.report_kinds.validators import ReportNotFoundError from policyengine_api.services.v2.simulation_entrypoint.types import ( SimulationRecordUnreadableError, SimulationRequestRefusedError, @@ -128,10 +130,12 @@ HouseholdReportNotFoundError, HouseholdNotFoundError, PolicyNotFoundError, + ReportNotFoundError, ) REPORT_ERROR_MAPPINGS = ( V2ErrorMapping( ( + ReportNotFoundError, EconomyReportNotFoundError, HouseholdReportNotFoundError, HouseholdNotFoundError, @@ -231,10 +235,17 @@ def _report_detail( return ReportDetailResponse(result=ReportDetailResult(item=item)) -def _report_list(reports: tuple[EconomyReportRecord, ...]) -> ReportListResponse: +def _report_list( + reports: tuple[EconomyReportRecord | HouseholdReportRecord, ...], +) -> ReportListResponse: return ReportListResponse( result=ReportListResult( - items=[ReportSummaryItem.from_record(report) for report in reports] + items=[ + HouseholdReportSummaryItem.from_record(report) + if isinstance(report, HouseholdReportRecord) + else ReportSummaryItem.from_record(report) + for report in reports + ] ) ) @@ -301,13 +312,14 @@ def submit_report( @router.get( "/reports", response_model=ReportListResponse, - summary="List chosen economy reports", + summary="List chosen reports", description=( - "Lists the country's reports among the report_id values (at most " - "25), in that order and once each, with their status and newest run " - "but without results. Unknown reports and other countries' are left " - "out. Listing does not start the first run of a report that has " - "none; reading it does." + "Lists the country's reports of both kinds among the report_id " + "values (at most 25), in that order and once each, with their " + "status and newest run but without economy results or household " + "calculations. Unknown reports and other countries' are left out. " + "Listing does not start the first run of an economy report that " + "has none; reading it does." ), ) def list_reports( @@ -348,14 +360,15 @@ def get_report_by_legacy_number( "/reports/{report_id}", response_model=ReportDetailResponse, responses=READ_RESPONSES, - summary="Read an economy report", + summary="Read an economy or household report", description=( "Returns the report, its newest run and, once that run succeeded, its " - "result. A report with no run, such as one migrated from v1, is " - "calculated the first time it is read: the read starts a run with " - "the system trigger on the current catalog version and answers with " - "it. If the simulation service cannot run it, the report is " - "returned not run." + "result: an economy report's result, or a household report's " + "calculated households in the run's baseline and reform. An economy " + "report with no run, such as one migrated from v1, is calculated the " + "first time it is read: the read starts a run with the system " + "trigger on the current catalog version and answers with it. If the " + "simulation service cannot run it, the report is returned not run." ), ) def get_report( diff --git a/policyengine_api/services/v2/household_reports/services.py b/policyengine_api/services/v2/household_reports/services.py index c7eb6fe62..7c3196b2d 100644 --- a/policyengine_api/services/v2/household_reports/services.py +++ b/policyengine_api/services/v2/household_reports/services.py @@ -32,10 +32,14 @@ from policyengine_api.services.v2.household_reports.validators import ( require_default_year, require_household_reports_supported, + require_report_in_country, +) +from policyengine_api.services.v2.simulation_entrypoint.types import ( + SimulationRecordNotFoundError, ) if TYPE_CHECKING: - from collections.abc import Callable + from collections.abc import Callable, Sequence from uuid import UUID from policyengine_api.services.v2.household_reports.database_session import ( @@ -170,3 +174,42 @@ def submit_report( ) (report,) = self._with_policyengine_versions((report,)) return report + + def _report_or_none(self, report_id: UUID) -> HouseholdReportRecord | None: + try: + return self._simulation.get_report(report_id) + except SimulationRecordNotFoundError: + return None + + def get_report(self, *, country_id: str, report_id: UUID) -> HouseholdReportRecord: + """Return a household report of the country with its newest run. + + Raise ``HouseholdReportNotFoundError`` when the entrypoint has no such + report in this country. + """ + report = require_report_in_country( + self._report_or_none(report_id), + country_id=country_id, + report_id=report_id, + ) + (report,) = self._with_policyengine_versions((report,)) + return report + + def list_reports( + self, *, country_id: str, report_ids: Sequence[UUID] + ) -> tuple[HouseholdReportRecord, ...]: + """Return the country's household reports among ``report_ids``. + + They are in that order, once each; unknown reports and other + countries' are left out. + """ + found = ( + self._report_or_none(report_id) for report_id in dict.fromkeys(report_ids) + ) + return self._with_policyengine_versions( + tuple( + report + for report in found + if report is not None and report.country_id == country_id + ) + ) diff --git a/policyengine_api/services/v2/report_kinds/services.py b/policyengine_api/services/v2/report_kinds/services.py index 965b84917..a4f383d92 100644 --- a/policyengine_api/services/v2/report_kinds/services.py +++ b/policyengine_api/services/v2/report_kinds/services.py @@ -1,16 +1,26 @@ """Serve ``/v2/reports`` for both kinds of report: economy and household. A submission names its kind. Each kind's service, and the entrypoint's -routes behind it, handle only reports of that kind. +routes behind it, handle only reports of that kind, so a report read by its +ID is looked for among economy reports first and then among household +reports. A failure other than "not found", such as an unavailable +simulation service, is never taken for a missing report. """ from __future__ import annotations from typing import TYPE_CHECKING +from policyengine_api.services.v2.economy_reports.validators import ( + EconomyReportNotFoundError, +) from policyengine_api.services.v2.household_reports.types import ( HouseholdReportFields, ) +from policyengine_api.services.v2.household_reports.validators import ( + HouseholdReportNotFoundError, +) +from policyengine_api.services.v2.report_kinds.validators import report_not_found if TYPE_CHECKING: from collections.abc import Callable, Sequence @@ -73,11 +83,29 @@ def submit_report( country_id=country_id, report_input=report_input ) - def get_report(self, *, country_id: str, report_id: UUID) -> EconomyReportRecord: - """Return an economy report of the country.""" - return self._economy_reports().get_report( - country_id=country_id, report_id=report_id - ) + def get_report( + self, *, country_id: str, report_id: UUID + ) -> EconomyReportRecord | HouseholdReportRecord: + """Return the country's report of either kind. + + An economy report with no run starts its first run, as + ``V2EconomyReportService.get_report`` does. Raise + ``ReportNotFoundError`` when the country has neither kind by this ID. + """ + try: + return self._economy_reports().get_report( + country_id=country_id, report_id=report_id + ) + except EconomyReportNotFoundError: + pass + try: + return self._household_reports().get_report( + country_id=country_id, report_id=report_id + ) + except HouseholdReportNotFoundError as error: + raise report_not_found( + country_id=country_id, reference=str(report_id) + ) from error def get_report_by_legacy_number( self, *, country_id: str, legacy_report_id: int @@ -97,11 +125,28 @@ def run_report( def list_reports( self, *, country_id: str, report_ids: Sequence[UUID] - ) -> tuple[EconomyReportRecord, ...]: - """Return the country's economy reports among ``report_ids``.""" - return self._economy_reports().list_reports( - country_id=country_id, report_ids=report_ids - ) + ) -> tuple[EconomyReportRecord | HouseholdReportRecord, ...]: + """Return the country's reports of both kinds among ``report_ids``. + + They are in that order, once each. IDs that are not economy reports + of the country are looked for among its household reports. + """ + requested = tuple(dict.fromkeys(report_ids)) + found: dict[UUID, EconomyReportRecord | HouseholdReportRecord] = { + report.id: report + for report in self._economy_reports().list_reports( + country_id=country_id, report_ids=requested + ) + } + remaining = [report_id for report_id in requested if report_id not in found] + if remaining: + found.update( + (report.id, report) + for report in self._household_reports().list_reports( + country_id=country_id, report_ids=remaining + ) + ) + return tuple(found[report_id] for report_id in requested if report_id in found) def list_report_runs( self, *, country_id: str, report_id: UUID diff --git a/policyengine_api/services/v2/report_kinds/types.py b/policyengine_api/services/v2/report_kinds/types.py index 225377bee..d80645ac0 100644 --- a/policyengine_api/services/v2/report_kinds/types.py +++ b/policyengine_api/services/v2/report_kinds/types.py @@ -72,3 +72,16 @@ def submit_report( and ``SimulationRequestRefusedError`` when the entrypoint refuses it. """ ... + + def get_report(self, *, country_id: str, report_id: UUID) -> HouseholdReportRecord: + """Return a household report of the country with its newest run. + + Raise ``HouseholdReportNotFoundError`` when the country has no such one. + """ + ... + + def list_reports( + self, *, country_id: str, report_ids: Sequence[UUID] + ) -> tuple[HouseholdReportRecord, ...]: + """Return the country's household reports among ``report_ids``.""" + ... diff --git a/policyengine_api/services/v2/report_kinds/validators.py b/policyengine_api/services/v2/report_kinds/validators.py new file mode 100644 index 000000000..6c29a4257 --- /dev/null +++ b/policyengine_api/services/v2/report_kinds/validators.py @@ -0,0 +1,14 @@ +"""Database-independent validation for ``/v2/reports`` across both kinds.""" + +from __future__ import annotations + + +class ReportNotFoundError(LookupError): + """The country has neither an economy report nor a household report by this ID.""" + + +def report_not_found(*, country_id: str, reference: str) -> ReportNotFoundError: + """Build the error for a report of neither kind in the country.""" + return ReportNotFoundError( + f"Report {reference} was not found in country {country_id!r}" + ) diff --git a/tests/unit/v2/test_economy_report_routes.py b/tests/unit/v2/test_economy_report_routes.py index 55b3a6ae2..8d8af131f 100644 --- a/tests/unit/v2/test_economy_report_routes.py +++ b/tests/unit/v2/test_economy_report_routes.py @@ -58,6 +58,10 @@ report_record, report_run_record, ) +from tests.unit.v2.household_report_support import ( + FakeHouseholdReportSimulation, + household_report_service, +) SUBMISSION = { "country_id": "us", @@ -83,12 +87,16 @@ def fallback(resource: str): return jsonify({"source": "flask", "resource": resource}) service = economy_report_service(simulation, sessions) + # A report that is not an economy report is looked for among household + # reports, of which this entrypoint has none. + household_service = household_report_service(FakeHouseholdReportSimulation()) dependencies = NativeRouteDependencies( readiness_probe=lambda: True, gateway_client_factory=lambda: None, metadata_reader_factory=lambda: None, specification_provider=dict, v2_economy_report_service_factory=service_factory or (lambda: service), + v2_household_report_service_factory=lambda: household_service, ) settings = RouteImplementationSettings( health=RouteImplementation.FLASK_FALLBACK, @@ -397,7 +405,7 @@ def test_a_report_outside_the_country_is_not_found(reports) -> None: assert response.status_code == 404 assert response.json() == { "status": "error", - "message": f"Economy report {REPORT_ID} was not found in country 'us'", + "message": f"Report {REPORT_ID} was not found in country 'us'", } diff --git a/tests/unit/v2/test_household_report_routes.py b/tests/unit/v2/test_household_report_routes.py index 8846e1aa0..0e2b05965 100644 --- a/tests/unit/v2/test_household_report_routes.py +++ b/tests/unit/v2/test_household_report_routes.py @@ -41,6 +41,7 @@ REFORM_CALCULATION_ID, FakeHouseholdReportSimulation, FakeHouseholds, + household_report_record, household_report_service, household_report_store, household_simulation_ids, @@ -351,3 +352,69 @@ def test_openapi_describes_both_kinds_of_report() -> None: "item" ] assert set(item_schema["discriminator"]["mapping"]) == {"economy", "household"} + + +def test_a_household_report_is_read_by_its_id() -> None: + simulation = FakeHouseholdReportSimulation(household_report_record()) + + response = _client(simulation).get( + f"/v2/reports/{HOUSEHOLD_REPORT_ID}?country_id=us" + ) + + assert response.status_code == 200, response.text + assert response.json()["result"]["item"] == PENDING_REPORT_ITEM + + +def test_a_report_of_neither_kind_is_404() -> None: + response = _client(FakeHouseholdReportSimulation()).get( + f"/v2/reports/{HOUSEHOLD_REPORT_ID}?country_id=us" + ) + + assert response.status_code == 404, response.text + assert response.json()["message"] == ( + f"Report {HOUSEHOLD_REPORT_ID} was not found in country 'us'" + ) + + +def test_another_countrys_household_report_is_404() -> None: + simulation = FakeHouseholdReportSimulation(household_report_record(country_id="uk")) + + response = _client(simulation).get( + f"/v2/reports/{HOUSEHOLD_REPORT_ID}?country_id=us" + ) + + assert response.status_code == 404, response.text + + +def test_a_listing_summarizes_household_reports_without_their_calculations() -> None: + simulation = FakeHouseholdReportSimulation( + household_report_record(status=ReportStatus.SUCCEEDED) + ) + + response = _client(simulation).get( + f"/v2/reports?country_id=us&report_id={HOUSEHOLD_REPORT_ID}" + ) + + assert response.status_code == 200, response.text + (item,) = response.json()["result"]["items"] + assert item["kind"] == "household" + assert item["status"] == "succeeded" + assert item["run"] == { + "kind": "household", + "id": str(HOUSEHOLD_REPORT_RUN_ID), + "report_id": str(HOUSEHOLD_REPORT_ID), + "trigger": "initial", + "tax_benefit_model_version_id": str(HOUSEHOLD_CATALOG_VERSION_ID), + "policyengine_version": CURRENT_POLICYENGINE_VERSION, + "year": 2026, + "status": "succeeded", + } + + +def test_openapi_lists_both_kinds_of_report_summary() -> None: + schema = _client(FakeHouseholdReportSimulation()).get("/v2/openapi.json").json() + + items_schema = schema["components"]["schemas"]["ReportListResult"]["properties"][ + "items" + ]["items"] + assert set(items_schema["discriminator"]["mapping"]) == {"economy", "household"} diff --git a/tests/unit/v2/test_household_report_service.py b/tests/unit/v2/test_household_report_service.py index 4aecf4d82..517553657 100644 --- a/tests/unit/v2/test_household_report_service.py +++ b/tests/unit/v2/test_household_report_service.py @@ -6,6 +6,8 @@ from __future__ import annotations +from uuid import UUID + import pytest from policyengine_api.services.v2.household_reports.types import ( @@ -14,6 +16,7 @@ HouseholdReportSubmission, ) from policyengine_api.services.v2.household_reports.validators import ( + HouseholdReportNotFoundError, HouseholdReportsUnsupportedError, HouseholdReportYearError, ) @@ -28,11 +31,13 @@ HOUSEHOLD_ID, HOUSEHOLD_ONLY_HOUSEHOLD_ID, HOUSEHOLD_REFORM_POLICY_ID, + HOUSEHOLD_REPORT_ID, OLDER_CATALOG_VERSION_ID, OLDER_POLICYENGINE_VERSION, UNKNOWN_CATALOG_VERSION_ID, FakeHouseholdReportSimulation, FakeHouseholds, + household_report_record, household_report_service, stored_household, ) @@ -196,3 +201,69 @@ def test_the_entrypoint_refusal_reaches_the_caller_unchanged() -> None: household_report_service(simulation).submit_report( country_id="us", report_input=_fields() ) + + +def test_a_report_is_read_with_its_runs_policyengine_version() -> None: + simulation = FakeHouseholdReportSimulation( + household_report_record(status=ReportStatus.SUCCEEDED) + ) + + report = household_report_service(simulation).get_report( + country_id="us", report_id=HOUSEHOLD_REPORT_ID + ) + + assert report.status is ReportStatus.SUCCEEDED + assert report.run is not None + assert report.run.policyengine_version == CURRENT_POLICYENGINE_VERSION + assert simulation.calls == [("get_report", HOUSEHOLD_REPORT_ID)] + + +def test_a_report_before_its_first_run_is_read_without_a_catalog_read() -> None: + simulation = FakeHouseholdReportSimulation(household_report_record(status=None)) + + report = household_report_service(simulation).get_report( + country_id="us", report_id=HOUSEHOLD_REPORT_ID + ) + + assert report.status is ReportStatus.NOT_RUN + assert report.run is None + + +@pytest.mark.parametrize( + "reports", + [(), (household_report_record(country_id="uk"),)], + ids=["unknown", "other-country"], +) +def test_a_report_the_country_lacks_is_not_found(reports) -> None: + simulation = FakeHouseholdReportSimulation(*reports) + + with pytest.raises(HouseholdReportNotFoundError): + household_report_service(simulation).get_report( + country_id="us", report_id=HOUSEHOLD_REPORT_ID + ) + + +def test_reports_are_listed_in_the_order_asked_once_each() -> None: + second_report_id = UUID("00000000-0000-4000-8000-0000000003a1") + other_country_report_id = UUID("00000000-0000-4000-8000-0000000003a2") + unknown_report_id = UUID("00000000-0000-4000-8000-0000000003a3") + simulation = FakeHouseholdReportSimulation( + household_report_record(), + household_report_record(report_id=second_report_id, status=None), + household_report_record(report_id=other_country_report_id, country_id="uk"), + ) + + reports = household_report_service(simulation).list_reports( + country_id="us", + report_ids=[ + second_report_id, + unknown_report_id, + HOUSEHOLD_REPORT_ID, + other_country_report_id, + second_report_id, + ], + ) + + assert [report.id for report in reports] == [second_report_id, HOUSEHOLD_REPORT_ID] + assert reports[1].run is not None + assert reports[1].run.policyengine_version == CURRENT_POLICYENGINE_VERSION diff --git a/tests/unit/v2/test_report_kinds_service.py b/tests/unit/v2/test_report_kinds_service.py new file mode 100644 index 000000000..723c99166 --- /dev/null +++ b/tests/unit/v2/test_report_kinds_service.py @@ -0,0 +1,107 @@ +"""Which kind's service answers each /v2/reports operation.""" + +from __future__ import annotations + +from uuid import UUID + +import pytest + +from policyengine_api.services.v2.report_kinds.services import V2ReportService +from policyengine_api.services.v2.report_kinds.validators import ReportNotFoundError +from policyengine_api.services.v2.simulation_entrypoint.types import ( + SimulationServiceUnavailableError, +) +from tests.unit.v2.economy_report_support import ( + REPORT_ID, + FakeEconomyReportSimulation, + economy_report_service, + report_record, + report_run_record, +) +from tests.unit.v2.household_report_support import ( + HOUSEHOLD_REPORT_ID, + FakeHouseholdReportSimulation, + household_report_record, + household_report_service, + household_report_store, +) + +UNKNOWN_REPORT_ID = UUID("00000000-0000-4000-8000-0000000004ff") + + +def _service( + economy_simulation: FakeEconomyReportSimulation, + household_simulation: FakeHouseholdReportSimulation, +) -> V2ReportService: + sessions = household_report_store() + return V2ReportService( + economy_reports=lambda: economy_report_service(economy_simulation, sessions), + household_reports=lambda: household_report_service( + household_simulation, sessions=sessions + ), + ) + + +def test_an_economy_report_is_read_without_asking_for_a_household_report() -> None: + economy_simulation = FakeEconomyReportSimulation( + report_record(run=report_run_record()) + ) + household_simulation = FakeHouseholdReportSimulation() + + report = _service(economy_simulation, household_simulation).get_report( + country_id="us", report_id=REPORT_ID + ) + + assert report.id == REPORT_ID + assert household_simulation.calls == [] + + +def test_a_household_report_is_read_once_no_economy_report_has_its_id() -> None: + economy_simulation = FakeEconomyReportSimulation() + household_simulation = FakeHouseholdReportSimulation(household_report_record()) + + report = _service(economy_simulation, household_simulation).get_report( + country_id="us", report_id=HOUSEHOLD_REPORT_ID + ) + + assert report.id == HOUSEHOLD_REPORT_ID + assert [name for name, _argument in economy_simulation.calls] == ["get_report"] + assert household_simulation.calls == [("get_report", HOUSEHOLD_REPORT_ID)] + + +def test_a_report_of_neither_kind_is_not_found() -> None: + service = _service(FakeEconomyReportSimulation(), FakeHouseholdReportSimulation()) + + with pytest.raises(ReportNotFoundError, match="was not found in country 'us'"): + service.get_report(country_id="us", report_id=UNKNOWN_REPORT_ID) + + +def test_an_unavailable_economy_read_is_not_taken_for_a_missing_report() -> None: + economy_simulation = FakeEconomyReportSimulation() + economy_simulation.error = SimulationServiceUnavailableError("unreachable") + household_simulation = FakeHouseholdReportSimulation(household_report_record()) + + with pytest.raises(SimulationServiceUnavailableError): + _service(economy_simulation, household_simulation).get_report( + country_id="us", report_id=HOUSEHOLD_REPORT_ID + ) + + assert household_simulation.calls == [] + + +def test_a_listing_holds_both_kinds_in_the_order_asked() -> None: + economy_simulation = FakeEconomyReportSimulation( + report_record(run=report_run_record()) + ) + household_simulation = FakeHouseholdReportSimulation(household_report_record()) + + reports = _service(economy_simulation, household_simulation).list_reports( + country_id="us", + report_ids=[HOUSEHOLD_REPORT_ID, UNKNOWN_REPORT_ID, REPORT_ID], + ) + + assert [report.id for report in reports] == [HOUSEHOLD_REPORT_ID, REPORT_ID] + assert [argument for name, argument in household_simulation.calls] == [ + HOUSEHOLD_REPORT_ID, + UNKNOWN_REPORT_ID, + ] From 53a700274c01b5b3f9eabb43ef891f63f9596d02 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 29 Oct 2026 22:27:56 +0400 Subject: [PATCH 04/21] Run household reports again at POST /v2/reports/{report_id}/runs, with force A report that is not an economy report of the country is looked for among its household reports and run again on the current catalog version, with the manual trigger; force recalculates both households. A household-only country can run its household reports again, and keeps refusing its other report IDs as before. Every run item now names its kind, so a run answer is one of the two kinds. Co-Authored-By: Claude Opus 5.5 --- .../v2/reports/response_models.py | 11 ++- .../fastapi_routes/v2/reports/routes.py | 31 +++++--- .../services/v2/household_reports/services.py | 45 ++++++++++- .../services/v2/report_kinds/services.py | 32 ++++++-- .../services/v2/report_kinds/types.py | 12 +++ tests/unit/v2/test_economy_report_routes.py | 1 + tests/unit/v2/test_household_report_routes.py | 50 ++++++++++++ .../unit/v2/test_household_report_service.py | 58 ++++++++++++++ tests/unit/v2/test_report_kinds_service.py | 79 +++++++++++++++++++ 9 files changed, 302 insertions(+), 17 deletions(-) diff --git a/policyengine_api/fastapi_routes/v2/reports/response_models.py b/policyengine_api/fastapi_routes/v2/reports/response_models.py index 0a9c187e9..d1804b2ef 100644 --- a/policyengine_api/fastapi_routes/v2/reports/response_models.py +++ b/policyengine_api/fastapi_routes/v2/reports/response_models.py @@ -14,6 +14,7 @@ from policyengine_api.fastapi_routes.v2.errors import V2ErrorResponse from policyengine_api.fastapi_routes.v2.reports.household_response_models import ( HouseholdReportItem, + HouseholdReportRunItem, HouseholdReportSummaryItem, ) from policyengine_api.services.v2.simulation_entrypoint.types import ReportStatus @@ -98,6 +99,7 @@ class ReportRunItem(StrictReportAPIModel): ``simulating`` or ``outputs``. """ + kind: Literal["economy"] = "economy" id: UUID report_id: UUID trigger: str @@ -184,10 +186,15 @@ class ReportListResult(StrictReportAPIModel): items: list[ReportSummary] +ReportRunDetailItem = Annotated[ + ReportRunItem | HouseholdReportRunItem, Field(discriminator="kind") +] + + class ReportRunDetailResult(StrictReportAPIModel): - """Result holding one report run in ``item``.""" + """Result holding one report run, of either kind, in ``item``.""" - item: ReportRunItem + item: ReportRunDetailItem class ReportRunListResult(StrictReportAPIModel): diff --git a/policyengine_api/fastapi_routes/v2/reports/routes.py b/policyengine_api/fastapi_routes/v2/reports/routes.py index d45ef4088..dd5be1caf 100644 --- a/policyengine_api/fastapi_routes/v2/reports/routes.py +++ b/policyengine_api/fastapi_routes/v2/reports/routes.py @@ -31,6 +31,7 @@ ) from policyengine_api.fastapi_routes.v2.reports.household_response_models import ( HouseholdReportItem, + HouseholdReportRunItem, HouseholdReportSummaryItem, ) from policyengine_api.fastapi_routes.v2.reports.request_models import ( @@ -68,6 +69,7 @@ ) from policyengine_api.services.v2.household_reports.types import ( HouseholdReportRecord, + HouseholdReportRunRecord, ) from policyengine_api.services.v2.household_reports.validators import ( HouseholdReportNotFoundError, @@ -250,10 +252,15 @@ def _report_list( ) -def _report_run_detail(report_run: EconomyReportRunRecord) -> ReportRunDetailResponse: - return ReportRunDetailResponse( - result=ReportRunDetailResult(item=ReportRunItem.from_record(report_run)) +def _report_run_detail( + report_run: EconomyReportRunRecord | HouseholdReportRunRecord, +) -> ReportRunDetailResponse: + item = ( + HouseholdReportRunItem.from_record(report_run) + if isinstance(report_run, HouseholdReportRunRecord) + else ReportRunItem.from_record(report_run) ) + return ReportRunDetailResponse(result=ReportRunDetailResult(item=item)) def _report_run_list( @@ -388,13 +395,18 @@ def get_report( response_model=ReportRunDetailResponse, status_code=201, responses=RUN_RESPONSES, - summary="Run an economy report again", + summary="Run an economy or household report again", description=( "Runs the report on the country's current catalog version with the " - "manual trigger. Simulation runs that already exist for that version " - "and year are reused unless force is set, which calculates both " - "simulations again. A country without economy reports is refused " - "with 422 before the simulation service is asked." + "manual trigger, and answers with the new run. Simulation runs that " + "already exist for that version and year are reused unless force is " + "set, which calculates both simulations again; a household report " + "reuses calculations that are unfinished or succeeded within the " + "last 28 days. A country without economy reports, such as a " + "household-only one, can run only its household reports again; its " + "other report IDs are refused with 422, and a country without " + "household calculations cannot run household reports again (422), " + "before the simulation service is asked to run them." ), ) def run_report( @@ -418,7 +430,8 @@ def run_report( summary="List an economy report's runs", description=( "Every run of the report, newest first, each with the catalog version " - "and PolicyEngine.py version it ran on." + "and PolicyEngine.py version it ran on. Only economy reports list " + "their runs; a household report's newest run is in the report." ), ) def list_report_runs( diff --git a/policyengine_api/services/v2/household_reports/services.py b/policyengine_api/services/v2/household_reports/services.py index 7c3196b2d..e85d5a789 100644 --- a/policyengine_api/services/v2/household_reports/services.py +++ b/policyengine_api/services/v2/household_reports/services.py @@ -35,7 +35,9 @@ require_report_in_country, ) from policyengine_api.services.v2.simulation_entrypoint.types import ( + RunTrigger, SimulationRecordNotFoundError, + SimulationServiceUnavailableError, ) if TYPE_CHECKING: @@ -48,12 +50,12 @@ from policyengine_api.services.v2.household_reports.types import ( HouseholdReportFields, HouseholdReportRecord, + HouseholdReportRunRecord, HouseholdReportSimulation, HouseholdReportSubmission, HouseholdSource, PolicySource, ) - from policyengine_api.services.v2.simulation_entrypoint.types import RunTrigger class DeferredHouseholdReportSimulation: @@ -213,3 +215,44 @@ def list_reports( if report is not None and report.country_id == country_id ) ) + + def run_report( + self, *, country_id: str, report_id: UUID, force: bool + ) -> HouseholdReportRunRecord: + """Run a household report of the country again on the current version. + + The run has the ``manual`` trigger. The entrypoint reuses runs that are + unfinished or succeeded within the last 28 days unless ``force`` is + set, and reads the report's household and policies again. Raise + ``HouseholdReportNotFoundError`` when the country has no such report, + ``HouseholdReportsUnsupportedError`` for a country without household + calculations, before the entrypoint is asked to run it, and + ``SimulationRequestRefusedError`` when the entrypoint refuses the + household or a policy. + """ + require_report_in_country( + self._report_or_none(report_id), + country_id=country_id, + report_id=report_id, + ) + self._require_household_reports(country_id) + try: + report = self._simulation.run_report( + report_id, + policyengine_version=self._current_policyengine_version, + trigger=RunTrigger.MANUAL, + force=force, + ) + except SimulationRecordNotFoundError: + report = None + (report,) = self._with_policyengine_versions( + ( + require_report_in_country( + report, country_id=country_id, report_id=report_id + ), + ) + ) + if report.run is None: + msg = "the Simulation Entrypoint answered a re-run without its run" + raise SimulationServiceUnavailableError(msg) + return report.run diff --git a/policyengine_api/services/v2/report_kinds/services.py b/policyengine_api/services/v2/report_kinds/services.py index a4f383d92..8b965a67b 100644 --- a/policyengine_api/services/v2/report_kinds/services.py +++ b/policyengine_api/services/v2/report_kinds/services.py @@ -13,6 +13,7 @@ from policyengine_api.services.v2.economy_reports.validators import ( EconomyReportNotFoundError, + EconomyReportsUnsupportedError, ) from policyengine_api.services.v2.household_reports.types import ( HouseholdReportFields, @@ -29,6 +30,7 @@ from policyengine_api.services.v2.economy_reports.types import EconomyReportFields from policyengine_api.services.v2.household_reports.types import ( HouseholdReportRecord, + HouseholdReportRunRecord, ) from policyengine_api.services.v2.report_kinds.types import ( EconomyReportOperations, @@ -117,11 +119,31 @@ def get_report_by_legacy_number( def run_report( self, *, country_id: str, report_id: UUID, force: bool - ) -> EconomyReportRunRecord: - """Run an economy report again on the current catalog version.""" - return self._economy_reports().run_report( - country_id=country_id, report_id=report_id, force=force - ) + ) -> EconomyReportRunRecord | HouseholdReportRunRecord: + """Run the country's report of either kind again on the current version. + + A household-only country has no economy reports, so its report is + looked for among household reports at once. When the country has + neither kind by this ID, the economy refusal of a household-only + country (``EconomyReportsUnsupportedError``) stands, and otherwise + ``ReportNotFoundError`` is raised. + """ + try: + return self._economy_reports().run_report( + country_id=country_id, report_id=report_id, force=force + ) + except (EconomyReportNotFoundError, EconomyReportsUnsupportedError) as error: + economy_refusal: Exception = error + try: + return self._household_reports().run_report( + country_id=country_id, report_id=report_id, force=force + ) + except HouseholdReportNotFoundError as error: + if isinstance(economy_refusal, EconomyReportsUnsupportedError): + raise economy_refusal from error + raise report_not_found( + country_id=country_id, reference=str(report_id) + ) from error def list_reports( self, *, country_id: str, report_ids: Sequence[UUID] diff --git a/policyengine_api/services/v2/report_kinds/types.py b/policyengine_api/services/v2/report_kinds/types.py index d80645ac0..ab4d49b35 100644 --- a/policyengine_api/services/v2/report_kinds/types.py +++ b/policyengine_api/services/v2/report_kinds/types.py @@ -12,6 +12,7 @@ from policyengine_api.services.v2.household_reports.types import ( HouseholdReportFields, HouseholdReportRecord, + HouseholdReportRunRecord, ) from policyengine_api.services.v2.simulation_entrypoint.types import ( EconomyReportRecord, @@ -85,3 +86,14 @@ def list_reports( ) -> tuple[HouseholdReportRecord, ...]: """Return the country's household reports among ``report_ids``.""" ... + + def run_report( + self, *, country_id: str, report_id: UUID, force: bool + ) -> HouseholdReportRunRecord: + """Run a household report again on the current version (trigger ``manual``). + + Raise ``HouseholdReportNotFoundError`` for a report the country lacks + and ``HouseholdReportsUnsupportedError`` for a country without + household calculations. + """ + ... diff --git a/tests/unit/v2/test_economy_report_routes.py b/tests/unit/v2/test_economy_report_routes.py index 8d8af131f..3dcfcf4ad 100644 --- a/tests/unit/v2/test_economy_report_routes.py +++ b/tests/unit/v2/test_economy_report_routes.py @@ -138,6 +138,7 @@ def _simulation_run_item(run_id, simulation_id) -> dict: PENDING_RUN_ITEM = { + "kind": "economy", "id": str(REPORT_RUN_ID), "report_id": str(REPORT_ID), "trigger": "initial", diff --git a/tests/unit/v2/test_household_report_routes.py b/tests/unit/v2/test_household_report_routes.py index 0e2b05965..d3377c63d 100644 --- a/tests/unit/v2/test_household_report_routes.py +++ b/tests/unit/v2/test_household_report_routes.py @@ -21,6 +21,7 @@ ) from policyengine_api.services.v2.simulation_entrypoint.types import ( ReportStatus, + RunTrigger, SimulationRequestRefusedError, SimulationServiceUnavailableError, ) @@ -418,3 +419,52 @@ def test_openapi_lists_both_kinds_of_report_summary() -> None: "items" ]["items"] assert set(items_schema["discriminator"]["mapping"]) == {"economy", "household"} + + +@pytest.mark.parametrize("body", [{}, {"force": True}], ids=["reuse", "force"]) +def test_a_household_report_is_run_again_and_answered_with_the_new_run( + body: dict, +) -> None: + simulation = FakeHouseholdReportSimulation( + household_report_record(status=ReportStatus.SUCCEEDED) + ) + + response = _client(simulation).post( + f"/v2/reports/{HOUSEHOLD_REPORT_ID}/runs?country_id=us", json=body + ) + + assert response.status_code == 201, response.text + item = response.json()["result"]["item"] + assert item["kind"] == "household" + assert item["trigger"] == "manual" + assert item["status"] == "pending" + assert item["baseline"]["trigger"] == "manual" + assert simulation.calls[-1] == ( + "run_report", + { + "report_id": HOUSEHOLD_REPORT_ID, + "policyengine_version": CURRENT_POLICYENGINE_VERSION, + "trigger": RunTrigger.MANUAL, + "force": body.get("force", False), + }, + ) + + +def test_a_rerun_of_neither_kind_is_404() -> None: + response = _client(FakeHouseholdReportSimulation()).post( + f"/v2/reports/{HOUSEHOLD_REPORT_ID}/runs?country_id=us", json={} + ) + + assert response.status_code == 404, response.text + assert response.json()["message"] == ( + f"Report {HOUSEHOLD_REPORT_ID} was not found in country 'us'" + ) + + +def test_openapi_describes_both_kinds_of_run() -> None: + schema = _client(FakeHouseholdReportSimulation()).get("/v2/openapi.json").json() + + item_schema = schema["components"]["schemas"]["ReportRunDetailResult"][ + "properties" + ]["item"] + assert set(item_schema["discriminator"]["mapping"]) == {"economy", "household"} diff --git a/tests/unit/v2/test_household_report_service.py b/tests/unit/v2/test_household_report_service.py index 517553657..b710354c6 100644 --- a/tests/unit/v2/test_household_report_service.py +++ b/tests/unit/v2/test_household_report_service.py @@ -24,6 +24,7 @@ from policyengine_api.services.v2.policies.validators import PolicyNotFoundError from policyengine_api.services.v2.simulation_entrypoint.types import ( ReportStatus, + RunTrigger, SimulationRequestRefusedError, ) from tests.unit.v2.economy_report_support import CURRENT_POLICYENGINE_VERSION @@ -267,3 +268,60 @@ def test_reports_are_listed_in_the_order_asked_once_each() -> None: assert [report.id for report in reports] == [second_report_id, HOUSEHOLD_REPORT_ID] assert reports[1].run is not None assert reports[1].run.policyengine_version == CURRENT_POLICYENGINE_VERSION + + +@pytest.mark.parametrize("force", [False, True]) +def test_a_rerun_runs_on_the_current_version_and_answers_the_new_run( + *, force: bool +) -> None: + simulation = FakeHouseholdReportSimulation( + household_report_record(status=ReportStatus.SUCCEEDED) + ) + simulation.next_catalog_version_id = OLDER_CATALOG_VERSION_ID + + run = household_report_service(simulation).run_report( + country_id="us", report_id=HOUSEHOLD_REPORT_ID, force=force + ) + + assert simulation.calls == [ + ("get_report", HOUSEHOLD_REPORT_ID), + ( + "run_report", + { + "report_id": HOUSEHOLD_REPORT_ID, + "policyengine_version": CURRENT_POLICYENGINE_VERSION, + "trigger": RunTrigger.MANUAL, + "force": force, + }, + ), + ] + assert run.trigger == "manual" + assert run.status is ReportStatus.PENDING + assert run.policyengine_version == OLDER_POLICYENGINE_VERSION + + +@pytest.mark.parametrize( + "reports", + [(), (household_report_record(country_id="uk"),)], + ids=["unknown", "other-country"], +) +def test_a_rerun_of_a_report_the_country_lacks_is_not_found(reports) -> None: + simulation = FakeHouseholdReportSimulation(*reports) + + with pytest.raises(HouseholdReportNotFoundError): + household_report_service(simulation).run_report( + country_id="us", report_id=HOUSEHOLD_REPORT_ID, force=False + ) + + assert [name for name, _argument in simulation.calls] == ["get_report"] + + +def test_a_rerun_in_a_country_without_household_calculations_is_refused() -> None: + simulation = FakeHouseholdReportSimulation(household_report_record(country_id="xx")) + + with pytest.raises(HouseholdReportsUnsupportedError, match="'xx'"): + household_report_service(simulation).run_report( + country_id="xx", report_id=HOUSEHOLD_REPORT_ID, force=False + ) + + assert [name for name, _argument in simulation.calls] == ["get_report"] diff --git a/tests/unit/v2/test_report_kinds_service.py b/tests/unit/v2/test_report_kinds_service.py index 723c99166..10cc05e79 100644 --- a/tests/unit/v2/test_report_kinds_service.py +++ b/tests/unit/v2/test_report_kinds_service.py @@ -6,6 +6,9 @@ import pytest +from policyengine_api.services.v2.economy_reports.validators import ( + EconomyReportsUnsupportedError, +) from policyengine_api.services.v2.report_kinds.services import V2ReportService from policyengine_api.services.v2.report_kinds.validators import ReportNotFoundError from policyengine_api.services.v2.simulation_entrypoint.types import ( @@ -24,6 +27,7 @@ household_report_record, household_report_service, household_report_store, + set_country_support, ) UNKNOWN_REPORT_ID = UUID("00000000-0000-4000-8000-0000000004ff") @@ -105,3 +109,78 @@ def test_a_listing_holds_both_kinds_in_the_order_asked() -> None: HOUSEHOLD_REPORT_ID, UNKNOWN_REPORT_ID, ] + + +def test_an_economy_report_is_run_again_without_asking_for_a_household_report() -> None: + economy_simulation = FakeEconomyReportSimulation( + report_record(run=report_run_record()) + ) + household_simulation = FakeHouseholdReportSimulation() + + run = _service(economy_simulation, household_simulation).run_report( + country_id="us", report_id=REPORT_ID, force=True + ) + + assert run.trigger == "manual" + assert household_simulation.calls == [] + + +def test_a_household_report_is_run_again_once_no_economy_report_has_its_id() -> None: + household_simulation = FakeHouseholdReportSimulation(household_report_record()) + + run = _service(FakeEconomyReportSimulation(), household_simulation).run_report( + country_id="us", report_id=HOUSEHOLD_REPORT_ID, force=True + ) + + assert run.report_id == HOUSEHOLD_REPORT_ID + assert [name for name, _argument in household_simulation.calls] == [ + "get_report", + "run_report", + ] + + +def test_a_household_report_of_a_household_only_country_is_run_again() -> None: + sessions = household_report_store() + set_country_support( + sessions, "us", household_enabled=True, society_wide_enabled=False + ) + household_simulation = FakeHouseholdReportSimulation(household_report_record()) + service = V2ReportService( + economy_reports=lambda: economy_report_service( + FakeEconomyReportSimulation(), sessions + ), + household_reports=lambda: household_report_service( + household_simulation, sessions=sessions + ), + ) + + run = service.run_report( + country_id="us", report_id=HOUSEHOLD_REPORT_ID, force=False + ) + + assert run.report_id == HOUSEHOLD_REPORT_ID + + +def test_a_rerun_of_neither_kind_keeps_the_household_only_refusal() -> None: + sessions = household_report_store() + set_country_support( + sessions, "us", household_enabled=True, society_wide_enabled=False + ) + service = V2ReportService( + economy_reports=lambda: economy_report_service( + FakeEconomyReportSimulation(), sessions + ), + household_reports=lambda: household_report_service( + FakeHouseholdReportSimulation(), sessions=sessions + ), + ) + + with pytest.raises(EconomyReportsUnsupportedError): + service.run_report(country_id="us", report_id=UNKNOWN_REPORT_ID, force=False) + + +def test_a_rerun_of_neither_kind_is_not_found() -> None: + service = _service(FakeEconomyReportSimulation(), FakeHouseholdReportSimulation()) + + with pytest.raises(ReportNotFoundError): + service.run_report(country_id="us", report_id=UNKNOWN_REPORT_ID, force=False) From 0d78c5ac56dc376ea766686c0c12418e362debee Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 29 Oct 2026 22:29:17 +0400 Subject: [PATCH 05/21] Save household report IDs in saved reports after checking them with the simulation service A saved report's v2 ID that is not one of the country's economy reports is looked for among its household reports before it is saved, so a user can keep a household report in their account. A household report holds no v1 report number, so a saved report naming both a household report and a v1 report is refused as a conflict. Co-Authored-By: Claude Opus 5.5 --- .../fastapi_routes/dependencies.py | 10 +- .../services/v2/simulation_refs/services.py | 66 ++++++++--- .../test_saved_household_report_references.py | 106 ++++++++++++++++++ 3 files changed, 166 insertions(+), 16 deletions(-) create mode 100644 tests/unit/v2/test_saved_household_report_references.py diff --git a/policyengine_api/fastapi_routes/dependencies.py b/policyengine_api/fastapi_routes/dependencies.py index c28105b8f..ca90e99e0 100644 --- a/policyengine_api/fastapi_routes/dependencies.py +++ b/policyengine_api/fastapi_routes/dependencies.py @@ -1042,11 +1042,19 @@ def _default_legacy_reference_checker() -> LegacyReferenceChecker: def _default_simulation_reference_checker() -> SimulationReferenceChecker: """Defer the simulation client until a request checks a v2 reference.""" + from policyengine_api.services.v2.household_reports.services import ( + DeferredHouseholdReportSimulation, + ) from policyengine_api.services.v2.simulation_refs.services import ( SimulationReferenceService, ) - return SimulationReferenceService(_deferred_economy_report_simulation()) + return SimulationReferenceService( + _deferred_economy_report_simulation(), + household_reports=DeferredHouseholdReportSimulation( + _default_household_report_simulation + ), + ) def _default_v2_user_policy_service_factory() -> V2UserPolicyResourceService: diff --git a/policyengine_api/services/v2/simulation_refs/services.py b/policyengine_api/services/v2/simulation_refs/services.py index 44e0c24ca..5e087cffb 100644 --- a/policyengine_api/services/v2/simulation_refs/services.py +++ b/policyengine_api/services/v2/simulation_refs/services.py @@ -3,7 +3,9 @@ Reports and simulations live in the Simulation Entrypoint's database, so the v2 tables that save them keep no foreign key to them. Before a v2 ID is saved, the entrypoint is asked whether that record exists in the country. -The check runs before the caller opens its v2 transaction. +The check runs before the caller opens its v2 transaction. A report ID that +is not one of the country's economy reports may be one of its household +reports, which the entrypoint serves through other routes. """ from __future__ import annotations @@ -24,6 +26,9 @@ if TYPE_CHECKING: from uuid import UUID + from policyengine_api.services.v2.household_reports.types import ( + HouseholdReportSimulation, + ) from policyengine_api.services.v2.simulation_entrypoint.types import ( EconomyReportRecord, EconomyReportSimulation, @@ -32,11 +37,36 @@ class SimulationReferenceService: - """Existence checks of v2 reports and simulations, one entrypoint read each.""" + """Existence checks of v2 reports and simulations with the entrypoint.""" + + def __init__( + self, + simulation: EconomyReportSimulation, + *, + household_reports: HouseholdReportSimulation | None = None, + ) -> None: + """Read economy reports and simulations through ``simulation``. - def __init__(self, simulation: EconomyReportSimulation) -> None: - """Read reports and simulations through ``simulation``.""" + With ``household_reports``, a report ID that is not one of the + country's economy reports is also looked for among its household + reports. + """ self._simulation = simulation + self._household_reports = household_reports + + @property + def checks_household_reports(self) -> bool: + """Whether a saved report may name a household report.""" + return self._household_reports is not None + + def _household_report_country(self, report_id: UUID) -> str | None: + """Return a household report's country, or None without one.""" + if self._household_reports is None: + return None + try: + return self._household_reports.get_report(report_id).country_id + except SimulationRecordNotFoundError: + return None def require_report( self, @@ -60,19 +90,23 @@ def require_report( report: EconomyReportRecord | None = self._simulation.get_report(report_id) except SimulationRecordNotFoundError: report = None - require_reference_in_country( - None if report is None else report.country_id, - kind="report", - reference_id=report_id, - country_id=country_id, - ) - if report is not None: - require_migrated_from( - report.legacy_report_id, + if report is None or report.country_id != country_id: + # A household report is never migrated from a v1 report output. + require_reference_in_country( + self._household_report_country(report_id), kind="report", - record_id=report_id, - legacy_id=legacy_report_id, + reference_id=report_id, + country_id=country_id, ) + found_legacy_id = None + else: + found_legacy_id = report.legacy_report_id + require_migrated_from( + found_legacy_id, + kind="report", + record_id=report_id, + legacy_id=legacy_report_id, + ) def _require_report_country(self, *, country_id: str, report_id: UUID) -> None: try: @@ -83,6 +117,8 @@ def _require_report_country(self, *, country_id: str, report_id: UUID) -> None: ) except SimulationRecordNotFoundError: found_country_id = None + if found_country_id != country_id: + found_country_id = self._household_report_country(report_id) require_reference_in_country( found_country_id, kind="report", diff --git a/tests/unit/v2/test_saved_household_report_references.py b/tests/unit/v2/test_saved_household_report_references.py new file mode 100644 index 000000000..276c27677 --- /dev/null +++ b/tests/unit/v2/test_saved_household_report_references.py @@ -0,0 +1,106 @@ +"""Saved reports that name v2 household reports, checked with the entrypoint. + +A saved report's v2 ID is checked with the Simulation Entrypoint before it is +saved; an ID that is not one of the country's economy reports may be one of +its household reports. +""" + +from __future__ import annotations + +import pytest + +from policyengine_api.services.v2.associations.validators import ( + AssociationReferenceConflictError, +) +from policyengine_api.services.v2.simulation_entrypoint.types import ( + SimulationServiceUnavailableError, +) +from policyengine_api.services.v2.simulation_refs.services import ( + SimulationReferenceService, +) +from policyengine_api.services.v2.simulation_refs.validators import ( + SimulationReferenceNotFoundError, +) +from tests.unit.v2.economy_report_support import ( + REPORT_ID, + FakeEconomyReportSimulation, + report_record, + report_run_record, +) +from tests.unit.v2.household_report_support import ( + HOUSEHOLD_REPORT_ID, + FakeHouseholdReportSimulation, + household_report_record, +) + + +def test_a_household_report_of_the_country_can_be_saved() -> None: + household_reports = FakeHouseholdReportSimulation(household_report_record()) + + SimulationReferenceService( + FakeEconomyReportSimulation(), household_reports=household_reports + ).require_report(country_id="us", report_id=HOUSEHOLD_REPORT_ID) + + assert household_reports.calls == [("get_report", HOUSEHOLD_REPORT_ID)] + + +def test_another_countrys_household_report_cannot_be_saved() -> None: + household_reports = FakeHouseholdReportSimulation( + household_report_record(country_id="uk") + ) + + with pytest.raises(SimulationReferenceNotFoundError): + SimulationReferenceService( + FakeEconomyReportSimulation(), household_reports=household_reports + ).require_report(country_id="us", report_id=HOUSEHOLD_REPORT_ID) + + +def test_a_report_of_neither_kind_cannot_be_saved() -> None: + with pytest.raises(SimulationReferenceNotFoundError): + SimulationReferenceService( + FakeEconomyReportSimulation(), + household_reports=FakeHouseholdReportSimulation(), + ).require_report(country_id="us", report_id=HOUSEHOLD_REPORT_ID) + + +def test_an_economy_report_is_checked_without_asking_for_a_household_report() -> None: + household_reports = FakeHouseholdReportSimulation() + + SimulationReferenceService( + FakeEconomyReportSimulation(report_record(run=report_run_record())), + household_reports=household_reports, + ).require_report(country_id="us", report_id=REPORT_ID) + + assert household_reports.calls == [] + + +def test_a_household_report_was_not_migrated_from_a_v1_report() -> None: + household_reports = FakeHouseholdReportSimulation(household_report_record()) + + with pytest.raises(AssociationReferenceConflictError, match="was not migrated"): + SimulationReferenceService( + FakeEconomyReportSimulation(), household_reports=household_reports + ).require_report( + country_id="us", report_id=HOUSEHOLD_REPORT_ID, legacy_report_id=615 + ) + + +def test_an_unavailable_household_check_is_not_taken_for_a_missing_report() -> None: + household_reports = FakeHouseholdReportSimulation(household_report_record()) + household_reports.error = SimulationServiceUnavailableError("unreachable") + + with pytest.raises(SimulationServiceUnavailableError): + SimulationReferenceService( + FakeEconomyReportSimulation(), household_reports=household_reports + ).require_report(country_id="us", report_id=HOUSEHOLD_REPORT_ID) + + +def test_the_default_checker_also_checks_household_reports() -> None: + from policyengine_api.fastapi_routes.dependencies import ( + _default_simulation_reference_checker, + ) + + checker = _default_simulation_reference_checker() + + assert isinstance(checker, SimulationReferenceService) + assert checker.checks_household_reports From d0b86302d7a7525650dae0509e7096d136be5348 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 29 Oct 2026 22:38:17 +0400 Subject: [PATCH 06/21] Check household reports against migrated Postgres as the runtime role The cross-database integration check now submits, reads and runs again a household report of a household-only country through the routes, reading the countries row, the stored household and the catalog version as the runtime role, and checks that the country still refuses economy reports. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/v2-integration-check.yml | 4 +- .../integration/test_v2_household_reports.py | 230 ++++++++++++++++++ tests/unit/test_alembic_workflows.py | 1 + 3 files changed, 233 insertions(+), 2 deletions(-) create mode 100644 tests/integration/test_v2_household_reports.py diff --git a/.github/workflows/v2-integration-check.yml b/.github/workflows/v2-integration-check.yml index e6beed211..9ec8eac0f 100644 --- a/.github/workflows/v2-integration-check.yml +++ b/.github/workflows/v2-integration-check.yml @@ -77,8 +77,8 @@ jobs: run: uv run coverage run --branch -m pytest -q tests/integration/test_v2_catalog_installed.py env: RUN_V2_CATALOG_COMPATIBILITY: "1" - - name: Test v2 metadata publication, resource routes, the countries list and economy reports - run: uv run coverage run -a --branch -m pytest -q tests/integration/test_v2_catalog_publication.py tests/integration/test_v2_metadata_routes.py tests/integration/test_v2_country_routes.py tests/integration/test_v2_economy_reports.py + - name: Test v2 metadata publication, resource routes, the countries list and economy and household reports + run: uv run coverage run -a --branch -m pytest -q tests/integration/test_v2_catalog_publication.py tests/integration/test_v2_metadata_routes.py tests/integration/test_v2_country_routes.py tests/integration/test_v2_economy_reports.py tests/integration/test_v2_household_reports.py - name: Test v2 policy persistence and immediate v1 mirroring run: uv run coverage run -a --branch -m pytest -q tests/integration/test_v2_policy_persistence.py tests/integration/test_v1_policy_dual_write.py tests/integration/test_mysql_policy_dual_write.py tests/integration/test_v2_user_policy_mirroring.py tests/integration/test_v1_user_policy_dual_write.py - name: Test signed-in identity provisioning and user associations diff --git a/tests/integration/test_v2_household_reports.py b/tests/integration/test_v2_household_reports.py new file mode 100644 index 000000000..8252577b2 --- /dev/null +++ b/tests/integration/test_v2_household_reports.py @@ -0,0 +1,230 @@ +"""Household reports against a migrated disposable Postgres database. + +The services read the countries row, the stored household and the catalog +version as the Public API's runtime role, so the tests prove its declared +privileges cover them (rule ``api.v2.runtime-privileges-tested-as-runtime-role``). +The simulation service is a fake: these tests check what the Public API +decides and reads around it. +""" + +from __future__ import annotations + +import pytest +from alembic import command +from alembic.config import Config +from fastapi.testclient import TestClient +from flask import Flask +from sqlalchemy import create_engine, text +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import NullPool +from sqlmodel import Session + +from policyengine_api.asgi_factory import create_asgi_app +from policyengine_api.constants import REPO +from policyengine_api.fastapi_routes.dependencies import NativeRouteDependencies +from policyengine_api.migration_flags import ( + RouteImplementation, + RouteImplementationSettings, +) +from policyengine_api.services.v2.economy_reports.database_session import ( + EconomyReportDatabaseSession, +) +from policyengine_api.services.v2.economy_reports.services import ( + V2EconomyReportService, +) +from policyengine_api.services.v2.household_reports.database_session import ( + HouseholdReportDatabaseSession, +) +from policyengine_api.services.v2.household_reports.services import ( + V2HouseholdReportService, +) +from policyengine_api.services.v2.households.database_session import ( + HouseholdDatabaseSession, +) +from policyengine_api.services.v2.households.services import V2HouseholdService +from policyengine_api.services.v2.households.types import ( + NativeHouseholdCreationInput, +) +from policyengine_api.services.v2.policies.database_session import ( + PolicyDatabaseSession, +) +from policyengine_api.services.v2.policies.services import V2PolicyService +from tests.unit.v2.economy_report_support import ( + CURRENT_POLICYENGINE_VERSION, + FakeEconomyReportSimulation, +) +from tests.unit.v2.household_report_support import ( + HOUSEHOLD_CATALOG_VERSION_ID, + FakeHouseholdReportSimulation, +) + +# A model name no catalog publication uses, so the test rows never collide. +TEST_MODEL_NAME = "household-report-test" +# One adult in each of the US groups, in the v2 household form. +HOUSEHOLD_MEMBERSHIPS = { + "household": "household-1", + "family": "family-1", + "tax_unit": "tax-unit-1", + "spm_unit": "spm-unit-1", + "marital_unit": "marital-unit-1", +} +HOUSEHOLD_DATA = { + "people": [ + { + "id": "person-1", + "values": {"age": {"2026": 40}}, + "memberships": HOUSEHOLD_MEMBERSHIPS, + } + ], + **{ + collection: [{"id": identifier, "values": {}}] + for collection, identifier in HOUSEHOLD_MEMBERSHIPS.items() + }, +} + + +@pytest.fixture +def runtime_sessions(managed_v2_roles, v2_engine_as_role, disposable_v2_database_url): + """Migrate, make the US household-only, add a catalog version, and yield sessions. + + The sessions act as the runtime role. The US row and the test catalog + version are restored and removed afterwards. + """ + command.upgrade(Config(str(REPO / "alembic-v2.ini")), "head") + owner_engine = create_engine(disposable_v2_database_url, poolclass=NullPool) + runtime_engine = v2_engine_as_role(managed_v2_roles.runtime, poolclass=NullPool) + with owner_engine.begin() as connection: + connection.execute( + text("UPDATE countries SET society_wide_enabled = false WHERE id = 'us'") + ) + model_id = connection.execute( + text( + "INSERT INTO tax_benefit_models (id, name, created_at) " + "VALUES (gen_random_uuid(), :name, now()) RETURNING id" + ), + {"name": TEST_MODEL_NAME}, + ).scalar_one() + connection.execute( + text( + "INSERT INTO tax_benefit_model_versions " + "(id, model_id, version, current_law_id, metadata_time_periods, " + "created_at) VALUES (:id, :model_id, :version, 1, '[2026]', now())" + ), + { + "id": HOUSEHOLD_CATALOG_VERSION_ID, + "model_id": model_id, + "version": CURRENT_POLICYENGINE_VERSION, + }, + ) + try: + yield sessionmaker(runtime_engine, class_=Session) + finally: + with owner_engine.begin() as connection: + connection.execute( + text("UPDATE countries SET society_wide_enabled = true WHERE id = 'us'") + ) + connection.execute( + text("DELETE FROM tax_benefit_models WHERE name = :name"), + {"name": TEST_MODEL_NAME}, + ) + owner_engine.dispose() + runtime_engine.dispose() + + +def _client(sessions, simulation: FakeHouseholdReportSimulation) -> TestClient: + households = V2HouseholdService(HouseholdDatabaseSession(sessions)) + dependencies = NativeRouteDependencies( + readiness_probe=lambda: True, + gateway_client_factory=lambda: None, + metadata_reader_factory=lambda: None, + specification_provider=dict, + v2_economy_report_service_factory=lambda: V2EconomyReportService( + EconomyReportDatabaseSession(sessions), + FakeEconomyReportSimulation(), + current_policyengine_version=CURRENT_POLICYENGINE_VERSION, + ), + v2_household_report_service_factory=lambda: V2HouseholdReportService( + HouseholdReportDatabaseSession(sessions), + households=households, + policies=V2PolicyService( + PolicyDatabaseSession(sessions), + running_policyengine_version=CURRENT_POLICYENGINE_VERSION, + ), + simulation=simulation, + current_policyengine_version=CURRENT_POLICYENGINE_VERSION, + ), + ) + settings = RouteImplementationSettings( + health=RouteImplementation.FLASK_FALLBACK, + specification=RouteImplementation.FLASK_FALLBACK, + metadata=RouteImplementation.FLASK_FALLBACK, + ) + return TestClient( + create_asgi_app( + Flask(__name__), dependencies=dependencies, route_settings=settings + ) + ) + + +def test_postgres_household_only_country_runs_household_reports_as_the_runtime_role( + runtime_sessions, +) -> None: + household = ( + V2HouseholdService(HouseholdDatabaseSession(runtime_sessions)) + .create_household( + NativeHouseholdCreationInput( + country_id="us", default_year=2026, household_data=HOUSEHOLD_DATA + ) + ) + .item + ) + simulation = FakeHouseholdReportSimulation() + client = _client(runtime_sessions, simulation) + + submitted = client.post( + "/v2/reports?country_id=us", + json={ + "country_id": "us", + "kind": "household", + "household_id": str(household.id), + "baseline": {}, + }, + ) + assert submitted.status_code == 201, submitted.text + report_id = submitted.json()["result"]["item"]["id"] + read = client.get(f"/v2/reports/{report_id}?country_id=us") + rerun = client.post(f"/v2/reports/{report_id}/runs?country_id=us", json={}) + + assert read.status_code == 200, read.text + assert rerun.status_code == 201, rerun.text + assert read.json()["result"]["item"]["run"]["policyengine_version"] == ( + CURRENT_POLICYENGINE_VERSION + ) + assert rerun.json()["result"]["item"]["kind"] == "household" + assert [name for name, _argument in simulation.calls] == [ + "submit_report", + "get_report", + "get_report", + "run_report", + ] + + +def test_postgres_household_only_country_still_refuses_economy_reports( + runtime_sessions, +) -> None: + response = _client(runtime_sessions, FakeHouseholdReportSimulation()).post( + "/v2/reports?country_id=us", + json={ + "country_id": "us", + "kind": "economy", + "year": 2026, + "region_code": "state/ca", + "baseline": {}, + "reform": {}, + }, + ) + + assert response.status_code == 422, response.text + assert response.json()["message"] == ( + "Economy reports are not available for country 'us'" + ) diff --git a/tests/unit/test_alembic_workflows.py b/tests/unit/test_alembic_workflows.py index 5f3812f00..2445d0f7a 100644 --- a/tests/unit/test_alembic_workflows.py +++ b/tests/unit/test_alembic_workflows.py @@ -223,6 +223,7 @@ def test_reusable_v2_integration_check_uses_disposable_databases_and_coverage(): assert "test_v2_metadata_routes.py" in workflow assert "test_v2_country_routes.py" in workflow assert "test_v2_economy_reports.py" in workflow + assert "test_v2_household_reports.py" in workflow assert "test_v2_saved_reference_updates.py" in workflow assert "test_v2_policy_persistence.py" in workflow assert "test_v1_policy_dual_write.py" in workflow From 3ebc27dca06888d74cffb0739a6cdc5aeab3b788 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 29 Oct 2026 22:38:17 +0400 Subject: [PATCH 07/21] Register household reports in the route contracts The household report requests of /v2/reports (submit, read and re-run) join the workflow contracts, and the generated migration contract documents are exported again. Co-Authored-By: Claude Opus 5.5 --- docs/engineering/migration-contracts.md | 15 ++++- docs/generated/migration_contracts.json | 64 ++++++++++++++++++- tests/contract/registry.py | 57 +++++++++++++++++ .../test_app_v2_workflow_contracts.py | 2 + .../unit/test_migration_contract_artifacts.py | 5 +- 5 files changed, 137 insertions(+), 6 deletions(-) diff --git a/docs/engineering/migration-contracts.md b/docs/engineering/migration-contracts.md index 7f56f8d13..cc4237a6e 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 | 18 | -| request count | 96 | +| workflow count | 19 | +| request count | 99 | | db entity count | 6 | | sim flow count | 3 | @@ -213,6 +213,17 @@ Generated from `policyengine_api/migration_registry.py` and `tests/contract/regi | `POST` | `/v2/reports/{report_id}/runs?country_id=us` | 201 | `report` | none | `status`, `message`, `result.item.id`, `result.item.trigger`, `result.item.tax_benefit_model_version_id`, `result.item.policyengine_version`, `result.item.status` | | `GET` | `/v2/reports/{report_id}/runs?country_id=us` | 200 | `report` | none | `status`, `message`, `result.items` | +### `household_reports_v2` + +- Current contract: `typed_v2_resources` +- Future owner: Household reports on v2 + +| Method | Path | Status | Route group | Required headers | Stable response fields | +| --- | --- | ---: | --- | --- | --- | +| `POST` | `/v2/reports?country_id=uk` | 201 | `report` | none | `status`, `message`, `result.item.id`, `result.item.country_id`, `result.item.kind`, `result.item.year`, `result.item.household_id`, `result.item.baseline_simulation_id`, `result.item.reform_simulation_id`, `result.item.status`, `result.item.run` | +| `GET` | `/v2/reports/{report_id}?country_id=uk` | 200 | `report` | none | `status`, `message`, `result.item.id`, `result.item.kind`, `result.item.status`, `result.item.run.baseline.result`, `result.item.run.reform.result` | +| `POST` | `/v2/reports/{report_id}/runs?country_id=uk` | 201 | `report` | none | `status`, `message`, `result.item.kind`, `result.item.id`, `result.item.trigger`, `result.item.tax_benefit_model_version_id`, `result.item.policyengine_version`, `result.item.status` | + ### `metadata_resources_v2_preview` - Current contract: `typed_v2_resources` diff --git a/docs/generated/migration_contracts.json b/docs/generated/migration_contracts.json index a81086a9c..0f62ccbe2 100644 --- a/docs/generated/migration_contracts.json +++ b/docs/generated/migration_contracts.json @@ -1,10 +1,10 @@ { "metadata": { "db_entity_count": 6, - "request_count": 96, + "request_count": 99, "route_group_count": 9, "sim_flow_count": 3, - "workflow_count": 18 + "workflow_count": 19 }, "route_groups": [ { @@ -1471,6 +1471,66 @@ } ] }, + { + "current_contract": "typed_v2_resources", + "future_owner_pr": "Household reports on v2", + "name": "household_reports_v2", + "requests": [ + { + "expected_status": 201, + "headers": [], + "method": "POST", + "path": "/v2/reports?country_id=uk", + "route_group": "report", + "stable_response_fields": [ + "status", + "message", + "result.item.id", + "result.item.country_id", + "result.item.kind", + "result.item.year", + "result.item.household_id", + "result.item.baseline_simulation_id", + "result.item.reform_simulation_id", + "result.item.status", + "result.item.run" + ] + }, + { + "expected_status": 200, + "headers": [], + "method": "GET", + "path": "/v2/reports/{report_id}?country_id=uk", + "route_group": "report", + "stable_response_fields": [ + "status", + "message", + "result.item.id", + "result.item.kind", + "result.item.status", + "result.item.run.baseline.result", + "result.item.run.reform.result" + ] + }, + { + "expected_status": 201, + "headers": [], + "method": "POST", + "path": "/v2/reports/{report_id}/runs?country_id=uk", + "route_group": "report", + "stable_response_fields": [ + "status", + "message", + "result.item.kind", + "result.item.id", + "result.item.trigger", + "result.item.tax_benefit_model_version_id", + "result.item.policyengine_version", + "result.item.status" + ] + } + ] + }, { "current_contract": "typed_v2_resources", "future_owner_pr": "Later metadata read cutover and v2 route-prefix removal", diff --git a/tests/contract/registry.py b/tests/contract/registry.py index 250c3078a..e522d8500 100644 --- a/tests/contract/registry.py +++ b/tests/contract/registry.py @@ -695,6 +695,63 @@ class WorkflowContract: ), ), ), + WorkflowContract( + name="household_reports_v2", + current_contract="typed_v2_resources", + future_owner_pr="Household reports on v2", + requests=( + ContractRequest( + method="POST", + path="/v2/reports?country_id=uk", + expected_status=201, + stable_response_fields=( + "status", + "message", + "result.item.id", + "result.item.country_id", + "result.item.kind", + "result.item.year", + "result.item.household_id", + "result.item.baseline_simulation_id", + "result.item.reform_simulation_id", + "result.item.status", + "result.item.run", + ), + route_group="report", + ), + ContractRequest( + method="GET", + path="/v2/reports/{report_id}?country_id=uk", + expected_status=200, + stable_response_fields=( + "status", + "message", + "result.item.id", + "result.item.kind", + "result.item.status", + "result.item.run.baseline.result", + "result.item.run.reform.result", + ), + route_group="report", + ), + ContractRequest( + method="POST", + path="/v2/reports/{report_id}/runs?country_id=uk", + expected_status=201, + stable_response_fields=( + "status", + "message", + "result.item.kind", + "result.item.id", + "result.item.trigger", + "result.item.tax_benefit_model_version_id", + "result.item.policyengine_version", + "result.item.status", + ), + route_group="report", + ), + ), + ), WorkflowContract( name="metadata_resources_v2_preview", 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 abdcb3ba7..968989cc4 100644 --- a/tests/contract/test_app_v2_workflow_contracts.py +++ b/tests/contract/test_app_v2_workflow_contracts.py @@ -22,6 +22,7 @@ def test_app_v2_workflow_contract_registry_is_complete(): "region_selection", "country_selection_v2", "economy_reports_v2", + "household_reports_v2", "metadata_resources_v2_preview", "simulation_submit_poll", "report_create_poll", @@ -35,6 +36,7 @@ def test_app_v2_workflow_contract_registry_is_complete(): in { "country_selection_v2", "economy_reports_v2", + "household_reports_v2", "metadata_resources_v2_preview", "policy_resources_v2", "user_policy_associations_v2", diff --git a/tests/unit/test_migration_contract_artifacts.py b/tests/unit/test_migration_contract_artifacts.py index 353ae639f..ce998a58e 100644 --- a/tests/unit/test_migration_contract_artifacts.py +++ b/tests/unit/test_migration_contract_artifacts.py @@ -13,8 +13,8 @@ def test_migration_contract_payload_summarizes_route_contracts(): assert payload["version"] == 1 assert payload["metadata"] == { "route_group_count": 9, - "workflow_count": 18, - "request_count": 96, + "workflow_count": 19, + "request_count": 99, "db_entity_count": 6, "sim_flow_count": 3, } @@ -33,6 +33,7 @@ def test_migration_contract_payload_summarizes_route_contracts(): "region_selection", "country_selection_v2", "economy_reports_v2", + "household_reports_v2", "metadata_resources_v2_preview", "simulation_submit_poll", "report_create_poll", From 61cb7e025df58ebd72a44bccfd319aad34e2c726 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 29 Oct 2026 22:46:44 +0400 Subject: [PATCH 08/21] Name the household report's calculation schemas for household reports The household calculation routes have their own HouseholdCalculationItem schema, so the report's calculation items are HouseholdReportCalculation* and both can be served from one OpenAPI document without one replacing the other. Co-Authored-By: Claude Opus 5.5 --- .../v2/reports/household_response_models.py | 14 ++++++------- tests/unit/v2/test_household_report_routes.py | 20 +++++++++++++++++++ 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/policyengine_api/fastapi_routes/v2/reports/household_response_models.py b/policyengine_api/fastapi_routes/v2/reports/household_response_models.py index 2cd3e142c..800fc2bc1 100644 --- a/policyengine_api/fastapi_routes/v2/reports/household_response_models.py +++ b/policyengine_api/fastapi_routes/v2/reports/household_response_models.py @@ -27,7 +27,7 @@ class StrictHouseholdReportAPIModel(BaseModel): ) -class HouseholdCalculationErrorItem(StrictHouseholdReportAPIModel): +class HouseholdReportCalculationErrorItem(StrictHouseholdReportAPIModel): """Why a household calculation failed. ``code`` is ``household_calculation_failed`` when the household function @@ -40,7 +40,7 @@ class HouseholdCalculationErrorItem(StrictHouseholdReportAPIModel): detail: dict[str, JsonValue] | None -class HouseholdCalculationResultItem(StrictHouseholdReportAPIModel): +class HouseholdReportCalculationResultItem(StrictHouseholdReportAPIModel): """The household with each requested value filled in, in its own key order. ``spm_config`` is the SPM selection applied and ``spm_provenance`` its @@ -53,7 +53,7 @@ class HouseholdCalculationResultItem(StrictHouseholdReportAPIModel): spm_provenance: dict[str, JsonValue] | None -class HouseholdCalculationItem(StrictHouseholdReportAPIModel): +class HouseholdReportCalculationItem(StrictHouseholdReportAPIModel): """One calculation of the household under one policy (a simulation run). A null ``policy_id`` means current law. ``status`` is ``pending``, @@ -68,8 +68,8 @@ class HouseholdCalculationItem(StrictHouseholdReportAPIModel): status: str attempt: int trigger: str - error: HouseholdCalculationErrorItem | None - result: HouseholdCalculationResultItem | None + error: HouseholdReportCalculationErrorItem | None + result: HouseholdReportCalculationResultItem | None dispatched_at: datetime | None completed_at: datetime | None created_at: datetime @@ -99,8 +99,8 @@ class HouseholdReportRunItem(HouseholdReportRunSummaryItem): ``reform`` is null for a report of the baseline alone. """ - baseline: HouseholdCalculationItem - reform: HouseholdCalculationItem | None + baseline: HouseholdReportCalculationItem + reform: HouseholdReportCalculationItem | None @classmethod def from_record(cls, record: HouseholdReportRunRecord) -> HouseholdReportRunItem: diff --git a/tests/unit/v2/test_household_report_routes.py b/tests/unit/v2/test_household_report_routes.py index d3377c63d..e107eafa5 100644 --- a/tests/unit/v2/test_household_report_routes.py +++ b/tests/unit/v2/test_household_report_routes.py @@ -468,3 +468,23 @@ def test_openapi_describes_both_kinds_of_run() -> None: "properties" ]["item"] assert set(item_schema["discriminator"]["mapping"]) == {"economy", "household"} + + +def test_household_report_schemas_are_named_for_household_reports() -> None: + """Name the report's calculations for household reports. + + The household calculation routes have their own ``HouseholdCalculation*`` + schemas, so both sets can be served side by side. + """ + schemas = ( + _client(FakeHouseholdReportSimulation()) + .get("/v2/openapi.json") + .json()["components"]["schemas"] + ) + + assert { + "HouseholdReportCalculationItem", + "HouseholdReportCalculationErrorItem", + "HouseholdReportCalculationResultItem", + } <= set(schemas) + assert not {name for name in schemas if name.startswith("HouseholdCalculation")} From df8d7140c8d8cc9d62d5b8f0a4e54c0a7b7e90a7 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 29 Oct 2026 22:56:05 +0400 Subject: [PATCH 09/21] Answer 503 when the installed simulation client has no household report routes A client generated before the household report routes existed makes the adapter's import fail with ImportError, not ModuleNotFoundError, which answered 500; economy reads of an unknown report reach the household adapter too. It now means the simulation service is unavailable, and the adapter's tests skip with such a client instead of failing to import. Co-Authored-By: Claude Opus 5.5 --- .../fastapi_routes/dependencies.py | 8 ++-- .../libs/test_simulation_household_reports.py | 9 +++-- .../unit/v2/test_household_report_defaults.py | 39 +++++++++++++++++++ 3 files changed, 50 insertions(+), 6 deletions(-) create mode 100644 tests/unit/v2/test_household_report_defaults.py diff --git a/policyengine_api/fastapi_routes/dependencies.py b/policyengine_api/fastapi_routes/dependencies.py index ca90e99e0..240982da4 100644 --- a/policyengine_api/fastapi_routes/dependencies.py +++ b/policyengine_api/fastapi_routes/dependencies.py @@ -911,18 +911,20 @@ def _default_household_report_simulation() -> HouseholdReportSimulation: """Return the process's adapter for the entrypoint's household report routes. Raise ``SimulationServiceUnavailableError`` when the generated client is - not installed, or the entrypoint URL or gateway auth is unusable. + not installed or has no household report routes (a client generated + before them, whose import fails with ``ImportError``), or the entrypoint + URL or gateway auth is unusable. """ try: from policyengine_api.libs.simulation_household_reports import ( shared_household_reports, ) - except ModuleNotFoundError as error: + except ImportError as error: if (error.name or "").split(".")[0] != SIMULATION_CLIENT_PACKAGE: raise msg = ( "the generated simulation client (policyengine-api-simulation-client) " - "is not installed" + "is not installed or has no household report routes" ) raise SimulationServiceUnavailableError(msg) from error return shared_household_reports() diff --git a/tests/unit/libs/test_simulation_household_reports.py b/tests/unit/libs/test_simulation_household_reports.py index 25deabaad..6641f2421 100644 --- a/tests/unit/libs/test_simulation_household_reports.py +++ b/tests/unit/libs/test_simulation_household_reports.py @@ -2,8 +2,8 @@ The client is generated from the Simulation Entrypoint's OpenAPI document and is not a dependency until a release contains the household report routes, so -these tests skip when it is not installed. Requests go to an in-memory -transport that answers as the entrypoint does. +these tests skip when it is not installed or lacks those routes. Requests go +to an in-memory transport that answers as the entrypoint does. """ from __future__ import annotations @@ -15,7 +15,10 @@ import httpx import pytest -pytest.importorskip("policyengine_api_simulation_client") +# A client generated before the household report routes lacks this module. +pytest.importorskip( + "policyengine_api_simulation_client.api.default.get_household_report" +) from policyengine_api.libs.gateway_auth import GatewayAuthError from policyengine_api.libs.simulation_household_reports import ( diff --git a/tests/unit/v2/test_household_report_defaults.py b/tests/unit/v2/test_household_report_defaults.py new file mode 100644 index 000000000..f5312a95f --- /dev/null +++ b/tests/unit/v2/test_household_report_defaults.py @@ -0,0 +1,39 @@ +"""The default household report adapter, when the generated client cannot serve it.""" + +from __future__ import annotations + +import sys +import types + +import pytest + +from policyengine_api.fastapi_routes.dependencies import ( + _default_household_report_simulation, +) +from policyengine_api.services.v2.simulation_entrypoint.types import ( + SimulationServiceUnavailableError, +) + +pytest.importorskip("policyengine_api_simulation_client") + + +def test_a_client_without_the_household_report_routes_is_unavailable( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A client generated before the household report routes existed lacks them. + + Importing the adapter then fails with ``ImportError`` ("cannot import + name"), not ``ModuleNotFoundError``; it must still mean the simulation + service is unavailable, not an unexpected failure. + """ + monkeypatch.delitem( + sys.modules, "policyengine_api.libs.simulation_household_reports", raising=False + ) + monkeypatch.setitem( + sys.modules, + "policyengine_api_simulation_client.api.default", + types.ModuleType("policyengine_api_simulation_client.api.default"), + ) + + with pytest.raises(SimulationServiceUnavailableError, match="household report"): + _default_household_report_simulation() From cf34b6e677356c93682af927027d9b14c82d5369 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 29 Oct 2026 22:57:54 +0400 Subject: [PATCH 10/21] Name a new household run's version without a database read, and check the country first on a re-run A run the service starts is on the catalog version of the PolicyEngine.py version it sent, so the submission's and re-run's answers name that version instead of reading the catalog after the simulation service has acted; a database failure there answered 503 for a report that already existed. A re-run checks the country's household calculations before reading the report, as economy re-runs check theirs, so that refusal does not depend on the simulation service being reachable. Co-Authored-By: Claude Opus 5.5 --- .../services/v2/household_reports/services.py | 50 ++++++---- tests/unit/v2/household_report_support.py | 21 ++-- .../unit/v2/test_household_report_service.py | 95 ++++++++++++++++--- 3 files changed, 127 insertions(+), 39 deletions(-) diff --git a/policyengine_api/services/v2/household_reports/services.py b/policyengine_api/services/v2/household_reports/services.py index e85d5a789..cc6e4fb28 100644 --- a/policyengine_api/services/v2/household_reports/services.py +++ b/policyengine_api/services/v2/household_reports/services.py @@ -12,8 +12,9 @@ - The household must have a default year, the year the report calculates. Reports run on the current catalog version: the one published for the -PolicyEngine.py version the Public API runs, as economy reports do. A run's -PolicyEngine.py version is read from the Public API's own catalog. +PolicyEngine.py version the Public API runs, as economy reports do. A run +this service starts is on that version; the PolicyEngine.py version of a run +read back is looked up in the Public API's own catalog. """ from __future__ import annotations @@ -134,6 +135,21 @@ def _require_household_reports(self, country_id: str) -> None: country = read_country(session, country_id) require_household_reports_supported(country, country_id=country_id) + def _with_current_version( + self, report: HouseholdReportRecord + ) -> HouseholdReportRecord: + """Name the version a run this service just started runs on. + + The entrypoint runs it on the catalog version of the PolicyEngine.py + version sent, so no database read follows the entrypoint's work. + """ + if report.run is None: + return report + catalog_version_id = report.run.tax_benefit_model_version_id + return with_policyengine_version( + report, {catalog_version_id: self._current_policyengine_version} + ) + def _with_policyengine_versions( self, reports: tuple[HouseholdReportRecord, ...] ) -> tuple[HouseholdReportRecord, ...]: @@ -167,15 +183,15 @@ def submit_report( country_id=country_id, policy_id=choice.policy_id ) require_default_year(household.default_year) - report = self._simulation.submit_report( - household_report_submission( - report_input, - country_id=country_id, - policyengine_version=self._current_policyengine_version, + return self._with_current_version( + self._simulation.submit_report( + household_report_submission( + report_input, + country_id=country_id, + policyengine_version=self._current_policyengine_version, + ) ) ) - (report,) = self._with_policyengine_versions((report,)) - return report def _report_or_none(self, report_id: UUID) -> HouseholdReportRecord | None: try: @@ -224,18 +240,18 @@ def run_report( The run has the ``manual`` trigger. The entrypoint reuses runs that are unfinished or succeeded within the last 28 days unless ``force`` is set, and reads the report's household and policies again. Raise - ``HouseholdReportNotFoundError`` when the country has no such report, ``HouseholdReportsUnsupportedError`` for a country without household - calculations, before the entrypoint is asked to run it, and - ``SimulationRequestRefusedError`` when the entrypoint refuses the + calculations before the entrypoint is asked, as for economy reports; + ``HouseholdReportNotFoundError`` when the country has no such report; + and ``SimulationRequestRefusedError`` when the entrypoint refuses the household or a policy. """ + self._require_household_reports(country_id) require_report_in_country( self._report_or_none(report_id), country_id=country_id, report_id=report_id, ) - self._require_household_reports(country_id) try: report = self._simulation.run_report( report_id, @@ -245,11 +261,9 @@ def run_report( ) except SimulationRecordNotFoundError: report = None - (report,) = self._with_policyengine_versions( - ( - require_report_in_country( - report, country_id=country_id, report_id=report_id - ), + report = self._with_current_version( + require_report_in_country( + report, country_id=country_id, report_id=report_id ) ) if report.run is None: diff --git a/tests/unit/v2/household_report_support.py b/tests/unit/v2/household_report_support.py index ba0d5d596..452608ec3 100644 --- a/tests/unit/v2/household_report_support.py +++ b/tests/unit/v2/household_report_support.py @@ -212,6 +212,18 @@ def household_report_record( ) +def on_catalog_version( + report: HouseholdReportRecord, catalog_version_id: UUID +) -> HouseholdReportRecord: + """Return the report with its run on another catalog version.""" + if report.run is None: + return report + return replace( + report, + run=replace(report.run, tax_benefit_model_version_id=catalog_version_id), + ) + + class FakeHouseholdReportSimulation: """An in-memory Simulation Entrypoint for household reports. @@ -235,14 +247,7 @@ def _record(self, name: str, argument: object) -> None: def _on_next_catalog_version( self, report: HouseholdReportRecord ) -> HouseholdReportRecord: - if report.run is None: - return report - return replace( - report, - run=replace( - report.run, tax_benefit_model_version_id=self.next_catalog_version_id - ), - ) + return on_catalog_version(report, self.next_catalog_version_id) def submit_report( self, submission: HouseholdReportSubmission diff --git a/tests/unit/v2/test_household_report_service.py b/tests/unit/v2/test_household_report_service.py index b710354c6..f20ebce99 100644 --- a/tests/unit/v2/test_household_report_service.py +++ b/tests/unit/v2/test_household_report_service.py @@ -6,10 +6,18 @@ from __future__ import annotations +from contextlib import contextmanager +from typing import TYPE_CHECKING from uuid import UUID import pytest +from policyengine_api.services.v2.household_reports.database_session import ( + HouseholdReportDatabaseSession, +) +from policyengine_api.services.v2.household_reports.services import ( + V2HouseholdReportService, +) from policyengine_api.services.v2.household_reports.types import ( HouseholdPolicyChoice, HouseholdReportFields, @@ -38,11 +46,19 @@ UNKNOWN_CATALOG_VERSION_ID, FakeHouseholdReportSimulation, FakeHouseholds, + FakePolicies, household_report_record, household_report_service, + household_report_store, + on_catalog_version, stored_household, ) +if TYPE_CHECKING: + from collections.abc import Iterator + + from sqlmodel import Session + def _fields(*, reform_policy_id=HOUSEHOLD_REFORM_POLICY_ID, household_id=HOUSEHOLD_ID): return HouseholdReportFields( @@ -81,24 +97,75 @@ def test_a_report_is_submitted_by_ids_on_the_current_version() -> None: assert report.run.policyengine_version == CURRENT_POLICYENGINE_VERSION -def test_a_run_names_the_policyengine_version_of_its_catalog_version() -> None: +class CountingDatabaseSession(HouseholdReportDatabaseSession): + """Count the read sessions the service opens.""" + + def __init__(self) -> None: + super().__init__(household_report_store()) + self.reads = 0 + + @contextmanager + def read(self) -> Iterator[Session]: + self.reads += 1 + with super().read() as session: + yield session + + +def _service_on( + database_session: HouseholdReportDatabaseSession, + simulation: FakeHouseholdReportSimulation, +) -> V2HouseholdReportService: + return V2HouseholdReportService( + database_session, + households=FakeHouseholds(stored_household()), + policies=FakePolicies({HOUSEHOLD_REFORM_POLICY_ID: "us"}), + simulation=simulation, + current_policyengine_version=CURRENT_POLICYENGINE_VERSION, + ) + + +def test_a_submission_names_the_version_it_sent_without_reading_the_catalog() -> None: + """The first run is on the catalog version of the version sent. + + So once the entrypoint has created the report, no database read can fail + the answer. + """ + database_session = CountingDatabaseSession() simulation = FakeHouseholdReportSimulation() - simulation.next_catalog_version_id = OLDER_CATALOG_VERSION_ID + simulation.next_catalog_version_id = UNKNOWN_CATALOG_VERSION_ID - report = household_report_service(simulation).submit_report( + report = _service_on(database_session, simulation).submit_report( country_id="us", report_input=_fields() ) + assert report.run is not None + assert report.run.policyengine_version == CURRENT_POLICYENGINE_VERSION + # Only the countries row, before the entrypoint is asked. + assert database_session.reads == 1 + + +def test_a_read_run_names_the_policyengine_version_of_its_catalog_version() -> None: + simulation = FakeHouseholdReportSimulation( + on_catalog_version(household_report_record(), OLDER_CATALOG_VERSION_ID) + ) + + report = household_report_service(simulation).get_report( + country_id="us", report_id=HOUSEHOLD_REPORT_ID + ) + assert report.run is not None assert report.run.policyengine_version == OLDER_POLICYENGINE_VERSION -def test_a_run_on_a_catalog_version_the_public_api_lacks_names_no_version() -> None: - simulation = FakeHouseholdReportSimulation() - simulation.next_catalog_version_id = UNKNOWN_CATALOG_VERSION_ID +def test_a_read_run_on_a_catalog_version_the_public_api_lacks_names_no_version() -> ( + None +): + simulation = FakeHouseholdReportSimulation( + on_catalog_version(household_report_record(), UNKNOWN_CATALOG_VERSION_ID) + ) - report = household_report_service(simulation).submit_report( - country_id="us", report_input=_fields() + report = household_report_service(simulation).get_report( + country_id="us", report_id=HOUSEHOLD_REPORT_ID ) assert report.run is not None @@ -277,9 +344,9 @@ def test_a_rerun_runs_on_the_current_version_and_answers_the_new_run( simulation = FakeHouseholdReportSimulation( household_report_record(status=ReportStatus.SUCCEEDED) ) - simulation.next_catalog_version_id = OLDER_CATALOG_VERSION_ID + database_session = CountingDatabaseSession() - run = household_report_service(simulation).run_report( + run = _service_on(database_session, simulation).run_report( country_id="us", report_id=HOUSEHOLD_REPORT_ID, force=force ) @@ -297,7 +364,9 @@ def test_a_rerun_runs_on_the_current_version_and_answers_the_new_run( ] assert run.trigger == "manual" assert run.status is ReportStatus.PENDING - assert run.policyengine_version == OLDER_POLICYENGINE_VERSION + assert run.policyengine_version == CURRENT_POLICYENGINE_VERSION + # Only the countries row, before the entrypoint is asked to run it. + assert database_session.reads == 1 @pytest.mark.parametrize( @@ -316,7 +385,7 @@ def test_a_rerun_of_a_report_the_country_lacks_is_not_found(reports) -> None: assert [name for name, _argument in simulation.calls] == ["get_report"] -def test_a_rerun_in_a_country_without_household_calculations_is_refused() -> None: +def test_a_rerun_in_a_country_without_household_calculations_is_refused_first() -> None: simulation = FakeHouseholdReportSimulation(household_report_record(country_id="xx")) with pytest.raises(HouseholdReportsUnsupportedError, match="'xx'"): @@ -324,4 +393,4 @@ def test_a_rerun_in_a_country_without_household_calculations_is_refused() -> Non country_id="xx", report_id=HOUSEHOLD_REPORT_ID, force=False ) - assert [name for name, _argument in simulation.calls] == ["get_report"] + assert simulation.calls == [] From b8ef2b0be1c5b2dc7aaee7abde8f15f7790cdc83 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 29 Oct 2026 22:59:38 +0400 Subject: [PATCH 11/21] Say in the runs list's 404 that household reports do not list their runs GET /v2/reports/{report_id}/runs serves only economy reports, so its 404 says so instead of "no report has this ID", which is wrong for a household report the report route returns. Co-Authored-By: Claude Opus 5.5 --- .../fastapi_routes/v2/reports/response_models.py | 6 ++++++ policyengine_api/fastapi_routes/v2/reports/routes.py | 4 ++-- tests/unit/v2/test_household_report_routes.py | 9 +++++++++ 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/policyengine_api/fastapi_routes/v2/reports/response_models.py b/policyengine_api/fastapi_routes/v2/reports/response_models.py index d1804b2ef..1be9eeab6 100644 --- a/policyengine_api/fastapi_routes/v2/reports/response_models.py +++ b/policyengine_api/fastapi_routes/v2/reports/response_models.py @@ -254,6 +254,12 @@ def _error(description: str) -> dict[str, Any]: REPORT_NOT_FOUND_RESPONSE: dict[int | str, dict[str, Any]] = { 404: _error("No report has this ID or v1 number in this country."), } +RUNS_NOT_FOUND_RESPONSE: dict[int | str, dict[str, Any]] = { + 404: _error( + "No economy report has this ID in this country. A household report " + "does not list its runs; its newest run is in the report itself." + ), +} REPORT_BODY_RESPONSE: dict[int | str, dict[str, Any]] = { 413: _error("The request body exceeds 16 KiB."), } diff --git a/policyengine_api/fastapi_routes/v2/reports/routes.py b/policyengine_api/fastapi_routes/v2/reports/routes.py index dd5be1caf..1b883c9ca 100644 --- a/policyengine_api/fastapi_routes/v2/reports/routes.py +++ b/policyengine_api/fastapi_routes/v2/reports/routes.py @@ -41,8 +41,8 @@ from policyengine_api.fastapi_routes.v2.reports.response_models import ( READ_RESPONSES, REPORT_ERROR_RESPONSES, - REPORT_NOT_FOUND_RESPONSE, RUN_RESPONSES, + RUNS_NOT_FOUND_RESPONSE, SUBMIT_RESPONSES, ReportDetailResponse, ReportDetailResult, @@ -426,7 +426,7 @@ def run_report( @router.get( "/reports/{report_id}/runs", response_model=ReportRunListResponse, - responses=REPORT_NOT_FOUND_RESPONSE, + responses=RUNS_NOT_FOUND_RESPONSE, summary="List an economy report's runs", description=( "Every run of the report, newest first, each with the catalog version " diff --git a/tests/unit/v2/test_household_report_routes.py b/tests/unit/v2/test_household_report_routes.py index e107eafa5..df1694011 100644 --- a/tests/unit/v2/test_household_report_routes.py +++ b/tests/unit/v2/test_household_report_routes.py @@ -488,3 +488,12 @@ def test_household_report_schemas_are_named_for_household_reports() -> None: "HouseholdReportCalculationResultItem", } <= set(schemas) assert not {name for name in schemas if name.startswith("HouseholdCalculation")} + + +def test_openapi_says_household_reports_do_not_list_their_runs() -> None: + schema = _client(FakeHouseholdReportSimulation()).get("/v2/openapi.json").json() + + not_found = schema["paths"]["/v2/reports/{report_id}/runs"]["get"]["responses"][ + "404" + ]["description"] + assert "household report" in not_found From 7ae76ea4fc50f9ae01b9b005ce387a01757be03c Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 29 Oct 2026 22:59:38 +0400 Subject: [PATCH 12/21] Test that only a missing report of one kind sends a request to the other kind An unavailable simulation service, on either kind's routes, fails the read, listing or re-run instead of being taken for a report that is missing. Co-Authored-By: Claude Opus 5.5 --- tests/unit/v2/test_report_kinds_service.py | 53 ++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/tests/unit/v2/test_report_kinds_service.py b/tests/unit/v2/test_report_kinds_service.py index 10cc05e79..9f1bbe911 100644 --- a/tests/unit/v2/test_report_kinds_service.py +++ b/tests/unit/v2/test_report_kinds_service.py @@ -184,3 +184,56 @@ def test_a_rerun_of_neither_kind_is_not_found() -> None: with pytest.raises(ReportNotFoundError): service.run_report(country_id="us", report_id=UNKNOWN_REPORT_ID, force=False) + + +def _unavailable_household_reports() -> FakeHouseholdReportSimulation: + household_simulation = FakeHouseholdReportSimulation(household_report_record()) + household_simulation.error = SimulationServiceUnavailableError("unreachable") + return household_simulation + + +def test_an_unavailable_household_read_after_an_economy_miss_is_raised() -> None: + service = _service(FakeEconomyReportSimulation(), _unavailable_household_reports()) + + with pytest.raises(SimulationServiceUnavailableError): + service.get_report(country_id="us", report_id=UNKNOWN_REPORT_ID) + + +def test_an_unavailable_household_listing_after_an_economy_miss_is_raised() -> None: + service = _service(FakeEconomyReportSimulation(), _unavailable_household_reports()) + + with pytest.raises(SimulationServiceUnavailableError): + service.list_reports(country_id="us", report_ids=[UNKNOWN_REPORT_ID]) + + +def test_an_unavailable_household_rerun_after_an_economy_miss_is_raised() -> None: + service = _service(FakeEconomyReportSimulation(), _unavailable_household_reports()) + + with pytest.raises(SimulationServiceUnavailableError): + service.run_report(country_id="us", report_id=UNKNOWN_REPORT_ID, force=False) + + +def _unavailable_economy_reports() -> FakeEconomyReportSimulation: + economy_simulation = FakeEconomyReportSimulation() + economy_simulation.error = SimulationServiceUnavailableError("unreachable") + return economy_simulation + + +def test_an_unavailable_economy_listing_asks_no_household_report() -> None: + household_simulation = FakeHouseholdReportSimulation(household_report_record()) + service = _service(_unavailable_economy_reports(), household_simulation) + + with pytest.raises(SimulationServiceUnavailableError): + service.list_reports(country_id="us", report_ids=[HOUSEHOLD_REPORT_ID]) + + assert household_simulation.calls == [] + + +def test_an_unavailable_economy_rerun_asks_no_household_report() -> None: + household_simulation = FakeHouseholdReportSimulation(household_report_record()) + service = _service(_unavailable_economy_reports(), household_simulation) + + with pytest.raises(SimulationServiceUnavailableError): + service.run_report(country_id="us", report_id=HOUSEHOLD_REPORT_ID, force=False) + + assert household_simulation.calls == [] From 060ef7b36fa6f71edf03cb4ca4069dd2645a16d0 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 29 Oct 2026 23:16:23 +0400 Subject: [PATCH 13/21] Answer 503 when a household report answer's nested values do not parse The generated client keeps a nested value it cannot parse as it came, such as a run status it does not know or a calculation of another shape, so building the record raised AttributeError or KeyError after the parse and answered 500. The record is now built under the same rule as the parse: an answer that does not parse means the simulation service cannot be used. Two descriptions also catch up: which 400s the Public API cannot rule out itself, and where a run's PolicyEngine.py version comes from. Co-Authored-By: Claude Opus 5.5 --- .../libs/simulation_household_reports.py | 57 +++++++++++-------- .../services/v2/household_reports/types.py | 7 ++- .../libs/test_simulation_household_reports.py | 27 +++++++++ 3 files changed, 65 insertions(+), 26 deletions(-) diff --git a/policyengine_api/libs/simulation_household_reports.py b/policyengine_api/libs/simulation_household_reports.py index 54c783530..c51350508 100644 --- a/policyengine_api/libs/simulation_household_reports.py +++ b/policyengine_api/libs/simulation_household_reports.py @@ -22,10 +22,13 @@ - 422: the caller's data it cannot calculate (a parameter the catalog version lacks, a stored policy value or household the household functions refuse), with its reason, or the request fields it refused. -- 400: a household, policy or catalog version it does not have, or a - household without a default year. The Public API checks those itself - before sending, so a 400 means the two services disagree, and the - entrypoint cannot be used for this request. +- 400: a household, policy or catalog version it does not have, a household + without a default year, or no household function for the PolicyEngine.py + version. The Public API checks the household, the policies and the default + year itself before sending; the catalog version and the household function + are those of the version the Public API runs, which the entrypoint must + serve. So a 400 means the two services disagree, and the entrypoint cannot + be used for this request. - 503, any other answer, an answer that does not parse, no answer or a gateway token that cannot be fetched: the entrypoint cannot be used now. """ @@ -263,6 +266,25 @@ def _report(report: HouseholdReportResponse) -> HouseholdReportRecord: ) +def _report_answer(call: Callable[[], Response[Any]]) -> HouseholdReportRecord: + """Run one report route call and return the report it answered. + + The generated client keeps a nested value it cannot parse as it came, + such as a run status it does not know or a calculation of another shape, + so the record is built here under the same rule as the parse: an answer + that does not parse means the entrypoint cannot be used. + """ + answer = _parsed(call, HouseholdReportResponse) + try: + return _report(answer) + except (AttributeError, KeyError, TypeError, ValueError) as error: + msg = ( + "the Simulation Entrypoint's answer could not be read: " + f"{type(error).__name__}" + ) + raise SimulationServiceUnavailableError(msg) from error + + def _policy_choice(choice: HouseholdPolicyChoice) -> GeneratedPolicyChoice: return GeneratedPolicyChoice(policy_id=choice.policy_id) @@ -289,24 +311,16 @@ def submit_report( ), trigger=GeneratedRunTrigger.INITIAL, ) - return _report( - _parsed( - lambda: submit_household_report.sync_detailed( - client=self._client, body=body - ), - HouseholdReportResponse, + return _report_answer( + lambda: submit_household_report.sync_detailed( + client=self._client, body=body ) ) def get_report(self, report_id: UUID) -> HouseholdReportRecord: """Return a report with its newest run and that run's calculations.""" - return _report( - _parsed( - lambda: get_household_report.sync_detailed( - report_id, client=self._client - ), - HouseholdReportResponse, - ) + return _report_answer( + lambda: get_household_report.sync_detailed(report_id, client=self._client) ) def run_report( @@ -323,12 +337,9 @@ def run_report( trigger=GeneratedRunTrigger(trigger.value), force=force, ) - return _report( - _parsed( - lambda: run_household_report.sync_detailed( - report_id, client=self._client, body=body - ), - HouseholdReportResponse, + return _report_answer( + lambda: run_household_report.sync_detailed( + report_id, client=self._client, body=body ) ) diff --git a/policyengine_api/services/v2/household_reports/types.py b/policyengine_api/services/v2/household_reports/types.py index 3cc409d16..5dd336241 100644 --- a/policyengine_api/services/v2/household_reports/types.py +++ b/policyengine_api/services/v2/household_reports/types.py @@ -128,9 +128,10 @@ class HouseholdReportRunRecord: """One calculation of a household report: the baseline's and the reform's. ``tax_benefit_model_version_id`` is the catalog version it ran on and - ``policyengine_version`` that version's PolicyEngine.py version, which the - Public API reads from its own catalog. ``reform`` is None for a report of - the baseline alone. + ``policyengine_version`` that version's PolicyEngine.py version: the one + the Public API sent, for a run it just started, or the one its own + catalog records, for a run read back (None if the catalog lacks it). + ``reform`` is None for a report of the baseline alone. """ id: UUID diff --git a/tests/unit/libs/test_simulation_household_reports.py b/tests/unit/libs/test_simulation_household_reports.py index 6641f2421..f966e9f99 100644 --- a/tests/unit/libs/test_simulation_household_reports.py +++ b/tests/unit/libs/test_simulation_household_reports.py @@ -564,3 +564,30 @@ def test_partial_gateway_auth_is_refused(monkeypatch: pytest.MonkeyPatch) -> Non household_reports_from_environment() assert "partially configured" in str(raised.value) + + +@pytest.mark.parametrize( + "answer", + [ + _report_body(status="paused"), + _report_body(baseline={"calculation_id": str(BASELINE_RUN_ID)}), + _report_body( + baseline=_calculation_body( + BASELINE_RUN_ID, BASELINE_SIMULATION_ID, None, status="paused" + ) + ), + ], + ids=[ + "unknown-report-status", + "malformed-calculation", + "unknown-calculation-status", + ], +) +def test_a_success_answer_that_does_not_parse_means_the_entrypoint_is_unavailable( + answer: dict, +) -> None: + """The generated client keeps a nested value it cannot parse as it came.""" + entrypoint = SimulationEntrypointStub(httpx.Response(200, json=answer)) + + with pytest.raises(SimulationServiceUnavailableError): + entrypoint.adapter().get_report(REPORT_ID) From 0433f5c9df42088b3827f11e9dfee627b022f07e Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 29 Oct 2026 23:16:55 +0400 Subject: [PATCH 14/21] Test the default saved-report check with fake services instead of a test-only property The default checker is now shown to look for household reports by checking a household report ID through it, with the simulation adapters replaced by fakes, so SimulationReferenceService loses the property only that test read. Co-Authored-By: Claude Opus 5.5 --- .../services/v2/simulation_refs/services.py | 5 ---- .../test_saved_household_report_references.py | 25 ++++++++++++++----- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/policyengine_api/services/v2/simulation_refs/services.py b/policyengine_api/services/v2/simulation_refs/services.py index 5e087cffb..733cca2b8 100644 --- a/policyengine_api/services/v2/simulation_refs/services.py +++ b/policyengine_api/services/v2/simulation_refs/services.py @@ -54,11 +54,6 @@ def __init__( self._simulation = simulation self._household_reports = household_reports - @property - def checks_household_reports(self) -> bool: - """Whether a saved report may name a household report.""" - return self._household_reports is not None - def _household_report_country(self, report_id: UUID) -> str | None: """Return a household report's country, or None without one.""" if self._household_reports is None: diff --git a/tests/unit/v2/test_saved_household_report_references.py b/tests/unit/v2/test_saved_household_report_references.py index 276c27677..5515a2609 100644 --- a/tests/unit/v2/test_saved_household_report_references.py +++ b/tests/unit/v2/test_saved_household_report_references.py @@ -95,12 +95,25 @@ def test_an_unavailable_household_check_is_not_taken_for_a_missing_report() -> N ).require_report(country_id="us", report_id=HOUSEHOLD_REPORT_ID) -def test_the_default_checker_also_checks_household_reports() -> None: - from policyengine_api.fastapi_routes.dependencies import ( - _default_simulation_reference_checker, +def test_the_default_checker_also_checks_household_reports( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from policyengine_api.fastapi_routes import dependencies + + household_reports = FakeHouseholdReportSimulation(household_report_record()) + monkeypatch.setattr( + dependencies, + "_default_economy_report_simulation", + FakeEconomyReportSimulation, + ) + monkeypatch.setattr( + dependencies, + "_default_household_report_simulation", + lambda: household_reports, ) - checker = _default_simulation_reference_checker() + dependencies._default_simulation_reference_checker().require_report( + country_id="us", report_id=HOUSEHOLD_REPORT_ID + ) - assert isinstance(checker, SimulationReferenceService) - assert checker.checks_household_reports + assert household_reports.calls == [("get_report", HOUSEHOLD_REPORT_ID)] From fbd14a0f58a07e6ab69c0c2eebb4e80b9bc3c335 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 29 Oct 2026 23:34:19 +0400 Subject: [PATCH 15/21] Check the types of household report fields the generated client copies unchecked The generated models copy nullable UUIDs and date-times, the country and the warnings as they came, so a value of another type reached the strict response models and answered 500. The adapter checks each such field's type while it builds the record, and an answer that fails the check means the simulation service cannot be used (503). Co-Authored-By: Claude Opus 5.5 --- .../libs/simulation_household_reports.py | 74 ++++++++++----- .../libs/test_simulation_household_reports.py | 92 +++++++++++++++++++ 2 files changed, 142 insertions(+), 24 deletions(-) diff --git a/policyengine_api/libs/simulation_household_reports.py b/policyengine_api/libs/simulation_household_reports.py index c51350508..9d827c692 100644 --- a/policyengine_api/libs/simulation_household_reports.py +++ b/policyengine_api/libs/simulation_household_reports.py @@ -35,8 +35,10 @@ from __future__ import annotations +from datetime import datetime from functools import cache from typing import TYPE_CHECKING, Any, NoReturn, Protocol, TypeVar +from uuid import UUID import httpx from policyengine_api_simulation_client import AuthenticatedClient @@ -89,7 +91,6 @@ if TYPE_CHECKING: from collections.abc import Callable - from uuid import UUID from policyengine_api_simulation_client.models import ( HouseholdCalculationResponse, @@ -193,23 +194,47 @@ def _optional_object(value: _GeneratedObject | None) -> dict[str, Any] | None: return None if value is None else value.to_dict() +CheckedT = TypeVar("CheckedT") + + +def _checked(value: object, expected: type[CheckedT]) -> CheckedT: + """Return ``value`` if it has the type the generated model declares for it. + + The generated models copy some fields as they came, such as a nullable + UUID or date-time, or a list of text, so the record checks them; raise + TypeError otherwise. + """ + if not isinstance(value, expected) or (expected is int and isinstance(value, bool)): + msg = f"expected {expected.__name__}, got {type(value).__name__}" + raise TypeError(msg) + return value + + +def _optional(value: object, expected: type[CheckedT]) -> CheckedT | None: + return None if value is None else _checked(value, expected) + + +def _warnings(warnings: object) -> tuple[str, ...]: + return tuple(_checked(warning, str) for warning in _checked(warnings, list)) + + def _calculation( calculation: HouseholdCalculationResponse, ) -> HouseholdCalculationRecord: result, failure = calculation.result, calculation.error return HouseholdCalculationRecord( - id=calculation.calculation_id, - simulation_id=calculation.simulation_id, - policy_id=calculation.policy_id, + id=_checked(calculation.calculation_id, UUID), + simulation_id=_checked(calculation.simulation_id, UUID), + policy_id=_optional(calculation.policy_id, UUID), status=calculation.status.value, - attempt=calculation.attempt, + attempt=_checked(calculation.attempt, int), trigger=calculation.trigger.value, error=( None if failure is None else HouseholdCalculationError( - code=failure.code, - message=failure.message, + code=_checked(failure.code, str), + message=_checked(failure.message, str), detail=_optional_object(failure.detail), ) ), @@ -218,14 +243,14 @@ def _calculation( if result is None else HouseholdCalculationOutput( household=result.household.to_dict(), - warnings=tuple(result.warnings), + warnings=_warnings(result.warnings), spm_config=_optional_object(result.spm_config), spm_provenance=_optional_object(result.spm_provenance), ) ), - dispatched_at=calculation.dispatched_at, - completed_at=calculation.completed_at, - created_at=calculation.created_at, + dispatched_at=_optional(calculation.dispatched_at, datetime), + completed_at=_optional(calculation.completed_at, datetime), + created_at=_checked(calculation.created_at, datetime), ) @@ -240,12 +265,12 @@ def _report_run(report: HouseholdReportResponse) -> HouseholdReportRunRecord | N ): return None return HouseholdReportRunRecord( - id=report.report_run_id, - report_id=report.report_id, + id=_checked(report.report_run_id, UUID), + report_id=_checked(report.report_id, UUID), trigger=report.trigger.value, - tax_benefit_model_version_id=report.catalog_version_id, + tax_benefit_model_version_id=_checked(report.catalog_version_id, UUID), policyengine_version=None, - year=report.year, + year=_checked(report.year, int), status=REPORT_STATUSES[report.status.value], baseline=_calculation(report.baseline), reform=None if report.reform is None else _calculation(report.reform), @@ -255,12 +280,12 @@ def _report_run(report: HouseholdReportResponse) -> HouseholdReportRunRecord | N def _report(report: HouseholdReportResponse) -> HouseholdReportRecord: run = _report_run(report) return HouseholdReportRecord( - id=report.report_id, - country_id=report.country, - year=report.year, - household_id=report.household_id, - baseline_simulation_id=report.baseline_simulation_id, - reform_simulation_id=report.reform_simulation_id, + id=_checked(report.report_id, UUID), + country_id=_checked(report.country, str), + year=_checked(report.year, int), + household_id=_optional(report.household_id, UUID), + baseline_simulation_id=_checked(report.baseline_simulation_id, UUID), + reform_simulation_id=_optional(report.reform_simulation_id, UUID), status=ReportStatus.NOT_RUN if run is None else run.status, run=run, ) @@ -270,9 +295,10 @@ def _report_answer(call: Callable[[], Response[Any]]) -> HouseholdReportRecord: """Run one report route call and return the report it answered. The generated client keeps a nested value it cannot parse as it came, - such as a run status it does not know or a calculation of another shape, - so the record is built here under the same rule as the parse: an answer - that does not parse means the entrypoint cannot be used. + such as a run status it does not know, a calculation of another shape or + a nullable UUID that is not one, so the record is built and checked here + under the same rule as the parse: an answer that does not parse means the + entrypoint cannot be used. """ answer = _parsed(call, HouseholdReportResponse) try: diff --git a/tests/unit/libs/test_simulation_household_reports.py b/tests/unit/libs/test_simulation_household_reports.py index f966e9f99..885238dcf 100644 --- a/tests/unit/libs/test_simulation_household_reports.py +++ b/tests/unit/libs/test_simulation_household_reports.py @@ -591,3 +591,95 @@ def test_a_success_answer_that_does_not_parse_means_the_entrypoint_is_unavailabl with pytest.raises(SimulationServiceUnavailableError): entrypoint.adapter().get_report(REPORT_ID) + + +@pytest.mark.parametrize( + "answer", + [ + _report_body(household_id="not-a-uuid"), + _report_body(report_run_id="not-a-uuid"), + _report_body(catalog_version_id="not-a-uuid"), + _report_body(reform_simulation_id="not-a-uuid"), + _report_body(country=7), + _report_body( + baseline={ + **_calculation_body(BASELINE_RUN_ID, BASELINE_SIMULATION_ID, None), + "policy_id": "not-a-uuid", + } + ), + _report_body( + baseline=_calculation_body( + BASELINE_RUN_ID, BASELINE_SIMULATION_ID, None, dispatched_at="yesterday" + ) + ), + _report_body( + baseline={ + **_calculation_body(BASELINE_RUN_ID, BASELINE_SIMULATION_ID, None), + "result": { + "household": CALCULATED_HOUSEHOLD, + "warnings": [7], + "spm_config": None, + "spm_provenance": None, + "bundle": None, + }, + } + ), + ], + ids=[ + "household-id", + "report-run-id", + "catalog-version-id", + "reform-simulation-id", + "country", + "policy-id", + "dispatched-at", + "warnings", + ], +) +def test_a_value_the_client_passes_unchecked_means_the_entrypoint_is_unavailable( + answer: dict, +) -> None: + """The generated client copies some fields without checking their type.""" + entrypoint = SimulationEntrypointStub(httpx.Response(200, json=answer)) + + with pytest.raises(SimulationServiceUnavailableError): + entrypoint.adapter().get_report(REPORT_ID) + + +def test_a_well_formed_answer_passes_the_type_checks_unchanged() -> None: + failure = { + "code": "worker_timeout", + "message": "The call timed out.", + "detail": None, + } + answer = _report_body( + status="failed", + reform=_calculation_body( + REFORM_RUN_ID, + REFORM_SIMULATION_ID, + REFORM_POLICY_ID, + status="failed", + error=failure, + ), + ) + entrypoint = SimulationEntrypointStub(httpx.Response(200, json=answer)) + + report = entrypoint.adapter().get_report(REPORT_ID) + + assert report.run is not None + assert report.run.baseline == SUCCEEDED_REPORT.run.baseline + assert report.run.reform == HouseholdCalculationRecord( + id=REFORM_RUN_ID, + simulation_id=REFORM_SIMULATION_ID, + policy_id=REFORM_POLICY_ID, + status="failed", + attempt=1, + trigger="initial", + error=HouseholdCalculationError( + code="worker_timeout", message="The call timed out.", detail=None + ), + result=None, + dispatched_at=CREATED_AT_VALUE, + completed_at=None, + created_at=CREATED_AT_VALUE, + ) From c509918b5818cd2b3447ec5933e322c60ad5b82d Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 29 Oct 2026 23:35:46 +0400 Subject: [PATCH 16/21] Leave an unreadable economy report out of a listing without a household lookup A listing left out an economy report whose stored result cannot be read back, and then looked for its ID among household reports, so an unavailable household route answered 503 where the economy listing alone answered 200. The economy service now names the unreadable IDs beside the reports it lists, and only IDs it does not have are looked for among household reports. Co-Authored-By: Claude Opus 5.5 --- .../fastapi_routes/dependencies.py | 11 ++++- .../services/v2/economy_reports/services.py | 47 +++++++++++-------- .../services/v2/economy_reports/types.py | 22 ++++++++- .../services/v2/report_kinds/services.py | 18 ++++--- .../services/v2/report_kinds/types.py | 11 +++-- tests/unit/v2/test_report_kinds_service.py | 21 +++++++++ 6 files changed, 98 insertions(+), 32 deletions(-) diff --git a/policyengine_api/fastapi_routes/dependencies.py b/policyengine_api/fastapi_routes/dependencies.py index 240982da4..3db2fe60b 100644 --- a/policyengine_api/fastapi_routes/dependencies.py +++ b/policyengine_api/fastapi_routes/dependencies.py @@ -30,7 +30,10 @@ SimulationReferenceChecker, ) from policyengine_api.services.v2.countries.types import CountryRead - from policyengine_api.services.v2.economy_reports.types import EconomyReportFields + from policyengine_api.services.v2.economy_reports.types import ( + EconomyReportFields, + EconomyReportListing, + ) from policyengine_api.services.v2.household_reports.types import ( HouseholdReportSimulation, ) @@ -388,6 +391,12 @@ def list_reports( """Return the country's reports among ``report_ids``, in order, once each.""" ... + def find_reports( + self, *, country_id: str, report_ids: Sequence[UUID] + ) -> EconomyReportListing: + """List the country's reports among ``report_ids``, naming the unreadable.""" + ... + def list_report_runs( self, *, country_id: str, report_id: UUID ) -> tuple[EconomyReportRunRecord, ...]: diff --git a/policyengine_api/services/v2/economy_reports/services.py b/policyengine_api/services/v2/economy_reports/services.py index 14cb7b8a9..f8823452e 100644 --- a/policyengine_api/services/v2/economy_reports/services.py +++ b/policyengine_api/services/v2/economy_reports/services.py @@ -28,6 +28,9 @@ economy_report_submission, reports_of_country, ) +from policyengine_api.services.v2.economy_reports.types import ( + EconomyReportListing, +) from policyengine_api.services.v2.economy_reports.validators import ( economy_reports_supported, report_not_found, @@ -245,22 +248,32 @@ def list_report_runs( """ return self._runs_in_country(country_id=country_id, report_id=report_id) - def _report_or_none(self, report_id: UUID) -> EconomyReportRecord | None: - """Read a listed report; leave out an unknown one or an unreadable one. + def find_reports( + self, *, country_id: str, report_ids: Sequence[UUID] + ) -> EconomyReportListing: + """List the country's reports among ``report_ids``, and name the unreadable. A report whose stored result cannot be read back is logged and left out - of listings, as the caller can tell from the IDs it asked for. + of the listing, as the caller can tell from the IDs it asked for, and + its ID is named, because it is not missing. """ - try: - return self._simulation.get_report(report_id) - except SimulationRecordNotFoundError: - return None - except SimulationRecordUnreadableError: - logger.warning( - "v2_economy_report_unreadable_in_listing", - extra={"policyengine_attributes": {"report_id": str(report_id)}}, - ) - return None + found: list[EconomyReportRecord] = [] + unreadable_report_ids: list[UUID] = [] + for report_id in dict.fromkeys(report_ids): + try: + found.append(self._simulation.get_report(report_id)) + except SimulationRecordNotFoundError: + continue + except SimulationRecordUnreadableError: + logger.warning( + "v2_economy_report_unreadable_in_listing", + extra={"policyengine_attributes": {"report_id": str(report_id)}}, + ) + unreadable_report_ids.append(report_id) + return EconomyReportListing( + reports=reports_of_country(found, country_id=country_id), + unreadable_report_ids=tuple(unreadable_report_ids), + ) def list_reports( self, *, country_id: str, report_ids: Sequence[UUID] @@ -271,10 +284,4 @@ def list_reports( cannot be read back are left out. Listing a report with no run does not start one; reading it does. """ - return reports_of_country( - ( - self._report_or_none(report_id) - for report_id in dict.fromkeys(report_ids) - ), - country_id=country_id, - ) + return self.find_reports(country_id=country_id, report_ids=report_ids).reports diff --git a/policyengine_api/services/v2/economy_reports/types.py b/policyengine_api/services/v2/economy_reports/types.py index 5ac6108fc..020ed2b0d 100644 --- a/policyengine_api/services/v2/economy_reports/types.py +++ b/policyengine_api/services/v2/economy_reports/types.py @@ -6,7 +6,8 @@ from __future__ import annotations -from typing import Annotated, Literal +from dataclasses import dataclass +from typing import TYPE_CHECKING, Annotated, Literal from pydantic import BaseModel, ConfigDict, Field, StrictBool @@ -14,6 +15,13 @@ SimulationChoice, ) +if TYPE_CHECKING: + from uuid import UUID + + from policyengine_api.services.v2.simulation_entrypoint.types import ( + EconomyReportRecord, + ) + # The bounds and form the Simulation Entrypoint accepts. ReportYear = Annotated[int, Field(ge=1900, le=2200, description="The report's year")] CatalogKey = Annotated[str, Field(min_length=1, max_length=255, pattern=r"^\S+$")] @@ -45,3 +53,15 @@ class EconomyReportRunFields(StrictEconomyReportInput): """A re-run; ``force`` calculates both simulations again instead of reusing runs.""" force: StrictBool = False + + +@dataclass(frozen=True) +class EconomyReportListing: + """The country's economy reports among some IDs, and the IDs left unreadable. + + ``unreadable_report_ids`` are reports the simulation service has but whose + stored result it cannot read back; a listing leaves them out. + """ + + reports: tuple[EconomyReportRecord, ...] + unreadable_report_ids: tuple[UUID, ...] diff --git a/policyengine_api/services/v2/report_kinds/services.py b/policyengine_api/services/v2/report_kinds/services.py index 8b965a67b..bbf19be50 100644 --- a/policyengine_api/services/v2/report_kinds/services.py +++ b/policyengine_api/services/v2/report_kinds/services.py @@ -151,16 +151,22 @@ def list_reports( """Return the country's reports of both kinds among ``report_ids``. They are in that order, once each. IDs that are not economy reports - of the country are looked for among its household reports. + the simulation service has are looked for among the country's + household reports; an economy report whose stored result cannot be + read back is left out, as before, without that lookup. """ requested = tuple(dict.fromkeys(report_ids)) + economy_listing = self._economy_reports().find_reports( + country_id=country_id, report_ids=requested + ) found: dict[UUID, EconomyReportRecord | HouseholdReportRecord] = { - report.id: report - for report in self._economy_reports().list_reports( - country_id=country_id, report_ids=requested - ) + report.id: report for report in economy_listing.reports } - remaining = [report_id for report_id in requested if report_id not in found] + # An economy report whose result cannot be read back is not missing. + not_missing = set(found) | set(economy_listing.unreadable_report_ids) + remaining = [ + report_id for report_id in requested if report_id not in not_missing + ] if remaining: found.update( (report.id, report) diff --git a/policyengine_api/services/v2/report_kinds/types.py b/policyengine_api/services/v2/report_kinds/types.py index ab4d49b35..b3dc21850 100644 --- a/policyengine_api/services/v2/report_kinds/types.py +++ b/policyengine_api/services/v2/report_kinds/types.py @@ -8,7 +8,10 @@ from collections.abc import Sequence from uuid import UUID - from policyengine_api.services.v2.economy_reports.types import EconomyReportFields + from policyengine_api.services.v2.economy_reports.types import ( + EconomyReportFields, + EconomyReportListing, + ) from policyengine_api.services.v2.household_reports.types import ( HouseholdReportFields, HouseholdReportRecord, @@ -45,10 +48,10 @@ def run_report( """Run a report again on the current catalog version.""" ... - def list_reports( + def find_reports( self, *, country_id: str, report_ids: Sequence[UUID] - ) -> tuple[EconomyReportRecord, ...]: - """Return the country's reports among ``report_ids``, in order, once each.""" + ) -> EconomyReportListing: + """List the country's reports among ``report_ids``, naming the unreadable.""" ... def list_report_runs( diff --git a/tests/unit/v2/test_report_kinds_service.py b/tests/unit/v2/test_report_kinds_service.py index 9f1bbe911..aa7110270 100644 --- a/tests/unit/v2/test_report_kinds_service.py +++ b/tests/unit/v2/test_report_kinds_service.py @@ -237,3 +237,24 @@ def test_an_unavailable_economy_rerun_asks_no_household_report() -> None: service.run_report(country_id="us", report_id=HOUSEHOLD_REPORT_ID, force=False) assert household_simulation.calls == [] + + +def test_an_unreadable_economy_report_in_a_listing_is_no_household_lookup() -> None: + """An economy report whose result cannot be read back is left out, as before. + + It is not missing, so its ID is not looked for among household reports: + an unavailable household route leaves the listing as the economy one was. + """ + economy_simulation = FakeEconomyReportSimulation( + report_record(run=report_run_record()) + ) + economy_simulation.unreadable_results.add(REPORT_ID) + household_simulation = FakeHouseholdReportSimulation() + household_simulation.error = SimulationServiceUnavailableError("unreachable") + + reports = _service(economy_simulation, household_simulation).list_reports( + country_id="us", report_ids=[REPORT_ID] + ) + + assert reports == () + assert household_simulation.calls == [] From 2a0765dfb79e76eda8e6c93dd7436dc44f3c6703 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 29 Oct 2026 23:36:37 +0400 Subject: [PATCH 17/21] Keep the economy refusal for a country with neither kind, and describe re-runs of both kinds Re-running a report ID in a country with neither economy reports nor household calculations answers the economy refusal it answered before, instead of the household one. The re-run request's description covers both kinds' reuse rules, and the household report check takes the report's ID as a UUID. Co-Authored-By: Claude Opus 5.5 --- .../v2/reports/request_models.py | 8 +++++-- .../v2/household_reports/validators.py | 4 +++- .../services/v2/report_kinds/services.py | 9 +++++++- tests/unit/v2/test_household_report_routes.py | 7 ++++++ tests/unit/v2/test_report_kinds_service.py | 23 +++++++++++++++++++ 5 files changed, 47 insertions(+), 4 deletions(-) diff --git a/policyengine_api/fastapi_routes/v2/reports/request_models.py b/policyengine_api/fastapi_routes/v2/reports/request_models.py index 28d274679..69166ce29 100644 --- a/policyengine_api/fastapi_routes/v2/reports/request_models.py +++ b/policyengine_api/fastapi_routes/v2/reports/request_models.py @@ -42,7 +42,11 @@ class HouseholdReportCreateRequest(HouseholdReportFields): class EconomyReportRunRequest(EconomyReportRunFields): - """A re-run on the current catalog version; ``{}`` reuses existing runs. + """A re-run of an economy or household report on the current catalog version. - With ``force``, both simulations are calculated again as new attempts. + ``{}`` reuses existing runs: an economy report's simulation runs for that + version and year, or a household report's calculations that are + unfinished or succeeded within the last 28 days. With ``force``, each of + the report's simulations (one for a household report of the baseline + alone) is calculated again as a new attempt. """ diff --git a/policyengine_api/services/v2/household_reports/validators.py b/policyengine_api/services/v2/household_reports/validators.py index 22209d547..f6c46fee3 100644 --- a/policyengine_api/services/v2/household_reports/validators.py +++ b/policyengine_api/services/v2/household_reports/validators.py @@ -5,6 +5,8 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: + from uuid import UUID + from policyengine_api.data.v2.models import Country from policyengine_api.services.v2.household_reports.types import ( HouseholdReportRecord, @@ -45,7 +47,7 @@ def require_default_year(default_year: int | None) -> int: def require_report_in_country( - report: HouseholdReportRecord | None, *, country_id: str, report_id: object + report: HouseholdReportRecord | None, *, country_id: str, report_id: UUID ) -> HouseholdReportRecord: """Return the report, or raise ``HouseholdReportNotFoundError``. diff --git a/policyengine_api/services/v2/report_kinds/services.py b/policyengine_api/services/v2/report_kinds/services.py index bbf19be50..ae186c83c 100644 --- a/policyengine_api/services/v2/report_kinds/services.py +++ b/policyengine_api/services/v2/report_kinds/services.py @@ -20,6 +20,7 @@ ) from policyengine_api.services.v2.household_reports.validators import ( HouseholdReportNotFoundError, + HouseholdReportsUnsupportedError, ) from policyengine_api.services.v2.report_kinds.validators import report_not_found @@ -138,9 +139,15 @@ def run_report( return self._household_reports().run_report( country_id=country_id, report_id=report_id, force=force ) - except HouseholdReportNotFoundError as error: + except ( + HouseholdReportNotFoundError, + HouseholdReportsUnsupportedError, + ) as error: + # A country with neither kind keeps the economy refusal it had. if isinstance(economy_refusal, EconomyReportsUnsupportedError): raise economy_refusal from error + if isinstance(error, HouseholdReportsUnsupportedError): + raise raise report_not_found( country_id=country_id, reference=str(report_id) ) from error diff --git a/tests/unit/v2/test_household_report_routes.py b/tests/unit/v2/test_household_report_routes.py index df1694011..4b5b6945a 100644 --- a/tests/unit/v2/test_household_report_routes.py +++ b/tests/unit/v2/test_household_report_routes.py @@ -497,3 +497,10 @@ def test_openapi_says_household_reports_do_not_list_their_runs() -> None: "404" ]["description"] assert "household report" in not_found + + +def test_openapi_describes_a_rerun_of_either_kind() -> None: + schema = _client(FakeHouseholdReportSimulation()).get("/v2/openapi.json").json() + + run_request = schema["components"]["schemas"]["EconomyReportRunRequest"] + assert "household report" in run_request["description"] diff --git a/tests/unit/v2/test_report_kinds_service.py b/tests/unit/v2/test_report_kinds_service.py index aa7110270..b61c6565c 100644 --- a/tests/unit/v2/test_report_kinds_service.py +++ b/tests/unit/v2/test_report_kinds_service.py @@ -258,3 +258,26 @@ def test_an_unreadable_economy_report_in_a_listing_is_no_household_lookup() -> N assert reports == () assert household_simulation.calls == [] + + +def test_a_rerun_in_a_country_with_neither_kind_keeps_the_economy_refusal() -> None: + sessions = household_report_store() + set_country_support( + sessions, "us", household_enabled=False, society_wide_enabled=False + ) + household_simulation = FakeHouseholdReportSimulation() + service = V2ReportService( + economy_reports=lambda: economy_report_service( + FakeEconomyReportSimulation(), sessions + ), + household_reports=lambda: household_report_service( + household_simulation, sessions=sessions + ), + ) + + with pytest.raises( + EconomyReportsUnsupportedError, match="Economy reports are not available" + ): + service.run_report(country_id="us", report_id=UNKNOWN_REPORT_ID, force=False) + + assert household_simulation.calls == [] From 423bb3b7afe19029476b17c27345846741b20116 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 29 Oct 2026 23:59:30 +0400 Subject: [PATCH 18/21] Let a household report ask for every variable the household lacks A household report can set add_missing_variables, which the simulation service applies to both calculations, as /calculate-full and the household calculation routes do. Without it, a household stored with its inputs alone came back with none of the values a report shows, such as net income. Co-Authored-By: Claude Opus 5.5 --- .../v2/reports/request_models.py | 4 +++- .../libs/simulation_household_reports.py | 10 +++++++--- .../v2/household_reports/transformations.py | 1 + .../services/v2/household_reports/types.py | 9 +++++++-- .../libs/test_simulation_household_reports.py | 19 ++++++++++++++++++ tests/unit/v2/test_household_report_routes.py | 17 ++++++++++++++++ .../unit/v2/test_household_report_service.py | 20 +++++++++++++++++++ 7 files changed, 74 insertions(+), 6 deletions(-) diff --git a/policyengine_api/fastapi_routes/v2/reports/request_models.py b/policyengine_api/fastapi_routes/v2/reports/request_models.py index 69166ce29..d37a252fa 100644 --- a/policyengine_api/fastapi_routes/v2/reports/request_models.py +++ b/policyengine_api/fastapi_routes/v2/reports/request_models.py @@ -29,7 +29,9 @@ class HouseholdReportCreateRequest(HouseholdReportFields): ``baseline`` and ``reform`` each name a v2 policy of the country, and an empty object means current law; ``reform`` is null for a report of the - baseline alone. It is calculated for the household's ``default_year``. + baseline alone. It is calculated for the household's ``default_year``; + with ``add_missing_variables``, each calculation also adds every variable + the household lacks, as ``/calculate-full`` does. """ country_id: CountryId diff --git a/policyengine_api/libs/simulation_household_reports.py b/policyengine_api/libs/simulation_household_reports.py index 9d827c692..585fa47c3 100644 --- a/policyengine_api/libs/simulation_household_reports.py +++ b/policyengine_api/libs/simulation_household_reports.py @@ -48,9 +48,7 @@ submit_household_report, ) from policyengine_api_simulation_client.models import ( - HouseholdPolicyChoice as GeneratedPolicyChoice, -) -from policyengine_api_simulation_client.models import ( + HouseholdCalculationOptions, HouseholdRefusalResponse, HouseholdReportResponse, HouseholdReportRunSubmission, @@ -58,6 +56,9 @@ HouseholdRequestFieldError, HouseholdRouteError, ) +from policyengine_api_simulation_client.models import ( + HouseholdPolicyChoice as GeneratedPolicyChoice, +) from policyengine_api_simulation_client.models import ( HouseholdReportSubmission as GeneratedSubmission, ) @@ -335,6 +336,9 @@ def submit_report( reform=( None if submission.reform is None else _policy_choice(submission.reform) ), + options=HouseholdCalculationOptions( + add_missing_variables=submission.add_missing_variables + ), trigger=GeneratedRunTrigger.INITIAL, ) return _report_answer( diff --git a/policyengine_api/services/v2/household_reports/transformations.py b/policyengine_api/services/v2/household_reports/transformations.py index bfb1b8574..e9ab8d6e1 100644 --- a/policyengine_api/services/v2/household_reports/transformations.py +++ b/policyengine_api/services/v2/household_reports/transformations.py @@ -32,6 +32,7 @@ def household_report_submission( household_id=report_input.household_id, baseline=report_input.baseline, reform=report_input.reform, + add_missing_variables=report_input.add_missing_variables, ) diff --git a/policyengine_api/services/v2/household_reports/types.py b/policyengine_api/services/v2/household_reports/types.py index 5dd336241..700101322 100644 --- a/policyengine_api/services/v2/household_reports/types.py +++ b/policyengine_api/services/v2/household_reports/types.py @@ -17,7 +17,7 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, Literal, Protocol -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, StrictBool from policyengine_api.query_parameters import ResourceId @@ -49,13 +49,17 @@ class HouseholdReportFields(StrictHouseholdReportInput): ``household_id`` names a household stored through ``/v2/households``. ``reform`` is null for a report of the baseline alone. The report is for - the household's ``default_year``. + the household's ``default_year``. With ``add_missing_variables``, each + calculation adds every yearly, monthly and eternal variable the household + lacks, as ``/calculate-full`` does; otherwise it fills in only the values + the household asks for. """ kind: Literal["household"] household_id: ResourceId baseline: HouseholdPolicyChoice reform: HouseholdPolicyChoice | None = None + add_missing_variables: StrictBool = False @dataclass(frozen=True) @@ -70,6 +74,7 @@ class HouseholdReportSubmission: household_id: UUID baseline: HouseholdPolicyChoice reform: HouseholdPolicyChoice | None + add_missing_variables: bool = False @dataclass(frozen=True) diff --git a/tests/unit/libs/test_simulation_household_reports.py b/tests/unit/libs/test_simulation_household_reports.py index 885238dcf..d10029116 100644 --- a/tests/unit/libs/test_simulation_household_reports.py +++ b/tests/unit/libs/test_simulation_household_reports.py @@ -220,6 +220,7 @@ def test_submit_sends_the_household_and_policies_and_reads_the_new_report() -> N "household_id": str(HOUSEHOLD_ID), "baseline": {"policy_id": None}, "reform": {"policy_id": str(REFORM_POLICY_ID)}, + "options": {"add_missing_variables": False}, "force": False, "trigger": "initial", } @@ -683,3 +684,21 @@ def test_a_well_formed_answer_passes_the_type_checks_unchanged() -> None: completed_at=None, created_at=CREATED_AT_VALUE, ) + + +def test_a_report_of_every_variable_asks_for_the_missing_ones() -> None: + entrypoint = SimulationEntrypointStub(httpx.Response(202, json=_report_body())) + submission = HouseholdReportSubmission( + country_id="us", + policyengine_version="6.7.5", + household_id=HOUSEHOLD_ID, + baseline=HouseholdPolicyChoice(), + reform=None, + add_missing_variables=True, + ) + + entrypoint.adapter().submit_report(submission) + + assert json.loads(entrypoint.requests[0].content)["options"] == { + "add_missing_variables": True + } diff --git a/tests/unit/v2/test_household_report_routes.py b/tests/unit/v2/test_household_report_routes.py index 4b5b6945a..ca3c6e2c8 100644 --- a/tests/unit/v2/test_household_report_routes.py +++ b/tests/unit/v2/test_household_report_routes.py @@ -504,3 +504,20 @@ def test_openapi_describes_a_rerun_of_either_kind() -> None: run_request = schema["components"]["schemas"]["EconomyReportRunRequest"] assert "household report" in run_request["description"] + + +@pytest.mark.parametrize( + ("value", "status"), [(True, 201), (False, 201), ("yes", 422), (None, 422)] +) +def test_a_household_report_may_ask_for_every_variable(value, status: int) -> None: + simulation = FakeHouseholdReportSimulation() + + response = _client(simulation).post( + "/v2/reports?country_id=us", + json={**SUBMISSION, "add_missing_variables": value}, + ) + + assert response.status_code == status, response.text + if status == 201: + ((_name, submission),) = simulation.calls + assert submission.add_missing_variables is value diff --git a/tests/unit/v2/test_household_report_service.py b/tests/unit/v2/test_household_report_service.py index f20ebce99..801cedd3a 100644 --- a/tests/unit/v2/test_household_report_service.py +++ b/tests/unit/v2/test_household_report_service.py @@ -89,6 +89,7 @@ def test_a_report_is_submitted_by_ids_on_the_current_version() -> None: household_id=HOUSEHOLD_ID, baseline=HouseholdPolicyChoice(), reform=HouseholdPolicyChoice(policy_id=HOUSEHOLD_REFORM_POLICY_ID), + add_missing_variables=False, ), ) ] @@ -394,3 +395,22 @@ def test_a_rerun_in_a_country_without_household_calculations_is_refused_first() ) assert simulation.calls == [] + + +def test_a_report_can_ask_for_every_variable_the_household_lacks() -> None: + simulation = FakeHouseholdReportSimulation() + + household_report_service(simulation).submit_report( + country_id="us", + report_input=HouseholdReportFields( + kind="household", + household_id=HOUSEHOLD_ID, + baseline=HouseholdPolicyChoice(), + reform=None, + add_missing_variables=True, + ), + ) + + ((_name, submission),) = simulation.calls + assert isinstance(submission, HouseholdReportSubmission) + assert submission.add_missing_variables is True From 5c1afd87798efc84c65b9e56e59305336e6b54ae Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Fri, 30 Oct 2026 00:06:14 +0400 Subject: [PATCH 19/21] Say which variables a household report adds, and that a re-run keeps the choice Co-Authored-By: Claude Opus 5.5 --- policyengine_api/fastapi_routes/v2/reports/request_models.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/policyengine_api/fastapi_routes/v2/reports/request_models.py b/policyengine_api/fastapi_routes/v2/reports/request_models.py index d37a252fa..57dde3783 100644 --- a/policyengine_api/fastapi_routes/v2/reports/request_models.py +++ b/policyengine_api/fastapi_routes/v2/reports/request_models.py @@ -30,8 +30,9 @@ class HouseholdReportCreateRequest(HouseholdReportFields): ``baseline`` and ``reform`` each name a v2 policy of the country, and an empty object means current law; ``reform`` is null for a report of the baseline alone. It is calculated for the household's ``default_year``; - with ``add_missing_variables``, each calculation also adds every variable - the household lacks, as ``/calculate-full`` does. + with ``add_missing_variables``, each calculation also adds every yearly, + monthly and eternal variable the household lacks, as ``/calculate-full`` + does. A re-run keeps the choice made when the report was created. """ country_id: CountryId From a5094197c8a15e72813473795af55dcb85d97241 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Fri, 30 Oct 2026 16:56:00 +0400 Subject: [PATCH 20/21] Remove the households the household report integration test stores The cross-database integration job runs every test in one disposable database, and a later step's dual-write test counts households, so the household this test stored made that count 2 instead of 1. The fixture now deletes the households a test stored and checks none is left. Co-Authored-By: Claude Opus 5.5 --- .../integration/test_v2_household_reports.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/tests/integration/test_v2_household_reports.py b/tests/integration/test_v2_household_reports.py index 8252577b2..bed28f823 100644 --- a/tests/integration/test_v2_household_reports.py +++ b/tests/integration/test_v2_household_reports.py @@ -88,12 +88,16 @@ def runtime_sessions(managed_v2_roles, v2_engine_as_role, disposable_v2_database """Migrate, make the US household-only, add a catalog version, and yield sessions. The sessions act as the runtime role. The US row and the test catalog - version are restored and removed afterwards. + version are restored and removed afterwards, and so is any household a + test stored: later tests in the same database count households. """ command.upgrade(Config(str(REPO / "alembic-v2.ini")), "head") owner_engine = create_engine(disposable_v2_database_url, poolclass=NullPool) runtime_engine = v2_engine_as_role(managed_v2_roles.runtime, poolclass=NullPool) with owner_engine.begin() as connection: + households_before = set( + connection.execute(text("SELECT id FROM households")).scalars() + ) connection.execute( text("UPDATE countries SET society_wide_enabled = false WHERE id = 'us'") ) @@ -127,8 +131,21 @@ def runtime_sessions(managed_v2_roles, v2_engine_as_role, disposable_v2_database text("DELETE FROM tax_benefit_models WHERE name = :name"), {"name": TEST_MODEL_NAME}, ) + stored_by_the_test = ( + set(connection.execute(text("SELECT id FROM households")).scalars()) + - households_before + ) + if stored_by_the_test: + connection.execute( + text("DELETE FROM households WHERE id = ANY(:household_ids)"), + {"household_ids": list(stored_by_the_test)}, + ) + households_after = set( + connection.execute(text("SELECT id FROM households")).scalars() + ) owner_engine.dispose() runtime_engine.dispose() + assert households_after == households_before, "the test left households behind" def _client(sessions, simulation: FakeHouseholdReportSimulation) -> TestClient: From 202880c8a2a0b9de135aa6cc33bd8efa79ee57f5 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Fri, 30 Oct 2026 17:01:19 +0400 Subject: [PATCH 21/21] Check for households left behind after the removal commits, in its own transaction The removal ran in the same transaction as restoring the US row, and the check read the IDs inside it, so it could not fail. Restoring, removing and checking now run separately, and the check reads in a new connection. Co-Authored-By: Claude Opus 5.5 --- tests/integration/test_v2_household_reports.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/integration/test_v2_household_reports.py b/tests/integration/test_v2_household_reports.py index bed28f823..f94a3c43e 100644 --- a/tests/integration/test_v2_household_reports.py +++ b/tests/integration/test_v2_household_reports.py @@ -123,6 +123,8 @@ def runtime_sessions(managed_v2_roles, v2_engine_as_role, disposable_v2_database try: yield sessionmaker(runtime_engine, class_=Session) finally: + # Each step in its own transaction: a failed household removal must + # not undo restoring the US row and removing the catalog version. with owner_engine.begin() as connection: connection.execute( text("UPDATE countries SET society_wide_enabled = true WHERE id = 'us'") @@ -131,6 +133,7 @@ def runtime_sessions(managed_v2_roles, v2_engine_as_role, disposable_v2_database text("DELETE FROM tax_benefit_models WHERE name = :name"), {"name": TEST_MODEL_NAME}, ) + with owner_engine.begin() as connection: stored_by_the_test = ( set(connection.execute(text("SELECT id FROM households")).scalars()) - households_before @@ -140,6 +143,8 @@ def runtime_sessions(managed_v2_roles, v2_engine_as_role, disposable_v2_database text("DELETE FROM households WHERE id = ANY(:household_ids)"), {"household_ids": list(stored_by_the_test)}, ) + # Read after the removal committed, as a later test would. + with owner_engine.connect() as connection: households_after = set( connection.execute(text("SELECT id FROM households")).scalars() )