Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
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
6 changes: 5 additions & 1 deletion .github/scripts/update_policyengine_package.py
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,6 @@ def verify_dispatch() -> None:
promotable is refused. ``dispatch_verified`` is true only when at least
one field was compared. Prints digests for the PR body.
"""

raw = os.environ.get("RELEASE_PAYLOAD", "").strip()
payload = json.loads(raw) if raw and raw != "null" else {}
installed = {}
Expand Down Expand Up @@ -379,6 +378,11 @@ def _parser() -> argparse.ArgumentParser:


def main() -> None:
"""Run one subcommand for update-policyengine-package.sh.

Results go to stdout for the shell script to capture; a failed check exits
non-zero through ``SystemExit`` with the reason as its message.
"""
args = _parser().parse_args()
if args.command == "current-version":
print(current_version(args.pyproject, args.package))
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ 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 (ANN, E4, E7, E9, F, FLY, FURB, I, PIE, PYI, RET, RSE, TC, 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 (ANN, D, E4, E7, E9, F, FLY, FURB, I, PIE, PYI, RET, RSE, TC, UP, W))
- `sim.quality.tests-pass`: Each project's unit tests pass in its own locked environment. (blocking: CI job `test`)
- `sim.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_entry.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
- 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 docstring rules (D) and documented every module, class and function outside tests; published contract models and routes now carry these descriptions in their JSON schema and OpenAPI, with no structural schema change
- Enabled ruff's annotation rules (ANN) and annotated every function outside tests; test files are exempt from ANN, missing docstrings, magic numbers and bare-assert rules
- 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
- Ran ruff check in CI with the first group of rule families (imports, pyupgrade and other safe auto-fixes) and fixed every finding
Expand Down
8 changes: 4 additions & 4 deletions docs/generated/rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,7 @@ 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 (ANN, E4, E7, E9, F, FLY, FURB, I, PIE, PYI, RET, RSE, TC, 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 (ANN, D, E4, E7, E9, F, FLY, FURB, I, PIE, PYI, RET, RSE, TC, UP, W) | `pr.yml:lint` | — |
| `sim.quality.tests-pass` | Each project's unit tests pass in its own locked environment. | mechanical | enforced | yes | CI job `test` | `pr.yml:test` | — |
| `sim.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 @@ -258,7 +258,7 @@ 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__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_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"]
Expand All @@ -268,8 +268,8 @@ flowchart LR
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__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_sim_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_sim_quality_tests_pass --> e_CI_job_test
e_CI_job_test --> j_CI_pr_yml_test
r_sim_quality_tests_ruff_exemptions --> e_ruff_per_file_ignore_of_ANN__D1__PLR2004__S101_for____tests___
Expand Down
2 changes: 2 additions & 0 deletions libs/policyengine-fastapi/fixtures/ping/shared.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,13 @@

@pytest.fixture
def health_registry() -> HealthRegistry:
"""Provide an empty registry; it reports healthy until a test registers a system."""
return HealthRegistry()


@pytest.fixture
def client(health_registry: HealthRegistry) -> TestClient:
"""Return a client for a bare app serving ping routes from ``health_registry``."""
api = FastAPI()
ping.include_all_routers(api, health_registry)
return TestClient(api)
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
"""
Re-usable elements that support FastAPI and SQLModel indipendent of this specific app.
"""
"""Reusable FastAPI and SQLModel building blocks that are independent of any one app."""

from .observability import (
ObservabilityConfig as ObservabilityConfig,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"""Bearer-token authentication dependencies for FastAPI routes."""

from .jwt_decoder import JWTDecoder

__all__ = ["JWTDecoder"]
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"""FastAPI dependency that verifies RS256 bearer JWTs against an issuer's JWKS."""

import logging

import jwt
Expand All @@ -11,12 +13,19 @@


class JWTDecoder:
"""
Parse and verify the JWT, verifying the correct issuer and audience and returning
the parsed token with all fields.
"""Callable FastAPI dependency that verifies a bearer JWT and returns its claims.

Tokens must be RS256-signed by a key from the issuer's JWKS and carry the
configured issuer and audience. A missing or invalid token raises HTTP 403,
or yields None when ``auto_error`` is False.
"""

def __init__(self, issuer: str, audience: str, auto_error: bool = True) -> None:
"""Store the expected claims and create a JWKS client for the issuer.

Keys are fetched from ``{issuer}.well-known/jwks.json``, so ``issuer``
must end with a slash.
"""
self.issuer = issuer
self.audience = audience
self.auto_error = auto_error
Expand All @@ -29,6 +38,11 @@ def __call__(
HTTPBearer(auto_error=False)
),
) -> dict[str, str] | None:
"""Return the verified token's claims.

A missing or invalid token raises HTTP 403, or returns None when
``auto_error`` is False.
"""
try:
return self._validate(token)
except HTTPException as err:
Expand Down
14 changes: 9 additions & 5 deletions libs/policyengine-fastapi/src/policyengine_fastapi/database.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"""SQLModel engine and per-request session dependency helpers."""

from collections.abc import Callable, Generator
from typing import Any

Expand All @@ -13,8 +15,10 @@


def create_sqlite_engine(filename: str | None = None) -> Engine:
"""
For desktop and testing, use sqlite to back sqlmodel
"""Create a SQLite engine for desktop use and tests.

Without ``filename`` the database is in memory; StaticPool keeps that single
in-memory connection shared across threads.
"""
sqlite_url = f"sqlite:///{filename}" if filename else "sqlite:///:memory:"
connect_args = {"check_same_thread": False}
Expand All @@ -25,9 +29,9 @@ def create_sqlite_engine(filename: str | None = None) -> Engine:


