Skip to content
7 changes: 6 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ When adding, changing, or removing a rule, a guard test, a checker
configuration, or a CI job that enforces one, read
`docs/engineering/skills/rules-registry.md`.

Before adding a Pydantic model or a class that other models inherit from, or
moving an import under `if TYPE_CHECKING:`, read
`docs/engineering/skills/typing-only-imports.md`.

## Development

- Use Python 3.13 and `uv`.
Expand Down Expand Up @@ -138,7 +142,8 @@ To add or change a rule, read `docs/engineering/skills/rules-registry.md`.
- `sim.quality.every-project-tested`: Every project and library with unit tests runs them in the PR test matrix. (doc-only, not checked: follow it yourself)
- `sim.quality.ruff-format-src`: Source code under projects/*/src and libs/*/src is formatted with ruff format. (blocking: ruff format)
- `sim.quality.ruff-format-tests`: Test code is formatted with ruff format too; CI currently checks only src directories. (doc-only, not checked: follow it yourself)
- `sim.quality.ruff-lint`: All Python code passes ruff check with every rule family selected in ruff.toml, with no ratchet; families are added one themed group at a time until the selection is ALL. (blocking: ruff check (E4, E7, E9, F, FLY, FURB, I, PIE, PYI, RET, RSE, UP, W))
- `sim.quality.ruff-lint`: All Python code passes ruff check with every rule family selected in ruff.toml, with no ratchet; families are added one themed group at a time until the selection is ALL. (blocking: ruff check (E4, E7, E9, F, FLY, FURB, I, PIE, PYI, RET, RSE, TC, UP, W))
- `sim.quality.runtime-evaluated-models-resolve`: Every Pydantic model is runtime-evaluated for ruff: one of its bases, read through the imports of the model's own module, is listed in ruff.toml's runtime-evaluated-base-classes or is a runtime-evaluated class in that same module; and every Pydantic model resolves its annotations once its package is imported. (blocking: pytest `projects/policyengine-simulation-entry/tests/test_runtime_evaluated_annotations.py`, `projects/policyengine-simulation-executor/tests/test_runtime_evaluated_annotations.py`)
- `sim.quality.tests-pass`: Each project's unit tests pass in its own locked environment. (blocking: CI job `test`)

### CI workflows and scripts
Expand Down
2 changes: 2 additions & 0 deletions changelog_entry.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
- Made CLAUDE.md and the Copilot instructions thin pointers to AGENTS.md, required workflow logic longer than one line to live in tested and shellchecked scripts, and registered the tracking-issue rule
- Added a rules registry (rules/registry.toml) with a standard-library tool that validates it, checks every rule is enforced where it claims, and renders docs/generated/rules.md and the Rules block in AGENTS.md
changed:
- Enabled ruff's typing-only import rules (TC) and moved annotation-only imports under TYPE_CHECKING, keeping runtime imports for Pydantic models and FastAPI endpoints
- Listed policyengine.py's Simulation, pydantic-settings' BaseSettings and the contract's SingleYearMacroOutput as runtime-evaluated bases for ruff, imported SingleYearMacroOutput from the contract in the executor's compat models instead of through a re-export, and added executor and entrypoint tests that fail when ruff, reading a model's bases through its module's imports, would not treat a Pydantic model as runtime-evaluated, or when a model's annotations do not resolve
- Ran ruff check in CI with the first group of rule families (imports, pyupgrade and other safe auto-fixes) and fixed every finding
- Moved the multi-line `run` blocks of the simulation deployment workflows into single-line steps and tested scripts without changing their behaviour or outputs, leaving only the release-sentinel and routing-rollback blocks in the one-line `run` rule's baseline
- Moved multi-line `run` blocks in the PR, image smoke and client publishing workflows into single-line steps and tested scripts, tested the dependency-update scripts, fixed the shellcheck finding in modal-precompute.sh, and enforced the CI-script testing and shellcheck rules with empty baselines
Expand Down
3 changes: 3 additions & 0 deletions docs/engineering/skills/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,6 @@ Current skills:
isolation.
- `rules-registry.md`: how rules are registered in `rules/registry.toml`,
enforced, proven, and rendered into `docs/generated/rules.md`.
- `typing-only-imports.md`: which imports ruff moves under `TYPE_CHECKING`, how
it decides a Pydantic model is runtime-evaluated, and how to list model bases
so models keep the imports their annotations need.
101 changes: 101 additions & 0 deletions docs/engineering/skills/typing-only-imports.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
# Typing-Only Imports Skill

