Skip to content
Draft
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
9 changes: 9 additions & 0 deletions .github/bump_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@


def get_current_version(pyproject_path: Path) -> str:
"""Return the version in pyproject.toml, exiting with status 1 if it is absent."""
text = pyproject_path.read_text()
match = re.search(r'^version\s*=\s*"(\d+\.\d+\.\d+)"', text, re.MULTILINE)
if not match:
Expand All @@ -18,6 +19,11 @@ def get_current_version(pyproject_path: Path) -> str:


def infer_bump(changelog_dir: Path) -> str:
"""Return "major", "minor" or "patch" from the pending changelog fragment types.

Any breaking fragment makes a major bump and any added or removed fragment a
minor one; exit with status 1 when there are no fragments.
"""
fragments = [
f for f in changelog_dir.iterdir() if f.is_file() and f.name != ".gitkeep"
]
Expand All @@ -39,6 +45,7 @@ def infer_bump(changelog_dir: Path) -> str:


def bump_version(version: str, bump: str) -> str:
"""Return ``version`` raised by one ``bump`` level, resetting the lower parts."""
major, minor, patch = (int(x) for x in version.split("."))
if bump == "major":
return f"{major + 1}.0.0"
Expand All @@ -48,6 +55,7 @@ def bump_version(version: str, bump: str) -> str:


