Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,8 @@ update:
(cd "$$dir" && uv lock --upgrade); \
fi \
done
@echo "Re-exporting Modal image requirements from uv.lock..."
@./scripts/export-modal-image-requirements.sh
@echo "✅ All dependencies updated"

# Code quality
Expand Down
17 changes: 17 additions & 0 deletions projects/policyengine-api-simulation/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,23 @@

PolicyEngine Simulation API service.

## Modal image dependencies

The Modal images (gateway in `src/modal/gateway/app.py`, base simulation
image in `src/modal/app.py`) install from pinned requirements files under
`requirements/`, exported from the `modal-simulation-image` and
`modal-gateway-image` dependency groups in `pyproject.toml`/`uv.lock`.
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).

To change image dependencies, edit the dependency group, then run
`uv lock` and `scripts/export-modal-image-requirements.sh` (or
`make update`, which relocks and re-exports everything). CI fails if the
exports drift from the lock (`tests/test_modal_image_requirements.py`).
Note that any change to the exports invalidates the image layer cache,
including the dataset prebuild layer below.

## Temporary: prebuilt single-year datasets in the Modal image

The Modal image prebuilds single-year datasets (2025–2027, US national
Expand Down
29 changes: 29 additions & 0 deletions projects/policyengine-api-simulation/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,35 @@ dependencies = [
[tool.hatch.build.targets.wheel]
packages = ["src/policyengine_api_simulation", "src/policyengine_api"]

# Modal image dependencies. Resolved inside uv.lock together with the
# project, then exported to requirements/modal-*.txt (see
# scripts/export-modal-image-requirements.sh); the images install those
# pinned exports so their packages can only change through a relock and
# always match the versions the test environment runs against.
[dependency-groups]
modal-simulation-image = [
"uv",
"fastapi>=0.115.0",
"tables>=3.10.2",
"logfire>=3.0.0",
# logfire imports importlib_metadata unconditionally on Python 3.13
# but does not declare it (see issue #602).
"importlib-metadata>=8",
"policyengine-observability[fastapi]>=1.3.0,<2",
]
modal-gateway-image = [
"fastapi>=0.115.0",
"pydantic>=2.0",
# PyJWT powers the bearer-token decoder in gateway.auth.
"pyjwt>=2.10.1,<3.0.0",
# JWTDecoder lives in the policyengine-fastapi lib; it only needs
# the auth module at runtime here.
"cryptography>=41.0.0",
"logfire>=3.0.0",
"importlib-metadata>=8",
"policyengine-observability[fastapi]>=1.3.0,<2",
]

[tool.uv.sources]
policyengine-fastapi = { path = "../../libs/policyengine-fastapi", editable = true }

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# This file was autogenerated by uv via the following command:
# uv export --only-group modal-gateway-image --frozen --no-hashes --no-annotate --output-file requirements/modal-gateway-image.txt
annotated-types==0.7.0
anyio==4.13.0
asgiref==3.11.1
certifi==2026.4.22
cffi==2.0.0 ; platform_python_implementation != 'PyPy'
charset-normalizer==3.4.7
cryptography==46.0.7
deprecated==1.3.1
executing==2.2.1
fastapi==0.115.14
googleapis-common-protos==1.74.0
grpcio==1.80.0
idna==3.13
importlib-metadata==8.5.0
logfire==4.6.0
markdown-it-py==4.0.0
mdurl==0.1.2
opentelemetry-api==1.30.0
opentelemetry-exporter-otlp-proto-common==1.30.0
opentelemetry-exporter-otlp-proto-grpc==1.30.0
opentelemetry-exporter-otlp-proto-http==1.30.0
opentelemetry-instrumentation==0.51b0
opentelemetry-instrumentation-asgi==0.51b0
opentelemetry-instrumentation-fastapi==0.51b0
opentelemetry-instrumentation-httpx==0.51b0
opentelemetry-proto==1.30.0
opentelemetry-sdk==1.30.0
opentelemetry-semantic-conventions==0.51b0
opentelemetry-util-http==0.51b0
packaging==26.1
policyengine-observability==1.3.0
protobuf==5.29.6
pycparser==3.0 ; implementation_name != 'PyPy' and platform_python_implementation != 'PyPy'
pydantic==2.13.3
pydantic-core==2.46.3
pygments==2.20.0
pyjwt==2.12.1
requests==2.33.1
rich==15.0.0
starlette==0.46.2
typing-extensions==4.15.0
typing-inspection==0.4.2
urllib3==2.6.3
wrapt==1.17.3
zipp==3.23.1
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# This file was autogenerated by uv via the following command:
# uv export --only-group modal-simulation-image --frozen --no-hashes --no-annotate --output-file requirements/modal-simulation-image.txt
annotated-types==0.7.0
anyio==4.13.0
asgiref==3.11.1
blosc2==4.1.2
certifi==2026.4.22
charset-normalizer==3.4.7
deprecated==1.3.1
executing==2.2.1
fastapi==0.115.14
googleapis-common-protos==1.74.0
grpcio==1.80.0
idna==3.13
importlib-metadata==8.5.0
logfire==4.6.0
markdown-it-py==4.0.0
mdurl==0.1.2
msgpack==1.1.2
ndindex==1.10.1
numexpr==2.14.1
numpy==2.4.4
opentelemetry-api==1.30.0
opentelemetry-exporter-otlp-proto-common==1.30.0
opentelemetry-exporter-otlp-proto-grpc==1.30.0
opentelemetry-exporter-otlp-proto-http==1.30.0
opentelemetry-instrumentation==0.51b0
opentelemetry-instrumentation-asgi==0.51b0
opentelemetry-instrumentation-fastapi==0.51b0
opentelemetry-instrumentation-httpx==0.51b0
opentelemetry-proto==1.30.0
opentelemetry-sdk==1.30.0
opentelemetry-semantic-conventions==0.51b0
opentelemetry-util-http==0.51b0
packaging==26.1
policyengine-observability==1.3.0
protobuf==5.29.6
py-cpuinfo==9.0.0
pydantic==2.13.3
pydantic-core==2.46.3
pygments==2.20.0
requests==2.33.1
rich==15.0.0
starlette==0.46.2
tables==3.11.1
typing-extensions==4.15.0
typing-inspection==0.4.2
urllib3==2.6.3
uv==0.11.26
wrapt==1.17.3
zipp==3.23.1
18 changes: 12 additions & 6 deletions projects/policyengine-api-simulation/src/modal/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

import modal
import os
from pathlib import Path

from policyengine_observability import operation, set_attribute

Expand Down Expand Up @@ -139,12 +140,17 @@ def build_base_simulation_image() -> modal.Image:
"""
return (
modal.Image.debian_slim(python_version="3.13")
.pip_install(
"uv",
"fastapi>=0.115.0",
"tables>=3.10.2",
"logfire>=3.0.0",
"policyengine-observability[fastapi]>=1.3.0,<2",
# Pinned export of the modal-simulation-image dependency group in
# uv.lock, so image packages match the tested environment and can
# only change through a relock. Regenerate with
# scripts/export-modal-image-requirements.sh after editing the
# group or relocking.
.pip_install_from_requirements(
str(
Path(__file__).resolve().parents[2]
/ "requirements"
/ "modal-simulation-image.txt"
)
)
.run_commands(
bundle_install_command(POLICYENGINE_VERSION),
Expand Down
22 changes: 12 additions & 10 deletions projects/policyengine-api-simulation/src/modal/gateway/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"""

import modal
from pathlib import Path

from src.modal.logfire_legacy import configure_logfire

Expand All @@ -20,16 +21,17 @@
# Lightweight image for gateway - no heavy dependencies
gateway_image = (
modal.Image.debian_slim(python_version="3.13")
.pip_install(
"fastapi>=0.115.0",
"pydantic>=2.0",
# PyJWT powers the bearer-token decoder in gateway.auth.
"pyjwt>=2.10.1,<3.0.0",
# JWTDecoder lives in the policyengine-fastapi lib; it only needs
# the auth module at runtime here.
"cryptography>=41.0.0",
"logfire>=3.0.0",
"policyengine-observability[fastapi]>=1.3.0,<2",
# Pinned export of the modal-gateway-image dependency group in
# uv.lock, so image packages match the tested environment and can
# only change through a relock. Regenerate with
# scripts/export-modal-image-requirements.sh after editing the group
# or relocking.
.pip_install_from_requirements(
str(
Path(__file__).resolve().parents[3]
/ "requirements"
/ "modal-gateway-image.txt"
)
)
.add_local_python_source(
"src.modal",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,18 @@
import importlib
import sys
from pathlib import Path
from types import ModuleType


def requirements_package_names(path):
"""Package names pinned in an exported requirements file."""
return {
line.split(";")[0].split("==")[0].strip()
for line in Path(path).read_text().splitlines()
if line.strip() and not line.lstrip().startswith("#")
}


class FakeImage:
def __init__(self):
self.calls = []
Expand All @@ -17,6 +27,12 @@ def pip_install(self, *packages):
self.calls.append(("pip_install", packages))
return self

def pip_install_from_requirements(self, requirements_txt, **kwargs):
self.calls.append(
("pip_install_from_requirements", requirements_txt, kwargs)
)
return self

def run_commands(self, *commands, **kwargs):
self.calls.append(("run_commands", commands, kwargs))
return self
Expand Down Expand Up @@ -88,13 +104,23 @@ def test_modal_image_uses_policyengine_bundle_install(monkeypatch):
"/.policyengine-bundle-receipt.json"
)
assert command_calls[0][2]["secrets"] == [app.data_secret, app.hf_secret]
pip_install_calls = [
call for call in app.simulation_image.calls if call[0] == "pip_install"
requirements_calls = [
call
for call in app.simulation_image.calls
if call[0] == "pip_install_from_requirements"
]
assert pip_install_calls
packages = pip_install_calls[0][1]
assert "policyengine-observability[fastapi]>=1.3.0,<2" in packages
assert "logfire>=3.0.0" in packages
assert requirements_calls
requirements_path = Path(requirements_calls[0][1])
assert requirements_path.name == "modal-simulation-image.txt"
packages = requirements_package_names(requirements_path)
assert "policyengine-observability" in packages
assert "logfire" in packages
# logfire needs importlib_metadata at import time on Python 3.13 but
# does not declare it; the pinned export must keep providing it or
# every worker crashes on ``import logfire``.
assert "importlib-metadata" in packages
# uvx drives the policyengine bundle install into the image.
assert "uv" in packages

runtime_secret_sets = {
name: kwargs["secrets"] for name, kwargs in app.app.function_calls
Expand Down Expand Up @@ -157,13 +183,22 @@ def test_gateway_image_installs_dual_observability(monkeypatch):

app = importlib.import_module("src.modal.gateway.app")

pip_install_calls = [
call for call in app.gateway_image.calls if call[0] == "pip_install"
requirements_calls = [
call
for call in app.gateway_image.calls
if call[0] == "pip_install_from_requirements"
]
assert pip_install_calls
packages = pip_install_calls[0][1]
assert "policyengine-observability[fastapi]>=1.3.0,<2" in packages
assert "logfire>=3.0.0" in packages
assert requirements_calls
requirements_path = Path(requirements_calls[0][1])
assert requirements_path.name == "modal-gateway-image.txt"
packages = requirements_package_names(requirements_path)
assert "policyengine-observability" in packages
assert "logfire" in packages
# Same importlib_metadata gap as the simulation image: without this the
# gateway ASGI factory dies in configure_logfire and every request 303s.
assert "importlib-metadata" in packages
assert "pyjwt" in packages
assert "cryptography" in packages

function_kwargs = {name: kwargs for name, kwargs in app.app.function_calls}
assert function_kwargs["web_app"]["secrets"] == [
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
"""The Modal images install pinned exports of uv.lock dependency groups.

These tests fail when the checked-in requirements files drift from
uv.lock — rerun scripts/export-modal-image-requirements.sh (make update
does it automatically). They also guard the regression from issue #602:
the exports must keep pinning logfire's undeclared importlib_metadata
runtime dependency.
"""

import subprocess
from pathlib import Path

import pytest

PROJECT_ROOT = Path(__file__).resolve().parents[1]
GROUPS = ("modal-simulation-image", "modal-gateway-image")


def package_lines(text):
return [
line.strip()
for line in text.splitlines()
if line.strip() and not line.lstrip().startswith("#")
]


@pytest.mark.parametrize("group", GROUPS)
def test_checked_in_export_matches_lock(group):
checked_in = PROJECT_ROOT / "requirements" / f"{group}.txt"
assert checked_in.exists(), (
f"{checked_in} is missing; run scripts/export-modal-image-requirements.sh"
)
exported = subprocess.run(
[
"uv",
"export",
"--only-group",
group,
"--frozen",
"--no-hashes",
"--no-annotate",
],
cwd=PROJECT_ROOT,
check=True,
capture_output=True,
text=True,
).stdout
assert package_lines(checked_in.read_text()) == package_lines(exported), (
f"requirements/{group}.txt is stale relative to uv.lock; "
"run scripts/export-modal-image-requirements.sh"
)


@pytest.mark.parametrize("group", GROUPS)
def test_export_is_fully_pinned(group):
checked_in = PROJECT_ROOT / "requirements" / f"{group}.txt"
for line in package_lines(checked_in.read_text()):
requirement = line.split(";")[0].strip()
assert "==" in requirement, f"unpinned requirement in {group}: {line}"


@pytest.mark.parametrize("group", GROUPS)
def test_export_pins_logfire_runtime_deps(group):
checked_in = PROJECT_ROOT / "requirements" / f"{group}.txt"
names = {
line.split(";")[0].split("==")[0].strip()
for line in package_lines(checked_in.read_text())
}
assert {"logfire", "importlib-metadata"} <= names
Loading
Loading