Read this before adding a Pydantic model, before adding a class that other
models inherit from, or before moving an import under `if TYPE_CHECKING:`.

## When an import moves

The package modules use `from __future__ import annotations`, so Python stores
every annotation as a string and never evaluates it. An import that only
annotations use is therefore not needed at runtime, and ruff's typing-only
import rules (`TC`) move it under `if TYPE_CHECKING:`, which is `False` when the
code runs and `True` for type checkers. That removes import-time work and
import cycles without changing behaviour.

## Models that read annotations at runtime

Some code does evaluate those strings at runtime:

- Pydantic builds each model's validators from its annotations, including
`pydantic-settings` settings classes.
- FastAPI reads endpoint signatures when routes are registered.

Names those annotations use must exist at runtime. `[lint.flake8-type-checking]`
in `ruff.toml` tells ruff which classes and functions these are:
`runtime-evaluated-base-classes` lists model base classes, and
`runtime-evaluated-decorators` lists the FastAPI route decorators. Ruff keeps an
import at runtime when a runtime-evaluated class or endpoint uses it in an
annotation.

If ruff does not treat a model as runtime-evaluated, it moves the model's
annotation-only imports under `TYPE_CHECKING`, and Pydantic raises "not fully
defined" the first time the model is used.

## How ruff decides a class is runtime-evaluated

Ruff reads each base of a class statement through the import statements of the
module that holds the class, and compares the qualified name it gets with
`runtime-evaluated-base-classes`:

- `from pydantic import BaseModel` makes the base `BaseModel` read as
`pydantic.BaseModel`, and so does `pydantic.BaseModel` after
`import pydantic`. A relative import resolves against the module's package.
- A subscripted base reads as the class it subscripts: `RootModel[Item]` is
`pydantic.RootModel` and `Generic[Item]` is `typing.Generic`.
- A base defined in the same module counts when it is runtime-evaluated itself.
Ruff follows inheritance through classes in the same module, however many
levels there are.
- Ruff follows nothing else. It never looks inside the module an imported base
comes from, so a base imported through a re-export reads as the re-exporting
path, and a model that subclasses a model from another module is covered only
when that base's import path is listed. A name bound by an assignment, such as
`Base = BaseModel`, does not resolve at all.

Ruff does not check whether a class is a Pydantic model: listing the right path
is the only signal it gets.

## Adding a model or a model base

- Subclass a project model by importing it from the module that defines it, and
list that path, the defining module then the class name. Don't subclass
through a re-export such as the executor's `simulation_macro_output`
compatibility module: the re-exported path is not listed.
- When you add a model class that models in other modules inherit from, add its
path to `runtime-evaluated-base-classes` in the same change.
- A third-party base is listed by the public path its subclasses import it from,
such as `pydantic_settings.BaseSettings` or policyengine.py's
`policyengine.core.Simulation`.
- A base used only by models in its own module needs no entry, as long as it
derives from a listed class.

If ruff reports a typing-only import in a module that defines a model, check the
model is runtime-evaluated before moving the import. Moving it for a model ruff
misses breaks the model at runtime.

## The guard

`sim.quality.runtime-evaluated-models-resolve` runs
`tests/test_runtime_evaluated_annotations.py` in two test suites:

- the executor's, over the executor package and the project libraries installed
with it (`policyengine_fastapi`, `policyengine_simulation_contract`,
`policyengine_simulation_observability` and
`policyengine_stage12_persistence`);
- the entrypoint's, over `policyengine_simulation_entry`.

Each imports every module of its packages and checks every Pydantic model a
module-level class statement defines:

