diff --git a/docs/engineering/skills/typing-only-imports.md b/docs/engineering/skills/typing-only-imports.md index c20172ed2..67988950b 100644 --- a/docs/engineering/skills/typing-only-imports.md +++ b/docs/engineering/skills/typing-only-imports.md @@ -81,7 +81,8 @@ misses breaks the model at runtime. with it (`policyengine_fastapi`, `policyengine_simulation_contract`, `policyengine_simulation_observability` and `policyengine_stage12_persistence`); -- the entrypoint's, over `policyengine_simulation_entry`. +- the entrypoint's, over `policyengine_simulation_entry` and + `policyengine_simulation_persistence`, which only the entrypoint installs. Each imports every module of its packages and checks every Pydantic model a module-level class statement defines: diff --git a/libs/policyengine-simulation-persistence/README.md b/libs/policyengine-simulation-persistence/README.md index 7df36c97e..2cd0e20d7 100644 --- a/libs/policyengine-simulation-persistence/README.md +++ b/libs/policyengine-simulation-persistence/README.md @@ -62,6 +62,31 @@ Scheduler sweep re-enqueues running jobs nobody has checked for five minutes and fails jobs whose dispatch stalled for ten. Status reads use only these tables (`sim.persistence.status-reads-never-advance-work`). +## Report tables + +`simulation.reports` records economy and household reports (`kind`). A report +migrated from the Public API keeps its v1 report ID in `legacy_report_id` +(unique) and starts `not_started` with no job. Once a job is attached the +report is `submitted`, and its progress is that job's status, so the report +and job never disagree; a check constraint ties the status to the presence of +`job_id`. + +`simulation.report_results` holds one aggregate per finished report job, +keyed by the job, so reports with identical inputs (one shared job) share one +stored result. `JobStore.complete_report` validates the aggregate with the +Pydantic model registered for its report kind and schema version in +`REPORT_AGGREGATE_MODELS` (economy, version 1: the contract's +`SingleYearMacroOutput`) before writing anything, then stores it as JSONB with +`aggregate_schema_version`, `aggregate_sha256`, summary columns (budget +impacts, households, baseline net income, model and data versions) and +`artifacts`, a JSON array of validated `ReportArtifact` references. It +finishes the job in the same transaction and, like every job write, only +while the job is running. + +A job kind with `report_kind` set stores its result here; other kinds use +`job_results`. No report job kind is registered and nothing creates report +rows yet; household aggregates get a model when their contract exists. + ## Database roles | Role | Used by | Privileges | diff --git a/libs/policyengine-simulation-persistence/pyproject.toml b/libs/policyengine-simulation-persistence/pyproject.toml index 7c4a6f847..7ab6d0ef4 100644 --- a/libs/policyengine-simulation-persistence/pyproject.toml +++ b/libs/policyengine-simulation-persistence/pyproject.toml @@ -8,7 +8,10 @@ authors = [ license = {file = "../../LICENSE"} requires-python = ">=3.13,<3.14" dependencies = [ + # Report aggregates are validated by the contract's Pydantic models. + "policyengine-simulation-contract", "psycopg[binary]>=3.2,<4", + "pydantic>=2,<3", "sqlalchemy>=2,<3", ] @@ -33,6 +36,10 @@ sources = ["src"] only-include = ["src/policyengine_simulation_persistence"] packages = ["src/policyengine_simulation_persistence"] +[tool.uv.sources] +policyengine-simulation-contract = { path = "../policyengine-simulation-contract", editable = true } +policyengine-simulation-observability = { path = "../policyengine-simulation-observability", editable = true } + [tool.pytest.ini_options] pythonpath = ["src"] testpaths = ["tests"] diff --git a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/canonical.py b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/canonical.py new file mode 100644 index 000000000..9ac07f82f --- /dev/null +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/canonical.py @@ -0,0 +1,28 @@ +"""Canonical JSON hashing and the clock shared by the job and report stores.""" + +from __future__ import annotations + +import hashlib +import json +from datetime import UTC, datetime + + +def canonical_json(value: object) -> bytes: + """Encode ``value`` as JSON with sorted keys and no whitespace.""" + return json.dumps( + value, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + allow_nan=False, + ).encode("utf-8") + + +def result_sha256(result: object) -> str: + """Return the SHA-256 of a result as canonical JSON.""" + return hashlib.sha256(canonical_json(result)).hexdigest() + + +def utc_now() -> datetime: + """Return the current time in UTC.""" + return datetime.now(UTC) diff --git a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/jobs.py b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/jobs.py index 0712393f3..66cb8e2f0 100644 --- a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/jobs.py +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/jobs.py @@ -18,9 +18,7 @@ from __future__ import annotations import hashlib -import json from dataclasses import dataclass, fields -from datetime import UTC, datetime from enum import StrEnum from typing import TYPE_CHECKING, Any from uuid import UUID, uuid4 @@ -46,10 +44,23 @@ from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.dialects.postgresql import insert as postgres_insert +from policyengine_simulation_persistence.canonical import ( + canonical_json, + result_sha256, + utc_now, +) +from policyengine_simulation_persistence.reports import ( + ReportArtifact, + ReportKind, + ReportResultRecord, + report_result_values, + report_results, +) from policyengine_simulation_persistence.schema import simulation_metadata if TYPE_CHECKING: from collections.abc import Callable + from datetime import datetime from sqlalchemy import Connection, Engine, RowMapping @@ -131,16 +142,6 @@ class JobStatus(StrEnum): ) -def _canonical_json(value: object) -> bytes: - return json.dumps( - value, - sort_keys=True, - separators=(",", ":"), - ensure_ascii=False, - allow_nan=False, - ).encode("utf-8") - - def input_sha256(*, kind: str, policyengine_version: str, request: object) -> str: """Return the idempotency hash of one calculation's inputs. @@ -148,7 +149,7 @@ def input_sha256(*, kind: str, policyengine_version: str, request: object) -> st as canonical JSON, so the same request under another bundle is another job. """ return hashlib.sha256( - _canonical_json( + canonical_json( { "kind": kind, "policyengine_version": policyengine_version, @@ -158,16 +159,6 @@ def input_sha256(*, kind: str, policyengine_version: str, request: object) -> st ).hexdigest() -def result_sha256(result: object) -> str: - """Return the SHA-256 of a result as canonical JSON.""" - return hashlib.sha256(_canonical_json(result)).hexdigest() - - -def utc_now() -> datetime: - """Return the current time in UTC.""" - return datetime.now(UTC) - - @dataclass(frozen=True) class NewJob: """What a submission records before anything is dispatched.""" @@ -383,6 +374,18 @@ def get_result(self, job_id: UUID) -> JobResultRecord | None: ) return None if row is None else JobResultRecord(**row) + def get_report_result(self, job_id: UUID) -> ReportResultRecord | None: + """Return the stored report result of a succeeded report job, or None.""" + with self.engine.connect() as connection: + row = ( + connection.execute( + select(report_results).where(report_results.c.job_id == job_id) + ) + .mappings() + .first() + ) + return None if row is None else ReportResultRecord.from_row(row) + # --- Collection ------------------------------------------------------------ def record_check(self, job_id: UUID) -> int | None: @@ -400,6 +403,27 @@ def record_check(self, job_id: UUID) -> int | None: .returning(jobs.c.collection_attempts) ).scalar() + def _finish_succeeded( + self, + connection: Connection, + job_id: UUID, + now: datetime, + ) -> bool: + return ( + connection.execute( + update(jobs) + .where(jobs.c.id == job_id, jobs.c.status == JobStatus.RUNNING.value) + .values( + status=JobStatus.SUCCEEDED.value, + completed_at=now, + last_checked_at=now, + updated_at=now, + ) + .returning(jobs.c.id) + ).first() + is not None + ) + def _complete( self, connection: Connection, @@ -409,18 +433,7 @@ def _complete( result_schema_version: int, ) -> bool: now = self._clock() - finished = connection.execute( - update(jobs) - .where(jobs.c.id == job_id, jobs.c.status == JobStatus.RUNNING.value) - .values( - status=JobStatus.SUCCEEDED.value, - completed_at=now, - last_checked_at=now, - updated_at=now, - ) - .returning(jobs.c.id) - ).first() - if finished is None: + if not self._finish_succeeded(connection, job_id, now): return False connection.execute( insert(job_results).values( @@ -453,6 +466,37 @@ def complete( result_schema_version=result_schema_version, ) + def complete_report( + self, + job_id: UUID, + *, + report_kind: ReportKind, + aggregate: object, + aggregate_schema_version: int, + artifacts: tuple[ReportArtifact, ...] = (), + ) -> bool: + """Store a running report job's aggregate and mark the job succeeded. + + The aggregate is validated by the model registered for its report kind + and schema version before anything is written; an invalid aggregate + raises and leaves the job running. Return False, writing nothing, when + another collector already finished the job. + """ + values = report_result_values( + report_kind=report_kind, + aggregate=aggregate, + aggregate_schema_version=aggregate_schema_version, + artifacts=artifacts, + ) + with self.engine.begin() as connection: + now = self._clock() + if not self._finish_succeeded(connection, job_id, now): + return False + connection.execute( + insert(report_results).values(job_id=job_id, created_at=now, **values) + ) + return True + def _finish_failed( self, connection: Connection, diff --git a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/reports.py b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/reports.py new file mode 100644 index 000000000..17a89c343 --- /dev/null +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/reports.py @@ -0,0 +1,392 @@ +"""Report tables: ``simulation.reports`` and ``simulation.report_results``. + +A report is an economy or household comparison a user can save. A report row +created by the one-time migration from the Public API keeps its v1 report ID in +``legacy_report_id`` and starts ``not_started``, with no job. Once a job is +attached the report is ``submitted``, and its progress is that job's status, +so the two never disagree. + +``report_results`` holds one validated aggregate per finished job, so reports +with identical inputs (and therefore one shared job) share one stored result. +The aggregate is JSONB validated by the Pydantic model registered for its +report kind and schema version, stored with its SHA-256, summary columns for +queries, and references to any stored artifacts. + +Nothing writes these tables yet: Step 2 of the API v2 migration adds the +report routes and registers the report job kinds. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, fields +from enum import StrEnum +from typing import TYPE_CHECKING, Annotated, Any +from uuid import UUID, uuid4 + +from policyengine_simulation_contract.macro_output import SingleYearMacroOutput +from pydantic import BaseModel, ConfigDict, Field, TypeAdapter +from sqlalchemy import ( + BigInteger, + CheckConstraint, + Column, + DateTime, + Float, + ForeignKey, + Index, + Integer, + String, + Table, + Text, + Uuid, + select, + text, +) +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.dialects.postgresql import insert as postgres_insert + +from policyengine_simulation_persistence.canonical import result_sha256, utc_now +from policyengine_simulation_persistence.schema import ( + SIMULATION_SCHEMA, + simulation_metadata, +) + +if TYPE_CHECKING: + from collections.abc import Callable + from datetime import datetime + + from sqlalchemy import Engine, RowMapping, Select + +COUNTRY_PATTERN = re.compile(r"[a-z]{2}") + + +class ReportKind(StrEnum): + """What a report compares.""" + + ECONOMY = "economy" + HOUSEHOLD = "household" + + +class ReportStatus(StrEnum): + """Whether a report has a job; a submitted report's progress is its job's.""" + + NOT_STARTED = "not_started" + SUBMITTED = "submitted" + + +class UnsupportedReportAggregateError(ValueError): + """No aggregate model is registered for this report kind and version.""" + + +# The model each report kind's aggregate is validated against, by schema +# version. Household aggregates are registered when their contract exists. +REPORT_AGGREGATE_MODELS: dict[tuple[ReportKind, int], type[BaseModel]] = { + (ReportKind.ECONOMY, 1): SingleYearMacroOutput, +} + + +def validate_report_aggregate( + kind: ReportKind, + schema_version: int, + aggregate: object, +) -> BaseModel: + """Return ``aggregate`` validated by its registered model. + + Raise UnsupportedReportAggregateError when no model is registered, and + pydantic.ValidationError when the aggregate does not match it. + """ + model = REPORT_AGGREGATE_MODELS.get((kind, schema_version)) + if model is None: + msg = f"no aggregate model for {kind.value} reports, version {schema_version}" + raise UnsupportedReportAggregateError(msg) + return model.model_validate(aggregate) + + +def report_summary(kind: ReportKind, aggregate: BaseModel) -> dict[str, Any]: + """Return the summary columns a validated aggregate fills.""" + if kind is ReportKind.ECONOMY and isinstance(aggregate, SingleYearMacroOutput): + budget = aggregate.budget + return { + "budgetary_impact": budget.budgetary_impact, + "tax_revenue_impact": budget.tax_revenue_impact, + "state_tax_revenue_impact": budget.state_tax_revenue_impact, + "benefit_spending_impact": budget.benefit_spending_impact, + "households": budget.households, + "baseline_net_income": budget.baseline_net_income, + "model_version": aggregate.model_version, + "data_version": aggregate.data_version, + } + return {} + + +class ReportArtifact(BaseModel): + """A stored file a report result was computed from or exported to.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + role: Annotated[str, Field(pattern=r"^[a-z][a-z0-9_]{0,62}$")] + uri: Annotated[str, Field(pattern=r"^(gs|hf|https)://\S+$", max_length=2048)] + sha256: Annotated[str, Field(pattern=r"^[0-9a-f]{64}$")] + media_type: Annotated[str, Field(min_length=1, max_length=255)] + + +_artifacts_adapter = TypeAdapter(tuple[ReportArtifact, ...]) +# jobs.py defines simulation.jobs and imports this module, so the foreign keys +# name it rather than import it. +JOBS_ID = f"{SIMULATION_SCHEMA}.jobs.id" +_KIND_VALUES = ", ".join(f"'{kind.value}'" for kind in ReportKind) +_SHA256_PATTERN = "^[0-9a-f]{64}$" +SUMMARY_COLUMNS = ( + "budgetary_impact", + "tax_revenue_impact", + "state_tax_revenue_impact", + "benefit_spending_impact", + "households", + "baseline_net_income", + "model_version", + "data_version", +) + +reports = Table( + "reports", + simulation_metadata, + Column("id", Uuid(as_uuid=True), primary_key=True), + Column("kind", Text, nullable=False), + Column("status", Text, nullable=False), + Column("country", Text, nullable=False), + Column("legacy_report_id", BigInteger, nullable=True, unique=True), + Column("job_id", Uuid(as_uuid=True), ForeignKey(JOBS_ID), nullable=True), + Column("created_at", DateTime(timezone=True), nullable=False), + Column("updated_at", DateTime(timezone=True), nullable=False), + CheckConstraint(f"kind IN ({_KIND_VALUES})", name="kind"), + CheckConstraint("country ~ '^[a-z]{2}$'", name="country"), + CheckConstraint( + "(status = 'not_started' AND job_id IS NULL) " + "OR (status = 'submitted' AND job_id IS NOT NULL)", + name="status_matches_job", + ), +) +Index("ix_reports_job_id", reports.c.job_id) + +report_results = Table( + "report_results", + simulation_metadata, + Column("job_id", Uuid(as_uuid=True), ForeignKey(JOBS_ID), primary_key=True), + Column("report_kind", Text, nullable=False), + Column("aggregate", JSONB, nullable=False), + Column("aggregate_schema_version", Integer, nullable=False), + Column("aggregate_sha256", String(64), nullable=False), + Column("budgetary_impact", Float, nullable=True), + Column("tax_revenue_impact", Float, nullable=True), + Column("state_tax_revenue_impact", Float, nullable=True), + Column("benefit_spending_impact", Float, nullable=True), + Column("households", Float, nullable=True), + Column("baseline_net_income", Float, nullable=True), + Column("model_version", Text, nullable=True), + Column("data_version", Text, nullable=True), + Column("artifacts", JSONB, nullable=False, server_default=text("'[]'::jsonb")), + Column("created_at", DateTime(timezone=True), nullable=False), + CheckConstraint(f"report_kind IN ({_KIND_VALUES})", name="report_kind"), + CheckConstraint(f"aggregate_sha256 ~ '{_SHA256_PATTERN}'", name="aggregate_sha256"), + CheckConstraint("aggregate_schema_version >= 1", name="aggregate_schema_version"), + CheckConstraint("jsonb_typeof(artifacts) = 'array'", name="artifacts"), +) + + +def report_result_values( + *, + report_kind: ReportKind, + aggregate: object, + aggregate_schema_version: int, + artifacts: tuple[ReportArtifact, ...], +) -> dict[str, Any]: + """Validate an aggregate and return the ``report_results`` columns it fills. + + The job ID and creation time are added by the writer. + """ + validated = validate_report_aggregate( + report_kind, aggregate_schema_version, aggregate + ) + stored_aggregate = validated.model_dump(mode="json") + return { + "report_kind": report_kind.value, + "aggregate": stored_aggregate, + "aggregate_schema_version": aggregate_schema_version, + "aggregate_sha256": result_sha256(stored_aggregate), + **dict.fromkeys(SUMMARY_COLUMNS), + **report_summary(report_kind, validated), + "artifacts": [artifact.model_dump(mode="json") for artifact in artifacts], + } + + +@dataclass(frozen=True) +class NewReport: + """A report to create; with a job it is submitted, without one not started.""" + + kind: ReportKind + country: str + job_id: UUID | None = None + legacy_report_id: int | None = None + + def __post_init__(self) -> None: + """Reject a country that is not a lowercase two-letter code.""" + if not COUNTRY_PATTERN.fullmatch(self.country): + msg = "country must be a lowercase two-letter code" + raise ValueError(msg) + + +@dataclass(frozen=True) +class ReportRecord: + """One ``simulation.reports`` row.""" + + id: UUID + kind: ReportKind + status: ReportStatus + country: str + legacy_report_id: int | None + job_id: UUID | None + created_at: datetime + updated_at: datetime + + @classmethod + def from_row(cls, row: RowMapping) -> ReportRecord: + """Build the record from a selected ``reports`` row.""" + values = {field.name: row[field.name] for field in fields(cls)} + values["kind"] = ReportKind(values["kind"]) + values["status"] = ReportStatus(values["status"]) + return cls(**values) + + +@dataclass(frozen=True) +class ReportResultRecord: + """One ``simulation.report_results`` row.""" + + job_id: UUID + report_kind: ReportKind + aggregate: dict[str, Any] + aggregate_schema_version: int + aggregate_sha256: str + budgetary_impact: float | None + tax_revenue_impact: float | None + state_tax_revenue_impact: float | None + benefit_spending_impact: float | None + households: float | None + baseline_net_income: float | None + model_version: str | None + data_version: str | None + artifacts: tuple[ReportArtifact, ...] + created_at: datetime + + @classmethod + def from_row(cls, row: RowMapping) -> ReportResultRecord: + """Build the record from a selected ``report_results`` row.""" + values = {field.name: row[field.name] for field in fields(cls)} + values["report_kind"] = ReportKind(values["report_kind"]) + values["artifacts"] = _artifacts_adapter.validate_python(values["artifacts"]) + return cls(**values) + + +class ReportStore: + """Typed SQLAlchemy Core reads and writes for report rows.""" + + def __init__( + self, + engine: Engine, + *, + clock: Callable[[], datetime] = utc_now, + ) -> None: + """Use ``engine`` (the runtime role) and ``clock`` for every timestamp.""" + self.engine = engine + self._clock = clock + + def _values(self, new_report: NewReport) -> dict[str, Any]: + now = self._clock() + return { + "id": uuid4(), + "kind": new_report.kind.value, + "status": ( + ReportStatus.NOT_STARTED.value + if new_report.job_id is None + else ReportStatus.SUBMITTED.value + ), + "country": new_report.country, + "legacy_report_id": new_report.legacy_report_id, + "job_id": new_report.job_id, + "created_at": now, + "updated_at": now, + } + + def create_report(self, new_report: NewReport) -> ReportRecord: + """Insert one report and return it.""" + with self.engine.begin() as connection: + row = ( + connection.execute( + postgres_insert(reports) + .values(**self._values(new_report)) + .returning(*reports.c) + ) + .mappings() + .one() + ) + return ReportRecord.from_row(row) + + def create_migrated_report( + self, + *, + legacy_report_id: int, + kind: ReportKind, + country: str, + ) -> ReportRecord: + """Create a not-started report for a v1 report, or return the existing one.""" + values = self._values( + NewReport(kind=kind, country=country, legacy_report_id=legacy_report_id) + ) + with self.engine.begin() as connection: + created = ( + connection.execute( + postgres_insert(reports) + .values(**values) + .on_conflict_do_nothing(index_elements=[reports.c.legacy_report_id]) + .returning(*reports.c) + ) + .mappings() + .first() + ) + row = ( + created + or connection.execute( + select(reports).where( + reports.c.legacy_report_id == legacy_report_id + ) + ) + .mappings() + .one() + ) + return ReportRecord.from_row(row) + + def get(self, report_id: UUID) -> ReportRecord | None: + """Return the report with this ID, or None.""" + return self._one(select(reports).where(reports.c.id == report_id)) + + def get_by_legacy_report_id(self, legacy_report_id: int) -> ReportRecord | None: + """Return the report migrated from this v1 report, or None.""" + return self._one( + select(reports).where(reports.c.legacy_report_id == legacy_report_id) + ) + + def _one(self, statement: Select[Any]) -> ReportRecord | None: + with self.engine.connect() as connection: + row = connection.execute(statement).mappings().first() + return None if row is None else ReportRecord.from_row(row) + + def get_result(self, job_id: UUID) -> ReportResultRecord | None: + """Return the stored report result of a finished job, or None.""" + with self.engine.connect() as connection: + row = ( + connection.execute( + select(report_results).where(report_results.c.job_id == job_id) + ) + .mappings() + .first() + ) + return None if row is None else ReportResultRecord.from_row(row) diff --git a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/schema.py b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/schema.py index 0a63de00b..8f041353f 100644 --- a/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/schema.py +++ b/libs/policyengine-simulation-persistence/src/policyengine_simulation_persistence/schema.py @@ -17,7 +17,7 @@ # The newest revision in migrations/versions. Each migration PR updates it, and # tests/test_schema.py fails when it differs from the chain's head. The # readiness check reports it next to the database's revision. -HEAD_REVISION = "0002" +HEAD_REVISION = "0003" NAMING_CONVENTION = { "ix": "ix_%(table_name)s_%(column_0_N_name)s", diff --git a/libs/policyengine-simulation-persistence/tests/report_support.py b/libs/policyengine-simulation-persistence/tests/report_support.py new file mode 100644 index 000000000..8693e8101 --- /dev/null +++ b/libs/policyengine-simulation-persistence/tests/report_support.py @@ -0,0 +1,68 @@ +"""A valid single-year economy aggregate for the report tests.""" + +from __future__ import annotations + +from copy import deepcopy + + +def _baseline_reform(baseline: float, reform: float) -> dict[str, float]: + return {"baseline": baseline, "reform": reform} + + +_AGE_GROUPS = { + "child": _baseline_reform(0.12, 0.11), + "adult": _baseline_reform(0.1, 0.1), + "senior": _baseline_reform(0.08, 0.08), + "all": _baseline_reform(0.1, 0.09), +} + +ECONOMY_AGGREGATE = { + "spm_provenance": None, + "model_version": "1.715.2", + "data_version": "1.115.5", + "budget": { + "budgetary_impact": 70.0, + "tax_revenue_impact": 100.0, + "state_tax_revenue_impact": 20.0, + "benefit_spending_impact": 30.0, + "households": 2.0, + "baseline_net_income": 1000.0, + }, + "detailed_budget": { + "income_tax": {"baseline": 100.0, "reform": 125.0, "difference": 25.0} + }, + "decile": {"relative": {"1": 0.01, "2": 0.02}, "average": {"1": 10.0, "2": 20.0}}, + "inequality": { + "gini": _baseline_reform(0.4, 0.39), + "top_10_pct_share": _baseline_reform(0.3, 0.29), + "top_1_pct_share": _baseline_reform(0.1, 0.09), + }, + "poverty": {"poverty": _AGE_GROUPS, "deep_poverty": _AGE_GROUPS}, + "poverty_by_gender": { + "poverty": { + "male": _baseline_reform(0.08, 0.07), + "female": _baseline_reform(0.09, 0.08), + }, + "deep_poverty": { + "male": _baseline_reform(0.02, 0.02), + "female": _baseline_reform(0.03, 0.02), + }, + }, + "poverty_by_race": None, + "intra_decile": { + "all": {"Gain more than 5%": 0.3, "No change": 0.7}, + "deciles": {"Gain more than 5%": [0.5, 0.1], "No change": [0.5, 0.9]}, + }, + "wealth_decile": None, + "intra_wealth_decile": None, + "labor_supply_response": None, + "constituency_impact": None, + "local_authority_impact": None, + "congressional_district_impact": None, + "cliff_impact": None, +} + + +def economy_aggregate(**overrides) -> dict: + """Return a copy of the aggregate with top-level fields replaced.""" + return deepcopy(ECONOMY_AGGREGATE) | overrides diff --git a/libs/policyengine-simulation-persistence/tests/test_job_store_concurrency.py b/libs/policyengine-simulation-persistence/tests/test_job_store_concurrency.py index 2eaa6d00d..2748ab077 100644 --- a/libs/policyengine-simulation-persistence/tests/test_job_store_concurrency.py +++ b/libs/policyengine-simulation-persistence/tests/test_job_store_concurrency.py @@ -266,3 +266,35 @@ def test_concurrent_identical_submissions_create_one_job(migrated_database) -> N assert len({submission.job.id for submission in submissions}) == 1 with store.engine.connect() as connection: assert connection.execute(select(func.count()).select_from(jobs)).scalar() == 1 + + +def test_concurrent_report_collectors_write_exactly_one_report_result( + migrated_database, +) -> None: + from policyengine_simulation_persistence.reports import ( + ReportKind, + report_results, + ) + from report_support import economy_aggregate + + store = runtime_store(migrated_database) + for case in range(3): + job = running_job(store, {"case": case}) + + outcomes = _race( + lambda index, job_id=job.id: store.complete_report( + job_id, + report_kind=ReportKind.ECONOMY, + aggregate=economy_aggregate(model_version=f"collector-{index}"), + aggregate_schema_version=1, + ) + ) + + assert outcomes.count(True) == 1 + with store.engine.connect() as connection: + stored = connection.execute( + select(report_results.c.model_version).where( + report_results.c.job_id == job.id + ) + ).all() + assert stored == [(f"collector-{outcomes.index(True)}",)] diff --git a/libs/policyengine-simulation-persistence/tests/test_reports.py b/libs/policyengine-simulation-persistence/tests/test_reports.py new file mode 100644 index 000000000..61aaacf42 --- /dev/null +++ b/libs/policyengine-simulation-persistence/tests/test_reports.py @@ -0,0 +1,206 @@ +"""Report tables: simulation.reports and simulation.report_results.""" + +from __future__ import annotations + +from uuid import uuid4 + +import pytest +from job_store_support import new_job, running_job, runtime_store +from policyengine_simulation_persistence import create_simulation_engine +from policyengine_simulation_persistence.jobs import JobStatus, result_sha256 +from policyengine_simulation_persistence.reports import ( + NewReport, + ReportArtifact, + ReportKind, + ReportStatus, + ReportStore, + UnsupportedReportAggregateError, + report_summary, + validate_report_aggregate, +) +from pydantic import ValidationError +from report_support import economy_aggregate +from sqlalchemy.exc import IntegrityError + +ARTIFACT = ReportArtifact( + role="baseline_simulation", + uri="gs://policyengine-simulation-artifacts/reports/baseline.parquet", + sha256="a" * 64, + media_type="application/vnd.apache.parquet", +) + + +# --- Validation, without a database ------------------------------------------ + + +def test_an_economy_aggregate_is_validated_by_the_single_year_model() -> None: + validated = validate_report_aggregate(ReportKind.ECONOMY, 1, economy_aggregate()) + + assert validated.budget.budgetary_impact == 70.0 + + +def test_an_invalid_economy_aggregate_is_rejected() -> None: + with pytest.raises(ValidationError): + validate_report_aggregate( + ReportKind.ECONOMY, 1, economy_aggregate(budget={"budgetary_impact": 1}) + ) + with pytest.raises(ValidationError): + validate_report_aggregate( + ReportKind.ECONOMY, 1, economy_aggregate(unexpected_field=True) + ) + + +@pytest.mark.parametrize( + ("kind", "schema_version"), + [(ReportKind.HOUSEHOLD, 1), (ReportKind.ECONOMY, 2)], +) +def test_aggregates_without_a_registered_model_are_refused(kind, schema_version): + with pytest.raises(UnsupportedReportAggregateError): + validate_report_aggregate(kind, schema_version, economy_aggregate()) + + +def test_economy_summary_columns_come_from_the_validated_aggregate() -> None: + validated = validate_report_aggregate(ReportKind.ECONOMY, 1, economy_aggregate()) + + assert report_summary(ReportKind.ECONOMY, validated) == { + "budgetary_impact": 70.0, + "tax_revenue_impact": 100.0, + "state_tax_revenue_impact": 20.0, + "benefit_spending_impact": 30.0, + "households": 2.0, + "baseline_net_income": 1000.0, + "model_version": "1.715.2", + "data_version": "1.115.5", + } + + +@pytest.mark.parametrize( + "overrides", + [ + {"sha256": "A" * 64}, + {"sha256": "a" * 63}, + {"uri": "file:///tmp/baseline.parquet"}, + {"role": "Baseline Simulation"}, + ], +) +def test_artifact_references_are_validated(overrides) -> None: + with pytest.raises(ValidationError): + ReportArtifact.model_validate(ARTIFACT.model_dump() | overrides) + + +# --- Reports ------------------------------------------------------------------------ + + +def _report_store(database, clock=None): + from job_store_support import SteppingClock + + return ReportStore( + create_simulation_engine(database.runtime_url), + clock=clock or SteppingClock(), + ) + + +@pytest.mark.postgres +def test_a_migrated_report_starts_not_started_and_is_created_once( + migrated_database, +) -> None: + reports = _report_store(migrated_database) + + first = reports.create_migrated_report( + legacy_report_id=4321, kind=ReportKind.ECONOMY, country="us" + ) + second = reports.create_migrated_report( + legacy_report_id=4321, kind=ReportKind.ECONOMY, country="us" + ) + + assert first.status is ReportStatus.NOT_STARTED + assert first.job_id is None + assert second.id == first.id + assert reports.get_by_legacy_report_id(4321).id == first.id + assert reports.get(first.id).kind is ReportKind.ECONOMY + + +@pytest.mark.postgres +def test_a_submitted_report_names_its_job(migrated_database) -> None: + jobs = runtime_store(migrated_database) + reports = _report_store(migrated_database) + job = jobs.submit(new_job(kind="economy_report")).job + + report = reports.create_report( + NewReport(kind=ReportKind.ECONOMY, country="uk", job_id=job.id) + ) + + assert report.status is ReportStatus.SUBMITTED + assert report.job_id == job.id + assert report.legacy_report_id is None + + +@pytest.mark.postgres +def test_the_report_status_and_job_must_agree(migrated_database) -> None: + reports = _report_store(migrated_database) + + with pytest.raises(IntegrityError): + reports.create_report( + NewReport(kind=ReportKind.ECONOMY, country="us", job_id=uuid4()) + ) + with pytest.raises(ValueError, match="country"): + NewReport(kind=ReportKind.ECONOMY, country="USA") + + +# --- Report results ------------------------------------------------------------ + + +@pytest.mark.postgres +def test_a_report_result_is_written_once_with_its_hash_summary_and_artifacts( + migrated_database, +) -> None: + jobs = runtime_store(migrated_database) + reports = _report_store(migrated_database) + job = running_job(jobs) + aggregate = economy_aggregate() + + assert jobs.complete_report( + job.id, + report_kind=ReportKind.ECONOMY, + aggregate=aggregate, + aggregate_schema_version=1, + artifacts=(ARTIFACT,), + ) + assert not jobs.complete_report( + job.id, + report_kind=ReportKind.ECONOMY, + aggregate=aggregate, + aggregate_schema_version=1, + ) + + stored = reports.get_result(job.id) + assert jobs.get_report_result(job.id) == stored + assert jobs.get(job.id).status is JobStatus.SUCCEEDED + assert jobs.get_result(job.id) is None + assert stored.report_kind is ReportKind.ECONOMY + assert stored.aggregate_schema_version == 1 + assert stored.aggregate_sha256 == result_sha256(stored.aggregate) + assert stored.budgetary_impact == 70.0 + assert stored.model_version == "1.715.2" + assert stored.artifacts == (ARTIFACT,) + assert validate_report_aggregate( + ReportKind.ECONOMY, 1, stored.aggregate + ) == validate_report_aggregate(ReportKind.ECONOMY, 1, aggregate) + + +@pytest.mark.postgres +def test_an_invalid_aggregate_writes_nothing(migrated_database) -> None: + jobs = runtime_store(migrated_database) + reports = _report_store(migrated_database) + job = running_job(jobs) + + with pytest.raises(ValidationError): + jobs.complete_report( + job.id, + report_kind=ReportKind.ECONOMY, + aggregate=economy_aggregate(budget=None), + aggregate_schema_version=1, + ) + + assert jobs.get(job.id).status is JobStatus.RUNNING + assert reports.get_result(job.id) is None diff --git a/libs/policyengine-simulation-persistence/uv.lock b/libs/policyengine-simulation-persistence/uv.lock index a0aff9e60..d9644f7df 100644 --- a/libs/policyengine-simulation-persistence/uv.lock +++ b/libs/policyengine-simulation-persistence/uv.lock @@ -16,6 +16,117 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3f/27/78a89b55b0904d222183164e079b4ca56208e94eff1d35ad1f1ad5be9b06/alembic-1.20.0-py3-none-any.whl", hash = "sha256:77eb101048d95f982c0353e9233404889dcd7a6fc244c107836c0e2fc9cf7d9d", size = 268719, upload-time = "2026-09-11T19:09:12.88Z" }, ] +[[package]] +name = "annotated-doc" +version = "0.0.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "anyio" +version = "4.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, +] + +[[package]] +name = "certifi" +version = "2026.10.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/eb/a2aa1ac3ea30dd3f30ca57f40232138b6b0040719316ecb85daf61167771/certifi-2026.10.18.tar.gz", hash = "sha256:b9995d97b64d0431a5af837d8ca8cd6238104d1f319f04bd93ea43124d6c861e", size = 135227, upload-time = "2026-10-18T16:21:07.655Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/ba/95dd616042484f7c83a06974388b37b837732740ee70bc5dc715b5fcd55a/certifi-2026.10.18-py3-none-any.whl", hash = "sha256:73191c47aaa79da394e8fee2dbc2ec827432cab56311122c9e47abf79e390f62", size = 134093, upload-time = "2026-10-18T16:21:06.276Z" }, +] + +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" }, + { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" }, + { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" }, + { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" }, + { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" }, + { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" }, + { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" }, + { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/33/1c/f41d4e74c28ab327ff3acd36053f7ea506c55872d7a90b0fa71aa3ab0c89/charset_normalizer-3.5.2.tar.gz", hash = "sha256:39de2a259fc954455c57274dc94c79d5842774e1247a016aff30bc0efed0f4ef", size = 172659, upload-time = "2026-09-30T04:39:23.398Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/34/68292d68512768591aaff07c59bb53ee31341c87759433a859c4641a50c2/charset_normalizer-3.5.2-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:ed905975ab14056a2e5eb1c376cb2e1ebc5396baf84163939c518556fccde9f5", size = 225946, upload-time = "2026-09-30T04:35:55.313Z" }, + { url = "https://files.pythonhosted.org/packages/e3/80/bee0b01b90ccd5322ae1d0abb33fab1bd95b7c2eadaf02aeccf22e04ee83/charset_normalizer-3.5.2-cp313-cp313-android_24_x86_64.whl", hash = "sha256:a66c3bc5ab1f0ff2164fc9965ddd611ff0802173f4b9d24554c563f6ab7e1d6e", size = 238619, upload-time = "2026-09-30T04:35:56.863Z" }, + { url = "https://files.pythonhosted.org/packages/78/6e/60ce52a85a7fd631ae8482ae6d74521014ca2f255892679484dc04d7ef56/charset_normalizer-3.5.2-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2374b62878abb00cd8309b32af6c0b715cd02dec0ca74ef12e5069bdc64144a", size = 206701, upload-time = "2026-09-30T04:35:58.639Z" }, + { url = "https://files.pythonhosted.org/packages/36/8c/71aafad23f971afc84c2b295bc0c560739ce1dac558aad9fec22e39f3639/charset_normalizer-3.5.2-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d376bbd28b3a8999db1a103b3b388aee6f1ddeb3e51bc2172993efdcd86e064d", size = 210506, upload-time = "2026-09-30T04:36:00.147Z" }, + { url = "https://files.pythonhosted.org/packages/91/da/3c5a7798c046df7d2d68ad653cf5b6c5a8bfee225055a843c6f2f42aac1a/charset_normalizer-3.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6045373d5a89a5ec71afde535db987ca28e76dfa276c2d4c818265b375d4b055", size = 366626, upload-time = "2026-09-30T04:36:01.77Z" }, + { url = "https://files.pythonhosted.org/packages/e1/16/710ac3de2ee354e2bd1a9c94efe45a2d27b5c6ad39b2d6a905be2c094b6c/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:849df64e889b2e17230d58410a03dba311a65b163508fd33679b2b737d4b7858", size = 244624, upload-time = "2026-09-30T04:36:03.389Z" }, + { url = "https://files.pythonhosted.org/packages/d6/39/45c7439f5b63d24f7d5b2a1d760f34af7628782d7144b4cc8ded45c2d4bc/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:15c44f7edfd477b06f517a5cc317fc1707edb9de2c865f43d4b6513907473234", size = 235731, upload-time = "2026-09-30T04:36:04.987Z" }, + { url = "https://files.pythonhosted.org/packages/4d/34/38f3154785ce92e9f56eb226f4d35bdfae6b008480dd055f58837a89c810/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a89012d6d5476ee112d20d998570ed58df2260a852afb1758809cd6900411d21", size = 270352, upload-time = "2026-09-30T04:36:06.412Z" }, + { url = "https://files.pythonhosted.org/packages/04/f3/859f74e7babc977705026b30593b3be04049632a522fb7000f83c033d747/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0c951d5e6dd9c2ff60609476752bee49da4206adde960ebc247766937f72e718", size = 267393, upload-time = "2026-09-30T04:36:07.865Z" }, + { url = "https://files.pythonhosted.org/packages/4b/85/41d27f234b82e47c167a5f6c0f62501dc0c640585ff4aba79e08a390336a/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7218e8f32b0956cfcd048fd42d9d5779809745ca1d86113ca56f66e7ae1549c4", size = 254660, upload-time = "2026-09-30T04:36:09.248Z" }, + { url = "https://files.pythonhosted.org/packages/58/ca/5d1a997587febe5b26d8daffe363b5c1a091cece19828eec6502fd09c5ef/charset_normalizer-3.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19a731138fc27d5682277d3b9df22855cea1239bce7fcec5f78f42ef2d1f3c3", size = 249996, upload-time = "2026-09-30T04:36:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/b3/1f/d1e78246f7ed60c8c8d606b4ac27f66ce49cc3e95f24893ccbeba9f77302/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:62603db9a7caa0802eaa28c1c46fecd7b3a263a774069c24c3c28c302448721c", size = 247028, upload-time = "2026-09-30T04:36:12.294Z" }, + { url = "https://files.pythonhosted.org/packages/8e/37/eba316edd4f0c4d3a5d945924c4eeeae59abac4056aa815d8a4268f863a2/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:b6856554c4f44d79fc2307d5768854310a8f0096e501c75637542c82292b0429", size = 240562, upload-time = "2026-09-30T04:36:13.887Z" }, + { url = "https://files.pythonhosted.org/packages/c8/8e/aaa037d40ca9ef045977f1a661048b1aa33f223adfce3452fe9be9f79d14/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1bc0baf5ef96b6ede57d47f4b8fe4d9d84019c3bfcbeb20a41edc6a6ee341f1f", size = 271699, upload-time = "2026-09-30T04:36:15.41Z" }, + { url = "https://files.pythonhosted.org/packages/26/19/1c1c9f75974adf523b87f34b8a2adc5a435cd65916812bcbd0dfa45f9a29/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:56bc200a365efb37383b7852e4cc5898d3b2da5987289b543956cf8cad71018a", size = 252030, upload-time = "2026-09-30T04:36:16.839Z" }, + { url = "https://files.pythonhosted.org/packages/bc/90/0660ef18e18df0a4d2a1a0edff7dfbba42d4e50ef2425557a5bb7051f77b/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2c9ad19a6cfcd5ea5c0d41161d22f9df1dcc277e9bef2751391334546a314c00", size = 268565, upload-time = "2026-09-30T04:36:18.468Z" }, + { url = "https://files.pythonhosted.org/packages/79/ba/57adc269824e8658f1a0f97a9e514c247445a9632b3419b97e0ba37f16dc/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e243bd13217235fc7290c621941c3f5cc8b66e4872495be821d7436ba2fb838d", size = 257627, upload-time = "2026-09-30T04:36:19.938Z" }, + { url = "https://files.pythonhosted.org/packages/9a/85/33abd4315c052d3d4f54c92b1ee49bfbc0dc7115a981e462a793b6d2ab87/charset_normalizer-3.5.2-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:a090bb2c68df85450502e3e20d665e3a5af9c65a84d6508ed477badd49166fd3", size = 143407, upload-time = "2026-09-30T04:36:21.376Z" }, + { url = "https://files.pythonhosted.org/packages/4f/de/6435e18d1aaa5d910b896d551411c96af1f42a0c56c29afc2016c61ccc2e/charset_normalizer-3.5.2-cp313-cp313-win32.whl", hash = "sha256:2b7b3bbfb4fe8ef40600792d762fbaa9057559f9d3fad209525b7a22b99e91fd", size = 183207, upload-time = "2026-09-30T04:36:22.776Z" }, + { url = "https://files.pythonhosted.org/packages/9c/76/b8ec57f4e9ee3253541abf95e4a462c0175fe8032dcd070f1f2421240942/charset_normalizer-3.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:78456a747de8dc58360ffa581f30a002baf5aa28cb262536545e91f113ed7639", size = 206489, upload-time = "2026-09-30T04:36:24.306Z" }, + { url = "https://files.pythonhosted.org/packages/3e/60/c647c6ae47480221e875ea5d743ff94946f7416e3c69415ab772928e8d32/charset_normalizer-3.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:11912e4bb14baae7c5d8791aa55ba0a3a03ec6729073307b0f57270abaa713d3", size = 195501, upload-time = "2026-09-30T04:36:25.846Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ab/176fbfd5b64939c55d652366aa5b9ef1d767af207a3aa6ebeb0d226c484d/charset_normalizer-3.5.2-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:4275811936e2f06feff5e598fb42a1b7ae852da8e39605211892b56b81a34efd", size = 331815, upload-time = "2026-09-30T04:38:26.216Z" }, + { url = "https://files.pythonhosted.org/packages/7e/84/371eac6b30bdbcbf2d632a1a01809103459216fcaae61b8b8d922c1bfb8a/charset_normalizer-3.5.2-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1c50fe28bbc2ced33386f298650d91218076c05420e6cbd790b913adc41659e7", size = 253276, upload-time = "2026-09-30T04:38:28.032Z" }, + { url = "https://files.pythonhosted.org/packages/43/6f/c4fbae58febff71709c51bc7e18fdfa55341dc382704740f9f0cbf03817b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d19fbd981a488e22cd04883659ca6b08f50b5974f9fd7c95655ef6a043e5893f", size = 241239, upload-time = "2026-09-30T04:38:29.732Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/458c3f42164a07d0c5210798e9e704b39e540a6793b05aba67f3a35243a9/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0fed1d06615f022ee3b13caf5e8b180cfea32bb2c5aded8a9d44277afc040f93", size = 231121, upload-time = "2026-09-30T04:38:31.462Z" }, + { url = "https://files.pythonhosted.org/packages/09/54/ab9e89367076f6331bb6c65c4bf14a5361fa5191cb6561bf534f18504e1b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:838dcc90063569a0448120554591a1d6c4a4ffe11babf048908793154ab86ade", size = 260350, upload-time = "2026-09-30T04:38:33.239Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c1/061431ecc688d9d76602502cb57cc01e691e682c18f1beb45f9673b5bbd2/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2ce45c6627b22c47e390bc91a41c3d13032192e699fa0bea96e9671b373d69b0", size = 255430, upload-time = "2026-09-30T04:38:34.865Z" }, + { url = "https://files.pythonhosted.org/packages/8d/1f/20c8949f0676f7ab811abdeb7f4d7f1cbc6e61ff20bef08b44edeb092bc8/charset_normalizer-3.5.2-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0774bf9bf620249fee3e0b8b9fd3065de213be30f3aa94ce2494b3b638949e26", size = 250612, upload-time = "2026-09-30T04:38:36.649Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9e/46f2fa4c431fc98c4ae76a8cb5bdca54e0341e3cfc3fcfd8e82740250818/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1db38f4c5496827c1a501846d64d14c3b80c7e6714e406cd7dc36a9899fa1011", size = 242083, upload-time = "2026-09-30T04:38:38.26Z" }, + { url = "https://files.pythonhosted.org/packages/bd/39/559be29a0c0f086e0bba6922babd38916cc5e0b58ced4de13ee01ea05508/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:304d8e4d493af723536393eee0c689eb7813f4a474c8b479dee63f1fdd98f621", size = 232738, upload-time = "2026-09-30T04:38:39.81Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6c/387b0e4f756a282831c1d9fc6aeb6c51ca4507ca202767c8de15ce9b12e2/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:9b7f416ff0978e2f2249330527f0ad6fa02f4932e6199692d3b52da2048c19e4", size = 260703, upload-time = "2026-09-30T04:38:41.346Z" }, + { url = "https://files.pythonhosted.org/packages/96/92/1fdf015f09ef449f50d3ac4b67c90887c9c318b727daa95cc4f866e6521d/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:01077390b03f7988f11d700a2194e69b119741a86b1a638b1db88891e3eced8e", size = 247622, upload-time = "2026-09-30T04:38:42.937Z" }, + { url = "https://files.pythonhosted.org/packages/dc/3c/8e7b8a5671ad5d433669fb2a76f1a0164df2d9b1718b0206bc2a16d840cc/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:7e841fb9010836c992c9f12fcbd43a831de93a5f726fc1ccd8ca1d0268c5014c", size = 257500, upload-time = "2026-09-30T04:38:44.604Z" }, + { url = "https://files.pythonhosted.org/packages/b4/f0/45b579df5cabc1d5d53ea1cc35e8437d3ca768c0acccc7041517cb6fbb32/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9cae88599c7219005d879f98e5ed53341e9a122af585e1091200358a3003d2a0", size = 255100, upload-time = "2026-09-30T04:38:46.289Z" }, + { url = "https://files.pythonhosted.org/packages/31/68/fdec18a343f5fb3f310588dd478b09ac4799e0b187dbade3a8cd776f03ef/charset_normalizer-3.5.2-cp37-abi3-win32.whl", hash = "sha256:01b0c0d2262a9e28e8484a278c7e1b5d650e3ac8cf2683d2967e25899f208bdf", size = 174499, upload-time = "2026-09-30T04:38:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8a/b618149cc5207943a0242068d7a27897f56a62947b5a039085f2a22029f8/charset_normalizer-3.5.2-cp37-abi3-win_amd64.whl", hash = "sha256:9f56f72050826f63dcee7a7f55b0a77168cb3bfc553fd405e7f8f9ece75a4036", size = 200092, upload-time = "2026-09-30T04:38:49.707Z" }, + { url = "https://files.pythonhosted.org/packages/03/cf/4c66866fa9e2b1c78e3c911516d1de497a677b7ac60f1eceda74ce777ca3/charset_normalizer-3.5.2-cp37-abi3-win_arm64.whl", hash = "sha256:40ab6bffa02ae10a0581e6c198be7d2d8ca5c2a0c64e4ed3465d766df457573e", size = 294363, upload-time = "2026-09-30T04:38:51.312Z" }, + { url = "https://files.pythonhosted.org/packages/fc/ad/d07d7862a62ffa6d79d68074d14823243dd235a77c45262acbf6adeb28bf/charset_normalizer-3.5.2-py3-none-any.whl", hash = "sha256:b6b751274acb69d77b3323d6b7dbaa3c7fdfc1eb829b7eb61d262f32e1af9685", size = 68872, upload-time = "2026-09-30T04:39:21.828Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -49,6 +160,247 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3f/0c/7a64e1ac90541a8edf50daef0914848011fb057a5bf55284a4811e21939a/coverage-7.16.2-py3-none-any.whl", hash = "sha256:11d28e9123a9156cb405d8d27b44256c9a58fb5decc2073a8f17862057e3aa0f", size = 215754, upload-time = "2026-09-27T12:28:59.075Z" }, ] +[[package]] +name = "cryptography" +version = "50.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/af/182eb91b0df3fe75c4d9f26fe70684569566745f6ba7e5c9c73a862c5252/cryptography-50.0.2.tar.gz", hash = "sha256:7b46165bb56eb4704e2eaaf86f3c940d19154535d9b0ca7d6d590b04060e00d5", size = 880623, upload-time = "2026-09-30T15:30:04.884Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/56/d194340cc4a57535e82e1bee9e89667ac4b7c13b5d3f59686deae3094dd5/cryptography-50.0.2-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:fa8f5efb344d6908a1ce62f4a24e2e5780f825d6f53f5f50ec5ffacac72936cb", size = 3914904, upload-time = "2026-09-30T14:43:44.339Z" }, + { url = "https://files.pythonhosted.org/packages/d9/69/c9bd862c3bf43d6399c433caf002df16e2dffd4be49bdf515cda38038711/cryptography-50.0.2-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:79def8d059362e7831389ed3be0ecdf58a89386e1271e35dd9f5af84e81bffd0", size = 4731146, upload-time = "2026-09-30T14:43:47.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/69/64cef1f702bf6657e0cc186ed1a2891d50d29fb41586b254e1c07adea261/cryptography-50.0.2-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:630ebfea3bf689d075f82316324ff7433dc447fe6bc1bfc76524b74b4a9567d2", size = 4719841, upload-time = "2026-09-30T14:43:49.01Z" }, + { url = "https://files.pythonhosted.org/packages/38/6b/61a3f8d8c5e1e49a6cddccafc4015cc1c0021360ab0acb4080e7a423644a/cryptography-50.0.2-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f9f6143a8c75945eb960d9eb98905a441394abfa24afaae239d514ffb2586480", size = 4738340, upload-time = "2026-09-30T14:43:50.932Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2e/7212ca32fd43dc91f2f41db20160b268098874b4c9a0e7be94d6835f5b2e/cryptography-50.0.2-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:a582ab2ae1d34f67112cadc86702774c9ea4374df6bca6afe672817203c99134", size = 5367029, upload-time = "2026-09-30T14:43:52.911Z" }, + { url = "https://files.pythonhosted.org/packages/1a/f1/b474e930c4d910328780e3940da76f5aa5cbc48ce1fc14e44d239d9ea9db/cryptography-50.0.2-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:4061c0079120205fb760c58acab6443e217307dcf05e3702cf970e0689972856", size = 4753050, upload-time = "2026-09-30T14:43:55.272Z" }, + { url = "https://files.pythonhosted.org/packages/7c/52/9af10e80ac16b0fcc2123f9cbd5e7afbd0fd5075bb7a607c592258a39cda/cryptography-50.0.2-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:ac9ed99d81760c62fe89d5f0815cdfa1ba9a35141cf30f1c2d044f04b4803d2e", size = 4376724, upload-time = "2026-09-30T14:43:57.24Z" }, + { url = "https://files.pythonhosted.org/packages/71/37/6202e488cc1eb625ea110c292c6bda92823176e023f427d8d5660ce8d632/cryptography-50.0.2-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:87e9ce85beb6b328ba370cc6e6aea483c92617b4c95b1d33a49297eb662bfb04", size = 4737859, upload-time = "2026-09-30T14:43:59.541Z" }, + { url = "https://files.pythonhosted.org/packages/8f/30/e86d7d518489b0ae2497091a35287abcb1a2ce4037837a34afbe9b1d6964/cryptography-50.0.2-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f265528741e048bce55c3463ed721fb0aa45a5888d8add8cfeccb3035451bbdc", size = 5324103, upload-time = "2026-09-30T14:44:01.901Z" }, + { url = "https://files.pythonhosted.org/packages/d3/69/2c833a049475e0a3444e94c7d0aca0aa51d166374a449b09e92ac98138de/cryptography-50.0.2-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dab55f57c74c3cad24c323bacbbd04be4705ba6eb0d92e920b1fc4837ed5079", size = 4752576, upload-time = "2026-09-30T14:44:04.545Z" }, + { url = "https://files.pythonhosted.org/packages/6c/5d/906970b83bbfc1f5bbfb677a143c181f2801f23b6a7204a3b47c42c97e65/cryptography-50.0.2-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:25784ce8b9621c90c643efb9e1e2162ab3b0224cae446ad5e70e7fcb1ce18b51", size = 4870819, upload-time = "2026-09-30T14:44:06.884Z" }, + { url = "https://files.pythonhosted.org/packages/68/e3/f2298d3bb55e0c4a91841ec4d01b3f020ba8c5fbf15ccdcc6dcf03f97025/cryptography-50.0.2-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:85d0d9a31b9098e98534226d5686b47264b95e62ce459dc2e62fdfc809f9fe93", size = 5030152, upload-time = "2026-09-30T14:44:09.443Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4f/adfc442765721292fff86d314ce385d3249d22db42295c0dd057727b60f3/cryptography-50.0.2-cp311-abi3-win_amd64.whl", hash = "sha256:7afa5a6602a9f29af1f3a2965f831bae7c9d5d597b7cbb716d41ab3b7d89879c", size = 3824692, upload-time = "2026-09-30T14:44:11.671Z" }, + { url = "https://files.pythonhosted.org/packages/2d/49/93f6a6e7a87c9aa68d44d3e1cdb5fe8f60c90d5d2f46acae9a56892816b8/cryptography-50.0.2-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:edc3342adf8f697fc5f59c887a304356f147b397809440ed64e2fa6af2f50f37", size = 4133708, upload-time = "2026-09-30T14:44:41.807Z" }, + { url = "https://files.pythonhosted.org/packages/8c/75/32ac2a56243d778805c16ca6a32b8f74fb757df7e28d7ecb560afafb59cf/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d370b8d1dfcdf7130178137f6fbee6140774a1acc6cacefc4b42643ec11d0a3a", size = 4956267, upload-time = "2026-09-30T14:44:43.693Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a4/2c8d734e43d97f0842ee9f1b7b4bfb3d0cf5e19edebf43c2afe6675c2320/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f2f9bd7f90c64fe89253f0a2c05e3c4856072660429ce8831b4235bf29403a67", size = 4966465, upload-time = "2026-09-30T14:44:45.769Z" }, + { url = "https://files.pythonhosted.org/packages/c2/58/ee288c829a6f41f6235ae9dd33d82fd19b45442b65b4c8a3da36963d9f7a/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_aarch64.whl", hash = "sha256:e275096ea1e60cc595cda2836fd4a6c725d1125108b868be17f53684d164e2cc", size = 4959356, upload-time = "2026-09-30T14:44:48.211Z" }, + { url = "https://files.pythonhosted.org/packages/92/20/9ded6d51ddd9897f6b6e81fb9ebea7951d7cc5d6c890b0ed8abf77a51a80/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_ppc64le.whl", hash = "sha256:b13478603dcd0a2479ff8e87e2c19a7d525734686fe3c49542472293a204212d", size = 5548822, upload-time = "2026-09-30T14:44:50.86Z" }, + { url = "https://files.pythonhosted.org/packages/02/a8/8df951850d6b31d2a00218f19e2b3f999523437ed7a819df7fa427942fca/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_x86_64.whl", hash = "sha256:58a0c478eeca76fe5e07993c5a0703def34a6dc6a0cda4f5564639b33112ffe7", size = 5001199, upload-time = "2026-09-30T14:44:53.379Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f9/36b3022218ce75b7cdf068fb95f809f9bd0d820e4955ef43b90c255cc7ac/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_31_armv7l.whl", hash = "sha256:d38cdff612d06fa6a32840d5e1b1f7a27cee4a349aa9085d94a67789d6bfd408", size = 4629333, upload-time = "2026-09-30T14:44:55.635Z" }, + { url = "https://files.pythonhosted.org/packages/8c/72/20f99a219f6af47cdd1cbd978c243b92d71496e168a746138af44ded4f29/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_aarch64.whl", hash = "sha256:fdd28f912fccfec1846a94e2e1e8f9b0012f557f0c46fe4f3eb0d7a87afcf90b", size = 4958822, upload-time = "2026-09-30T14:44:59.639Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/196f112617fb08eb4d608a2a6c422373d46f9cc2857f38fc0667033c0899/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_ppc64le.whl", hash = "sha256:cbc8738fd8526d80f35cb3a40d41f41a2e7030bb3b18b09a6778ef63d291c2fd", size = 5506351, upload-time = "2026-09-30T14:45:02.267Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/83378121ef3eaaaf71d4b781577ff794acb39b9e1b87a3f156898c8497ed/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_x86_64.whl", hash = "sha256:e105ab60406787da31fccc883fc0f733af1efd78f0136a4599692c4083a73d0c", size = 5000859, upload-time = "2026-09-30T14:45:05.009Z" }, + { url = "https://files.pythonhosted.org/packages/22/f7/70fd7ae4d1dbfa7ba29b02e1b9068771519a86027756510b700ce81086a8/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:6f8700550aa1474a91e5dc07049c46f98b423b5b1ddd0483e0b51362eeeaf5be", size = 5092151, upload-time = "2026-09-30T15:29:15.932Z" }, + { url = "https://files.pythonhosted.org/packages/d4/be/688367b74de86984bd58d8efacfc7c9e68b89a6a22ced0fb4f38db50254a/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:c71be1cbfa5cd9a41ee452acf1eccd82b2c05950358b106ec8ceb83411d1a020", size = 5286120, upload-time = "2026-09-30T15:29:18.309Z" }, + { url = "https://files.pythonhosted.org/packages/39/d1/55f8a3f2ef5d1529e16835ef10cf0fe3d559ce237b46dddc440c0bba3649/cryptography-50.0.2-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:c423ab384a46c4dff7217b2ea5ba2e11cffdeab6441acd04cf65a369caf0366c", size = 4111557, upload-time = "2026-09-30T15:29:20.155Z" }, + { url = "https://files.pythonhosted.org/packages/23/ad/ac987755d00e1e64273760228d2635ae38dae2be83e3c6e0d3289d91dec3/cryptography-50.0.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:0ec5f09541743261e66e291b4a0cbf0fb2997aeaab6d9e9c740b9dba1b58d1c2", size = 3943588, upload-time = "2026-09-30T15:29:22.265Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8d/6d585339bedf85d45044c85d8412dac53f2bb6f918e8b7777efba1787844/cryptography-50.0.2-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c5e67125c7dca78d199ec4e116aa93dbb83494808ecbb8211a2cb09b1bf41dbd", size = 4756166, upload-time = "2026-09-30T15:29:24.58Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f1/1c1f6874e8550cfddd4b688ceb38cefb6ed15ceed224d56f133f3d88c214/cryptography-50.0.2-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ee247f5c245c9a2fe7c8e2214e295918838e44e00a45a6718451e4004219e767", size = 4749145, upload-time = "2026-09-30T15:29:26.807Z" }, + { url = "https://files.pythonhosted.org/packages/c1/63/61b15dc1a8de03fe0adbe3fd7608b3ad5c73bf50993bbcb1faaa930afe33/cryptography-50.0.2-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dfe9763530994147d9af1def057a5b9658b00e8f8fe8743d144d1e0911c2e454", size = 4763638, upload-time = "2026-09-30T15:29:28.588Z" }, + { url = "https://files.pythonhosted.org/packages/fc/35/b345bdfa40c9126df1a9d33236aa98418367931b8725f84fc3ae2b98dc59/cryptography-50.0.2-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:58ddb5a8e3179d12f19e4ea34d2d32e9d63a4baa142c875c1eb59f41b7243acd", size = 5382217, upload-time = "2026-09-30T15:29:30.589Z" }, + { url = "https://files.pythonhosted.org/packages/4f/87/ef344a9e616871f2519c22d6afcda79ddd5d35e9592d95eb6e677608d055/cryptography-50.0.2-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f21e8a22c8605750c7af886bab299a363721264061b4ac0a30efb73cfd58efc5", size = 4781387, upload-time = "2026-09-30T15:29:32.605Z" }, + { url = "https://files.pythonhosted.org/packages/90/5b/f2fdb13cd0b96f6f932c8627bb292a45f11c64d21620a8e120aee9a3b848/cryptography-50.0.2-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:9c8402a82ea0dc4ceeab793db05f0fafa8ca139ca34fcde5df0f596103c74107", size = 4403790, upload-time = "2026-09-30T15:29:34.374Z" }, + { url = "https://files.pythonhosted.org/packages/bc/ce/7e4f662b1e3c393513569e402cfc85ac7da0bd3d5435e122a3140219eb2d/cryptography-50.0.2-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:0ddc924c04591c2811ca024d62ecad4f7f6f08af8939c211438f48a16bd23602", size = 4764319, upload-time = "2026-09-30T15:29:36.149Z" }, + { url = "https://files.pythonhosted.org/packages/3c/3f/86ff33ce34cc0de6847fb96e035a1a760d81652e38643f617c02ad32ef7a/cryptography-50.0.2-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a6557e5f38e065ca9fbdaf7cfc7435ecb1d113aa81a022d1b51921ee7432e227", size = 5338560, upload-time = "2026-09-30T15:29:39.053Z" }, + { url = "https://files.pythonhosted.org/packages/40/cf/6b5c8e2fd9202d98988ab7cb5cc5c991704c4ad55f492ff408e4969f83f1/cryptography-50.0.2-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:1981f1db4630889b9ef7803fadef12b056f428cb6b85c27ba57b774793b6093c", size = 4780973, upload-time = "2026-09-30T15:29:41.251Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/8d6ebc7dded797bd0f0160d52188021211f011a2b164ef0ae1dac4587465/cryptography-50.0.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a8701d6b584d76e909e3d305b7d126b41439876a5aaf76cddc67fc230eafa2e", size = 4897738, upload-time = "2026-09-30T15:29:43.106Z" }, + { url = "https://files.pythonhosted.org/packages/d4/aa/f3f6e0de7e6253b8baa8b2d8fb9d50924fa75cee3d4624bd4bc1208ee923/cryptography-50.0.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ce47f66801c20ec6c6632453bb5960fe38939e9306970b48b3a5a26de7745d94", size = 5058280, upload-time = "2026-09-30T15:29:44.827Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b6/a1faf3a27ae9405fb34b1713cc73b2d8a26b04d5c561578fa2e6ef3e5bb9/cryptography-50.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:4e81d95e5bafc2d6e34e4bed780e53e4d5b9a2f928573428aa4d35fbec1eb0de", size = 3854095, upload-time = "2026-09-30T15:29:46.782Z" }, +] + +[[package]] +name = "fastapi" +version = "0.149.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "opentelemetry-api" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/37/6e7ec4dbe12d9721902066ddb83dd9c6e7adb1cee81e094502c43024e4d1/fastapi-0.149.0.tar.gz", hash = "sha256:aba5779358d0c5233759ddcddd84fe7269d71f775fd754b653b608f770558a41", size = 483369, upload-time = "2026-10-19T17:22:29.495Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/6c/78c324bd6725f69ebbac46e0d6a9a59744e108cececac4c1c8500c567512/fastapi-0.149.0-py3-none-any.whl", hash = "sha256:fc4a5cf7e14f607a48eb1ab98147e9140943e88ccd0c8791d72c0a89b1159a35", size = 145182, upload-time = "2026-10-19T17:22:27.753Z" }, +] + +[[package]] +name = "google-api-core" +version = "2.42.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-auth" }, + { name = "googleapis-common-protos" }, + { name = "opentelemetry-api" }, + { name = "proto-plus" }, + { name = "protobuf" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/aa/2aa84799e6920216f8aa2866d3b43daa20f7060dcb6efc8f0449e8be0ab0/google_api_core-2.42.0.tar.gz", hash = "sha256:82cf5daa2ef1b456d4e29ff1de1a5c2995c7be3ccf4fc608184326e03390c1ee", size = 273100, upload-time = "2026-10-08T18:12:37.477Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/ca/fb2a5b38366bcb12990c80384b920f04b968fd834cf98be67d306c374612/google_api_core-2.42.0-py3-none-any.whl", hash = "sha256:b1bdf4f72dc4f910736ce4ba49038352effbbc309579215107649b22973a1317", size = 221364, upload-time = "2026-10-08T18:12:04.618Z" }, +] + +[package.optional-dependencies] +grpc = [ + { name = "grpcio" }, + { name = "grpcio-status" }, +] + +[[package]] +name = "google-auth" +version = "2.62.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "pyasn1-modules" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/b9/4b2528f30114b106e3c3a7298ba38d85e682fd1f83fe7c4a5b77c7677082/google_auth-2.62.0.tar.gz", hash = "sha256:0bef0ce54bdf9ce226c5d66e4264413bd918141c31bbe49fb52eac882f513d69", size = 398180, upload-time = "2026-10-12T19:20:48.328Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/a3/87fe9b7203ec2e56ea6a57c75e6d632eaf9487c7f7978cdad1d801bee0af/google_auth-2.62.0-py3-none-any.whl", hash = "sha256:4ff4319aeb4ad128409759d397a9fcafad126d0031d241cc0dd6b9a00b43e3f3", size = 267911, upload-time = "2026-10-12T19:20:46.355Z" }, +] + +[[package]] +name = "google-cloud-appengine-logging" +version = "1.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-api-core", extra = ["grpc"] }, + { name = "google-auth" }, + { name = "grpcio" }, + { name = "proto-plus" }, + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/34/aec7eca310c84a106a22dfaad076766c2e2ebe0389bdb88270a22c807f9d/google_cloud_appengine_logging-1.11.0.tar.gz", hash = "sha256:ce053792df98fde4790c5f070176088c9ebf0e55a37c754c8f8e11b28ddb4d73", size = 21678, upload-time = "2026-10-01T18:14:43.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/a8/da5d9865b0e1ce53194eb1052484303f86e94cd87f3b6d49511690f70ea6/google_cloud_appengine_logging-1.11.0-py3-none-any.whl", hash = "sha256:4d70953a0f35d0295256d977e7e3962242674d64c72fcb3e48b10a1cbf4f9b54", size = 20251, upload-time = "2026-10-01T18:08:00.913Z" }, +] + +[[package]] +name = "google-cloud-audit-log" +version = "0.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/91/e2a4ed95e790564b807a61fd54d90aa6bf2d3a9474bc5197651acf0cad13/google_cloud_audit_log-0.6.2.tar.gz", hash = "sha256:ea5dc892b1858d345aef0c5563a728534fa875bd0388765391574d3c57fd8f0e", size = 41010, upload-time = "2026-08-25T19:18:29.397Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/f3/f24fba2cb01ad41615d55335306642e3ddf3a5c3cdda6b44754d37bd650e/google_cloud_audit_log-0.6.2-py3-none-any.whl", hash = "sha256:1f19bdf2586cfdb72054eec769ede153a037988b73a7d3b6d266f672ab426c00", size = 40382, upload-time = "2026-08-24T21:55:11.849Z" }, +] + +[[package]] +name = "google-cloud-core" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-api-core" }, + { name = "google-auth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/71/d6081acadf55d4233271c39860f0d140ef61fbdb4bceb2075e9c2905d947/google_cloud_core-2.8.0.tar.gz", hash = "sha256:365f8e4518ae81c8101b8dea5fc1c32a960badedb8b511f19db2843cbbd285d2", size = 36605, upload-time = "2026-09-29T19:25:59.275Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/ed/1b09640a565e5d34d4e517463f65a67a11f9dcdeb6b6e8220f2d06aaf489/google_cloud_core-2.8.0-py3-none-any.whl", hash = "sha256:e235b0952f7ffe7b9c71a4cf96b506d9cfb557e22557c412f0df9b7068b5d007", size = 31048, upload-time = "2026-09-29T19:25:35.361Z" }, +] + +[[package]] +name = "google-cloud-logging" +version = "3.17.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-api-core", extra = ["grpc"] }, + { name = "google-auth" }, + { name = "google-cloud-appengine-logging" }, + { name = "google-cloud-audit-log" }, + { name = "google-cloud-core" }, + { name = "grpc-google-iam-v1" }, + { name = "grpcio" }, + { name = "opentelemetry-api" }, + { name = "proto-plus" }, + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e1/5b/fbb367f8bb05b3e968048686f440181765a8681c3a07cfa901814f9280e1/google_cloud_logging-3.17.0.tar.gz", hash = "sha256:d7343f2b82b4f9e9244bfc8aba7ad034c415f9bbb405ed28d1125aa37d270b72", size = 292071, upload-time = "2026-10-01T18:17:01.302Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/53/911cbf97517123350292ac6edba7d130ee44acccfd3e2a36f6807f32da14/google_cloud_logging-3.17.0-py3-none-any.whl", hash = "sha256:da2819da683a5135e21c2853ff2c86040786cd5521c3f71424755c110cf36fe2", size = 234020, upload-time = "2026-10-01T18:10:50.73Z" }, +] + +[[package]] +name = "googleapis-common-protos" +version = "1.75.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8d/2b/6ce81972d5c8cab9705fddce3153be63222d9e12fd96f8baba5038a744dd/googleapis_common_protos-1.75.5.tar.gz", hash = "sha256:c7a866fc34ed29a3b10af627a4b9b1dc2433313ca6e959f0ae4feb132047ed72", size = 156513, upload-time = "2026-09-29T19:26:14.863Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/65/b9/6b29500a1c581ff4d77fd83c6568d068bee06f1b139fb6eb0a4f2d4bce8a/googleapis_common_protos-1.75.5-py3-none-any.whl", hash = "sha256:d7285525c23039db98f2463e6d5a4f9b958b94d497f03a844ece3259c4e72d5d", size = 307737, upload-time = "2026-09-29T19:25:48.735Z" }, +] + +[package.optional-dependencies] +grpc = [ + { name = "grpcio" }, +] + +[[package]] +name = "grpc-google-iam-v1" +version = "0.14.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos", extra = ["grpc"] }, + { name = "grpcio" }, + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d2/d0/fa5bdd5f3f421bb68dc6dc162e9caaf942897ca41ce7255b524723c80f0b/grpc_google_iam_v1-0.14.5.tar.gz", hash = "sha256:07fd3a9fafb586588e771831fbfc8f6597050181d0c3b45e039d18b8fdc1aab5", size = 23736, upload-time = "2026-08-06T06:24:54.489Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/ab/be3ad0d46cffe35fd1e7cc3f9947edd6cb3c552229de3be2742f15f7ea47/grpc_google_iam_v1-0.14.5-py3-none-any.whl", hash = "sha256:0f5e680b20aa0a9441e68c769da04d94d70fca4e43751a82d8abb8aa6a7181ca", size = 32674, upload-time = "2026-08-06T06:23:49.467Z" }, +] + +[[package]] +name = "grpcio" +version = "1.84.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3f/4f/4435c0aae54657258d9cfcba78598f3d9e5fe4c82ff18d78558567b90faf/grpcio-1.84.0.tar.gz", hash = "sha256:19aaf172fc2edbefccce3f6e92c5150975dbe56c45744e9e87cf72ebdf85bfbe", size = 13493876, upload-time = "2026-09-14T06:59:33.291Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/51/40f99701adb01d4e5316a2aaf13838da1a24d5c879cd8c95156d7c364454/grpcio-1.84.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:209414080da8c20af94df1395b635da52dd57b5edc9e917e1deca0dc1c4bb55e", size = 6427619, upload-time = "2026-09-14T06:58:06.025Z" }, + { url = "https://files.pythonhosted.org/packages/c5/4b/ed8e22a1237e6b2be6ef4f221d074a5b0e0dd8a0da8c944c04aea731f0eb/grpcio-1.84.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:e41c3993eee896c617dbd8a505085d28b6e84a0445ed9a1f40f95808473cf678", size = 12336549, upload-time = "2026-09-14T06:58:08.583Z" }, + { url = "https://files.pythonhosted.org/packages/d3/50/00165b05cd73f45996748ea67ce9e55d08936f2fea94a7fd8541cc2d0e54/grpcio-1.84.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fff5ef3fe1bba7d6147e5f19e01e5e122ac2c076486887ddcb8d42e663400fbe", size = 6989458, upload-time = "2026-09-14T06:58:11.884Z" }, + { url = "https://files.pythonhosted.org/packages/26/38/d0486230e684d916f97429a53041db88410e662a38f2a8d09e2d90375840/grpcio-1.84.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b8c62888c3e49debf37ad9773e3c02f77b0c1e811f8fb0962f2b6c3bbab5b97a", size = 7757778, upload-time = "2026-09-14T06:58:14.849Z" }, + { url = "https://files.pythonhosted.org/packages/da/56/548a643decb059ca244499c675ae2c13a15f523ba94592c2774bd80a13c1/grpcio-1.84.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:986e9751d416d7a6eaa2fecdac38da63153d63a4b340ba7d624889c490451500", size = 7159572, upload-time = "2026-09-14T06:58:17.87Z" }, + { url = "https://files.pythonhosted.org/packages/db/f5/42caac81a79ec680f1f7a8eaf7ca90d2f93936ce0c3a073141ba96757f77/grpcio-1.84.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5933a052946873d01a42119a05420d669bdca436aeba2d1851988ccb12b421c0", size = 7710547, upload-time = "2026-09-14T06:58:20.607Z" }, + { url = "https://files.pythonhosted.org/packages/57/a4/828ad990b2410fee0a55cc73aa1bf98eb5b911c54847374ef4f24b9e877b/grpcio-1.84.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e094dd21f077af8194923fc263cad872eaa1802bb0156fd7e5ae18e99cd86715", size = 8761519, upload-time = "2026-09-14T06:58:23.875Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a5/1f91af098919eaf5d80d5a61126ad9fae074e5190c25a3014ce1d8d0d890/grpcio-1.84.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:08735e3d08d24ab3132cf87e2e5dea8746cabcc7d676c2b0b7362f195feef9d9", size = 8121424, upload-time = "2026-09-14T06:58:27.006Z" }, + { url = "https://files.pythonhosted.org/packages/8c/8f/77fd4a7a913b636785479922349c4cb98d94d05d15652e556b3ca0df6663/grpcio-1.84.0-cp313-cp313-win32.whl", hash = "sha256:70bb4ce8be0c5606bec259cbd7152374470396413b7863a658a08c849e6b29ff", size = 4477974, upload-time = "2026-09-14T06:58:29.528Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9a/1fa59ddbfc8898e5518d1447e46f771f387f0ed6132ad531395338e51a5c/grpcio-1.84.0-cp313-cp313-win_amd64.whl", hash = "sha256:b61692f0069b3eee2fc8a3a1b7f6c044df9e03fede6ce69b3ca832e1c39f26c5", size = 5255326, upload-time = "2026-09-14T06:58:31.781Z" }, +] + +[[package]] +name = "grpcio-status" +version = "1.84.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "grpcio" }, + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/45/f80309cdb6a7dbf8f65e2082dd2ddc9797ba7180516a73c54d966ba632c4/grpcio_status-1.84.0.tar.gz", hash = "sha256:5caf28ba7184b81f618b5f7f094859fd2541bf429d2189bbbcd715c9c2cdcee2", size = 14015, upload-time = "2026-09-14T07:10:29.402Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/c4/3a77e4273e866b1b0c412afd80882e95941a37170032b5109d847c501124/grpcio_status-1.84.0-py3-none-any.whl", hash = "sha256:0c182ca0d6e60acbfd0e14499cf39a155e4827a1c3fd9f7638e49af15a74c30a", size = 14639, upload-time = "2026-09-14T07:10:15.175Z" }, +] + +[[package]] +name = "idna" +version = "3.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" }, +] + [[package]] name = "iniconfig" version = "2.3.1" @@ -98,6 +450,100 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9f/a8/9b662783ffaa1149221432a923cee562f78b9cbbb8baa3df9b3753e63e1e/markupsafe-3.0.4-cp313-cp313-win_arm64.whl", hash = "sha256:06de8ef6331f6e822c28d577dc8bf43fe398800477c49498f38fc38b67ff33fc", size = 14189, upload-time = "2026-10-02T23:05:29.917Z" }, ] +[[package]] +name = "opentelemetry-api" +version = "1.45.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/02/6e0ae9cc61bd3169d401077b507b3ebc344745171e1051ab430be012dcd9/opentelemetry_api-1.45.1.tar.gz", hash = "sha256:aa38ed19bcc084ba42782a73255b3582283eced7ad6dddbd6695189e69adfb75", size = 72804, upload-time = "2026-10-06T17:32:58.133Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/41/f7dcf80b81ee8e71c1a2b59f14208bc723edbd89ed027a73b175abf6348e/opentelemetry_api-1.45.1-py3-none-any.whl", hash = "sha256:b31553efa588ae44bc306f863c785c5333a9ecc091248c6ee68b4b6c87fdedfb", size = 60256, upload-time = "2026-10-06T17:32:33.506Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-common" +version = "0.66b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-sdk" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/19/41de712173f43057e4532d42ece7d0c6d4210d353e5752433cb14987643f/opentelemetry_exporter_otlp_common-0.66b1.tar.gz", hash = "sha256:6b1403487a2185ac1feb45fd5546fdf8630ce71c36bcefaadf51e2130e9e23f9", size = 14325, upload-time = "2026-10-06T17:33:01.725Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/39/8c23d67665c762aa51840fa06f86e902e8f6f1693bc8d7e3d98cd6e2f753/opentelemetry_exporter_otlp_common-0.66b1-py3-none-any.whl", hash = "sha256:00ff8592c3a7cb729ff3fdc7ffa12372c243bdf2163e80c180994d0c7bd83ee9", size = 12385, upload-time = "2026-10-06T17:32:38.177Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-common" +version = "1.45.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-proto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c1/8e/65e85e5137991a3c493b11682151d198638a5bc1dd4b4c5f67e013c57d7c/opentelemetry_exporter_otlp_proto_common-1.45.1.tar.gz", hash = "sha256:2e4adcc3a67bcf57804fc49514f0ef64974ca7590aa3491da389852b4a0628f6", size = 18873, upload-time = "2026-10-06T17:33:04.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/aa/92f225d353904e7f70b8b3e3c1b02db0cf56f744c2e83c581dc372e78873/opentelemetry_exporter_otlp_proto_common-1.45.1-py3-none-any.whl", hash = "sha256:2f446183ae7047b036226f1d846c41a834b0e8755ad13b51a51dd38952eb466c", size = 15393, upload-time = "2026-10-06T17:32:41.911Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-grpc" +version = "1.45.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "grpcio" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-common" }, + { name = "opentelemetry-exporter-otlp-proto-common" }, + { name = "opentelemetry-proto" }, + { name = "opentelemetry-sdk" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d6/00/a82af0be959dc58495740b169c6669a86e0811f6cd353a01eda34d255db3/opentelemetry_exporter_otlp_proto_grpc-1.45.1.tar.gz", hash = "sha256:3b3dcfbfdcb4e35149fcf309972282054b45228f5c10547d0095d6578510a9a0", size = 25997, upload-time = "2026-10-06T17:33:05.114Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/46/2d1da202f1e17c81aae7efcf702898d524b46709e4d3e2bf1f7f8ca8fbc6/opentelemetry_exporter_otlp_proto_grpc-1.45.1-py3-none-any.whl", hash = "sha256:e42ecb789d2fc5d8145e3dadc3e2991c9f18cd166d7c7514e234702540274b76", size = 19492, upload-time = "2026-10-06T17:32:42.838Z" }, +] + +[[package]] +name = "opentelemetry-proto" +version = "1.45.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4b/7f/15f014fb195da6c2dbb6c71399b8e76824878718e94de6454038488eed28/opentelemetry_proto-1.45.1.tar.gz", hash = "sha256:79e0fb95e4616691a469439238aa9224d75779b3e108e895d1aa125ab29ca77c", size = 46488, upload-time = "2026-10-06T17:33:11.49Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/9a/42ec8180a769516ae757e893b69736826efceac7332553915b4528a91c6d/opentelemetry_proto-1.45.1-py3-none-any.whl", hash = "sha256:f38e2a8413053c180cd3d2637fbb279673ec2f6a6e09c995aafa2f452c52b46e", size = 72488, upload-time = "2026-10-06T17:32:53.057Z" }, +] + +[[package]] +name = "opentelemetry-sdk" +version = "1.45.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/79/7392e21a1c8f0c61d90b223e31c7e48cb9d452e91a6b820ad24cca5f23c4/opentelemetry_sdk-1.45.1.tar.gz", hash = "sha256:63d24a6ca645019a631e6a51999c73e93adcac1196ca640b8ae78a7cc4762bf3", size = 218324, upload-time = "2026-10-06T17:33:13.26Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/3c/87c42b4bd6dd297536f04cd9383d212ac557ecd49f2cbdcd46da1c9ef5c8/opentelemetry_sdk-1.45.1-py3-none-any.whl", hash = "sha256:c604c11dc429810812348989115fa44bd558772a3d7442afc43d024f2c250ca4", size = 140063, upload-time = "2026-10-06T17:32:55.04Z" }, +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.66b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/e4/dbbfb2a010c4db2224a5114638acede6fe563d33cc20fb1752cebcbe6298/opentelemetry_semantic_conventions-0.66b1.tar.gz", hash = "sha256:497ca63bf383723411e8eaf60c8779e9877633c936bb641080adab59d0eb6ec8", size = 150250, upload-time = "2026-10-06T17:33:14.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/14/67f8aa798857f8cf686f515bf93d9bb877ce952ddc8efae0fa25b45ce0d6/opentelemetry_semantic_conventions-0.66b1-py3-none-any.whl", hash = "sha256:d4cddeb4315490b35213f55e2bdc9ac54bb1e4d318927475bed62b35545e581b", size = 206279, upload-time = "2026-10-06T17:32:56.103Z" }, +] + [[package]] name = "packaging" version = "26.3" @@ -116,12 +562,86 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/40/9e/2b38731e0fc536806f16490e1a12d7f0dc2a1235aa8cc07bcc75416a7daa/pluggy-1.7.0-py3-none-any.whl", hash = "sha256:7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec", size = 27082, upload-time = "2026-10-15T09:50:56.808Z" }, ] +[[package]] +name = "policyengine-observability" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/74/2a/0c261c8ca693bb3a13d7fe837d3f89f7fdda245496d4b3e92eefed3c972e/policyengine_observability-3.0.2.tar.gz", hash = "sha256:554f907e43d8eeb274c1298fbe990b1626ac45f194c49995ddde091ddcdc2a64", size = 125718, upload-time = "2026-09-30T21:36:02.028Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/9c/b48fb64c1bebf83bd5fdeebe914188806c3513cf2de0fc95ee1d95082fc4/policyengine_observability-3.0.2-py3-none-any.whl", hash = "sha256:d84c7564922f6a3294268e89549065924edde932c31adf8b7c714558e107cb31", size = 42029, upload-time = "2026-09-30T21:36:00.841Z" }, +] + +[package.optional-dependencies] +fastapi = [ + { name = "fastapi" }, +] +google = [ + { name = "google-auth" }, + { name = "google-cloud-logging" }, +] +otlp-grpc = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-grpc" }, + { name = "opentelemetry-sdk" }, +] + +[[package]] +name = "policyengine-simulation-contract" +version = "0.1.0" +source = { editable = "../policyengine-simulation-contract" } +dependencies = [ + { name = "packaging" }, + { name = "policyengine-simulation-observability" }, + { name = "pydantic" }, +] + +[package.metadata] +requires-dist = [ + { name = "black", marker = "extra == 'build'", specifier = ">=25.1.0" }, + { name = "modal", marker = "extra == 'modal'", specifier = ">=1.4,<2" }, + { name = "modal", marker = "extra == 'test'", specifier = ">=1.4,<2" }, + { name = "packaging", specifier = ">=24" }, + { name = "policyengine-simulation-observability", editable = "../policyengine-simulation-observability" }, + { name = "pydantic", specifier = ">=2.0" }, + { name = "pyright", marker = "extra == 'build'", specifier = ">=1.1.401" }, + { name = "pytest", marker = "extra == 'test'", specifier = ">=8.3.4" }, + { name = "pytest-asyncio", marker = "extra == 'test'", specifier = ">=0.25.3" }, + { name = "pytest-cov", marker = "extra == 'test'", specifier = ">=6.1.1" }, +] +provides-extras = ["modal", "test", "build"] + +[[package]] +name = "policyengine-simulation-observability" +version = "0.1.0" +source = { editable = "../policyengine-simulation-observability" } +dependencies = [ + { name = "fastapi" }, + { name = "policyengine-observability", extra = ["fastapi", "google", "otlp-grpc"] }, + { name = "pydantic" }, +] + +[package.metadata] +requires-dist = [ + { name = "black", marker = "extra == 'build'", specifier = ">=25.1.0" }, + { name = "fastapi", specifier = ">=0.115.0" }, + { name = "httpx2", marker = "extra == 'test'" }, + { name = "policyengine-observability", extras = ["fastapi", "google", "otlp-grpc"], specifier = ">=3.0.2,<4" }, + { name = "pydantic", specifier = ">=2.0" }, + { name = "pyright", marker = "extra == 'build'", specifier = ">=1.1.401" }, + { name = "pytest", marker = "extra == 'test'", specifier = ">=8.3.4" }, + { name = "pytest-asyncio", marker = "extra == 'test'", specifier = ">=0.25.3" }, + { name = "pytest-cov", marker = "extra == 'test'", specifier = ">=6.1.1" }, +] +provides-extras = ["test", "build"] + [[package]] name = "policyengine-simulation-persistence" version = "0.1.0" source = { editable = "." } dependencies = [ + { name = "policyengine-simulation-contract" }, { name = "psycopg", extra = ["binary"] }, + { name = "pydantic" }, { name = "sqlalchemy" }, ] @@ -140,7 +660,9 @@ test = [ requires-dist = [ { name = "alembic", marker = "extra == 'migrations'", specifier = ">=1.16,<2" }, { name = "alembic", marker = "extra == 'test'", specifier = ">=1.16,<2" }, + { name = "policyengine-simulation-contract", editable = "../policyengine-simulation-contract" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.2,<4" }, + { name = "pydantic", specifier = ">=2,<3" }, { name = "pytest", marker = "extra == 'test'", specifier = ">=8.3.4" }, { name = "pytest-cov", marker = "extra == 'test'", specifier = ">=6.1.1" }, { name = "pyyaml", marker = "extra == 'test'", specifier = ">=6.0.2" }, @@ -148,6 +670,33 @@ requires-dist = [ ] provides-extras = ["migrations", "test"] +[[package]] +name = "proto-plus" +version = "1.29.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/70/783e33ffbb4466cc154a94f79b869b92a451e2bd45605054e68ff68b7af6/proto_plus-1.29.0.tar.gz", hash = "sha256:cfb4e62ad7e13dd18f346cabbda00cab39930d36a05791fd81ddb074d6ee884f", size = 58592, upload-time = "2026-09-29T19:26:15.963Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/05/a3ef5b1161498e7b5a2a60288a9cf41b9149e94555423a43ec536737ac1a/proto_plus-1.29.0-py3-none-any.whl", hash = "sha256:8acd070469a7aaf43f440b022ef9757c8cac1a9f866e933f59ae98669ddc6c8b", size = 50786, upload-time = "2026-09-29T19:25:50.409Z" }, +] + +[[package]] +name = "protobuf" +version = "7.36.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d9/89/5b8517baa72f84a67b8a307ba953c91057af618bf40bf676f3c03551f8f0/protobuf-7.36.2.tar.gz", hash = "sha256:497d0463ff3316681da6c0b9e8d06cb465d61abce00b613ab42226175644d1bb", size = 512737, upload-time = "2026-09-17T20:07:59.326Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/72/98342feb672507c8f3a69e34b4fa8961f608edba5c1a48a6f47156d92cb5/protobuf-7.36.2-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:cbc70b17ee27e28894c7fee8bb04be1abead49e936bc70eb60052531eee2079e", size = 456039, upload-time = "2026-09-17T20:07:51.542Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ea/91fdf7c2b8bbd49cde056f00a9df6773532987e1c00fe2830b895af95c7e/protobuf-7.36.2-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:e11e1f0180583a2af89db6a2ecd9e8dc40aa6d2988ca175bfd0e6d12ea72d74e", size = 344219, upload-time = "2026-09-17T20:07:52.914Z" }, + { url = "https://files.pythonhosted.org/packages/17/ab/5fd5f8ece73fad885c5a09aa849b32d70472f954ba3a92d3bb5974ea953b/protobuf-7.36.2-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:f4fee11ec330d238b34a05c9b675f693c20415d1c5bd7d5320cc2f8a798eb9cf", size = 357223, upload-time = "2026-09-17T20:07:53.985Z" }, + { url = "https://files.pythonhosted.org/packages/db/f3/3996583dd2906297a637af12114deddf7658af6e683fedb83be061983fb5/protobuf-7.36.2-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:89f23aa53c24553a2416fd4fd1ec06f74fa42b14b546d8883128813f775bbfd2", size = 343223, upload-time = "2026-09-17T20:07:54.931Z" }, + { url = "https://files.pythonhosted.org/packages/fc/1b/dcc64f358fcb51811b58ae40b3d28f820725f116d86487cc20bd4b130701/protobuf-7.36.2-cp310-abi3-win32.whl", hash = "sha256:912c1221170e16c08d1f086762f563dd61ff83c18b5fa6652952dfaded66f728", size = 442998, upload-time = "2026-09-17T20:07:55.826Z" }, + { url = "https://files.pythonhosted.org/packages/8a/55/b77bda4e5e5f5971fb51b07663694690e9afdb9402136c16a522bd621cad/protobuf-7.36.2-cp310-abi3-win_amd64.whl", hash = "sha256:a300819d441e078a5608c0d3c709796bb548136058fda017ae51d425b44fd353", size = 456514, upload-time = "2026-09-17T20:07:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/d52c7016b04b6c5108f26691f9d33ec82a9b65d041f1a9c771137693d618/protobuf-7.36.2-py3-none-any.whl", hash = "sha256:bdb3a345d48db958e6ce1f18e508beb0cc981d64f24088427549c866cd039f1e", size = 179806, upload-time = "2026-09-17T20:07:58.211Z" }, +] + [[package]] name = "psycopg" version = "3.3.6" @@ -183,6 +732,77 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/55/7f/e350e1cf498ba2565c3f87b12f429d2012eb86b76c2b3845a19ee5fbb4d6/psycopg_binary-3.3.6-cp313-cp313-win_amd64.whl", hash = "sha256:0ebfad5d131de9f892ae9e70cc7616207768b6714b66a52d4612b8ceaf78b372", size = 3658179, upload-time = "2026-09-18T13:20:22.691Z" }, ] +[[package]] +name = "pyasn1" +version = "0.6.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a4/9a/23310166d960def5897e91fe20e5b724601b02a22e84ba1f94232c0b7f67/pyasn1-0.6.4.tar.gz", hash = "sha256:9c447d8431c947fe4c8febc4ed9e760bc29011a5b01e5c74b67025bd9fb8ce81", size = 151262, upload-time = "2026-07-09T01:12:33.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3b/6163796d69c3977d1e4287bea4a6979161cbbdd170ebb430511e8e1999ce/pyasn1-0.6.4-py3-none-any.whl", hash = "sha256:deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b", size = 84410, upload-time = "2026-07-09T01:12:32.92Z" }, +] + +[[package]] +name = "pyasn1-modules" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, +] + +[[package]] +name = "pycparser" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/a8/c5fdbeee588bb8ada9458774f43adf1bdd30bd59157055142183e769a024/pycparser-3.11.tar.gz", hash = "sha256:d875f09c3507d00e1aba0eecc6dcadc1352f30fff09dc6bff2f1c2935e97c2bc", size = 113796, upload-time = "2026-10-09T12:56:59.539Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/11/0e6f11117525ff0eec40ebac3d313376f102df93ca44ad9e893ee85e4f89/pycparser-3.11-py3-none-any.whl", hash = "sha256:51d5a8ba2be0bbe440b99d2112604c95bbbc3c2748a64260186c541e1729cd80", size = 51178, upload-time = "2026-10-09T12:56:58.131Z" }, +] + +[[package]] +name = "pydantic" +version = "2.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7c/0b/8e10b2e693af8ec54346a14caf36221334775975a747c9ceaa3f8371d96d/pydantic-2.14.1.tar.gz", hash = "sha256:94f478203dd03404682a1ada216965651dd74b1d2d5ffd62e00e0837caab5c26", size = 900362, upload-time = "2026-10-11T18:37:55.396Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/ea/a56b9fe5066f3537b7882f77e9c5dfb26d8c2eefdaed9b5fc73d57b4dc22/pydantic-2.14.1-py3-none-any.whl", hash = "sha256:9195d967ec791692a04438115466764fb8b9a27b31f14a760437694f40d6b454", size = 488106, upload-time = "2026-10-11T18:37:53.437Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.50.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/24/af4ec4be49fbc810f35b0bdcacc3d433b56bbfa469fd3bfd4ae116cd9bf1/pydantic_core-2.50.1.tar.gz", hash = "sha256:e50d7b94baac6c7d09927fa5ca5800a0c7ee5015c7fcff65beb3a1931b5a6e09", size = 513515, upload-time = "2026-10-11T18:35:44.82Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/57/0e237d7091d2cd44a35d243b7344227f90440166860469cd036afc23a04d/pydantic_core-2.50.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:d5e062c01286d861fd6a1c4ff6e063547b3e713067f2df033c0ff97ac2ca006b", size = 2222774, upload-time = "2026-10-11T18:32:43.103Z" }, + { url = "https://files.pythonhosted.org/packages/f7/b9/c720e56858d4e1539503297ed37063e0c08e0f3541c41b777f6a800f75fa/pydantic_core-2.50.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0c003c3b7f49debb893d2d85ae099ac5959c9839e2f330fadb1fcdf7a6594482", size = 2024738, upload-time = "2026-10-11T18:32:44.587Z" }, + { url = "https://files.pythonhosted.org/packages/4d/b8/fbfc25875219cc060e613170ff10e850c6d8924beb4910da57c2ee3ba1d2/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:409e0ea40ec30d9158f33574fd758e689f6045a0f2596701828c27816ca9687d", size = 2047049, upload-time = "2026-10-11T18:32:46.164Z" }, + { url = "https://files.pythonhosted.org/packages/d7/43/34210124d504c553688f2f04b48500b131237528ac545b445e0d6d30e0d5/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:131059670f1d2444269b8585cb888963994871932447c08b39ac6a51fcfef658", size = 2173415, upload-time = "2026-10-11T18:32:47.722Z" }, + { url = "https://files.pythonhosted.org/packages/65/cf/6e178e8fdc11da5965bef980983bf46326a42f436871dd51ba0a57f39df1/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6dbcbee53bf17196a7f745aa9bf5a9603953a1e365b1f020be3207c676a3e7c4", size = 2361719, upload-time = "2026-10-11T18:32:49.217Z" }, + { url = "https://files.pythonhosted.org/packages/11/14/bd5169d356aa91bf777e28ba0b281c192d286d03c2812c9c9db023a2f00e/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:325c23f3e35cfbf0fe3486fa5f7260d1e45885173002d30a28ca019994124255", size = 2439803, upload-time = "2026-10-11T18:32:51.025Z" }, + { url = "https://files.pythonhosted.org/packages/1c/bc/d79d000e5203ebef39af839f2ce77a777fcad6e08ad26af9a2fcd114ffc8/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17e722e156d0444ecaefbe640bdb60928752bf2013e2b7a11cdb099aaae19bec", size = 2158402, upload-time = "2026-10-11T18:32:52.714Z" }, + { url = "https://files.pythonhosted.org/packages/1b/a5/4902cb5fd599422c130bd3124ab31ee8b771199bd56662702eed07d3fec7/pydantic_core-2.50.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:aa8224f10880d9bf1b5993988ba153d42a8b4f3f4f511f93b1f09c93ff613c72", size = 2229755, upload-time = "2026-10-11T18:32:54.126Z" }, + { url = "https://files.pythonhosted.org/packages/1a/f5/c1481f8669f6060d89110c9b1374173fc8767ca276b84f4870bd8acd5e3f/pydantic_core-2.50.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:41bc8237121bd8dc8d888dfd6279fc166ffc88c1f1bf3a8bf00869680533ca4c", size = 2242303, upload-time = "2026-10-11T18:32:55.641Z" }, + { url = "https://files.pythonhosted.org/packages/04/f9/77fc3c7653ba9b6e42049e17e96b25388187d4a7c274ab1cb07f58ac8419/pydantic_core-2.50.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:45c6266d071c241f2a168d45bf8c54344f0effce35e7e6b73afdec11f3687568", size = 2278345, upload-time = "2026-10-11T18:32:57.38Z" }, + { url = "https://files.pythonhosted.org/packages/95/9b/0579c5d12e7f2b16b27e6782427987341fcce07b0725e02ddb0b74add0c4/pydantic_core-2.50.1-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:1deeacb112d14d3f4fcb16b165f7dbaf76c70ba6e82f37ba042bdab51970a0b8", size = 2449947, upload-time = "2026-10-11T18:32:58.896Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ac/1b677db91eba54cc5922f4de6edc46ba45c2bd712dfdc0130382d158e214/pydantic_core-2.50.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:1c96fd793b73d1b92e65570132505498fe7b21eaef73cdf74e67e5dfba7ac9e4", size = 2476889, upload-time = "2026-10-11T18:33:00.665Z" }, + { url = "https://files.pythonhosted.org/packages/4d/2a/3a9f6624ee3ea9ccba5249dde11418bb4c35780a7a92608f8768bd3fea39/pydantic_core-2.50.1-cp313-cp313-win32.whl", hash = "sha256:06ead20d39ffd6f2f6f2a8f8a6de67ff8bb1b4f14a8a30e058502514ee2ac685", size = 2101118, upload-time = "2026-10-11T18:33:02.329Z" }, + { url = "https://files.pythonhosted.org/packages/2d/1f/323f78ddd9d9938aac420c1abb4e8ba799fc8bdab0acc67c0593b837c979/pydantic_core-2.50.1-cp313-cp313-win_amd64.whl", hash = "sha256:7816e98acc08119dc0f340ab167048ecc54126316330c1f0caf7c6756c88e28f", size = 2170840, upload-time = "2026-10-11T18:33:03.919Z" }, + { url = "https://files.pythonhosted.org/packages/cb/09/8497c52a739ae425c3ac2f7f56414cbc711c67d374346174f40fe2062644/pydantic_core-2.50.1-cp313-cp313-win_arm64.whl", hash = "sha256:c17799a62c142d61b8a3c51752a7cbc87fe2ad4ccfab10e628a77b405075c662", size = 2161633, upload-time = "2026-10-11T18:33:05.518Z" }, +] + [[package]] name = "pygments" version = "2.21.0" @@ -240,6 +860,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, ] +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + [[package]] name = "sqlalchemy" version = "2.1.5" @@ -262,6 +897,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/de/e9/61176e4753f74ab5f6e72a56314ee654d25c31e6fc98c99060442998cccb/sqlalchemy-2.1.5-py3-none-any.whl", hash = "sha256:2f77b77b716f005b2dc3cc059d56df6a919bfb5b439e4149890a9df6c36be9e3", size = 2060214, upload-time = "2026-10-16T22:43:30.801Z" }, ] +[[package]] +name = "starlette" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ad/eb/4e197bf26b023abafc5e9043ec8710d7de0fcb032d0969045236d72aa0b1/starlette-1.9.0.tar.gz", hash = "sha256:3d931de246ed870e35ce605974440816ef895e592f255d1396cfdf191d4c4318", size = 2732466, upload-time = "2026-10-18T15:50:33.189Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/93/181df62699d9b5aca1d87e5cfda131dac87e3cdaebf668662f7206ca3470/starlette-1.9.0-py3-none-any.whl", hash = "sha256:fd3f159936e882d06c1108f84688f7cda2cf85fc6a592a50edbd175e882c2ddc", size = 80335, upload-time = "2026-10-18T15:50:31.526Z" }, +] + [[package]] name = "typing-extensions" version = "4.16.0" @@ -271,6 +918,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, ] +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, +] + [[package]] name = "tzdata" version = "2026.5" @@ -279,3 +938,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/d9/68/f1b440335057bfce7 wheels = [ { url = "https://files.pythonhosted.org/packages/94/21/1e5995a1c920cce14e4bffae20c665ec10e7ed03ab25e006cd741092b718/tzdata-2026.5-py2.py3-none-any.whl", hash = "sha256:b683bd1b6659ddcd810ff02ad09ba821d4bf1065072805063eb35c49617905ac", size = 347996, upload-time = "2026-10-03T09:23:12.535Z" }, ] + +[[package]] +name = "urllib3" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" }, +] diff --git a/migrations/env.py b/migrations/env.py index 72576d470..d5732c305 100644 --- a/migrations/env.py +++ b/migrations/env.py @@ -10,6 +10,7 @@ # the schema comparison (alembic check and autogenerate) reads. Import every # module that defines tables, or the comparison reports them as removed. import policyengine_simulation_persistence.jobs # noqa: F401 # registers the job tables +import policyengine_simulation_persistence.reports # noqa: F401 # registers the report tables from alembic import context from policyengine_simulation_persistence import ( ALEMBIC_VERSION_TABLE, diff --git a/migrations/versions/0003_simulation_reports.py b/migrations/versions/0003_simulation_reports.py new file mode 100644 index 000000000..916e53890 --- /dev/null +++ b/migrations/versions/0003_simulation_reports.py @@ -0,0 +1,114 @@ +"""Add the report tables: simulation.reports and simulation.report_results. + +A report row records an economy or household report. A row migrated from +the Public API keeps the v1 report ID in ``legacy_report_id`` and starts +``not_started`` with no job; once a job is attached the report is +``submitted`` and its progress is that job's status. ``report_results`` holds +one aggregate per finished report job, validated by a Pydantic model and +stored as JSONB with its schema version, SHA-256, summary columns and +artifact references. Nothing writes these tables yet. + +Revision ID: 0003 +Revises: 0002 +Create Date: 2026-10-20 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision = "0003" +down_revision = "0002" +branch_labels = None +depends_on = None + +SCHEMA = "simulation" + + +def upgrade() -> None: + """Create the reports and report_results tables.""" + op.create_table( + "reports", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("kind", sa.Text(), nullable=False), + sa.Column("status", sa.Text(), nullable=False), + sa.Column("country", sa.Text(), nullable=False), + sa.Column("legacy_report_id", sa.BigInteger(), nullable=True), + sa.Column("job_id", sa.Uuid(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.CheckConstraint( + "kind IN ('economy', 'household')", + name="ck_reports_kind", + ), + sa.CheckConstraint("country ~ '^[a-z]{2}$'", name="ck_reports_country"), + sa.CheckConstraint( + "(status = 'not_started' AND job_id IS NULL) " + "OR (status = 'submitted' AND job_id IS NOT NULL)", + name="ck_reports_status_matches_job", + ), + sa.ForeignKeyConstraint( + ["job_id"], + [f"{SCHEMA}.jobs.id"], + name="fk_reports_job_id_jobs", + ), + sa.PrimaryKeyConstraint("id", name="pk_reports"), + sa.UniqueConstraint("legacy_report_id", name="uq_reports_legacy_report_id"), + schema=SCHEMA, + ) + op.create_index("ix_reports_job_id", "reports", ["job_id"], schema=SCHEMA) + op.create_table( + "report_results", + sa.Column("job_id", sa.Uuid(), nullable=False), + sa.Column("report_kind", sa.Text(), nullable=False), + sa.Column("aggregate", postgresql.JSONB(), nullable=False), + sa.Column("aggregate_schema_version", sa.Integer(), nullable=False), + sa.Column("aggregate_sha256", sa.String(length=64), nullable=False), + sa.Column("budgetary_impact", sa.Float(), nullable=True), + sa.Column("tax_revenue_impact", sa.Float(), nullable=True), + sa.Column("state_tax_revenue_impact", sa.Float(), nullable=True), + sa.Column("benefit_spending_impact", sa.Float(), nullable=True), + sa.Column("households", sa.Float(), nullable=True), + sa.Column("baseline_net_income", sa.Float(), nullable=True), + sa.Column("model_version", sa.Text(), nullable=True), + sa.Column("data_version", sa.Text(), nullable=True), + sa.Column( + "artifacts", + postgresql.JSONB(), + server_default=sa.text("'[]'::jsonb"), + nullable=False, + ), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.CheckConstraint( + "report_kind IN ('economy', 'household')", + name="ck_report_results_report_kind", + ), + sa.CheckConstraint( + "aggregate_sha256 ~ '^[0-9a-f]{64}$'", + name="ck_report_results_aggregate_sha256", + ), + sa.CheckConstraint( + "aggregate_schema_version >= 1", + name="ck_report_results_aggregate_schema_version", + ), + sa.CheckConstraint( + "jsonb_typeof(artifacts) = 'array'", + name="ck_report_results_artifacts", + ), + sa.ForeignKeyConstraint( + ["job_id"], + [f"{SCHEMA}.jobs.id"], + name="fk_report_results_job_id_jobs", + ), + sa.PrimaryKeyConstraint("job_id", name="pk_report_results"), + schema=SCHEMA, + ) + + +def downgrade() -> None: + """Drop the report_results and reports tables.""" + op.drop_table("report_results", schema=SCHEMA) + op.drop_index("ix_reports_job_id", table_name="reports", schema=SCHEMA) + op.drop_table("reports", schema=SCHEMA) diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/app.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/app.py index b8929e26e..47168d9cc 100644 --- a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/app.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/app.py @@ -44,6 +44,7 @@ ) from policyengine_simulation_persistence import HEAD_REVISION from policyengine_simulation_persistence.jobs import JobStore +from policyengine_simulation_persistence.reports import ReportResultRecord from pydantic import BaseModel, TypeAdapter, ValidationError from starlette.datastructures import MutableHeaders @@ -685,6 +686,16 @@ async def internal_caller( ) raise HTTPException(status_code=403) from error + def stored_result(view: JobView) -> JsonObject | None: + if isinstance(view.result, ReportResultRecord): + return view.result.aggregate + return None if view.result is None else view.result.result + + def stored_result_sha256(view: JobView) -> str | None: + if isinstance(view.result, ReportResultRecord): + return view.result.aggregate_sha256 + return None if view.result is None else view.result.result_sha256 + def job_status_payload(view: JobView) -> SimulationJobStatusResponse: job = view.job return SimulationJobStatusResponse( @@ -701,8 +712,8 @@ def job_status_payload(view: JobView) -> SimulationJobStatusResponse: if job.error_code is not None else None ), - result=None if view.result is None else view.result.result, - result_sha256=None if view.result is None else view.result.result_sha256, + result=stored_result(view), + result_sha256=stored_result_sha256(view), ) # Simulation job store (API v2 migration, Step 1). These routes are not in diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/simulation_jobs.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/simulation_jobs.py index 152caeb9d..c6f256c9e 100644 --- a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/simulation_jobs.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/simulation_jobs.py @@ -44,6 +44,11 @@ input_sha256, utc_now, ) +from policyengine_simulation_persistence.reports import ( + ReportKind, + ReportResultRecord, + UnsupportedReportAggregateError, +) from pydantic import BaseModel, ValidationError if TYPE_CHECKING: @@ -79,13 +84,18 @@ def collection_delay_seconds(attempts: int) -> int: class JobKind: """A kind of calculation the job store can collect. - Step 2 registers the household calculation and report kinds; until then no - kind is registered and nothing submits jobs. + A report kind (``report_kind`` set) stores its result in + ``simulation.report_results``, validated by the aggregate model registered + for that report kind and ``result_schema_version``; any other kind stores + it in ``simulation.job_results``. Step 2 registers the household + calculation and report kinds; until then no kind is registered and nothing + submits jobs. """ name: str result_model: type[BaseModel] result_schema_version: int + report_kind: ReportKind | None = None # The job kinds production collects, by name. @@ -198,6 +208,10 @@ def get_result(self, job_id: UUID) -> JobResultRecord | None: """Return a succeeded job's result.""" ... + def get_report_result(self, job_id: UUID) -> ReportResultRecord | None: + """Return a succeeded report job's result.""" + ... + def record_check(self, job_id: UUID) -> int | None: """Count one check of a running job.""" ... @@ -208,6 +222,17 @@ def complete( """Store a running job's result.""" ... + def complete_report( + self, + job_id: UUID, + *, + report_kind: ReportKind, + aggregate: object, + aggregate_schema_version: int, + ) -> bool: + """Store a running report job's validated aggregate.""" + ... + def fail(self, job_id: UUID, *, error_code: str, error_message: str) -> bool: """Fail a running job.""" ... @@ -255,7 +280,7 @@ class JobView: """A job and, once it succeeded, its stored result.""" job: JobRecord - result: JobResultRecord | None + result: JobResultRecord | ReportResultRecord | None @dataclass @@ -383,6 +408,8 @@ async def _complete(self, job: JobRecord, output: object) -> CollectionReport: "unknown_job_kind", f"No collector is registered for job kind {job.kind!r}.", ) + if kind.report_kind is not None: + return await self._complete_report(job, kind, output) try: result = kind.result_model.model_validate(output).model_dump(mode="json") except ValidationError as error: @@ -405,6 +432,34 @@ async def _complete(self, job: JobRecord, output: object) -> CollectionReport: return await self._finished_elsewhere(job.id) return CollectionReport(CollectionOutcome.SUCCEEDED, JobStatus.SUCCEEDED) + async def _complete_report( + self, job: JobRecord, kind: JobKind, output: object + ) -> CollectionReport: + try: + stored = await asyncio.to_thread( + lambda: self.store.complete_report( + job.id, + report_kind=kind.report_kind, + aggregate=output, + aggregate_schema_version=kind.result_schema_version, + ) + ) + except ValidationError as error: + return await self._fail( + job.id, + "invalid_result", + f"The aggregate failed validation with {error.error_count()} errors.", + ) + except UnsupportedReportAggregateError: + return await self._fail( + job.id, + "invalid_result", + "No aggregate model is registered for this report.", + ) + if not stored: + return await self._finished_elsewhere(job.id) + return CollectionReport(CollectionOutcome.SUCCEEDED, JobStatus.SUCCEEDED) + async def _fail( self, job_id: UUID, error_code: str, error_message: str ) -> CollectionReport: @@ -479,9 +534,14 @@ async def find(self, reference: str) -> JobView | None: job = await asyncio.to_thread(self.store.get, job_id) if job is None: return None - result = None + result: JobResultRecord | ReportResultRecord | None = None if job.status is JobStatus.SUCCEEDED: - result = await asyncio.to_thread(self.store.get_result, job.id) + kind = self.job_kinds.get(job.kind) + reads_report = kind is not None and kind.report_kind is not None + result = await asyncio.to_thread( + self.store.get_report_result if reads_report else self.store.get_result, + job.id, + ) return JobView(job=job, result=result) diff --git a/projects/policyengine-simulation-entry/tests/report_aggregate_support.py b/projects/policyengine-simulation-entry/tests/report_aggregate_support.py new file mode 100644 index 000000000..1ef1c879c --- /dev/null +++ b/projects/policyengine-simulation-entry/tests/report_aggregate_support.py @@ -0,0 +1,68 @@ +"""A valid single-year economy aggregate for the report job tests.""" + +from __future__ import annotations + +from copy import deepcopy + + +def _baseline_reform(baseline: float, reform: float) -> dict[str, float]: + return {"baseline": baseline, "reform": reform} + + +_AGE_GROUPS = { + "child": _baseline_reform(0.12, 0.11), + "adult": _baseline_reform(0.1, 0.1), + "senior": _baseline_reform(0.08, 0.08), + "all": _baseline_reform(0.1, 0.09), +} + +ECONOMY_AGGREGATE = { + "spm_provenance": None, + "model_version": "1.715.2", + "data_version": "1.115.5", + "budget": { + "budgetary_impact": 70.0, + "tax_revenue_impact": 100.0, + "state_tax_revenue_impact": 20.0, + "benefit_spending_impact": 30.0, + "households": 2.0, + "baseline_net_income": 1000.0, + }, + "detailed_budget": { + "income_tax": {"baseline": 100.0, "reform": 125.0, "difference": 25.0} + }, + "decile": {"relative": {"1": 0.01, "2": 0.02}, "average": {"1": 10.0, "2": 20.0}}, + "inequality": { + "gini": _baseline_reform(0.4, 0.39), + "top_10_pct_share": _baseline_reform(0.3, 0.29), + "top_1_pct_share": _baseline_reform(0.1, 0.09), + }, + "poverty": {"poverty": _AGE_GROUPS, "deep_poverty": _AGE_GROUPS}, + "poverty_by_gender": { + "poverty": { + "male": _baseline_reform(0.08, 0.07), + "female": _baseline_reform(0.09, 0.08), + }, + "deep_poverty": { + "male": _baseline_reform(0.02, 0.02), + "female": _baseline_reform(0.03, 0.02), + }, + }, + "poverty_by_race": None, + "intra_decile": { + "all": {"Gain more than 5%": 0.3, "No change": 0.7}, + "deciles": {"Gain more than 5%": [0.5, 0.1], "No change": [0.5, 0.9]}, + }, + "wealth_decile": None, + "intra_wealth_decile": None, + "labor_supply_response": None, + "constituency_impact": None, + "local_authority_impact": None, + "congressional_district_impact": None, + "cliff_impact": None, +} + + +def economy_aggregate(**overrides) -> dict: + """Return a copy of the aggregate with top-level fields replaced.""" + return deepcopy(ECONOMY_AGGREGATE) | overrides diff --git a/projects/policyengine-simulation-entry/tests/simulation_job_support.py b/projects/policyengine-simulation-entry/tests/simulation_job_support.py index c10536abd..df10b863b 100644 --- a/projects/policyengine-simulation-entry/tests/simulation_job_support.py +++ b/projects/policyengine-simulation-entry/tests/simulation_job_support.py @@ -13,6 +13,7 @@ from datetime import UTC, datetime, timedelta from uuid import UUID, uuid4 +from policyengine_simulation_contract.macro_output import SingleYearMacroOutput from policyengine_simulation_entry.simulation_jobs import ( CallRunning, CollectionQueueError, @@ -28,6 +29,11 @@ Submission, result_sha256, ) +from policyengine_simulation_persistence.reports import ( + ReportKind, + ReportResultRecord, + report_result_values, +) from pydantic import BaseModel, ConfigDict START = datetime(2026, 10, 20, 12, tzinfo=UTC) @@ -52,6 +58,14 @@ class HouseholdResult(BaseModel): ) +ECONOMY_REPORT_KIND = JobKind( + name="economy_report", + result_model=SingleYearMacroOutput, + result_schema_version=1, + report_kind=ReportKind.ECONOMY, +) + + class SteppingClock: """A clock the test moves forward explicitly.""" @@ -72,6 +86,7 @@ def __init__(self, clock: SteppingClock) -> None: self.clock = clock self.jobs: dict[UUID, JobRecord] = {} self.results: dict[UUID, JobResultRecord] = {} + self.report_results: dict[UUID, ReportResultRecord] = {} self.lock = threading.Lock() self.calls: list[str] = [] @@ -204,6 +219,46 @@ def complete(self, job_id, *, result, result_schema_version): ) return True + def complete_report( + self, + job_id, + *, + report_kind, + aggregate, + aggregate_schema_version, + artifacts=(), + ): + self.calls.append("complete_report") + values = report_result_values( + report_kind=report_kind, + aggregate=aggregate, + aggregate_schema_version=aggregate_schema_version, + artifacts=artifacts, + ) + with self.lock: + finished = self._transition( + job_id, + {JobStatus.RUNNING}, + status=JobStatus.SUCCEEDED, + completed_at=self.clock(), + ) + if finished is None: + return False + self.report_results[job_id] = ReportResultRecord( + job_id=job_id, + created_at=self.clock(), + **{ + **values, + "report_kind": report_kind, + "artifacts": tuple(artifacts), + }, + ) + return True + + def get_report_result(self, job_id): + self.calls.append("get_report_result") + return self.report_results.get(job_id) + def fail(self, job_id, *, error_code, error_message): self.calls.append("fail") with self.lock: diff --git a/projects/policyengine-simulation-entry/tests/test_runtime_evaluated_annotations.py b/projects/policyengine-simulation-entry/tests/test_runtime_evaluated_annotations.py index 51c17d1f6..92d50c9e0 100644 --- a/projects/policyengine-simulation-entry/tests/test_runtime_evaluated_annotations.py +++ b/projects/policyengine-simulation-entry/tests/test_runtime_evaluated_annotations.py @@ -13,7 +13,9 @@ The resolution helpers are a copy of those in the executor's tests/test_runtime_evaluated_annotations.py, because the two projects share no package that test helpers could live in; keep the copies the same. The -executor's copy checks the project libraries. +executor's copy checks the project libraries installed with the executor; this +copy also checks the simulation persistence library, which only the +entrypoint installs. """ from __future__ import annotations @@ -37,7 +39,11 @@ from types import ModuleType REPOSITORY_ROOT = Path(__file__).resolve().parents[3] -CHECKED_PACKAGES = ("policyengine_simulation_entry",) +CHECKED_PACKAGES = ( + "policyengine_simulation_entry", + # Only the entrypoint installs it (sim.persistence.executor-independent). + "policyengine_simulation_persistence", +) # What a module-level name is bound to: the qualified name an import gives it, # a class statement in the same module, or None when ruff cannot resolve it (an @@ -302,6 +308,21 @@ def test_every_model_is_runtime_evaluated_for_ruff() -> None: ) +def test_the_persistence_library_models_are_checked() -> None: + """The entrypoint installs the persistence library and the executor never does. + + So this suite is the only one that can check the library's models. + """ + checked_models = [ + module_class.qualified_name for module_class, _ in _model_classes() + ] + + assert any( + model_name.startswith("policyengine_simulation_persistence.") + for model_name in checked_models + ), "the persistence library's Pydantic models are not checked" + + def test_every_model_resolves_its_annotations() -> None: unresolved = [ f"{module_class.qualified_name}: {message}" diff --git a/projects/policyengine-simulation-entry/tests/test_simulation_job_routes.py b/projects/policyengine-simulation-entry/tests/test_simulation_job_routes.py index 151aa2ae2..ac70cb0ef 100644 --- a/projects/policyengine-simulation-entry/tests/test_simulation_job_routes.py +++ b/projects/policyengine-simulation-entry/tests/test_simulation_job_routes.py @@ -23,6 +23,7 @@ ) from policyengine_simulation_observability.identifiers import OBSERVABILITY_ID_HEADER from simulation_job_support import ( + ECONOMY_REPORT_KIND, HOUSEHOLD_KIND, TARGET, FakeDispatcher, @@ -57,16 +58,19 @@ def _service(inspector=None, queue=None): dispatcher=FakeDispatcher(), inspector=inspector or FakeInspector(CallRunning()), queue=queue or FakeQueue(), - job_kinds={HOUSEHOLD_KIND.name: HOUSEHOLD_KIND}, + job_kinds={ + HOUSEHOLD_KIND.name: HOUSEHOLD_KIND, + ECONOMY_REPORT_KIND.name: ECONOMY_REPORT_KIND, + }, clock=clock, ) return service, store, clock -def _running_job(service): +def _running_job(service, kind=HOUSEHOLD_KIND.name): return asyncio.run( service.submit( - kind=HOUSEHOLD_KIND.name, + kind=kind, request_payload={"household": {}}, policyengine_version="6.7.5", submitted_by="api-v1@clients", @@ -261,3 +265,21 @@ def test_job_routes_stay_out_of_the_openapi_document(): ] assert not [path for path in paths if "simulation-jobs" in path] + + +def test_a_report_job_reads_its_aggregate_from_the_report_results(): + from report_aggregate_support import ECONOMY_AGGREGATE + + service, store, _ = _service( + inspector=FakeInspector(CallSucceeded(ECONOMY_AGGREGATE)) + ) + job = _running_job(service, kind=ECONOMY_REPORT_KIND.name) + asyncio.run(service.collect(job.id)) + + with _client(service) as client: + body = client.get(f"/simulation-jobs/{job.id}").json() + + stored = store.get_report_result(job.id) + assert body["status"] == "succeeded" + assert body["result"] == stored.aggregate + assert body["result_sha256"] == stored.aggregate_sha256 diff --git a/projects/policyengine-simulation-entry/tests/test_simulation_jobs.py b/projects/policyengine-simulation-entry/tests/test_simulation_jobs.py index f09768ba1..244cff590 100644 --- a/projects/policyengine-simulation-entry/tests/test_simulation_jobs.py +++ b/projects/policyengine-simulation-entry/tests/test_simulation_jobs.py @@ -21,6 +21,7 @@ ) from policyengine_simulation_persistence.jobs import JobStatus, input_sha256 from simulation_job_support import ( + ECONOMY_REPORT_KIND, HOUSEHOLD_KIND, TARGET, FakeDispatcher, @@ -50,15 +51,23 @@ def _service( dispatcher=dispatcher or FakeDispatcher(), inspector=inspector or FakeInspector(), queue=queue or FakeQueue(), - job_kinds={HOUSEHOLD_KIND.name: HOUSEHOLD_KIND}, + job_kinds={ + HOUSEHOLD_KIND.name: HOUSEHOLD_KIND, + ECONOMY_REPORT_KIND.name: ECONOMY_REPORT_KIND, + }, clock=clock, ) return service, store -async def _submit(service, request=REQUEST, submitted_by="api-v1@clients"): +async def _submit( + service, + request=REQUEST, + submitted_by="api-v1@clients", + kind=HOUSEHOLD_KIND.name, +): return await service.submit( - kind=HOUSEHOLD_KIND.name, + kind=kind, request_payload=request, policyengine_version="6.7.5", submitted_by=submitted_by, @@ -402,3 +411,42 @@ def test_an_unfinished_job_reads_without_a_result(): assert view.job.status is JobStatus.RUNNING assert view.result is None + + +# --- Report jobs ------------------------------------------------------------------ + + +def _economy_aggregate(**overrides): + from copy import deepcopy + + from report_aggregate_support import ECONOMY_AGGREGATE + + return deepcopy(ECONOMY_AGGREGATE) | overrides + + +def test_a_report_job_result_goes_to_the_report_results(): + inspector = FakeInspector(CallSucceeded(_economy_aggregate())) + service, store = _service(inspector=inspector) + job = asyncio.run(_submit(service, kind=ECONOMY_REPORT_KIND.name)) + + report = asyncio.run(service.collect(job.id)) + + assert report.outcome is CollectionOutcome.SUCCEEDED + assert store.get_result(job.id) is None + stored = store.get_report_result(job.id) + assert stored.aggregate_schema_version == 1 + assert stored.budgetary_impact == 70.0 + view = asyncio.run(service.find(str(job.id))) + assert view.result is stored + + +def test_an_invalid_report_aggregate_fails_the_job(): + inspector = FakeInspector(CallSucceeded(_economy_aggregate(budget=None))) + service, store = _service(inspector=inspector) + job = asyncio.run(_submit(service, kind=ECONOMY_REPORT_KIND.name)) + + report = asyncio.run(service.collect(job.id)) + + assert report.outcome is CollectionOutcome.FAILED + assert store.get(job.id).error_code == "invalid_result" + assert store.get_report_result(job.id) is None diff --git a/projects/policyengine-simulation-entry/uv.lock b/projects/policyengine-simulation-entry/uv.lock index 89243bb07..9390e2d80 100644 --- a/projects/policyengine-simulation-entry/uv.lock +++ b/projects/policyengine-simulation-entry/uv.lock @@ -977,7 +977,9 @@ name = "policyengine-simulation-persistence" version = "0.1.0" source = { editable = "../../libs/policyengine-simulation-persistence" } dependencies = [ + { name = "policyengine-simulation-contract" }, { name = "psycopg", extra = ["binary"] }, + { name = "pydantic" }, { name = "sqlalchemy" }, ] @@ -985,7 +987,9 @@ dependencies = [ requires-dist = [ { name = "alembic", marker = "extra == 'migrations'", specifier = ">=1.16,<2" }, { name = "alembic", marker = "extra == 'test'", specifier = ">=1.16,<2" }, + { name = "policyengine-simulation-contract", editable = "../../libs/policyengine-simulation-contract" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.2,<4" }, + { name = "pydantic", specifier = ">=2,<3" }, { name = "pytest", marker = "extra == 'test'", specifier = ">=8.3.4" }, { name = "pytest-cov", marker = "extra == 'test'", specifier = ">=6.1.1" }, { name = "pyyaml", marker = "extra == 'test'", specifier = ">=6.0.2" }, diff --git a/rules/registry.toml b/rules/registry.toml index 5675bddfb..9e147f31e 100644 --- a/rules/registry.toml +++ b/rules/registry.toml @@ -1054,6 +1054,7 @@ nodes = [ "projects/policyengine-simulation-executor/tests/test_runtime_evaluated_annotations.py::test_same_module_base_in_a_package_is_named_after_its_file", "projects/policyengine-simulation-entry/tests/test_runtime_evaluated_annotations.py::test_every_model_is_runtime_evaluated_for_ruff", "projects/policyengine-simulation-entry/tests/test_runtime_evaluated_annotations.py::test_every_model_resolves_its_annotations", + "projects/policyengine-simulation-entry/tests/test_runtime_evaluated_annotations.py::test_the_persistence_library_models_are_checked", "projects/policyengine-simulation-entry/tests/test_runtime_evaluated_annotations.py::test_bases_resolve_as_ruff_resolves_them", "projects/policyengine-simulation-entry/tests/test_runtime_evaluated_annotations.py::test_same_module_base_in_a_package_is_named_after_its_file", ]