diff --git a/AGENTS.md b/AGENTS.md index cf573a889..3415e4f33 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,6 +32,10 @@ names, logging, or observability failure handling, read When modifying the `Makefile`, read `docs/engineering/skills/repository-maintenance.md`. +When adding a Pydantic, SQLModel or SQLAlchemy model class that other models +inherit from, or moving an import under `if TYPE_CHECKING:`, read +`docs/engineering/skills/typing-only-imports.md`. + When adding or changing a GitHub Actions workflow step or a script under `.github/scripts/`, read `docs/engineering/skills/ci-workflows.md`. @@ -122,7 +126,8 @@ 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 (E4, E7, E9, F, FLY, FURB, I, PIE, PYI, RET, RSE, 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 (E4, E7, E9, F, FLY, FURB, I, PIE, PYI, RET, RSE, TC, UP, W)) +- `api.quality.runtime-evaluated-models-resolve`: Every package model class that other models inherit from is listed in ruff's runtime-evaluated-base-classes, 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`) ### Observability diff --git a/changelog.d/ruff-typing-imports.changed.md b/changelog.d/ruff-typing-imports.changed.md new file mode 100644 index 000000000..5a4a5eda7 --- /dev/null +++ b/changelog.d/ruff-typing-imports.changed.md @@ -0,0 +1 @@ +Enabled ruff's typing-only import rules (TC) and moved annotation-only imports under TYPE_CHECKING, keeping runtime imports for Pydantic, SQLModel, SQLAlchemy and FastAPI; a new test fails if a model base is missing from ruff's runtime-evaluated list or any model's annotations don't resolve. diff --git a/docs/engineering/skills/README.md b/docs/engineering/skills/README.md index 47001dd72..709838f8a 100644 --- a/docs/engineering/skills/README.md +++ b/docs/engineering/skills/README.md @@ -27,5 +27,8 @@ Current skills: enforced, proven, and rendered into `docs/generated/rules.md`. - `testing.md`: focused test commands and dependency boundaries for migration work. +- `typing-only-imports.md`: when ruff moves imports under `TYPE_CHECKING`, and + listing model bases whose annotations Pydantic, SQLModel and SQLAlchemy read + at runtime. - `v2-code-organization.md`: mandatory API v2 route, service, and database-access package boundaries and literal module-naming rules. diff --git a/docs/engineering/skills/typing-only-imports.md b/docs/engineering/skills/typing-only-imports.md new file mode 100644 index 000000000..f3e66b4e8 --- /dev/null +++ b/docs/engineering/skills/typing-only-imports.md @@ -0,0 +1,44 @@ +# Typing-Only Imports Skill + +Read this before adding a Pydantic, SQLModel or SQLAlchemy model class that other +models inherit from, or before moving an import under `if TYPE_CHECKING:`. + +## When an import moves + +The package's 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 libraries do evaluate those strings at runtime: + +- Pydantic and SQLModel build each model's validators from its annotations. +- SQLAlchemy resolves `Mapped[...]` columns when a mapped class is configured. +- FastAPI reads endpoint signatures when routes are registered. + +Names those annotations use must exist at runtime. Ruff keeps a class's +annotation imports at runtime only when one of the class's **direct** bases is +listed in `runtime-evaluated-base-classes`, or when an endpoint's decorator is +listed in `runtime-evaluated-decorators`, under `[tool.ruff.lint.flake8-type-checking]` +in `pyproject.toml`. Ruff does not follow inheritance, so every package class +that other models inherit from is listed by name. + +## Adding a model base + +When you add a model class that other models inherit from, add its fully +qualified name (module where it is defined, then class name) to +`runtime-evaluated-base-classes` in the same change. + +If ruff reports a typing-only import in a model, do not move the import until you +have checked that the model's base is listed. Moving it for a model whose base +is missing breaks the model at runtime: Pydantic raises "not fully defined" the +first time the model is used, and SQLAlchemy fails to configure its mappers. + +`tests/unit/test_runtime_evaluated_annotations.py` enforces both halves +(`api.quality.runtime-evaluated-models-resolve`): every package model base is +listed, and every Pydantic model and SQLAlchemy mapper resolves its annotations +once the package is imported. diff --git a/docs/generated/rules.md b/docs/generated/rules.md index 42149b814..d155a209b 100644 --- a/docs/generated/rules.md +++ b/docs/generated/rules.md @@ -8,11 +8,11 @@ Every rule that governs `policyengine-api` is listed here with how it is enforce | Tier | enforced | ratcheted | advisory | doc-only | Total | | --- | ---: | ---: | ---: | ---: | ---: | -| mechanical | 41 | 0 | 1 | 12 | 54 | +| mechanical | 42 | 0 | 1 | 12 | 55 | | metric | 0 | 0 | 0 | 0 | 0 | | judgment | 0 | 0 | 0 | 0 | 0 | -**Blocking:** 41 of 54 rules. +**Blocking:** 42 of 55 rules. ## Gaps @@ -44,6 +44,7 @@ Every rule that governs `policyengine-api` is listed here with how it is enforce - `api.metadata.read-modules-isolated` - `api.migration.contracts-consistent` - `api.process.rules-registry` +- `api.quality.runtime-evaluated-models-resolve` - `api.reports.v1-persistence-only` - `api.routes.flask-blueprints-own-urls` - `api.routes.no-domain-logic-in-http` @@ -268,7 +269,8 @@ 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 (E4, E7, E9, F, FLY, FURB, I, PIE, PYI, RET, RSE, 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 (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 class that other models inherit from is listed in ruff's runtime-evaluated-base-classes, 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` | — | ```mermaid @@ -286,10 +288,12 @@ 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__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_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"] r_api_quality_tests_pass("api.quality.tests-pass (enforced)") e_CI_job_test["CI job test"] - j_CI_pr_yml_test["CI pr.yml:test"] r_api_quality_alembic_revisions_unused_imports --> e_ruff_per_file_ignore_of_ALL_for_migrations___versions___py e_ruff_per_file_ignore_of_ALL_for_migrations___versions___py -.-> local_only r_api_quality_environment_variables_load --> e_CI_job_test_env_vars @@ -298,8 +302,10 @@ 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__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_api_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_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 e_CI_job_test --> j_CI_pr_yml_test ``` diff --git a/policyengine_api/asgi_factory.py b/policyengine_api/asgi_factory.py index 9fc4fd2a0..37a1da94c 100644 --- a/policyengine_api/asgi_factory.py +++ b/policyengine_api/asgi_factory.py @@ -3,20 +3,18 @@ from __future__ import annotations import time -from collections.abc import Callable from contextlib import asynccontextmanager +from typing import TYPE_CHECKING from a2wsgi import WSGIMiddleware from fastapi import FastAPI, Request from fastapi.exception_handlers import request_validation_exception_handler from fastapi.exceptions import RequestValidationError -from policyengine_observability import ObservabilityRuntime from starlette.datastructures import MutableHeaders from starlette.middleware.cors import CORSMiddleware from starlette.middleware.gzip import GZipMiddleware from starlette.responses import PlainTextResponse, Response from starlette.routing import Match, Mount -from starlette.types import ASGIApp from policyengine_api.constants import VERSION from policyengine_api.fastapi_routes.dependencies import NativeRouteDependencies @@ -52,6 +50,12 @@ generate_request_id, ) +if TYPE_CHECKING: + from collections.abc import Callable + + from policyengine_observability import ObservabilityRuntime + from starlette.types import ASGIApp + def _apply_request_id_header( response: Response, diff --git a/policyengine_api/data/v2/catalog/initialization.py b/policyengine_api/data/v2/catalog/initialization.py index 94978a2e8..556519694 100644 --- a/policyengine_api/data/v2/catalog/initialization.py +++ b/policyengine_api/data/v2/catalog/initialization.py @@ -4,7 +4,7 @@ import json import sys -from collections.abc import Callable, Mapping +from typing import TYPE_CHECKING from sqlalchemy import Engine, create_engine from sqlalchemy.pool import NullPool @@ -18,13 +18,17 @@ PublicationEvidence, publish_catalog, ) -from policyengine_api.data.v2.catalog.records import NormalizedCatalog from policyengine_api.data.v2.settings import ( V2ConfigurationError, V2DatabaseSettings, load_v2_data_write_database_settings, ) +if TYPE_CHECKING: + from collections.abc import Callable, Mapping + + from policyengine_api.data.v2.catalog.records import NormalizedCatalog + def build_data_write_engine(settings: V2DatabaseSettings) -> Engine: """Build an isolated, unpooled engine for one initialization attempt.""" diff --git a/policyengine_api/data/v2/catalog/publication.py b/policyengine_api/data/v2/catalog/publication.py index 6155dd836..8f33fc664 100644 --- a/policyengine_api/data/v2/catalog/publication.py +++ b/policyengine_api/data/v2/catalog/publication.py @@ -4,7 +4,7 @@ import logging import time -from collections.abc import Callable +from typing import TYPE_CHECKING import sqlalchemy as sa from sqlalchemy import Connection, Engine @@ -23,7 +23,6 @@ CatalogPublicationError, PublicationEvidence, ) -from policyengine_api.data.v2.catalog.records import NormalizedCatalog from policyengine_api.data.v2.migration_target import V2_ALEMBIC_HEAD_REVISION from policyengine_api.data.v2.models import ( DatasetVersion, @@ -32,6 +31,11 @@ Simulation, ) +if TYPE_CHECKING: + from collections.abc import Callable + + from policyengine_api.data.v2.catalog.records import NormalizedCatalog + # Stable application-defined PostgreSQL lock ID shared by all v2 catalog publishers. PUBLICATION_ADVISORY_LOCK_KEY = 8_629_020_026_090_001 diff --git a/policyengine_api/data/v2/catalog/publication_reconciliation.py b/policyengine_api/data/v2/catalog/publication_reconciliation.py index b4cb49f33..75d6ede2a 100644 --- a/policyengine_api/data/v2/catalog/publication_reconciliation.py +++ b/policyengine_api/data/v2/catalog/publication_reconciliation.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing import TYPE_CHECKING + import sqlalchemy as sa from sqlalchemy import Connection, Select from sqlalchemy.dialects.postgresql import JSONB @@ -19,7 +21,6 @@ from policyengine_api.data.v2.catalog.publication_types import ( CatalogPublicationError, ) -from policyengine_api.data.v2.catalog.records import CountryCatalog from policyengine_api.data.v2.models import ( Dataset, Parameter, @@ -31,6 +32,9 @@ Variable, ) +if TYPE_CHECKING: + from policyengine_api.data.v2.catalog.records import CountryCatalog + DATASETS = Dataset.__table__ MODELS = TaxBenefitModel.__table__ MODEL_VERSIONS = TaxBenefitModelVersion.__table__ diff --git a/policyengine_api/data/v2/catalog/publication_staging.py b/policyengine_api/data/v2/catalog/publication_staging.py index b00f18ec4..d45dcb5f6 100644 --- a/policyengine_api/data/v2/catalog/publication_staging.py +++ b/policyengine_api/data/v2/catalog/publication_staging.py @@ -2,7 +2,7 @@ from __future__ import annotations -from collections.abc import Callable, Iterable, Iterator, Sequence +from typing import TYPE_CHECKING import sqlalchemy as sa from psycopg import sql @@ -19,6 +19,9 @@ iter_batches, ) +if TYPE_CHECKING: + from collections.abc import Callable, Iterable, Iterator, Sequence + COPY_BATCH_SIZE = 10_000 STAGING_METADATA = MetaData() diff --git a/policyengine_api/data/v2/catalog/records.py b/policyengine_api/data/v2/catalog/records.py index bead12892..6a0adf232 100644 --- a/policyengine_api/data/v2/catalog/records.py +++ b/policyengine_api/data/v2/catalog/records.py @@ -2,11 +2,13 @@ from __future__ import annotations -from collections.abc import Iterator, Sequence from dataclasses import dataclass, fields -from datetime import datetime -from typing import Any, TypeVar -from uuid import UUID +from typing import TYPE_CHECKING, Any, TypeVar + +if TYPE_CHECKING: + from collections.abc import Iterator, Sequence + from datetime import datetime + from uuid import UUID @dataclass(frozen=True, slots=True) diff --git a/policyengine_api/data/v2/database.py b/policyengine_api/data/v2/database.py index ea14cd325..b742177bd 100644 --- a/policyengine_api/data/v2/database.py +++ b/policyengine_api/data/v2/database.py @@ -5,8 +5,8 @@ import atexit import os from threading import Lock +from typing import TYPE_CHECKING -from sqlalchemy import Engine from sqlalchemy.orm import sessionmaker from sqlmodel import Session, create_engine @@ -16,6 +16,9 @@ load_v2_runtime_database_settings, ) +if TYPE_CHECKING: + from sqlalchemy import Engine + DATABASE_POOL_RECYCLE_SECONDS = 1800 DATABASE_POOL_SIZE = 5 DATABASE_POOL_MAX_OVERFLOW = 5 diff --git a/policyengine_api/data/v2/household_migration_qualification.py b/policyengine_api/data/v2/household_migration_qualification.py index f017ece60..11ca1616e 100644 --- a/policyengine_api/data/v2/household_migration_qualification.py +++ b/policyengine_api/data/v2/household_migration_qualification.py @@ -4,9 +4,8 @@ import json import sys -from collections.abc import Callable, Collection, Mapping from dataclasses import dataclass -from typing import Protocol +from typing import TYPE_CHECKING, Protocol from alembic.config import Config from alembic.migration import MigrationContext @@ -29,6 +28,9 @@ load_v2_migration_database_settings, ) +if TYPE_CHECKING: + from collections.abc import Callable, Collection, Mapping + HOUSEHOLD_MIGRATION_REVISION = "724b1b11a33e" V2_ALEMBIC_CONFIG = "alembic-v2.ini" diff --git a/policyengine_api/data/v2/migration_target.py b/policyengine_api/data/v2/migration_target.py index d832cc879..5595a3dee 100644 --- a/policyengine_api/data/v2/migration_target.py +++ b/policyengine_api/data/v2/migration_target.py @@ -3,9 +3,9 @@ from __future__ import annotations import os -from collections.abc import Mapping from dataclasses import dataclass from datetime import date +from typing import TYPE_CHECKING from sqlalchemy import Connection, MetaData, inspect, text from sqlalchemy.engine import URL, make_url @@ -20,6 +20,9 @@ validate_supabase_database_identity, ) +if TYPE_CHECKING: + from collections.abc import Mapping + V2_ALEMBIC_DISPOSABLE_TEST = "V2_ALEMBIC_DISPOSABLE_TEST" V2_ALEMBIC_HEAD_REVISION = "91af4fc8d8c1" DISPOSABLE_DATABASE_NAME = "policyengine_v2_alembic_test" diff --git a/policyengine_api/data/v2/policy_migration_qualification.py b/policyengine_api/data/v2/policy_migration_qualification.py index 91832823e..b88c237e7 100644 --- a/policyengine_api/data/v2/policy_migration_qualification.py +++ b/policyengine_api/data/v2/policy_migration_qualification.py @@ -4,9 +4,8 @@ import json import sys -from collections.abc import Callable, Collection, Mapping from dataclasses import dataclass -from typing import Protocol +from typing import TYPE_CHECKING, Protocol from alembic.config import Config from alembic.migration import MigrationContext @@ -23,6 +22,9 @@ load_v2_migration_database_settings, ) +if TYPE_CHECKING: + from collections.abc import Callable, Collection, Mapping + POLICY_MIGRATION_REVISION = "711ec2f0a5a5" V2_ALEMBIC_CONFIG = "alembic-v2.ini" diff --git a/policyengine_api/data/v2/report_runs.py b/policyengine_api/data/v2/report_runs.py index b18851b5b..e4da22634 100644 --- a/policyengine_api/data/v2/report_runs.py +++ b/policyengine_api/data/v2/report_runs.py @@ -2,9 +2,7 @@ from __future__ import annotations -from collections.abc import Mapping -from datetime import datetime -from uuid import UUID +from typing import TYPE_CHECKING from sqlalchemy.exc import IntegrityError from sqlmodel import Session, select @@ -18,6 +16,11 @@ ) from policyengine_api.data.v2.models.base import utc_now +if TYPE_CHECKING: + from collections.abc import Mapping + from datetime import datetime + from uuid import UUID + class ReportNotFoundError(LookupError): """Raised when a requested durable report does not exist.""" diff --git a/policyengine_api/data/v2/settings.py b/policyengine_api/data/v2/settings.py index c169f847c..4ef27980f 100644 --- a/policyengine_api/data/v2/settings.py +++ b/policyengine_api/data/v2/settings.py @@ -9,13 +9,16 @@ import os import re -from collections.abc import Callable, Mapping from dataclasses import dataclass, field from functools import cache +from typing import TYPE_CHECKING from sqlalchemy.engine import URL, make_url from sqlalchemy.exc import ArgumentError +if TYPE_CHECKING: + from collections.abc import Callable, Mapping + V2_RUNTIME_DATABASE_URL = "V2_RUNTIME_DATABASE_URL" V2_RUNTIME_DATABASE_URL_SECRET_RESOURCE = "V2_RUNTIME_DATABASE_URL_SECRET_RESOURCE" V2_MIGRATION_DATABASE_URL = "V2_MIGRATION_DATABASE_URL" diff --git a/policyengine_api/fastapi_routes/dependencies.py b/policyengine_api/fastapi_routes/dependencies.py index 823f88452..d13e2649d 100644 --- a/policyengine_api/fastapi_routes/dependencies.py +++ b/policyengine_api/fastapi_routes/dependencies.py @@ -2,17 +2,17 @@ from __future__ import annotations -from collections.abc import Callable from dataclasses import dataclass -from datetime import datetime from functools import lru_cache from importlib import metadata as importlib_metadata from typing import TYPE_CHECKING, Protocol -from uuid import UUID - -from policyengine_api.json_types import JSONObject if TYPE_CHECKING: + from collections.abc import Callable + from datetime import datetime + from uuid import UUID + + from policyengine_api.json_types import JSONObject from policyengine_api.services.v2.households.types import ( HouseholdPage, HouseholdRead, diff --git a/policyengine_api/fastapi_routes/health.py b/policyengine_api/fastapi_routes/health.py index 8b7eb33e3..b411512a8 100644 --- a/policyengine_api/fastapi_routes/health.py +++ b/policyengine_api/fastapi_routes/health.py @@ -2,13 +2,14 @@ from __future__ import annotations -from typing import Literal +from typing import TYPE_CHECKING, Literal from fastapi import APIRouter, HTTPException from pydantic import BaseModel from starlette.responses import PlainTextResponse -from policyengine_api.fastapi_routes.dependencies import NativeRouteDependencies +if TYPE_CHECKING: + from policyengine_api.fastapi_routes.dependencies import NativeRouteDependencies class HealthResponse(BaseModel): diff --git a/policyengine_api/fastapi_routes/metadata.py b/policyengine_api/fastapi_routes/metadata.py index ee9c928c8..24a214a72 100644 --- a/policyengine_api/fastapi_routes/metadata.py +++ b/policyengine_api/fastapi_routes/metadata.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Literal, TypedDict +from typing import TYPE_CHECKING, Literal, TypedDict from fastapi import APIRouter from starlette.responses import Response, StreamingResponse @@ -11,17 +11,19 @@ InvalidCountryError, ensure_supported_country, ) -from policyengine_api.fastapi_routes.dependencies import NativeRouteDependencies from policyengine_api.fastapi_routes.responses import ( LegacyJSONResponse, render_legacy_json, ) -from policyengine_api.json_types import JSONObject from policyengine_api.utils.streaming_json import ( iter_body_chunks, should_stream_body, ) +if TYPE_CHECKING: + from policyengine_api.fastapi_routes.dependencies import NativeRouteDependencies + from policyengine_api.json_types import JSONObject + class MetadataSuccessPayload(TypedDict): status: Literal["ok"] diff --git a/policyengine_api/fastapi_routes/query_parameters.py b/policyengine_api/fastapi_routes/query_parameters.py index 608406bca..52c62b745 100644 --- a/policyengine_api/fastapi_routes/query_parameters.py +++ b/policyengine_api/fastapi_routes/query_parameters.py @@ -2,9 +2,8 @@ from __future__ import annotations -from collections.abc import Awaitable, Callable from inspect import Parameter, Signature -from typing import Annotated, TypeVar +from typing import TYPE_CHECKING, Annotated, TypeVar from fastapi import Query, Request from fastapi.exceptions import RequestValidationError @@ -15,6 +14,9 @@ validate_scalar_query_multiplicity, ) +if TYPE_CHECKING: + from collections.abc import Awaitable, Callable + QueryParametersT = TypeVar("QueryParametersT", bound=StrictQueryParameters) diff --git a/policyengine_api/fastapi_routes/responses.py b/policyengine_api/fastapi_routes/responses.py index 7c1d160de..a58ab27ec 100644 --- a/policyengine_api/fastapi_routes/responses.py +++ b/policyengine_api/fastapi_routes/responses.py @@ -3,10 +3,12 @@ from __future__ import annotations import json +from typing import TYPE_CHECKING from starlette.responses import Response -from policyengine_api.json_types import JSONValue +if TYPE_CHECKING: + from policyengine_api.json_types import JSONValue def render_legacy_json(content: JSONValue) -> bytes: diff --git a/policyengine_api/fastapi_routes/specification.py b/policyengine_api/fastapi_routes/specification.py index f104159f6..974f99d45 100644 --- a/policyengine_api/fastapi_routes/specification.py +++ b/policyengine_api/fastapi_routes/specification.py @@ -2,10 +2,13 @@ from __future__ import annotations +from typing import TYPE_CHECKING + from fastapi import APIRouter from starlette.responses import JSONResponse -from policyengine_api.fastapi_routes.dependencies import NativeRouteDependencies +if TYPE_CHECKING: + from policyengine_api.fastapi_routes.dependencies import NativeRouteDependencies def build_specification_router( diff --git a/policyengine_api/fastapi_routes/v2/households/response_models.py b/policyengine_api/fastapi_routes/v2/households/response_models.py index 4388a4765..c51a8e5d6 100644 --- a/policyengine_api/fastapi_routes/v2/households/response_models.py +++ b/policyengine_api/fastapi_routes/v2/households/response_models.py @@ -3,7 +3,7 @@ from __future__ import annotations from datetime import datetime -from typing import Any, Generic, Literal, TypeVar +from typing import TYPE_CHECKING, Any, Generic, Literal, TypeVar from uuid import UUID from pydantic import BaseModel, ConfigDict @@ -13,7 +13,12 @@ HouseholdDocument, ) from policyengine_api.query_parameters import CountryId, DefaultYear -from policyengine_api.services.v2.households.types import HouseholdPage, HouseholdRead + +if TYPE_CHECKING: + from policyengine_api.services.v2.households.types import ( + HouseholdPage, + HouseholdRead, + ) class StrictHouseholdAPIModel(BaseModel): diff --git a/policyengine_api/fastapi_routes/v2/households/routes.py b/policyengine_api/fastapi_routes/v2/households/routes.py index 0e0337fb3..cf0fe4006 100644 --- a/policyengine_api/fastapi_routes/v2/households/routes.py +++ b/policyengine_api/fastapi_routes/v2/households/routes.py @@ -2,8 +2,7 @@ from __future__ import annotations -from collections.abc import Callable -from typing import TypeVar +from typing import TYPE_CHECKING, TypeVar from uuid import UUID from fastapi import APIRouter, Depends, Request @@ -11,10 +10,6 @@ from starlette.responses import JSONResponse from policyengine_api.data.v2.settings import V2ConfigurationError -from policyengine_api.fastapi_routes.dependencies import ( - NativeRouteDependencies, - V2HouseholdResourceService, -) from policyengine_api.fastapi_routes.query_parameters import query_dependency from policyengine_api.fastapi_routes.v2.errors import ( V2RequestTooLargeError, @@ -44,6 +39,14 @@ HouseholdValidationError, ) +if TYPE_CHECKING: + from collections.abc import Callable + + from policyengine_api.fastapi_routes.dependencies import ( + NativeRouteDependencies, + V2HouseholdResourceService, + ) + async def enforce_household_request_size(request: Request) -> None: """Reject declared or actual request bodies larger than 1 MiB.""" diff --git a/policyengine_api/fastapi_routes/v2/metadata/common.py b/policyengine_api/fastapi_routes/v2/metadata/common.py index cb483c011..bfe6fc90e 100644 --- a/policyengine_api/fastapi_routes/v2/metadata/common.py +++ b/policyengine_api/fastapi_routes/v2/metadata/common.py @@ -2,11 +2,9 @@ from __future__ import annotations -from collections.abc import Callable -from typing import Any, TypeVar +from typing import TYPE_CHECKING, Any, TypeVar from pydantic import BaseModel -from starlette.responses import JSONResponse from policyengine_api.data.v2.catalog.catalog_selection import ( InvalidPolicyEngineVersionError, @@ -15,10 +13,6 @@ UnsupportedPreviewCountryError, ) from policyengine_api.data.v2.settings import V2ConfigurationError -from policyengine_api.fastapi_routes.dependencies import ( - NativeRouteDependencies, - V2MetadataResourceReader, -) from policyengine_api.fastapi_routes.v2.errors import ( V2ErrorResponse, v2_error_response, @@ -28,6 +22,16 @@ MetadataResourceNotFoundError, ) +if TYPE_CHECKING: + from collections.abc import Callable + + from starlette.responses import JSONResponse + + from policyengine_api.fastapi_routes.dependencies import ( + NativeRouteDependencies, + V2MetadataResourceReader, + ) + ERROR_RESPONSES: dict[int | str, dict[str, Any]] = { 400: { "model": V2ErrorResponse, diff --git a/policyengine_api/fastapi_routes/v2/metadata/geography_routes.py b/policyengine_api/fastapi_routes/v2/metadata/geography_routes.py index ed8a0aca2..cf3653a54 100644 --- a/policyengine_api/fastapi_routes/v2/metadata/geography_routes.py +++ b/policyengine_api/fastapi_routes/v2/metadata/geography_routes.py @@ -2,13 +2,12 @@ from __future__ import annotations -from typing import Annotated +from typing import TYPE_CHECKING, Annotated from uuid import UUID from fastapi import APIRouter, Query from starlette.responses import JSONResponse -from policyengine_api.fastapi_routes.dependencies import NativeRouteDependencies from policyengine_api.fastapi_routes.v2.metadata.common import ( ERROR_RESPONSES, read_resource, @@ -22,6 +21,9 @@ ) from policyengine_api.services.v2.metadata.types import MetadataRegionType +if TYPE_CHECKING: + from policyengine_api.fastapi_routes.dependencies import NativeRouteDependencies + Offset = Annotated[int, Query(ge=0)] Limit = Annotated[int, Query(ge=1, le=500)] diff --git a/policyengine_api/fastapi_routes/v2/metadata/model_routes.py b/policyengine_api/fastapi_routes/v2/metadata/model_routes.py index 5d7315641..68336b1fe 100644 --- a/policyengine_api/fastapi_routes/v2/metadata/model_routes.py +++ b/policyengine_api/fastapi_routes/v2/metadata/model_routes.py @@ -2,13 +2,12 @@ from __future__ import annotations -from typing import Annotated +from typing import TYPE_CHECKING, Annotated from uuid import UUID from fastapi import APIRouter, Query from starlette.responses import JSONResponse -from policyengine_api.fastapi_routes.dependencies import NativeRouteDependencies from policyengine_api.fastapi_routes.v2.metadata.common import ( ERROR_RESPONSES, read_resource, @@ -23,6 +22,9 @@ MetadataVariablePageResponse, ) +if TYPE_CHECKING: + from policyengine_api.fastapi_routes.dependencies import NativeRouteDependencies + Offset = Annotated[int, Query(ge=0)] Limit = Annotated[int, Query(ge=1, le=500)] diff --git a/policyengine_api/fastapi_routes/v2/metadata/parameter_routes.py b/policyengine_api/fastapi_routes/v2/metadata/parameter_routes.py index 5c7f19f4f..5683a3801 100644 --- a/policyengine_api/fastapi_routes/v2/metadata/parameter_routes.py +++ b/policyengine_api/fastapi_routes/v2/metadata/parameter_routes.py @@ -2,13 +2,12 @@ from __future__ import annotations -from typing import Annotated +from typing import TYPE_CHECKING, Annotated from uuid import UUID from fastapi import APIRouter, Query from starlette.responses import JSONResponse -from policyengine_api.fastapi_routes.dependencies import NativeRouteDependencies from policyengine_api.fastapi_routes.v2.metadata.common import ( ERROR_RESPONSES, read_resource, @@ -21,6 +20,9 @@ MetadataParameterValuePageResponse, ) +if TYPE_CHECKING: + from policyengine_api.fastapi_routes.dependencies import NativeRouteDependencies + Offset = Annotated[int, Query(ge=0)] Limit = Annotated[int, Query(ge=1, le=500)] diff --git a/policyengine_api/fastapi_routes/v2/policies/response_models.py b/policyengine_api/fastapi_routes/v2/policies/response_models.py index ec44894fd..30cb2a242 100644 --- a/policyengine_api/fastapi_routes/v2/policies/response_models.py +++ b/policyengine_api/fastapi_routes/v2/policies/response_models.py @@ -3,13 +3,15 @@ from __future__ import annotations from datetime import datetime -from typing import Any, Generic, Literal, TypeVar +from typing import TYPE_CHECKING, Any, Generic, Literal, TypeVar from uuid import UUID from pydantic import BaseModel, ConfigDict, JsonValue from policyengine_api.fastapi_routes.v2.errors import V2ErrorResponse -from policyengine_api.services.v2.policies.types import PolicyPage, PolicyRead + +if TYPE_CHECKING: + from policyengine_api.services.v2.policies.types import PolicyPage, PolicyRead class StrictPolicyAPIModel(BaseModel): diff --git a/policyengine_api/fastapi_routes/v2/policies/routes.py b/policyengine_api/fastapi_routes/v2/policies/routes.py index 383c4c0f2..3ecd854ec 100644 --- a/policyengine_api/fastapi_routes/v2/policies/routes.py +++ b/policyengine_api/fastapi_routes/v2/policies/routes.py @@ -2,8 +2,7 @@ from __future__ import annotations -from collections.abc import Callable -from typing import TypeVar +from typing import TYPE_CHECKING, TypeVar from uuid import UUID from fastapi import APIRouter, Depends, Request @@ -15,10 +14,6 @@ MetadataCatalogVersionNotFoundError, ) from policyengine_api.data.v2.settings import V2ConfigurationError -from policyengine_api.fastapi_routes.dependencies import ( - NativeRouteDependencies, - V2PolicyResourceService, -) from policyengine_api.fastapi_routes.query_parameters import query_dependency from policyengine_api.fastapi_routes.v2.errors import ( V2RequestTooLargeError, @@ -49,6 +44,14 @@ PolicyNotFoundError, ) +if TYPE_CHECKING: + from collections.abc import Callable + + from policyengine_api.fastapi_routes.dependencies import ( + NativeRouteDependencies, + V2PolicyResourceService, + ) + async def enforce_policy_request_size(request: Request) -> None: """Bound both declared and actual request bytes before service creation.""" diff --git a/policyengine_api/fastapi_routes/v2/routes.py b/policyengine_api/fastapi_routes/v2/routes.py index d5b97d3c8..85f4dbff5 100644 --- a/policyengine_api/fastapi_routes/v2/routes.py +++ b/policyengine_api/fastapi_routes/v2/routes.py @@ -2,10 +2,11 @@ from __future__ import annotations +from typing import TYPE_CHECKING + from fastapi import APIRouter, Request from starlette.responses import JSONResponse -from policyengine_api.fastapi_routes.dependencies import NativeRouteDependencies from policyengine_api.fastapi_routes.v2.errors import V2ErrorResponse from policyengine_api.fastapi_routes.v2.households.routes import ( build_v2_household_router, @@ -27,6 +28,9 @@ build_v2_user_policy_router, ) +if TYPE_CHECKING: + from policyengine_api.fastapi_routes.dependencies import NativeRouteDependencies + def build_v2_router( dependencies: NativeRouteDependencies, diff --git a/policyengine_api/fastapi_routes/v2/user_households/response_models.py b/policyengine_api/fastapi_routes/v2/user_households/response_models.py index ccb53cd0a..d269e541f 100644 --- a/policyengine_api/fastapi_routes/v2/user_households/response_models.py +++ b/policyengine_api/fastapi_routes/v2/user_households/response_models.py @@ -3,17 +3,19 @@ from __future__ import annotations from datetime import datetime -from typing import Any, Generic, Literal, TypeVar +from typing import TYPE_CHECKING, Any, Generic, Literal, TypeVar from uuid import UUID from pydantic import BaseModel, ConfigDict from policyengine_api.fastapi_routes.v2.errors import V2ErrorResponse from policyengine_api.query_parameters import CountryId, ResourceId, UserId -from policyengine_api.services.v2.user_households.types import ( - UserHouseholdPage, - UserHouseholdRead, -) + +if TYPE_CHECKING: + from policyengine_api.services.v2.user_households.types import ( + UserHouseholdPage, + UserHouseholdRead, + ) class StrictUserHouseholdAPIModel(BaseModel): diff --git a/policyengine_api/fastapi_routes/v2/user_households/routes.py b/policyengine_api/fastapi_routes/v2/user_households/routes.py index 089255343..8710ac1f7 100644 --- a/policyengine_api/fastapi_routes/v2/user_households/routes.py +++ b/policyengine_api/fastapi_routes/v2/user_households/routes.py @@ -2,8 +2,7 @@ from __future__ import annotations -from collections.abc import Callable -from typing import TypeVar +from typing import TYPE_CHECKING, TypeVar from uuid import UUID from fastapi import APIRouter, Depends @@ -11,10 +10,6 @@ from starlette.responses import JSONResponse, Response from policyengine_api.data.v2.settings import V2ConfigurationError -from policyengine_api.fastapi_routes.dependencies import ( - NativeRouteDependencies, - V2UserHouseholdResourceService, -) from policyengine_api.fastapi_routes.query_parameters import query_dependency from policyengine_api.fastapi_routes.v2.errors import v2_error_response from policyengine_api.fastapi_routes.v2.user_households.request_models import ( @@ -43,6 +38,14 @@ UserHouseholdNotFoundError, ) +if TYPE_CHECKING: + from collections.abc import Callable + + from policyengine_api.fastapi_routes.dependencies import ( + NativeRouteDependencies, + V2UserHouseholdResourceService, + ) + def _service_factory( dependencies: NativeRouteDependencies, diff --git a/policyengine_api/fastapi_routes/v2/user_policies/response_models.py b/policyengine_api/fastapi_routes/v2/user_policies/response_models.py index da0c9ed21..646e0bcf8 100644 --- a/policyengine_api/fastapi_routes/v2/user_policies/response_models.py +++ b/policyengine_api/fastapi_routes/v2/user_policies/response_models.py @@ -3,17 +3,19 @@ from __future__ import annotations from datetime import datetime -from typing import Any, Generic, Literal, TypeVar +from typing import TYPE_CHECKING, Any, Generic, Literal, TypeVar from uuid import UUID from pydantic import BaseModel, ConfigDict from policyengine_api.fastapi_routes.v2.errors import V2ErrorResponse from policyengine_api.query_parameters import CountryId, ResourceId, UserId -from policyengine_api.services.v2.user_policies.types import ( - UserPolicyPage, - UserPolicyRead, -) + +if TYPE_CHECKING: + from policyengine_api.services.v2.user_policies.types import ( + UserPolicyPage, + UserPolicyRead, + ) class StrictUserPolicyAPIModel(BaseModel): diff --git a/policyengine_api/fastapi_routes/v2/user_policies/routes.py b/policyengine_api/fastapi_routes/v2/user_policies/routes.py index 2be0184d0..740eeedaf 100644 --- a/policyengine_api/fastapi_routes/v2/user_policies/routes.py +++ b/policyengine_api/fastapi_routes/v2/user_policies/routes.py @@ -2,8 +2,7 @@ from __future__ import annotations -from collections.abc import Callable -from typing import TypeVar +from typing import TYPE_CHECKING, TypeVar from uuid import UUID from fastapi import APIRouter, Depends @@ -11,10 +10,6 @@ from starlette.responses import JSONResponse, Response from policyengine_api.data.v2.settings import V2ConfigurationError -from policyengine_api.fastapi_routes.dependencies import ( - NativeRouteDependencies, - V2UserPolicyResourceService, -) from policyengine_api.fastapi_routes.query_parameters import query_dependency from policyengine_api.fastapi_routes.v2.errors import v2_error_response from policyengine_api.fastapi_routes.v2.user_policies.request_models import ( @@ -40,6 +35,14 @@ UserPolicyNotFoundError, ) +if TYPE_CHECKING: + from collections.abc import Callable + + from policyengine_api.fastapi_routes.dependencies import ( + NativeRouteDependencies, + V2UserPolicyResourceService, + ) + def _service_factory( dependencies: NativeRouteDependencies, diff --git a/policyengine_api/migration_logging.py b/policyengine_api/migration_logging.py index cef7e8985..9123a53a4 100644 --- a/policyengine_api/migration_logging.py +++ b/policyengine_api/migration_logging.py @@ -3,9 +3,9 @@ from __future__ import annotations import time +from typing import TYPE_CHECKING import flask -from policyengine_observability import ObservabilityRuntime from policyengine_api.gcp_logging import logger from policyengine_api.migration_flags import ( @@ -22,6 +22,9 @@ generate_request_id, ) +if TYPE_CHECKING: + from policyengine_observability import ObservabilityRuntime + V2_METADATA_RESOURCE_SEGMENTS = frozenset( { "datasets", diff --git a/policyengine_api/observability/stages.py b/policyengine_api/observability/stages.py index efa020b1d..2c517542b 100644 --- a/policyengine_api/observability/stages.py +++ b/policyengine_api/observability/stages.py @@ -2,10 +2,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): diff --git a/policyengine_api/query_parameters.py b/policyengine_api/query_parameters.py index 3cbb45202..3b0d854cb 100644 --- a/policyengine_api/query_parameters.py +++ b/policyengine_api/query_parameters.py @@ -3,9 +3,17 @@ from __future__ import annotations import json -from collections.abc import Iterable, Mapping from types import UnionType -from typing import Annotated, Any, Literal, TypeVar, Union, get_args, get_origin +from typing import ( + TYPE_CHECKING, + Annotated, + Any, + Literal, + TypeVar, + Union, + get_args, + get_origin, +) from uuid import UUID from pydantic import ( @@ -26,6 +34,9 @@ BUDGET_WINDOW_MAX_YEARS, ) +if TYPE_CHECKING: + from collections.abc import Iterable, Mapping + DEFAULT_QUERY_LIMIT = 100 MAXIMUM_QUERY_LIMIT = 500 MAXIMUM_USER_ID_LENGTH = 255 diff --git a/policyengine_api/runtime_cache/core.py b/policyengine_api/runtime_cache/core.py index ccb1a0df3..8b75dabfd 100644 --- a/policyengine_api/runtime_cache/core.py +++ b/policyengine_api/runtime_cache/core.py @@ -6,12 +6,14 @@ import json import random import time -from collections.abc import Callable from dataclasses import dataclass -from typing import Any, Protocol +from typing import TYPE_CHECKING, Any, Protocol from policyengine_api.gcp_logging import logger +if TYPE_CHECKING: + from collections.abc import Callable + CACHE_KEY_ROOT = "policyengine" COMPLETED_RESULT_TTL_JITTER_FRACTION = 0.10 diff --git a/policyengine_api/runtime_cache/fake.py b/policyengine_api/runtime_cache/fake.py index 11c4e212f..1a9008659 100644 --- a/policyengine_api/runtime_cache/fake.py +++ b/policyengine_api/runtime_cache/fake.py @@ -2,9 +2,11 @@ from __future__ import annotations -from collections.abc import Callable from threading import RLock -from typing import Any, Self +from typing import TYPE_CHECKING, Any, Self + +if TYPE_CHECKING: + from collections.abc import Callable class InMemoryCacheBackend: diff --git a/policyengine_api/runtime_cache/settings.py b/policyengine_api/runtime_cache/settings.py index 35aba6b7d..6fb2937da 100644 --- a/policyengine_api/runtime_cache/settings.py +++ b/policyengine_api/runtime_cache/settings.py @@ -4,12 +4,15 @@ import os import re -from collections.abc import Mapping from dataclasses import dataclass, field +from typing import TYPE_CHECKING from urllib.parse import SplitResult, urlsplit from pydantic import SecretStr +if TYPE_CHECKING: + from collections.abc import Mapping + RUNTIME_CACHE_MODE = "RUNTIME_CACHE_MODE" RUNTIME_CACHE_URL = "RUNTIME_CACHE_URL" RUNTIME_CACHE_CA_CERT = "RUNTIME_CACHE_CA_CERT" diff --git a/policyengine_api/services/household_calculation_service.py b/policyengine_api/services/household_calculation_service.py index 17ad32244..655b7700b 100644 --- a/policyengine_api/services/household_calculation_service.py +++ b/policyengine_api/services/household_calculation_service.py @@ -1,14 +1,12 @@ from __future__ import annotations import time -from collections.abc import Callable from copy import deepcopy from dataclasses import dataclass, replace from datetime import date -from typing import Any +from typing import TYPE_CHECKING, Any from sqlalchemy import select -from sqlalchemy.orm import Session, sessionmaker from policyengine_api.constants import COUNTRY_PACKAGE_VERSIONS, POLICYENGINE_VERSION from policyengine_api.data.orm import get_v1_session_factory @@ -33,6 +31,11 @@ from policyengine_api.utils.deprecated_inputs import drop_deprecated_inputs from policyengine_api.utils.input_validation import find_unrecognized_inputs +if TYPE_CHECKING: + from collections.abc import Callable + + from sqlalchemy.orm import Session, sessionmaker + @dataclass(frozen=True) class CalculationResult: diff --git a/policyengine_api/services/household_mirroring.py b/policyengine_api/services/household_mirroring.py index 863b89493..276e7ca90 100644 --- a/policyengine_api/services/household_mirroring.py +++ b/policyengine_api/services/household_mirroring.py @@ -3,8 +3,7 @@ from __future__ import annotations import time -from collections.abc import Callable -from typing import Protocol +from typing import TYPE_CHECKING, Protocol from sqlalchemy.exc import SQLAlchemyError @@ -15,10 +14,6 @@ HouseholdService, PendingHouseholdMirrorEvent, ) -from policyengine_api.services.v2.households.types import ( - LegacyHouseholdPersistenceResult, - LegacyHouseholdSnapshot, -) from policyengine_api.services.v2.households.validators import ( HouseholdContentHashCollisionError, HouseholdCreationIntegrityError, @@ -26,6 +21,14 @@ LegacyHouseholdTranslationError, ) +if TYPE_CHECKING: + from collections.abc import Callable + + from policyengine_api.services.v2.households.types import ( + LegacyHouseholdPersistenceResult, + LegacyHouseholdSnapshot, + ) + class LegacyHouseholdMirror(Protocol): """Supabase transaction service used after a v1 commit.""" diff --git a/policyengine_api/services/household_service.py b/policyengine_api/services/household_service.py index c9e35910e..958dba053 100644 --- a/policyengine_api/services/household_service.py +++ b/policyengine_api/services/household_service.py @@ -1,11 +1,11 @@ from __future__ import annotations import copy -from collections.abc import Callable from copy import deepcopy from dataclasses import dataclass from datetime import UTC, datetime from enum import StrEnum +from typing import TYPE_CHECKING from sqlalchemy import select from sqlalchemy.exc import ( @@ -16,7 +16,6 @@ from sqlalchemy.exc import ( TimeoutError as SQLAlchemyTimeoutError, ) -from sqlalchemy.orm import Session, sessionmaker from policyengine_api.constants import COUNTRY_PACKAGE_VERSIONS from policyengine_api.data.orm import get_v1_session_factory @@ -31,6 +30,11 @@ from policyengine_api.spm import SPMValidationError, normalize_spm_selection from policyengine_api.utils import hash_object +if TYPE_CHECKING: + from collections.abc import Callable + + from sqlalchemy.orm import Session, sessionmaker + def household_storage_json(country_id: str, household_json: dict, spm=None) -> dict: """Keep the measurement selection in the same atomic JSON/hash as inputs. diff --git a/policyengine_api/services/policy_mirroring.py b/policyengine_api/services/policy_mirroring.py index 326312fb4..257481f7f 100644 --- a/policyengine_api/services/policy_mirroring.py +++ b/policyengine_api/services/policy_mirroring.py @@ -3,8 +3,7 @@ from __future__ import annotations import time -from collections.abc import Callable -from typing import Protocol +from typing import TYPE_CHECKING, Protocol from sqlalchemy.exc import SQLAlchemyError @@ -14,10 +13,6 @@ ) from policyengine_api.data.v2.settings import V2ConfigurationError from policyengine_api.gcp_logging import logger -from policyengine_api.services.v2.policies.types import ( - LegacyPolicyPersistenceResult, - LegacyPolicySnapshot, -) from policyengine_api.services.v2.policies.validators import ( LegacyPolicyMappingIntegrityError, LegacyPolicyTranslationError, @@ -26,6 +21,14 @@ PolicyCreationIntegrityError, ) +if TYPE_CHECKING: + from collections.abc import Callable + + from policyengine_api.services.v2.policies.types import ( + LegacyPolicyPersistenceResult, + LegacyPolicySnapshot, + ) + class LegacyPolicyMirror(Protocol): """Supabase transaction service used after a v1 commit.""" diff --git a/policyengine_api/services/policy_service.py b/policyengine_api/services/policy_service.py index 9a2d2ab5d..3c5662605 100644 --- a/policyengine_api/services/policy_service.py +++ b/policyengine_api/services/policy_service.py @@ -1,12 +1,10 @@ from __future__ import annotations import copy -from collections.abc import Iterator from dataclasses import dataclass -from typing import Any +from typing import TYPE_CHECKING, Any from sqlalchemy import select -from sqlalchemy.orm import Session, sessionmaker from policyengine_api.constants import COUNTRY_PACKAGE_VERSIONS from policyengine_api.data.orm import get_v1_session_factory @@ -14,6 +12,11 @@ from policyengine_api.services.v2.policies.types import LegacyPolicySnapshot from policyengine_api.utils import hash_object +if TYPE_CHECKING: + from collections.abc import Iterator + + from sqlalchemy.orm import Session, sessionmaker + @dataclass(frozen=True) class PolicySetResult: diff --git a/policyengine_api/services/report_output_service.py b/policyengine_api/services/report_output_service.py index b218bf39b..cbcfc7cf8 100644 --- a/policyengine_api/services/report_output_service.py +++ b/policyengine_api/services/report_output_service.py @@ -46,7 +46,7 @@ def _canonicalize_annual_spm_output( return { **output, "spm_provenance": cast( - JSONValue, + "JSONValue", comparison.model_dump(mode="json", by_alias=True), ), } @@ -61,7 +61,7 @@ def _canonicalize_society_wide_spm_output( if not isinstance(output, dict): return output - typed_output = cast(JSONObject, output) + typed_output = cast("JSONObject", output) if output.get("kind") != "budgetWindow": return _canonicalize_annual_spm_output( typed_output, @@ -78,7 +78,7 @@ def _canonicalize_society_wide_spm_output( row_year = row.get("year") canonical_rows.append( _canonicalize_annual_spm_output( - cast(JSONObject, row), + cast("JSONObject", row), expected_year=str(row_year) if row_year is not None else None, ) ) diff --git a/policyengine_api/services/user_policy_mirroring.py b/policyengine_api/services/user_policy_mirroring.py index 1e44e1a06..7f24d8de9 100644 --- a/policyengine_api/services/user_policy_mirroring.py +++ b/policyengine_api/services/user_policy_mirroring.py @@ -3,8 +3,7 @@ from __future__ import annotations import time -from collections.abc import Callable -from typing import Protocol +from typing import TYPE_CHECKING, Protocol from sqlalchemy.exc import SQLAlchemyError @@ -19,7 +18,6 @@ PendingUserPolicyMirrorEvent, UserPolicyService, ) -from policyengine_api.services.v2.policies.types import LegacyPolicySnapshot from policyengine_api.services.v2.policies.validators import ( LegacyPolicyMappingIntegrityError, LegacyPolicyTranslationError, @@ -27,14 +25,19 @@ PolicyContentHashCollisionError, PolicyCreationIntegrityError, ) -from policyengine_api.services.v2.user_policies.types import ( - LegacyUserPolicyPersistenceResult, - LegacyUserPolicySnapshot, -) from policyengine_api.services.v2.user_policies.validators import ( LegacyUserPolicyIntegrityError, ) +if TYPE_CHECKING: + from collections.abc import Callable + + from policyengine_api.services.v2.policies.types import LegacyPolicySnapshot + from policyengine_api.services.v2.user_policies.types import ( + LegacyUserPolicyPersistenceResult, + LegacyUserPolicySnapshot, + ) + class LegacyUserPolicyMirror(Protocol): def mirror_legacy_user_policy( diff --git a/policyengine_api/services/user_policy_service.py b/policyengine_api/services/user_policy_service.py index 42d60150a..3818791e5 100644 --- a/policyengine_api/services/user_policy_service.py +++ b/policyengine_api/services/user_policy_service.py @@ -1,10 +1,9 @@ from __future__ import annotations -from collections.abc import Callable, Mapping from dataclasses import dataclass from datetime import UTC, datetime from enum import StrEnum -from typing import Any +from typing import TYPE_CHECKING, Any from sqlalchemy import select from sqlalchemy.exc import ( @@ -15,7 +14,6 @@ from sqlalchemy.exc import ( TimeoutError as SQLAlchemyTimeoutError, ) -from sqlalchemy.orm import Session, sessionmaker from policyengine_api.data.orm import get_v1_session_factory from policyengine_api.data.v1_models import UserPolicy, UserPolicyMirrorEvent @@ -27,6 +25,11 @@ LegacyUserPolicySnapshot, ) +if TYPE_CHECKING: + from collections.abc import Callable, Mapping + + from sqlalchemy.orm import Session, sessionmaker + USER_POLICY_IDENTITY_FIELDS = ( "country_id", "reform_id", diff --git a/policyengine_api/services/user_service.py b/policyengine_api/services/user_service.py index d7c11193d..f27fa7d1e 100644 --- a/policyengine_api/services/user_service.py +++ b/policyengine_api/services/user_service.py @@ -1,11 +1,15 @@ from __future__ import annotations +from typing import TYPE_CHECKING + from sqlalchemy import select -from sqlalchemy.orm import Session, sessionmaker from policyengine_api.data.orm import get_v1_session_factory from policyengine_api.data.v1_models import UserProfile +if TYPE_CHECKING: + from sqlalchemy.orm import Session, sessionmaker + class UserService: """User-profile operations with service-owned ORM transaction boundaries.""" diff --git a/policyengine_api/services/v2/comparison_runs/database_connectors/creates.py b/policyengine_api/services/v2/comparison_runs/database_connectors/creates.py index 1de775e9f..32c0ca17c 100644 --- a/policyengine_api/services/v2/comparison_runs/database_connectors/creates.py +++ b/policyengine_api/services/v2/comparison_runs/database_connectors/creates.py @@ -2,7 +2,7 @@ from __future__ import annotations -from uuid import UUID +from typing import TYPE_CHECKING from sqlalchemy.dialects.postgresql import insert from sqlmodel import Session, col @@ -15,10 +15,14 @@ report_insert_values, simulation_insert_values, ) -from policyengine_api.services.v2.comparison_runs.types import ( - ComparisonReportRecord, - ComparisonSimulationRecord, -) + +if TYPE_CHECKING: + from uuid import UUID + + from policyengine_api.services.v2.comparison_runs.types import ( + ComparisonReportRecord, + ComparisonSimulationRecord, + ) def create_comparison_report( diff --git a/policyengine_api/services/v2/comparison_runs/database_connectors/reads.py b/policyengine_api/services/v2/comparison_runs/database_connectors/reads.py index 8324c4b54..c06b6d337 100644 --- a/policyengine_api/services/v2/comparison_runs/database_connectors/reads.py +++ b/policyengine_api/services/v2/comparison_runs/database_connectors/reads.py @@ -2,7 +2,7 @@ from __future__ import annotations -from uuid import UUID +from typing import TYPE_CHECKING from sqlmodel import Session, select @@ -10,10 +10,14 @@ Stage12ComparisonReport, Stage12ComparisonSimulation, ) -from policyengine_api.services.v2.comparison_runs.types import ( - ComparisonReportRecord, - ComparisonSimulationRecord, -) + +if TYPE_CHECKING: + from uuid import UUID + + from policyengine_api.services.v2.comparison_runs.types import ( + ComparisonReportRecord, + ComparisonSimulationRecord, + ) def read_comparison_report( diff --git a/policyengine_api/services/v2/comparison_runs/database_connectors/updates.py b/policyengine_api/services/v2/comparison_runs/database_connectors/updates.py index 5286cb6e7..91f4fa773 100644 --- a/policyengine_api/services/v2/comparison_runs/database_connectors/updates.py +++ b/policyengine_api/services/v2/comparison_runs/database_connectors/updates.py @@ -2,7 +2,7 @@ from __future__ import annotations -from sqlmodel import Session +from typing import TYPE_CHECKING from policyengine_api.data.v2.models import ( Stage12AggregationStatus, @@ -11,10 +11,14 @@ Stage12ResultComparisonStatus, Stage12RunStatus, ) -from policyengine_api.services.v2.comparison_runs.types import ( - ComparisonReportRecord, - ComparisonSimulationRecord, -) + +if TYPE_CHECKING: + from sqlmodel import Session + + from policyengine_api.services.v2.comparison_runs.types import ( + ComparisonReportRecord, + ComparisonSimulationRecord, + ) REPORT_MUTABLE_FIELDS = ( "coordinator_invocation_id", diff --git a/policyengine_api/services/v2/comparison_runs/database_session.py b/policyengine_api/services/v2/comparison_runs/database_session.py index 454d68519..e3d156915 100644 --- a/policyengine_api/services/v2/comparison_runs/database_session.py +++ b/policyengine_api/services/v2/comparison_runs/database_session.py @@ -2,11 +2,14 @@ from __future__ import annotations -from collections.abc import Iterator from contextlib import contextmanager +from typing import TYPE_CHECKING -from sqlalchemy.orm import sessionmaker -from sqlmodel import Session +if TYPE_CHECKING: + from collections.abc import Iterator + + from sqlalchemy.orm import sessionmaker + from sqlmodel import Session class ComparisonRunDatabaseSession: diff --git a/policyengine_api/services/v2/comparison_runs/services.py b/policyengine_api/services/v2/comparison_runs/services.py index 1437d2f41..abb22116a 100644 --- a/policyengine_api/services/v2/comparison_runs/services.py +++ b/policyengine_api/services/v2/comparison_runs/services.py @@ -2,9 +2,7 @@ from __future__ import annotations -from uuid import UUID - -from sqlmodel import Session +from typing import TYPE_CHECKING from policyengine_api.services.v2.comparison_runs.database_connectors.creates import ( create_comparison_report, @@ -21,9 +19,6 @@ update_comparison_simulation, update_report_result_comparison, ) -from policyengine_api.services.v2.comparison_runs.database_session import ( - ComparisonRunDatabaseSession, -) from policyengine_api.services.v2.comparison_runs.transformations import ( report_record, simulation_record, @@ -52,6 +47,15 @@ require_successful_simulation_replay, ) +if TYPE_CHECKING: + from uuid import UUID + + from sqlmodel import Session + + from policyengine_api.services.v2.comparison_runs.database_session import ( + ComparisonRunDatabaseSession, + ) + def create_or_resolve_report( session: Session, diff --git a/policyengine_api/services/v2/comparison_runs/validators.py b/policyengine_api/services/v2/comparison_runs/validators.py index 756a8b41b..56dd42d8b 100644 --- a/policyengine_api/services/v2/comparison_runs/validators.py +++ b/policyengine_api/services/v2/comparison_runs/validators.py @@ -2,7 +2,7 @@ from __future__ import annotations -from collections.abc import Iterable +from typing import TYPE_CHECKING from policyengine_api.services.v2.comparison_runs.types import ( ComparisonReportRecord, @@ -12,6 +12,9 @@ ResultComparisonStatus, ) +if TYPE_CHECKING: + from collections.abc import Iterable + class ComparisonRunNotFoundError(LookupError): """Raised when a requested temporary comparison record does not exist.""" diff --git a/policyengine_api/services/v2/households/database_connectors/creates.py b/policyengine_api/services/v2/households/database_connectors/creates.py index 2349b913c..72624119c 100644 --- a/policyengine_api/services/v2/households/database_connectors/creates.py +++ b/policyengine_api/services/v2/households/database_connectors/creates.py @@ -2,13 +2,16 @@ from __future__ import annotations +from typing import TYPE_CHECKING from uuid import UUID, uuid4 from sqlalchemy.dialects.postgresql import insert from sqlmodel import Session, col from policyengine_api.data.v2.models import Household, LegacyHouseholdMapping -from policyengine_api.services.v2.households.types import HouseholdCreationInput + +if TYPE_CHECKING: + from policyengine_api.services.v2.households.types import HouseholdCreationInput def create_household( diff --git a/policyengine_api/services/v2/households/database_connectors/reads.py b/policyengine_api/services/v2/households/database_connectors/reads.py index 6a1b5ed91..107a72ca8 100644 --- a/policyengine_api/services/v2/households/database_connectors/reads.py +++ b/policyengine_api/services/v2/households/database_connectors/reads.py @@ -2,12 +2,15 @@ from __future__ import annotations -from uuid import UUID +from typing import TYPE_CHECKING from sqlmodel import Session, col, select from policyengine_api.data.v2.models import Household, LegacyHouseholdMapping +if TYPE_CHECKING: + from uuid import UUID + def read_household_by_content_identity( session: Session, *, canonicalization_version: int, content_hash: str diff --git a/policyengine_api/services/v2/households/database_session.py b/policyengine_api/services/v2/households/database_session.py index 61141cfc7..c0d580c90 100644 --- a/policyengine_api/services/v2/households/database_session.py +++ b/policyengine_api/services/v2/households/database_session.py @@ -2,11 +2,14 @@ from __future__ import annotations -from collections.abc import Iterator from contextlib import contextmanager +from typing import TYPE_CHECKING -from sqlalchemy.orm import sessionmaker -from sqlmodel import Session +if TYPE_CHECKING: + from collections.abc import Iterator + + from sqlalchemy.orm import sessionmaker + from sqlmodel import Session class HouseholdDatabaseSession: diff --git a/policyengine_api/services/v2/households/services.py b/policyengine_api/services/v2/households/services.py index 265588c84..0bfe957fa 100644 --- a/policyengine_api/services/v2/households/services.py +++ b/policyengine_api/services/v2/households/services.py @@ -2,10 +2,7 @@ from __future__ import annotations -from collections.abc import Callable -from uuid import UUID - -from sqlmodel import Session +from typing import TYPE_CHECKING from policyengine_api.services.v2.households.database_connectors.creates import ( create_household, @@ -17,9 +14,6 @@ read_household_rows, read_legacy_household_mapping, ) -from policyengine_api.services.v2.households.database_session import ( - HouseholdDatabaseSession, -) from policyengine_api.services.v2.households.transformations import ( LEGACY_HOUSEHOLD_FINGERPRINT_VERSION, canonical_household_document, @@ -50,6 +44,16 @@ verify_legacy_household_mapping, ) +if TYPE_CHECKING: + from collections.abc import Callable + from uuid import UUID + + from sqlmodel import Session + + from policyengine_api.services.v2.households.database_session import ( + HouseholdDatabaseSession, + ) + def normalize_creation_input( household_input: HouseholdCreationInput, diff --git a/policyengine_api/services/v2/households/transformations.py b/policyengine_api/services/v2/households/transformations.py index 43c15b06b..25b8acca3 100644 --- a/policyengine_api/services/v2/households/transformations.py +++ b/policyengine_api/services/v2/households/transformations.py @@ -5,13 +5,12 @@ import hashlib import json from decimal import Decimal -from typing import Any, cast +from typing import TYPE_CHECKING, Any, cast from policyengine_core.periods import ( # type: ignore[import-untyped] period as parse_policyengine_period, ) -from policyengine_api.data.v2.models import Household from policyengine_api.services.v2.households.types import ( CanonicalHouseholdContent, HouseholdCreationInput, @@ -25,6 +24,9 @@ normalize_household_document, ) +if TYPE_CHECKING: + from policyengine_api.data.v2.models import Household + HOUSEHOLD_CANONICALIZATION_VERSION = 1 LEGACY_HOUSEHOLD_FINGERPRINT_VERSION = 1 LEGACY_COLLECTION_NAMES = { @@ -68,17 +70,17 @@ def _canonical_json(value: Any) -> str: def canonical_household_document(household_input: HouseholdCreationInput) -> bytes: - content = dict(cast(dict[str, Any], household_input.household_data)) + content = dict(cast("dict[str, Any]", household_input.household_data)) for collection, records in content.items(): if collection in { "people", *SUPPORTED_ENTITY_COLLECTIONS[household_input.country_id], }: assert isinstance(records, list) - typed_records = cast(list[dict[str, Any]], records) + typed_records = cast("list[dict[str, Any]]", records) content[collection] = sorted( typed_records, - key=lambda record: cast(str, record["id"]), + key=lambda record: cast("str", record["id"]), ) document = { "canonicalization_version": HOUSEHOLD_CANONICALIZATION_VERSION, diff --git a/policyengine_api/services/v2/households/types.py b/policyengine_api/services/v2/households/types.py index 17b50d609..dab71705a 100644 --- a/policyengine_api/services/v2/households/types.py +++ b/policyengine_api/services/v2/households/types.py @@ -3,9 +3,7 @@ from __future__ import annotations from dataclasses import dataclass -from datetime import datetime -from typing import Annotated, Any -from uuid import UUID +from typing import TYPE_CHECKING, Annotated, Any from pydantic import ( BaseModel, @@ -19,6 +17,10 @@ from policyengine_api.query_parameters import CountryId, DefaultYear from policyengine_api.services.v2.households.validators import require_json_value +if TYPE_CHECKING: + from datetime import datetime + from uuid import UUID + StrictJsonValue = Annotated[JsonValue, BeforeValidator(require_json_value)] diff --git a/policyengine_api/services/v2/households/validators.py b/policyengine_api/services/v2/households/validators.py index ee0abefc6..ec031e71b 100644 --- a/policyengine_api/services/v2/households/validators.py +++ b/policyengine_api/services/v2/households/validators.py @@ -3,12 +3,15 @@ from __future__ import annotations import math -from typing import Any -from uuid import UUID +from typing import TYPE_CHECKING, Any -from policyengine_api.data.v2.models import LegacyHouseholdMapping from policyengine_api.spm import SPMSelection +if TYPE_CHECKING: + from uuid import UUID + + from policyengine_api.data.v2.models import LegacyHouseholdMapping + MAXIMUM_HOUSEHOLD_ENTITIES = 1_000 MAXIMUM_HOUSEHOLD_RECORD_VALUES = 500 MAXIMUM_JSON_NESTING = 100 diff --git a/policyengine_api/services/v2/metadata/database_connectors/reads.py b/policyengine_api/services/v2/metadata/database_connectors/reads.py index d44aaa432..bc202807f 100644 --- a/policyengine_api/services/v2/metadata/database_connectors/reads.py +++ b/policyengine_api/services/v2/metadata/database_connectors/reads.py @@ -2,10 +2,9 @@ from __future__ import annotations -from typing import Any +from typing import TYPE_CHECKING, Any from sqlalchemy.exc import SQLAlchemyError -from sqlmodel import Session from policyengine_api.data.v2.catalog.catalog_selection import ( MetadataCatalogUnavailableError, @@ -13,6 +12,9 @@ select_catalog, ) +if TYPE_CHECKING: + from sqlmodel import Session + def read_metadata_catalog( session: Session, diff --git a/policyengine_api/services/v2/metadata/database_connectors/reads_datasets.py b/policyengine_api/services/v2/metadata/database_connectors/reads_datasets.py index d719e443a..27a197bae 100644 --- a/policyengine_api/services/v2/metadata/database_connectors/reads_datasets.py +++ b/policyengine_api/services/v2/metadata/database_connectors/reads_datasets.py @@ -2,13 +2,16 @@ from __future__ import annotations -from uuid import UUID +from typing import TYPE_CHECKING from sqlmodel import Session, col, select from policyengine_api.data.v2.models import Dataset from policyengine_api.services.v2.metadata.database_connectors.reads import read_rows +if TYPE_CHECKING: + from uuid import UUID + def read_datasets( session: Session, *, model_version_id: UUID, offset: int, limit: int diff --git a/policyengine_api/services/v2/metadata/database_connectors/reads_parameter_tree.py b/policyengine_api/services/v2/metadata/database_connectors/reads_parameter_tree.py index 011f2d75d..880df9f75 100644 --- a/policyengine_api/services/v2/metadata/database_connectors/reads_parameter_tree.py +++ b/policyengine_api/services/v2/metadata/database_connectors/reads_parameter_tree.py @@ -2,8 +2,7 @@ from __future__ import annotations -from typing import Any -from uuid import UUID +from typing import TYPE_CHECKING, Any import sqlalchemy as sa from sqlmodel import Session, col @@ -11,6 +10,9 @@ from policyengine_api.data.v2.models import Parameter, ParameterNode from policyengine_api.services.v2.metadata.database_connectors.reads import read_rows +if TYPE_CHECKING: + from uuid import UUID + def _escaped_like(value: str) -> str: return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") diff --git a/policyengine_api/services/v2/metadata/database_connectors/reads_parameters.py b/policyengine_api/services/v2/metadata/database_connectors/reads_parameters.py index 69817fb5c..848804c66 100644 --- a/policyengine_api/services/v2/metadata/database_connectors/reads_parameters.py +++ b/policyengine_api/services/v2/metadata/database_connectors/reads_parameters.py @@ -2,8 +2,7 @@ from __future__ import annotations -from datetime import datetime -from uuid import UUID +from typing import TYPE_CHECKING import sqlalchemy as sa from sqlmodel import Session, col, select @@ -11,6 +10,10 @@ from policyengine_api.data.v2.models import Parameter, ParameterValue from policyengine_api.services.v2.metadata.database_connectors.reads import read_rows +if TYPE_CHECKING: + from datetime import datetime + from uuid import UUID + def _escape_like(value: str) -> str: return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") diff --git a/policyengine_api/services/v2/metadata/database_connectors/reads_regions.py b/policyengine_api/services/v2/metadata/database_connectors/reads_regions.py index 84f85d668..719f77a20 100644 --- a/policyengine_api/services/v2/metadata/database_connectors/reads_regions.py +++ b/policyengine_api/services/v2/metadata/database_connectors/reads_regions.py @@ -2,13 +2,16 @@ from __future__ import annotations -from uuid import UUID +from typing import TYPE_CHECKING from sqlmodel import Session, col, select from policyengine_api.data.v2.models import Dataset, Region from policyengine_api.services.v2.metadata.database_connectors.reads import read_rows +if TYPE_CHECKING: + from uuid import UUID + def read_regions( session: Session, diff --git a/policyengine_api/services/v2/metadata/database_connectors/reads_variables.py b/policyengine_api/services/v2/metadata/database_connectors/reads_variables.py index f9ba8dad9..f833c2257 100644 --- a/policyengine_api/services/v2/metadata/database_connectors/reads_variables.py +++ b/policyengine_api/services/v2/metadata/database_connectors/reads_variables.py @@ -2,7 +2,7 @@ from __future__ import annotations -from uuid import UUID +from typing import TYPE_CHECKING import sqlalchemy as sa from sqlmodel import Session, col, select @@ -10,6 +10,9 @@ from policyengine_api.data.v2.models import Variable from policyengine_api.services.v2.metadata.database_connectors.reads import read_rows +if TYPE_CHECKING: + from uuid import UUID + def _escape_like(value: str) -> str: return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") diff --git a/policyengine_api/services/v2/metadata/database_session.py b/policyengine_api/services/v2/metadata/database_session.py index 32c6f0d0d..e0e1fb278 100644 --- a/policyengine_api/services/v2/metadata/database_session.py +++ b/policyengine_api/services/v2/metadata/database_session.py @@ -2,7 +2,10 @@ from __future__ import annotations -from sqlmodel import Session +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from sqlmodel import Session class MetadataDatabaseSession: diff --git a/policyengine_api/services/v2/metadata/services.py b/policyengine_api/services/v2/metadata/services.py index a0f9e625a..948743543 100644 --- a/policyengine_api/services/v2/metadata/services.py +++ b/policyengine_api/services/v2/metadata/services.py @@ -3,7 +3,7 @@ from __future__ import annotations from datetime import UTC, datetime -from uuid import UUID +from typing import TYPE_CHECKING from policyengine_api.data.v2.catalog.catalog_selection import ( SelectedCatalog, @@ -34,9 +34,6 @@ read_variable, read_variables, ) -from policyengine_api.services.v2.metadata.database_session import ( - MetadataDatabaseSession, -) from policyengine_api.services.v2.metadata.transformations import ( metadata_dataset, metadata_economy_options, @@ -71,6 +68,13 @@ validate_metadata_page, ) +if TYPE_CHECKING: + from uuid import UUID + + from policyengine_api.services.v2.metadata.database_session import ( + MetadataDatabaseSession, + ) + class V2MetadataService: """Sequence metadata reads through a request-scoped database session.""" diff --git a/policyengine_api/services/v2/metadata/transformations.py b/policyengine_api/services/v2/metadata/transformations.py index e71ef90f4..e17a9fd57 100644 --- a/policyengine_api/services/v2/metadata/transformations.py +++ b/policyengine_api/services/v2/metadata/transformations.py @@ -3,16 +3,8 @@ from __future__ import annotations from datetime import UTC, datetime -from typing import Any, TypeVar - -from policyengine_api.data.v2.catalog.catalog_selection import SelectedCatalog -from policyengine_api.data.v2.models import ( - Dataset, - Parameter, - ParameterValue, - Region, - Variable, -) +from typing import TYPE_CHECKING, Any, TypeVar + from policyengine_api.dataset_display import get_dataset_display_label from policyengine_api.services.v2.metadata.types import ( MetadataCanonicalParameterValue, @@ -32,6 +24,16 @@ MetadataVariable, ) +if TYPE_CHECKING: + from policyengine_api.data.v2.catalog.catalog_selection import SelectedCatalog + from policyengine_api.data.v2.models import ( + Dataset, + Parameter, + ParameterValue, + Region, + Variable, + ) + ResourceT = TypeVar("ResourceT") diff --git a/policyengine_api/services/v2/policies/database_connectors/creates.py b/policyengine_api/services/v2/policies/database_connectors/creates.py index 1821921b4..ccebd0bb3 100644 --- a/policyengine_api/services/v2/policies/database_connectors/creates.py +++ b/policyengine_api/services/v2/policies/database_connectors/creates.py @@ -2,13 +2,16 @@ from __future__ import annotations +from typing import TYPE_CHECKING from uuid import UUID, uuid4 from sqlalchemy.dialects.postgresql import insert from sqlmodel import Session, col from policyengine_api.data.v2.models import LegacyPolicyMapping, ParameterValue, Policy -from policyengine_api.services.v2.policies.types import ResolvedPolicyCreationInput + +if TYPE_CHECKING: + from policyengine_api.services.v2.policies.types import ResolvedPolicyCreationInput def create_policy( diff --git a/policyengine_api/services/v2/policies/database_connectors/reads.py b/policyengine_api/services/v2/policies/database_connectors/reads.py index 0276a1b43..244bb9eeb 100644 --- a/policyengine_api/services/v2/policies/database_connectors/reads.py +++ b/policyengine_api/services/v2/policies/database_connectors/reads.py @@ -2,7 +2,7 @@ from __future__ import annotations -from uuid import UUID +from typing import TYPE_CHECKING from sqlmodel import Session, col, select @@ -19,6 +19,9 @@ TaxBenefitModelVersion, ) +if TYPE_CHECKING: + from uuid import UUID + def read_policy_catalog( session: Session, diff --git a/policyengine_api/services/v2/policies/database_session.py b/policyengine_api/services/v2/policies/database_session.py index 3a8a6e057..af57e150e 100644 --- a/policyengine_api/services/v2/policies/database_session.py +++ b/policyengine_api/services/v2/policies/database_session.py @@ -2,11 +2,14 @@ from __future__ import annotations -from collections.abc import Iterator from contextlib import contextmanager +from typing import TYPE_CHECKING -from sqlalchemy.orm import sessionmaker -from sqlmodel import Session +if TYPE_CHECKING: + from collections.abc import Iterator + + from sqlalchemy.orm import sessionmaker + from sqlmodel import Session class PolicyDatabaseSession: diff --git a/policyengine_api/services/v2/policies/services.py b/policyengine_api/services/v2/policies/services.py index 6f5ac9959..0fcb68c23 100644 --- a/policyengine_api/services/v2/policies/services.py +++ b/policyengine_api/services/v2/policies/services.py @@ -2,10 +2,7 @@ from __future__ import annotations -from collections.abc import Callable, Mapping -from uuid import UUID - -from sqlmodel import Session +from typing import TYPE_CHECKING from policyengine_api.constants import COUNTRY_PACKAGE_VERSIONS, POLICYENGINE_VERSION from policyengine_api.services.v2.policies.database_connectors.creates import ( @@ -25,7 +22,6 @@ read_policy_rows, read_version_parameter_ids, ) -from policyengine_api.services.v2.policies.database_session import PolicyDatabaseSession from policyengine_api.services.v2.policies.transformations import ( canonical_policy_document, canonicalize_policy, @@ -56,6 +52,16 @@ verify_legacy_policy_mapping, ) +if TYPE_CHECKING: + from collections.abc import Callable, Mapping + from uuid import UUID + + from sqlmodel import Session + + from policyengine_api.services.v2.policies.database_session import ( + PolicyDatabaseSession, + ) + def resolve_policy_creation_input( session: Session, diff --git a/policyengine_api/services/v2/policies/transformations.py b/policyengine_api/services/v2/policies/transformations.py index 1e4a93488..839c7466d 100644 --- a/policyengine_api/services/v2/policies/transformations.py +++ b/policyengine_api/services/v2/policies/transformations.py @@ -4,23 +4,14 @@ import hashlib import json -from collections.abc import Mapping from datetime import UTC, date, datetime, time from decimal import Decimal -from typing import Any -from uuid import UUID +from typing import TYPE_CHECKING, Any from policyengine_core.periods import ( # type: ignore[import-untyped] period as parse_policyengine_period, ) -from policyengine_api.data.v2.catalog.catalog_selection import SelectedCatalog -from policyengine_api.data.v2.models import ( - Parameter, - ParameterValue, - Policy, - TaxBenefitModelVersion, -) from policyengine_api.services.v2.policies.types import ( CanonicalPolicyContent, LegacyPolicySnapshot, @@ -36,6 +27,18 @@ validate_policy_catalog, ) +if TYPE_CHECKING: + from collections.abc import Mapping + from uuid import UUID + + from policyengine_api.data.v2.catalog.catalog_selection import SelectedCatalog + from policyengine_api.data.v2.models import ( + Parameter, + ParameterValue, + Policy, + TaxBenefitModelVersion, + ) + POLICY_CANONICALIZATION_VERSION = 1 diff --git a/policyengine_api/services/v2/policies/validators.py b/policyengine_api/services/v2/policies/validators.py index ac0322e82..bc3afa613 100644 --- a/policyengine_api/services/v2/policies/validators.py +++ b/policyengine_api/services/v2/policies/validators.py @@ -5,12 +5,12 @@ import math from datetime import UTC, datetime from typing import TYPE_CHECKING, Any -from uuid import UUID - -from policyengine_api.data.v2.catalog.catalog_selection import SelectedCatalog -from policyengine_api.data.v2.models import LegacyPolicyMapping if TYPE_CHECKING: + from uuid import UUID + + from policyengine_api.data.v2.catalog.catalog_selection import SelectedCatalog + from policyengine_api.data.v2.models import LegacyPolicyMapping from policyengine_api.services.v2.policies.types import ( PolicyCreationInput, ResolvedPolicyCreationInput, diff --git a/policyengine_api/services/v2/user_households/database_connectors/reads.py b/policyengine_api/services/v2/user_households/database_connectors/reads.py index 02d9b8288..bcab40a73 100644 --- a/policyengine_api/services/v2/user_households/database_connectors/reads.py +++ b/policyengine_api/services/v2/user_households/database_connectors/reads.py @@ -2,12 +2,15 @@ from __future__ import annotations -from uuid import UUID +from typing import TYPE_CHECKING from sqlmodel import Session, col, select from policyengine_api.data.v2.models import Household, User, UserHouseholdAssociation +if TYPE_CHECKING: + from uuid import UUID + def read_user(session: Session, user_id: UUID) -> User | None: return session.get(User, user_id) diff --git a/policyengine_api/services/v2/user_households/database_session.py b/policyengine_api/services/v2/user_households/database_session.py index f67454d32..ffa531976 100644 --- a/policyengine_api/services/v2/user_households/database_session.py +++ b/policyengine_api/services/v2/user_households/database_session.py @@ -2,11 +2,14 @@ from __future__ import annotations -from collections.abc import Iterator from contextlib import contextmanager +from typing import TYPE_CHECKING -from sqlalchemy.orm import sessionmaker -from sqlmodel import Session +if TYPE_CHECKING: + from collections.abc import Iterator + + from sqlalchemy.orm import sessionmaker + from sqlmodel import Session class UserHouseholdDatabaseSession: diff --git a/policyengine_api/services/v2/user_households/services.py b/policyengine_api/services/v2/user_households/services.py index 0853e3114..a26a9bf5c 100644 --- a/policyengine_api/services/v2/user_households/services.py +++ b/policyengine_api/services/v2/user_households/services.py @@ -2,9 +2,7 @@ from __future__ import annotations -from uuid import UUID - -from sqlmodel import Session +from typing import TYPE_CHECKING from policyengine_api.services.v2.user_households.database_connectors.creates import ( create_user_household, @@ -21,25 +19,31 @@ from policyengine_api.services.v2.user_households.database_connectors.updates import ( update_user_household, ) -from policyengine_api.services.v2.user_households.database_session import ( - UserHouseholdDatabaseSession, -) from policyengine_api.services.v2.user_households.transformations import ( user_household_page, user_household_read, ) -from policyengine_api.services.v2.user_households.types import ( - UserHouseholdCreationInput, - UserHouseholdPage, - UserHouseholdRead, - UserHouseholdUpdateInput, -) from policyengine_api.services.v2.user_households.validators import ( require_user_household, validate_association_creation, validate_association_household, ) +if TYPE_CHECKING: + from uuid import UUID + + from sqlmodel import Session + + from policyengine_api.services.v2.user_households.database_session import ( + UserHouseholdDatabaseSession, + ) + from policyengine_api.services.v2.user_households.types import ( + UserHouseholdCreationInput, + UserHouseholdPage, + UserHouseholdRead, + UserHouseholdUpdateInput, + ) + def read_complete_user_household( session: Session, diff --git a/policyengine_api/services/v2/user_households/types.py b/policyengine_api/services/v2/user_households/types.py index 095be46eb..7ce3c1fb2 100644 --- a/policyengine_api/services/v2/user_households/types.py +++ b/policyengine_api/services/v2/user_households/types.py @@ -3,14 +3,16 @@ from __future__ import annotations from dataclasses import dataclass -from datetime import datetime -from typing import Annotated -from uuid import UUID +from typing import TYPE_CHECKING, Annotated from pydantic import BaseModel, ConfigDict, StringConstraints, model_validator from policyengine_api.query_parameters import CountryId, ResourceId, UserId +if TYPE_CHECKING: + from datetime import datetime + from uuid import UUID + AssociationName = Annotated[str, StringConstraints(max_length=255)] diff --git a/policyengine_api/services/v2/user_households/validators.py b/policyengine_api/services/v2/user_households/validators.py index f33200064..4a27aa531 100644 --- a/policyengine_api/services/v2/user_households/validators.py +++ b/policyengine_api/services/v2/user_households/validators.py @@ -2,12 +2,19 @@ from __future__ import annotations -from uuid import UUID +from typing import TYPE_CHECKING -from policyengine_api.data.v2.models import Household, User, UserHouseholdAssociation -from policyengine_api.services.v2.user_households.types import ( - UserHouseholdCreationInput, -) +if TYPE_CHECKING: + from uuid import UUID + + from policyengine_api.data.v2.models import ( + Household, + User, + UserHouseholdAssociation, + ) + from policyengine_api.services.v2.user_households.types import ( + UserHouseholdCreationInput, + ) class UserHouseholdNotFoundError(LookupError): diff --git a/policyengine_api/services/v2/user_policies/database_connectors/creates.py b/policyengine_api/services/v2/user_policies/database_connectors/creates.py index e8d48701f..822abafd3 100644 --- a/policyengine_api/services/v2/user_policies/database_connectors/creates.py +++ b/policyengine_api/services/v2/user_policies/database_connectors/creates.py @@ -2,7 +2,7 @@ from __future__ import annotations -from uuid import UUID +from typing import TYPE_CHECKING from sqlalchemy.dialects.postgresql import insert from sqlmodel import Session, col @@ -13,7 +13,11 @@ User, UserPolicy, ) -from policyengine_api.services.v2.user_policies.types import UserPolicyCreationInput + +if TYPE_CHECKING: + from uuid import UUID + + from policyengine_api.services.v2.user_policies.types import UserPolicyCreationInput def create_user_policy( diff --git a/policyengine_api/services/v2/user_policies/database_connectors/deletes.py b/policyengine_api/services/v2/user_policies/database_connectors/deletes.py index 7b0a077de..7b62bb24b 100644 --- a/policyengine_api/services/v2/user_policies/database_connectors/deletes.py +++ b/policyengine_api/services/v2/user_policies/database_connectors/deletes.py @@ -2,9 +2,12 @@ from __future__ import annotations -from sqlmodel import Session +from typing import TYPE_CHECKING -from policyengine_api.data.v2.models import User, UserPolicy +if TYPE_CHECKING: + from sqlmodel import Session + + from policyengine_api.data.v2.models import User, UserPolicy def delete_user_policy(session: Session, association: UserPolicy) -> None: diff --git a/policyengine_api/services/v2/user_policies/database_connectors/reads.py b/policyengine_api/services/v2/user_policies/database_connectors/reads.py index 66a12624f..0ac9acaa5 100644 --- a/policyengine_api/services/v2/user_policies/database_connectors/reads.py +++ b/policyengine_api/services/v2/user_policies/database_connectors/reads.py @@ -2,7 +2,7 @@ from __future__ import annotations -from uuid import UUID +from typing import TYPE_CHECKING from sqlmodel import Session, col, select @@ -14,6 +14,9 @@ UserPolicy, ) +if TYPE_CHECKING: + from uuid import UUID + def read_user(session: Session, user_id: UUID) -> User | None: return session.get(User, user_id) diff --git a/policyengine_api/services/v2/user_policies/database_connectors/updates.py b/policyengine_api/services/v2/user_policies/database_connectors/updates.py index c58fd4d12..0422b1317 100644 --- a/policyengine_api/services/v2/user_policies/database_connectors/updates.py +++ b/policyengine_api/services/v2/user_policies/database_connectors/updates.py @@ -2,11 +2,15 @@ from __future__ import annotations -from sqlmodel import Session +from typing import TYPE_CHECKING -from policyengine_api.data.v2.models import LegacyUserPolicyMapping, UserPolicy from policyengine_api.data.v2.models.base import utc_now -from policyengine_api.services.v2.user_policies.types import UserPolicyUpdateInput + +if TYPE_CHECKING: + from sqlmodel import Session + + from policyengine_api.data.v2.models import LegacyUserPolicyMapping, UserPolicy + from policyengine_api.services.v2.user_policies.types import UserPolicyUpdateInput def update_user_policy( diff --git a/policyengine_api/services/v2/user_policies/database_session.py b/policyengine_api/services/v2/user_policies/database_session.py index f7576c23b..78d084ac9 100644 --- a/policyengine_api/services/v2/user_policies/database_session.py +++ b/policyengine_api/services/v2/user_policies/database_session.py @@ -2,11 +2,14 @@ from __future__ import annotations -from collections.abc import Iterator from contextlib import contextmanager +from typing import TYPE_CHECKING -from sqlalchemy.orm import sessionmaker -from sqlmodel import Session +if TYPE_CHECKING: + from collections.abc import Iterator + + from sqlalchemy.orm import sessionmaker + from sqlmodel import Session class UserPolicyDatabaseSession: diff --git a/policyengine_api/services/v2/user_policies/services.py b/policyengine_api/services/v2/user_policies/services.py index ac71912d4..627ea2898 100644 --- a/policyengine_api/services/v2/user_policies/services.py +++ b/policyengine_api/services/v2/user_policies/services.py @@ -2,15 +2,11 @@ from __future__ import annotations -from uuid import UUID +from typing import TYPE_CHECKING -from sqlmodel import Session - -from policyengine_api.data.v2.models import LegacyUserPolicyMapping from policyengine_api.services.v2.policies.services import ( mirror_legacy_policy_in_session, ) -from policyengine_api.services.v2.policies.types import LegacyPolicySnapshot from policyengine_api.services.v2.user_policies.database_connectors.creates import ( create_legacy_user_mapping, create_legacy_user_policy_mapping, @@ -34,9 +30,6 @@ update_legacy_user_policy_state, update_user_policy, ) -from policyengine_api.services.v2.user_policies.database_session import ( - UserPolicyDatabaseSession, -) from policyengine_api.services.v2.user_policies.transformations import ( USER_POLICY_FINGERPRINT_VERSION, fingerprint_legacy_user_policy, @@ -64,6 +57,17 @@ validate_legacy_user_policy_input, ) +if TYPE_CHECKING: + from uuid import UUID + + from sqlmodel import Session + + from policyengine_api.data.v2.models import LegacyUserPolicyMapping + from policyengine_api.services.v2.policies.types import LegacyPolicySnapshot + from policyengine_api.services.v2.user_policies.database_session import ( + UserPolicyDatabaseSession, + ) + def read_complete_user_policy( session: Session, *, country_id: str, association_id: UUID diff --git a/policyengine_api/services/v2/user_policies/transformations.py b/policyengine_api/services/v2/user_policies/transformations.py index 230f7a27e..002987117 100644 --- a/policyengine_api/services/v2/user_policies/transformations.py +++ b/policyengine_api/services/v2/user_policies/transformations.py @@ -4,9 +4,8 @@ import json from hashlib import sha256 -from uuid import UUID +from typing import TYPE_CHECKING -from policyengine_api.data.v2.models import UserPolicy from policyengine_api.services.v2.user_policies.types import ( LegacyUserPolicySnapshot, UserPolicyCreationInput, @@ -14,6 +13,11 @@ UserPolicyRead, ) +if TYPE_CHECKING: + from uuid import UUID + + from policyengine_api.data.v2.models import UserPolicy + USER_POLICY_FINGERPRINT_VERSION = 1 diff --git a/policyengine_api/services/v2/user_policies/types.py b/policyengine_api/services/v2/user_policies/types.py index 5a4a97f94..2a1a62ded 100644 --- a/policyengine_api/services/v2/user_policies/types.py +++ b/policyengine_api/services/v2/user_policies/types.py @@ -3,10 +3,8 @@ from __future__ import annotations from dataclasses import dataclass -from datetime import datetime from enum import StrEnum -from typing import Annotated -from uuid import UUID +from typing import TYPE_CHECKING, Annotated from pydantic import BaseModel, ConfigDict, Field, StringConstraints, model_validator @@ -17,6 +15,10 @@ UserId, ) +if TYPE_CHECKING: + from datetime import datetime + from uuid import UUID + class StrictAssociationInput(BaseModel): """Reject fields outside the reviewed association contract.""" diff --git a/policyengine_api/services/v2/user_policies/validators.py b/policyengine_api/services/v2/user_policies/validators.py index 3172750e8..5a5916c09 100644 --- a/policyengine_api/services/v2/user_policies/validators.py +++ b/policyengine_api/services/v2/user_policies/validators.py @@ -3,20 +3,21 @@ from __future__ import annotations from typing import TYPE_CHECKING -from uuid import UUID - -from policyengine_api.data.v2.models import ( - LegacyUserMapping, - LegacyUserPolicyMapping, - Policy, - User, - UserPolicy, -) + from policyengine_api.services.v2.user_policies.types import ( LegacyUserPolicyMappingAction, ) if TYPE_CHECKING: + from uuid import UUID + + from policyengine_api.data.v2.models import ( + LegacyUserMapping, + LegacyUserPolicyMapping, + Policy, + User, + UserPolicy, + ) from policyengine_api.services.v2.policies.types import LegacyPolicySnapshot from policyengine_api.services.v2.user_policies.types import ( LegacyUserPolicySnapshot, diff --git a/policyengine_api/specification.py b/policyengine_api/specification.py index 1d4b378a5..986988ed7 100644 --- a/policyengine_api/specification.py +++ b/policyengine_api/specification.py @@ -4,12 +4,11 @@ import json from pathlib import Path -from typing import cast +from typing import TYPE_CHECKING, cast import yaml from policyengine_api.constants import VERSION -from policyengine_api.json_types import JSONObject from policyengine_api.query_parameters import ( AnnualEconomyQuery, BudgetWindowEconomyQuery, @@ -22,6 +21,9 @@ SPMSelection, ) +if TYPE_CHECKING: + from policyengine_api.json_types import JSONObject + DEFAULT_SPECIFICATION_PATH = Path(__file__).with_name("openapi_spec.yaml") @@ -110,7 +112,7 @@ def load_specification( ] + _economy_query_parameters(query_model) # YAML accepts both quoted and numeric response codes. JSON object keys are # strings; normalize before Flask sorts them while serializing the document. - return cast(JSONObject, json.loads(json.dumps(document))) + return cast("JSONObject", json.loads(json.dumps(document))) OPENAPI_SPECIFICATION = load_specification() diff --git a/policyengine_api/utils/streaming_json.py b/policyengine_api/utils/streaming_json.py index 53dc17216..f0589a525 100644 --- a/policyengine_api/utils/streaming_json.py +++ b/policyengine_api/utils/streaming_json.py @@ -10,7 +10,10 @@ from __future__ import annotations -from collections.abc import Iterator +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Iterator STREAMING_THRESHOLD_BYTES = 20 * 1024 * 1024 STREAMING_CHUNK_BYTES = 1024 * 1024 diff --git a/pyproject.toml b/pyproject.toml index 226a932bf..9969b37d3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -87,6 +87,8 @@ select = [ "E4", "E7", "E9", "F", # Group 1: safe auto-fixes. "FLY", "FURB", "I", "PIE", "PYI", "RET", "RSE", "UP", "W", + # Group 2: typing-only imports. + "TC", ] [tool.ruff.lint.per-file-ignores] @@ -142,3 +144,55 @@ showcontent = true directory = "removed" name = "Removed" showcontent = true +[tool.ruff.lint.flake8-type-checking] +# Classes whose annotations Pydantic, SQLModel or SQLAlchemy evaluate at runtime, so their +# imports must stay at runtime. Ruff checks direct bases only, so every +# project class that other models inherit from is listed. +runtime-evaluated-base-classes = [ + "policyengine_api.data.v2.models.base.IdentifiedModel", + "policyengine_api.data.v2.models.base.TimestampedModel", + "policyengine_api.data.v2.models.comparison_runs.Stage12RunTimestamps", + "policyengine_api.data.v2.models.reports.GeographicImpactBase", + "policyengine_api.fastapi_routes.v2.households.document_models.HouseholdEntityRecord", + "policyengine_api.fastapi_routes.v2.households.document_models.StrictHouseholdDocumentModel", + "policyengine_api.fastapi_routes.v2.households.response_models.HouseholdSuccessResponse", + "policyengine_api.fastapi_routes.v2.households.response_models.StrictHouseholdAPIModel", + "policyengine_api.fastapi_routes.v2.metadata.response_models.MetadataResourceSuccessResponse", + "policyengine_api.fastapi_routes.v2.policies.response_models.PolicySuccessResponse", + "policyengine_api.fastapi_routes.v2.policies.response_models.StrictPolicyAPIModel", + "policyengine_api.fastapi_routes.v2.user_households.response_models.StrictUserHouseholdAPIModel", + "policyengine_api.fastapi_routes.v2.user_households.response_models.UserHouseholdSuccessResponse", + "policyengine_api.fastapi_routes.v2.user_policies.response_models.StrictUserPolicyAPIModel", + "policyengine_api.fastapi_routes.v2.user_policies.response_models.UserPolicySuccessResponse", + "policyengine_api.query_parameters.CatalogQuery", + "policyengine_api.query_parameters.CountryQuery", + "policyengine_api.query_parameters.EconomyQuery", + "policyengine_api.query_parameters.PaginationQuery", + "policyengine_api.query_parameters.StrictQueryParameters", + "policyengine_api.services.v2.households.types.HouseholdCreationInput", + "policyengine_api.services.v2.households.types.StrictHouseholdInput", + "policyengine_api.services.v2.metadata.types.MetadataModelVersion", + "policyengine_api.services.v2.metadata.types.StrictResponseModel", + "policyengine_api.services.v2.policies.types.PolicyCreationInput", + "policyengine_api.services.v2.policies.types.StrictPolicyInput", + "policyengine_api.services.v2.simulations.types.StrictContractModel", + "policyengine_api.services.v2.user_households.types.StrictUserHouseholdInput", + "policyengine_api.services.v2.user_households.types.UserHouseholdCreationInput", + "policyengine_api.services.v2.user_households.types.UserHouseholdUpdateInput", + "policyengine_api.services.v2.user_policies.types.StrictAssociationInput", + "policyengine_api.services.v2.user_policies.types.UserPolicyCreationInput", + "policyengine_api.services.v2.user_policies.types.UserPolicyUpdateInput", + "policyengine_api.data.v1_models.V1Base", + "pydantic.BaseModel", + "sqlalchemy.orm.DeclarativeBase", + "sqlmodel.SQLModel", +] +# FastAPI evaluates endpoint annotations when routes are registered. +runtime-evaluated-decorators = [ + "fastapi.APIRouter.api_route", + "fastapi.APIRouter.delete", + "fastapi.APIRouter.get", + "fastapi.APIRouter.patch", + "fastapi.APIRouter.post", + "fastapi.APIRouter.put", +] diff --git a/rules/registry.toml b/rules/registry.toml index e7a4c6bdb..46b8c6490 100644 --- a/rules/registry.toml +++ b/rules/registry.toml @@ -679,12 +679,34 @@ autofix = true [rule.enforcer] kind = "ruff" mode = "lint" -codes = ["E4", "E7", "E9", "F", "FLY", "FURB", "I", "PIE", "PYI", "RET", "RSE", "UP", "W"] +codes = ["E4", "E7", "E9", "F", "FLY", "FURB", "I", "PIE", "PYI", "RET", "RSE", "TC", "UP", "W"] [[rule.ci]] workflow = ".github/workflows/pr.yml" job = "lint" invocation = "ruff check ." +[[rule]] +id = "api.quality.runtime-evaluated-models-resolve" +category = "quality" +statement = "Every package model class that other models inherit from is listed in ruff's runtime-evaluated-base-classes, and every Pydantic model and SQLAlchemy mapper resolves its annotations once the package is imported." +rationale = "docs/engineering/skills/typing-only-imports.md#models-that-read-annotations-at-runtime" +scope = ["policyengine_api/**/*.py", "pyproject.toml"] +tier = "mechanical" +status = "enforced" +autofix = false +[rule.enforcer] +kind = "pytest" +nodes = [ + "tests/unit/test_runtime_evaluated_annotations.py::test_every_model_base_is_listed_for_ruff", + "tests/unit/test_runtime_evaluated_annotations.py::test_every_pydantic_model_resolves_its_annotations", + "tests/unit/test_runtime_evaluated_annotations.py::test_every_sqlalchemy_mapper_configures", + "tests/unit/test_runtime_evaluated_annotations.py::test_skipped_app_modules_define_no_classes", +] +[[rule.ci]] +workflow = ".github/workflows/pr.yml" +job = "test" +invocation = "make test" + [[rule]] id = "api.quality.alembic-revisions-unused-imports" category = "quality" diff --git a/scripts/capture_migration_baseline.py b/scripts/capture_migration_baseline.py index 24662190b..75e37dcd0 100644 --- a/scripts/capture_migration_baseline.py +++ b/scripts/capture_migration_baseline.py @@ -12,12 +12,15 @@ import os import statistics import time -from collections.abc import Iterable from dataclasses import dataclass from pathlib import Path +from typing import TYPE_CHECKING import httpx +if TYPE_CHECKING: + from collections.abc import Iterable + @dataclass(frozen=True) class RouteProbe: diff --git a/scripts/process_v1_household_mirror_event.py b/scripts/process_v1_household_mirror_event.py index 566144d49..8ffd8392e 100644 --- a/scripts/process_v1_household_mirror_event.py +++ b/scripts/process_v1_household_mirror_event.py @@ -7,7 +7,7 @@ import json import os import sys -from collections.abc import Mapping, Sequence +from typing import TYPE_CHECKING from sqlalchemy import Engine, create_engine from sqlalchemy.engine import URL @@ -20,6 +20,9 @@ ) from policyengine_api.services.household_service import HouseholdService +if TYPE_CHECKING: + from collections.abc import Mapping, Sequence + class HouseholdEventCommandConfigurationError(RuntimeError): """Raised when command execution does not identify an authorized target.""" diff --git a/scripts/rules/baseline.py b/scripts/rules/baseline.py index 9728f904b..f1a8aeb4b 100644 --- a/scripts/rules/baseline.py +++ b/scripts/rules/baseline.py @@ -4,11 +4,14 @@ import os import subprocess -from pathlib import Path +from typing import TYPE_CHECKING from scripts.rules.load import REGISTRY_PATH, load_text from scripts.rules.model import Registry, Status, Violation +if TYPE_CHECKING: + from pathlib import Path + class BaseUnavailable(Exception): """The base ref could not be read.""" diff --git a/scripts/rules/discover.py b/scripts/rules/discover.py index c7c7e42d9..323425618 100644 --- a/scripts/rules/discover.py +++ b/scripts/rules/discover.py @@ -8,7 +8,7 @@ from __future__ import annotations -from pathlib import Path +from typing import TYPE_CHECKING from scripts.rules.model import Kind, Registry, Violation from scripts.rules.resolve import ( @@ -22,6 +22,9 @@ workflow_jobs, ) +if TYPE_CHECKING: + from pathlib import Path + def _hidden(message: str, fix: str) -> Violation: return Violation(None, "hidden-rule", message, fix) diff --git a/scripts/rules/model.py b/scripts/rules/model.py index 1e7c68739..619138b01 100644 --- a/scripts/rules/model.py +++ b/scripts/rules/model.py @@ -2,11 +2,13 @@ from __future__ import annotations -from collections.abc import Mapping from dataclasses import dataclass, field -from datetime import date from enum import StrEnum -from typing import Any +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from collections.abc import Mapping + from datetime import date class Tier(StrEnum): diff --git a/scripts/rules/resolve.py b/scripts/rules/resolve.py index 1e47b09c7..cddb6536d 100644 --- a/scripts/rules/resolve.py +++ b/scripts/rules/resolve.py @@ -10,14 +10,16 @@ import ast import re import tomllib -from collections.abc import Callable from functools import cache -from pathlib import Path -from typing import Any +from typing import TYPE_CHECKING, Any from scripts.rules.model import CiRef, Kind, Rule, Violation from scripts.rules.workflows import JobBlock, parse_jobs +if TYPE_CHECKING: + from collections.abc import Callable + from pathlib import Path + DEFAULT_RUNNER = "scripts/run_quality_guards.py" RUFF_DEFAULT_SELECT = ("E4", "E7", "E9", "F") PROOF_CI_FIELDS = ("workflow", "job", "invocation") diff --git a/scripts/rules/validate.py b/scripts/rules/validate.py index 0469a5260..f7d117d65 100644 --- a/scripts/rules/validate.py +++ b/scripts/rules/validate.py @@ -4,12 +4,14 @@ import re from datetime import date -from pathlib import Path -from typing import Any +from typing import TYPE_CHECKING, Any from scripts.rules.model import Kind, Registry, Rule, Status, Tier, Violation from scripts.rules.resolve import node_exists +if TYPE_CHECKING: + from pathlib import Path + RULE_ID = re.compile(r"^[a-z0-9]+(\.[a-z0-9][a-z0-9-]*)+$") # Required and optional enforcer fields per kind, with their expected types. diff --git a/tests/contract/clients.py b/tests/contract/clients.py index 2472abd80..485227444 100644 --- a/tests/contract/clients.py +++ b/tests/contract/clients.py @@ -1,14 +1,17 @@ from __future__ import annotations -from collections.abc import Mapping from dataclasses import dataclass -from typing import Protocol +from typing import TYPE_CHECKING, Protocol from fastapi.testclient import TestClient -from flask import Flask from policyengine_api.asgi_factory import create_asgi_app +if TYPE_CHECKING: + from collections.abc import Mapping + + from flask import Flask + @dataclass(frozen=True) class ContractResponse: diff --git a/tests/fixtures/v2_catalog.py b/tests/fixtures/v2_catalog.py index 23ecff6cd..948b9d0e7 100644 --- a/tests/fixtures/v2_catalog.py +++ b/tests/fixtures/v2_catalog.py @@ -4,9 +4,12 @@ from datetime import UTC, datetime from types import SimpleNamespace +from typing import TYPE_CHECKING from policyengine_api.data.v2.catalog.extraction import extract_catalog -from policyengine_api.data.v2.catalog.records import NormalizedCatalog + +if TYPE_CHECKING: + from policyengine_api.data.v2.catalog.records import NormalizedCatalog POLICYENGINE_VERSION = "5.0.4" DEPENDENCY_VERSIONS = { diff --git a/tests/unit/test_runtime_evaluated_annotations.py b/tests/unit/test_runtime_evaluated_annotations.py new file mode 100644 index 000000000..2c75d57ea --- /dev/null +++ b/tests/unit/test_runtime_evaluated_annotations.py @@ -0,0 +1,149 @@ +"""Models that read their annotations at runtime keep the imports those need. + +The package's modules use ``from __future__ import annotations``, so ruff's +typing-only import rules (TC) move imports used only in annotations under +``if TYPE_CHECKING:``. Pydantic, SQLModel and SQLAlchemy read model annotations +at runtime, so ruff keeps a class's annotation imports at runtime only when one +of its direct bases is listed in ``runtime-evaluated-base-classes`` in +pyproject.toml. See docs/engineering/skills/typing-only-imports.md. +""" + +from __future__ import annotations + +import ast +import importlib +import pkgutil +import tomllib +from functools import cache +from pathlib import Path + +import pydantic +import sqlmodel +from sqlalchemy.orm import DeclarativeBase, configure_mappers + +import policyengine_api + +PROJECT_ROOT = Path(__file__).parents[2] +PACKAGE_PREFIX = "policyengine_api." +# These modules assemble the Flask and ASGI apps when imported, which the route +# tests do only with patched settings. They define no classes, which +# test_skipped_app_modules_define_no_classes checks, so skipping them hides no +# model. +APP_ASSEMBLY_MODULES = frozenset({"policyengine_api.api", "policyengine_api.asgi"}) +MODEL_ROOTS = (pydantic.BaseModel, sqlmodel.SQLModel, DeclarativeBase) + + +@cache +def _import_package_modules() -> tuple[str, ...]: + imported = [] + for module_info in pkgutil.walk_packages(policyengine_api.__path__, PACKAGE_PREFIX): + if module_info.name not in APP_ASSEMBLY_MODULES: + importlib.import_module(module_info.name) + imported.append(module_info.name) + return tuple(imported) + + +def _qualified_name(model: type) -> str: + return f"{model.__module__}.{model.__qualname__}" + + +def _generic_origin(model: type) -> type: + """Return the class a Pydantic generic like ``Model[Item]`` was built from. + + Pydantic creates ``Model[Item]`` at runtime as a subclass of ``Model``. Ruff + never sees it as a class of its own: a ``class Child(Model[Item])`` statement + names ``Model``. + """ + metadata = getattr(model, "__pydantic_generic_metadata__", None) + origin = metadata.get("origin") if metadata else None + return origin if origin is not None else model + + +def _package_model_classes() -> list[type]: + _import_package_modules() + found: set[type] = set() + pending = list(MODEL_ROOTS) + while pending: + for child in pending.pop().__subclasses__(): + if child not in found: + found.add(child) + pending.append(child) + return sorted( + ( + model + for model in found + if model.__module__.startswith(PACKAGE_PREFIX) + and _generic_origin(model) is model + ), + key=_qualified_name, + ) + + +def _listed_runtime_evaluated_bases() -> set[str]: + with (PROJECT_ROOT / "pyproject.toml").open("rb") as pyproject: + settings = tomllib.load(pyproject) + type_checking = settings["tool"]["ruff"]["lint"]["flake8-type-checking"] + return set(type_checking["runtime-evaluated-base-classes"]) + + +def _unresolved_annotation(model: type[pydantic.BaseModel]) -> str | None: + try: + model.model_rebuild(raise_errors=True) + except pydantic.PydanticUndefinedAnnotation as error: + return f"{_qualified_name(model)}: {error}" + return None + + +def test_every_model_base_is_listed_for_ruff() -> None: + models = _package_model_classes() + package_models = set(models) + inherited_bases = { + _generic_origin(base) + for model in models + for base in model.__bases__ + if _generic_origin(base) in package_models + } + listed = _listed_runtime_evaluated_bases() + missing = sorted( + _qualified_name(base) + for base in inherited_bases + if _qualified_name(base) not in listed + ) + assert missing == [], ( + "these package models are inherited by other models but are missing from " + "[tool.ruff.lint.flake8-type-checking] runtime-evaluated-base-classes in " + "pyproject.toml, so ruff would move imports their subclasses need at " + "runtime; add them:\n" + "\n".join(missing) + ) + + +def test_every_pydantic_model_resolves_its_annotations() -> None: + unresolved = [ + message + for model in _package_model_classes() + if issubclass(model, pydantic.BaseModel) + and (message := _unresolved_annotation(model)) is not None + ] + assert unresolved == [], ( + "these models use names in annotations that don't exist at runtime, " + "usually an import moved under TYPE_CHECKING for a model whose base is " + "missing from runtime-evaluated-base-classes:\n" + "\n".join(unresolved) + ) + + +def test_every_sqlalchemy_mapper_configures() -> None: + _import_package_modules() + configure_mappers() + + +def test_skipped_app_modules_define_no_classes() -> None: + for module_name in sorted(APP_ASSEMBLY_MODULES): + path = PROJECT_ROOT / f"{module_name.replace('.', '/')}.py" + tree = ast.parse(path.read_text(encoding="utf-8")) + classes = [ + node.name for node in ast.walk(tree) if isinstance(node, ast.ClassDef) + ] + assert classes == [], ( + f"{module_name} now defines {classes}; import it in these checks, with " + "the settings the route tests patch, so its models are checked too" + ) diff --git a/tests/unit/v2/test_household_migration_qualification.py b/tests/unit/v2/test_household_migration_qualification.py index a264d61ba..0b012f254 100644 --- a/tests/unit/v2/test_household_migration_qualification.py +++ b/tests/unit/v2/test_household_migration_qualification.py @@ -2,8 +2,8 @@ from __future__ import annotations -from collections.abc import Iterator from contextlib import contextmanager +from typing import TYPE_CHECKING import pytest @@ -14,6 +14,9 @@ V2_SUPABASE_PROJECT_REF, ) +if TYPE_CHECKING: + from collections.abc import Iterator + ENVIRONMENT = { V2_MIGRATION_DATABASE_URL: ( "postgresql+psycopg://migration:test-password@db." diff --git a/tests/unit/v2/test_policy_migration_qualification.py b/tests/unit/v2/test_policy_migration_qualification.py index 8a3bfde66..8fa8b8506 100644 --- a/tests/unit/v2/test_policy_migration_qualification.py +++ b/tests/unit/v2/test_policy_migration_qualification.py @@ -2,8 +2,8 @@ from __future__ import annotations -from collections.abc import Iterator from contextlib import contextmanager +from typing import TYPE_CHECKING import pytest @@ -14,6 +14,9 @@ V2_SUPABASE_PROJECT_REF, ) +if TYPE_CHECKING: + from collections.abc import Iterator + ENVIRONMENT = { V2_MIGRATION_DATABASE_URL: ( "postgresql+psycopg://migration:test-password@db."