- the model is runtime-evaluated, with its bases resolved as ruff resolves them
above; a failure names the model and the base path to list;
- the model resolves its annotations, with `model_rebuild(raise_errors=True)`.

The gateway defines no Pydantic models; it uses the contract's. The executor's
`src/modal` directory holds Modal entry files, which are not part of the
installed package and build Modal images and functions when imported, so the
guard does not import them; it checks statically that every class there derives
only from builtins and the standard library, so none can be a Pydantic model.

The two projects share no package that test helpers could live in, so the
entrypoint's test keeps a copy of the executor's resolution helpers. Change both
copies together.
19 changes: 12 additions & 7 deletions docs/generated/rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@ Every rule that governs `policyengine-sim-api` is listed here with how it is enf

| Tier | enforced | ratcheted | advisory | doc-only | Total |
| --- | ---: | ---: | ---: | ---: | ---: |
| mechanical | 32 | 1 | 0 | 14 | 47 |
| mechanical | 33 | 1 | 0 | 14 | 48 |
| metric | 0 | 0 | 0 | 0 | 0 |
| judgment | 0 | 0 | 0 | 0 | 0 |

**Blocking:** 33 of 47 rules.
**Blocking:** 34 of 48 rules.

## Gaps

Expand Down Expand Up @@ -245,7 +245,8 @@ flowchart LR
| `sim.quality.every-project-tested` | Every project and library with unit tests runs them in the PR test matrix. | mechanical | doc-only | no | nothing | — | — |
| `sim.quality.ruff-format-src` | Source code under projects/*/src and libs/*/src is formatted with ruff format. | mechanical | enforced | yes | ruff format | `pr.yml:lint` | — |
| `sim.quality.ruff-format-tests` | Test code is formatted with ruff format too; CI currently checks only src directories. | mechanical | doc-only | no | nothing | — | — |
| `sim.quality.ruff-lint` | All Python code passes ruff check with every rule family selected in ruff.toml, with no ratchet; families are added one themed group at a time until the selection is ALL. | mechanical | enforced | yes | ruff check (E4, E7, E9, F, FLY, FURB, I, PIE, PYI, RET, RSE, UP, W) | `pr.yml:lint` | — |
| `sim.quality.ruff-lint` | All Python code passes ruff check with every rule family selected in ruff.toml, with no ratchet; families are added one themed group at a time until the selection is ALL. | mechanical | enforced | yes | ruff check (E4, E7, E9, F, FLY, FURB, I, PIE, PYI, RET, RSE, TC, UP, W) | `pr.yml:lint` | — |
| `sim.quality.runtime-evaluated-models-resolve` | Every Pydantic model is runtime-evaluated for ruff: one of its bases, read through the imports of the model's own module, is listed in ruff.toml's runtime-evaluated-base-classes or is a runtime-evaluated class in that same module; and every Pydantic model resolves its annotations once its package is imported. | mechanical | enforced | yes | pytest `projects/policyengine-simulation-entry/tests/test_runtime_evaluated_annotations.py`, `projects/policyengine-simulation-executor/tests/test_runtime_evaluated_annotations.py` | `pr.yml:test` | yes |
| `sim.quality.tests-pass` | Each project's unit tests pass in its own locked environment. | mechanical | enforced | yes | CI job `test` | `pr.yml:test` | — |