def update_file(path: Path, old_version: str, new_version: str) -> None:
"""Rewrite the ``version = "..."`` line in ``path``; leave other files untouched."""
text = path.read_text()
updated = text.replace(
f'version = "{old_version}"',
Expand All @@ -59,6 +67,7 @@ def update_file(path: Path, old_version: str, new_version: str) -> None:


def main() -> None:
"""Bump the version in pyproject.toml according to the changelog.d fragments."""
root = Path(__file__).resolve().parent.parent
pyproject = root / "pyproject.toml"
changelog_dir = root / "changelog.d"
Expand Down
14 changes: 8 additions & 6 deletions .github/find-api-model-versions.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
"""CI helper that reads the versions pinned by the installed PolicyEngine bundle.

It checks the package and data versions against the API's version constants,
then writes them to GITHUB_ENV or, with --shell, prints shell assignments.
"""

import argparse
import os
import shlex
Expand Down Expand Up @@ -48,9 +54,7 @@ def _data_release_version(bundle: dict, country_id: str) -> str:


def find_api_model_versions() -> dict[str, str]:
"""
Find and validate the package and data versions in the installed bundle.
"""
"""Find and validate the package and data versions in the installed bundle."""
bundle = get_current_bundle()
if not isinstance(bundle, dict):
raise RuntimeError("Installed PolicyEngine bundle manifest is not an object.")
Expand Down Expand Up @@ -89,9 +93,7 @@ def find_api_model_versions() -> dict[str, str]:


def find_api_model_versions_and_output_to_github() -> None:
"""
Find the API model versions and output them to a file for GitHub.
"""
"""Find the API model versions and output them to a file for GitHub."""
versions = find_api_model_versions()
with open(os.environ["GITHUB_ENV"], "a") as f:
for key, value in versions.items():
Expand Down
9 changes: 7 additions & 2 deletions .github/scripts/check_updates.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
#!/usr/bin/env python3
"""
Fetch and format changelog entries between two versions of a package.
"""Fetch and format changelog entries between two versions of a package.

Fetches CHANGELOG.md from the package's GitHub repo and extracts
entries between the old and new version, formatted as markdown.
Expand All @@ -25,6 +24,8 @@


class ChangelogEntry(TypedDict):
"""One released version's changelog items, grouped by lowercase category."""

version: str
changes: dict[str, list[str]]

Expand Down Expand Up @@ -119,6 +120,10 @@ def format_changes(entries: list[ChangelogEntry]) -> str:


def main() -> int:
"""Print the markdown changes between two versions of a package.

Always return 0, so an unreachable changelog never fails the update job.
"""
parser = argparse.ArgumentParser(
description="Fetch changelog entries between two package versions."
)
Expand Down
8 changes: 7 additions & 1 deletion .github/scripts/verify_observability_collector.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@


class VerificationProbe(NamedTuple):
"""Identifiers of one verification export, for finding its stored telemetry."""

verification_id: str
trace_id: str
started_at: float
Expand Down Expand Up @@ -315,7 +317,6 @@ def verify(
delivery_timeout_seconds: float = 600.0,
) -> VerificationProbe:
"""Export real telemetry, verify storage, and require log rejection."""

started_at = time.time()
verification_id = f"collector-verify-{uuid.uuid4().hex}"
trace_request, metric_request, log_request, trace_id = _probe_requests(
Expand Down Expand Up @@ -370,6 +371,11 @@ def verify(


def main() -> None:
"""Verify the collector, reading both tokens from the environment.

COLLECTOR_ID_TOKEN authenticates to the collector and GOOGLE_OAUTH_ACCESS_TOKEN
reads Cloud Trace and Cloud Monitoring.
"""
parser = argparse.ArgumentParser()
parser.add_argument("--endpoint", required=True)
parser.add_argument("--project-id", required=True)
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ To add or change a rule, read `docs/engineering/skills/rules-registry.md`.
- `api.quality.environment-variables-load`: Required environment variables and credentials load in a deployed-like environment. (blocking: CI job `test_env_vars`)
- `api.quality.mypy-v2`: Shared query models, FastAPI dependencies, and v2 routes and services pass mypy with untyped definitions disallowed. (blocking: mypy)
- `api.quality.ruff-format`: All Python files are formatted with ruff format. (blocking: ruff format)
- `api.quality.ruff-lint`: All Python code passes ruff check with every rule family selected in pyproject.toml, with no ratchet; families are added one themed group at a time until the selection is ALL. (blocking: ruff check (ANN, E4, E7, E9, F, FLY, FURB, I, PIE, PYI, RET, RSE, TC, UP, W))
- `api.quality.ruff-lint`: All Python code passes ruff check with every rule family selected in pyproject.toml, with no ratchet; families are added one themed group at a time until the selection is ALL. (blocking: ruff check (ANN, D, E4, E7, E9, F, FLY, FURB, I, PIE, PYI, RET, RSE, TC, UP, W))
- `api.quality.runtime-evaluated-models-resolve`: Every package model is one ruff treats as runtime-evaluated (a base resolves, through the import in its own module, to a name in runtime-evaluated-base-classes, or is a same-module class that does), and every Pydantic model and SQLAlchemy mapper resolves its annotations once the package is imported. (blocking: pytest `tests/unit/test_runtime_evaluated_annotations.py`)
- `api.quality.tests-pass`: The unit, contract and to-refactor test suites pass. (blocking: CI job `test`)
- `api.quality.tests-ruff-exemptions`: Test files may use pytest's bare assert and need no docstrings, annotations or named constants for magic numbers (S101, D1, ANN, PLR2004); every other ruff rule applies to tests. (blocking: ruff per-file ignore of ANN, D1, PLR2004, S101 for `**/tests/**`)
Expand Down
1 change: 1 addition & 0 deletions changelog.d/ruff-docstrings.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Enabled ruff's docstring rules (D) and documented every module, class and function outside tests; published models and routes now carry these descriptions in their JSON schema and OpenAPI, with no structural schema change.
15 changes: 15 additions & 0 deletions dashboard/experiments/gpt4_api_user.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
"""Streamlit experiment that answers a UK policy question with GPT-4 and the API.

It finds related parameters by embedding similarity, asks GPT-4 to write the
reform as JSON, runs it on the public PolicyEngine API, and asks GPT-4 to answer
from the resulting economic impact.
"""

import json
import os
import time
Expand All @@ -14,6 +21,7 @@

@st.cache_data
def get_embeddings_df() -> pd.DataFrame:
"""Load parameter_embeddings.csv.gz, evaluating each embedding string to a list."""
embeddings_df = pd.read_csv("parameter_embeddings.csv.gz", compression="gzip")
embeddings_df.parameter_embedding = embeddings_df.parameter_embedding.apply(
lambda x: eval(x)
Expand All @@ -23,6 +31,7 @@ def get_embeddings_df() -> pd.DataFrame:

@st.cache_data
def embed(prompt: str, engine: str = "text-embedding-ada-002") -> list[float]:
"""Return the OpenAI embedding of ``prompt``, cached across Streamlit reruns."""
return get_embedding(prompt, engine=engine)


Expand Down Expand Up @@ -59,6 +68,7 @@ def embed(prompt: str, engine: str = "text-embedding-ada-002") -> list[float]:


def write_prompt(question: str, relevant_parameters: object) -> str:
"""Build the GPT-4 prompt that rewrites ``question`` as a reform JSON object."""
return f"""Question: {question}
Here's some metadata about the parameters that are relevant to your question:
Relevant parameters: {relevant_parameters}
Expand All @@ -79,6 +89,7 @@ def write_prompt(question: str, relevant_parameters: object) -> str:

@st.cache_data
def get_gpt4_response(prompt: str) -> str:
"""Return GPT-4's reply to ``prompt`` as one user message, cached across reruns."""
return openai.ChatCompletion.create(
model="gpt-4",
messages=[{"role": "user", "content": prompt}],
Expand All @@ -94,6 +105,10 @@ def get_gpt4_response(prompt: str) -> str:

@st.cache_data
def get_policyengine_impact(reform: object) -> object:
"""Create ``reform`` on the public UK API and return its 2023 UK economic impact.

Poll every five seconds, with no time limit, while the impact is computing.
"""
payload = {"data": reform}
url = "https://api.policyengine.org/uk/policy"

Expand Down
8 changes: 4 additions & 4 deletions docs/generated/rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,7 @@ flowchart LR
| `api.quality.environment-variables-load` | Required environment variables and credentials load in a deployed-like environment. | mechanical | enforced | yes | CI job `test_env_vars` | `pr.yml:test_env_vars` | — |
| `api.quality.mypy-v2` | Shared query models, FastAPI dependencies, and v2 routes and services pass mypy with untyped definitions disallowed. | mechanical | enforced | yes | mypy | `pr.yml:typecheck-v2` | — |
| `api.quality.ruff-format` | All Python files are formatted with ruff format. | mechanical | enforced | yes | ruff format | `pr.yml:lint` | — |
| `api.quality.ruff-lint` | All Python code passes ruff check with every rule family selected in pyproject.toml, with no ratchet; families are added one themed group at a time until the selection is ALL. | mechanical | enforced | yes | ruff check (ANN, E4, E7, E9, F, FLY, FURB, I, PIE, PYI, RET, RSE, TC, UP, W) | `pr.yml:lint` | — |
| `api.quality.ruff-lint` | All Python code passes ruff check with every rule family selected in pyproject.toml, with no ratchet; families are added one themed group at a time until the selection is ALL. | mechanical | enforced | yes | ruff check (ANN, D, E4, E7, E9, F, FLY, FURB, I, PIE, PYI, RET, RSE, TC, UP, W) | `pr.yml:lint` | — |
| `api.quality.runtime-evaluated-models-resolve` | Every package model is one ruff treats as runtime-evaluated (a base resolves, through the import in its own module, to a name in runtime-evaluated-base-classes, or is a same-module class that does), and every Pydantic model and SQLAlchemy mapper resolves its annotations once the package is imported. | mechanical | enforced | yes | pytest `tests/unit/test_runtime_evaluated_annotations.py` | `pr.yml:test` | **unproven** |
| `api.quality.tests-pass` | The unit, contract and to-refactor test suites pass. | mechanical | enforced | yes | CI job `test` | `pr.yml:test` | — |
| `api.quality.tests-ruff-exemptions` | Test files may use pytest's bare assert and need no docstrings, annotations or named constants for magic numbers (S101, D1, ANN, PLR2004); every other ruff rule applies to tests. | mechanical | enforced | yes | ruff per-file ignore of ANN, D1, PLR2004, S101 for `**/tests/**` | `pr.yml:lint` | — |
Expand All @@ -289,7 +289,7 @@ flowchart LR
e_ruff_format["ruff format"]
j_CI_pr_yml_lint["CI pr.yml:lint"]
r_api_quality_ruff_lint("api.quality.ruff-lint (enforced)")
e_ruff_check__ANN__E4__E7__E9__F__FLY__FURB__I__PIE__PYI__RET__RSE__TC__UP__W_["ruff check (ANN, E4, E7, E9, F, FLY, FURB, I, PIE, PYI, RET, RSE, TC, UP, W)"]
e_ruff_check__ANN__D__E4__E7__E9__F__FLY__FURB__I__PIE__PYI__RET__RSE__TC__UP__W_["ruff check (ANN, D, E4, E7, E9, F, FLY, FURB, I, PIE, PYI, RET, RSE, TC, UP, W)"]
r_api_quality_runtime_evaluated_models_resolve("api.quality.runtime-evaluated-models-resolve (enforced)")
e_pytest_tests_unit_test_runtime_evaluated_annotations_py["pytest tests/unit/test_runtime_evaluated_annotations.py"]
j_CI_pr_yml_test["CI pr.yml:test"]
Expand All @@ -305,8 +305,8 @@ flowchart LR
e_mypy --> j_CI_pr_yml_typecheck_v2
r_api_quality_ruff_format --> e_ruff_format
e_ruff_format --> j_CI_pr_yml_lint
r_api_quality_ruff_lint --> e_ruff_check__ANN__E4__E7__E9__F__FLY__FURB__I__PIE__PYI__RET__RSE__TC__UP__W_
e_ruff_check__ANN__E4__E7__E9__F__FLY__FURB__I__PIE__PYI__RET__RSE__TC__UP__W_ --> j_CI_pr_yml_lint
r_api_quality_ruff_lint --> e_ruff_check__ANN__D__E4__E7__E9__F__FLY__FURB__I__PIE__PYI__RET__RSE__TC__UP__W_
e_ruff_check__ANN__D__E4__E7__E9__F__FLY__FURB__I__PIE__PYI__RET__RSE__TC__UP__W_ --> j_CI_pr_yml_lint
r_api_quality_runtime_evaluated_models_resolve --> e_pytest_tests_unit_test_runtime_evaluated_annotations_py
e_pytest_tests_unit_test_runtime_evaluated_annotations_py --> j_CI_pr_yml_test
r_api_quality_tests_pass --> e_CI_job_test
Expand Down
2 changes: 2 additions & 0 deletions migrations/v1/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@


def run_migrations_offline() -> None:
"""Emit the migration SQL for the configured MySQL URL without connecting."""
context.configure(
url=config.get_main_option("sqlalchemy.url"),
target_metadata=target_metadata,
Expand All @@ -52,6 +53,7 @@ def run_migrations_offline() -> None:


def run_migrations_online() -> None:
"""Migrate the connection the caller supplied, or else a new configured engine."""
if provided_connection is not None:
_run_migrations_with_connection(provided_connection)
return
Expand Down
7 changes: 6 additions & 1 deletion migrations/v2/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@ def _include_application_object(
_compare_to: SchemaItem | None,
) -> bool:
"""Keep Alembic's own version table outside application drift."""

return not (type_ == "table" and name == "alembic_version")


Expand All @@ -65,13 +64,19 @@ def _configure(connection: Connection) -> None:


def run_migrations_offline() -> None:
"""Raise, because v2 migrations need a live connection to qualify the target."""
raise RuntimeError(
"v2 migrations require an online connection so target identity and "
"generated application-data before/after states can be verified"
)


def run_migrations_online() -> None:
"""Migrate the caller's connection, or else a new engine in one transaction.

The new engine's transaction commits, so the target qualification's reads
cannot leave the completed migration to be rolled back.
"""
provided_connection = config.attributes.get("connection")
if provided_connection is not None:
_configure(provided_connection)
Expand Down
5 changes: 2 additions & 3 deletions policyengine_api/api.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
# ruff: noqa: E402
"""
This is the main Flask app for the PolicyEngine API.
"""
"""The PolicyEngine API Flask application, with its blueprints and extensions."""

import sys
import time
Expand All @@ -10,6 +8,7 @@


def log_timing(message: str) -> None:
"""Print a startup milestone to stderr with the seconds since import began."""
elapsed = time.time() - start_time
print(f"[{elapsed:.2f}s] {message}", file=sys.stderr, flush=True)

Expand Down
2 changes: 0 additions & 2 deletions policyengine_api/asgi_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,6 @@ def _matched_route_template(routes: Iterable[BaseRoute], scope: Scope) -> str |

def _native_route_template(app: FastAPI, scope: Scope) -> str | None:
"""Return the matched FastAPI route template, excluding the Flask mount."""

return _matched_route_template(app.router.routes, scope)


Expand All @@ -109,7 +108,6 @@ def create_asgi_app(
observability_runtime: ObservabilityRuntime | None = None,
) -> ASGIApp:
"""Create the Stage 2 FastAPI shell around the existing Flask app."""

if route_settings is None:
route_settings = RouteImplementationSettings.from_environment()
if dependencies is None:
Expand Down
24 changes: 20 additions & 4 deletions policyengine_api/constants.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"""API version, country package versions and bundle metadata, resolved at import."""

import hashlib
import json
from datetime import datetime
Expand Down Expand Up @@ -49,6 +51,7 @@ def _resolve_distribution_version(


def get_py_manifest() -> Any: # noqa: ANN401 # SimplePath, which open() rejects; a Path at runtime
"""Return the path of the bundle manifest in the installed policyengine wheel."""
return distribution("policyengine").locate_file(
"policyengine/data/bundle/manifest.json"
)
Expand Down Expand Up @@ -89,6 +92,7 @@ def _resolve_bundle_package_versions(bundle: dict | None) -> dict[str, str]:


def get_bundle_data_release(country_id: str) -> dict:
"""Return the bundle's data release entry for a country, or {} if it has none."""
if not isinstance(_policyengine_bundle, dict):
return {}

Expand All @@ -104,6 +108,7 @@ def get_bundle_data_release(country_id: str) -> dict:


def get_bundle_default_dataset(country_id: str) -> str | None:
"""Return the bundle's default dataset name for a country, or None if unset."""
release = get_bundle_data_release(country_id)
default_dataset = release.get("default_dataset")
if default_dataset is None:
Expand All @@ -116,6 +121,11 @@ def _bundle_dataset_display_label(default_dataset: object) -> str:


def get_bundle_default_dataset_option(country_id: str) -> dict:
"""Build the metadata dataset option for the bundle's certified default dataset.

The option carries ``dataset_uri`` and ``data_version`` only when the bundle
records them.
"""
release = get_bundle_data_release(country_id)
default_dataset = release.get("default_dataset")
label = _bundle_dataset_display_label(default_dataset)
Expand Down Expand Up @@ -185,10 +195,10 @@ def get_bundle_default_dataset_option(country_id: str) -> dict:
def _build_runtime_cache_version(
scope: str, country_id: str, caller_version: str | None = None
) -> str:
"""
Build a compact version token for cache keys stored in legacy VARCHAR(10)
columns. The token changes whenever the relevant runtime or payload schema
changes, even if the country package version is unchanged.
"""Build a compact cache version token that fits legacy VARCHAR(10) columns.

The token changes whenever the relevant runtime or payload schema changes,
even if the country package version is unchanged.
"""
schema_version = str(RUNTIME_CACHE_SCHEMA_VERSIONS[scope])
prefix = "e" if scope == "economy_impact" else "r"
Expand Down Expand Up @@ -216,10 +226,16 @@ def _build_runtime_cache_version(
def get_economy_impact_cache_version(
country_id: str, caller_version: str | None = None
) -> str:
"""Return the cache version stored with economy impacts for this country.

``caller_version`` is the country model version the request asked for; it
defaults to the installed country package version.
"""
return _build_runtime_cache_version("economy_impact", country_id, caller_version)


def get_report_output_cache_version(country_id: str) -> str:
"""Return the cache version that marks a report output current for this runtime."""
return _build_runtime_cache_version("report_output", country_id)


Expand Down
Loading
Loading