From f099389ee0912a9ec70bd19311ac00099a444e24 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 29 Sep 2026 02:56:09 +0400 Subject: [PATCH 01/11] test(executor): prepare fixtures for PolicyEngine 6 --- .../fixtures/identity_stubs.py | 35 ++++++- .../fixtures/spm_doubles.py | 37 ++++++++ .../fixtures/wrapper_spm.py | 17 ++-- .../tests/test_baseline_artifacts.py | 73 ++++++++++++--- .../tests/test_simulation_output_builder.py | 93 ++++++++++++------- 5 files changed, 194 insertions(+), 61 deletions(-) create mode 100644 projects/policyengine-simulation-executor/fixtures/spm_doubles.py diff --git a/projects/policyengine-simulation-executor/fixtures/identity_stubs.py b/projects/policyengine-simulation-executor/fixtures/identity_stubs.py index 0b58fbc61..d260dd5ad 100644 --- a/projects/policyengine-simulation-executor/fixtures/identity_stubs.py +++ b/projects/policyengine-simulation-executor/fixtures/identity_stubs.py @@ -5,8 +5,14 @@ ``release_bundle.get_country_release_bundle``, ``release_bundle._receipt_dataset``, ``manifest.resolve_dataset_reference``, ``manifest.dataset_logical_name``, -``manifest.get_release_manifest`` — or one suite silently tests a stale -list when identity collection grows a seam. This is that list's one home. +``manifest.get_release_manifest``, ``spm.runtime_spm_capability`` — or one +suite silently tests a stale list when identity collection grows a seam. +This is that list's one home. + +The SPM capability is a seam because ``collect_dataset_identity`` resolves +the installed bundle's SPM selection into the dataset identity. The default +stub has no capability so fixed identifier tests remain tied to their +synthetic inputs instead of whichever PolicyEngine release is installed. """ from types import SimpleNamespace @@ -15,11 +21,17 @@ def install_identity_stubs(monkeypatch): """Stub the identity seams with the canonical synthetic version-set. - Returns a mutable state (``bundle``, ``receipt_entry``) so tests can - vary the receipt or bundle per case after installation. + Returns mutable bundle, receipt, and SPM-capability state, plus a handle + to the installed capability resolver for tests that intentionally exercise + the installed release. """ from policyengine.provenance import manifest as manifest_module + # This module imports ``get_release_manifest`` by value while constructing + # country models. Import it before replacing the manifest module's function + # so full-suite ordering cannot bind this test's stub into production code. + import policyengine.tax_benefit_models.common.model_version # noqa: F401 + from policyengine_simulation_executor import release_bundle bundle = SimpleNamespace( @@ -38,7 +50,11 @@ def install_identity_stubs(monkeypatch): "version": "1.2.3", "installed_sha256": "feedbead" * 8, } - state = SimpleNamespace(bundle=bundle, receipt_entry=receipt_entry) + state = SimpleNamespace( + bundle=bundle, + receipt_entry=receipt_entry, + spm_capability=None, + ) monkeypatch.setattr( release_bundle, "get_country_release_bundle", lambda country: state.bundle @@ -63,4 +79,13 @@ def install_identity_stubs(monkeypatch): certification=SimpleNamespace(data_build_fingerprint="fp-123") ), ) + + from policyengine_simulation_executor import spm as executor_spm + + state.installed_spm_capability = executor_spm.runtime_spm_capability + monkeypatch.setattr( + executor_spm, + "runtime_spm_capability", + lambda: state.spm_capability, + ) return state diff --git a/projects/policyengine-simulation-executor/fixtures/spm_doubles.py b/projects/policyengine-simulation-executor/fixtures/spm_doubles.py new file mode 100644 index 000000000..b9c2771d1 --- /dev/null +++ b/projects/policyengine-simulation-executor/fixtures/spm_doubles.py @@ -0,0 +1,37 @@ +"""Small simulation doubles for executor tests that are not model tests.""" + +from types import SimpleNamespace + + +def installed_spm_selection(): + """Return the installed US bundle's default SPM selection, if any.""" + from policyengine_simulation_executor.spm import runtime_spm_capability + + capability = runtime_spm_capability() + return None if capability is None else capability.defaults.model_dump() + + +def spm_receipt(selection, year="2026"): + """Build the smallest calculation record accepted by ``SPMProvenance``.""" + return { + "forecast_id": "test-only", + "forecast_sha256": selection["forecast_content_sha256"], + "scenario": selection["scenario"], + "geography_kind": selection["geography_kind"], + "runtime_versions": {"policyengine-us": "test-only"}, + "years": {str(year): {"status": "forecast"}}, + "geographies": [], + "composition_method": "classified-inputs", + "storage_method": "formula", + } + + +def spm_capable_simulation(selection, year="2026", **attributes): + """Return a stand-in for a simulation that measured ``selection``.""" + if selection is None: + return SimpleNamespace(**attributes) + return SimpleNamespace( + spm_config=dict(selection), + spm_provenance=lambda: spm_receipt(selection, year), + **attributes, + ) diff --git a/projects/policyengine-simulation-executor/fixtures/wrapper_spm.py b/projects/policyengine-simulation-executor/fixtures/wrapper_spm.py index 46442315e..172c1dc37 100644 --- a/projects/policyengine-simulation-executor/fixtures/wrapper_spm.py +++ b/projects/policyengine-simulation-executor/fixtures/wrapper_spm.py @@ -1,16 +1,13 @@ -"""The canonical wrapper's ``storage_id``, transcribed for hermetic tests. +"""The SPM-capable wrapper's ``storage_id``, transcribed for hermetic tests. -The executor pins a pre-canonical ``policyengine``: its ``Simulation`` has -no ``spm`` field and no ``storage_id``, so hermetic CI cannot import the -property that names every canonical baseline artifact. Precompute plans a -store path from ``BaselineArtifactIdentity.storage_id`` and the -in-container worker aborts when the wrapper's value disagrees, so the two -derivations have to be one identifier reached down two paths. +Precompute plans a store path from ``BaselineArtifactIdentity.storage_id`` +and the in-container worker aborts when the wrapper's value disagrees, so the +two derivations have to be one identifier reached down two paths. This is that second path, copied out of the SPM-capable wrapper so the key-discipline tests and the precompute writer==reader tests state it once. -It proves our side has not drifted from the contract we read; it is not the -wrapper, and only the SPM_NATIVE_SMOKE_SOURCE-gated suites run against one. +It is an independent expression of the identifier, not a replacement for +tests against the installed wrapper. ``policyengine/core/simulation.py``:: @@ -50,7 +47,7 @@ def wrapper_storage_id(simulation_id: str, spm_config: dict | None) -> str: def installed_wrapper_has_storage_id() -> bool: - """True once an SPM-capable wrapper is pinned and this file can retire.""" + """Whether the installed wrapper exposes the SPM-aware identifier.""" from policyengine.core import Simulation return hasattr(Simulation, "storage_id") diff --git a/projects/policyengine-simulation-executor/tests/test_baseline_artifacts.py b/projects/policyengine-simulation-executor/tests/test_baseline_artifacts.py index 576a5b5c3..419887278 100644 --- a/projects/policyengine-simulation-executor/tests/test_baseline_artifacts.py +++ b/projects/policyengine-simulation-executor/tests/test_baseline_artifacts.py @@ -326,7 +326,11 @@ def wired(self, monkeypatch): from policyengine_simulation_executor import simulation_runtime as sr recorded = SimpleNamespace( - artifact_kwargs=None, plain_kwargs=None, id=None, id_kwargs=None + artifact_kwargs=None, + plain_kwargs=None, + id=None, + id_kwargs=None, + capability=None, ) class FakeArtifactSimulation: @@ -337,8 +341,21 @@ class FakePlainSimulation: def __init__(self, **kwargs): recorded.plain_kwargs = kwargs + # Replacing ``Simulation`` also replaces a class inspected by the SPM + # capability resolver. Preserve the capability derived from the real + # installed class before installing the test double. + from policyengine_simulation_executor import spm as executor_spm + + installed_capability = executor_spm.runtime_spm_capability() + monkeypatch.setattr(ba, "ArtifactBaselineSimulation", FakeArtifactSimulation) monkeypatch.setattr(policyengine_core, "Simulation", FakePlainSimulation) + monkeypatch.setattr( + executor_spm, + "runtime_spm_capability", + lambda: installed_capability, + ) + recorded.capability = installed_capability monkeypatch.setattr( sr, "_country_module", @@ -367,12 +384,19 @@ def test_qualifying_baseline_uses_artifact_class(self, wired): assert wired.plain_kwargs is None assert wired.artifact_kwargs["id"] == "bl1-deadbeefdeadbeef" assert wired.artifact_kwargs["dataset"] == "dataset" + expected_selection = ( + None if wired.capability is None else wired.capability.defaults.model_dump() + ) + assert wired.artifact_kwargs.get("spm") == expected_selection # The predicate must see the request's own facts. A wiring # regression (e.g. policy=None passed unconditionally) would hand # a REFORM simulation the baseline's deterministic id — and # ensure() would then serve the baseline artifact as the reform. + expected_params = {"country": "us", "scope": "macro"} + if expected_selection is not None: + expected_params["spm"] = expected_selection assert wired.id_kwargs == { - "params": {"country": "us", "scope": "macro"}, + "params": expected_params, "country": "us", "dataset_is_default": True, "policy": None, @@ -465,6 +489,10 @@ class DiskModelVersion: these tests exercise genuine h5 files on disk — including the exception type a missing artifact raises — without loading the US tax system.""" + # PolicyEngine 6 reads this class-level value while restoring SPM metadata. + # These disk tests are model-neutral, so keep the non-US branch selected. + country_code = "" + def __init__(self): from policyengine.tax_benefit_models.us.datasets import PolicyEngineUSDataset @@ -650,18 +678,34 @@ class SPMWrapperSimulation(Simulation): selections. Those three are the native suite's claims; hermetic green here is not wrapper conformance. - ``storage_id`` is a plain field rather than a property derived from the - selection, so it stays put while a load rewrites ``spm`` — the double - makes no claim about how the wrapper computes it, only that the artifact - class keys the process cache on it. + The real v6 wrapper exposes ``spm_config`` and ``storage_id`` as + properties. This double keeps them settable so each case can control what + a load restored, but overrides the properties instead of trying to shadow + them with Pydantic fields. """ spm: dict | None = None - spm_config: dict | None = None spm_receipt: dict | None = None - storage_id: str = "" + _spm_config: dict | None = PrivateAttr(default=None) + _storage_id: str = PrivateAttr(default="") _provenance_reads: list = PrivateAttr(default_factory=list) + @property + def spm_config(self) -> dict | None: + return self._spm_config + + @spm_config.setter + def spm_config(self, value: dict | None) -> None: + self._spm_config = value + + @property + def storage_id(self) -> str: + return self._storage_id + + @storage_id.setter + def storage_id(self, value: str) -> None: + self._storage_id = value + def spm_provenance(self): self._provenance_reads.append(deepcopy(self.spm_config)) return self.spm_receipt @@ -726,9 +770,8 @@ def _make_spm_sim( model_version, *, spm=None, sim_id="bl1-spm", storage_id=None, year=2026 ): selection = SPM_SELECTION if spm is None else spm - return CanonicalSPMSimulation.model_construct( + simulation = CanonicalSPMSimulation.model_construct( id=sim_id, - storage_id=storage_id or f"{sim_id}-{selection['scenario']}", dataset=SimpleNamespace(year=year), tax_benefit_model_version=model_version, policy=None, @@ -737,13 +780,13 @@ def _make_spm_sim( extra_variables={}, output_dataset=None, spm=deepcopy(SPM_SELECTION) if spm is None else deepcopy(spm), - # The wrapper carries the selection it was configured with from - # construction; a load or cache hit then overwrites it with whatever - # the artifact was built under, which is exactly what the guard in - # ``ensure()`` compares against the pre-load value. - spm_config=deepcopy(SPM_SELECTION) if spm is None else deepcopy(spm), spm_receipt=None, ) + simulation.storage_id = storage_id or f"{sim_id}-{selection['scenario']}" + # A load or cache hit overwrites the configured selection with the value + # stored in the artifact; the guard compares it with this requested value. + simulation.spm_config = deepcopy(selection) + return simulation class TestEnsureValidatesSPMReceipts: diff --git a/projects/policyengine-simulation-executor/tests/test_simulation_output_builder.py b/projects/policyengine-simulation-executor/tests/test_simulation_output_builder.py index 8f71684b6..5cf1452b2 100644 --- a/projects/policyengine-simulation-executor/tests/test_simulation_output_builder.py +++ b/projects/policyengine-simulation-executor/tests/test_simulation_output_builder.py @@ -10,6 +10,10 @@ import pandas as pd import pytest +from fixtures.spm_doubles import ( + installed_spm_selection, + spm_capable_simulation, +) from fixtures.test_simulation_api_contracts import ( CURRENT_SINGLE_YEAR_MACRO_KEYS, CURRENT_SINGLE_YEAR_MACRO_RESULT, @@ -76,6 +80,26 @@ ) +def _params_with_spm(params, selection): + """Return request parameters after the runtime resolves SPM settings.""" + return params if selection is None else {**params, "spm": selection} + + +def _assert_macro_result(result, selection): + """Check the ordinary macro result plus any installed SPM record.""" + spm_keys = ("spm_config", "spm_provenance") + assert {key: value for key, value in result.items() if key not in spm_keys} == { + key: value + for key, value in CURRENT_SINGLE_YEAR_MACRO_RESULT.items() + if key not in spm_keys + } + if selection is None: + assert all(result[key] is None for key in spm_keys) + return + assert result["spm_config"] == selection + assert set(result["spm_provenance"]) == {"baseline", "reform"} + + def _load_dataset(params, *, country_module=None, region_resolution=None): """Exercise dataset resolution and loading together in direct loader tests.""" selection = _resolve_dataset_selection(params, region_resolution=region_resolution) @@ -479,8 +503,9 @@ def test_run_simulation_impl_records_runtime_timings_without_real_calculation( ): dataset = object() country_module = SimpleNamespace(model=SimpleNamespace(version="1.715.2")) - baseline_simulation = object() - reform_simulation = object() + selection = installed_spm_selection() + baseline_simulation = spm_capable_simulation(selection) + reform_simulation = spm_capable_simulation(selection) build_calls = [] def fake_build_simulation( @@ -500,7 +525,7 @@ def __init__(self, **kwargs): self.kwargs = kwargs def serialize(self): - return CURRENT_SINGLE_YEAR_MACRO_RESULT + return dict(CURRENT_SINGLE_YEAR_MACRO_RESULT) monkeypatch.delenv("GOOGLE_APPLICATION_CREDENTIALS", raising=False) monkeypatch.delenv("GOOGLE_APPLICATION_CREDENTIALS_JSON", raising=False) @@ -543,7 +568,7 @@ def serialize(self): ) ) - assert result == CURRENT_SINGLE_YEAR_MACRO_RESULT + _assert_macro_result(result, selection) assert set(timings) >= { Stage.CREDENTIAL_SETUP, Stage.REQUEST_PARSE, @@ -576,24 +601,24 @@ def serialize(self): # region_code must be the RESOLVED code: it feeds the deterministic # baseline-id predicate, and dropping it silently disables artifact # reuse (every baseline becomes a miss). + built_params = _params_with_spm( + { + "country": "us", + "baseline": {"gov.test.parameter": {"2026-01-01": 1}}, + "reform": {"gov.test.parameter": {"2026-01-01": 2}}, + }, + selection, + ) assert build_calls == [ ( - { - "country": "us", - "baseline": {"gov.test.parameter": {"2026-01-01": 1}}, - "reform": {"gov.test.parameter": {"2026-01-01": 2}}, - }, + built_params, dataset, {"gov.test.parameter": {"2026-01-01": 1}}, "mock-scoping", "us", ), ( - { - "country": "us", - "baseline": {"gov.test.parameter": {"2026-01-01": 1}}, - "reform": {"gov.test.parameter": {"2026-01-01": 2}}, - }, + built_params, dataset, {"gov.test.parameter": {"2026-01-01": 2}}, "mock-scoping", @@ -607,9 +632,10 @@ def test_run_simulation_impl_exports_baseline_artifact_outcome(monkeypatch): artifact pipeline; losing the export would blind that measurement.""" dataset = object() country_module = SimpleNamespace(model=SimpleNamespace(version="1.715.2")) + selection = installed_spm_selection() simulations = { - "baseline": SimpleNamespace(artifact_outcome="incomplete"), - "reform": object(), + "baseline": spm_capable_simulation(selection, artifact_outcome="incomplete"), + "reform": spm_capable_simulation(selection), } build_count = [0] @@ -630,7 +656,7 @@ def __init__(self, **kwargs): self.kwargs = kwargs def serialize(self): - return CURRENT_SINGLE_YEAR_MACRO_RESULT + return dict(CURRENT_SINGLE_YEAR_MACRO_RESULT) for env in ( "GOOGLE_APPLICATION_CREDENTIALS", @@ -676,7 +702,7 @@ def serialize(self): # A plain Simulation baseline (no artifact_outcome) must emit nothing. runtime.context.clear() build_count[0] = 0 - simulations["baseline"] = object() + simulations["baseline"] = spm_capable_simulation(selection) run_simulation_impl(params, runtime=runtime) assert all(key != "baseline_artifact" for key, _ in runtime.context) @@ -1011,8 +1037,9 @@ def test_normalise_policy_converts_legacy_period_range_keys(): def test_run_simulation_impl_core_builds_and_serializes_macro_output(monkeypatch): dataset = object() country_module = SimpleNamespace(model=SimpleNamespace(version="1.715.2")) - baseline_simulation = object() - reform_simulation = object() + selection = installed_spm_selection() + baseline_simulation = spm_capable_simulation(selection) + reform_simulation = spm_capable_simulation(selection) build_calls = [] builder_calls = [] @@ -1037,7 +1064,7 @@ def __init__(self, **kwargs): builder_calls.append(kwargs) def serialize(self): - return CURRENT_SINGLE_YEAR_MACRO_RESULT + return dict(CURRENT_SINGLE_YEAR_MACRO_RESULT) monkeypatch.setattr( "policyengine_simulation_executor.simulation_runtime._country_module", @@ -1070,7 +1097,7 @@ def serialize(self): runtime=runtime, ) - assert result == CURRENT_SINGLE_YEAR_MACRO_RESULT + _assert_macro_result(result, selection) assert build_calls[0][2] == {"gov.test.parameter": {"2026-01-01": 1}} assert build_calls[1][2] == {"gov.test.parameter": {"2026-01-01": 2}} assert build_calls[0][3] is None @@ -1078,11 +1105,14 @@ def serialize(self): assert builder_calls == [ { "country": "us", - "simulation_params": { - "country": "us", - "baseline": {"gov.test.parameter": {"2026-01-01.2100-12-31": 1}}, - "reform": {"gov.test.parameter": {"2026-01-01.2100-12-31": 2}}, - }, + "simulation_params": _params_with_spm( + { + "country": "us", + "baseline": {"gov.test.parameter": {"2026-01-01.2100-12-31": 1}}, + "reform": {"gov.test.parameter": {"2026-01-01.2100-12-31": 2}}, + }, + selection, + ), "country_module": country_module, "dataset": dataset, "baseline": baseline_simulation, @@ -1097,8 +1127,9 @@ def serialize(self): def test_run_simulation_impl_core_passes_region_scoping_to_simulations(monkeypatch): dataset = object() country_module = SimpleNamespace(model=SimpleNamespace(version="1.715.2")) - baseline_simulation = object() - reform_simulation = object() + selection = installed_spm_selection() + baseline_simulation = spm_capable_simulation(selection) + reform_simulation = spm_capable_simulation(selection) scoping_strategy = object() region_resolution = RegionResolution( code="state/ut", @@ -1131,7 +1162,7 @@ def __init__(self, **kwargs): pass def serialize(self): - return CURRENT_SINGLE_YEAR_MACRO_RESULT + return dict(CURRENT_SINGLE_YEAR_MACRO_RESULT) monkeypatch.setattr( "policyengine_simulation_executor.simulation_runtime._country_module", @@ -1164,7 +1195,7 @@ def serialize(self): runtime=_TrackingRuntime(), ) - assert result == CURRENT_SINGLE_YEAR_MACRO_RESULT + _assert_macro_result(result, selection) assert build_calls[0][3] is scoping_strategy assert build_calls[1][3] is scoping_strategy assert len(load_selections) == 1 From 49bf1a0a9530b6a92bffb97022ad8232f977225e Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 29 Sep 2026 02:59:02 +0400 Subject: [PATCH 02/11] build(executor): install PolicyEngine 6.1.2 from uv.lock --- .github/scripts/modal-extract-versions.sh | 5 +- .github/workflows/pr-image-smoke.yml | 3 +- .../workflows/simulation-deploy.reusable.yml | 1 + .../README.md | 37 +++--- .../bundle-constraints.txt | 4 - .../pyproject.toml | 28 ++-- .../src/modal/app.py | 79 ++--------- .../src/modal/bundle_data.py | 26 ++++ .../src/modal/smoke_app.py | 4 +- .../modal/utils/extract_bundle_versions.py | 2 + .../src/modal/v2_app.py | 23 +--- .../tests/test_bundle_version_export.py | 3 + .../tests/test_modal_bundle_image.py | 124 ++++-------------- .../tests/test_stage12_modal_app.py | 18 ++- .../policyengine-simulation-executor/uv.lock | 95 ++++++-------- 15 files changed, 163 insertions(+), 289 deletions(-) delete mode 100644 projects/policyengine-simulation-executor/bundle-constraints.txt create mode 100644 projects/policyengine-simulation-executor/src/modal/bundle_data.py diff --git a/.github/scripts/modal-extract-versions.sh b/.github/scripts/modal-extract-versions.sh index 656eacd85..8f2bc2699 100755 --- a/.github/scripts/modal-extract-versions.sh +++ b/.github/scripts/modal-extract-versions.sh @@ -1,9 +1,10 @@ #!/bin/bash # Extract policyengine.py, policyengine-core, country package versions, and -# country data release versions. +# SPM calculator, and country data release versions. # Usage: ./modal-extract-versions.sh # Outputs: Sets policyengine_version, policyengine_core_version, us_version, -# us_data_version, uk_version, and uk_data_version in GITHUB_OUTPUT +# spm_calculator_version, us_data_version, uk_version, and uk_data_version in +# GITHUB_OUTPUT set -euo pipefail diff --git a/.github/workflows/pr-image-smoke.yml b/.github/workflows/pr-image-smoke.yml index 714c88137..4c38ae4f3 100644 --- a/.github/workflows/pr-image-smoke.yml +++ b/.github/workflows/pr-image-smoke.yml @@ -14,7 +14,6 @@ on: - 'projects/policyengine-simulation-executor/uv.lock' - 'projects/policyengine-simulation-executor/pyproject.toml' - 'projects/policyengine-simulation-executor/src/modal/**' - - 'projects/policyengine-simulation-executor/bundle-constraints.txt' - 'projects/policyengine-simulation-executor/src/policyengine_simulation_executor/**' - 'libs/policyengine-simulation-contract/**' - 'libs/policyengine-simulation-observability/**' @@ -32,7 +31,7 @@ jobs: runs-on: ubuntu-latest if: github.event.pull_request.head.repo.full_name == github.repository # Warm cache: ~2 min. After a relock the executor smoke pays the - # policyengine bundle install layer (~15-20 min). + # certified dataset download layer (~15-20 min). timeout-minutes: 45 steps: diff --git a/.github/workflows/simulation-deploy.reusable.yml b/.github/workflows/simulation-deploy.reusable.yml index f859f173f..ec6271a76 100644 --- a/.github/workflows/simulation-deploy.reusable.yml +++ b/.github/workflows/simulation-deploy.reusable.yml @@ -100,6 +100,7 @@ jobs: outputs: policyengine_version: ${{ steps.versions.outputs.policyengine_version }} policyengine_core_version: ${{ steps.versions.outputs.policyengine_core_version }} + spm_calculator_version: ${{ steps.versions.outputs.spm_calculator_version }} us_version: ${{ steps.versions.outputs.us_version }} us_data_version: ${{ steps.versions.outputs.us_data_version }} uk_version: ${{ steps.versions.outputs.uk_version }} diff --git a/projects/policyengine-simulation-executor/README.md b/projects/policyengine-simulation-executor/README.md index 638892281..45cf40ca4 100644 --- a/projects/policyengine-simulation-executor/README.md +++ b/projects/policyengine-simulation-executor/README.md @@ -4,29 +4,26 @@ PolicyEngine Simulation API service. ## Modal image dependencies -The executor image (`src/modal/app.py`) installs its bootstrap packages -straight from this project's `uv.lock` via +The executor images (`src/modal/app.py` and `src/modal/v2_app.py`) install +their runtime packages straight from this project's `uv.lock` via `uv_sync(frozen=True, --only-group modal-simulation-image)`. Image packages therefore match the versions the test environment runs against and can only change through a relock — never through a fresh resolution -at image-build time (issue #602 is what happens otherwise). Country -model packages are deliberately not in the group: the -`policyengine bundle install` layer manages them, installing into the -same interpreter (uv_sync's venv is first on PATH). The gateway lives in -its own project (`projects/policyengine-simulation-gateway`) whose image -installs the same way from that project's lock — see its README. - -The bundle install subprocess uses `bundle-constraints.txt` through -`PIP_CONSTRAINT`, because pip does not read `uv.lock`. This preserves SPM -calculator 0.3.1 for the current country model and historical bundle rebuilds. -The worker, precompute, and import-smoke images share this constrained build -path. `BUNDLE_CONSTRAINT_FILES` selects the file by exact bundle version; -unreviewed versions fail before building. Add a new reviewed file and selection -for a country/bundle migration, keeping historical selections on their compatible -calculator version. - -To change image dependencies, edit the `modal-simulation-image` -dependency group and run `uv lock`. PRs touching image inputs run an +at image-build time (issue #602 is what happens otherwise). The +`policyengine-models` group contains the exact PolicyEngine.py wrapper, core, +country-model, and SPM calculator versions selected by the release manifest, +and `modal-simulation-image` includes that group. + +After `uv_sync`, `policyengine bundle install --no-packages` downloads and +verifies the certified datasets and writes the bundle receipt. It does not run +pip or alter the locked Python environment. The v1 worker, Stage 12 workers, +precompute, and import-smoke paths share this package and data arrangement. +The gateway lives in its own project and installs from its own lock; see its +README. + +To change a PolicyEngine release, update the exact requirements in both the +project dependencies and `policyengine-models`, then run `uv lock`. Other image +dependencies belong in `modal-simulation-image`. PRs touching image inputs run an in-image import smoke (`src/modal/smoke_app.py` via `.github/workflows/pr-image-smoke.yml`). Note that any change to the group or lock invalidates the image layer cache, including the artifact diff --git a/projects/policyengine-simulation-executor/bundle-constraints.txt b/projects/policyengine-simulation-executor/bundle-constraints.txt deleted file mode 100644 index 9829f790f..000000000 --- a/projects/policyengine-simulation-executor/bundle-constraints.txt +++ /dev/null @@ -1,4 +0,0 @@ -# Older policyengine bundles omit this transitive dependency from their manifest. -# Keep their established SPM method when pip rebuilds an image. Update only with -# a reviewed country-model/bundle migration, including historical route handling. -spm-calculator==0.3.1 diff --git a/projects/policyengine-simulation-executor/pyproject.toml b/projects/policyengine-simulation-executor/pyproject.toml index abf19235d..30fae65da 100644 --- a/projects/policyengine-simulation-executor/pyproject.toml +++ b/projects/policyengine-simulation-executor/pyproject.toml @@ -19,11 +19,11 @@ dependencies = [ "policyengine-simulation-observability", "policyengine-simulation-contract[modal]", "policyengine-stage12-persistence", - "policyengine==5.2.0", - "policyengine-core==3.30.1", + "policyengine==6.1.2", + "policyengine-core==3.32.5", "policyengine-uk==2.90.2", - "policyengine-us==1.764.6", - "spm-calculator==0.3.1", + "policyengine-us==2.2.1", + "spm-calculator==1.0.0", "tables>=3.10.2", "modal>=0.73.0", "policyengine-observability[fastapi,google,otlp-grpc]>=3.0.1,<4", @@ -40,20 +40,22 @@ packages = ["src/policyengine_simulation_executor"] # project; the image installs it directly with # uv_sync(--only-group modal-simulation-image, frozen=True), so image # packages can only change through a relock and always match the -# versions the test environment runs against. Country models are NOT -# here — the policyengine bundle install manages them. +# versions the test environment runs against. The included model group is +# maintained from PolicyEngine.py's release manifest; the bundle command only +# downloads certified datasets and writes their receipt. [dependency-groups] +policyengine-models = [ + "policyengine==6.1.2", + "policyengine-core==3.32.5", + "policyengine-uk==2.90.2", + "policyengine-us==2.2.1", + "spm-calculator==1.0.0", +] modal-simulation-image = [ - "uv", - # The policyengine bundle installer runs `python -m pip install` in - # the target environment, and uv-created venvs do not ship pip. - "pip", + {include-group = "policyengine-models"}, "fastapi>=0.115.0", "tables>=3.10.2", "policyengine-observability[fastapi,google,otlp-grpc]>=3.0.1,<4", - # policyengine-us 1.764.6 imports spm_calculator.forecast, which was - # removed from later releases still allowed by its broad requirement. - "spm-calculator==0.3.1", # The artifact fetch layer and store client read GCS. The lib is also a # transitive dependency of policyengine, but the image must not depend # on an upstream package keeping it. diff --git a/projects/policyengine-simulation-executor/src/modal/app.py b/projects/policyengine-simulation-executor/src/modal/app.py index ce7f8af22..186af6462 100644 --- a/projects/policyengine-simulation-executor/src/modal/app.py +++ b/projects/policyengine-simulation-executor/src/modal/app.py @@ -8,7 +8,6 @@ """ import os -import shlex from pathlib import Path from policyengine_simulation_observability.observability import ( @@ -30,6 +29,7 @@ get_bundled_country_model_version, ) from src.modal._image_setup import fetch_artifacts, snapshot_models +from src.modal.bundle_data import bundle_data_install_command from src.modal.dependency_pins import project_dependency_pin from src.modal.logging_redaction import redact_params_for_logging @@ -75,25 +75,6 @@ def _version_from_env_or_local_bundle(env_var: str, country: str) -> str: SIMULATION_BUNDLE_RECEIPT = ( f"{SIMULATION_BUNDLE_DATA_DIR}/.policyengine-bundle-receipt.json" ) -BUNDLE_CONSTRAINTS_PATH = "/opt/policyengine/bundle-constraints.txt" -# Retain old bundle selections when a new method gets its own reviewed file. -# These include live historical routes, the existing image-smoke fixture, -# and the released 5.3.0 bundle, whose US model is unchanged from 5.2.0. -BUNDLE_CONSTRAINT_FILES = { - version: "bundle-constraints.txt" - for version in ( - "4.18.3", - "4.18.5", - "4.18.7", - "4.18.8", - "4.18.9", - "4.19.1", - "4.20.3", - "4.22.0", - "5.2.0", - "5.3.0", - ) -} VERSION_ENV = { "POLICYENGINE_VERSION": POLICYENGINE_VERSION, "POLICYENGINE_CORE_VERSION": POLICYENGINE_CORE_VERSION, @@ -178,44 +159,11 @@ def _deploy_time_artifact_inputs() -> tuple[str, dict | None]: _ARTIFACT_BUCKET, _DEPLOY_MANIFEST = _deploy_time_artifact_inputs() -def bundle_constraints_file(policyengine_version: str) -> str: - try: - return BUNDLE_CONSTRAINT_FILES[policyengine_version] - except KeyError: - raise ValueError( - f"Bundle {policyengine_version} needs a reviewed calculator constraint; " - "add its selection without changing historical bundle selections." - ) from None - - def bundle_install_command(policyengine_version: str) -> str: - bundle_constraints_file(policyengine_version) - return " ".join( - [ - f"PIP_CONSTRAINT={shlex.quote(BUNDLE_CONSTRAINTS_PATH)}", - "uvx", - "--from", - f"policyengine=={policyengine_version}", - "policyengine", - "bundle", - "install", - policyengine_version, - # Install into uv_sync's venv so the bundle's model packages - # share one environment with the locked bootstrap packages - # (Modal's uv_sync creates the venv at /.uv/.venv and prepends - # its bin to PATH). Temporary bridge: once policyengine's CLI - # grows a datasets-only mode, uv will own all packages and - # this step shrinks to data + receipt. - "--venv", - "/.uv/.venv", - "--country", - "us", - "--country", - "uk", - "--data-dir", - SIMULATION_BUNDLE_DATA_DIR, - "--yes", - ] + return bundle_data_install_command( + policyengine_version, + countries=("us", "uk"), + data_dir=SIMULATION_BUNDLE_DATA_DIR, ) @@ -233,23 +181,16 @@ def build_runtime_simulation_image() -> modal.Image: # The modal-simulation-image dependency group, installed straight # from this project's uv.lock (frozen): image packages match the # tested environment and can only change through a relock. - # --only-group keeps the heavyweight project dependencies out — - # country models arrive via the policyengine bundle install below. + # --only-group keeps the project's local packages out. Its included + # policyengine-models group installs the exact manifest-selected model + # packages from uv.lock. .uv_sync( uv_project_dir=_UV_PROJECT_DIR, frozen=True, extra_options="--only-group modal-simulation-image", ) - # The bundle installer invokes pip, which does not consult uv.lock. - # Copy the constraint into a build layer so historical bundles cannot - # resolve a newer, incompatible SPM calculator during a rebuild. - .add_local_file( - str(Path(_UV_PROJECT_DIR) / bundle_constraints_file(POLICYENGINE_VERSION)) - if modal.is_local() - else BUNDLE_CONSTRAINTS_PATH, - BUNDLE_CONSTRAINTS_PATH, - copy=True, - ) + # Packages are already installed from the frozen lock. The wrapper CLI + # downloads only the certified datasets and writes their receipt. .run_commands( bundle_install_command(POLICYENGINE_VERSION), secrets=[data_secret, hf_secret], diff --git a/projects/policyengine-simulation-executor/src/modal/bundle_data.py b/projects/policyengine-simulation-executor/src/modal/bundle_data.py new file mode 100644 index 000000000..f395a3235 --- /dev/null +++ b/projects/policyengine-simulation-executor/src/modal/bundle_data.py @@ -0,0 +1,26 @@ +"""Build the dataset-only PolicyEngine bundle command used by Modal images.""" + +from __future__ import annotations + +import shlex +from collections.abc import Sequence + + +def bundle_data_install_command( + policyengine_version: str, + *, + countries: Sequence[str], + data_dir: str, +) -> str: + """Download certified datasets without changing Python packages.""" + parts = [ + "policyengine", + "bundle", + "install", + policyengine_version, + "--no-packages", + ] + for country in countries: + parts.extend(("--country", country)) + parts.extend(("--data-dir", data_dir, "--yes")) + return " ".join(shlex.quote(part) for part in parts) diff --git a/projects/policyengine-simulation-executor/src/modal/smoke_app.py b/projects/policyengine-simulation-executor/src/modal/smoke_app.py index 164a4971f..cca727838 100644 --- a/projects/policyengine-simulation-executor/src/modal/smoke_app.py +++ b/projects/policyengine-simulation-executor/src/modal/smoke_app.py @@ -1,8 +1,8 @@ """Pre-merge image smoke: import the executor runtime inside its image. Import parity, not data parity: the image here is the deployed image's -layer prefix (pinned pip layer, policyengine bundle install, version -env) plus the source mounts — deliberately excluding the artifact-fetch +layer prefix (frozen uv sync, dataset-only PolicyEngine bundle install, +version env) plus the source mounts — deliberately excluding the artifact-fetch and model-snapshot layers, which add no Python packages. Because layers are content-addressed and built through the shared ``build_runtime_simulation_image()``, a warm cache makes this run take seconds; after a relock it pays only the bundle install. diff --git a/projects/policyengine-simulation-executor/src/modal/utils/extract_bundle_versions.py b/projects/policyengine-simulation-executor/src/modal/utils/extract_bundle_versions.py index 5e6d50a86..54cc2ee4a 100644 --- a/projects/policyengine-simulation-executor/src/modal/utils/extract_bundle_versions.py +++ b/projects/policyengine-simulation-executor/src/modal/utils/extract_bundle_versions.py @@ -19,6 +19,7 @@ def _bundle_outputs() -> dict[str, str]: return { "policyengine_version": get_bundled_package_version("policyengine"), "policyengine_core_version": get_bundled_package_version("policyengine-core"), + "spm_calculator_version": get_bundled_package_version("spm-calculator"), "us_version": get_bundled_package_version("policyengine-us"), "us_data_version": us_bundle.data_version, "uk_version": get_bundled_package_version("policyengine-uk"), @@ -45,6 +46,7 @@ def main() -> None: "Deploying with policyengine.py bundle " f"{outputs['policyengine_version']}: " f"policyengine-core={outputs['policyengine_core_version']}, " + f"spm-calculator={outputs['spm_calculator_version']}, " f"policyengine-us={outputs['us_version']}, " f"us-data-release={outputs['us_data_version']}, " f"policyengine-uk={outputs['uk_version']}, " diff --git a/projects/policyengine-simulation-executor/src/modal/v2_app.py b/projects/policyengine-simulation-executor/src/modal/v2_app.py index 4a9f0affd..86d5700b1 100644 --- a/projects/policyengine-simulation-executor/src/modal/v2_app.py +++ b/projects/policyengine-simulation-executor/src/modal/v2_app.py @@ -9,7 +9,6 @@ import json import os -import shlex from pathlib import Path from policyengine_simulation_contract.stage12_bundle import CountryId @@ -34,6 +33,7 @@ assertion_values, load_stage12_bundle, ) +from src.modal.bundle_data import bundle_data_install_command STAGE12_DATA_DIR = "/opt/policyengine/stage12-data" _UV_PROJECT_DIR = str(Path(__file__).resolve().parents[2]) if modal.is_local() else "." @@ -85,22 +85,11 @@ def _country_bundle(country: CountryId): def bundle_install_command(countries: tuple[CountryId, ...]) -> str: - version = RESOLVED_BUNDLE.bundle.policyengine_version - parts = [ - "uvx", - "--from", - RESOLVED_BUNDLE.bundle.policyengine_requirement, - "policyengine", - "bundle", - "install", - version, - "--venv", - "/.uv/.venv", - ] - for country in countries: - parts.extend(("--country", country)) - parts.extend(("--data-dir", STAGE12_DATA_DIR, "--yes")) - return " ".join(shlex.quote(part) for part in parts) + return bundle_data_install_command( + RESOLVED_BUNDLE.bundle.policyengine_version, + countries=countries, + data_dir=STAGE12_DATA_DIR, + ) def build_v2_image(countries: tuple[CountryId, ...]) -> modal.Image: diff --git a/projects/policyengine-simulation-executor/tests/test_bundle_version_export.py b/projects/policyengine-simulation-executor/tests/test_bundle_version_export.py index 6e61a6dd0..501cb78a0 100644 --- a/projects/policyengine-simulation-executor/tests/test_bundle_version_export.py +++ b/projects/policyengine-simulation-executor/tests/test_bundle_version_export.py @@ -46,6 +46,7 @@ def test_version_export_reads_package_versions_from_policyengine_bundle(monkeypa package_versions = { "policyengine": "4.1.0", "policyengine-core": "9.9.9", + "spm-calculator": "3.1.4", "policyengine-us": "1.1.0", "policyengine-uk": "2.1.0", } @@ -67,12 +68,14 @@ def test_version_export_reads_package_versions_from_policyengine_bundle(monkeypa assert requested_packages == [ "policyengine", "policyengine-core", + "spm-calculator", "policyengine-us", "policyengine-uk", ] assert outputs == { "policyengine_version": "4.1.0", "policyengine_core_version": "9.9.9", + "spm_calculator_version": "3.1.4", "us_version": "1.1.0", "us_data_version": "1.10.0", "uk_version": "2.1.0", diff --git a/projects/policyengine-simulation-executor/tests/test_modal_bundle_image.py b/projects/policyengine-simulation-executor/tests/test_modal_bundle_image.py index 8b84c2698..2a796480e 100644 --- a/projects/policyengine-simulation-executor/tests/test_modal_bundle_image.py +++ b/projects/policyengine-simulation-executor/tests/test_modal_bundle_image.py @@ -1,7 +1,5 @@ import importlib -import json import os -import subprocess import sys import tomllib from pathlib import Path @@ -13,10 +11,10 @@ def test_modal_image_uses_policyengine_bundle_install(monkeypatch): install_fake_modal(monkeypatch) - monkeypatch.setenv("POLICYENGINE_VERSION", "4.19.1") - monkeypatch.setenv("POLICYENGINE_CORE_VERSION", "3.27.1") - monkeypatch.setenv("POLICYENGINE_US_VERSION", "1.700.0") - monkeypatch.setenv("POLICYENGINE_UK_VERSION", "2.90.0") + monkeypatch.setenv("POLICYENGINE_VERSION", "6.1.2") + monkeypatch.setenv("POLICYENGINE_CORE_VERSION", "3.32.5") + monkeypatch.setenv("POLICYENGINE_US_VERSION", "2.2.1") + monkeypatch.setenv("POLICYENGINE_UK_VERSION", "2.90.2") monkeypatch.setenv("OBSERVABILITY_SERVICE_NAMESPACE", "policyengine.api-v1") monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "https://collector.test") sys.modules.pop("src.modal.app", None) @@ -29,26 +27,26 @@ def test_modal_image_uses_policyengine_bundle_install(monkeypatch): assert command_calls command = command_calls[0][1][0] assert command.startswith( - "PIP_CONSTRAINT=/opt/policyengine/bundle-constraints.txt " - "uvx --from policyengine==4.19.1 policyengine bundle install 4.19.1" + "policyengine bundle install 6.1.2 --no-packages --country us --country uk" ) - constraint_call = next( + assert "PIP_CONSTRAINT" not in command + assert "uvx" not in command + assert "--venv" not in command + assert not [ call for call in app.simulation_image.calls if call[0] == "add_local_file" - ) - assert constraint_call[2] == "/opt/policyengine/bundle-constraints.txt" - assert constraint_call[3] == {"copy": True} - assert app.simulation_image.calls.index(constraint_call) < ( - app.simulation_image.calls.index(command_calls[0]) - ) - constraints = Path(constraint_call[1]).read_text() - assert "spm-calculator==0.3.1" in constraints.splitlines() + ] project = tomllib.loads( (Path(__file__).resolve().parents[1] / "pyproject.toml").read_text() ) - assert "spm-calculator==0.3.1" in project["project"]["dependencies"] - # The bundle installs into uv_sync's venv so locked packages and - # bundled models share one environment. - assert "--venv /.uv/.venv" in command + expected_models = [ + "policyengine==6.1.2", + "policyengine-core==3.32.5", + "policyengine-uk==2.90.2", + "policyengine-us==2.2.1", + "spm-calculator==1.0.0", + ] + assert project["dependency-groups"]["policyengine-models"] == expected_models + assert all(item in project["project"]["dependencies"] for item in expected_models) assert "--data-dir /opt/policyengine/data" in command assert app.VERSION_ENV["POLICYENGINE_DATA_FOLDER"] == "/opt/policyengine/data" assert app.VERSION_ENV["POLICYENGINE_BUNDLE_RECEIPT"].endswith( @@ -68,8 +66,6 @@ def test_modal_image_uses_policyengine_bundle_install(monkeypatch): _, uv_project_dir, kwargs = uv_sync_calls[0] assert Path(uv_project_dir) == Path(__file__).resolve().parents[1] assert kwargs["frozen"] is True - # Only the image dependency group — the project's heavyweight deps - # (country models) arrive via the bundle install instead. assert "--only-group modal-simulation-image" in kwargs["extra_options"] # The lock is the only package source; ad-hoc pip layers would # reintroduce build-time resolution (issue #602). @@ -79,14 +75,14 @@ def test_modal_image_uses_policyengine_bundle_install(monkeypatch): if call[0] in ("pip_install", "pip_install_from_requirements") ] - group = tomllib.loads( - (Path(__file__).resolve().parents[1] / "pyproject.toml").read_text() - )["dependency-groups"]["modal-simulation-image"] - names = {requirement.split(">=")[0].split("[")[0] for requirement in group} + group = project["dependency-groups"]["modal-simulation-image"] + assert {"include-group": "policyengine-models"} in group + requirements = [item for item in group if isinstance(item, str)] + names = {requirement.split(">=")[0].split("[")[0] for requirement in requirements} assert "policyengine-observability" in names assert "logfire" not in names - # uvx drives the policyengine bundle install into the image. - assert "uv" in names + assert "uv" not in names + assert "pip" not in names runtime_secret_sets = { name: kwargs["secrets"] for name, kwargs in app.app.function_calls @@ -103,76 +99,6 @@ def test_modal_image_uses_policyengine_bundle_install(monkeypatch): ] -@pytest.mark.parametrize( - "version", - [ - "4.18.3", - "4.18.5", - "4.18.7", - "4.18.8", - "4.18.9", - "4.19.1", - "4.20.3", - "4.22.0", - "5.2.0", - "5.3.0", - ], -) -def test_bundle_command_passes_constraints_to_child_installer( - monkeypatch, tmp_path, version -): - """Historical bundle rebuilds must inherit the same pip constraint.""" - install_fake_modal(monkeypatch) - monkeypatch.setenv("POLICYENGINE_VERSION", version) - monkeypatch.setenv("POLICYENGINE_CORE_VERSION", "3.30.1") - monkeypatch.setenv("POLICYENGINE_US_VERSION", "1.764.6") - monkeypatch.setenv("POLICYENGINE_UK_VERSION", "2.90.2") - sys.modules.pop("src.modal.app", None) - app = importlib.import_module("src.modal.app") - constraint_path = tmp_path / "bundle-constraints.txt" - constraint_path.write_text("spm-calculator==0.3.1\n") - monkeypatch.setattr(app, "BUNDLE_CONSTRAINTS_PATH", str(constraint_path)) - executable = tmp_path / "uvx" - executable.write_text( - f"#!{sys.executable}\n" - "import json, os, pathlib, sys\n" - "print(json.dumps({'args': sys.argv[1:], 'constraints': " - "pathlib.Path(os.environ['PIP_CONSTRAINT']).read_text()}))\n" - ) - executable.chmod(0o755) - env = {**os.environ, "PATH": f"{tmp_path}:{os.environ['PATH']}"} - result = subprocess.run( - ["/bin/sh", "-c", app.bundle_install_command(version)], - env=env, - check=True, - capture_output=True, - text=True, - ) - output = json.loads(result.stdout) - assert output["constraints"] == "spm-calculator==0.3.1\n" - assert output["args"][:6] == [ - "--from", - f"policyengine=={version}", - "policyengine", - "bundle", - "install", - version, - ] - - -def test_unreviewed_bundle_cannot_inherit_legacy_calculator_constraint(monkeypatch): - """A new bundle needs an explicit reviewed calculator selection.""" - install_fake_modal(monkeypatch) - monkeypatch.setenv("POLICYENGINE_VERSION", "5.2.0") - monkeypatch.setenv("POLICYENGINE_CORE_VERSION", "3.30.1") - monkeypatch.setenv("POLICYENGINE_US_VERSION", "1.764.6") - monkeypatch.setenv("POLICYENGINE_UK_VERSION", "2.90.2") - sys.modules.pop("src.modal.app", None) - app = importlib.import_module("src.modal.app") - with pytest.raises(ValueError, match="reviewed calculator constraint"): - app.bundle_install_command("unreviewed-future-bundle") - - def _fake_manifest(): """A schema-valid store payload: app.py validates what it reads, so the fake must satisfy ArtifactManifest — deriving it from the model diff --git a/projects/policyengine-simulation-executor/tests/test_stage12_modal_app.py b/projects/policyengine-simulation-executor/tests/test_stage12_modal_app.py index f1841008c..0fb2cd21a 100644 --- a/projects/policyengine-simulation-executor/tests/test_stage12_modal_app.py +++ b/projects/policyengine-simulation-executor/tests/test_stage12_modal_app.py @@ -95,7 +95,9 @@ def test_v2_app_name_and_images_are_separate_and_bundle_derived(monkeypatch) -> assert "--country uk" not in us_command assert "--country uk" in uk_command assert "--country us" not in uk_command - assert module.RESOLVED_BUNDLE.bundle.policyengine_requirement in us_command + assert "--no-packages" in us_command + assert "uvx" not in us_command + assert "--venv" not in us_command assert module.gcp_secret["args"] == ("stage12-evaluation-gcp-credentials",) assert all( secret.get("args") != ("policyengine-logfire",) @@ -123,10 +125,16 @@ def test_v2_image_retains_required_runtime_dependencies() -> None: (PROJECT_ROOT / "pyproject.toml").read_text(encoding="utf-8") ) - assert ( - "spm-calculator==0.3.1" - in project["dependency-groups"]["modal-simulation-image"] - ) + assert project["dependency-groups"]["policyengine-models"] == [ + "policyengine==6.1.2", + "policyengine-core==3.32.5", + "policyengine-uk==2.90.2", + "policyengine-us==2.2.1", + "spm-calculator==1.0.0", + ] + assert {"include-group": "policyengine-models"} in project["dependency-groups"][ + "modal-simulation-image" + ] assert "sqlalchemy>=2,<3" in project["dependency-groups"]["modal-simulation-image"] assert ( "psycopg[binary]>=3.2,<4" diff --git a/projects/policyengine-simulation-executor/uv.lock b/projects/policyengine-simulation-executor/uv.lock index f20ad74b6..592774c35 100644 --- a/projects/policyengine-simulation-executor/uv.lock +++ b/projects/policyengine-simulation-executor/uv.lock @@ -1660,15 +1660,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" }, ] -[[package]] -name = "pip" -version = "26.1.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/01/91/47e7d486260f618783899587af63ccf7980fb60245c3e63dd4571c6b57ad/pip-26.1.2.tar.gz", hash = "sha256:f49cd134c61cf2fd75e0ce2676db03e4054504a5a4986d00f8299ae632dc4605", size = 1840799, upload-time = "2026-05-31T17:33:58.56Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/95/6b5cb3461ea5673ba0995989746db58eb18b91b54dbf331e72f569540946/pip-26.1.2-py3-none-any.whl", hash = "sha256:382ff9f685ee3bc25864f820aa50505825f10f5458ffff07e30a6d96e5715cab", size = 1813144, upload-time = "2026-05-31T17:33:56.772Z" }, -] - [[package]] name = "platformdirs" version = "4.9.6" @@ -1702,7 +1693,7 @@ wheels = [ [[package]] name = "policyengine" -version = "5.2.0" +version = "6.1.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "h5py" }, @@ -1714,14 +1705,14 @@ dependencies = [ { name = "pydantic" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/45/2d/5b09f414e26573748f9ae2e448d6f9f94196b7733e8400d727db9ed17868/policyengine-5.2.0.tar.gz", hash = "sha256:c1e7a3a8d7fb23401aedbe8a9fa2d6ed69e24ae9983b6a4a8a0d5112394b0bc4", size = 732330, upload-time = "2026-08-29T17:24:52.814Z" } +sdist = { url = "https://files.pythonhosted.org/packages/21/93/1cbdb9324920fe9f9b91db3ea076c2b8ea1e5c3eb986bcc3f601e07cb720/policyengine-6.1.2.tar.gz", hash = "sha256:18eb12c7639105d3369c182babedf52a8aef8b0c0e4aae27181fe97d9a02d1ee", size = 771537, upload-time = "2026-09-27T03:54:52.533Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f3/c5/677144df3800ae41dd587b292bd633c09edd5c998695ca15987c05730bc6/policyengine-5.2.0-py3-none-any.whl", hash = "sha256:e73307f787cf366dc16c080b708c39c28a58b0adf34abe51de5ecd97ecfab154", size = 226102, upload-time = "2026-08-29T17:24:51.377Z" }, + { url = "https://files.pythonhosted.org/packages/2c/00/63af579350d76cf85debca243c0360fa6689b50d22bedd89b3425aa7dd5b/policyengine-6.1.2-py3-none-any.whl", hash = "sha256:526e6557fc52b18d671be1726e30bf4eb728124d17372bf27c82c5fcc25813be", size = 252407, upload-time = "2026-09-27T03:54:51.197Z" }, ] [[package]] name = "policyengine-core" -version = "3.30.1" +version = "3.32.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "dpath" }, @@ -1741,9 +1732,9 @@ dependencies = [ { name = "standard-imghdr" }, { name = "wheel" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a1/9d/f71da3e348ddc52e058c76b72bbc4b2f20abcf0e7389ecd5aca5d550fd14/policyengine_core-3.30.1.tar.gz", hash = "sha256:a16f29fe51ec01a7be2171386f8bd26b9ba55ffc338adb70adcadafe33d9c55f", size = 500419, upload-time = "2026-07-19T15:50:41.288Z" } +sdist = { url = "https://files.pythonhosted.org/packages/12/54/cfd2138584de9cddaa4459bba7fdc381101ca8b8ffd38356a1b256850d5f/policyengine_core-3.32.5.tar.gz", hash = "sha256:f051c269b538fe77ee7868f7cfbc0138551960b1b677a0ad6fdc1a93f1660460", size = 420112, upload-time = "2026-09-10T04:16:48.889Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b9/7e/07624d8add8889af1b4af796d36c38b2e2af494b1d78a064fab29e9bea00/policyengine_core-3.30.1-py3-none-any.whl", hash = "sha256:2dbcf5f590a0199a7b7c77fcbbda2ff6bc289f6c6169ca0af3beace35d8b3e63", size = 244846, upload-time = "2026-07-19T15:50:39.651Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d9/72c3707465e9fe77e6f7a55ea64b49a287cbf875b78f6e862d945df26975/policyengine_core-3.32.5-py3-none-any.whl", hash = "sha256:9d8c162d5fe5c784ae16c885da3ffbfc39a536add0e111133144d8edd555935f", size = 245925, upload-time = "2026-09-10T04:16:47.153Z" }, ] [[package]] @@ -1881,14 +1872,23 @@ dev = [ modal-simulation-image = [ { name = "fastapi" }, { name = "google-cloud-storage" }, - { name = "pip" }, + { name = "policyengine" }, + { name = "policyengine-core" }, { name = "policyengine-observability", extra = ["fastapi", "google", "otlp-grpc"] }, + { name = "policyengine-uk" }, + { name = "policyengine-us" }, { name = "psycopg", extra = ["binary"] }, { name = "pyarrow" }, { name = "spm-calculator" }, { name = "sqlalchemy" }, { name = "tables" }, - { name = "uv" }, +] +policyengine-models = [ + { name = "policyengine" }, + { name = "policyengine-core" }, + { name = "policyengine-uk" }, + { name = "policyengine-us" }, + { name = "spm-calculator" }, ] [package.metadata] @@ -1899,22 +1899,22 @@ requires-dist = [ { name = "openapi-python-client", marker = "extra == 'build'", specifier = ">=0.21.6" }, { name = "opentelemetry-instrumentation-fastapi", specifier = ">=0.65b0,<0.66" }, { name = "opentelemetry-instrumentation-sqlalchemy", specifier = ">=0.65b0,<0.66" }, - { name = "policyengine", specifier = "==5.2.0" }, - { name = "policyengine-core", specifier = "==3.30.1" }, + { name = "policyengine", specifier = "==6.1.2" }, + { name = "policyengine-core", specifier = "==3.32.5" }, { name = "policyengine-fastapi", editable = "../../libs/policyengine-fastapi" }, { name = "policyengine-observability", extras = ["fastapi", "google", "otlp-grpc"], specifier = ">=3.0.1,<4" }, { name = "policyengine-simulation-contract", extras = ["modal"], editable = "../../libs/policyengine-simulation-contract" }, { name = "policyengine-simulation-observability", editable = "../../libs/policyengine-simulation-observability" }, { name = "policyengine-stage12-persistence", editable = "../../libs/policyengine-stage12-persistence" }, { name = "policyengine-uk", specifier = "==2.90.2" }, - { name = "policyengine-us", specifier = "==1.764.6" }, + { name = "policyengine-us", specifier = "==2.2.1" }, { name = "pyarrow", specifier = ">=20,<24" }, { name = "pydantic-settings", specifier = ">=2.7.1,<3.0.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" }, - { name = "spm-calculator", specifier = "==0.3.1" }, + { name = "spm-calculator", specifier = "==1.0.0" }, { name = "tables", specifier = ">=3.10.2" }, ] provides-extras = ["test", "build"] @@ -1924,14 +1924,23 @@ dev = [{ name = "policyengine-simulation-gateway", editable = "../policyengine-s modal-simulation-image = [ { name = "fastapi", specifier = ">=0.115.0" }, { name = "google-cloud-storage", specifier = ">=2" }, - { name = "pip" }, + { name = "policyengine", specifier = "==6.1.2" }, + { name = "policyengine-core", specifier = "==3.32.5" }, { name = "policyengine-observability", extras = ["fastapi", "google", "otlp-grpc"], specifier = ">=3.0.1,<4" }, + { name = "policyengine-uk", specifier = "==2.90.2" }, + { name = "policyengine-us", specifier = "==2.2.1" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.2,<4" }, { name = "pyarrow", specifier = ">=20,<24" }, - { name = "spm-calculator", specifier = "==0.3.1" }, + { name = "spm-calculator", specifier = "==1.0.0" }, { name = "sqlalchemy", specifier = ">=2,<3" }, { name = "tables", specifier = ">=3.10.2" }, - { name = "uv" }, +] +policyengine-models = [ + { name = "policyengine", specifier = "==6.1.2" }, + { name = "policyengine-core", specifier = "==3.32.5" }, + { name = "policyengine-uk", specifier = "==2.90.2" }, + { name = "policyengine-us", specifier = "==2.2.1" }, + { name = "spm-calculator", specifier = "==1.0.0" }, ] [[package]] @@ -2037,7 +2046,7 @@ wheels = [ [[package]] name = "policyengine-us" -version = "1.764.6" +version = "2.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "microdf-python" }, @@ -2047,9 +2056,9 @@ dependencies = [ { name = "tables" }, { name = "tqdm" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6e/cc/9e65a9586069beab1f02102a23f7a0e6332f1a9c2d590cb9f0e68f9ac987/policyengine_us-1.764.6.tar.gz", hash = "sha256:9817d48abc5b7d690b8abe33047005e5cf40b71654a85a2534bb5e3bcf921f79", size = 11009555, upload-time = "2026-07-06T13:13:07.254Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/09/763b21371a62635e15e62b53fb696e299f01460907c27313d40f63031eb2/policyengine_us-2.2.1.tar.gz", hash = "sha256:d5300c91f3eae2e47a61f485fdfde91a892b80e95db3e46ff2c4bbd417d76501", size = 11946510, upload-time = "2026-09-15T04:45:50.33Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e2/5a/0566666f30ad06e416f0cf25b24bdee73acd71897fb2de3c0ac43b59e3e3/policyengine_us-1.764.6-py3-none-any.whl", hash = "sha256:2b2c5c02c26ab212270948db1f30129728b625fc6109fc9ca0c604b714546909", size = 13117921, upload-time = "2026-07-06T13:13:03.476Z" }, + { url = "https://files.pythonhosted.org/packages/5e/09/86ac3968d2a18e28fce3ba1186fc21728e9f299b96a806d6f204f1e0aaed/policyengine_us-2.2.1-py3-none-any.whl", hash = "sha256:0993a6c73fcdfbe171a796aca9d302741bd8e00ab83388ad81c15a08740318c6", size = 14885537, upload-time = "2026-09-15T04:45:46.649Z" }, ] [[package]] @@ -2668,7 +2677,7 @@ wheels = [ [[package]] name = "spm-calculator" -version = "0.3.1" +version = "1.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "census" }, @@ -2678,9 +2687,9 @@ dependencies = [ { name = "requests" }, { name = "us" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/54/3b/b805c7e3e18c5b5c00f61b60112f9690d084c910e2481bc020f35390d8fd/spm_calculator-0.3.1.tar.gz", hash = "sha256:41f2f4d00d8c03422a7d57b800052e7760b88e463a5884802f83ed58d35c18c1", size = 75945, upload-time = "2026-04-17T19:52:39.707Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c2/5e/3f28b212c401795990250bb3daec741f699a8c7dc2a23a3db7005df6feb6/spm_calculator-1.0.0.tar.gz", hash = "sha256:a99aac8c2c0bf81a9455105bbf872366f1ea9066a99cd793714881cd3db846dc", size = 7150825, upload-time = "2026-09-11T15:45:12.732Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8e/1b/29f705f8a96fc7f55f2c07dfcddbbae78efdc6f174d25d4a0560fc3f5cf9/spm_calculator-0.3.1-py3-none-any.whl", hash = "sha256:52c57ecc5a240ec941b0f2b0d93bc4fa437ef6250e233baed8e11916fa9c1150", size = 57826, upload-time = "2026-04-17T19:52:38.444Z" }, + { url = "https://files.pythonhosted.org/packages/19/a0/c484f69a0ebf88a9b9fd0ac28176f8df66550f46714f1b0c5ec0b360824e/spm_calculator-1.0.0-py3-none-any.whl", hash = "sha256:e354937a5e1a4045d4966ed594a528d8b02866fabaac9bb5672017004b627305", size = 7395384, upload-time = "2026-09-11T15:45:10.492Z" }, ] [[package]] @@ -2912,32 +2921,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/65/a8/1791660a87f03d10a3bce00401a66035999c91f5a9a6987569b84df5719d/us-3.2.0-py3-none-any.whl", hash = "sha256:571714ad6d473c72bbd2058a53404cdf4ecc0129e4f19adfcbeb4e2d7e3dc3e7", size = 13775, upload-time = "2024-07-22T01:09:41.432Z" }, ] -[[package]] -name = "uv" -version = "0.11.26" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d0/cb/5efc713948ddb10b00abfb51bfd429221c720175557f9c7965fea2448fe4/uv-0.11.26.tar.gz", hash = "sha256:2a433ece2ace088dd572d8abb0e6bd9a4ecb0e10bc9856447bbb37545f384f29", size = 4331220, upload-time = "2026-06-30T14:52:03.77Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c2/71/86dbffac9e26df28a16639c426cf4ba572aaf43d9231463e0dca337895b2/uv-0.11.26-py3-none-linux_armv6l.whl", hash = "sha256:fb97bf04512dfe16d86084e75d8129701fc8da9fb40de8746b73c3aa617c5897", size = 25197324, upload-time = "2026-06-30T14:50:51.75Z" }, - { url = "https://files.pythonhosted.org/packages/ec/80/525b73c8188e7052343e7109466a08fcd5195055aff4b0346ce3622e48cb/uv-0.11.26-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a58a06e5a4b0035538d3ab4160ad74c716076ea7148eb3317171c6276ac020b4", size = 24179172, upload-time = "2026-06-30T14:50:56.52Z" }, - { url = "https://files.pythonhosted.org/packages/7b/5e/cf7b94ed3b1932c2a62573dcd388ad6c1da5c52111cd71ab7f20faa4a0aa/uv-0.11.26-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7b6d078d2ce83897884c2330c0676f27be4bf3d223fb2a409460f579fb5f0a98", size = 22949576, upload-time = "2026-06-30T14:51:00.538Z" }, - { url = "https://files.pythonhosted.org/packages/bf/fd/71fa021f6909c4139d8354bea623b5e0ef0ce4a08da250da1a1645528da2/uv-0.11.26-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:1cd9ba4951681ce17f1703106266fcbe27aaa7d37f07d53cce8b5686d68a8755", size = 24936673, upload-time = "2026-06-30T14:51:04.496Z" }, - { url = "https://files.pythonhosted.org/packages/7d/5e/273425e58a8812423e3d1f6c5da1015e636fbf13a83d104317ca37e16304/uv-0.11.26-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:e4f4c3268e69ac96f01972274a62f5f930c03cbc680adba6f21e63237ba3a639", size = 24719617, upload-time = "2026-06-30T14:51:08.419Z" }, - { url = "https://files.pythonhosted.org/packages/81/f8/1601e2acc7c54963814b4831eab996d8599e690712722c5acec5114860be/uv-0.11.26-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:efcbe0e187846f5ddba23bcaed17e4f9cd2463da5c45bdb5869616f686d713ff", size = 24734176, upload-time = "2026-06-30T14:51:12.685Z" }, - { url = "https://files.pythonhosted.org/packages/88/d2/a8a422e54c08cf4b8d51bedb9dbdd3cc233aa290ad8b3ee0438c0c02a3a5/uv-0.11.26-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:120ab2de93164d08cf5950f7fe18cbebe3ff670865ae41a292452bab2346477f", size = 26158780, upload-time = "2026-06-30T14:51:16.514Z" }, - { url = "https://files.pythonhosted.org/packages/db/e6/647fe5fdc888a3d27f79977877ce4e88052fe9be5398371e51bb134fc262/uv-0.11.26-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9052bf27c7ee426901f35a48715fa9288ce631c1878b91c9a6c950288f4b8633", size = 27009550, upload-time = "2026-06-30T14:51:20.659Z" }, - { url = "https://files.pythonhosted.org/packages/72/c2/85d8e762ad83b0f14fae2255b0578c4fd7dc915746f81b64ed786342627a/uv-0.11.26-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:efdddfcc9b1b790c5f7985c5c183c851682ced165b44ffa914f4947f5cad1fbf", size = 26183777, upload-time = "2026-06-30T14:51:24.715Z" }, - { url = "https://files.pythonhosted.org/packages/d3/00/478c3a870dcac690b8c337ee950a60a952e817f574945e85155c3cc0ab34/uv-0.11.26-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4dcf4e0b5b5cbdc242dcb002f1f8d99e7cf8c043609869228a9ce15e095c0b18", size = 26260589, upload-time = "2026-06-30T14:51:28.809Z" }, - { url = "https://files.pythonhosted.org/packages/a7/51/e4e43e106fb8cdc026b97491ea4600f4194a9c4da0b4e4e30c2a7dceb268/uv-0.11.26-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:866ae8d28f7381c15de0906a284c1e97916424c635bf40f7960b3fc889cd725e", size = 25073850, upload-time = "2026-06-30T14:51:32.717Z" }, - { url = "https://files.pythonhosted.org/packages/f2/c2/e772b7e6c8a835e8bf6739a391cdfc8e8e244c5c496d9b40625068b59ff4/uv-0.11.26-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:22f6d62e794b252ff3a1e2dfe5010cc76208f90b2c906e54971a0223ad6f16bc", size = 25682609, upload-time = "2026-06-30T14:51:36.888Z" }, - { url = "https://files.pythonhosted.org/packages/1a/69/ea77209a224a23a399cb7f6414f77ef032bd9e083e01199a0ebebf0d3ff2/uv-0.11.26-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:edd0c12b75141a6d830d138a91e366ad66e630f1c1dcaf83b8325b80cbacfcbb", size = 25800556, upload-time = "2026-06-30T14:51:40.937Z" }, - { url = "https://files.pythonhosted.org/packages/77/60/b6c0c03d2538a016b6624fa251960012e564ea02f841e958c7d60e974685/uv-0.11.26-py3-none-musllinux_1_1_i686.whl", hash = "sha256:af6a45b11a569cc4d2437e89a25a53dcf753f2a02a8f2de96be09b9b942cb3ec", size = 25385658, upload-time = "2026-06-30T14:51:45.103Z" }, - { url = "https://files.pythonhosted.org/packages/8d/e7/46881ff9164aa2e7c649901837d58eee3c57beb3b0fcc0fea6a4e40cf8f3/uv-0.11.26-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:c28822517d03aebbe9549aaaecc88ad580e4b2b6a927abffe5774a74d6ba09f6", size = 26551013, upload-time = "2026-06-30T14:51:49.062Z" }, - { url = "https://files.pythonhosted.org/packages/d6/94/380dad6c2bbe12417025aacd12cfc08322ed4c9dd8f760bff7035b86f22d/uv-0.11.26-py3-none-win32.whl", hash = "sha256:79e5c1b3410047e1962290c3b7b8f512d2c1bb95200c60b016f7729287cf34c0", size = 23947180, upload-time = "2026-06-30T14:51:53.065Z" }, - { url = "https://files.pythonhosted.org/packages/d0/13/9c588226d5b478328d739e654944430719f3ffe8999d6a24d425ec9664ab/uv-0.11.26-py3-none-win_amd64.whl", hash = "sha256:d95567e9470dc48ff03265f420c3c6973f6437f18a79d5e00b6eb4b2d9379907", size = 26909320, upload-time = "2026-06-30T14:51:57.235Z" }, - { url = "https://files.pythonhosted.org/packages/21/1d/ea66b12813878797126e2b3aca124b1c9c5ef53120702d1c00172f90a21d/uv-0.11.26-py3-none-win_arm64.whl", hash = "sha256:7e69d1569afbb936e7bf4e4ab2f72d606405f4a68f380f088a0b2233e84e056a", size = 25176820, upload-time = "2026-06-30T14:52:01.05Z" }, -] - [[package]] name = "uvicorn" version = "0.46.0" From 2b776858367ba879896c845c597e5ab21301bb42 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 29 Sep 2026 03:04:36 +0400 Subject: [PATCH 03/11] test(executor): verify PolicyEngine 6 runtime behavior --- .../src/modal/smoke_app.py | 85 ++++++++++++++++--- .../tests/test_artifact_keys.py | 40 ++++++++- .../test_policyengine_dependency_source.py | 29 +++++++ .../tests/test_smoke_app.py | 83 ++++++++++++++++++ .../tests/test_update_version_registry.py | 34 ++++++++ 5 files changed, 259 insertions(+), 12 deletions(-) create mode 100644 projects/policyengine-simulation-executor/tests/test_smoke_app.py diff --git a/projects/policyengine-simulation-executor/src/modal/smoke_app.py b/projects/policyengine-simulation-executor/src/modal/smoke_app.py index cca727838..870d11532 100644 --- a/projects/policyengine-simulation-executor/src/modal/smoke_app.py +++ b/projects/policyengine-simulation-executor/src/modal/smoke_app.py @@ -1,12 +1,16 @@ -"""Pre-merge image smoke: import the executor runtime inside its image. +"""Pre-merge image smoke: validate and import the executor runtime image. -Import parity, not data parity: the image here is the deployed image's -layer prefix (frozen uv sync, dataset-only PolicyEngine bundle install, -version env) plus the source mounts — deliberately excluding the artifact-fetch -and model-snapshot layers, which add no Python packages. Because layers are content-addressed and built through the -shared ``build_runtime_simulation_image()``, a warm cache makes this run +The image here is the deployed image's layer prefix (frozen uv sync, +dataset-only PolicyEngine bundle install, version env) plus the source mounts. +It deliberately excludes the artifact-fetch and model-snapshot layers, which +add no Python packages. Because layers are content-addressed and built through +the shared ``build_runtime_simulation_image()``, a warm cache makes this run take seconds; after a relock it pays only the bundle install. +Before importing worker code, the smoke uses PolicyEngine's bundle status +check. That compares all selected package versions with the bundle manifest, +reads the dataset-install receipt, and hashes both installed country datasets. + Runs the imports the deployed workers perform lazily at request time — ``run_simulation_impl``, the budget-window batch, and both shared libraries. @@ -15,7 +19,6 @@ """ import modal - from src.modal.app import build_runtime_simulation_image app = modal.App("policyengine-simulation-executor-smoke") @@ -28,11 +31,71 @@ copy=True, ) +_EXPECTED_RUNTIME_PACKAGES = frozenset( + { + "policyengine", + "policyengine-core", + "policyengine-uk", + "policyengine-us", + "spm-calculator", + } +) +_EXPECTED_COUNTRIES = frozenset({"us", "uk"}) + + +def _summarize_bundle_status(status: dict) -> dict: + """Reject an incomplete or mismatched runtime bundle and summarize it.""" + import json + + package_names = {check.get("package") for check in status.get("packages", [])} + dataset_countries = {check.get("country") for check in status.get("datasets", [])} + receipt = status.get("receipt") + receipt_countries = ( + set(receipt.get("countries", [])) if isinstance(receipt, dict) else set() + ) + complete = ( + status.get("matched") is True + and package_names == _EXPECTED_RUNTIME_PACKAGES + and dataset_countries == _EXPECTED_COUNTRIES + and receipt_countries == _EXPECTED_COUNTRIES + ) + if not complete: + raise RuntimeError( + "PolicyEngine runtime bundle validation failed:\n" + + json.dumps(status, indent=2, sort_keys=True) + ) + return { + "bundle_version": status["bundle_version"], + "packages": { + check["package"]: check["installed_version"] for check in status["packages"] + }, + "datasets": { + check["country"]: { + "dataset": check["dataset"], + "version": check["expected_version"], + "sha256": check["expected_sha256"], + } + for check in status["datasets"] + }, + } + @app.function(image=smoke_image, timeout=600, memory=8192) def smoke_import_executor() -> dict: import importlib + import os import pkgutil + from pathlib import Path + + from policyengine.bundle import inspect_bundle_status + + bundle = _summarize_bundle_status( + inspect_bundle_status( + os.environ["POLICYENGINE_VERSION"], + countries=["us", "uk"], + data_dir=Path(os.environ["POLICYENGINE_DATA_FOLDER"]), + ) + ) # Module-level surface of the deployed app (versions resolve from the # baked env layer). @@ -43,6 +106,9 @@ def smoke_import_executor() -> dict: # policyengine.core chain) on EVERY request, so a break there crashes # all requests at import time — exactly the #602 failure class this # smoke exists to catch. + import policyengine_simulation_contract + import policyengine_simulation_observability + from policyengine_simulation_executor.simulation_runtime import ( # noqa: F401 run_simulation_impl, ) @@ -53,9 +119,6 @@ def smoke_import_executor() -> dict: dispatch_run_simulation, ) - import policyengine_simulation_contract - import policyengine_simulation_observability - imported = [] for package in ( policyengine_simulation_contract, @@ -67,7 +130,7 @@ def smoke_import_executor() -> dict: importlib.import_module(module.name) imported.append(module.name) - return {"modules_imported": len(imported)} + return {"modules_imported": len(imported), "bundle": bundle} @app.local_entrypoint() diff --git a/projects/policyengine-simulation-executor/tests/test_artifact_keys.py b/projects/policyengine-simulation-executor/tests/test_artifact_keys.py index 9ece5ae4e..be853e576 100644 --- a/projects/policyengine-simulation-executor/tests/test_artifact_keys.py +++ b/projects/policyengine-simulation-executor/tests/test_artifact_keys.py @@ -16,11 +16,12 @@ from fixtures.identity_stubs import install_identity_stubs from fixtures.wrapper_spm import ( installed_wrapper_has_storage_id, +) +from fixtures.wrapper_spm import ( wrapper_storage_id as _wrapper_storage_id, ) from policyengine_simulation_executor import artifact_keys as ak - _DATASET_KWARGS = dict( country="us", dataset="populace_cps", @@ -182,6 +183,43 @@ def test_baseline_identity_composes(self, stub_identity_sources): f"baselines/us/{_BASELINE_GOLDEN}/bl1-f9cac05d94509895.h5" ) + def test_installed_spm_defaults_enter_dataset_identity( + self, + stub_identity_sources, + monkeypatch, + ): + """PolicyEngine 6's bundle-selected SPM inputs rotate artifact keys.""" + from policyengine_simulation_executor import spm as executor_spm + + class _StubForecast: + years = (2026,) + + def entry(self, year, *, scenario, as_of): + return {"year": year, "scenario": scenario, "as_of": as_of} + + monkeypatch.setattr( + executor_spm, "_forecast", lambda expected_sha256: _StubForecast() + ) + monkeypatch.setattr( + executor_spm, + "_prevalidate_selection", + lambda selection_json, start_year, window_size: None, + ) + + capability = stub_identity_sources.installed_spm_capability() + assert capability is not None + stub_identity_sources.spm_capability = capability + + identity = ak.collect_dataset_identity("us", 2026) + expected_selection = capability.defaults.model_dump(mode="json") + + assert identity.spm == expected_selection + assert identity.digest == ak.dataset_key( + **_DATASET_KWARGS, + spm=expected_selection, + ) + assert identity.digest != _DATASET_GOLDEN + _SPM_SELECTION = { "forecast_content_sha256": "a" * 64, diff --git a/projects/policyengine-simulation-executor/tests/test_policyengine_dependency_source.py b/projects/policyengine-simulation-executor/tests/test_policyengine_dependency_source.py index 6b72a30d3..bf1c3db51 100644 --- a/projects/policyengine-simulation-executor/tests/test_policyengine_dependency_source.py +++ b/projects/policyengine-simulation-executor/tests/test_policyengine_dependency_source.py @@ -15,6 +15,13 @@ "us": "policyengine-us", "uk": "policyengine-uk", } +BUNDLE_PACKAGES = { + "policyengine", + "policyengine-core", + "policyengine-us", + "policyengine-uk", + "spm-calculator", +} MODAL_APP_MODULE = "src.modal.app" VERSION_ENV = { "POLICYENGINE_VERSION": "4.18.3", @@ -106,6 +113,28 @@ def test_country_package_pins_match_policyengine_bundle(): ) +def test_all_runtime_package_pins_match_policyengine_bundle(): + from policyengine_simulation_executor.release_bundle import ( + get_bundled_package_version, + ) + + pyproject = _load_toml(PYPROJECT_PATH) + model_dependencies = pyproject["dependency-groups"]["policyengine-models"] + model_pins = { + package: next( + dependency.removeprefix(f"{package}==") + for dependency in model_dependencies + if dependency.startswith(f"{package}==") + ) + for package in BUNDLE_PACKAGES + } + + assert set(model_pins) == BUNDLE_PACKAGES + for package, version in model_pins.items(): + assert version == _get_dependency_pin(pyproject, package) + assert version == get_bundled_package_version(package) + + def _modal_import_env() -> dict[str, str]: env = os.environ.copy() for env_var in VERSION_ENV: diff --git a/projects/policyengine-simulation-executor/tests/test_smoke_app.py b/projects/policyengine-simulation-executor/tests/test_smoke_app.py new file mode 100644 index 000000000..14e3465a5 --- /dev/null +++ b/projects/policyengine-simulation-executor/tests/test_smoke_app.py @@ -0,0 +1,83 @@ +"""Unit tests for the executor image smoke's bundle validation.""" + +import importlib +import sys + +import pytest + +from fixtures.fake_modal import install_fake_modal + + +@pytest.fixture +def summarize_bundle_status(monkeypatch): + install_fake_modal(monkeypatch) + sys.modules.pop("src.modal.app", None) + sys.modules.pop("src.modal.smoke_app", None) + return importlib.import_module("src.modal.smoke_app")._summarize_bundle_status + + +def _passing_status() -> dict: + package_versions = { + "policyengine": "6.1.2", + "policyengine-core": "3.32.5", + "policyengine-us": "2.2.1", + "policyengine-uk": "2.90.2", + "spm-calculator": "1.0.0", + } + return { + "matched": True, + "bundle_version": "6.1.2", + "packages": [ + { + "package": package, + "installed_version": version, + "status": "ok", + } + for package, version in package_versions.items() + ], + "datasets": [ + { + "country": "us", + "dataset": "populace_us_2024", + "expected_version": "populace-us-2024-spm-20260915", + "expected_sha256": "a" * 64, + "status": "ok", + }, + { + "country": "uk", + "dataset": "enhanced_frs_2024_25", + "expected_version": "policyengine-uk-data-1.56.16", + "expected_sha256": "b" * 64, + "status": "ok", + }, + ], + "receipt": {"countries": ["us", "uk"]}, + } + + +def test_bundle_status_summary_requires_complete_v6_runtime(summarize_bundle_status): + summary = summarize_bundle_status(_passing_status()) + + assert summary["bundle_version"] == "6.1.2" + assert summary["packages"]["spm-calculator"] == "1.0.0" + assert summary["datasets"]["uk"]["version"] == "policyengine-uk-data-1.56.16" + + +@pytest.mark.parametrize( + "mutation", + [ + lambda status: status.update(matched=False), + lambda status: status["packages"].pop(), + lambda status: status["datasets"].pop(), + lambda status: status.update(receipt=None), + ], +) +def test_bundle_status_summary_rejects_mismatch_or_incomplete_install( + mutation, + summarize_bundle_status, +): + status = _passing_status() + mutation(status) + + with pytest.raises(RuntimeError, match="runtime bundle validation failed"): + summarize_bundle_status(status) diff --git a/projects/policyengine-simulation-executor/tests/test_update_version_registry.py b/projects/policyengine-simulation-executor/tests/test_update_version_registry.py index d6912ec01..9bd1544c0 100644 --- a/projects/policyengine-simulation-executor/tests/test_update_version_registry.py +++ b/projects/policyengine-simulation-executor/tests/test_update_version_registry.py @@ -9,6 +9,13 @@ from src.modal.utils import update_version_registry as registry +class _StubForecast: + years = (2026,) + + def entry(self, year, *, scenario, as_of): + return {"year": year, "scenario": scenario, "as_of": as_of} + + class FakeDict: def __init__(self, initial: dict | None = None): self._data = dict(initial or {}) @@ -114,6 +121,33 @@ def test_validate_routing_state_accepts_complete_state(fake_bundle_metadata): registry.validate_routing_state(state) +def test_bundle_manifest_records_policyengine_6_spm_capability( + fake_bundle_metadata, + monkeypatch, +): + from policyengine.bundle import get_current_bundle + from policyengine_simulation_contract.spm import SPM_CONTRACT_VERSION + + from policyengine_simulation_executor import spm as executor_spm + + monkeypatch.setattr( + executor_spm, + "_forecast", + lambda expected_sha256: _StubForecast(), + ) + + bundle = get_current_bundle() + metadata = registry.build_bundle_manifest_metadata( + app_name="policyengine-simulation-py6-1-2", + policyengine_version=bundle["policyengine_version"], + ) + + assert metadata["spm"] == { + "contract_version": SPM_CONTRACT_VERSION, + "defaults": bundle["measurements"]["spm"], + } + + def test_validate_routing_state_rejects_missing_latest_route(fake_bundle_metadata): state = registry.build_next_routing_state( current_state=None, From 86c517e6b0b9e5c9559d74e939685bcdef1a1967 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 29 Sep 2026 03:07:55 +0400 Subject: [PATCH 04/11] ci: update the complete PolicyEngine bundle --- .../scripts/update-policyengine-package.sh | 137 ++++++++++++++++-- ...est_policyengine_package_update_scripts.py | 57 ++++---- ...est_policyengine_package_update_scripts.py | 62 ++++++++ 3 files changed, 219 insertions(+), 37 deletions(-) diff --git a/.github/scripts/update-policyengine-package.sh b/.github/scripts/update-policyengine-package.sh index 94322924b..065d5a078 100755 --- a/.github/scripts/update-policyengine-package.sh +++ b/.github/scripts/update-policyengine-package.sh @@ -46,8 +46,11 @@ create_pr_body_file() { echo "- policyengine-core: ${BUNDLED_CORE_VERSION:-resolved from bundle during update}" echo "- policyengine-us: ${BUNDLED_US_VERSION:-resolved from bundle during update}" echo "- policyengine-uk: ${BUNDLED_UK_VERSION:-resolved from bundle during update}" + echo "- spm-calculator: ${BUNDLED_SPM_VERSION:-resolved from bundle during update}" echo - echo "Country data package versions remain manifest-derived at runtime/deploy time rather than independently pinned here." + echo "The bundle also selects these certified data releases:" + echo "- US: ${BUNDLED_US_DATA_VERSION:-resolved from bundle during update}" + echo "- UK: ${BUNDLED_UK_DATA_VERSION:-resolved from bundle during update}" echo echo "---" echo "Generated automatically by GitHub Actions." @@ -144,18 +147,51 @@ git config user.email "github-actions[bot]@users.noreply.github.com" git checkout -b "$BRANCH" python3 - "$PYPROJECT" "$PACKAGE" "$CURRENT" "$LATEST" <<'PY' +import os import sys +import tempfile +import tomllib from pathlib import Path pyproject_path, package, current, latest = sys.argv[1:] pyproject = Path(pyproject_path) pyproject_text = pyproject.read_text(encoding="utf-8") +parsed = tomllib.loads(pyproject_text) +dependency_lists = { + "project.dependencies": parsed.get("project", {}).get("dependencies", []), + "dependency-groups.policyengine-models": parsed.get("dependency-groups", {}).get( + "policyengine-models", [] + ), +} old_pin = f'"{package}=={current}"' new_pin = f'"{package}=={latest}"' -if old_pin not in pyproject_text: - raise SystemExit(f"Could not find {old_pin} in {pyproject}") -pyproject.write_text(pyproject_text.replace(old_pin, new_pin), encoding="utf-8") +for location, dependencies in dependency_lists.items(): + matches = [ + dependency + for dependency in dependencies + if isinstance(dependency, str) and dependency.startswith(f"{package}==") + ] + if matches != [f"{package}=={current}"]: + raise SystemExit( + f"Expected {package}=={current} in {location}; found {matches!r}" + ) +updated_text = pyproject_text.replace(old_pin, new_pin) +if pyproject_text.count(old_pin) != len(dependency_lists): + raise SystemExit( + f"Expected {old_pin} {len(dependency_lists)} times in {pyproject}; " + f"found {pyproject_text.count(old_pin)}" + ) +with tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8", + dir=pyproject.parent, + prefix=f".{pyproject.name}.", + delete=False, +) as temporary: + temporary.write(updated_text) + temporary_path = temporary.name +os.replace(temporary_path, pyproject) PY # The PyPI Simple index (which uv resolves from) can lag the JSON API right @@ -182,43 +218,122 @@ BUNDLE_OUTPUT=$( BUNDLED_US_VERSION=$(printf '%s\n' "$BUNDLE_OUTPUT" | awk -F= '$1 == "us_version" {print $2}') BUNDLED_UK_VERSION=$(printf '%s\n' "$BUNDLE_OUTPUT" | awk -F= '$1 == "uk_version" {print $2}') BUNDLED_CORE_VERSION=$(printf '%s\n' "$BUNDLE_OUTPUT" | awk -F= '$1 == "policyengine_core_version" {print $2}') +BUNDLED_POLICYENGINE_VERSION=$(printf '%s\n' "$BUNDLE_OUTPUT" | awk -F= '$1 == "policyengine_version" {print $2}') +BUNDLED_SPM_VERSION=$(printf '%s\n' "$BUNDLE_OUTPUT" | awk -F= '$1 == "spm_calculator_version" {print $2}') +BUNDLED_US_DATA_VERSION=$(printf '%s\n' "$BUNDLE_OUTPUT" | awk -F= '$1 == "us_data_version" {print $2}') +BUNDLED_UK_DATA_VERSION=$(printf '%s\n' "$BUNDLE_OUTPUT" | awk -F= '$1 == "uk_data_version" {print $2}') -if [[ -z "$BUNDLED_CORE_VERSION" || -z "$BUNDLED_US_VERSION" || -z "$BUNDLED_UK_VERSION" ]]; then +if [[ -z "$BUNDLED_POLICYENGINE_VERSION" || -z "$BUNDLED_CORE_VERSION" || -z "$BUNDLED_US_VERSION" || -z "$BUNDLED_UK_VERSION" || -z "$BUNDLED_SPM_VERSION" || -z "$BUNDLED_US_DATA_VERSION" || -z "$BUNDLED_UK_DATA_VERSION" ]]; then echo "ERROR: Could not resolve bundled runtime package versions." >&2 echo "$BUNDLE_OUTPUT" >&2 exit 1 fi +if [[ "$BUNDLED_POLICYENGINE_VERSION" != "$LATEST" ]]; then + echo "ERROR: Installed policyengine.py reports bundle ${BUNDLED_POLICYENGINE_VERSION}, expected ${LATEST}." >&2 + exit 1 +fi echo "Bundled runtime pins:" +echo " policyengine==${BUNDLED_POLICYENGINE_VERSION}" echo " policyengine-core==${BUNDLED_CORE_VERSION}" echo " policyengine-us==${BUNDLED_US_VERSION}" echo " policyengine-uk==${BUNDLED_UK_VERSION}" +echo " spm-calculator==${BUNDLED_SPM_VERSION}" +echo "Certified data releases:" +echo " us=${BUNDLED_US_DATA_VERSION}" +echo " uk=${BUNDLED_UK_DATA_VERSION}" -python3 - "$PYPROJECT" "$BUNDLED_CORE_VERSION" "$BUNDLED_US_VERSION" "$BUNDLED_UK_VERSION" <<'PY' +python3 - "$PYPROJECT" "$BUNDLED_POLICYENGINE_VERSION" "$BUNDLED_CORE_VERSION" "$BUNDLED_US_VERSION" "$BUNDLED_UK_VERSION" "$BUNDLED_SPM_VERSION" <<'PY' +import os import re import sys +import tempfile +import tomllib from pathlib import Path -pyproject_path, core_version, us_version, uk_version = sys.argv[1:] +( + pyproject_path, + policyengine_version, + core_version, + us_version, + uk_version, + spm_version, +) = sys.argv[1:] pyproject = Path(pyproject_path) text = pyproject.read_text(encoding="utf-8") pins = { + "policyengine": policyengine_version, "policyengine-core": core_version, "policyengine-us": us_version, "policyengine-uk": uk_version, + "spm-calculator": spm_version, } + +# These two dependency lists are installed in different places: project +# dependencies supply local development/tests, while policyengine-models is +# the only source for the Modal image. Require one exact pin in each list so a +# release cannot update one runtime while leaving the other on an older bundle. +parsed = tomllib.loads(text) +dependency_lists = { + "project.dependencies": parsed.get("project", {}).get("dependencies", []), + "dependency-groups.policyengine-models": parsed.get("dependency-groups", {}).get( + "policyengine-models", [] + ), +} +for location, dependencies in dependency_lists.items(): + for package in pins: + matches = [ + dependency + for dependency in dependencies + if isinstance(dependency, str) and dependency.startswith(f"{package}==") + ] + if len(matches) != 1: + raise SystemExit( + f"Expected one exact {package} pin in {location}; found {len(matches)}" + ) + for package, version in pins.items(): pattern = rf'"{re.escape(package)}==[^"]+"' replacement = f'"{package}=={version}"' - text, count = re.subn(pattern, replacement, text, count=1) - if count != 1: - raise SystemExit(f"Could not update {package} in {pyproject}") -pyproject.write_text(text, encoding="utf-8") + text, count = re.subn(pattern, replacement, text) + if count != len(dependency_lists): + raise SystemExit( + f"Expected to update {package} {len(dependency_lists)} times in " + f"{pyproject}; updated {count}" + ) + +updated = tomllib.loads(text) +for location, dependencies in { + "project.dependencies": updated["project"]["dependencies"], + "dependency-groups.policyengine-models": updated["dependency-groups"][ + "policyengine-models" + ], +}.items(): + for package, version in pins.items(): + if f"{package}=={version}" not in dependencies: + raise SystemExit(f"Updated {package} pin is missing from {location}") + +with tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8", + dir=pyproject.parent, + prefix=f".{pyproject.name}.", + delete=False, +) as temporary: + temporary.write(text) + temporary_path = temporary.name +os.replace(temporary_path, pyproject) PY ( cd "$PROJECT_PATH" uv lock + uv lock --check + uv run --extra test pytest \ + tests/test_bundle_version_export.py \ + tests/test_policyengine_dependency_source.py \ + tests/test_modal_bundle_image.py \ + -q ) if git diff --quiet -- "$PYPROJECT" "$LOCKFILE"; then diff --git a/projects/policyengine-simulation-executor/fixtures/test_policyengine_package_update_scripts.py b/projects/policyengine-simulation-executor/fixtures/test_policyengine_package_update_scripts.py index 082e27f1f..3eff07328 100644 --- a/projects/policyengine-simulation-executor/fixtures/test_policyengine_package_update_scripts.py +++ b/projects/policyengine-simulation-executor/fixtures/test_policyengine_package_update_scripts.py @@ -19,34 +19,35 @@ def fake_repo(tmp_path: Path) -> Path: project.mkdir(parents=True) (project / "pyproject.toml").write_text( - "\n".join( - [ - "[project]", - 'dependencies = ["policyengine==4.0.0", "policyengine-core==0.0.0", "policyengine-us==1.0.0", "policyengine-uk==2.0.0"]', - ] - ), + """[project] +dependencies = ["policyengine==4.0.0", "policyengine-core==0.0.0", "policyengine-us==1.0.0", "policyengine-uk==2.0.0", "spm-calculator==0.1.0"] + +[dependency-groups] +policyengine-models = ["policyengine==4.0.0", "policyengine-core==0.0.0", "policyengine-us==1.0.0", "policyengine-uk==2.0.0", "spm-calculator==0.1.0"] +""", encoding="utf-8", ) (project / "uv.lock").write_text( - "\n".join( - [ - "[[package]]", - 'name = "policyengine"', - 'version = "4.0.0"', - "", - "[[package]]", - 'name = "policyengine-core"', - 'version = "0.0.0"', - "", - "[[package]]", - 'name = "policyengine-us"', - 'version = "1.0.0"', - "", - "[[package]]", - 'name = "policyengine-uk"', - 'version = "2.0.0"', - ] - ), + """[[package]] +name = "policyengine" +version = "4.0.0" + +[[package]] +name = "policyengine-core" +version = "0.0.0" + +[[package]] +name = "policyengine-us" +version = "1.0.0" + +[[package]] +name = "policyengine-uk" +version = "2.0.0" + +[[package]] +name = "spm-calculator" +version = "0.1.0" +""", encoding="utf-8", ) @@ -131,6 +132,8 @@ def install_fake_uv( bundled_core_version: str = "999.999.999", bundled_us_version: str = "1.1.0", bundled_uk_version: str = "2.1.0", + bundled_policyengine_version: str = "4.1.0", + bundled_spm_version: str = "1.0.0", ) -> None: write_executable( fake_bin / "uv", @@ -139,8 +142,9 @@ def install_fake_uv( printf 'uv %s\\n' "$*" >> "{log}" if [[ "$1" == "run" && "$2" == "python" && "$3" == "-m" && "$4" == "src.modal.utils.extract_bundle_versions" ]]; then - echo "policyengine_version=4.1.0" + echo "policyengine_version={bundled_policyengine_version}" echo "policyengine_core_version={bundled_core_version}" + echo "spm_calculator_version={bundled_spm_version}" echo "us_version={bundled_us_version}" echo "us_data_version=1.10.0" echo "uk_version={bundled_uk_version}" @@ -172,4 +176,5 @@ def run_updater(*args: str, env: dict[str, str]) -> subprocess.CompletedProcess[ env=env, capture_output=True, text=True, + check=False, ) diff --git a/projects/policyengine-simulation-executor/tests/test_policyengine_package_update_scripts.py b/projects/policyengine-simulation-executor/tests/test_policyengine_package_update_scripts.py index ba88d2241..9ffb13393 100644 --- a/projects/policyengine-simulation-executor/tests/test_policyengine_package_update_scripts.py +++ b/projects/policyengine-simulation-executor/tests/test_policyengine_package_update_scripts.py @@ -22,6 +22,7 @@ def test_update_policyengine_package_script_has_valid_bash_syntax() -> None: ["bash", "-n", str(SCRIPT)], capture_output=True, text=True, + check=False, ) assert result.returncode == 0, result.stderr @@ -152,11 +153,72 @@ def test_update_policyengine_package_updates_py_and_bundled_runtime_pins( assert "policyengine-core==999.999.999" in pyproject_text assert "policyengine-us==1.1.0" in pyproject_text assert "policyengine-uk==2.1.0" in pyproject_text + assert "spm-calculator==1.0.0" in pyproject_text + assert pyproject_text.count("policyengine==4.1.0") == 2 + assert pyproject_text.count("policyengine-core==999.999.999") == 2 + assert pyproject_text.count("policyengine-us==1.1.0") == 2 + assert pyproject_text.count("policyengine-uk==2.1.0") == 2 + assert pyproject_text.count("spm-calculator==1.0.0") == 2 uv_calls = uv_log.read_text(encoding="utf-8") assert "lock --upgrade-package policyengine" in uv_calls assert "run python -m src.modal.utils.extract_bundle_versions --shell" in uv_calls assert "uv lock" in uv_calls + assert "lock --check" in uv_calls + assert "run --extra test pytest tests/test_bundle_version_export.py" in uv_calls assert "checkout -b auto/update-policyengine-4.1.0" in git_log.read_text( encoding="utf-8" ) assert "pr create" in gh_log.read_text(encoding="utf-8") + + +def test_update_policyengine_package_stops_when_loaded_bundle_is_not_target( + fake_bin: Path, fake_repo: Path, tmp_path: Path +) -> None: + git_log = tmp_path / "git.log" + gh_log = tmp_path / "gh.log" + uv_log = tmp_path / "uv.log" + install_fake_git(fake_bin, root=fake_repo, log=git_log, diff_has_changes=True) + install_fake_gh(fake_bin, log=gh_log) + install_fake_uv( + fake_bin, + log=uv_log, + bundled_policyengine_version="4.0.0", + ) + + result = run_updater( + env=updater_env(fake_bin, LATEST_OVERRIDE="4.1.0"), + ) + + assert result.returncode != 0 + assert "reports bundle 4.0.0, expected 4.1.0" in result.stderr + git_calls = git_log.read_text(encoding="utf-8") + assert "git commit" not in git_calls + assert "git push" not in git_calls + assert "pr create" not in gh_log.read_text(encoding="utf-8") + + +def test_update_policyengine_package_requires_both_runtime_dependency_lists( + fake_bin: Path, fake_repo: Path, tmp_path: Path +) -> None: + git_log = tmp_path / "git.log" + gh_log = tmp_path / "gh.log" + uv_log = tmp_path / "uv.log" + install_fake_git(fake_bin, root=fake_repo, log=git_log, diff_has_changes=True) + install_fake_gh(fake_bin, log=gh_log) + install_fake_uv(fake_bin, log=uv_log) + pyproject = fake_repo / "simulation" / "pyproject.toml" + pyproject.write_text( + pyproject.read_text(encoding="utf-8").split("[dependency-groups]")[0], + encoding="utf-8", + ) + + result = run_updater( + env=updater_env(fake_bin, LATEST_OVERRIDE="4.1.0"), + ) + + assert result.returncode != 0 + assert "in dependency-groups.policyengine-models; found []" in result.stderr + git_calls = git_log.read_text(encoding="utf-8") + assert "git commit" not in git_calls + assert "git push" not in git_calls + assert "pr create" not in gh_log.read_text(encoding="utf-8") From 4fe903b3af15be075bbd96cb4e7f6fd7679b9871 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 29 Sep 2026 03:11:29 +0400 Subject: [PATCH 05/11] test(executor): update fixtures for PolicyEngine 6 --- .../fixtures/wrapper_spm.py | 28 ++---- .../tests/test_artifact_keys.py | 89 ++++++------------- .../tests/test_baseline_artifacts.py | 23 +---- 3 files changed, 36 insertions(+), 104 deletions(-) diff --git a/projects/policyengine-simulation-executor/fixtures/wrapper_spm.py b/projects/policyengine-simulation-executor/fixtures/wrapper_spm.py index 172c1dc37..e8ddc4226 100644 --- a/projects/policyengine-simulation-executor/fixtures/wrapper_spm.py +++ b/projects/policyengine-simulation-executor/fixtures/wrapper_spm.py @@ -1,13 +1,13 @@ -"""The SPM-capable wrapper's ``storage_id``, transcribed for hermetic tests. +"""PolicyEngine 6's SPM-aware ``storage_id`` expression for test doubles. Precompute plans a store path from ``BaselineArtifactIdentity.storage_id`` and the in-container worker aborts when the wrapper's value disagrees, so the two derivations have to be one identifier reached down two paths. -This is that second path, copied out of the SPM-capable wrapper so the -key-discipline tests and the precompute writer==reader tests state it once. -It is an independent expression of the identifier, not a replacement for -tests against the installed wrapper. +This is that second path, copied from the installed PolicyEngine 6 wrapper so +the key-discipline and precompute tests state it once. It is an independent +expression of the identifier, not a replacement for tests against the installed +wrapper; ``test_artifact_keys`` compares both paths directly. ``policyengine/core/simulation.py``:: @@ -20,19 +20,12 @@ def storage_id(self) -> str: encoded = json.dumps(config, sort_keys=True, separators=(",", ":")).encode() return f"{self.id}-spm-{hashlib.sha256(encoded).hexdigest()}" -Read from ``policyengine-5.3.0-py3-none-any.whl`` sha256 -``8c640d967575dddad70840bcbe938cea251c56958eded647c83eb9c5902735f1``, the -unpublished development wheel the native qualification lane installs. The -hash is the identifier, not the version: another local build carries the -same filename and version and has no ``storage_id`` at all. - -``spm_config`` above is not a stored value. On the canonical wrapper it +``spm_config`` above is not a stored value. On PolicyEngine 6 it refuses a non-US ``tax_benefit_model_version`` and otherwise re-resolves ``spm`` through the installed bundle, so an unset selection becomes the bundle's defaults rather than None -- which is why this function takes a config that has already been resolved, and why the agreement claim is -about the digest, not about the resolution. Both are checked against that -wheel in ``TestWrapperStorageIdAgreement``'s recorded out-of-band run. +about the digest, not about the resolution. """ import hashlib @@ -44,10 +37,3 @@ def wrapper_storage_id(simulation_id: str, spm_config: dict | None) -> str: return simulation_id encoded = json.dumps(spm_config, sort_keys=True, separators=(",", ":")).encode() return f"{simulation_id}-spm-{hashlib.sha256(encoded).hexdigest()}" - - -def installed_wrapper_has_storage_id() -> bool: - """Whether the installed wrapper exposes the SPM-aware identifier.""" - from policyengine.core import Simulation - - return hasattr(Simulation, "storage_id") diff --git a/projects/policyengine-simulation-executor/tests/test_artifact_keys.py b/projects/policyengine-simulation-executor/tests/test_artifact_keys.py index be853e576..ef648b74f 100644 --- a/projects/policyengine-simulation-executor/tests/test_artifact_keys.py +++ b/projects/policyengine-simulation-executor/tests/test_artifact_keys.py @@ -14,9 +14,6 @@ import pytest from fixtures.identity_stubs import install_identity_stubs -from fixtures.wrapper_spm import ( - installed_wrapper_has_storage_id, -) from fixtures.wrapper_spm import ( wrapper_storage_id as _wrapper_storage_id, ) @@ -229,7 +226,6 @@ def entry(self, year, *, scenario, as_of): "county_vintage": "2020", "as_of": None, } -_INSTALLED_WRAPPER_HAS_STORAGE_ID = installed_wrapper_has_storage_id() _SPM_STORAGE_GOLDEN = ( "bl1-21f52b30719e20bb-spm-" "7396bf5f4876c42bb6cba0f9533478098edc0861cc3657bd0d60f88dbb26ac39" @@ -237,7 +233,7 @@ def entry(self, year, *, scenario, as_of): class TestWrapperStorageIdAgreement: - """The planner's storage id against the wrapper that names the file. + """The planner's storage id against PolicyEngine 6, which names the file. Precompute plans a store path from ``BaselineArtifactIdentity.storage_id`` and the in-container worker refuses to publish when the wrapper's own @@ -246,37 +242,11 @@ class TestWrapperStorageIdAgreement: the same identifier reached down two independent code paths, and a disagreement blocks every canonical publish. - The ``policyengine`` this project pins is pre-canonical: it has neither - an ``spm`` field nor a ``storage_id``, so an SPM-capable wrapper cannot - be imported in hermetic CI and these tests cannot prove agreement with - one. What they do prove: - - * the no-selection arm agrees through the exact accessor ``precompute`` - uses, against the real installed object — the same answer on either - wrapper, which is the point: that arm must not move; - * the SPM arm agrees with the canonical wrapper's expression as read from - the wheel above, so our side cannot drift from the contract without a - reviewable diff, and the digest cannot be quietly reformatted; - * ``test_spm_arm_matches_the_installed_wrapper`` stops skipping and - asserts real equality the moment an SPM-capable wrapper is pinned. It - supplies the two things that wrapper's ``spm_config`` needs and this - project cannot assume — a US model version, and a bundle pinning this - selection — because ``spm_config`` refuses a non-US model and - re-resolves the selection through ``get_current_bundle`` rather than - reading it off the object. - - Agreement with the real wrapper was checked out of band on 2026-09-11 by - running the executor environment with that wheel's ``policyengine`` - shadowing the pinned one, then resolving seven selection shapes (unset, - empty, national, county, metro, a moved ``as_of``, and a non-ASCII - scenario) through both the wheel's ``resolve_spm_selection`` and this - repo's, and comparing the resolved configs and the storage ids. All - seven agreed on both. That is a recorded observation, not coverage — - only the native lane re-runs anything like it. - - The wheel's sha256 is load-bearing, not decoration: two builds both - named ``policyengine-5.3.0-py3-none-any.whl`` are available locally, and - the other one (``962882ea…``) has no ``storage_id`` at all. + The no-selection arm exercises the accessor used by precompute. The SPM + arm first freezes the digest expression for hermetic tests, then compares + the same selections with the installed wrapper. The installed-wrapper + cases provide the US model identity and a bundle containing the tested + defaults because ``Simulation.spm_config`` resolves through that bundle. """ @pytest.fixture @@ -342,11 +312,9 @@ def test_legacy_arm_matches_the_wrapper_accessor(self, identity): ``precompute`` reads ``getattr(baseline, "storage_id", baseline.id)``. With no resolved selection that has to be the planned id on *any* - wrapper: pre-canonical, because the attribute is absent and the - fallback is the id; canonical, because the property short-circuits - to the id when ``spm_config`` is None. The assertion is the same - either way, so this pins the accessor's contract rather than telling - the two wrappers apart. + wrapper: older versions use the fallback because the attribute is + absent; PolicyEngine 6's property returns the id when ``spm_config`` + is None. The assertion pins the accessor's behavior in either case. "No resolved selection" is the condition, not "the caller sent no selection". On a canonical bundle the wrapper resolves an unset @@ -361,37 +329,34 @@ def test_legacy_arm_matches_the_wrapper_accessor(self, identity): wrapper = Simulation.model_construct(id=built.simulation_id) assert getattr(wrapper, "storage_id", wrapper.id) == built.storage_id - @pytest.mark.skipif( - not _INSTALLED_WRAPPER_HAS_STORAGE_ID, - reason=( - "The pinned policyengine is pre-canonical and has no storage_id; " - "this asserts real equality as soon as an SPM-capable wrapper is " - "pinned, replacing the transcribed expression above." - ), + @pytest.mark.parametrize( + "selection", + [ + _SPM_SELECTION, + {**_SPM_SELECTION, "geography_kind": "county"}, + {**_SPM_SELECTION, "geography_kind": "metro", "geography_id": "35620"}, + {**_SPM_SELECTION, "as_of": "2025-01-01"}, + {**_SPM_SELECTION, "scenario": "ce_trend_ü"}, + ], ) - def test_spm_arm_matches_the_installed_wrapper(self, identity, monkeypatch): - """Real equality against the wrapper, once one can be imported. - - The canonical ``spm_config`` reads the model version's country and - re-resolves the selection through the installed bundle, so a bare - ``model_construct(id=..., spm=...)`` raises "SPM selection is only - supported by the US model" instead of comparing anything. Both are - supplied here so this activates on a pin rather than erroring. - """ + def test_spm_arm_matches_the_installed_wrapper( + self, + identity, + monkeypatch, + selection, + ): import policyengine.bundle from policyengine.core import Simulation - built = identity(_SPM_SELECTION) - # Only after the planner has read the real installed bundle: this - # stub is for the wrapper's re-resolution, not for ours. + built = identity(selection) monkeypatch.setattr( policyengine.bundle, "get_current_bundle", - lambda: {"measurements": {"spm": _SPM_SELECTION}}, + lambda: {"measurements": {"spm": selection}}, ) wrapper = Simulation.model_construct( id=built.simulation_id, - spm=_SPM_SELECTION, + spm=selection, tax_benefit_model_version=SimpleNamespace(country_code="us"), ) assert wrapper.storage_id == built.storage_id diff --git a/projects/policyengine-simulation-executor/tests/test_baseline_artifacts.py b/projects/policyengine-simulation-executor/tests/test_baseline_artifacts.py index 419887278..2f0557a2a 100644 --- a/projects/policyengine-simulation-executor/tests/test_baseline_artifacts.py +++ b/projects/policyengine-simulation-executor/tests/test_baseline_artifacts.py @@ -12,7 +12,6 @@ import pandas as pd import pytest - from policyengine.core import Simulation from pydantic import PrivateAttr @@ -458,7 +457,6 @@ def boom(*args, **kwargs): def _year_data(person_columns=None): """Minimal real USYearData — six entities, weights included.""" from microdf import MicroDataFrame - from policyengine.tax_benefit_models.us.datasets import USYearData person = { @@ -648,22 +646,13 @@ def _spm_receipt(selection=None, *, year="2026"): } -def _installed_wrapper_supports_spm() -> bool: - return "spm" in Simulation.model_fields and hasattr(Simulation, "spm_provenance") - - -_INSTALLED_WRAPPER_SUPPORTS_SPM = _installed_wrapper_supports_spm() - - class SPMWrapperSimulation(Simulation): - """Test double for the canonical wrapper's SPM surface. + """Controllable test double for PolicyEngine 6's SPM surface. The wrapper the canonical bundle installs adds an ``spm`` field, an ``spm_config`` holding the selection an artifact was built under, and an ``spm_provenance()`` calculation receipt; it restores that metadata on a - load or a cache hit. The ``policyengine`` this project pins has none of - it, which is why ``ensure()``'s receipt validation was reachable only - from the SPM_NATIVE_SMOKE_SOURCE-gated tests. + load or a cache hit. This double reproduces that surface's *shape and restore timing*, which is all the guard depends on. It is deliberately more permissive than the @@ -990,14 +979,6 @@ def test_the_replaced_cache_entry_is_a_snapshot(self, fresh_cache): assert sim.spm_config == other assert cached.spm_config == SPM_SELECTION - @pytest.mark.skipif( - not _INSTALLED_WRAPPER_SUPPORTS_SPM, - reason=( - "The pinned policyengine is pre-canonical; this checks the double " - "above against the real surface as soon as an SPM-capable wrapper " - "is pinned, at which point the double should be retired for it." - ), - ) def test_the_double_matches_the_installed_wrapper_surface(self): from policyengine.core import Simulation From 3ef92d94be342a723a71c9446966ed5ac7c82428 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 29 Sep 2026 03:35:33 +0400 Subject: [PATCH 06/11] fix: complete PolicyEngine 6 migration checks --- .../scripts/update-policyengine-package.sh | 120 +++++++++++++++--- .../workflows/check-policyengine-updates.yml | 4 + ...est_policyengine_package_update_scripts.py | 47 ++++++- .../stage12_worker_validation.py | 2 + .../tests/test_modal_bundle_image.py | 36 ++++-- ...est_policyengine_package_update_scripts.py | 43 ++++++- .../tests/test_smoke_app.py | 22 ++-- .../tests/test_stage12_modal_app.py | 16 ++- .../tests/test_stage12_worker_validation.py | 6 + 9 files changed, 248 insertions(+), 48 deletions(-) diff --git a/.github/scripts/update-policyengine-package.sh b/.github/scripts/update-policyengine-package.sh index 065d5a078..ba1314235 100755 --- a/.github/scripts/update-policyengine-package.sh +++ b/.github/scripts/update-policyengine-package.sh @@ -32,12 +32,109 @@ PROJECT_DIR="${PROJECT_DIR:-projects/policyengine-simulation-executor}" PROJECT_PATH="${ROOT_DIR}/${PROJECT_DIR}" PYPROJECT="${PROJECT_PATH}/pyproject.toml" LOCKFILE="${PROJECT_PATH}/uv.lock" +REPOSITORY="${GITHUB_REPOSITORY:-PolicyEngine/policyengine-sim-api}" +ISSUE_NUMBER="" + +ensure_update_issue() { + local issue_details + local issue_title + local issue_url + + issue_title="Update policyengine to ${LATEST}" + ISSUE_NUMBER=$( + gh api --paginate --slurp \ + "repos/${REPOSITORY}/issues?state=open&per_page=100" \ + | python3 -c ' +import json +import sys + +title = sys.argv[1] +pages = json.load(sys.stdin) +matches = sorted( + item["number"] + for page in pages + for item in page + if "pull_request" not in item and item.get("title") == title +) +if matches: + print(matches[0]) +' "$issue_title" + ) + + if [[ -z "$ISSUE_NUMBER" ]]; then + issue_url=$(gh issue create \ + --repo "$REPOSITORY" \ + --title "$issue_title" \ + --body "Track the automated simulation runtime update to policyengine ${LATEST}.") + ISSUE_NUMBER="${issue_url##*/}" + fi + + if [[ ! "$ISSUE_NUMBER" =~ ^[0-9]+$ ]]; then + echo "ERROR: Could not resolve an issue for policyengine ${LATEST}." >&2 + exit 1 + fi + + issue_details=$(gh issue view "$ISSUE_NUMBER" \ + --repo "$REPOSITORY" \ + --json number,state,title) + printf '%s' "$issue_details" | python3 -c ' +import json +import sys + +expected_number = int(sys.argv[1]) +expected_title = sys.argv[2] +issue = json.load(sys.stdin) +if issue.get("number") != expected_number: + raise SystemExit("Resolved update issue has an unexpected number") +if issue.get("state") != "OPEN": + raise SystemExit("Resolved update issue is not open") +if issue.get("title") != expected_title: + raise SystemExit("Resolved update issue has an unexpected title") +' "$ISSUE_NUMBER" "$issue_title" +} + +verify_update_pr() { + local pr_details + + pr_details=$(gh pr view "$BRANCH" \ + --repo "$REPOSITORY" \ + --json isDraft,headRepositoryOwner,headRepository) + printf '%s' "$pr_details" | python3 -c ' +import json +import sys + +expected_repository = sys.argv[1] +pr = json.load(sys.stdin) +if pr.get("isDraft") is not True: + raise SystemExit("Automated policyengine update PR is not a draft") +head_repository = pr.get("headRepository") or {} +if head_repository.get("nameWithOwner") != expected_repository: + raise SystemExit("Automated policyengine update PR is not from the canonical repository") +' "$REPOSITORY" +} + +create_update_pr() { + local pr_body_file + + ensure_update_issue + pr_body_file="$(create_pr_body_file)" + gh pr create \ + --draft \ + --repo "$REPOSITORY" \ + --base main \ + --head "$BRANCH" \ + --title "chore(deps): update policyengine to ${LATEST}" \ + --body-file "$pr_body_file" + verify_update_pr +} create_pr_body_file() { local pr_body_file pr_body_file="$(mktemp)" { + echo "Fixes #${ISSUE_NUMBER}" + echo echo "## Summary" echo echo "Update policyengine.py from ${CURRENT} to ${LATEST} in the simulation API runtime." @@ -120,11 +217,10 @@ if [[ "$DRY_RUN" == "1" ]]; then exit 0 fi -EXISTING_PR=$(gh pr list \ - --head "$BRANCH" \ - --state open \ - --json number \ - --jq '.[0].number' 2>/dev/null || true) +EXISTING_PR=$(gh pr view "$BRANCH" \ + --repo "$REPOSITORY" \ + --json number,state \ + --jq 'select(.state == "OPEN") | .number' 2>/dev/null || true) if [[ -n "$EXISTING_PR" ]]; then echo "PR #${EXISTING_PR} already exists for ${BRANCH}. Skipping." exit 0 @@ -132,12 +228,7 @@ fi if git ls-remote --exit-code --heads origin "$BRANCH" >/dev/null 2>&1; then echo "Remote branch '${BRANCH}' already exists without an open PR. Creating PR." - PR_BODY_FILE="$(create_pr_body_file)" - gh pr create \ - --base main \ - --head "$BRANCH" \ - --title "chore(deps): update policyengine to ${LATEST}" \ - --body-file "$PR_BODY_FILE" + create_update_pr echo "PR created for existing branch ${BRANCH}" exit 0 fi @@ -341,15 +432,10 @@ if git diff --quiet -- "$PYPROJECT" "$LOCKFILE"; then exit 0 fi -PR_BODY_FILE="$(create_pr_body_file)" - git add "$PYPROJECT" "$LOCKFILE" git commit -m "chore(deps): update policyengine to ${LATEST}" git push -u origin "$BRANCH" -gh pr create \ - --base main \ - --title "chore(deps): update policyengine to ${LATEST}" \ - --body-file "$PR_BODY_FILE" +create_update_pr echo "PR created for policyengine ${CURRENT} -> ${LATEST}" diff --git a/.github/workflows/check-policyengine-updates.yml b/.github/workflows/check-policyengine-updates.yml index 4d5b03270..905bf8ee0 100644 --- a/.github/workflows/check-policyengine-updates.yml +++ b/.github/workflows/check-policyengine-updates.yml @@ -16,6 +16,7 @@ concurrency: permissions: contents: write + issues: write pull-requests: write jobs: @@ -31,6 +32,9 @@ jobs: with: app-id: ${{ secrets.APP_ID }} private-key: ${{ secrets.APP_PRIVATE_KEY }} + permission-contents: write + permission-issues: write + permission-pull-requests: write - name: Checkout code uses: actions/checkout@v6 diff --git a/projects/policyengine-simulation-executor/fixtures/test_policyengine_package_update_scripts.py b/projects/policyengine-simulation-executor/fixtures/test_policyengine_package_update_scripts.py index 3eff07328..35baa22b2 100644 --- a/projects/policyengine-simulation-executor/fixtures/test_policyengine_package_update_scripts.py +++ b/projects/policyengine-simulation-executor/fixtures/test_policyengine_package_update_scripts.py @@ -104,19 +104,62 @@ def install_fake_git( ) -def install_fake_gh(fake_bin: Path, *, log: Path, open_pr: str = "") -> None: +def install_fake_gh( + fake_bin: Path, + *, + log: Path, + open_pr: str = "", + open_issue: str = "679", +) -> None: write_executable( fake_bin / "gh", f"""#!/usr/bin/env bash set -euo pipefail printf 'gh %s\\n' "$*" >> "{log}" -if [[ "$1" == "pr" && "$2" == "list" ]]; then +if [[ "$1" == "api" ]]; then + if [[ -n "{open_issue}" ]]; then + printf '[[{{"number":%s,"title":"Update policyengine to 4.1.0"}}]]\\n' "{open_issue}" + else + printf '[[]]\\n' + fi + exit 0 +fi + +if [[ "$1" == "issue" && "$2" == "view" ]]; then + printf '{{"number":%s,"state":"OPEN","title":"Update policyengine to 4.1.0"}}\\n' "${{3}}" + exit 0 +fi + +if [[ "$1" == "issue" && "$2" == "create" ]]; then + printf 'https://github.com/PolicyEngine/policyengine-sim-api/issues/680\\n' + exit 0 +fi + +if [[ "$1" == "pr" && "$2" == "view" && "$*" == *"number,state"* ]]; then printf '%s\\n' "{open_pr}" exit 0 fi +if [[ "$1" == "pr" && "$2" == "view" ]]; then + printf '{{"isDraft":true,"headRepository":{{"nameWithOwner":"PolicyEngine/policyengine-sim-api"}},"headRepositoryOwner":{{"login":"PolicyEngine"}}}}\\n' + exit 0 +fi + if [[ "$1" == "pr" && "$2" == "create" ]]; then + body_file="" + previous="" + for argument in "$@"; do + if [[ "$previous" == "--body-file" ]]; then + body_file="$argument" + break + fi + previous="$argument" + done + if [[ -n "$body_file" ]]; then + printf 'pr-body-first-line %s\\n' "$(head -n 1 "$body_file")" >> "{log}" + fi + printf 'https://github.com/PolicyEngine/policyengine-sim-api/pull/703\\n' exit 0 fi diff --git a/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/stage12_worker_validation.py b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/stage12_worker_validation.py index 26baab257..4ccb7bfa9 100644 --- a/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/stage12_worker_validation.py +++ b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/stage12_worker_validation.py @@ -55,8 +55,10 @@ def _check_dataset_access(path: str, expected_sha256: str) -> None: def _run_non_serving_calculation(country: CountryId) -> None: country_module = import_module(f"policyengine.tax_benefit_models.{country}") + household = {"county_fips": "06037"} if country == "us" else None result = country_module.calculate_household( people=[{"age": 40, "employment_income": 50_000}], + **({"household": household} if household is not None else {}), year=2026, extra_variables=["household_net_income"], ) diff --git a/projects/policyengine-simulation-executor/tests/test_modal_bundle_image.py b/projects/policyengine-simulation-executor/tests/test_modal_bundle_image.py index 2a796480e..ce03ac480 100644 --- a/projects/policyengine-simulation-executor/tests/test_modal_bundle_image.py +++ b/projects/policyengine-simulation-executor/tests/test_modal_bundle_image.py @@ -7,14 +7,32 @@ import pytest from fixtures.fake_modal import install_fake_modal +from policyengine_simulation_executor.release_bundle import ( + get_bundled_package_version, +) + + +POLICYENGINE_BUNDLE_PACKAGES = ( + "policyengine", + "policyengine-core", + "policyengine-uk", + "policyengine-us", + "spm-calculator", +) def test_modal_image_uses_policyengine_bundle_install(monkeypatch): install_fake_modal(monkeypatch) - monkeypatch.setenv("POLICYENGINE_VERSION", "6.1.2") - monkeypatch.setenv("POLICYENGINE_CORE_VERSION", "3.32.5") - monkeypatch.setenv("POLICYENGINE_US_VERSION", "2.2.1") - monkeypatch.setenv("POLICYENGINE_UK_VERSION", "2.90.2") + bundle_versions = { + package: get_bundled_package_version(package) + for package in POLICYENGINE_BUNDLE_PACKAGES + } + monkeypatch.setenv("POLICYENGINE_VERSION", bundle_versions["policyengine"]) + monkeypatch.setenv( + "POLICYENGINE_CORE_VERSION", bundle_versions["policyengine-core"] + ) + monkeypatch.setenv("POLICYENGINE_US_VERSION", bundle_versions["policyengine-us"]) + monkeypatch.setenv("POLICYENGINE_UK_VERSION", bundle_versions["policyengine-uk"]) monkeypatch.setenv("OBSERVABILITY_SERVICE_NAMESPACE", "policyengine.api-v1") monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "https://collector.test") sys.modules.pop("src.modal.app", None) @@ -27,7 +45,8 @@ def test_modal_image_uses_policyengine_bundle_install(monkeypatch): assert command_calls command = command_calls[0][1][0] assert command.startswith( - "policyengine bundle install 6.1.2 --no-packages --country us --country uk" + "policyengine bundle install " + f"{bundle_versions['policyengine']} --no-packages --country us --country uk" ) assert "PIP_CONSTRAINT" not in command assert "uvx" not in command @@ -39,11 +58,8 @@ def test_modal_image_uses_policyengine_bundle_install(monkeypatch): (Path(__file__).resolve().parents[1] / "pyproject.toml").read_text() ) expected_models = [ - "policyengine==6.1.2", - "policyengine-core==3.32.5", - "policyengine-uk==2.90.2", - "policyengine-us==2.2.1", - "spm-calculator==1.0.0", + f"{package}=={bundle_versions[package]}" + for package in POLICYENGINE_BUNDLE_PACKAGES ] assert project["dependency-groups"]["policyengine-models"] == expected_models assert all(item in project["project"]["dependencies"] for item in expected_models) diff --git a/projects/policyengine-simulation-executor/tests/test_policyengine_package_update_scripts.py b/projects/policyengine-simulation-executor/tests/test_policyengine_package_update_scripts.py index 9ffb13393..fbc61d25a 100644 --- a/projects/policyengine-simulation-executor/tests/test_policyengine_package_update_scripts.py +++ b/projects/policyengine-simulation-executor/tests/test_policyengine_package_update_scripts.py @@ -28,6 +28,17 @@ def test_update_policyengine_package_script_has_valid_bash_syntax() -> None: assert result.returncode == 0, result.stderr +def test_update_policyengine_workflow_requests_issue_and_pr_permissions() -> None: + workflow = ( + SCRIPT.parent.parent / "workflows" / "check-policyengine-updates.yml" + ).read_text(encoding="utf-8") + + assert "issues: write" in workflow + assert "permission-contents: write" in workflow + assert "permission-issues: write" in workflow + assert "permission-pull-requests: write" in workflow + + def test_update_policyengine_package_rejects_unknown_argument( fake_bin: Path, fake_repo: Path, tmp_path: Path ) -> None: @@ -101,7 +112,9 @@ def test_update_policyengine_package_skips_when_open_pr_exists( assert result.returncode == 0, result.stderr assert "PR #123 already exists for auto/update-policyengine-4.1.0" in result.stdout - assert "pr create" not in gh_log.read_text(encoding="utf-8") + gh_calls = gh_log.read_text(encoding="utf-8") + assert "pr create" not in gh_calls + assert "issue create" not in gh_calls def test_update_policyengine_package_opens_pr_for_existing_branch_without_open_pr( @@ -124,9 +137,10 @@ def test_update_policyengine_package_opens_pr_for_existing_branch_without_open_p assert result.returncode == 0, result.stderr assert "already exists without an open PR. Creating PR." in result.stdout gh_calls = gh_log.read_text(encoding="utf-8") - assert "pr list" in gh_calls - assert "pr create" in gh_calls + assert "pr view" in gh_calls + assert "pr create --draft --repo PolicyEngine/policyengine-sim-api" in gh_calls assert "--head auto/update-policyengine-4.1.0" in gh_calls + assert "pr-body-first-line Fixes #679" in gh_calls def test_update_policyengine_package_updates_py_and_bundled_runtime_pins( @@ -171,6 +185,29 @@ def test_update_policyengine_package_updates_py_and_bundled_runtime_pins( assert "pr create" in gh_log.read_text(encoding="utf-8") +def test_update_policyengine_package_creates_and_links_missing_issue( + fake_bin: Path, fake_repo: Path, tmp_path: Path +) -> None: + git_log = tmp_path / "git.log" + gh_log = tmp_path / "gh.log" + uv_log = tmp_path / "uv.log" + install_fake_git(fake_bin, root=fake_repo, log=git_log, diff_has_changes=True) + install_fake_gh(fake_bin, log=gh_log, open_issue="") + install_fake_uv(fake_bin, log=uv_log) + + result = run_updater( + env=updater_env(fake_bin, LATEST_OVERRIDE="4.1.0"), + ) + + assert result.returncode == 0, result.stderr + gh_calls = gh_log.read_text(encoding="utf-8") + assert "api --paginate --slurp" in gh_calls + assert "issue create --repo PolicyEngine/policyengine-sim-api" in gh_calls + assert "issue view 680 --repo PolicyEngine/policyengine-sim-api" in gh_calls + assert "pr-body-first-line Fixes #680" in gh_calls + assert "pr create --draft --repo PolicyEngine/policyengine-sim-api" in gh_calls + + def test_update_policyengine_package_stops_when_loaded_bundle_is_not_target( fake_bin: Path, fake_repo: Path, tmp_path: Path ) -> None: diff --git a/projects/policyengine-simulation-executor/tests/test_smoke_app.py b/projects/policyengine-simulation-executor/tests/test_smoke_app.py index 14e3465a5..0d6db864d 100644 --- a/projects/policyengine-simulation-executor/tests/test_smoke_app.py +++ b/projects/policyengine-simulation-executor/tests/test_smoke_app.py @@ -18,15 +18,15 @@ def summarize_bundle_status(monkeypatch): def _passing_status() -> dict: package_versions = { - "policyengine": "6.1.2", - "policyengine-core": "3.32.5", - "policyengine-us": "2.2.1", - "policyengine-uk": "2.90.2", - "spm-calculator": "1.0.0", + "policyengine": "wrapper-test-version", + "policyengine-core": "core-test-version", + "policyengine-us": "us-test-version", + "policyengine-uk": "uk-test-version", + "spm-calculator": "spm-test-version", } return { "matched": True, - "bundle_version": "6.1.2", + "bundle_version": "bundle-test-version", "packages": [ { "package": package, @@ -39,14 +39,14 @@ def _passing_status() -> dict: { "country": "us", "dataset": "populace_us_2024", - "expected_version": "populace-us-2024-spm-20260915", + "expected_version": "us-data-test-version", "expected_sha256": "a" * 64, "status": "ok", }, { "country": "uk", "dataset": "enhanced_frs_2024_25", - "expected_version": "policyengine-uk-data-1.56.16", + "expected_version": "uk-data-test-version", "expected_sha256": "b" * 64, "status": "ok", }, @@ -58,9 +58,9 @@ def _passing_status() -> dict: def test_bundle_status_summary_requires_complete_v6_runtime(summarize_bundle_status): summary = summarize_bundle_status(_passing_status()) - assert summary["bundle_version"] == "6.1.2" - assert summary["packages"]["spm-calculator"] == "1.0.0" - assert summary["datasets"]["uk"]["version"] == "policyengine-uk-data-1.56.16" + assert summary["bundle_version"] == "bundle-test-version" + assert summary["packages"]["spm-calculator"] == "spm-test-version" + assert summary["datasets"]["uk"]["version"] == "uk-data-test-version" @pytest.mark.parametrize( diff --git a/projects/policyengine-simulation-executor/tests/test_stage12_modal_app.py b/projects/policyengine-simulation-executor/tests/test_stage12_modal_app.py index 0fb2cd21a..b889ea18d 100644 --- a/projects/policyengine-simulation-executor/tests/test_stage12_modal_app.py +++ b/projects/policyengine-simulation-executor/tests/test_stage12_modal_app.py @@ -11,6 +11,9 @@ import pytest from fixtures.fake_modal import install_fake_modal +from policyengine_simulation_executor.release_bundle import ( + get_bundled_package_version, +) PROJECT_ROOT = Path(__file__).resolve().parents[1] OBSERVABILITY_ID = "00000000-0000-4000-8000-000000000001" @@ -125,12 +128,15 @@ def test_v2_image_retains_required_runtime_dependencies() -> None: (PROJECT_ROOT / "pyproject.toml").read_text(encoding="utf-8") ) + packages = ( + "policyengine", + "policyengine-core", + "policyengine-uk", + "policyengine-us", + "spm-calculator", + ) assert project["dependency-groups"]["policyengine-models"] == [ - "policyengine==6.1.2", - "policyengine-core==3.32.5", - "policyengine-uk==2.90.2", - "policyengine-us==2.2.1", - "spm-calculator==1.0.0", + f"{package}=={get_bundled_package_version(package)}" for package in packages ] assert {"include-group": "policyengine-models"} in project["dependency-groups"][ "modal-simulation-image" diff --git a/projects/policyengine-simulation-executor/tests/test_stage12_worker_validation.py b/projects/policyengine-simulation-executor/tests/test_stage12_worker_validation.py index b90f0bb4c..f2618569a 100644 --- a/projects/policyengine-simulation-executor/tests/test_stage12_worker_validation.py +++ b/projects/policyengine-simulation-executor/tests/test_stage12_worker_validation.py @@ -8,6 +8,7 @@ from policyengine_simulation_executor.stage12_bundle import load_stage12_bundle from policyengine_simulation_executor.stage12_worker_validation import ( + _run_non_serving_calculation, validate_country_worker, ) @@ -49,6 +50,11 @@ def test_validation_checks_dataset_and_non_serving_calculation() -> None: assert countries == ["us"] +def test_non_serving_us_calculation_supports_bundle_default_spm() -> None: + """The deployed-worker check must satisfy the bundle's county SPM input.""" + _run_non_serving_calculation("us") + + def test_validation_rejects_digest_mismatch_before_dataset_access() -> None: accessed: list[str] = [] with pytest.raises(RuntimeError, match="digest differs"): From bb04c646aaa0ba0493ed1d293f9b64ed17c510a9 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:32:22 +0400 Subject: [PATCH 07/11] fix(executor): use national SPM in worker validation --- .../stage12_worker_validation.py | 8 +++--- .../tests/test_stage12_worker_validation.py | 26 ++++++++++++++++--- 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/stage12_worker_validation.py b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/stage12_worker_validation.py index 4ccb7bfa9..b827b6dd6 100644 --- a/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/stage12_worker_validation.py +++ b/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/stage12_worker_validation.py @@ -2,19 +2,19 @@ from __future__ import annotations +import os from collections.abc import Callable, Mapping from hashlib import sha256 from importlib import import_module -import os from pathlib import Path from typing import Any from policyengine_simulation_contract.stage12_bundle import CountryId -from policyengine_simulation_executor.stage12_bundle import load_stage12_bundle from policyengine_simulation_executor.release_bundle import ( resolve_local_bundle_dataset_path, ) +from policyengine_simulation_executor.stage12_bundle import load_stage12_bundle REQUIRED_SECRET_ALTERNATIVES = ( ("HF_TOKEN",), @@ -55,10 +55,10 @@ def _check_dataset_access(path: str, expected_sha256: str) -> None: def _run_non_serving_calculation(country: CountryId) -> None: country_module = import_module(f"policyengine.tax_benefit_models.{country}") - household = {"county_fips": "06037"} if country == "us" else None + spm = {"geography_kind": "national"} if country == "us" else None result = country_module.calculate_household( people=[{"age": 40, "employment_income": 50_000}], - **({"household": household} if household is not None else {}), + **({"spm": spm} if spm is not None else {}), year=2026, extra_variables=["household_net_income"], ) diff --git a/projects/policyengine-simulation-executor/tests/test_stage12_worker_validation.py b/projects/policyengine-simulation-executor/tests/test_stage12_worker_validation.py index f2618569a..ccce883a3 100644 --- a/projects/policyengine-simulation-executor/tests/test_stage12_worker_validation.py +++ b/projects/policyengine-simulation-executor/tests/test_stage12_worker_validation.py @@ -3,9 +3,9 @@ from __future__ import annotations from hashlib import sha256 +from types import SimpleNamespace import pytest - from policyengine_simulation_executor.stage12_bundle import load_stage12_bundle from policyengine_simulation_executor.stage12_worker_validation import ( _run_non_serving_calculation, @@ -50,11 +50,31 @@ def test_validation_checks_dataset_and_non_serving_calculation() -> None: assert countries == ["us"] -def test_non_serving_us_calculation_supports_bundle_default_spm() -> None: - """The deployed-worker check must satisfy the bundle's county SPM input.""" +def test_non_serving_us_calculation_supports_national_spm() -> None: + """The deployed-worker check must not invent a county for its synthetic household.""" _run_non_serving_calculation("us") +def test_non_serving_us_calculation_selects_national_without_county( + monkeypatch, +) -> None: + calls: list[dict] = [] + + def calculate_household(**kwargs): + calls.append(kwargs) + return SimpleNamespace(household={"household_net_income": 1}) + + monkeypatch.setattr( + "policyengine_simulation_executor.stage12_worker_validation.import_module", + lambda _: SimpleNamespace(calculate_household=calculate_household), + ) + + _run_non_serving_calculation("us") + + assert calls[0]["spm"] == {"geography_kind": "national"} + assert "household" not in calls[0] + + def test_validation_rejects_digest_mismatch_before_dataset_access() -> None: accessed: list[str] = [] with pytest.raises(RuntimeError, match="digest differs"): From 05ca95daa0366ce1569e86d53639914103fd681d Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:55:35 +0400 Subject: [PATCH 08/11] build(executor): simplify Modal model dependencies --- .../scripts/update-policyengine-package.sh | 205 ++++++++++++------ .../README.md | 18 +- ...est_policyengine_package_update_scripts.py | 4 +- .../pyproject.toml | 16 +- .../src/modal/app.py | 6 +- .../tests/test_modal_bundle_image.py | 9 +- .../test_policyengine_dependency_source.py | 28 +-- ...est_policyengine_package_update_scripts.py | 20 +- .../tests/test_stage12_modal_app.py | 21 +- .../policyengine-simulation-executor/uv.lock | 34 +-- 10 files changed, 209 insertions(+), 152 deletions(-) diff --git a/.github/scripts/update-policyengine-package.sh b/.github/scripts/update-policyengine-package.sh index ba1314235..fd1e32163 100755 --- a/.github/scripts/update-policyengine-package.sh +++ b/.github/scripts/update-policyengine-package.sh @@ -249,30 +249,37 @@ pyproject_path, package, current, latest = sys.argv[1:] pyproject = Path(pyproject_path) pyproject_text = pyproject.read_text(encoding="utf-8") parsed = tomllib.loads(pyproject_text) -dependency_lists = { - "project.dependencies": parsed.get("project", {}).get("dependencies", []), - "dependency-groups.policyengine-models": parsed.get("dependency-groups", {}).get( - "policyengine-models", [] +requirements = { + "project.dependencies": ( + parsed.get("project", {}).get("dependencies", []), + f"{package}=={current}", + f"{package}=={latest}", + ), + "dependency-groups.modal-simulation-image": ( + parsed.get("dependency-groups", {}).get("modal-simulation-image", []), + f"{package}[models]=={current}", + f"{package}[models]=={latest}", ), } -old_pin = f'"{package}=={current}"' -new_pin = f'"{package}=={latest}"' -for location, dependencies in dependency_lists.items(): +for location, (dependencies, old_requirement, new_requirement) in requirements.items(): + package_prefix = old_requirement.rsplit("==", 1)[0] + "==" matches = [ dependency for dependency in dependencies - if isinstance(dependency, str) and dependency.startswith(f"{package}==") + if isinstance(dependency, str) and dependency.startswith(package_prefix) ] - if matches != [f"{package}=={current}"]: + if matches != [old_requirement]: raise SystemExit( - f"Expected {package}=={current} in {location}; found {matches!r}" + f"Expected {old_requirement} in {location}; found {matches!r}" ) -updated_text = pyproject_text.replace(old_pin, new_pin) -if pyproject_text.count(old_pin) != len(dependency_lists): - raise SystemExit( - f"Expected {old_pin} {len(dependency_lists)} times in {pyproject}; " - f"found {pyproject_text.count(old_pin)}" - ) + old_pin = f'"{old_requirement}"' + new_pin = f'"{new_requirement}"' + if pyproject_text.count(old_pin) != 1: + raise SystemExit( + f"Expected {old_pin} once in {pyproject}; " + f"found {pyproject_text.count(old_pin)}" + ) + pyproject_text = pyproject_text.replace(old_pin, new_pin) with tempfile.NamedTemporaryFile( mode="w", encoding="utf-8", @@ -280,32 +287,64 @@ with tempfile.NamedTemporaryFile( prefix=f".{pyproject.name}.", delete=False, ) as temporary: - temporary.write(updated_text) + temporary.write(pyproject_text) temporary_path = temporary.name os.replace(temporary_path, pyproject) PY -# The PyPI Simple index (which uv resolves from) can lag the JSON API right -# after a release, so retry the lock a few times. +# Read the target wrapper's release manifest without resolving the project. The +# project still has the previous component pins at this point, which may +# conflict with the target wrapper's models extra. for attempt in 1 2 3; do - if ( - cd "$PROJECT_PATH" - uv lock --upgrade-package "$PACKAGE" + if BUNDLE_OUTPUT=$( + uv run \ + --isolated \ + --no-project \ + --with "${PACKAGE}==${LATEST}" \ + python - <<'PY' +from policyengine.bundle import get_current_bundle + +bundle = get_current_bundle() +packages = bundle.get("packages", {}) +data_releases = bundle.get("data_releases", {}) + + +def package_version(name): + package = packages.get(name, {}) + version = package.get("version") + if not isinstance(version, str) or not version: + raise SystemExit(f"Bundle has no version for {name}") + return version + + +def data_release_version(country): + release = data_releases.get(country, {}) + data_package = release.get("data_package", {}) + version = release.get("version") or data_package.get("version") + if not isinstance(version, str) or not version: + raise SystemExit(f"Bundle has no data release version for {country}") + return version + + +print(f"policyengine_version={package_version('policyengine')}") +print(f"policyengine_core_version={package_version('policyengine-core')}") +print(f"spm_calculator_version={package_version('spm-calculator')}") +print(f"us_version={package_version('policyengine-us')}") +print(f"us_data_version={data_release_version('us')}") +print(f"uk_version={package_version('policyengine-uk')}") +print(f"uk_data_version={data_release_version('uk')}") +PY ); then break fi if [[ "$attempt" == "3" ]]; then - echo "ERROR: uv lock failed after ${attempt} attempts." >&2 + echo "ERROR: Could not inspect ${PACKAGE} ${LATEST} after ${attempt} attempts." >&2 exit 1 fi - echo "uv lock attempt ${attempt} failed; retrying in 30s..." + echo "Bundle inspection attempt ${attempt} failed; retrying in 30s..." sleep 30 done -BUNDLE_OUTPUT=$( - cd "$PROJECT_PATH" - uv run python -m src.modal.utils.extract_bundle_versions --shell -) BUNDLED_US_VERSION=$(printf '%s\n' "$BUNDLE_OUTPUT" | awk -F= '$1 == "us_version" {print $2}') BUNDLED_UK_VERSION=$(printf '%s\n' "$BUNDLE_OUTPUT" | awk -F= '$1 == "uk_version" {print $2}') BUNDLED_CORE_VERSION=$(printf '%s\n' "$BUNDLE_OUTPUT" | awk -F= '$1 == "policyengine_core_version" {print $2}') @@ -360,49 +399,75 @@ pins = { "spm-calculator": spm_version, } -# These two dependency lists are installed in different places: project -# dependencies supply local development/tests, while policyengine-models is -# the only source for the Modal image. Require one exact pin in each list so a -# release cannot update one runtime while leaving the other on an older bundle. +# Project dependencies supply local development, tests, and non-Modal runtimes, +# so retain exact direct pins for every package in the wrapper's release +# manifest. The Modal image needs only the wrapper's models extra: its package +# metadata declares those same exact component versions and uv.lock records the +# resolved environment. parsed = tomllib.loads(text) -dependency_lists = { - "project.dependencies": parsed.get("project", {}).get("dependencies", []), - "dependency-groups.policyengine-models": parsed.get("dependency-groups", {}).get( - "policyengine-models", [] - ), -} -for location, dependencies in dependency_lists.items(): - for package in pins: - matches = [ - dependency - for dependency in dependencies - if isinstance(dependency, str) and dependency.startswith(f"{package}==") - ] - if len(matches) != 1: - raise SystemExit( - f"Expected one exact {package} pin in {location}; found {len(matches)}" - ) +project_dependencies = parsed.get("project", {}).get("dependencies", []) +modal_dependencies = parsed.get("dependency-groups", {}).get( + "modal-simulation-image", [] +) +expected_modal_requirement = f"policyengine[models]=={policyengine_version}" +modal_wrapper_requirements = [ + dependency + for dependency in modal_dependencies + if isinstance(dependency, str) and dependency.startswith("policyengine[models]==") +] +if modal_wrapper_requirements != [expected_modal_requirement]: + raise SystemExit( + f"Expected {expected_modal_requirement} in " + "dependency-groups.modal-simulation-image; " + f"found {modal_wrapper_requirements!r}" + ) +for package in pins: + matches = [ + dependency + for dependency in project_dependencies + if isinstance(dependency, str) and dependency.startswith(f"{package}==") + ] + if len(matches) != 1: + raise SystemExit( + f"Expected one exact {package} pin in project.dependencies; " + f"found {len(matches)}" + ) +for package in pins: + if package == "policyengine": + continue + modal_component_pins = [ + dependency + for dependency in modal_dependencies + if isinstance(dependency, str) and dependency.startswith(f"{package}==") + ] + if modal_component_pins: + raise SystemExit( + f"Expected {package} to be supplied by policyengine[models], " + f"found direct Modal requirements {modal_component_pins!r}" + ) for package, version in pins.items(): pattern = rf'"{re.escape(package)}==[^"]+"' replacement = f'"{package}=={version}"' text, count = re.subn(pattern, replacement, text) - if count != len(dependency_lists): + if count != 1: raise SystemExit( - f"Expected to update {package} {len(dependency_lists)} times in " - f"{pyproject}; updated {count}" + f"Expected to update {package} once in {pyproject}; updated {count}" ) updated = tomllib.loads(text) -for location, dependencies in { - "project.dependencies": updated["project"]["dependencies"], - "dependency-groups.policyengine-models": updated["dependency-groups"][ - "policyengine-models" - ], -}.items(): - for package, version in pins.items(): - if f"{package}=={version}" not in dependencies: - raise SystemExit(f"Updated {package} pin is missing from {location}") +for package, version in pins.items(): + if f"{package}=={version}" not in updated["project"]["dependencies"]: + raise SystemExit( + f"Updated {package} pin is missing from project.dependencies" + ) +if expected_modal_requirement not in updated["dependency-groups"][ + "modal-simulation-image" +]: + raise SystemExit( + "Updated policyengine[models] requirement is missing from " + "dependency-groups.modal-simulation-image" + ) with tempfile.NamedTemporaryFile( mode="w", @@ -416,9 +481,25 @@ with tempfile.NamedTemporaryFile( os.replace(temporary_path, pyproject) PY +# The PyPI Simple index can briefly lag the JSON API after a release, so retry +# the final project lock as well. +for attempt in 1 2 3; do + if ( + cd "$PROJECT_PATH" + uv lock + ); then + break + fi + if [[ "$attempt" == "3" ]]; then + echo "ERROR: uv lock failed after ${attempt} attempts." >&2 + exit 1 + fi + echo "uv lock attempt ${attempt} failed; retrying in 30s..." + sleep 30 +done + ( cd "$PROJECT_PATH" - uv lock uv lock --check uv run --extra test pytest \ tests/test_bundle_version_export.py \ diff --git a/projects/policyengine-simulation-executor/README.md b/projects/policyengine-simulation-executor/README.md index 45cf40ca4..39206958a 100644 --- a/projects/policyengine-simulation-executor/README.md +++ b/projects/policyengine-simulation-executor/README.md @@ -9,10 +9,10 @@ their runtime packages straight from this project's `uv.lock` via `uv_sync(frozen=True, --only-group modal-simulation-image)`. Image packages therefore match the versions the test environment runs against and can only change through a relock — never through a fresh resolution -at image-build time (issue #602 is what happens otherwise). The -`policyengine-models` group contains the exact PolicyEngine.py wrapper, core, -country-model, and SPM calculator versions selected by the release manifest, -and `modal-simulation-image` includes that group. +at image-build time (issue #602 is what happens otherwise). +`modal-simulation-image` declares `policyengine[models]` at an exact wrapper +version. That extra declares the wrapper's exact core, country-model, and SPM +calculator dependencies, and `uv.lock` records the versions that uv resolved. After `uv_sync`, `policyengine bundle install --no-packages` downloads and verifies the certified datasets and writes the bundle receipt. It does not run @@ -21,10 +21,12 @@ precompute, and import-smoke paths share this package and data arrangement. The gateway lives in its own project and installs from its own lock; see its README. -To change a PolicyEngine release, update the exact requirements in both the -project dependencies and `policyengine-models`, then run `uv lock`. Other image -dependencies belong in `modal-simulation-image`. PRs touching image inputs run an -in-image import smoke (`src/modal/smoke_app.py` via +To change a PolicyEngine release, update the wrapper requirement in the project +dependencies and `modal-simulation-image`, then run `uv lock`. The automated +updater reads the wrapper's release manifest and updates the direct component +pins used by local development and non-Modal runtimes. Other image dependencies +belong in `modal-simulation-image`. PRs touching image inputs run an in-image +import smoke (`src/modal/smoke_app.py` via `.github/workflows/pr-image-smoke.yml`). Note that any change to the group or lock invalidates the image layer cache, including the artifact fetch layer below. diff --git a/projects/policyengine-simulation-executor/fixtures/test_policyengine_package_update_scripts.py b/projects/policyengine-simulation-executor/fixtures/test_policyengine_package_update_scripts.py index 35baa22b2..7dec7ce2d 100644 --- a/projects/policyengine-simulation-executor/fixtures/test_policyengine_package_update_scripts.py +++ b/projects/policyengine-simulation-executor/fixtures/test_policyengine_package_update_scripts.py @@ -23,7 +23,7 @@ def fake_repo(tmp_path: Path) -> Path: dependencies = ["policyengine==4.0.0", "policyengine-core==0.0.0", "policyengine-us==1.0.0", "policyengine-uk==2.0.0", "spm-calculator==0.1.0"] [dependency-groups] -policyengine-models = ["policyengine==4.0.0", "policyengine-core==0.0.0", "policyengine-us==1.0.0", "policyengine-uk==2.0.0", "spm-calculator==0.1.0"] +modal-simulation-image = ["policyengine[models]==4.0.0", "fastapi>=0.115.0"] """, encoding="utf-8", ) @@ -184,7 +184,7 @@ def install_fake_uv( set -euo pipefail printf 'uv %s\\n' "$*" >> "{log}" -if [[ "$1" == "run" && "$2" == "python" && "$3" == "-m" && "$4" == "src.modal.utils.extract_bundle_versions" ]]; then +if [[ "$1" == "run" && "$*" == *"--isolated --no-project --with policyengine==4.1.0 python -"* ]]; then echo "policyengine_version={bundled_policyengine_version}" echo "policyengine_core_version={bundled_core_version}" echo "spm_calculator_version={bundled_spm_version}" diff --git a/projects/policyengine-simulation-executor/pyproject.toml b/projects/policyengine-simulation-executor/pyproject.toml index 30fae65da..ede786b85 100644 --- a/projects/policyengine-simulation-executor/pyproject.toml +++ b/projects/policyengine-simulation-executor/pyproject.toml @@ -40,19 +40,13 @@ packages = ["src/policyengine_simulation_executor"] # project; the image installs it directly with # uv_sync(--only-group modal-simulation-image, frozen=True), so image # packages can only change through a relock and always match the -# versions the test environment runs against. The included model group is -# maintained from PolicyEngine.py's release manifest; the bundle command only -# downloads certified datasets and writes their receipt. +# versions the test environment runs against. PolicyEngine.py's models extra +# declares its exact core, country-model, and SPM calculator versions; uv.lock +# records the complete resolved environment. The bundle command only downloads +# certified datasets and writes their receipt. [dependency-groups] -policyengine-models = [ - "policyengine==6.1.2", - "policyengine-core==3.32.5", - "policyengine-uk==2.90.2", - "policyengine-us==2.2.1", - "spm-calculator==1.0.0", -] modal-simulation-image = [ - {include-group = "policyengine-models"}, + "policyengine[models]==6.1.2", "fastapi>=0.115.0", "tables>=3.10.2", "policyengine-observability[fastapi,google,otlp-grpc]>=3.0.1,<4", diff --git a/projects/policyengine-simulation-executor/src/modal/app.py b/projects/policyengine-simulation-executor/src/modal/app.py index 186af6462..5e4c4db6d 100644 --- a/projects/policyengine-simulation-executor/src/modal/app.py +++ b/projects/policyengine-simulation-executor/src/modal/app.py @@ -181,9 +181,9 @@ def build_runtime_simulation_image() -> modal.Image: # The modal-simulation-image dependency group, installed straight # from this project's uv.lock (frozen): image packages match the # tested environment and can only change through a relock. - # --only-group keeps the project's local packages out. Its included - # policyengine-models group installs the exact manifest-selected model - # packages from uv.lock. + # --only-group keeps the project's local packages out. The + # policyengine[models] requirement declares the wrapper's model + # packages, and uv.lock supplies their exact resolved versions. .uv_sync( uv_project_dir=_UV_PROJECT_DIR, frozen=True, diff --git a/projects/policyengine-simulation-executor/tests/test_modal_bundle_image.py b/projects/policyengine-simulation-executor/tests/test_modal_bundle_image.py index ce03ac480..48346d3ac 100644 --- a/projects/policyengine-simulation-executor/tests/test_modal_bundle_image.py +++ b/projects/policyengine-simulation-executor/tests/test_modal_bundle_image.py @@ -11,7 +11,6 @@ get_bundled_package_version, ) - POLICYENGINE_BUNDLE_PACKAGES = ( "policyengine", "policyengine-core", @@ -61,7 +60,6 @@ def test_modal_image_uses_policyengine_bundle_install(monkeypatch): f"{package}=={bundle_versions[package]}" for package in POLICYENGINE_BUNDLE_PACKAGES ] - assert project["dependency-groups"]["policyengine-models"] == expected_models assert all(item in project["project"]["dependencies"] for item in expected_models) assert "--data-dir /opt/policyengine/data" in command assert app.VERSION_ENV["POLICYENGINE_DATA_FOLDER"] == "/opt/policyengine/data" @@ -92,7 +90,12 @@ def test_modal_image_uses_policyengine_bundle_install(monkeypatch): ] group = project["dependency-groups"]["modal-simulation-image"] - assert {"include-group": "policyengine-models"} in group + assert f"policyengine[models]=={bundle_versions['policyengine']}" in group + assert not any(isinstance(item, dict) for item in group) + for package in POLICYENGINE_BUNDLE_PACKAGES[1:]: + assert not any( + isinstance(item, str) and item.startswith(f"{package}==") for item in group + ) requirements = [item for item in group if isinstance(item, str)] names = {requirement.split(">=")[0].split("[")[0] for requirement in requirements} assert "policyengine-observability" in names diff --git a/projects/policyengine-simulation-executor/tests/test_policyengine_dependency_source.py b/projects/policyengine-simulation-executor/tests/test_policyengine_dependency_source.py index bf1c3db51..17b057337 100644 --- a/projects/policyengine-simulation-executor/tests/test_policyengine_dependency_source.py +++ b/projects/policyengine-simulation-executor/tests/test_policyengine_dependency_source.py @@ -113,26 +113,26 @@ def test_country_package_pins_match_policyengine_bundle(): ) -def test_all_runtime_package_pins_match_policyengine_bundle(): +def test_modal_models_extra_and_project_pins_match_policyengine_bundle(): from policyengine_simulation_executor.release_bundle import ( get_bundled_package_version, ) pyproject = _load_toml(PYPROJECT_PATH) - model_dependencies = pyproject["dependency-groups"]["policyengine-models"] - model_pins = { - package: next( - dependency.removeprefix(f"{package}==") - for dependency in model_dependencies - if dependency.startswith(f"{package}==") + modal_dependencies = pyproject["dependency-groups"]["modal-simulation-image"] + wrapper_version = get_bundled_package_version("policyengine") + + assert modal_dependencies.count(f"policyengine[models]=={wrapper_version}") == 1 + assert not any(isinstance(dependency, dict) for dependency in modal_dependencies) + for package in BUNDLE_PACKAGES: + assert _get_dependency_pin(pyproject, package) == get_bundled_package_version( + package ) - for package in BUNDLE_PACKAGES - } - - assert set(model_pins) == BUNDLE_PACKAGES - for package, version in model_pins.items(): - assert version == _get_dependency_pin(pyproject, package) - assert version == get_bundled_package_version(package) + if package != "policyengine": + assert not any( + isinstance(dependency, str) and dependency.startswith(f"{package}==") + for dependency in modal_dependencies + ) def _modal_import_env() -> dict[str, str]: diff --git a/projects/policyengine-simulation-executor/tests/test_policyengine_package_update_scripts.py b/projects/policyengine-simulation-executor/tests/test_policyengine_package_update_scripts.py index fbc61d25a..8ffbd9b64 100644 --- a/projects/policyengine-simulation-executor/tests/test_policyengine_package_update_scripts.py +++ b/projects/policyengine-simulation-executor/tests/test_policyengine_package_update_scripts.py @@ -168,15 +168,17 @@ def test_update_policyengine_package_updates_py_and_bundled_runtime_pins( assert "policyengine-us==1.1.0" in pyproject_text assert "policyengine-uk==2.1.0" in pyproject_text assert "spm-calculator==1.0.0" in pyproject_text - assert pyproject_text.count("policyengine==4.1.0") == 2 - assert pyproject_text.count("policyengine-core==999.999.999") == 2 - assert pyproject_text.count("policyengine-us==1.1.0") == 2 - assert pyproject_text.count("policyengine-uk==2.1.0") == 2 - assert pyproject_text.count("spm-calculator==1.0.0") == 2 + assert pyproject_text.count("policyengine==4.1.0") == 1 + assert pyproject_text.count("policyengine[models]==4.1.0") == 1 + assert pyproject_text.count("policyengine-core==999.999.999") == 1 + assert pyproject_text.count("policyengine-us==1.1.0") == 1 + assert pyproject_text.count("policyengine-uk==2.1.0") == 1 + assert pyproject_text.count("spm-calculator==1.0.0") == 1 uv_calls = uv_log.read_text(encoding="utf-8") - assert "lock --upgrade-package policyengine" in uv_calls - assert "run python -m src.modal.utils.extract_bundle_versions --shell" in uv_calls + assert "run --isolated --no-project --with policyengine==4.1.0 python -" in uv_calls + assert "lock --upgrade-package policyengine" not in uv_calls assert "uv lock" in uv_calls + assert uv_calls.index("run --isolated") < uv_calls.index("uv lock") assert "lock --check" in uv_calls assert "run --extra test pytest tests/test_bundle_version_export.py" in uv_calls assert "checkout -b auto/update-policyengine-4.1.0" in git_log.read_text( @@ -234,7 +236,7 @@ def test_update_policyengine_package_stops_when_loaded_bundle_is_not_target( assert "pr create" not in gh_log.read_text(encoding="utf-8") -def test_update_policyengine_package_requires_both_runtime_dependency_lists( +def test_update_policyengine_package_requires_modal_models_requirement( fake_bin: Path, fake_repo: Path, tmp_path: Path ) -> None: git_log = tmp_path / "git.log" @@ -254,7 +256,7 @@ def test_update_policyengine_package_requires_both_runtime_dependency_lists( ) assert result.returncode != 0 - assert "in dependency-groups.policyengine-models; found []" in result.stderr + assert "in dependency-groups.modal-simulation-image; found []" in result.stderr git_calls = git_log.read_text(encoding="utf-8") assert "git commit" not in git_calls assert "git push" not in git_calls diff --git a/projects/policyengine-simulation-executor/tests/test_stage12_modal_app.py b/projects/policyengine-simulation-executor/tests/test_stage12_modal_app.py index b889ea18d..25c9fc095 100644 --- a/projects/policyengine-simulation-executor/tests/test_stage12_modal_app.py +++ b/projects/policyengine-simulation-executor/tests/test_stage12_modal_app.py @@ -128,24 +128,13 @@ def test_v2_image_retains_required_runtime_dependencies() -> None: (PROJECT_ROOT / "pyproject.toml").read_text(encoding="utf-8") ) - packages = ( - "policyengine", - "policyengine-core", - "policyengine-uk", - "policyengine-us", - "spm-calculator", - ) - assert project["dependency-groups"]["policyengine-models"] == [ - f"{package}=={get_bundled_package_version(package)}" for package in packages - ] - assert {"include-group": "policyengine-models"} in project["dependency-groups"][ - "modal-simulation-image" - ] - assert "sqlalchemy>=2,<3" in project["dependency-groups"]["modal-simulation-image"] + group = project["dependency-groups"]["modal-simulation-image"] assert ( - "psycopg[binary]>=3.2,<4" - in project["dependency-groups"]["modal-simulation-image"] + f"policyengine[models]=={get_bundled_package_version('policyengine')}" in group ) + assert not any(isinstance(item, dict) for item in group) + assert "sqlalchemy>=2,<3" in group + assert "psycopg[binary]>=3.2,<4" in group def test_v2_app_declares_validation_workers_and_non_http_coordinator( diff --git a/projects/policyengine-simulation-executor/uv.lock b/projects/policyengine-simulation-executor/uv.lock index 592774c35..5072d0627 100644 --- a/projects/policyengine-simulation-executor/uv.lock +++ b/projects/policyengine-simulation-executor/uv.lock @@ -1710,6 +1710,14 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2c/00/63af579350d76cf85debca243c0360fa6689b50d22bedd89b3425aa7dd5b/policyengine-6.1.2-py3-none-any.whl", hash = "sha256:526e6557fc52b18d671be1726e30bf4eb728124d17372bf27c82c5fcc25813be", size = 252407, upload-time = "2026-09-27T03:54:51.197Z" }, ] +[package.optional-dependencies] +models = [ + { name = "policyengine-core" }, + { name = "policyengine-uk" }, + { name = "policyengine-us" }, + { name = "spm-calculator" }, +] + [[package]] name = "policyengine-core" version = "3.32.5" @@ -1872,24 +1880,13 @@ dev = [ modal-simulation-image = [ { name = "fastapi" }, { name = "google-cloud-storage" }, - { name = "policyengine" }, - { name = "policyengine-core" }, + { name = "policyengine", extra = ["models"] }, { name = "policyengine-observability", extra = ["fastapi", "google", "otlp-grpc"] }, - { name = "policyengine-uk" }, - { name = "policyengine-us" }, { name = "psycopg", extra = ["binary"] }, { name = "pyarrow" }, - { name = "spm-calculator" }, { name = "sqlalchemy" }, { name = "tables" }, ] -policyengine-models = [ - { name = "policyengine" }, - { name = "policyengine-core" }, - { name = "policyengine-uk" }, - { name = "policyengine-us" }, - { name = "spm-calculator" }, -] [package.metadata] requires-dist = [ @@ -1924,24 +1921,13 @@ dev = [{ name = "policyengine-simulation-gateway", editable = "../policyengine-s modal-simulation-image = [ { name = "fastapi", specifier = ">=0.115.0" }, { name = "google-cloud-storage", specifier = ">=2" }, - { name = "policyengine", specifier = "==6.1.2" }, - { name = "policyengine-core", specifier = "==3.32.5" }, + { name = "policyengine", extras = ["models"], specifier = "==6.1.2" }, { name = "policyengine-observability", extras = ["fastapi", "google", "otlp-grpc"], specifier = ">=3.0.1,<4" }, - { name = "policyengine-uk", specifier = "==2.90.2" }, - { name = "policyengine-us", specifier = "==2.2.1" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.2,<4" }, { name = "pyarrow", specifier = ">=20,<24" }, - { name = "spm-calculator", specifier = "==1.0.0" }, { name = "sqlalchemy", specifier = ">=2,<3" }, { name = "tables", specifier = ">=3.10.2" }, ] -policyengine-models = [ - { name = "policyengine", specifier = "==6.1.2" }, - { name = "policyengine-core", specifier = "==3.32.5" }, - { name = "policyengine-uk", specifier = "==2.90.2" }, - { name = "policyengine-us", specifier = "==2.2.1" }, - { name = "spm-calculator", specifier = "==1.0.0" }, -] [[package]] name = "policyengine-simulation-gateway" From 85df783705d098649642020f8ebc105621018080 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 29 Sep 2026 18:12:34 +0400 Subject: [PATCH 09/11] fix(executor): derive model packages from PolicyEngine --- .../scripts/update-policyengine-package.sh | 203 ++++++------------ .../README.md | 20 +- ...est_policyengine_package_update_scripts.py | 2 +- .../pyproject.toml | 8 +- .../src/modal/app.py | 20 +- .../src/modal/dependency_pins.py | 25 --- .../tests/test_modal_bundle_image.py | 20 +- .../test_policyengine_dependency_source.py | 114 +++------- ...est_policyengine_package_update_scripts.py | 53 +++-- .../policyengine-simulation-executor/uv.lock | 12 +- 10 files changed, 180 insertions(+), 297 deletions(-) delete mode 100644 projects/policyengine-simulation-executor/src/modal/dependency_pins.py diff --git a/.github/scripts/update-policyengine-package.sh b/.github/scripts/update-policyengine-package.sh index fd1e32163..064f6b4f5 100755 --- a/.github/scripts/update-policyengine-package.sh +++ b/.github/scripts/update-policyengine-package.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # # Check PyPI for a newer policyengine.py package, update the simulation project -# pin, sync runtime package pins to that policyengine.py bundle, and open one +# requirement, let its models extra select every runtime component, and open one # bundle-level PR. # # Usage: @@ -139,7 +139,7 @@ create_pr_body_file() { echo echo "Update policyengine.py from ${CURRENT} to ${LATEST} in the simulation API runtime." echo - echo "This also syncs runtime package pins to the versions bundled by policyengine.py ${LATEST}:" + echo "The policyengine[models] requirement selects the runtime versions bundled by policyengine.py ${LATEST}:" echo "- policyengine-core: ${BUNDLED_CORE_VERSION:-resolved from bundle during update}" echo "- policyengine-us: ${BUNDLED_US_VERSION:-resolved from bundle during update}" echo "- policyengine-uk: ${BUNDLED_UK_VERSION:-resolved from bundle during update}" @@ -162,16 +162,25 @@ if [[ ! -f "$PYPROJECT" || ! -f "$LOCKFILE" ]]; then fi CURRENT=$(python3 - "$PYPROJECT" "$PACKAGE" <<'PY' -import re import sys +import tomllib from pathlib import Path pyproject, package = sys.argv[1:] -text = Path(pyproject).read_text(encoding="utf-8") -match = re.search(rf'"{re.escape(package)}==([^"]+)"', text) -if not match: - raise SystemExit(f"Package {package!r} not found in {pyproject}") -print(match.group(1)) +parsed = tomllib.loads(Path(pyproject).read_text(encoding="utf-8")) +dependencies = parsed.get("project", {}).get("dependencies", []) +prefix = f"{package}[models]==" +matches = [ + dependency.removeprefix(prefix) + for dependency in dependencies + if isinstance(dependency, str) and dependency.startswith(prefix) +] +if len(matches) != 1: + raise SystemExit( + f"Expected one {package}[models] requirement in project.dependencies; " + f"found {matches!r}" + ) +print(matches[0]) PY ) @@ -239,6 +248,7 @@ git checkout -b "$BRANCH" python3 - "$PYPROJECT" "$PACKAGE" "$CURRENT" "$LATEST" <<'PY' import os +import re import sys import tempfile import tomllib @@ -249,19 +259,27 @@ pyproject_path, package, current, latest = sys.argv[1:] pyproject = Path(pyproject_path) pyproject_text = pyproject.read_text(encoding="utf-8") parsed = tomllib.loads(pyproject_text) +old_requirement = f"{package}[models]=={current}" +new_requirement = f"{package}[models]=={latest}" requirements = { - "project.dependencies": ( - parsed.get("project", {}).get("dependencies", []), - f"{package}=={current}", - f"{package}=={latest}", - ), - "dependency-groups.modal-simulation-image": ( - parsed.get("dependency-groups", {}).get("modal-simulation-image", []), - f"{package}[models]=={current}", - f"{package}[models]=={latest}", - ), + "project.dependencies": parsed.get("project", {}).get("dependencies", []), + "dependency-groups.modal-simulation-image": parsed.get( + "dependency-groups", {} + ).get("modal-simulation-image", []), } -for location, (dependencies, old_requirement, new_requirement) in requirements.items(): +component_packages = ( + "policyengine-core", + "policyengine-us", + "policyengine-uk", + "spm-calculator", +) + + +def requirement_name(requirement): + return re.split(r"[\s\[<>=!~;@]", requirement, maxsplit=1)[0] + + +for location, dependencies in requirements.items(): package_prefix = old_requirement.rsplit("==", 1)[0] + "==" matches = [ dependency @@ -272,14 +290,37 @@ for location, (dependencies, old_requirement, new_requirement) in requirements.i raise SystemExit( f"Expected {old_requirement} in {location}; found {matches!r}" ) - old_pin = f'"{old_requirement}"' - new_pin = f'"{new_requirement}"' - if pyproject_text.count(old_pin) != 1: + redundant_wrapper_requirements = [ + dependency + for dependency in dependencies + if isinstance(dependency, str) + and dependency != old_requirement + and requirement_name(dependency) == package + ] + if redundant_wrapper_requirements: raise SystemExit( - f"Expected {old_pin} once in {pyproject}; " - f"found {pyproject_text.count(old_pin)}" + f"Expected only {old_requirement} for {package} in {location}; " + f"found {redundant_wrapper_requirements!r}" ) - pyproject_text = pyproject_text.replace(old_pin, new_pin) + direct_component_requirements = [ + dependency + for dependency in dependencies + if isinstance(dependency, str) + and requirement_name(dependency) in component_packages + ] + if direct_component_requirements: + raise SystemExit( + f"Expected {location} to obtain component packages from " + f"{package}[models]; found {direct_component_requirements!r}" + ) +old_pin = f'"{old_requirement}"' +new_pin = f'"{new_requirement}"' +if pyproject_text.count(old_pin) != len(requirements): + raise SystemExit( + f"Expected {old_pin} {len(requirements)} times in {pyproject}; " + f"found {pyproject_text.count(old_pin)}" + ) +pyproject_text = pyproject_text.replace(old_pin, new_pin) with tempfile.NamedTemporaryFile( mode="w", encoding="utf-8", @@ -292,9 +333,7 @@ with tempfile.NamedTemporaryFile( os.replace(temporary_path, pyproject) PY -# Read the target wrapper's release manifest without resolving the project. The -# project still has the previous component pins at this point, which may -# conflict with the target wrapper's models extra. +# Read the target wrapper's release manifest without resolving the project. for attempt in 1 2 3; do if BUNDLE_OUTPUT=$( uv run \ @@ -373,114 +412,6 @@ echo "Certified data releases:" echo " us=${BUNDLED_US_DATA_VERSION}" echo " uk=${BUNDLED_UK_DATA_VERSION}" -python3 - "$PYPROJECT" "$BUNDLED_POLICYENGINE_VERSION" "$BUNDLED_CORE_VERSION" "$BUNDLED_US_VERSION" "$BUNDLED_UK_VERSION" "$BUNDLED_SPM_VERSION" <<'PY' -import os -import re -import sys -import tempfile -import tomllib -from pathlib import Path - -( - pyproject_path, - policyengine_version, - core_version, - us_version, - uk_version, - spm_version, -) = sys.argv[1:] -pyproject = Path(pyproject_path) -text = pyproject.read_text(encoding="utf-8") -pins = { - "policyengine": policyengine_version, - "policyengine-core": core_version, - "policyengine-us": us_version, - "policyengine-uk": uk_version, - "spm-calculator": spm_version, -} - -# Project dependencies supply local development, tests, and non-Modal runtimes, -# so retain exact direct pins for every package in the wrapper's release -# manifest. The Modal image needs only the wrapper's models extra: its package -# metadata declares those same exact component versions and uv.lock records the -# resolved environment. -parsed = tomllib.loads(text) -project_dependencies = parsed.get("project", {}).get("dependencies", []) -modal_dependencies = parsed.get("dependency-groups", {}).get( - "modal-simulation-image", [] -) -expected_modal_requirement = f"policyengine[models]=={policyengine_version}" -modal_wrapper_requirements = [ - dependency - for dependency in modal_dependencies - if isinstance(dependency, str) and dependency.startswith("policyengine[models]==") -] -if modal_wrapper_requirements != [expected_modal_requirement]: - raise SystemExit( - f"Expected {expected_modal_requirement} in " - "dependency-groups.modal-simulation-image; " - f"found {modal_wrapper_requirements!r}" - ) -for package in pins: - matches = [ - dependency - for dependency in project_dependencies - if isinstance(dependency, str) and dependency.startswith(f"{package}==") - ] - if len(matches) != 1: - raise SystemExit( - f"Expected one exact {package} pin in project.dependencies; " - f"found {len(matches)}" - ) -for package in pins: - if package == "policyengine": - continue - modal_component_pins = [ - dependency - for dependency in modal_dependencies - if isinstance(dependency, str) and dependency.startswith(f"{package}==") - ] - if modal_component_pins: - raise SystemExit( - f"Expected {package} to be supplied by policyengine[models], " - f"found direct Modal requirements {modal_component_pins!r}" - ) - -for package, version in pins.items(): - pattern = rf'"{re.escape(package)}==[^"]+"' - replacement = f'"{package}=={version}"' - text, count = re.subn(pattern, replacement, text) - if count != 1: - raise SystemExit( - f"Expected to update {package} once in {pyproject}; updated {count}" - ) - -updated = tomllib.loads(text) -for package, version in pins.items(): - if f"{package}=={version}" not in updated["project"]["dependencies"]: - raise SystemExit( - f"Updated {package} pin is missing from project.dependencies" - ) -if expected_modal_requirement not in updated["dependency-groups"][ - "modal-simulation-image" -]: - raise SystemExit( - "Updated policyengine[models] requirement is missing from " - "dependency-groups.modal-simulation-image" - ) - -with tempfile.NamedTemporaryFile( - mode="w", - encoding="utf-8", - dir=pyproject.parent, - prefix=f".{pyproject.name}.", - delete=False, -) as temporary: - temporary.write(text) - temporary_path = temporary.name -os.replace(temporary_path, pyproject) -PY - # The PyPI Simple index can briefly lag the JSON API after a release, so retry # the final project lock as well. for attempt in 1 2 3; do diff --git a/projects/policyengine-simulation-executor/README.md b/projects/policyengine-simulation-executor/README.md index 39206958a..dfd73ecff 100644 --- a/projects/policyengine-simulation-executor/README.md +++ b/projects/policyengine-simulation-executor/README.md @@ -10,9 +10,11 @@ their runtime packages straight from this project's `uv.lock` via packages therefore match the versions the test environment runs against and can only change through a relock — never through a fresh resolution at image-build time (issue #602 is what happens otherwise). -`modal-simulation-image` declares `policyengine[models]` at an exact wrapper -version. That extra declares the wrapper's exact core, country-model, and SPM -calculator dependencies, and `uv.lock` records the versions that uv resolved. +Both the executor project and `modal-simulation-image` declare +`policyengine[models]` at an exact wrapper version. That extra declares the +wrapper's exact core, country-model, and SPM calculator dependencies, and +`uv.lock` records the versions that uv resolved. Neither dependency list +duplicates those component versions. After `uv_sync`, `policyengine bundle install --no-packages` downloads and verifies the certified datasets and writes the bundle receipt. It does not run @@ -21,12 +23,12 @@ precompute, and import-smoke paths share this package and data arrangement. The gateway lives in its own project and installs from its own lock; see its README. -To change a PolicyEngine release, update the wrapper requirement in the project -dependencies and `modal-simulation-image`, then run `uv lock`. The automated -updater reads the wrapper's release manifest and updates the direct component -pins used by local development and non-Modal runtimes. Other image dependencies -belong in `modal-simulation-image`. PRs touching image inputs run an in-image -import smoke (`src/modal/smoke_app.py` via +To change a PolicyEngine release, update the `policyengine[models]` requirement +in the project dependencies and `modal-simulation-image`, then run `uv lock`. +The automated updater changes only those wrapper requirements; it reads the +wrapper's release manifest to verify and report the selected component and data +versions. Other image dependencies belong in `modal-simulation-image`. PRs +touching image inputs run an in-image import smoke (`src/modal/smoke_app.py` via `.github/workflows/pr-image-smoke.yml`). Note that any change to the group or lock invalidates the image layer cache, including the artifact fetch layer below. diff --git a/projects/policyengine-simulation-executor/fixtures/test_policyengine_package_update_scripts.py b/projects/policyengine-simulation-executor/fixtures/test_policyengine_package_update_scripts.py index 7dec7ce2d..9fc41d87f 100644 --- a/projects/policyengine-simulation-executor/fixtures/test_policyengine_package_update_scripts.py +++ b/projects/policyengine-simulation-executor/fixtures/test_policyengine_package_update_scripts.py @@ -20,7 +20,7 @@ def fake_repo(tmp_path: Path) -> Path: (project / "pyproject.toml").write_text( """[project] -dependencies = ["policyengine==4.0.0", "policyengine-core==0.0.0", "policyengine-us==1.0.0", "policyengine-uk==2.0.0", "spm-calculator==0.1.0"] +dependencies = ["policyengine[models]==4.0.0"] [dependency-groups] modal-simulation-image = ["policyengine[models]==4.0.0", "fastapi>=0.115.0"] diff --git a/projects/policyengine-simulation-executor/pyproject.toml b/projects/policyengine-simulation-executor/pyproject.toml index ede786b85..c0f4766e8 100644 --- a/projects/policyengine-simulation-executor/pyproject.toml +++ b/projects/policyengine-simulation-executor/pyproject.toml @@ -19,11 +19,9 @@ dependencies = [ "policyengine-simulation-observability", "policyengine-simulation-contract[modal]", "policyengine-stage12-persistence", - "policyengine==6.1.2", - "policyengine-core==3.32.5", - "policyengine-uk==2.90.2", - "policyengine-us==2.2.1", - "spm-calculator==1.0.0", + # PolicyEngine.py's models extra is the only selector for core, country + # models, and the SPM calculator. Do not duplicate those versions here. + "policyengine[models]==6.1.2", "tables>=3.10.2", "modal>=0.73.0", "policyengine-observability[fastapi,google,otlp-grpc]>=3.0.1,<4", diff --git a/projects/policyengine-simulation-executor/src/modal/app.py b/projects/policyengine-simulation-executor/src/modal/app.py index 5e4c4db6d..bd4f25732 100644 --- a/projects/policyengine-simulation-executor/src/modal/app.py +++ b/projects/policyengine-simulation-executor/src/modal/app.py @@ -10,6 +10,10 @@ import os from pathlib import Path +from policyengine_simulation_executor.release_bundle import ( + get_bundled_country_model_version, + get_bundled_package_version, +) from policyengine_simulation_observability.observability import ( init_process_observability, modal_image_environment, @@ -23,23 +27,19 @@ from policyengine_simulation_observability.telemetry import ( normalize_observability_context, ) - -import modal -from policyengine_simulation_executor.release_bundle import ( - get_bundled_country_model_version, -) from src.modal._image_setup import fetch_artifacts, snapshot_models from src.modal.bundle_data import bundle_data_install_command -from src.modal.dependency_pins import project_dependency_pin from src.modal.logging_redaction import redact_params_for_logging +import modal + -def _version_from_env_or_local_dependency(env_var: str, package: str) -> str: +def _version_from_env_or_local_bundle_package(env_var: str, package: str) -> str: value = os.environ.get(env_var) if value: return value if modal.is_local(): - return project_dependency_pin(package) + return get_bundled_package_version(package) raise RuntimeError( f"{env_var} must be set in the Modal image environment for remote " "simulation workers." @@ -58,11 +58,11 @@ def _version_from_env_or_local_bundle(env_var: str, country: str) -> str: ) -POLICYENGINE_VERSION = _version_from_env_or_local_dependency( +POLICYENGINE_VERSION = _version_from_env_or_local_bundle_package( "POLICYENGINE_VERSION", "policyengine", ) -POLICYENGINE_CORE_VERSION = _version_from_env_or_local_dependency( +POLICYENGINE_CORE_VERSION = _version_from_env_or_local_bundle_package( "POLICYENGINE_CORE_VERSION", "policyengine-core", ) diff --git a/projects/policyengine-simulation-executor/src/modal/dependency_pins.py b/projects/policyengine-simulation-executor/src/modal/dependency_pins.py deleted file mode 100644 index 9d99bd613..000000000 --- a/projects/policyengine-simulation-executor/src/modal/dependency_pins.py +++ /dev/null @@ -1,25 +0,0 @@ -"""Helpers for reading pinned project dependencies.""" - -from __future__ import annotations - -import tomllib -from functools import lru_cache -from pathlib import Path - - -PROJECT_DIR = Path(__file__).resolve().parents[2] -PYPROJECT_PATH = PROJECT_DIR / "pyproject.toml" - - -@lru_cache -def _project_dependencies() -> tuple[str, ...]: - pyproject = tomllib.loads(PYPROJECT_PATH.read_text(encoding="utf-8")) - return tuple(pyproject["project"]["dependencies"]) - - -def project_dependency_pin(package: str) -> str: - prefix = f"{package}==" - for dependency in _project_dependencies(): - if dependency.startswith(prefix): - return dependency.removeprefix(prefix) - raise ValueError(f"Dependency {package!r} is not pinned in {PYPROJECT_PATH}") diff --git a/projects/policyengine-simulation-executor/tests/test_modal_bundle_image.py b/projects/policyengine-simulation-executor/tests/test_modal_bundle_image.py index 48346d3ac..1c2a531ba 100644 --- a/projects/policyengine-simulation-executor/tests/test_modal_bundle_image.py +++ b/projects/policyengine-simulation-executor/tests/test_modal_bundle_image.py @@ -1,11 +1,8 @@ import importlib -import os import sys -import tomllib from pathlib import Path -import pytest - +import tomllib from fixtures.fake_modal import install_fake_modal from policyengine_simulation_executor.release_bundle import ( get_bundled_package_version, @@ -56,11 +53,14 @@ def test_modal_image_uses_policyengine_bundle_install(monkeypatch): project = tomllib.loads( (Path(__file__).resolve().parents[1] / "pyproject.toml").read_text() ) - expected_models = [ - f"{package}=={bundle_versions[package]}" - for package in POLICYENGINE_BUNDLE_PACKAGES - ] - assert all(item in project["project"]["dependencies"] for item in expected_models) + project_dependencies = project["project"]["dependencies"] + expected_wrapper = f"policyengine[models]=={bundle_versions['policyengine']}" + assert project_dependencies.count(expected_wrapper) == 1 + for package in POLICYENGINE_BUNDLE_PACKAGES[1:]: + assert not any( + isinstance(item, str) and item.startswith(f"{package}==") + for item in project_dependencies + ) assert "--data-dir /opt/policyengine/data" in command assert app.VERSION_ENV["POLICYENGINE_DATA_FOLDER"] == "/opt/policyengine/data" assert app.VERSION_ENV["POLICYENGINE_BUNDLE_RECEIPT"].endswith( @@ -90,7 +90,7 @@ def test_modal_image_uses_policyengine_bundle_install(monkeypatch): ] group = project["dependency-groups"]["modal-simulation-image"] - assert f"policyengine[models]=={bundle_versions['policyengine']}" in group + assert expected_wrapper in group assert not any(isinstance(item, dict) for item in group) for package in POLICYENGINE_BUNDLE_PACKAGES[1:]: assert not any( diff --git a/projects/policyengine-simulation-executor/tests/test_policyengine_dependency_source.py b/projects/policyengine-simulation-executor/tests/test_policyengine_dependency_source.py index 17b057337..1fcd423c5 100644 --- a/projects/policyengine-simulation-executor/tests/test_policyengine_dependency_source.py +++ b/projects/policyengine-simulation-executor/tests/test_policyengine_dependency_source.py @@ -3,20 +3,15 @@ import os import subprocess import sys -import tomllib from pathlib import Path +import tomllib + REPO_ROOT = Path(__file__).parent.parent PYPROJECT_PATH = REPO_ROOT / "pyproject.toml" MODAL_APP_PATH = REPO_ROOT / "src" / "modal" / "app.py" -POLICYENGINE_DEPENDENCY_PREFIX = "policyengine==" -POLICYENGINE_CORE_DEPENDENCY_PREFIX = "policyengine-core==" -COUNTRY_PACKAGES = { - "us": "policyengine-us", - "uk": "policyengine-uk", -} -BUNDLE_PACKAGES = { - "policyengine", +POLICYENGINE_MODELS_DEPENDENCY_PREFIX = "policyengine[models]==" +COMPONENT_PACKAGES = { "policyengine-core", "policyengine-us", "policyengine-uk", @@ -37,54 +32,52 @@ def _load_toml(path: Path) -> dict: def _get_pyproject_policyengine_dependency(pyproject: dict) -> str: - dependencies = pyproject["project"]["dependencies"] - return next( - dep for dep in dependencies if dep.startswith(POLICYENGINE_DEPENDENCY_PREFIX) - ) - - -def _get_pyproject_policyengine_core_dependency(pyproject: dict) -> str: dependencies = pyproject["project"]["dependencies"] return next( dep for dep in dependencies - if dep.startswith(POLICYENGINE_CORE_DEPENDENCY_PREFIX) + if dep.startswith(POLICYENGINE_MODELS_DEPENDENCY_PREFIX) ) -def _get_dependency_pin(pyproject: dict, package: str) -> str: - dependencies = pyproject["project"]["dependencies"] - prefix = f"{package}==" - return next( - dep.removeprefix(prefix) for dep in dependencies if dep.startswith(prefix) +def test_all_runtime_dependencies_use_policyengine_models_extra(): + from policyengine_simulation_executor.release_bundle import ( + get_bundled_package_version, ) - -def test_policyengine_dependency_version_is_pinned_consistently(): - from src.modal.dependency_pins import project_dependency_pin - pyproject = _load_toml(PYPROJECT_PATH) - pyproject_dependency = _get_pyproject_policyengine_dependency(pyproject) - pyproject_core_dependency = _get_pyproject_policyengine_core_dependency(pyproject) - - assert pyproject_dependency.startswith(POLICYENGINE_DEPENDENCY_PREFIX) - assert pyproject_core_dependency.startswith(POLICYENGINE_CORE_DEPENDENCY_PREFIX) - assert ( - f"policyengine=={project_dependency_pin('policyengine')}" - == pyproject_dependency - ) - assert ( - f"policyengine-core=={project_dependency_pin('policyengine-core')}" - == pyproject_core_dependency + wrapper_version = get_bundled_package_version("policyengine") + expected_requirement = f"policyengine[models]=={wrapper_version}" + dependency_lists = ( + pyproject["project"]["dependencies"], + pyproject["dependency-groups"]["modal-simulation-image"], ) + assert _get_pyproject_policyengine_dependency(pyproject) == expected_requirement + for dependencies in dependency_lists: + assert dependencies.count(expected_requirement) == 1 + assert not any( + isinstance(dependency, str) and dependency.startswith("policyengine==") + for dependency in dependencies + ) + for package in COMPONENT_PACKAGES: + assert not any( + isinstance(dependency, str) + and ( + dependency.startswith(f"{package}==") + or dependency.startswith(f"{package}[") + ) + for dependency in dependencies + ) + -def test_modal_app_reads_policyengine_pins_from_pyproject(): +def test_modal_app_reads_package_versions_from_policyengine_bundle(): modal_source = MODAL_APP_PATH.read_text(encoding="utf-8") assert '"policyengine==4.10.0"' not in modal_source assert '"policyengine-core==3.26.1"' not in modal_source - assert "project_dependency_pin" in modal_source + assert "project_dependency_pin" not in modal_source + assert "get_bundled_package_version" in modal_source assert '"policyengine"' in modal_source assert '"policyengine-core"' in modal_source assert "POLICYENGINE_CORE_VERSION" in modal_source @@ -99,42 +92,6 @@ def test_modal_app_name_is_keyed_to_policyengine_py_version(): assert "policyengine-simulation-us" not in modal_source -def test_country_package_pins_match_policyengine_bundle(): - from policyengine_simulation_executor.release_bundle import ( - get_country_release_bundle, - ) - - pyproject = _load_toml(PYPROJECT_PATH) - - for country, package in COUNTRY_PACKAGES.items(): - assert ( - _get_dependency_pin(pyproject, package) - == get_country_release_bundle(country).model_version - ) - - -def test_modal_models_extra_and_project_pins_match_policyengine_bundle(): - from policyengine_simulation_executor.release_bundle import ( - get_bundled_package_version, - ) - - pyproject = _load_toml(PYPROJECT_PATH) - modal_dependencies = pyproject["dependency-groups"]["modal-simulation-image"] - wrapper_version = get_bundled_package_version("policyengine") - - assert modal_dependencies.count(f"policyengine[models]=={wrapper_version}") == 1 - assert not any(isinstance(dependency, dict) for dependency in modal_dependencies) - for package in BUNDLE_PACKAGES: - assert _get_dependency_pin(pyproject, package) == get_bundled_package_version( - package - ) - if package != "policyengine": - assert not any( - isinstance(dependency, str) and dependency.startswith(f"{package}==") - for dependency in modal_dependencies - ) - - def _modal_import_env() -> dict[str, str]: env = os.environ.copy() for env_var in VERSION_ENV: @@ -150,13 +107,12 @@ def test_modal_app_remote_import_uses_image_version_env(): modal.is_local = lambda: False from policyengine_simulation_executor import release_bundle -from src.modal import dependency_pins def fail(message): raise AssertionError(message) -dependency_pins.project_dependency_pin = lambda package: fail( - f"read pyproject for {package}" +release_bundle.get_bundled_package_version = lambda package: fail( + f"read bundle package for {package}" ) release_bundle.get_bundled_country_model_version = lambda country: fail( f"read bundle manifest for {country}" diff --git a/projects/policyengine-simulation-executor/tests/test_policyengine_package_update_scripts.py b/projects/policyengine-simulation-executor/tests/test_policyengine_package_update_scripts.py index 8ffbd9b64..f9c313fe9 100644 --- a/projects/policyengine-simulation-executor/tests/test_policyengine_package_update_scripts.py +++ b/projects/policyengine-simulation-executor/tests/test_policyengine_package_update_scripts.py @@ -143,7 +143,7 @@ def test_update_policyengine_package_opens_pr_for_existing_branch_without_open_p assert "pr-body-first-line Fixes #679" in gh_calls -def test_update_policyengine_package_updates_py_and_bundled_runtime_pins( +def test_update_policyengine_package_updates_only_wrapper_requirements( fake_bin: Path, fake_repo: Path, tmp_path: Path ) -> None: git_log = tmp_path / "git.log" @@ -163,17 +163,12 @@ def test_update_policyengine_package_updates_py_and_bundled_runtime_pins( pyproject_text = (fake_repo / "simulation" / "pyproject.toml").read_text( encoding="utf-8" ) - assert "policyengine==4.1.0" in pyproject_text - assert "policyengine-core==999.999.999" in pyproject_text - assert "policyengine-us==1.1.0" in pyproject_text - assert "policyengine-uk==2.1.0" in pyproject_text - assert "spm-calculator==1.0.0" in pyproject_text - assert pyproject_text.count("policyengine==4.1.0") == 1 - assert pyproject_text.count("policyengine[models]==4.1.0") == 1 - assert pyproject_text.count("policyengine-core==999.999.999") == 1 - assert pyproject_text.count("policyengine-us==1.1.0") == 1 - assert pyproject_text.count("policyengine-uk==2.1.0") == 1 - assert pyproject_text.count("spm-calculator==1.0.0") == 1 + assert pyproject_text.count("policyengine[models]==4.1.0") == 2 + assert "policyengine==4.1.0" not in pyproject_text + assert "policyengine-core==" not in pyproject_text + assert "policyengine-us==" not in pyproject_text + assert "policyengine-uk==" not in pyproject_text + assert "spm-calculator==" not in pyproject_text uv_calls = uv_log.read_text(encoding="utf-8") assert "run --isolated --no-project --with policyengine==4.1.0 python -" in uv_calls assert "lock --upgrade-package policyengine" not in uv_calls @@ -261,3 +256,37 @@ def test_update_policyengine_package_requires_modal_models_requirement( assert "git commit" not in git_calls assert "git push" not in git_calls assert "pr create" not in gh_log.read_text(encoding="utf-8") + + +def test_update_policyengine_package_rejects_direct_component_requirements( + fake_bin: Path, fake_repo: Path, tmp_path: Path +) -> None: + git_log = tmp_path / "git.log" + gh_log = tmp_path / "gh.log" + uv_log = tmp_path / "uv.log" + install_fake_git(fake_bin, root=fake_repo, log=git_log, diff_has_changes=True) + install_fake_gh(fake_bin, log=gh_log) + install_fake_uv(fake_bin, log=uv_log) + pyproject = fake_repo / "simulation" / "pyproject.toml" + pyproject.write_text( + pyproject.read_text(encoding="utf-8").replace( + 'dependencies = ["policyengine[models]==4.0.0"]', + 'dependencies = ["policyengine[models]==4.0.0", ' + '"policyengine-core==0.0.0"]', + ), + encoding="utf-8", + ) + + result = run_updater( + env=updater_env(fake_bin, LATEST_OVERRIDE="4.1.0"), + ) + + assert result.returncode != 0 + assert ( + "Expected project.dependencies to obtain component packages from " + "policyengine[models]" + ) in result.stderr + git_calls = git_log.read_text(encoding="utf-8") + assert "git commit" not in git_calls + assert "git push" not in git_calls + assert "pr create" not in gh_log.read_text(encoding="utf-8") diff --git a/projects/policyengine-simulation-executor/uv.lock b/projects/policyengine-simulation-executor/uv.lock index 5072d0627..704565870 100644 --- a/projects/policyengine-simulation-executor/uv.lock +++ b/projects/policyengine-simulation-executor/uv.lock @@ -1846,18 +1846,14 @@ dependencies = [ { name = "modal" }, { name = "opentelemetry-instrumentation-fastapi" }, { name = "opentelemetry-instrumentation-sqlalchemy" }, - { name = "policyengine" }, - { name = "policyengine-core" }, + { name = "policyengine", extra = ["models"] }, { name = "policyengine-fastapi" }, { name = "policyengine-observability", extra = ["fastapi", "google", "otlp-grpc"] }, { name = "policyengine-simulation-contract", extra = ["modal"] }, { name = "policyengine-simulation-observability" }, { name = "policyengine-stage12-persistence" }, - { name = "policyengine-uk" }, - { name = "policyengine-us" }, { name = "pyarrow" }, { name = "pydantic-settings" }, - { name = "spm-calculator" }, { name = "tables" }, ] @@ -1896,22 +1892,18 @@ requires-dist = [ { name = "openapi-python-client", marker = "extra == 'build'", specifier = ">=0.21.6" }, { name = "opentelemetry-instrumentation-fastapi", specifier = ">=0.65b0,<0.66" }, { name = "opentelemetry-instrumentation-sqlalchemy", specifier = ">=0.65b0,<0.66" }, - { name = "policyengine", specifier = "==6.1.2" }, - { name = "policyengine-core", specifier = "==3.32.5" }, + { name = "policyengine", extras = ["models"], specifier = "==6.1.2" }, { name = "policyengine-fastapi", editable = "../../libs/policyengine-fastapi" }, { name = "policyengine-observability", extras = ["fastapi", "google", "otlp-grpc"], specifier = ">=3.0.1,<4" }, { name = "policyengine-simulation-contract", extras = ["modal"], editable = "../../libs/policyengine-simulation-contract" }, { name = "policyengine-simulation-observability", editable = "../../libs/policyengine-simulation-observability" }, { name = "policyengine-stage12-persistence", editable = "../../libs/policyengine-stage12-persistence" }, - { name = "policyengine-uk", specifier = "==2.90.2" }, - { name = "policyengine-us", specifier = "==2.2.1" }, { name = "pyarrow", specifier = ">=20,<24" }, { name = "pydantic-settings", specifier = ">=2.7.1,<3.0.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" }, - { name = "spm-calculator", specifier = "==1.0.0" }, { name = "tables", specifier = ">=3.10.2" }, ] provides-extras = ["test", "build"] From df356a6056887f5c02c6a753122d059471186dc7 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 29 Sep 2026 19:50:40 +0400 Subject: [PATCH 10/11] refactor(ci): extract PolicyEngine update helper --- .../scripts/update-policyengine-package.sh | 203 ++----------- .../scripts/update_policyengine_package.py | 275 ++++++++++++++++++ ...est_policyengine_package_update_scripts.py | 4 +- ...est_policyengine_package_update_scripts.py | 21 +- 4 files changed, 318 insertions(+), 185 deletions(-) create mode 100644 .github/scripts/update_policyengine_package.py diff --git a/.github/scripts/update-policyengine-package.sh b/.github/scripts/update-policyengine-package.sh index 064f6b4f5..9342b0ae6 100755 --- a/.github/scripts/update-policyengine-package.sh +++ b/.github/scripts/update-policyengine-package.sh @@ -27,11 +27,13 @@ elif [[ -n "${1:-}" ]]; then fi PACKAGE="policyengine" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ROOT_DIR="$(git rev-parse --show-toplevel)" PROJECT_DIR="${PROJECT_DIR:-projects/policyengine-simulation-executor}" PROJECT_PATH="${ROOT_DIR}/${PROJECT_DIR}" PYPROJECT="${PROJECT_PATH}/pyproject.toml" LOCKFILE="${PROJECT_PATH}/uv.lock" +PYTHON_HELPER="${SCRIPT_DIR}/update_policyengine_package.py" REPOSITORY="${GITHUB_REPOSITORY:-PolicyEngine/policyengine-sim-api}" ISSUE_NUMBER="" @@ -44,21 +46,7 @@ ensure_update_issue() { ISSUE_NUMBER=$( gh api --paginate --slurp \ "repos/${REPOSITORY}/issues?state=open&per_page=100" \ - | python3 -c ' -import json -import sys - -title = sys.argv[1] -pages = json.load(sys.stdin) -matches = sorted( - item["number"] - for page in pages - for item in page - if "pull_request" not in item and item.get("title") == title -) -if matches: - print(matches[0]) -' "$issue_title" + | python3 "$PYTHON_HELPER" find-issue --title "$issue_title" ) if [[ -z "$ISSUE_NUMBER" ]]; then @@ -77,20 +65,10 @@ if matches: issue_details=$(gh issue view "$ISSUE_NUMBER" \ --repo "$REPOSITORY" \ --json number,state,title) - printf '%s' "$issue_details" | python3 -c ' -import json -import sys - -expected_number = int(sys.argv[1]) -expected_title = sys.argv[2] -issue = json.load(sys.stdin) -if issue.get("number") != expected_number: - raise SystemExit("Resolved update issue has an unexpected number") -if issue.get("state") != "OPEN": - raise SystemExit("Resolved update issue is not open") -if issue.get("title") != expected_title: - raise SystemExit("Resolved update issue has an unexpected title") -' "$ISSUE_NUMBER" "$issue_title" + printf '%s' "$issue_details" \ + | python3 "$PYTHON_HELPER" verify-issue \ + --number "$ISSUE_NUMBER" \ + --title "$issue_title" } verify_update_pr() { @@ -99,18 +77,8 @@ verify_update_pr() { pr_details=$(gh pr view "$BRANCH" \ --repo "$REPOSITORY" \ --json isDraft,headRepositoryOwner,headRepository) - printf '%s' "$pr_details" | python3 -c ' -import json -import sys - -expected_repository = sys.argv[1] -pr = json.load(sys.stdin) -if pr.get("isDraft") is not True: - raise SystemExit("Automated policyengine update PR is not a draft") -head_repository = pr.get("headRepository") or {} -if head_repository.get("nameWithOwner") != expected_repository: - raise SystemExit("Automated policyengine update PR is not from the canonical repository") -' "$REPOSITORY" + printf '%s' "$pr_details" \ + | python3 "$PYTHON_HELPER" verify-pr --repository "$REPOSITORY" } create_update_pr() { @@ -161,33 +129,15 @@ if [[ ! -f "$PYPROJECT" || ! -f "$LOCKFILE" ]]; then exit 1 fi -CURRENT=$(python3 - "$PYPROJECT" "$PACKAGE" <<'PY' -import sys -import tomllib -from pathlib import Path - -pyproject, package = sys.argv[1:] -parsed = tomllib.loads(Path(pyproject).read_text(encoding="utf-8")) -dependencies = parsed.get("project", {}).get("dependencies", []) -prefix = f"{package}[models]==" -matches = [ - dependency.removeprefix(prefix) - for dependency in dependencies - if isinstance(dependency, str) and dependency.startswith(prefix) -] -if len(matches) != 1: - raise SystemExit( - f"Expected one {package}[models] requirement in project.dependencies; " - f"found {matches!r}" - ) -print(matches[0]) -PY -) +CURRENT=$(python3 "$PYTHON_HELPER" current-version \ + --pyproject "$PYPROJECT" \ + --package "$PACKAGE") if [[ -n "${LATEST_OVERRIDE:-}" ]]; then LATEST="$LATEST_OVERRIDE" else - LATEST=$(curl -fsSL "https://pypi.org/pypi/${PACKAGE}/json" | python3 -c 'import json, sys; print(json.load(sys.stdin)["info"]["version"])') + LATEST=$(curl -fsSL "https://pypi.org/pypi/${PACKAGE}/json" \ + | python3 "$PYTHON_HELPER" latest-version --package "$PACKAGE") if [[ -z "$LATEST" ]]; then echo "ERROR: Could not fetch latest version for ${PACKAGE} from PyPI." >&2 exit 1 @@ -246,92 +196,11 @@ git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" git checkout -b "$BRANCH" -python3 - "$PYPROJECT" "$PACKAGE" "$CURRENT" "$LATEST" <<'PY' -import os -import re -import sys -import tempfile -import tomllib -from pathlib import Path - -pyproject_path, package, current, latest = sys.argv[1:] - -pyproject = Path(pyproject_path) -pyproject_text = pyproject.read_text(encoding="utf-8") -parsed = tomllib.loads(pyproject_text) -old_requirement = f"{package}[models]=={current}" -new_requirement = f"{package}[models]=={latest}" -requirements = { - "project.dependencies": parsed.get("project", {}).get("dependencies", []), - "dependency-groups.modal-simulation-image": parsed.get( - "dependency-groups", {} - ).get("modal-simulation-image", []), -} -component_packages = ( - "policyengine-core", - "policyengine-us", - "policyengine-uk", - "spm-calculator", -) - - -def requirement_name(requirement): - return re.split(r"[\s\[<>=!~;@]", requirement, maxsplit=1)[0] - - -for location, dependencies in requirements.items(): - package_prefix = old_requirement.rsplit("==", 1)[0] + "==" - matches = [ - dependency - for dependency in dependencies - if isinstance(dependency, str) and dependency.startswith(package_prefix) - ] - if matches != [old_requirement]: - raise SystemExit( - f"Expected {old_requirement} in {location}; found {matches!r}" - ) - redundant_wrapper_requirements = [ - dependency - for dependency in dependencies - if isinstance(dependency, str) - and dependency != old_requirement - and requirement_name(dependency) == package - ] - if redundant_wrapper_requirements: - raise SystemExit( - f"Expected only {old_requirement} for {package} in {location}; " - f"found {redundant_wrapper_requirements!r}" - ) - direct_component_requirements = [ - dependency - for dependency in dependencies - if isinstance(dependency, str) - and requirement_name(dependency) in component_packages - ] - if direct_component_requirements: - raise SystemExit( - f"Expected {location} to obtain component packages from " - f"{package}[models]; found {direct_component_requirements!r}" - ) -old_pin = f'"{old_requirement}"' -new_pin = f'"{new_requirement}"' -if pyproject_text.count(old_pin) != len(requirements): - raise SystemExit( - f"Expected {old_pin} {len(requirements)} times in {pyproject}; " - f"found {pyproject_text.count(old_pin)}" - ) -pyproject_text = pyproject_text.replace(old_pin, new_pin) -with tempfile.NamedTemporaryFile( - mode="w", - encoding="utf-8", - dir=pyproject.parent, - prefix=f".{pyproject.name}.", - delete=False, -) as temporary: - temporary.write(pyproject_text) - temporary_path = temporary.name -os.replace(temporary_path, pyproject) -PY +python3 "$PYTHON_HELPER" update-requirements \ + --pyproject "$PYPROJECT" \ + --package "$PACKAGE" \ + --current "$CURRENT" \ + --latest "$LATEST" # Read the target wrapper's release manifest without resolving the project. for attempt in 1 2 3; do @@ -340,39 +209,7 @@ for attempt in 1 2 3; do --isolated \ --no-project \ --with "${PACKAGE}==${LATEST}" \ - python - <<'PY' -from policyengine.bundle import get_current_bundle - -bundle = get_current_bundle() -packages = bundle.get("packages", {}) -data_releases = bundle.get("data_releases", {}) - - -def package_version(name): - package = packages.get(name, {}) - version = package.get("version") - if not isinstance(version, str) or not version: - raise SystemExit(f"Bundle has no version for {name}") - return version - - -def data_release_version(country): - release = data_releases.get(country, {}) - data_package = release.get("data_package", {}) - version = release.get("version") or data_package.get("version") - if not isinstance(version, str) or not version: - raise SystemExit(f"Bundle has no data release version for {country}") - return version - - -print(f"policyengine_version={package_version('policyengine')}") -print(f"policyengine_core_version={package_version('policyengine-core')}") -print(f"spm_calculator_version={package_version('spm-calculator')}") -print(f"us_version={package_version('policyengine-us')}") -print(f"us_data_version={data_release_version('us')}") -print(f"uk_version={package_version('policyengine-uk')}") -print(f"uk_data_version={data_release_version('uk')}") -PY + python "$PYTHON_HELPER" bundle-versions ); then break fi diff --git a/.github/scripts/update_policyengine_package.py b/.github/scripts/update_policyengine_package.py new file mode 100644 index 000000000..e9e9782ef --- /dev/null +++ b/.github/scripts/update_policyengine_package.py @@ -0,0 +1,275 @@ +"""Structured operations used by update-policyengine-package.sh.""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import sys +import tempfile +from collections.abc import Mapping +from pathlib import Path +from typing import Any + +import tomllib + +COMPONENT_PACKAGES = frozenset( + { + "policyengine-core", + "policyengine-us", + "policyengine-uk", + "spm-calculator", + } +) + + +def _mapping(value: object) -> Mapping[str, Any]: + return value if isinstance(value, Mapping) else {} + + +def _json_from_stdin() -> Any: + return json.load(sys.stdin) + + +def _requirement_name(requirement: str) -> str: + return re.split(r"[\s\[<>=!~;@]", requirement, maxsplit=1)[0] + + +def _project_dependencies(pyproject: Path) -> list: + parsed = tomllib.loads(pyproject.read_text(encoding="utf-8")) + dependencies = _mapping(parsed.get("project")).get("dependencies", []) + return dependencies if isinstance(dependencies, list) else [] + + +def current_version(pyproject: Path, package: str) -> str: + """Return the exact PolicyEngine models-extra version used by the project.""" + prefix = f"{package}[models]==" + matches = [ + dependency.removeprefix(prefix) + for dependency in _project_dependencies(pyproject) + if isinstance(dependency, str) and dependency.startswith(prefix) + ] + if len(matches) != 1: + raise SystemExit( + f"Expected one {package}[models] requirement in project.dependencies; " + f"found {matches!r}" + ) + return matches[0] + + +def update_requirements( + pyproject: Path, + package: str, + current: str, + latest: str, +) -> None: + """Update the project and Modal wrapper requirements atomically.""" + text = pyproject.read_text(encoding="utf-8") + parsed = tomllib.loads(text) + old_requirement = f"{package}[models]=={current}" + new_requirement = f"{package}[models]=={latest}" + dependency_groups = _mapping(parsed.get("dependency-groups")) + requirements = { + "project.dependencies": _mapping(parsed.get("project")).get("dependencies", []), + "dependency-groups.modal-simulation-image": dependency_groups.get( + "modal-simulation-image", [] + ), + } + + for location, dependencies in requirements.items(): + dependencies = dependencies if isinstance(dependencies, list) else [] + matches = [ + dependency + for dependency in dependencies + if isinstance(dependency, str) + and dependency.startswith(f"{package}[models]==") + ] + if matches != [old_requirement]: + raise SystemExit( + f"Expected {old_requirement} in {location}; found {matches!r}" + ) + + redundant_wrapper_requirements = [ + dependency + for dependency in dependencies + if isinstance(dependency, str) + and dependency != old_requirement + and _requirement_name(dependency) == package + ] + if redundant_wrapper_requirements: + raise SystemExit( + f"Expected only {old_requirement} for {package} in {location}; " + f"found {redundant_wrapper_requirements!r}" + ) + + direct_component_requirements = [ + dependency + for dependency in dependencies + if isinstance(dependency, str) + and _requirement_name(dependency) in COMPONENT_PACKAGES + ] + if direct_component_requirements: + raise SystemExit( + f"Expected {location} to obtain component packages from " + f"{package}[models]; found {direct_component_requirements!r}" + ) + + old_pin = f'"{old_requirement}"' + new_pin = f'"{new_requirement}"' + if text.count(old_pin) != len(requirements): + raise SystemExit( + f"Expected {old_pin} {len(requirements)} times in {pyproject}; " + f"found {text.count(old_pin)}" + ) + + with tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8", + dir=pyproject.parent, + prefix=f".{pyproject.name}.", + delete=False, + ) as temporary: + temporary.write(text.replace(old_pin, new_pin)) + temporary_path = temporary.name + os.replace(temporary_path, pyproject) + + +def find_issue(title: str) -> None: + """Print the lowest matching issue number from paginated GitHub output.""" + pages = _json_from_stdin() + if not isinstance(pages, list): + raise SystemExit("GitHub issue response must be a list of pages") + matches = sorted( + item["number"] + for page in pages + if isinstance(page, list) + for item in page + if isinstance(item, Mapping) + and "pull_request" not in item + and item.get("title") == title + and isinstance(item.get("number"), int) + ) + if matches: + print(matches[0]) + + +def verify_issue(expected_number: int, expected_title: str) -> None: + """Validate the issue selected for an automated update.""" + issue = _mapping(_json_from_stdin()) + if issue.get("number") != expected_number: + raise SystemExit("Resolved update issue has an unexpected number") + if issue.get("state") != "OPEN": + raise SystemExit("Resolved update issue is not open") + if issue.get("title") != expected_title: + raise SystemExit("Resolved update issue has an unexpected title") + + +def verify_pr(expected_repository: str) -> None: + """Validate that an automated pull request is a canonical draft.""" + pull_request = _mapping(_json_from_stdin()) + if pull_request.get("isDraft") is not True: + raise SystemExit("Automated policyengine update PR is not a draft") + head_repository = _mapping(pull_request.get("headRepository")) + if head_repository.get("nameWithOwner") != expected_repository: + raise SystemExit( + "Automated policyengine update PR is not from the canonical repository" + ) + + +def latest_version(package: str) -> None: + """Print a package version from the PyPI JSON response.""" + payload = _mapping(_json_from_stdin()) + version = _mapping(payload.get("info")).get("version") + if not isinstance(version, str) or not version: + raise SystemExit(f"PyPI response has no latest version for {package}") + print(version) + + +def bundle_versions() -> None: + """Print the package and data versions selected by PolicyEngine.py.""" + from policyengine.bundle import get_current_bundle + + bundle = _mapping(get_current_bundle()) + packages = _mapping(bundle.get("packages")) + data_releases = _mapping(bundle.get("data_releases")) + + def package_version(name: str) -> str: + version = _mapping(packages.get(name)).get("version") + if not isinstance(version, str) or not version: + raise SystemExit(f"Bundle has no version for {name}") + return version + + def data_release_version(country: str) -> str: + release = _mapping(data_releases.get(country)) + version = release.get("version") or _mapping(release.get("data_package")).get( + "version" + ) + if not isinstance(version, str) or not version: + raise SystemExit(f"Bundle has no data release version for {country}") + return version + + outputs = { + "policyengine_version": package_version("policyengine"), + "policyengine_core_version": package_version("policyengine-core"), + "spm_calculator_version": package_version("spm-calculator"), + "us_version": package_version("policyengine-us"), + "us_data_version": data_release_version("us"), + "uk_version": package_version("policyengine-uk"), + "uk_data_version": data_release_version("uk"), + } + for name, version in outputs.items(): + print(f"{name}={version}") + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest="command", required=True) + + current = commands.add_parser("current-version") + current.add_argument("--pyproject", type=Path, required=True) + current.add_argument("--package", required=True) + + update = commands.add_parser("update-requirements") + update.add_argument("--pyproject", type=Path, required=True) + update.add_argument("--package", required=True) + update.add_argument("--current", required=True) + update.add_argument("--latest", required=True) + + issue_search = commands.add_parser("find-issue") + issue_search.add_argument("--title", required=True) + + issue_check = commands.add_parser("verify-issue") + issue_check.add_argument("--number", type=int, required=True) + issue_check.add_argument("--title", required=True) + + pr_check = commands.add_parser("verify-pr") + pr_check.add_argument("--repository", required=True) + + pypi = commands.add_parser("latest-version") + pypi.add_argument("--package", required=True) + + commands.add_parser("bundle-versions") + return parser + + +def main() -> None: + args = _parser().parse_args() + if args.command == "current-version": + print(current_version(args.pyproject, args.package)) + elif args.command == "update-requirements": + update_requirements(args.pyproject, args.package, args.current, args.latest) + elif args.command == "find-issue": + find_issue(args.title) + elif args.command == "verify-issue": + verify_issue(args.number, args.title) + elif args.command == "verify-pr": + verify_pr(args.repository) + elif args.command == "latest-version": + latest_version(args.package) + elif args.command == "bundle-versions": + bundle_versions() + + +if __name__ == "__main__": + main() diff --git a/projects/policyengine-simulation-executor/fixtures/test_policyengine_package_update_scripts.py b/projects/policyengine-simulation-executor/fixtures/test_policyengine_package_update_scripts.py index 9fc41d87f..9cbf97953 100644 --- a/projects/policyengine-simulation-executor/fixtures/test_policyengine_package_update_scripts.py +++ b/projects/policyengine-simulation-executor/fixtures/test_policyengine_package_update_scripts.py @@ -184,7 +184,9 @@ def install_fake_uv( set -euo pipefail printf 'uv %s\\n' "$*" >> "{log}" -if [[ "$1" == "run" && "$*" == *"--isolated --no-project --with policyengine==4.1.0 python -"* ]]; then +if [[ "$1" == "run" \ + && "$*" == *"--isolated --no-project --with policyengine==4.1.0"* \ + && "$*" == *"update_policyengine_package.py bundle-versions"* ]]; then echo "policyengine_version={bundled_policyengine_version}" echo "policyengine_core_version={bundled_core_version}" echo "spm_calculator_version={bundled_spm_version}" diff --git a/projects/policyengine-simulation-executor/tests/test_policyengine_package_update_scripts.py b/projects/policyengine-simulation-executor/tests/test_policyengine_package_update_scripts.py index f9c313fe9..449d91545 100644 --- a/projects/policyengine-simulation-executor/tests/test_policyengine_package_update_scripts.py +++ b/projects/policyengine-simulation-executor/tests/test_policyengine_package_update_scripts.py @@ -3,6 +3,7 @@ from __future__ import annotations import subprocess +import sys from pathlib import Path from fixtures.test_policyengine_package_update_scripts import ( @@ -28,6 +29,23 @@ def test_update_policyengine_package_script_has_valid_bash_syntax() -> None: assert result.returncode == 0, result.stderr +def test_update_policyengine_package_uses_a_separate_python_helper() -> None: + source = SCRIPT.read_text(encoding="utf-8") + helper = SCRIPT.with_name("update_policyengine_package.py") + + assert "update_policyengine_package.py" in source + assert "python3 -c" not in source + assert "python -c" not in source + assert "<<'PY'" not in source + result = subprocess.run( + [sys.executable, str(helper), "--help"], + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, result.stderr + + def test_update_policyengine_workflow_requests_issue_and_pr_permissions() -> None: workflow = ( SCRIPT.parent.parent / "workflows" / "check-policyengine-updates.yml" @@ -170,7 +188,8 @@ def test_update_policyengine_package_updates_only_wrapper_requirements( assert "policyengine-uk==" not in pyproject_text assert "spm-calculator==" not in pyproject_text uv_calls = uv_log.read_text(encoding="utf-8") - assert "run --isolated --no-project --with policyengine==4.1.0 python -" in uv_calls + assert "run --isolated --no-project --with policyengine==4.1.0 python " in uv_calls + assert "update_policyengine_package.py bundle-versions" in uv_calls assert "lock --upgrade-package policyengine" not in uv_calls assert "uv lock" in uv_calls assert uv_calls.index("run --isolated") < uv_calls.index("uv lock") From 1c171800aa7e2917eeedc7f7be29159e19a32ea2 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:33:21 +0400 Subject: [PATCH 11/11] refactor(executor): remove bundle command wrappers --- .../src/modal/app.py | 26 ++++++++----------- .../src/modal/v2_app.py | 15 +++++------ 2 files changed, 17 insertions(+), 24 deletions(-) diff --git a/projects/policyengine-simulation-executor/src/modal/app.py b/projects/policyengine-simulation-executor/src/modal/app.py index bd4f25732..b4da2cd56 100644 --- a/projects/policyengine-simulation-executor/src/modal/app.py +++ b/projects/policyengine-simulation-executor/src/modal/app.py @@ -10,10 +10,6 @@ import os from pathlib import Path -from policyengine_simulation_executor.release_bundle import ( - get_bundled_country_model_version, - get_bundled_package_version, -) from policyengine_simulation_observability.observability import ( init_process_observability, modal_image_environment, @@ -27,12 +23,16 @@ from policyengine_simulation_observability.telemetry import ( normalize_observability_context, ) + +import modal +from policyengine_simulation_executor.release_bundle import ( + get_bundled_country_model_version, + get_bundled_package_version, +) from src.modal._image_setup import fetch_artifacts, snapshot_models from src.modal.bundle_data import bundle_data_install_command from src.modal.logging_redaction import redact_params_for_logging -import modal - def _version_from_env_or_local_bundle_package(env_var: str, package: str) -> str: value = os.environ.get(env_var) @@ -159,14 +159,6 @@ def _deploy_time_artifact_inputs() -> tuple[str, dict | None]: _ARTIFACT_BUCKET, _DEPLOY_MANIFEST = _deploy_time_artifact_inputs() -def bundle_install_command(policyengine_version: str) -> str: - return bundle_data_install_command( - policyengine_version, - countries=("us", "uk"), - data_dir=SIMULATION_BUNDLE_DATA_DIR, - ) - - def build_runtime_simulation_image() -> modal.Image: """Image layers up to the version env — everything except the artifact fetch and model snapshot. @@ -192,7 +184,11 @@ def build_runtime_simulation_image() -> modal.Image: # Packages are already installed from the frozen lock. The wrapper CLI # downloads only the certified datasets and writes their receipt. .run_commands( - bundle_install_command(POLICYENGINE_VERSION), + bundle_data_install_command( + POLICYENGINE_VERSION, + countries=("us", "uk"), + data_dir=SIMULATION_BUNDLE_DATA_DIR, + ), secrets=[data_secret, hf_secret], ) .env(VERSION_ENV) diff --git a/projects/policyengine-simulation-executor/src/modal/v2_app.py b/projects/policyengine-simulation-executor/src/modal/v2_app.py index 86d5700b1..9319c3587 100644 --- a/projects/policyengine-simulation-executor/src/modal/v2_app.py +++ b/projects/policyengine-simulation-executor/src/modal/v2_app.py @@ -84,14 +84,6 @@ def _country_bundle(country: CountryId): ) -def bundle_install_command(countries: tuple[CountryId, ...]) -> str: - return bundle_data_install_command( - RESOLVED_BUNDLE.bundle.policyengine_version, - countries=countries, - data_dir=STAGE12_DATA_DIR, - ) - - def build_v2_image(countries: tuple[CountryId, ...]) -> modal.Image: country_values = { country: _country_bundle(country).model_dump(mode="json") @@ -105,7 +97,12 @@ def build_v2_image(countries: tuple[CountryId, ...]) -> modal.Image: extra_options="--only-group modal-simulation-image", ) .run_commands( - bundle_install_command(countries), secrets=[data_secret, hf_secret] + bundle_data_install_command( + RESOLVED_BUNDLE.bundle.policyengine_version, + countries=countries, + data_dir=STAGE12_DATA_DIR, + ), + secrets=[data_secret, hf_secret], ) .env( {