```mermaid
Expand All @@ -257,16 +258,20 @@ flowchart LR
j_CI_pr_yml_lint["CI pr.yml:lint"]
r_sim_quality_ruff_format_tests("sim.quality.ruff-format-tests (doc-only)")
r_sim_quality_ruff_lint("sim.quality.ruff-lint (enforced)")
e_ruff_check__E4__E7__E9__F__FLY__FURB__I__PIE__PYI__RET__RSE__UP__W_["ruff check (E4, E7, E9, F, FLY, FURB, I, PIE, PYI, RET, RSE, UP, W)"]
e_ruff_check__E4__E7__E9__F__FLY__FURB__I__PIE__PYI__RET__RSE__TC__UP__W_["ruff check (E4, E7, E9, F, FLY, FURB, I, PIE, PYI, RET, RSE, TC, UP, W)"]
r_sim_quality_runtime_evaluated_models_resolve("sim.quality.runtime-evaluated-models-resolve (enforced)")
e_pytest_projects_policyengine_simulation_entry_tests_test_runtime_evaluated_annotations_py__projects_policyengine_simulation_executor_tests_test_runtime_evaluated_annotations_py["pytest projects/policyengine-simulation-entry/tests/test_runtime_evaluated_annotations.py, projects/policyengine-simulation-executor/tests/test_runtime_evaluated_annotations.py"]
j_CI_pr_yml_test["CI pr.yml:test"]
r_sim_quality_tests_pass("sim.quality.tests-pass (enforced)")
e_CI_job_test["CI job test"]
j_CI_pr_yml_test["CI pr.yml:test"]
r_sim_quality_every_project_tested -.-> gap_unenforced
r_sim_quality_ruff_format_src --> e_ruff_format
e_ruff_format --> j_CI_pr_yml_lint
r_sim_quality_ruff_format_tests -.-> gap_unenforced
r_sim_quality_ruff_lint --> e_ruff_check__E4__E7__E9__F__FLY__FURB__I__PIE__PYI__RET__RSE__UP__W_
e_ruff_check__E4__E7__E9__F__FLY__FURB__I__PIE__PYI__RET__RSE__UP__W_ --> j_CI_pr_yml_lint
r_sim_quality_ruff_lint --> e_ruff_check__E4__E7__E9__F__FLY__FURB__I__PIE__PYI__RET__RSE__TC__UP__W_
e_ruff_check__E4__E7__E9__F__FLY__FURB__I__PIE__PYI__RET__RSE__TC__UP__W_ --> j_CI_pr_yml_lint
r_sim_quality_runtime_evaluated_models_resolve --> e_pytest_projects_policyengine_simulation_entry_tests_test_runtime_evaluated_annotations_py__projects_policyengine_simulation_executor_tests_test_runtime_evaluated_annotations_py
e_pytest_projects_policyengine_simulation_entry_tests_test_runtime_evaluated_annotations_py__projects_policyengine_simulation_executor_tests_test_runtime_evaluated_annotations_py --> j_CI_pr_yml_test
r_sim_quality_tests_pass --> e_CI_job_test
e_CI_job_test --> j_CI_pr_yml_test
```
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
from __future__ import annotations

from collections.abc import Mapping
from contextlib import AbstractContextManager
from dataclasses import dataclass, field
from typing import Any, Protocol
from typing import TYPE_CHECKING, Any, Protocol

from .config import ObservabilityConfig
from .contracts import SimulationLifecycleEvent, TracerArtifactManifest

if TYPE_CHECKING:
from collections.abc import Mapping

from .contracts import SimulationLifecycleEvent, TracerArtifactManifest


class NoOpSpan(AbstractContextManager["NoOpSpan"]):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from __future__ import annotations

from typing import cast
from typing import TYPE_CHECKING, cast
from uuid import UUID

import pytest
Expand All @@ -28,7 +28,9 @@
UKLocalAuthorityBoundaryVersion,
UKLocalAuthorityMetadata,
)
from pydantic import JsonValue

if TYPE_CHECKING:
from pydantic import JsonValue