def create_session_dep(engine: Engine) -> SessionGeneratorFactory:
"""
given an SQLModelEngine create a session dependency for use in
routers.
"""Return a FastAPI dependency that yields one SQLModel session per request.

The session is bound to ``engine`` and closed when the request finishes.
"""

def session_dep() -> Generator[Session]:
Expand Down
17 changes: 14 additions & 3 deletions libs/policyengine-fastapi/src/policyengine_fastapi/exit.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
"""
atexit does not work with uvicorn/fastapi for whatever reason and fastapi does not provide
a middleware-style way to register lifecycle events so I'm writing this
"""Process-wide registry of shutdown callbacks run at the end of the app lifespan.

atexit handlers do not run reliably under uvicorn/FastAPI, and FastAPI has no
middleware-style way to register lifecycle events, so callbacks are registered
here and run from the app's lifespan instead.
"""

import logging
Expand All @@ -14,10 +16,18 @@


class AppExit:
"""Collect callbacks to run once, in registration order, when the app shuts down."""

def __init__(self) -> None:
"""Start with no registered callbacks."""
self.callbacks: list[Callable[[], None]] = []

def __call__(self, *args: object, **kwargs: object) -> Callable[[T], T]:
"""Return a decorator that registers a function to run with these args at exit.

The decorated function is returned unchanged.
"""

def accept(func: T) -> T:
self.callbacks.append(lambda: func(*args, **kwargs))
return func
Expand All @@ -32,6 +42,7 @@ def _exit(self) -> None:

@asynccontextmanager
async def lifespan(self) -> AsyncIterator[None]:
"""Run every registered callback once the lifespan body exits without error."""
yield
self._exit()

Expand Down
Original file line number Diff line number Diff line change
@@ -1,21 +1,29 @@
"""Health models and the registry behind the ``/ping/alive`` probe."""

from collections.abc import Callable

from pydantic import BaseModel


class ProbeStatus(BaseModel):
"""Result of one health probe, with an optional explanatory message."""

name: str
healthy: bool
message: str | None = None


class SystemStatus(BaseModel):
"""Health of one system; it is healthy only if every probe in ``detail`` is."""

name: str
healthy: bool
detail: list[ProbeStatus]


class HealthStatus(BaseModel):
"""Health of the service; it is healthy only if every system is."""

healthy: bool
systems: list[SystemStatus]

Expand All @@ -24,11 +32,15 @@ class HealthStatus(BaseModel):


class HealthSystemReporter:
"""Named group of health probes reported together as one system."""

def __init__(self, name: str, probes: dict[str, HealthProbe]) -> None:
"""Hold the system name and its probes, keyed by probe name."""
self.name = name
self.probes = probes

def report(self) -> SystemStatus:
"""Run every probe; the system is healthy only if all of its probes are."""
detail = [self.probes[key]() for key in self.probes.keys()]
first_unhealthy = next((d for d in detail if d.healthy is not True), None)
return SystemStatus(
Expand All @@ -37,21 +49,24 @@ def report(self) -> SystemStatus:


class HealthRegistry:
"""
Mechanism for collecting and reporting sevice health.
"""Registry of system reporters that together make up the service's health.

NOTE: any error will result in the service being restarted
so only report real errors.
"""

systems: dict[str, HealthSystemReporter]

def __init__(self) -> None:
"""Start with no systems, which reports as healthy."""
self.systems = {}

def register(self, reporter: HealthSystemReporter) -> None:
"""Add a system reporter, replacing any registered under the same name."""
self.systems[reporter.name] = reporter

def report(self) -> HealthStatus:
"""Report every system; the service is healthy only if all systems are."""
system_status = [self.systems[key].report() for key in self.systems.keys()]
first_unhealthy = next(
(s for s in system_status if s.healthy is not True), None
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
"""
Shared observability contracts and utilities for the PolicyEngine simulation API services.
"""Shared observability contracts and utilities for the PolicyEngine simulation API services.

This stays in `policyengine-fastapi` for now because only one active service
needs it and the main value today is shared contract stability, not a separate
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"""Settings for the shared observability emitters."""

from __future__ import annotations

from dataclasses import dataclass, field
Expand All @@ -8,7 +10,6 @@

def parse_header_value_pairs(raw: str | None) -> dict[str, str]:
"""Parse OTLP headers from a comma or newline separated key=value string."""

if raw is None:
return {}

Expand All @@ -33,6 +34,12 @@ def parse_header_value_pairs(raw: str | None) -> dict[str, str]:

@dataclass(frozen=True)
class ObservabilityConfig:
"""Immutable observability settings.

Covers whether emission is on, where OTLP telemetry and tracer artifacts go,
and when tracer output is captured. The defaults describe disabled emission.
"""

enabled: bool = False
shadow_mode: bool = True
service_name: str = "policyengine-observability"
Expand All @@ -46,4 +53,5 @@ class ObservabilityConfig:

@classmethod
def disabled(cls, service_name: str = "policyengine-observability") -> Self:
"""Return a config with emission off, setting only the service name."""
return cls(enabled=False, service_name=service_name)
Loading
Loading