def _plan() -> Stage12OutputPlan:
Expand Down Expand Up @@ -94,7 +96,7 @@ def _spm_comparison() -> dict[str, JsonValue]:
),
)
return cast(
dict[str, JsonValue],
"dict[str, JsonValue]",
build_spm_comparison_provenance(
baseline_receipts=(receipt,),
reform_receipts=(receipt,),
Expand Down Expand Up @@ -254,8 +256,8 @@ def test_aggregate_artifact_accepts_only_canonical_spm_provenance() -> None:
)

mismatched = _spm_comparison()
reform = cast(dict[str, JsonValue], mismatched["reform"])
receipt = cast(dict[str, JsonValue], reform["receipt"])
reform = cast("dict[str, JsonValue]", mismatched["reform"])
receipt = cast("dict[str, JsonValue]", reform["receipt"])
receipt["years"] = ["2027"]
with pytest.raises(ValueError, match="baseline and reform"):
AggregateReportArtifactPayload(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,10 @@

import logging
import uuid
from typing import Any
from typing import TYPE_CHECKING, Any

from policyengine_observability import ObservabilityRuntime
if TYPE_CHECKING:
from policyengine_observability import ObservabilityRuntime

logger = logging.getLogger(__name__)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,8 @@
import os
from dataclasses import replace
from importlib.metadata import PackageNotFoundError, version
from typing import Literal
from typing import TYPE_CHECKING, Literal

from fastapi import FastAPI
from policyengine_observability import (
DeploymentIdentity,
GoogleCloudLogDestination,
Expand All @@ -21,6 +20,9 @@
process_instance_id,
)

if TYPE_CHECKING:
from fastapi import FastAPI

Platform = Literal["google_cloud_run", "modal", "local", "other"]

MODAL_IMAGE_ENV_NAMES = (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,13 @@

from __future__ import annotations

from collections.abc import Mapping
from dataclasses import dataclass
from enum import StrEnum
from types import MappingProxyType
from typing import TYPE_CHECKING

if TYPE_CHECKING:
from collections.abc import Mapping


class RunConfiguration(StrEnum):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,9 @@

from __future__ import annotations

from collections.abc import Callable, Iterator
from contextlib import contextmanager
from datetime import datetime
from enum import StrEnum
from typing import Any, Protocol
from uuid import UUID
from typing import TYPE_CHECKING, Any, Protocol

from policyengine_simulation_contract.stage12_execution import (
ComparisonReportPersistenceResult,
Expand Down Expand Up @@ -43,6 +40,11 @@
require_successful_simulation_replay,
)

if TYPE_CHECKING:
from collections.abc import Callable, Iterator
from datetime import datetime
from uuid import UUID

REPORT_IDENTITY_COLUMNS = (
"environment",
"calculation_flow",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

from datetime import UTC, datetime, timedelta
from typing import TYPE_CHECKING
from uuid import uuid4

from sqlalchemy import (
Expand All @@ -17,7 +18,6 @@
update,
)
from sqlalchemy.dialects.postgresql import ENUM, UUID, insert
from sqlalchemy.engine import Connection, Engine
from sqlalchemy.exc import SQLAlchemyError

from policyengine_stage12_persistence.tables import (
Expand All @@ -31,6 +31,9 @@
comparison_simulations,
)

if TYPE_CHECKING:
from sqlalchemy.engine import Connection, Engine

REQUIRED_TABLE_PRIVILEGES = ("SELECT", "INSERT", "UPDATE", "DELETE")
FORBIDDEN_TABLE_PRIVILEGES = ("TRUNCATE", "REFERENCES", "TRIGGER")

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from __future__ import annotations

from collections.abc import Iterable
from typing import TYPE_CHECKING

from policyengine_simulation_contract.stage12_execution import (
ComparisonReportRecord,
Expand All @@ -12,6 +12,9 @@
ResultComparisonStatus,
)

if TYPE_CHECKING:
from collections.abc import Iterable

REPORT_IMMUTABLE_FIELDS = (
"evaluation_id",
"contract_version",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,7 @@ def record_sleep(seconds: float) -> None:
)
monkeypatch.setattr(time, "sleep", record_sleep)
result = poll_for_completion(
cast(Client, object()),
cast("Client", object()),
"fc-test",
max_wait_seconds=max_wait_seconds,
poll_interval=poll_interval,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,19 @@

import logging
from functools import lru_cache
from typing import TYPE_CHECKING

import jwt
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from policyengine_observability import ObservabilityRuntime

from policyengine_simulation_entry.config import Settings
from policyengine_simulation_entry.schemas import CallerIdentity

if TYPE_CHECKING:
from policyengine_observability import ObservabilityRuntime

from policyengine_simulation_entry.config import Settings

_bearer = HTTPBearer(auto_error=False)
logger = logging.getLogger(__name__)

Expand Down
Loading
Loading