From a03943d43f596349b9a97dbfac7fafbad0278e68 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 13 Oct 2026 17:36:17 +0400 Subject: [PATCH 01/12] Hoist exception messages into a msg variable Apply ruff's EM101/EM102 fix: every raise that built its message inline now assigns it to msg first, which also clears TRY003. Message text is unchanged; an AST check inlined each msg back into its raise and found the trees identical, and confirmed no function gaining msg already read, bound or closed over another msg. Co-Authored-By: Claude Opus 5.5 --- .github/find-api-model-versions.py | 22 +-- .../scripts/verify_observability_collector.py | 17 +- migrations/v1/env.py | 9 +- migrations/v2/env.py | 3 +- policyengine_api/constants.py | 13 +- policyengine_api/data/places.py | 11 +- .../data/v2/catalog/catalog_selection.py | 31 ++-- .../data/v2/catalog/extraction.py | 157 +++++++++--------- .../data/v2/catalog/publication.py | 16 +- .../v2/catalog/publication_reconciliation.py | 10 +- .../data/v2/catalog/publication_staging.py | 3 +- policyengine_api/data/v2/catalog/records.py | 6 +- policyengine_api/data/v2/database.py | 3 +- .../data/v2/metadata_validation.py | 3 +- policyengine_api/data/v2/migration_target.py | 51 +++--- policyengine_api/data/v2/report_runs.py | 23 ++- policyengine_api/data/v2/settings.py | 68 ++++---- .../v2/households/document_models.py | 3 +- .../v2/households/request_models.py | 3 +- .../fastapi_routes/v2/households/routes.py | 11 +- .../fastapi_routes/v2/policies/routes.py | 11 +- policyengine_api/libs/gateway_auth.py | 36 ++-- .../libs/simulation_entrypoint.py | 49 +++--- policyengine_api/migration_flags.py | 13 +- policyengine_api/observability/stages.py | 5 +- policyengine_api/query_parameters.py | 22 +-- policyengine_api/routes/household_routes.py | 17 +- policyengine_api/routes/policy_routes.py | 6 +- .../routes/report_output_routes.py | 39 +++-- policyengine_api/routes/simulation_routes.py | 48 ++++-- .../routes/user_profile_routes.py | 18 +- policyengine_api/runtime_cache/claims.py | 13 +- policyengine_api/runtime_cache/client.py | 6 +- policyengine_api/runtime_cache/core.py | 12 +- policyengine_api/runtime_cache/fake.py | 9 +- .../runtime_cache/reform_impacts.py | 26 ++- policyengine_api/runtime_cache/settings.py | 67 ++++---- .../services/budget_window_cache.py | 18 +- policyengine_api/services/economy_service.py | 50 +++--- .../services/household_calculation_service.py | 6 +- .../services/household_mirroring.py | 5 +- .../services/household_service.py | 43 ++--- policyengine_api/services/metadata_service.py | 5 +- policyengine_api/services/policy_mirroring.py | 5 +- policyengine_api/services/policy_service.py | 11 +- .../services/reform_impacts_service.py | 5 +- .../services/report_output_alias_service.py | 26 ++- .../services/report_output_service.py | 48 +++--- .../services/report_run_service.py | 3 +- .../services/report_spec_service.py | 59 ++++--- .../services/simulation_run_service.py | 3 +- .../services/simulation_service.py | 16 +- .../services/simulation_spec_service.py | 11 +- .../services/user_policy_mirroring.py | 15 +- .../services/user_policy_service.py | 55 +++--- policyengine_api/services/user_service.py | 6 +- .../services/v2/comparison_runs/services.py | 45 ++--- .../services/v2/comparison_runs/types.py | 81 +++++---- .../services/v2/comparison_runs/validators.py | 28 ++-- .../services/v2/households/services.py | 18 +- .../services/v2/households/transformations.py | 37 ++--- .../services/v2/households/types.py | 6 +- .../services/v2/households/validators.py | 86 +++++----- .../v2/metadata/database_connectors/reads.py | 5 +- .../services/v2/metadata/validators.py | 24 +-- .../services/v2/policies/services.py | 23 ++- .../services/v2/policies/transformations.py | 49 +++--- .../services/v2/policies/types.py | 11 +- .../services/v2/policies/validators.py | 38 +++-- policyengine_api/services/v2/reports/types.py | 29 ++-- .../services/v2/simulations/types.py | 12 +- .../services/v2/user_households/types.py | 8 +- .../services/v2/user_households/validators.py | 20 +-- .../services/v2/user_policies/services.py | 5 +- .../services/v2/user_policies/types.py | 3 +- .../services/v2/user_policies/validators.py | 65 +++----- policyengine_api/specification.py | 6 +- policyengine_api/spm.py | 118 ++++++++----- policyengine_api/utils/budget_window.py | 11 +- policyengine_api/worker_spm.py | 64 +++---- policyengine_api/worker_spm_release.py | 32 ++-- scripts/measure_cloud_run_runtime.py | 5 +- scripts/process_v1_household_mirror_event.py | 48 +++--- scripts/v1_database_migration.py | 26 +-- tests/contract/test_v1_route_contracts.py | 6 +- tests/fixtures/local_v1_database.py | 3 +- tests/fixtures/spm.py | 3 +- tests/integration/conftest.py | 8 +- tests/integration/test_live_v2_policies.py | 5 +- .../test_v1_household_dual_write.py | 3 +- .../integration/test_v1_policy_dual_write.py | 3 +- .../test_v2_catalog_publication.py | 3 +- .../test_v2_user_policy_mirroring.py | 3 +- tests/to_refactor/api/test_api.py | 11 +- tests/to_refactor/python/test_error_routes.py | 18 +- tests/unit/data/test_orm_sessions.py | 3 +- tests/unit/data/test_v1_database_migration.py | 3 +- .../routes/test_calculate_error_statuses.py | 3 +- .../routes/test_migration_context_logging.py | 3 +- .../routes/test_spm_year_worker_polling.py | 3 +- tests/unit/runtime_cache/test_core.py | 12 +- .../unit/services/test_budget_window_cache.py | 12 +- .../test_household_calculation_service.py | 6 +- .../unit/services/test_household_mirroring.py | 3 +- tests/unit/services/test_household_service.py | 6 +- .../test_report_service_owned_sessions.py | 3 +- .../services/test_service_owned_sessions.py | 3 +- .../unit/services/test_simulation_service.py | 3 +- tests/unit/test_asgi_factory.py | 6 +- tests/unit/test_country.py | 3 +- tests/unit/test_spm_settings.py | 21 ++- tests/unit/test_warmup.py | 3 +- tests/unit/v2/test_catalog_initialization.py | 9 +- .../test_household_migration_qualification.py | 3 +- tests/unit/v2/test_household_routes.py | 3 +- .../unit/v2/test_policy_legacy_translation.py | 3 +- .../v2/test_policy_migration_qualification.py | 3 +- tests/unit/v2/test_policy_routes.py | 3 +- tests/unit/v2/test_settings.py | 3 +- .../unit/v2/test_stage12_comparison_models.py | 12 +- tests/unit/v2/test_user_household_routes.py | 3 +- tests/unit/v2/test_user_policy_routes.py | 3 +- 122 files changed, 1270 insertions(+), 1090 deletions(-) diff --git a/.github/find-api-model-versions.py b/.github/find-api-model-versions.py index 6f80d3f57..f70ec327c 100644 --- a/.github/find-api-model-versions.py +++ b/.github/find-api-model-versions.py @@ -30,15 +30,15 @@ def _manifest_versions(bundle: dict) -> dict[str, str]: packages = bundle.get("packages") if not isinstance(packages, dict): - raise RuntimeError("PolicyEngine bundle manifest has no package mapping.") + msg = "PolicyEngine bundle manifest has no package mapping." + raise RuntimeError(msg) versions = {} for package_name in REQUIRED_PACKAGES: package = packages.get(package_name) if not isinstance(package, dict) or not package.get("version"): - raise RuntimeError( - f"PolicyEngine bundle manifest has no version for {package_name}." - ) + msg = f"PolicyEngine bundle manifest has no version for {package_name}." + raise RuntimeError(msg) versions[package_name] = str(package["version"]) return versions @@ -47,9 +47,8 @@ def _data_release_version(bundle: dict, country_id: str) -> str: data_releases = bundle.get("data_releases") release = data_releases.get(country_id) if isinstance(data_releases, dict) else None if not isinstance(release, dict) or not release.get("version"): - raise RuntimeError( - f"PolicyEngine bundle manifest has no {country_id.upper()} data release." - ) + msg = f"PolicyEngine bundle manifest has no {country_id.upper()} data release." + raise RuntimeError(msg) return str(release["version"]) @@ -57,16 +56,18 @@ def find_api_model_versions() -> dict[str, str]: """Find and validate the package and data versions in the installed bundle.""" bundle = get_current_bundle() if not isinstance(bundle, dict): - raise RuntimeError("Installed PolicyEngine bundle manifest is not an object.") + msg = "Installed PolicyEngine bundle manifest is not an object." + raise RuntimeError(msg) manifest_versions = _manifest_versions(bundle) for package_name, manifest_version in manifest_versions.items(): installed_version = distribution_version(package_name) if installed_version != manifest_version: - raise RuntimeError( + msg = ( f"Installed {package_name} version {installed_version} does not match " f"bundle manifest version {manifest_version}." ) + raise RuntimeError(msg) expected_constants = { "policyengine": POLICYENGINE_VERSION, @@ -76,10 +77,11 @@ def find_api_model_versions() -> dict[str, str]: } for package_name, constant_version in expected_constants.items(): if constant_version != manifest_versions[package_name]: - raise RuntimeError( + msg = ( f"API version for {package_name} is {constant_version}, but the bundle " f"manifest specifies {manifest_versions[package_name]}." ) + raise RuntimeError(msg) return { "POLICYENGINE_VERSION": manifest_versions["policyengine"], diff --git a/.github/scripts/verify_observability_collector.py b/.github/scripts/verify_observability_collector.py index 633c7213f..2483183e5 100644 --- a/.github/scripts/verify_observability_collector.py +++ b/.github/scripts/verify_observability_collector.py @@ -74,7 +74,8 @@ class VerificationProbe(NamedTuple): def _authority(endpoint: str) -> str: parsed = urlparse(endpoint) if parsed.scheme != "https" or not parsed.hostname: - raise ValueError("collector endpoint must be an HTTPS URL") + msg = "collector endpoint must be an HTTPS URL" + raise ValueError(msg) return f"{parsed.hostname}:{parsed.port or 443}" @@ -193,7 +194,8 @@ def _raise_for_partial_rejection( rejected = getattr(partial, rejected_field, 0) if rejected: message = getattr(partial, "error_message", "") - raise RuntimeError(f"collector rejected {rejected} {signal}: {message}") + msg = f"collector rejected {rejected} {signal}: {message}" + raise RuntimeError(msg) def _get_json(url: str, access_token: str) -> dict[str, object] | None: @@ -208,9 +210,8 @@ def _get_json(url: str, access_token: str) -> dict[str, object] | None: if error.code == 404: return None detail = error.read().decode("utf-8", errors="replace") - raise RuntimeError( - f"Google Cloud verification request failed ({error.code}): {detail}" - ) from error + msg = f"Google Cloud verification request failed ({error.code}): {detail}" + raise RuntimeError(msg) from error def _trace_available( @@ -302,7 +303,8 @@ def _wait_for_delivery( ) if not found ) - raise RuntimeError(f"collector verification data was not stored: {missing}") + msg = f"collector verification data was not stored: {missing}" + raise RuntimeError(msg) time.sleep(min(poll_seconds, remaining)) @@ -357,7 +359,8 @@ def verify( if error.code() is not grpc.StatusCode.UNIMPLEMENTED: raise else: - raise RuntimeError("collector unexpectedly accepted application logs") + msg = "collector unexpectedly accepted application logs" + raise RuntimeError(msg) _wait_for_delivery( project_id=project_id, trace_id=trace_id, diff --git a/migrations/v1/env.py b/migrations/v1/env.py index c2ce10a5c..09af0cb3c 100644 --- a/migrations/v1/env.py +++ b/migrations/v1/env.py @@ -22,18 +22,21 @@ provided_connection = config.attributes.get("connection") if provided_connection is not None: if provided_connection.dialect.name != "mysql": - raise RuntimeError("Alembic migrations must target a MySQL database") + msg = "Alembic migrations must target a MySQL database" + raise RuntimeError(msg) else: database_url = os.environ.get("ALEMBIC_DATABASE_URL") or config.get_main_option( "sqlalchemy.url" ) if not database_url: - raise RuntimeError( + msg = ( "ALEMBIC_DATABASE_URL is required; the local SQLite cache is not an " "Alembic migration target" ) + raise RuntimeError(msg) if make_url(database_url).get_backend_name() != "mysql": - raise RuntimeError("Alembic migrations must target a MySQL database") + msg = "Alembic migrations must target a MySQL database" + raise RuntimeError(msg) config.set_main_option("sqlalchemy.url", database_url.replace("%", "%%")) target_metadata = V1Base.metadata diff --git a/migrations/v2/env.py b/migrations/v2/env.py index 302ca39c5..b1b88bb38 100644 --- a/migrations/v2/env.py +++ b/migrations/v2/env.py @@ -65,10 +65,11 @@ def _configure(connection: Connection) -> None: def run_migrations_offline() -> None: """Raise, because v2 migrations need a live connection to qualify the target.""" - raise RuntimeError( + msg = ( "v2 migrations require an online connection so target identity and " "generated application-data before/after states can be verified" ) + raise RuntimeError(msg) def run_migrations_online() -> None: diff --git a/policyengine_api/constants.py b/policyengine_api/constants.py index b17a41b9f..65ef84081 100644 --- a/policyengine_api/constants.py +++ b/policyengine_api/constants.py @@ -62,12 +62,14 @@ def _load_policyengine_bundle() -> dict | None: with open(get_py_manifest()) as manifest_file: manifest = json.load(manifest_file) except Exception as exc: - raise RuntimeError( + msg = ( "Could not read PolicyEngine .py bundle manifest from the installed " "policyengine wheel." - ) from exc + ) + raise RuntimeError(msg) from exc if not isinstance(manifest, dict): - raise RuntimeError("PolicyEngine .py bundle manifest must be a JSON object.") + msg = "PolicyEngine .py bundle manifest must be a JSON object." + raise RuntimeError(msg) return manifest @@ -204,9 +206,8 @@ def _build_runtime_cache_version( prefix = "e" if scope == "economy_impact" else "r" digest_length = 10 - len(prefix) - len(schema_version) if digest_length < 4: - raise ValueError( - f"Runtime cache version for {scope} does not fit in VARCHAR(10)" - ) + msg = f"Runtime cache version for {scope} does not fit in VARCHAR(10)" + raise ValueError(msg) raw = "|".join( ( diff --git a/policyengine_api/data/places.py b/policyengine_api/data/places.py index 3a73b950b..6cdc65b19 100644 --- a/policyengine_api/data/places.py +++ b/policyengine_api/data/places.py @@ -21,10 +21,11 @@ def parse_place_code(place_code: str) -> tuple[str, str]: """ if "-" not in place_code: - raise ValueError( + msg = ( f"Invalid place format: '{place_code}'. " "Expected format: STATE_ABBREV-PLACE_FIPS (e.g., NJ-57000)" ) + raise ValueError(msg) return place_code.split("-", 1) @@ -41,9 +42,9 @@ def validate_place_code(place_code: str) -> None: state_abbrev, place_fips = parse_place_code(place_code) if state_abbrev.lower() not in get_valid_state_codes(): - raise ValueError(f"Invalid state in place code: '{state_abbrev}'") + msg = f"Invalid state in place code: '{state_abbrev}'" + raise ValueError(msg) if not place_fips.isdigit() or len(place_fips) != 5: - raise ValueError( - f"Invalid FIPS code in place: '{place_fips}'. Expected 5-digit FIPS code" - ) + msg = f"Invalid FIPS code in place: '{place_fips}'. Expected 5-digit FIPS code" + raise ValueError(msg) diff --git a/policyengine_api/data/v2/catalog/catalog_selection.py b/policyengine_api/data/v2/catalog/catalog_selection.py index f72a43192..b4c027551 100644 --- a/policyengine_api/data/v2/catalog/catalog_selection.py +++ b/policyengine_api/data/v2/catalog/catalog_selection.py @@ -45,23 +45,19 @@ class SelectedCatalog: def validate_policyengine_version(value: str) -> str: """Return one bounded canonical PEP 440 version string.""" if not isinstance(value, str) or not value or value != value.strip(): - raise InvalidPolicyEngineVersionError( - "policyengine_version must be a non-empty canonical version" - ) + msg = "policyengine_version must be a non-empty canonical version" + raise InvalidPolicyEngineVersionError(msg) if len(value) > 128: - raise InvalidPolicyEngineVersionError( - "policyengine_version must be at most 128 characters" - ) + msg = "policyengine_version must be at most 128 characters" + raise InvalidPolicyEngineVersionError(msg) try: parsed = Version(value) except InvalidVersion as error: - raise InvalidPolicyEngineVersionError( - "policyengine_version must be a canonical PEP 440 version" - ) from error + msg = "policyengine_version must be a canonical PEP 440 version" + raise InvalidPolicyEngineVersionError(msg) from error if str(parsed) != value or parsed == Version("0.0.0"): - raise InvalidPolicyEngineVersionError( - "policyengine_version must be a canonical non-placeholder version" - ) + msg = "policyengine_version must be a canonical non-placeholder version" + raise InvalidPolicyEngineVersionError(msg) return value @@ -94,19 +90,20 @@ def select_catalog( ) ).one_or_none() except SQLAlchemyError as error: - raise MetadataCatalogUnavailableError( - "the v2 metadata catalog cannot be queried" - ) from error + msg = "the v2 metadata catalog cannot be queried" + raise MetadataCatalogUnavailableError(msg) from error if row is None: if explicit_version: - raise MetadataCatalogVersionNotFoundError( + msg = ( f"PolicyEngine.py {selected_version} is not published for {country_id}" ) - raise MetadataCatalogUnavailableError( + raise MetadataCatalogVersionNotFoundError(msg) + msg = ( f"the running PolicyEngine.py {selected_version} catalog " f"is absent for {country_id}" ) + raise MetadataCatalogUnavailableError(msg) model, model_version = row return SelectedCatalog( country_id=country_id, diff --git a/policyengine_api/data/v2/catalog/extraction.py b/policyengine_api/data/v2/catalog/extraction.py index 4d8a24fc8..82df26b26 100644 --- a/policyengine_api/data/v2/catalog/extraction.py +++ b/policyengine_api/data/v2/catalog/extraction.py @@ -73,10 +73,12 @@ def _identifier(*parts: object) -> UUID: def _required_text(value: object, *, field_name: str, maximum: int) -> str: if not isinstance(value, str) or not value.strip(): - raise CatalogExtractionError(f"{field_name} must be a non-empty string") + msg = f"{field_name} must be a non-empty string" + raise CatalogExtractionError(msg) normalized = value.strip() if len(normalized) > maximum: - raise CatalogExtractionError(f"{field_name} exceeds {maximum} characters") + msg = f"{field_name} exceeds {maximum} characters" + raise CatalogExtractionError(msg) return normalized @@ -89,9 +91,11 @@ def _optional_text( if value is None: return None if not isinstance(value, str): - raise CatalogExtractionError(f"{field_name} must be a string or null") + msg = f"{field_name} must be a string or null" + raise CatalogExtractionError(msg) if maximum is not None and len(value) > maximum: - raise CatalogExtractionError(f"{field_name} exceeds {maximum} characters") + msg = f"{field_name} exceeds {maximum} characters" + raise CatalogExtractionError(msg) return value @@ -136,7 +140,8 @@ def _type_name(value: object) -> str | None: return value name = getattr(value, "__name__", None) if not isinstance(name, str) or not name: - raise CatalogExtractionError(f"unsupported data type {value!r}") + msg = f"unsupported data type {value!r}" + raise CatalogExtractionError(msg) return name @@ -146,7 +151,8 @@ def normalize_json_value(value: object) -> JSONValue: return value if isinstance(value, float): if math.isnan(value): - raise CatalogExtractionError("NaN is not JSON-compatible") + msg = "NaN is not JSON-compatible" + raise CatalogExtractionError(msg) if math.isinf(value): return "Infinity" if value > 0 else "-Infinity" return value @@ -158,7 +164,8 @@ def normalize_json_value(value: object) -> JSONValue: normalized: dict[str, JSONValue] = {} for key, nested in value.items(): if not isinstance(key, str): - raise CatalogExtractionError("JSON object keys must be strings") + msg = "JSON object keys must be strings" + raise CatalogExtractionError(msg) normalized[key] = normalize_json_value(nested) return normalized if isinstance(value, (list, tuple)): @@ -169,16 +176,16 @@ def normalize_json_value(value: object) -> JSONValue: scalar = item() if scalar is not value: return normalize_json_value(scalar) - raise CatalogExtractionError( - f"unsupported JSON value type {type(value).__module__}.{type(value).__name__}" - ) + msg = f"unsupported JSON value type {type(value).__module__}.{type(value).__name__}" + raise CatalogExtractionError(msg) def _aware_datetime(value: object, *, field_name: str) -> datetime: if isinstance(value, date) and not isinstance(value, datetime): value = datetime(value.year, value.month, value.day) if not isinstance(value, datetime): - raise CatalogExtractionError(f"{field_name} must be a date or datetime") + msg = f"{field_name} must be a date or datetime" + raise CatalogExtractionError(msg) if value.tzinfo is None: return value.replace(tzinfo=UTC) return value.astimezone(UTC) @@ -204,7 +211,8 @@ def _dataset_name_from_path(path: str) -> str: def _dataset_year(name: str) -> int: matches = DATASET_YEAR_PATTERN.findall(name) if not matches: - raise CatalogExtractionError(f"dataset {name!r} has no four-digit year") + msg = f"dataset {name!r} has no four-digit year" + raise CatalogExtractionError(msg) match = list(DATASET_YEAR_PATTERN.finditer(name))[-1] return int(match.group()) @@ -219,7 +227,8 @@ def _verify_bundle( not isinstance(policyengine_version, str) or policyengine_version.strip().lower() in PLACEHOLDER_VERSIONS ): - raise CatalogExtractionError("PolicyEngine.py version is a placeholder") + msg = "PolicyEngine.py version is a placeholder" + raise CatalogExtractionError(msg) canonical_version = _required_text( policyengine_version, field_name="PolicyEngine.py version", @@ -229,37 +238,35 @@ def _verify_bundle( "bundle_version" ) if manifest_version != canonical_version: - raise CatalogExtractionError( - "installed PolicyEngine.py version does not match its packaged manifest" - ) + msg = "installed PolicyEngine.py version does not match its packaged manifest" + raise CatalogExtractionError(msg) packages = bundle.get("packages") if not isinstance(packages, Mapping): - raise CatalogExtractionError("PolicyEngine.py manifest has no package map") + msg = "PolicyEngine.py manifest has no package map" + raise CatalogExtractionError(msg) observed_dependencies: list[tuple[str, str]] = [] for package_name in REQUIRED_DEPENDENCIES: package = packages.get(package_name) if not isinstance(package, Mapping): - raise CatalogExtractionError( - f"PolicyEngine.py manifest omits {package_name}" - ) + msg = f"PolicyEngine.py manifest omits {package_name}" + raise CatalogExtractionError(msg) expected = package.get("version") if not isinstance(expected, str) or expected.lower() in PLACEHOLDER_VERSIONS: - raise CatalogExtractionError( - f"PolicyEngine.py manifest has no valid {package_name} version" - ) + msg = f"PolicyEngine.py manifest has no valid {package_name} version" + raise CatalogExtractionError(msg) try: observed = installed_version(package_name) except importlib_metadata.PackageNotFoundError as error: - raise CatalogExtractionError( - f"required installed distribution {package_name} is absent" - ) from error + msg = f"required installed distribution {package_name} is absent" + raise CatalogExtractionError(msg) from error if observed != expected: - raise CatalogExtractionError( + msg = ( f"installed {package_name} version does not match " "the PolicyEngine.py manifest" ) + raise CatalogExtractionError(msg) observed_dependencies.append((package_name, expected)) return tuple(observed_dependencies) @@ -277,9 +284,8 @@ def _normalize_variable( if not isinstance(possible_values, Sequence) or isinstance( possible_values, (str, bytes) ): - raise CatalogExtractionError( - f"variable {name!r} possible_values must be a sequence" - ) + msg = f"variable {name!r} possible_values must be a sequence" + raise CatalogExtractionError(msg) normalized_possible_values = [ _required_text( value, field_name=f"variable {name} possible value", maximum=255 @@ -294,9 +300,8 @@ def optional_names(attribute: str) -> list[str] | None: if values is None: return None if not isinstance(values, Sequence) or isinstance(values, (str, bytes)): - raise CatalogExtractionError( - f"variable {name!r} {attribute} must be a sequence" - ) + msg = f"variable {name!r} {attribute} must be a sequence" + raise CatalogExtractionError(msg) return [ _required_text( value, @@ -358,26 +363,26 @@ def _normalize_parameter( try: value_json = normalize_json_value(getattr(source_value, "value", None)) except CatalogExtractionError as error: - raise CatalogExtractionError( - f"parameter {name!r} has an unsupported JSON value: {error}" - ) from error + msg = f"parameter {name!r} has an unsupported JSON value: {error}" + raise CatalogExtractionError(msg) from error if start_date in values_by_start: previous_end, previous_value = values_by_start[start_date] if previous_value != value_json: - raise CatalogExtractionError( + msg = ( f"parameter {name!r} has conflicting values at effective " f"date {start_date.isoformat()}" ) + raise CatalogExtractionError(msg) if previous_end != end_date: - raise CatalogExtractionError( + msg = ( f"parameter {name!r} exposes inconsistent intervals at effective " f"date {start_date.isoformat()}" ) + raise CatalogExtractionError(msg) continue if previous_start is not None and start_date <= previous_start: - raise CatalogExtractionError( - f"parameter {name!r} values are not ordered oldest to newest" - ) + msg = f"parameter {name!r} values are not ordered oldest to newest" + raise CatalogExtractionError(msg) values_by_start[start_date] = (end_date, value_json) previous_start = start_date source_values.append((start_date, end_date, value_json)) @@ -390,9 +395,8 @@ def _normalize_parameter( else None ) if supplied_end != expected_end: - raise CatalogExtractionError( - f"parameter {name!r} does not expose canonical inclusive intervals" - ) + msg = f"parameter {name!r} does not expose canonical inclusive intervals" + raise CatalogExtractionError(msg) normalized_values.append( ParameterValueRecord( id=_identifier("parameter-value", parameter_id, start_date.isoformat()), @@ -434,17 +438,17 @@ def _public_named_records( ) -> tuple[object, ...]: records = getattr(source, attribute, None) if not isinstance(records, Mapping): - raise CatalogExtractionError( - f"public model {attribute} must be a name-indexed mapping" - ) + msg = f"public model {attribute} must be a name-indexed mapping" + raise CatalogExtractionError(msg) normalized: list[object] = [] for key, record in sorted(records.items()): name = getattr(record, "name", None) if key != name: - raise CatalogExtractionError( + msg = ( f"{entity_name} mapping key {key!r} does not match record name {name!r}" ) + raise CatalogExtractionError(msg) normalized.append(record) return tuple(normalized) @@ -464,32 +468,31 @@ def _normalize_country( maximum=32, ) if model_name != expected_model_name: - raise CatalogExtractionError( - f"{country_id} public model identity does not match {expected_model_name}" - ) + msg = f"{country_id} public model identity does not match {expected_model_name}" + raise CatalogExtractionError(msg) model_id = _identifier("model", model_name) model_version_id = _identifier("model-version", model_id, policyengine_version) model_package = getattr(source, "model_package", None) if getattr(model_package, "name", None) != expected_model_name: - raise CatalogExtractionError( + msg = ( f"{country_id} public model package identity does not match " f"{expected_model_name}" ) + raise CatalogExtractionError(msg) if getattr(model_package, "version", None) != expected_country_package_version: - raise CatalogExtractionError( - f"{country_id} public model does not match the PolicyEngine.py manifest" - ) + msg = f"{country_id} public model does not match the PolicyEngine.py manifest" + raise CatalogExtractionError(msg) release_manifest = getattr(source, "release_manifest", None) if getattr(release_manifest, "country_id", None) != country_id: - raise CatalogExtractionError( - f"{country_id} public model has an inconsistent release manifest" - ) + msg = f"{country_id} public model has an inconsistent release manifest" + raise CatalogExtractionError(msg) if getattr(release_manifest, "policyengine_version", None) != policyengine_version: - raise CatalogExtractionError( + msg = ( f"{country_id} public model was not certified by the installed " "PolicyEngine.py version" ) + raise CatalogExtractionError(msg) variables = tuple( _normalize_variable(variable, model_version_id=model_version_id) @@ -544,13 +547,13 @@ def _normalize_country( reviewed_default = REVIEWED_DEFAULT_DATASETS[country_id] manifest_default = getattr(release_manifest, "default_dataset", None) if manifest_default != reviewed_default: - raise CatalogExtractionError( - f"{country_id} default dataset does not match the reviewed selection" - ) + msg = f"{country_id} default dataset does not match the reviewed selection" + raise CatalogExtractionError(msg) default_dataset_uri = getattr(release_manifest, "default_dataset_uri", None) registry = getattr(source, "region_registry", None) if registry is None or getattr(registry, "country_id", None) != country_id: - raise CatalogExtractionError(f"{country_id} public region registry is absent") + msg = f"{country_id} public region registry is absent" + raise CatalogExtractionError(msg) source_regions = tuple(registry) region_codes = [getattr(region, "code", None) for region in source_regions] @@ -558,11 +561,11 @@ def _normalize_country( code for code, count in Counter(region_codes).items() if count > 1 ) if duplicate_region_codes: - raise CatalogExtractionError( - f"duplicate region natural keys: {duplicate_region_codes[:3]}" - ) + msg = f"duplicate region natural keys: {duplicate_region_codes[:3]}" + raise CatalogExtractionError(msg) if country_id not in region_codes: - raise CatalogExtractionError(f"{country_id} national region is absent") + msg = f"{country_id} national region is absent" + raise CatalogExtractionError(msg) selected_dataset_names: set[str] = {reviewed_default} region_dataset_names: dict[str, str] = {} @@ -579,9 +582,8 @@ def _normalize_country( maximum=64, ) if region_type not in SUPPORTED_REGION_TYPES: - raise CatalogExtractionError( - f"region {code!r} has unsupported type {region_type!r}" - ) + msg = f"region {code!r} has unsupported type {region_type!r}" + raise CatalogExtractionError(msg) dataset_path = getattr(region, "dataset_path", None) if country_id == "us" and region_type != "national" and dataset_path: dataset_name = _dataset_name_from_path(dataset_path) @@ -594,9 +596,8 @@ def _normalize_country( None, default_dataset_uri, }: - raise CatalogExtractionError( - f"{country_id} national region does not use the reviewed dataset" - ) + msg = f"{country_id} national region does not use the reviewed dataset" + raise CatalogExtractionError(msg) region_dataset_names[code] = dataset_name datasets = tuple( @@ -633,9 +634,8 @@ def _normalize_country( ) additional_filters = getattr(strategy, "additional_filters", {}) if additional_filters: - raise CatalogExtractionError( - f"region {code!r} has unsupported additional filters" - ) + msg = f"region {code!r} has unsupported additional filters" + raise CatalogExtractionError(msg) else: strategy_type = None filter_field = None @@ -724,9 +724,8 @@ def extract_catalog( ) missing = [country for country in SUPPORTED_COUNTRIES if country not in models] if missing: - raise CatalogExtractionError( - f"PolicyEngine.py public models are absent for: {', '.join(missing)}" - ) + msg = f"PolicyEngine.py public models are absent for: {', '.join(missing)}" + raise CatalogExtractionError(msg) package_map = bundle["packages"] countries = tuple( diff --git a/policyengine_api/data/v2/catalog/publication.py b/policyengine_api/data/v2/catalog/publication.py index cb97ed763..5e629fb05 100644 --- a/policyengine_api/data/v2/catalog/publication.py +++ b/policyengine_api/data/v2/catalog/publication.py @@ -61,16 +61,17 @@ def _verify_expected_revision(connection: Connection) -> None: if connection.dialect.name != "postgresql": - raise CatalogPublicationError("catalog publication requires PostgreSQL") + msg = "catalog publication requires PostgreSQL" + raise CatalogPublicationError(msg) if not sa.inspect(connection).has_table(ALEMBIC_VERSION.name): - raise CatalogPublicationError("the v2 Alembic revision table is absent") + msg = "the v2 Alembic revision table is absent" + raise CatalogPublicationError(msg) revisions = set( connection.execute(sa.select(ALEMBIC_VERSION.c.version_num)).scalars() ) if revisions != {V2_ALEMBIC_HEAD_REVISION}: - raise CatalogPublicationError( - "the v2 database is not at the expected Alembic revision" - ) + msg = "the v2 database is not at the expected Alembic revision" + raise CatalogPublicationError(msg) def _acquire_publication_lock(connection: Connection) -> None: @@ -119,9 +120,8 @@ def publish_catalog( assert_country_matches(connection, country) assert_canonical_value_uniqueness(connection) if _protected_row_counts(connection) != before: - raise CatalogPublicationError( - "publication changed simulation, report, or dataset-version rows" - ) + msg = "publication changed simulation, report, or dataset-version rows" + raise CatalogPublicationError(msg) if checkpoint is not None: checkpoint("after_validation", connection) diff --git a/policyengine_api/data/v2/catalog/publication_reconciliation.py b/policyengine_api/data/v2/catalog/publication_reconciliation.py index 3296bf172..98872c630 100644 --- a/policyengine_api/data/v2/catalog/publication_reconciliation.py +++ b/policyengine_api/data/v2/catalog/publication_reconciliation.py @@ -292,9 +292,8 @@ def assert_country_matches( """ for staged, actual in _comparison_pairs(country): if _sets_differ(connection, staged, actual): - raise CatalogPublicationError( - f"persisted {country.country_id} catalog differs from PolicyEngine.py" - ) + msg = f"persisted {country.country_id} catalog differs from PolicyEngine.py" + raise CatalogPublicationError(msg) INSERT_MODEL = ( @@ -660,6 +659,5 @@ def assert_canonical_value_uniqueness(connection: Connection) -> None: .limit(1) ) if connection.execute(duplicates).first() is not None: - raise CatalogPublicationError( - "canonical parameter-value uniqueness validation failed" - ) + msg = "canonical parameter-value uniqueness validation failed" + raise CatalogPublicationError(msg) diff --git a/policyengine_api/data/v2/catalog/publication_staging.py b/policyengine_api/data/v2/catalog/publication_staging.py index 8656b73f8..cd9b4675d 100644 --- a/policyengine_api/data/v2/catalog/publication_staging.py +++ b/policyengine_api/data/v2/catalog/publication_staging.py @@ -326,5 +326,6 @@ def stage_catalog( STAGE_CATALOG_REGIONS.name: expected["regions"], } if observed != expected_by_table: - raise CatalogPublicationError("COPY row counts differ from the catalog") + msg = "COPY row counts differ from the catalog" + raise CatalogPublicationError(msg) return observed diff --git a/policyengine_api/data/v2/catalog/records.py b/policyengine_api/data/v2/catalog/records.py index 555311294..afccc4add 100644 --- a/policyengine_api/data/v2/catalog/records.py +++ b/policyengine_api/data/v2/catalog/records.py @@ -147,7 +147,8 @@ def iter_batches( ) -> Iterator[tuple[RecordT, ...]]: """Yield bounded immutable slices without constructing a flattened copy.""" if batch_size < 1: - raise ValueError("batch_size must be positive") + msg = "batch_size must be positive" + raise ValueError(msg) for start in range(0, len(records), batch_size): yield tuple(records[start : start + batch_size]) @@ -173,7 +174,8 @@ def parameter_value_batches( ) -> Iterator[tuple[ParameterValueRecord, ...]]: """Yield canonical parameter values in bounded batches.""" if batch_size < 1: - raise ValueError("batch_size must be positive") + msg = "batch_size must be positive" + raise ValueError(msg) batch: list[ParameterValueRecord] = [] for parameter in self.parameters: for value in parameter.values: diff --git a/policyengine_api/data/v2/database.py b/policyengine_api/data/v2/database.py index 0f93f9b16..c5e7b5d0f 100644 --- a/policyengine_api/data/v2/database.py +++ b/policyengine_api/data/v2/database.py @@ -93,10 +93,11 @@ def get_v2_engine(settings: V2DatabaseSettings | None = None) -> Engine: fingerprint = _url_fingerprint(selected_settings) if _engine is not None: if fingerprint != _engine_url_fingerprint: - raise V2ConfigurationError( + msg = ( "the process-owned v2 engine is already bound to a different " "explicit target" ) + raise V2ConfigurationError(msg) return _engine _engine = build_v2_engine(selected_settings) diff --git a/policyengine_api/data/v2/metadata_validation.py b/policyengine_api/data/v2/metadata_validation.py index 786908eb4..7445269f1 100644 --- a/policyengine_api/data/v2/metadata_validation.py +++ b/policyengine_api/data/v2/metadata_validation.py @@ -45,7 +45,8 @@ def validate_v2_metadata_table_names(table_names: Iterable[str]) -> None: prohibited = sorted(actual & PROHIBITED_V2_TABLES) v1_only = sorted(actual & V1_ONLY_TABLES) if prohibited or v1_only: - raise V2MetadataValidationError( + msg = ( "API v2-alpha metadata contains rejected tables: " f"prohibited={prohibited}, v1_only={v1_only}" ) + raise V2MetadataValidationError(msg) diff --git a/policyengine_api/data/v2/migration_target.py b/policyengine_api/data/v2/migration_target.py index 1fbc8fd71..5882d1dc5 100644 --- a/policyengine_api/data/v2/migration_target.py +++ b/policyengine_api/data/v2/migration_target.py @@ -64,7 +64,8 @@ class V2AlembicSettings: def _required(environ: Mapping[str, str], name: str) -> str: value = environ.get(name) if value is None or not value.strip(): - raise V2MigrationTargetError(f"{name} is required") + msg = f"{name} is required" + raise V2MigrationTargetError(msg) return value.strip() @@ -72,26 +73,24 @@ def _parse_url(raw_url: str) -> URL: try: url = make_url(raw_url) except ArgumentError as error: - raise V2MigrationTargetError( - f"{V2_MIGRATION_DATABASE_URL} is not a valid URL" - ) from error + msg = f"{V2_MIGRATION_DATABASE_URL} is not a valid URL" + raise V2MigrationTargetError(msg) from error if url.drivername != POSTGRES_DRIVER: - raise V2MigrationTargetError( - f"{V2_MIGRATION_DATABASE_URL} must use the {POSTGRES_DRIVER} driver" - ) + msg = f"{V2_MIGRATION_DATABASE_URL} must use the {POSTGRES_DRIVER} driver" + raise V2MigrationTargetError(msg) return url def _validate_disposable_url(url: URL) -> None: if url.host not in DISPOSABLE_HOSTS or url.database != DISPOSABLE_DATABASE_NAME: - raise V2MigrationTargetError( + msg = ( "disposable v2 Alembic mode requires the isolated local " f"{DISPOSABLE_DATABASE_NAME} database" ) + raise V2MigrationTargetError(msg) if not url.username or url.password is None: - raise V2MigrationTargetError( - "disposable v2 Alembic mode requires explicit test credentials" - ) + msg = "disposable v2 Alembic mode requires explicit test credentials" + raise V2MigrationTargetError(msg) def load_v2_alembic_settings( @@ -103,9 +102,8 @@ def load_v2_alembic_settings( url = _parse_url(raw_url) disposable_value = values.get(V2_ALEMBIC_DISPOSABLE_TEST) if disposable_value not in {None, "", "0", "1"}: - raise V2MigrationTargetError( - f"{V2_ALEMBIC_DISPOSABLE_TEST} must be 1 when explicitly enabled" - ) + msg = f"{V2_ALEMBIC_DISPOSABLE_TEST} must be 1 when explicitly enabled" + raise V2MigrationTargetError(msg) if disposable_value == "1": _validate_disposable_url(url) return V2AlembicSettings(url=url, disposable_test=True, target=None) @@ -144,38 +142,40 @@ def qualify_v2_connection( ) -> None: """Verify live target identity and first-use state before Alembic runs.""" if connection.dialect.name != "postgresql": - raise V2MigrationTargetError("v2 Alembic requires a Postgres connection") + msg = "v2 Alembic requires a Postgres connection" + raise V2MigrationTargetError(msg) if settings.disposable_test: return target = settings.target if target is None: - raise V2MigrationTargetError("persistent v2 Alembic target is missing") + msg = "persistent v2 Alembic target is missing" + raise V2MigrationTargetError(msg) identity = connection.execute(text("SELECT current_database(), current_user")).one() if identity[0] != target.database_name or identity[1] != target.migration_role: - raise V2MigrationTargetError( + msg = ( "the live database or migration identity does not match the configured " "v2 target" ) + raise V2MigrationTargetError(msg) can_create = connection.execute( text("SELECT has_schema_privilege(current_user, 'public', 'CREATE')") ).scalar_one() if can_create is not True: - raise V2MigrationTargetError( - "the configured v2 migration identity lacks public schema CREATE" - ) + msg = "the configured v2 migration identity lacks public schema CREATE" + raise V2MigrationTargetError(msg) public_tables = set(inspect(connection).get_table_names(schema="public")) if "alembic_version" not in public_tables: if not target.freshness_audit_passed: - raise V2MigrationTargetError( - "the configured first-use freshness audit has not passed" - ) + msg = "the configured first-use freshness audit has not passed" + raise V2MigrationTargetError(msg) if public_tables: - raise V2MigrationTargetError( + msg = ( "the unstamped v2 target is not fresh; reset and adoption are " "prohibited" ) + raise V2MigrationTargetError(msg) def validate_v2_head_schema(connection: Connection, metadata: MetaData) -> None: @@ -186,7 +186,8 @@ def validate_v2_head_schema(connection: Connection, metadata: MetaData) -> None: unexpected = application_tables - metadata_tables missing = metadata_tables - application_tables if unexpected or missing: - raise V2MigrationTargetError( + msg = ( "the v2 head schema differs from SQLModel metadata: " f"missing={sorted(missing)}, unexpected={sorted(unexpected)}" ) + raise V2MigrationTargetError(msg) diff --git a/policyengine_api/data/v2/report_runs.py b/policyengine_api/data/v2/report_runs.py index 6aa32f955..9f11d0b9a 100644 --- a/policyengine_api/data/v2/report_runs.py +++ b/policyengine_api/data/v2/report_runs.py @@ -44,7 +44,8 @@ def _locked_report(session: Session, report_id: UUID) -> Report: statement = select(Report).where(Report.id == report_id).with_for_update() report = session.exec(statement).one_or_none() if report is None: - raise ReportNotFoundError(f"report {report_id} does not exist") + msg = f"report {report_id} does not exist" + raise ReportNotFoundError(msg) return report @@ -63,9 +64,8 @@ def set_report_type( select(ReportRun.id).where(ReportRun.report_id == report_id).limit(1) ).first() if existing_run_id is not None: - raise ReportTypeImmutableError( - "report type is immutable after the first report run" - ) + msg = "report type is immutable after the first report run" + raise ReportTypeImmutableError(msg) report.type = report_type session.add(report) @@ -85,9 +85,11 @@ def create_report_run( """Create one run, or return the run for a retried idempotent request.""" _locked_report(session, report_id) if trigger is ReportRunTrigger.MANUAL and idempotency_key is None: - raise ValueError("manual report reruns require an idempotency key") + msg = "manual report reruns require an idempotency key" + raise ValueError(msg) if not country_package_version or not policyengine_version: - raise ValueError("report run package versions must be non-empty") + msg = "report run package versions must be non-empty" + raise ValueError(msg) run = ReportRun( report_id=report_id, @@ -132,9 +134,11 @@ def begin_report_run( select(ReportRun).where(ReportRun.id == report_run_id).with_for_update() ).one_or_none() if run is None: - raise ReportRunNotFoundError(f"report run {report_run_id} does not exist") + msg = f"report run {report_run_id} does not exist" + raise ReportRunNotFoundError(msg) if run.status in {ReportRunStatus.SUCCEEDED, ReportRunStatus.FAILED}: - raise ReportRunStateError("a terminal report run cannot be resumed") + msg = "a terminal report run cannot be resumed" + raise ReportRunStateError(msg) if run.status is ReportRunStatus.PENDING: run.status = ReportRunStatus.RUNNING run.started_at = started_at or utc_now() @@ -188,7 +192,8 @@ def select_current_report_run( """Select the deterministic current successful run for deployed versions.""" report = session.get(Report, report_id) if report is None: - raise ReportNotFoundError(f"report {report_id} does not exist") + msg = f"report {report_id} does not exist" + raise ReportNotFoundError(msg) country_package_version = country_package_versions.get(report.country) if country_package_version is None: return None diff --git a/policyengine_api/data/v2/settings.py b/policyengine_api/data/v2/settings.py index 5aa3833db..2b951e28f 100644 --- a/policyengine_api/data/v2/settings.py +++ b/policyengine_api/data/v2/settings.py @@ -84,7 +84,8 @@ def _environment(environ: Mapping[str, str] | None) -> Mapping[str, str]: def _required(environ: Mapping[str, str], name: str) -> str: value = environ.get(name) if value is None or not value.strip(): - raise V2ConfigurationError(f"{name} is required") + msg = f"{name} is required" + raise V2ConfigurationError(msg) return value.strip() @@ -106,31 +107,30 @@ def _resolve_runtime_database_url( direct_value = environ.get(V2_RUNTIME_DATABASE_URL, "").strip() resource = environ.get(V2_RUNTIME_DATABASE_URL_SECRET_RESOURCE, "").strip() if direct_value and resource: - raise V2ConfigurationError( + msg = ( f"set exactly one of {V2_RUNTIME_DATABASE_URL} or " f"{V2_RUNTIME_DATABASE_URL_SECRET_RESOURCE}" ) + raise V2ConfigurationError(msg) if direct_value: return direct_value if not resource: - raise V2ConfigurationError( + msg = ( f"{V2_RUNTIME_DATABASE_URL} or " f"{V2_RUNTIME_DATABASE_URL_SECRET_RESOURCE} is required" ) + raise V2ConfigurationError(msg) if SECRET_RESOURCE_PATTERN.fullmatch(resource) is None: - raise V2ConfigurationError( - f"{V2_RUNTIME_DATABASE_URL_SECRET_RESOURCE} is invalid" - ) + msg = f"{V2_RUNTIME_DATABASE_URL_SECRET_RESOURCE} is invalid" + raise V2ConfigurationError(msg) try: resolved_value = secret_loader(resource).strip() except Exception as error: - raise V2ConfigurationError( - f"{V2_RUNTIME_DATABASE_URL_SECRET_RESOURCE} could not be resolved" - ) from error + msg = f"{V2_RUNTIME_DATABASE_URL_SECRET_RESOURCE} could not be resolved" + raise V2ConfigurationError(msg) from error if not resolved_value: - raise V2ConfigurationError( - f"{V2_RUNTIME_DATABASE_URL_SECRET_RESOURCE} is empty" - ) + msg = f"{V2_RUNTIME_DATABASE_URL_SECRET_RESOURCE} is empty" + raise V2ConfigurationError(msg) return resolved_value @@ -143,13 +143,11 @@ def load_supabase_target_settings( environment = _required(values, V2_SUPABASE_ENVIRONMENT) if PROJECT_REF_PATTERN.fullmatch(project_ref) is None: - raise V2ConfigurationError( - f"{V2_SUPABASE_PROJECT_REF} is not a valid project reference" - ) + msg = f"{V2_SUPABASE_PROJECT_REF} is not a valid project reference" + raise V2ConfigurationError(msg) if ENVIRONMENT_PATTERN.fullmatch(environment) is None: - raise V2ConfigurationError( - f"{V2_SUPABASE_ENVIRONMENT} is not a valid environment name" - ) + msg = f"{V2_SUPABASE_ENVIRONMENT} is not a valid environment name" + raise V2ConfigurationError(msg) return SupabaseTargetSettings( project_ref=project_ref, environment=environment, @@ -165,27 +163,26 @@ def parse_persistent_postgres_url( try: url = make_url(raw_url) except ArgumentError as error: - raise V2ConfigurationError(f"{setting_name} is not a valid URL") from error + msg = f"{setting_name} is not a valid URL" + raise V2ConfigurationError(msg) from error if url.drivername != POSTGRES_DRIVER: - raise V2ConfigurationError( - f"{setting_name} must use the {POSTGRES_DRIVER} driver" - ) + msg = f"{setting_name} must use the {POSTGRES_DRIVER} driver" + raise V2ConfigurationError(msg) if not url.host or url.host.lower() in LOCAL_HOSTS: - raise V2ConfigurationError( - f"{setting_name} must name a non-local Postgres host" - ) + msg = f"{setting_name} must name a non-local Postgres host" + raise V2ConfigurationError(msg) if not url.database or not url.username or url.password is None: - raise V2ConfigurationError( - f"{setting_name} must include an explicit database and credentials" - ) + msg = f"{setting_name} must include an explicit database and credentials" + raise V2ConfigurationError(msg) sslmode = url.query.get("sslmode") if not isinstance(sslmode, str) or sslmode not in PERSISTENT_SSL_MODES: - raise V2ConfigurationError( + msg = ( f"{setting_name} must require TLS with sslmode=" "require, verify-ca, or verify-full" ) + raise V2ConfigurationError(msg) return PostgresConnectionSettings(url) @@ -206,13 +203,11 @@ def validate_supabase_database_identity( and url.username.endswith(f".{target.project_ref}") ) if not (is_direct or is_pooler): - raise V2ConfigurationError( - f"{setting_name} does not identify the configured Supabase project" - ) + msg = f"{setting_name} does not identify the configured Supabase project" + raise V2ConfigurationError(msg) if url.database != SUPABASE_DATABASE_NAME: - raise V2ConfigurationError( - f"{setting_name} does not identify the configured Supabase database" - ) + msg = f"{setting_name} does not identify the configured Supabase database" + raise V2ConfigurationError(msg) def _database_settings( @@ -251,11 +246,12 @@ def load_v2_runtime_database_settings( environ=values, ) if settings.connection.url.port == SUPABASE_TRANSACTION_POOLER_PORT: - raise V2ConfigurationError( + msg = ( f"{V2_RUNTIME_DATABASE_URL} must use a direct connection or " "Supavisor session mode on port 5432 so the runtime statement " "timeout is applied" ) + raise V2ConfigurationError(msg) return settings diff --git a/policyengine_api/fastapi_routes/v2/households/document_models.py b/policyengine_api/fastapi_routes/v2/households/document_models.py index d64c01e9e..1c5eefeb3 100644 --- a/policyengine_api/fastapi_routes/v2/households/document_models.py +++ b/policyengine_api/fastapi_routes/v2/households/document_models.py @@ -87,7 +87,8 @@ class USHouseholdDocument(StrictHouseholdDocumentModel): def require_spm_object(cls, value: object) -> object: """Reject an explicit null ``spm``; omit the field to make no selection.""" if value is None: - raise ValueError("spm must be an object when supplied") + msg = "spm must be an object when supplied" + raise ValueError(msg) return value @model_serializer(mode="wrap") diff --git a/policyengine_api/fastapi_routes/v2/households/request_models.py b/policyengine_api/fastapi_routes/v2/households/request_models.py index 80af28ab9..eccb65bb1 100644 --- a/policyengine_api/fastapi_routes/v2/households/request_models.py +++ b/policyengine_api/fastapi_routes/v2/households/request_models.py @@ -31,7 +31,8 @@ def require_country_document(self) -> HouseholdCreateRequest: USHouseholdDocument if self.country_id == "us" else UKHouseholdDocument ) if not isinstance(self.household_data, expected_type): - raise ValueError("household_data collections must match country_id") + msg = "household_data collections must match country_id" + raise ValueError(msg) return self def to_service_input(self) -> NativeHouseholdCreationInput: diff --git a/policyengine_api/fastapi_routes/v2/households/routes.py b/policyengine_api/fastapi_routes/v2/households/routes.py index 6e17e322a..2c59174dd 100644 --- a/policyengine_api/fastapi_routes/v2/households/routes.py +++ b/policyengine_api/fastapi_routes/v2/households/routes.py @@ -55,13 +55,14 @@ async def enforce_household_request_size(request: Request) -> None: try: declared_length = int(content_length) except ValueError as error: - raise V2RequestTooLargeError( - "Household request Content-Length is invalid" - ) from error + msg = "Household request Content-Length is invalid" + raise V2RequestTooLargeError(msg) from error if declared_length > MAXIMUM_HOUSEHOLD_REQUEST_BYTES: - raise V2RequestTooLargeError("Household request body exceeds 1 MiB") + msg = "Household request body exceeds 1 MiB" + raise V2RequestTooLargeError(msg) if len(await request.body()) > MAXIMUM_HOUSEHOLD_REQUEST_BYTES: - raise V2RequestTooLargeError("Household request body exceeds 1 MiB") + msg = "Household request body exceeds 1 MiB" + raise V2RequestTooLargeError(msg) def _service_factory( diff --git a/policyengine_api/fastapi_routes/v2/policies/routes.py b/policyengine_api/fastapi_routes/v2/policies/routes.py index 9b33d467a..1e49445f3 100644 --- a/policyengine_api/fastapi_routes/v2/policies/routes.py +++ b/policyengine_api/fastapi_routes/v2/policies/routes.py @@ -60,13 +60,14 @@ async def enforce_policy_request_size(request: Request) -> None: try: declared_length = int(content_length) except ValueError as error: - raise V2RequestTooLargeError( - "Policy request Content-Length is invalid" - ) from error + msg = "Policy request Content-Length is invalid" + raise V2RequestTooLargeError(msg) from error if declared_length > MAXIMUM_POLICY_REQUEST_BYTES: - raise V2RequestTooLargeError("Policy request body exceeds 1 MiB") + msg = "Policy request body exceeds 1 MiB" + raise V2RequestTooLargeError(msg) if len(await request.body()) > MAXIMUM_POLICY_REQUEST_BYTES: - raise V2RequestTooLargeError("Policy request body exceeds 1 MiB") + msg = "Policy request body exceeds 1 MiB" + raise V2RequestTooLargeError(msg) def _service_factory( diff --git a/policyengine_api/libs/gateway_auth.py b/policyengine_api/libs/gateway_auth.py index dcdc0dde7..1388b91e7 100644 --- a/policyengine_api/libs/gateway_auth.py +++ b/policyengine_api/libs/gateway_auth.py @@ -78,12 +78,13 @@ def _require_all_or_none_gateway_auth_env() -> None: name for name in GATEWAY_AUTH_SECRET_SOURCE_ENV_VARS if os.environ.get(name) ] if len(present_secret_sources) > 1: - raise GatewayAuthError( + msg = ( "Gateway auth is ambiguously configured: both " f"{GATEWAY_AUTH_CLIENT_SECRET_ENV} and " f"{GATEWAY_AUTH_CLIENT_SECRET_RESOURCE_ENV} are set. " "Set exactly one secret source." ) + raise GatewayAuthError(msg) if present_core or present_secret_sources: missing_core = [ name for name in GATEWAY_AUTH_CORE_ENV_VARS if not os.environ.get(name) @@ -101,11 +102,12 @@ def _require_all_or_none_gateway_auth_env() -> None: ), ] present = [*present_core, *present_secret_sources] - raise GatewayAuthError( + msg = ( "Gateway auth is partially configured: " f"{', '.join(present)} set but {', '.join(missing)} missing. " "Set issuer, audience, client ID, and exactly one secret source." ) + raise GatewayAuthError(msg) class GatewayAuthTokenProvider: @@ -169,13 +171,14 @@ def configured(self) -> bool: def get_token(self) -> str: """Return a valid bearer token, refreshing it when necessary.""" if not self.configured: - raise GatewayAuthError( + msg = ( "Gateway auth not configured: set " f"{GATEWAY_AUTH_ISSUER_ENV}, {GATEWAY_AUTH_AUDIENCE_ENV}, " f"{GATEWAY_AUTH_CLIENT_ID_ENV}, and either " f"{GATEWAY_AUTH_CLIENT_SECRET_ENV} or " f"{GATEWAY_AUTH_CLIENT_SECRET_RESOURCE_ENV}." ) + raise GatewayAuthError(msg) with self._lock: now = time.time() @@ -201,23 +204,25 @@ def _fetch_locked(self) -> None: timeout=self._http_timeout, ) except httpx.RequestError as exc: - raise GatewayAuthError(f"Auth0 token fetch network error: {exc}") from exc + msg = f"Auth0 token fetch network error: {exc}" + raise GatewayAuthError(msg) from exc try: response.raise_for_status() except httpx.HTTPStatusError as exc: - raise GatewayAuthError( - f"Auth0 token fetch failed: HTTP {response.status_code}" - ) from exc + msg = f"Auth0 token fetch failed: HTTP {response.status_code}" + raise GatewayAuthError(msg) from exc data = response.json() token = data.get("access_token") if not token: - raise GatewayAuthError("Auth0 response missing access_token") + msg = "Auth0 response missing access_token" + raise GatewayAuthError(msg) raw_expires_in = data.get("expires_in") if raw_expires_in is None: - raise GatewayAuthError("Auth0 response missing expires_in") + msg = "Auth0 response missing expires_in" + raise GatewayAuthError(msg) # Clamp pathological short expiries to avoid perpetual refetching. expires_in = max(int(raw_expires_in), self._REFRESH_MARGIN_SECONDS * 2) @@ -229,24 +234,25 @@ def _get_client_secret_locked(self) -> str: if self._client_secret: return self._client_secret if not self._client_secret_resource: - raise GatewayAuthError( + msg = ( "Gateway auth client secret not configured: set " f"{GATEWAY_AUTH_CLIENT_SECRET_ENV} or " f"{GATEWAY_AUTH_CLIENT_SECRET_RESOURCE_ENV}." ) + raise GatewayAuthError(msg) try: self._client_secret = _load_secret_from_secret_manager( self._client_secret_resource ) except Exception as exc: - raise GatewayAuthError( + msg = ( "Failed to load gateway auth client secret from Secret Manager " f"resource {self._client_secret_resource}: {exc}" - ) from exc - if not self._client_secret: - raise GatewayAuthError( - "Secret Manager returned an empty gateway auth client secret." ) + raise GatewayAuthError(msg) from exc + if not self._client_secret: + msg = "Secret Manager returned an empty gateway auth client secret." + raise GatewayAuthError(msg) return self._client_secret def invalidate(self) -> None: diff --git a/policyengine_api/libs/simulation_entrypoint.py b/policyengine_api/libs/simulation_entrypoint.py index ecea6e8e6..1691b3a58 100644 --- a/policyengine_api/libs/simulation_entrypoint.py +++ b/policyengine_api/libs/simulation_entrypoint.py @@ -30,7 +30,8 @@ def _required_base_url(env_name: str) -> str: value = os.environ.get(env_name, "").strip() if not value: - raise ValueError(f"{env_name} is required") + msg = f"{env_name} is required" + raise ValueError(msg) parsed = urlparse(value) if ( @@ -41,10 +42,11 @@ def _required_base_url(env_name: str) -> str: or parsed.query or parsed.fragment ): - raise ValueError( + msg = ( f"{env_name} must be an absolute HTTP(S) base URL without " "credentials, a query, or a fragment" ) + raise ValueError(msg) return value.rstrip("/") @@ -55,7 +57,8 @@ def resolve_simulation_entrypoint_url(entrypoint: str | None = None) -> str: return _required_base_url("OLD_SIMULATION_GATEWAY_URL") if selected_entrypoint == "cloud_run_simulation_entrypoint": return _required_base_url("SIMULATION_ENTRYPOINT_URL") - raise ValueError(f"Unsupported simulation entrypoint: {selected_entrypoint!r}") + msg = f"Unsupported simulation entrypoint: {selected_entrypoint!r}" + raise ValueError(msg) def _attach_current_request_id(request: httpx.Request) -> None: @@ -129,12 +132,13 @@ def __init__(self, entrypoint: str | None = None) -> None: ) if auth is None: if gateway_auth_required(): - raise GatewayAuthError( + msg = ( "Gateway auth is required in this runtime: set " "GATEWAY_AUTH_ISSUER, GATEWAY_AUTH_AUDIENCE, " "GATEWAY_AUTH_CLIENT_ID, and " "GATEWAY_AUTH_CLIENT_SECRET." ) + raise GatewayAuthError(msg) print( "SimulationEntrypointClient initialized without gateway auth; " "all GATEWAY_AUTH_* env vars are unset and " @@ -151,7 +155,8 @@ def __init__(self, entrypoint: str | None = None) -> None: def _normalize_submission_payload(self, payload: dict) -> dict: if "data" in payload or "data_version" in payload: - raise ValueError("Dataset overrides are not supported") + msg = "Dataset overrides are not supported" + raise ValueError(msg) from policyengine_api.constants import ( COUNTRY_PACKAGE_VERSIONS, @@ -172,8 +177,9 @@ def _normalize_submission_payload(self, payload: dict) -> dict: if bundle_version == "latest": from policyengine_api.spm import SPMValidationError + msg = "SPM_SETTINGS_INVALID" raise SPMValidationError( - "SPM_SETTINGS_INVALID", + msg, "Canonical SPM execution requires an exact PolicyEngine version", ) payload = { @@ -326,19 +332,23 @@ def get_spm_capability( response.raise_for_status() versions = response.json() if not isinstance(versions, dict): - raise ValueError("Invalid worker versions response") + msg = "Invalid worker versions response" + raise ValueError(msg) route_kind = "policyengine" if policyengine_version else country route_version = policyengine_version or version routes = versions[route_kind] if not isinstance(routes, dict): - raise ValueError("Invalid worker route map") + msg = "Invalid worker route map" + raise ValueError(msg) if route_version is None or route_version == "latest": route_version = routes["latest"] if not isinstance(route_version, str) or not route_version: - raise ValueError("Invalid worker route version") + msg = "Invalid worker route version" + raise ValueError(msg) app_name = routes[route_version] if not isinstance(app_name, str) or not app_name: - raise ValueError("Invalid worker application identity") + msg = "Invalid worker application identity" + raise ValueError(msg) # Registry bundles and their capabilities are keyed by wrapper # version. Country-version routes identify only the deployed app; # resolve its unique bundle instead of treating the app as a key. @@ -353,14 +363,16 @@ def get_spm_capability( or not app for bundle, app in bundle_routes.items() ): - raise ValueError("Invalid worker bundle route map") + msg = "Invalid worker bundle route map" + raise ValueError(msg) candidates = [ bundle for bundle, app in bundle_routes.items() if bundle != "latest" and app == app_name ] if len(candidates) != 1: - raise ValueError("Worker route has no unambiguous bundle identity") + msg = "Worker route has no unambiguous bundle identity" + raise ValueError(msg) bundle_version = candidates[0] capabilities = versions.get("spm_capabilities") return ( @@ -369,8 +381,9 @@ def get_spm_capability( else None ) except (httpx.HTTPError, KeyError, TypeError, ValueError) as exc: + msg = "SPM_CONFIGURATION_UNAVAILABLE" raise SPMValidationError( - "SPM_CONFIGURATION_UNAVAILABLE", + msg, "Cannot validate the selected worker's canonical SPM capability", ) from exc @@ -391,9 +404,8 @@ def resolve_app_name( version or policyengine_version ) except KeyError as exc: - raise ValueError( - f"Unknown policyengine version {policyengine_version}" - ) from exc + msg = f"Unknown policyengine version {policyengine_version}" + raise ValueError(msg) from exc response = self.client.get(f"{self.base_url}/versions/{country}") response.raise_for_status() @@ -403,9 +415,8 @@ def resolve_app_name( try: return version_map[resolved_version], resolved_version except KeyError as exc: - raise ValueError( - f"Unknown version {resolved_version} for country {country}" - ) from exc + msg = f"Unknown version {resolved_version} for country {country}" + raise ValueError(msg) from exc def get_execution_id(self, execution: ModalSimulationExecution) -> str: """Get the job ID from an execution. diff --git a/policyengine_api/migration_flags.py b/policyengine_api/migration_flags.py index 67cf4f6bc..19a813493 100644 --- a/policyengine_api/migration_flags.py +++ b/policyengine_api/migration_flags.py @@ -76,7 +76,8 @@ def _read_choice(env_name: str, default: str, valid_values: frozenset[str]) -> s value = os.environ.get(env_name, default) if value not in valid_values: choices = ", ".join(sorted(valid_values)) - raise ValueError(f"{env_name}={value!r} is invalid; expected one of: {choices}") + msg = f"{env_name}={value!r} is invalid; expected one of: {choices}" + raise ValueError(msg) return value @@ -242,13 +243,11 @@ def get_migration_context( db_read = get_db_read(db_entity) if db_entity else None else: if db_write_source is not None and db_write_source not in DB_WRITE_SOURCES: - raise ValueError( - f"invalid explicit database write source {db_write_source!r}" - ) + msg = f"invalid explicit database write source {db_write_source!r}" + raise ValueError(msg) if db_read_source is not None and db_read_source not in DB_READ_SOURCES: - raise ValueError( - f"invalid explicit database read source {db_read_source!r}" - ) + msg = f"invalid explicit database read source {db_read_source!r}" + raise ValueError(msg) db_write = db_write_source db_read = db_read_source diff --git a/policyengine_api/observability/stages.py b/policyengine_api/observability/stages.py index 698a69475..bb9c8b6d8 100644 --- a/policyengine_api/observability/stages.py +++ b/policyengine_api/observability/stages.py @@ -55,9 +55,8 @@ class StagePlan: def name(self, stage: Stage) -> str: """Return the span name for ``stage``; raise ValueError if not in this plan.""" if stage not in self.stages: - raise ValueError( - f"{stage.value!r} is not registered for {self.configuration.value!r}" - ) + msg = f"{stage.value!r} is not registered for {self.configuration.value!r}" + raise ValueError(msg) return stage.value diff --git a/policyengine_api/query_parameters.py b/policyengine_api/query_parameters.py index fc59d5d2c..4fcceb11d 100644 --- a/policyengine_api/query_parameters.py +++ b/policyengine_api/query_parameters.py @@ -51,9 +51,8 @@ def normalize_country_id(value: object) -> object: def validate_legacy_user_id(value: str) -> str: """Reject an empty or whitespace-only legacy user identifier.""" if not value.strip(): - raise ValueError( - "legacy user_id must contain at least one non-whitespace character" - ) + msg = "legacy user_id must contain at least one non-whitespace character" + raise ValueError(msg) return value @@ -110,7 +109,8 @@ def _unique_json_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]: result: dict[str, Any] = {} for name, value in pairs: if name in result: - raise ValueError(f"SPM field {name!r} must not be repeated") + msg = f"SPM field {name!r} must not be repeated" + raise ValueError(msg) result[name] = value return result @@ -120,7 +120,8 @@ def parse_spm_query_value(value: object) -> dict[str, object] | SPMSelection: if isinstance(value, str): value = json.loads(value, object_pairs_hook=_unique_json_object) if not isinstance(value, (dict, SPMSelection)): - raise ValueError("spm must be a JSON object") + msg = "spm must be a JSON object" + raise ValueError(msg) return value @@ -175,9 +176,8 @@ class BudgetWindowEconomyQuery(EconomyQuery): def validate_end_year(self) -> BudgetWindowEconomyQuery: """Reject a window whose last year falls after BUDGET_WINDOW_MAX_END_YEAR.""" if int(self.start_year) + self.window_size - 1 > BUDGET_WINDOW_MAX_END_YEAR: - raise ValueError( - f"budget-window end_year must be {BUDGET_WINDOW_MAX_END_YEAR} or earlier" - ) + msg = f"budget-window end_year must be {BUDGET_WINDOW_MAX_END_YEAR} or earlier" + raise ValueError(msg) return self @@ -333,9 +333,11 @@ def parse_multidict_query( """Adapt a Flask/Werkzeug-style MultiDict to the canonical parser.""" items_method = getattr(query_input, "items", None) if not callable(items_method): - raise TypeError("query input must provide an items method") + msg = "query input must provide an items method" + raise TypeError(msg) try: items = items_method(multi=True) except TypeError as error: - raise TypeError("query input must preserve repeated keys") from error + msg = "query input must preserve repeated keys" + raise TypeError(msg) from error return parse_query_items(model_type, items) diff --git a/policyengine_api/routes/household_routes.py b/policyengine_api/routes/household_routes.py index 0235fea20..603ea42e6 100644 --- a/policyengine_api/routes/household_routes.py +++ b/policyengine_api/routes/household_routes.py @@ -167,12 +167,12 @@ def _requested_spm(country_id: str, payload: dict) -> object: return None selection = payload["spm"] if country_id != "us": - raise SPMValidationError( - "SPM_SETTINGS_UNSUPPORTED", "SPM settings are only available for the US" - ) + msg = "SPM_SETTINGS_UNSUPPORTED" + raise SPMValidationError(msg, "SPM settings are only available for the US") if selection is None: + msg = "SPM_SETTINGS_INVALID" raise SPMValidationError( - "SPM_SETTINGS_INVALID", + msg, "spm must be an object; omit it to inherit the certified defaults.", ) return selection @@ -195,7 +195,8 @@ def wrapped( ) -> _ViewResult | Response: payload = request.get_json() if not isinstance(payload, dict): - raise BadRequest("Calculation payload must be a JSON object.") + msg = "Calculation payload must be a JSON object." + raise BadRequest(msg) try: selection = _requested_spm(country_id, payload) g.spm_requested = selection is not None @@ -275,7 +276,8 @@ def get_household(country_id: str, household_id: int) -> Response: household = household_service.get_household(country_id, household_id) result = None if household is None else _serialize_household(household) if result is None: - raise NotFound(f"Household #{household_id} not found.") + msg = f"Household #{household_id} not found." + raise NotFound(msg) return Response( json.dumps( { @@ -302,7 +304,8 @@ def post_household(country_id: str) -> Response: payload = request.json is_payload_valid, message = validate_household_payload(payload) if not is_payload_valid: - raise BadRequest(f"Unable to create new household; details: {message}") + msg = f"Unable to create new household; details: {message}" + raise BadRequest(msg) # The household label appears to be unimplemented at this time, # thus it should always be 'None' diff --git a/policyengine_api/routes/policy_routes.py b/policyengine_api/routes/policy_routes.py index 5f5e4d61a..df2f345a9 100644 --- a/policyengine_api/routes/policy_routes.py +++ b/policyengine_api/routes/policy_routes.py @@ -167,7 +167,8 @@ def get_policy(country_id: str, policy_id: int | str) -> Response: result = None if policy is None else _serialize_policy(policy) if result is None: - raise NotFound(f"Policy #{policy_id} not found.") + msg = f"Policy #{policy_id} not found." + raise NotFound(msg) return Response( json.dumps({"status": "ok", "message": None, "result": result}), @@ -192,7 +193,8 @@ def set_policy(country_id: str) -> Response: is_payload_valid, message = validate_set_policy_payload(payload) if not is_payload_valid: - raise BadRequest(f"Invalid JSON data; details: {message}") + msg = f"Invalid JSON data; details: {message}" + raise BadRequest(msg) try: write_source = get_v1_policy_write_source() diff --git a/policyengine_api/routes/report_output_routes.py b/policyengine_api/routes/report_output_routes.py index b8a919de3..c8decc990 100644 --- a/policyengine_api/routes/report_output_routes.py +++ b/policyengine_api/routes/report_output_routes.py @@ -55,7 +55,8 @@ def create_report_output(country_id: str) -> Response: print(f"Creating report output for country {country_id}") payload = request.json if payload is None: - raise BadRequest("Payload missing from request") + msg = "Payload missing from request" + raise BadRequest(msg) # Extract required fields simulation_1_id = payload.get("simulation_1_id") @@ -64,13 +65,17 @@ def create_report_output(country_id: str) -> Response: # Validate required fields if simulation_1_id is None: - raise BadRequest("simulation_1_id is required") + msg = "simulation_1_id is required" + raise BadRequest(msg) if not isinstance(simulation_1_id, int): - raise BadRequest("simulation_1_id must be an integer") + msg = "simulation_1_id must be an integer" + raise BadRequest(msg) if simulation_2_id is not None and not isinstance(simulation_2_id, int): - raise BadRequest("simulation_2_id must be an integer or null") + msg = "simulation_2_id must be an integer or null" + raise BadRequest(msg) if not isinstance(year, str): - raise BadRequest("year must be a string") + msg = "year must be a string" + raise BadRequest(msg) try: creation = report_output_service.create_or_reuse_report_output( @@ -107,7 +112,8 @@ def create_report_output(country_id: str) -> Response: current_app.logger.warning( "Bad request creating report output for country %s: %s", country_id, e ) - raise BadRequest(f"Failed to create report output: {e}") + msg = f"Failed to create report output: {e}" + raise BadRequest(msg) except Exception: current_app.logger.exception( "Unexpected error creating report output for country %s", country_id @@ -134,7 +140,8 @@ def get_report_output(country_id: str, report_id: int) -> Response: view = report_output_service.resolve_report_output(country_id, report_id) if view is None: - raise NotFound(f"Report #{report_id} not found.") + msg = f"Report #{report_id} not found." + raise NotFound(msg) result = _serialize_v1_report_output(view) response_body = dict( @@ -168,7 +175,8 @@ def update_report_output(country_id: str) -> Response: """ payload = request.json if payload is None: - raise BadRequest("Payload missing from request") + msg = "Payload missing from request" + raise BadRequest(msg) # Extract optional fields status = payload.get("status") @@ -184,11 +192,13 @@ def update_report_output(country_id: str) -> Response: "complete", "error", ]: - raise BadRequest("status must be 'pending', 'running', 'complete', or 'error'") + msg = "status must be 'pending', 'running', 'complete', or 'error'" + raise BadRequest(msg) # Validate that complete status has output if status == "complete" and output is None: - raise BadRequest("output is required when status is 'complete'") + msg = "output is required when status is 'complete'" + raise BadRequest(msg) try: view = report_output_service.update_report_output( @@ -199,7 +209,8 @@ def update_report_output(country_id: str) -> Response: error_message=error_message, ) if view is None: - raise BadRequest("No fields to update") + msg = "No fields to update" + raise BadRequest(msg) result = _serialize_v1_report_output(view) response_body = dict( @@ -215,7 +226,8 @@ def update_report_output(country_id: str) -> Response: ) except LookupError: - raise NotFound(f"Report #{report_id} not found.") from None + msg = f"Report #{report_id} not found." + raise NotFound(msg) from None except HTTPException: # Let explicit client-error responses (BadRequest/NotFound/etc.) pass # through without being logged as "Unexpected error". @@ -227,7 +239,8 @@ def update_report_output(country_id: str) -> Response: country_id, e, ) - raise BadRequest(f"Failed to update report output: {e}") + msg = f"Failed to update report output: {e}" + raise BadRequest(msg) except Exception: current_app.logger.exception( "Unexpected error updating report #%s for country %s", diff --git a/policyengine_api/routes/simulation_routes.py b/policyengine_api/routes/simulation_routes.py index 76ca6e7db..6c4ea211f 100644 --- a/policyengine_api/routes/simulation_routes.py +++ b/policyengine_api/routes/simulation_routes.py @@ -63,7 +63,8 @@ def create_simulation(country_id: str) -> Response: payload = request.json if payload is None: - raise BadRequest("Payload missing from request") + msg = "Payload missing from request" + raise BadRequest(msg) if (response := _spm_override_response(payload)) is not None: return response @@ -74,15 +75,20 @@ def create_simulation(country_id: str) -> Response: # Validate required fields if not population_id: - raise BadRequest("population_id is required") + msg = "population_id is required" + raise BadRequest(msg) if not population_type: - raise BadRequest("population_type is required") + msg = "population_type is required" + raise BadRequest(msg) if population_type not in ["household", "geography"]: - raise BadRequest("population_type must be 'household' or 'geography'") + msg = "population_type must be 'household' or 'geography'" + raise BadRequest(msg) if policy_id is None: - raise BadRequest("policy_id is required") + msg = "policy_id is required" + raise BadRequest(msg) if not isinstance(policy_id, int): - raise BadRequest("policy_id must be an integer") + msg = "policy_id must be an integer" + raise BadRequest(msg) try: creation = simulation_service.get_or_create_simulation( @@ -119,7 +125,8 @@ def create_simulation(country_id: str) -> Response: current_app.logger.warning( "Bad request creating simulation for country %s: %s", country_id, e ) - raise BadRequest(f"Failed to create simulation: {e}") + msg = f"Failed to create simulation: {e}" + raise BadRequest(msg) except Exception: current_app.logger.exception( "Unexpected error creating simulation for country %s", country_id @@ -140,15 +147,18 @@ def get_simulation(country_id: str, simulation_id: int) -> Response: print(f"Getting simulation {simulation_id} for country {country_id}") if not isinstance(simulation_id, int): - raise BadRequest("simulation_id must be an integer") + msg = "simulation_id must be an integer" + raise BadRequest(msg) if simulation_id <= 0: - raise BadRequest("simulation_id must be a positive integer") + msg = "simulation_id must be a positive integer" + raise BadRequest(msg) simulation = simulation_service.get_simulation(country_id, simulation_id) result = None if simulation is None else _serialize_v1_simulation(simulation) if result is None: - raise NotFound(f"Simulation #{simulation_id} not found.") + msg = f"Simulation #{simulation_id} not found." + raise NotFound(msg) response_body = dict( status="ok", @@ -181,7 +191,8 @@ def update_simulation(country_id: str) -> Response: """ payload = request.json if payload is None: - raise BadRequest("Payload missing from request") + msg = "Payload missing from request" + raise BadRequest(msg) if (response := _spm_override_response(payload)) is not None: return response @@ -194,11 +205,13 @@ def update_simulation(country_id: str) -> Response: # Validate status if provided if status is not None and status not in ["pending", "complete", "error"]: - raise BadRequest("status must be 'pending', 'complete', or 'error'") + msg = "status must be 'pending', 'complete', or 'error'" + raise BadRequest(msg) # Validate that complete status has output if status == "complete" and output is None: - raise BadRequest("output is required when status is 'complete'") + msg = "output is required when status is 'complete'" + raise BadRequest(msg) try: simulation = simulation_service.update_simulation( @@ -210,7 +223,8 @@ def update_simulation(country_id: str) -> Response: ) if simulation is None: - raise BadRequest("No fields to update") + msg = "No fields to update" + raise BadRequest(msg) result = _serialize_v1_simulation(simulation) @@ -227,7 +241,8 @@ def update_simulation(country_id: str) -> Response: ) except LookupError: - raise NotFound(f"Simulation #{simulation_id} not found.") from None + msg = f"Simulation #{simulation_id} not found." + raise NotFound(msg) from None except HTTPException: # Let explicit client-error responses (BadRequest/NotFound/etc.) pass # through without being logged as "Unexpected error". @@ -239,7 +254,8 @@ def update_simulation(country_id: str) -> Response: country_id, e, ) - raise BadRequest(f"Failed to update simulation: {e}") + msg = f"Failed to update simulation: {e}" + raise BadRequest(msg) except Exception: current_app.logger.exception( "Unexpected error updating simulation #%s for country %s", diff --git a/policyengine_api/routes/user_profile_routes.py b/policyengine_api/routes/user_profile_routes.py index 4ffcccbad..1ab09dc6d 100644 --- a/policyengine_api/routes/user_profile_routes.py +++ b/policyengine_api/routes/user_profile_routes.py @@ -39,7 +39,8 @@ def set_user_profile(country_id: str) -> Response: """ payload = request.json if payload is None: - raise BadRequest("Payload missing from request") + msg = "Payload missing from request" + raise BadRequest(msg) auth0_id = payload.pop("auth0_id") username = payload.pop("username", None) @@ -77,7 +78,8 @@ def get_user_profile(country_id: str) -> Response: user_id = request.args.get("user_id") if (auth0_id is None) and (user_id is None): - raise BadRequest("auth0_id or user_id must be provided") + msg = "auth0_id or user_id must be provided" + raise BadRequest(msg) profile = ( user_service.get_profile(user_id=user_id) @@ -94,7 +96,8 @@ def get_user_profile(country_id: str) -> Response: ) if readable_row is None: - raise NotFound("No such user") + msg = "No such user" + raise NotFound(msg) response_body = dict( status="ok", @@ -120,7 +123,8 @@ def update_user_profile(country_id: str) -> Response: payload = request.json if payload is None: - raise BadRequest("No user data provided in request") + msg = "No user data provided in request" + raise BadRequest(msg) # TODO: we should validate the payload # to ensure type safety https://github.com/PolicyEngine/policyengine-api/issues/2054 @@ -130,7 +134,8 @@ def update_user_profile(country_id: str) -> Response: user_since = payload.pop("user_since", None) if user_id is None: - raise BadRequest("Payload must include user_id") + msg = "Payload must include user_id" + raise BadRequest(msg) updated = user_service.update_profile( user_id=user_id, @@ -140,7 +145,8 @@ def update_user_profile(country_id: str) -> Response: ) if not updated: - raise NotFound("No such user id") + msg = "No such user id" + raise NotFound(msg) response_body = dict( status="ok", diff --git a/policyengine_api/runtime_cache/claims.py b/policyengine_api/runtime_cache/claims.py index 7ce7008f0..db8003e0c 100644 --- a/policyengine_api/runtime_cache/claims.py +++ b/policyengine_api/runtime_cache/claims.py @@ -38,7 +38,8 @@ def acquire(self, key: str, token: str, *, ttl_seconds: int) -> bool: proceed as if they owned the work. """ if not token or ttl_seconds <= 0: - raise ValueError("claim token and positive TTL are required") + msg = "claim token and positive TTL are required" + raise ValueError(msg) started_at = time.perf_counter() try: acquired = bool(self.client.set(key, token, nx=True, ex=ttl_seconds)) @@ -50,9 +51,8 @@ def acquire(self, key: str, token: str, *, ttl_seconds: int) -> bool: started_at=started_at, severity="WARNING", ) - raise CacheCoordinationError( - "shared-cache ownership could not be established" - ) from error + msg = "shared-cache ownership could not be established" + raise CacheCoordinationError(msg) from error record_cache_event( family=self.family, event="claim-acquired" if acquired else "claim-contended", @@ -78,9 +78,8 @@ def release(self, key: str, token: str) -> bool: started_at=started_at, severity="WARNING", ) - raise CacheCoordinationError( - "shared-cache ownership could not be released safely" - ) from error + msg = "shared-cache ownership could not be released safely" + raise CacheCoordinationError(msg) from error record_cache_event( family=self.family, event="claim-released" if released else "claim-release-rejected", diff --git a/policyengine_api/runtime_cache/client.py b/policyengine_api/runtime_cache/client.py index 13ad1cc51..73f1c4084 100644 --- a/policyengine_api/runtime_cache/client.py +++ b/policyengine_api/runtime_cache/client.py @@ -23,7 +23,8 @@ def build_runtime_cache_client(settings: RuntimeCacheSettings) -> redis.Redis: Raise RuntimeError when the cache is disabled or TLS has no CA bundle. """ if not settings.enabled or settings.url is None: - raise RuntimeError("the shared runtime cache is disabled") + msg = "the shared runtime cache is disabled" + raise RuntimeError(msg) kwargs: dict[str, Any] = { "decode_responses": True, "max_connections": settings.max_connections, @@ -35,7 +36,8 @@ def build_runtime_cache_client(settings: RuntimeCacheSettings) -> redis.Redis: if settings.tls: kwargs["ssl_cert_reqs"] = "required" if settings.ca_cert is None: - raise RuntimeError("the shared runtime cache TLS CA is missing") + msg = "the shared runtime cache TLS CA is missing" + raise RuntimeError(msg) kwargs["ssl_ca_data"] = settings.ca_cert.get_secret_value() return redis.Redis.from_url(settings.url.get_secret_value(), **kwargs) diff --git a/policyengine_api/runtime_cache/core.py b/policyengine_api/runtime_cache/core.py index f6bc712ea..c4c4026fc 100644 --- a/policyengine_api/runtime_cache/core.py +++ b/policyengine_api/runtime_cache/core.py @@ -184,13 +184,16 @@ def jittered_ttl( Coordination and claim TTLs are safety bounds and must not use this helper. """ if ttl_seconds <= 0: - raise ValueError("cache TTL must be positive") + msg = "cache TTL must be positive" + raise ValueError(msg) if not 0 <= jitter_fraction < 1: - raise ValueError("TTL jitter fraction must be at least 0 and less than 1") + msg = "TTL jitter fraction must be at least 0 and less than 1" + raise ValueError(msg) max_reduction = int(ttl_seconds * jitter_fraction) reduction = choose_reduction(0, max_reduction) if not 0 <= reduction <= max_reduction: - raise ValueError("TTL jitter reduction is outside the permitted range") + msg = "TTL jitter reduction is outside the permitted range" + raise ValueError(msg) return ttl_seconds - reduction @@ -230,7 +233,8 @@ def __init__( ) -> None: """Bind the cache to one key family; raise ValueError for a non-positive TTL.""" if ttl_seconds <= 0: - raise ValueError("cache TTL must be positive") + msg = "cache TTL must be positive" + raise ValueError(msg) self.client = client self.namespace = namespace self.family = family diff --git a/policyengine_api/runtime_cache/fake.py b/policyengine_api/runtime_cache/fake.py index 89b83a0c7..bc5e2f195 100644 --- a/policyengine_api/runtime_cache/fake.py +++ b/policyengine_api/runtime_cache/fake.py @@ -30,7 +30,8 @@ def advance(self, seconds: float) -> None: Raises RuntimeError when the fake follows an external clock instead. """ if self._external_now is not None: - raise RuntimeError("cannot advance an externally clocked fake") + msg = "cannot advance an externally clocked fake" + raise RuntimeError(msg) self._time += seconds def _purge(self, key: str) -> None: @@ -153,7 +154,8 @@ def eval( expected token, and raise ValueError for any other key/argument shape. """ if numkeys != 1 or len(keys_and_args) != 2: - raise ValueError("fake supports one-key compare-and-delete only") + msg = "fake supports one-key compare-and-delete only" + raise ValueError(msg) key, expected = keys_and_args with self._lock: if self.get(key) != expected: @@ -248,4 +250,5 @@ def eval( *keys_and_args: str, ) -> int: """Raise RuntimeError, so claim operations fail closed when disabled.""" - raise RuntimeError("the shared runtime cache is disabled") + msg = "the shared runtime cache is disabled" + raise RuntimeError(msg) diff --git a/policyengine_api/runtime_cache/reform_impacts.py b/policyengine_api/runtime_cache/reform_impacts.py index 32d7a867d..50e0c776e 100644 --- a/policyengine_api/runtime_cache/reform_impacts.py +++ b/policyengine_api/runtime_cache/reform_impacts.py @@ -96,7 +96,8 @@ def _datetime_from_wire(value: object) -> datetime | None: if value is None: return None if not isinstance(value, str): - raise ValueError("invalid cached datetime") + msg = "invalid cached datetime" + raise ValueError(msg) parsed = datetime.fromisoformat(value) if parsed.tzinfo is not None: parsed = parsed.astimezone(UTC).replace(tzinfo=None) @@ -234,9 +235,8 @@ def claim_start( } ) if claim is None: - raise ValueError( - "a claim token and valid observability identifier are required" - ) + msg = "a claim token and valid observability identifier are required" + raise ValueError(msg) return self._start_claims.acquire( self._start_claim_key( country_id=country_id, @@ -289,9 +289,8 @@ def get_start_claim( started_at=started_at, severity="WARNING", ) - raise CacheCoordinationError( - "reform-impact submission ownership is unavailable" - ) from error + msg = "reform-impact submission ownership is unavailable" + raise CacheCoordinationError(msg) from error payload = decode_envelope( encoded, @@ -307,9 +306,8 @@ def get_start_claim( started_at=started_at, severity="WARNING", ) - raise CacheCoordinationError( - "reform-impact submission ownership is unreadable" - ) + msg = "reform-impact submission ownership is unreadable" + raise CacheCoordinationError(msg) record_cache_event( family=self.family, @@ -342,9 +340,8 @@ def release_start( } ) if claim is None: - raise ValueError( - "a claim token and valid observability identifier are required" - ) + msg = "a claim token and valid observability identifier are required" + raise ValueError(msg) return self._start_claims.release( self._start_claim_key( @@ -414,7 +411,8 @@ def set( # the original record key and lookup indexes stable. record_execution_id = record_execution_id or impact.execution_id if not record_execution_id: - raise ValueError("a reform-impact cache record identifier is required") + msg = "a reform-impact cache record identifier is required" + raise ValueError(msg) record_key = self._record_key(record_execution_id) indexes = (self._scope_index(impact), self._recent_index()) try: diff --git a/policyengine_api/runtime_cache/settings.py b/policyengine_api/runtime_cache/settings.py index 958b0ac43..15e5a40ec 100644 --- a/policyengine_api/runtime_cache/settings.py +++ b/policyengine_api/runtime_cache/settings.py @@ -54,7 +54,8 @@ def enabled(self) -> bool: def _required(values: Mapping[str, str], name: str) -> str: value = values.get(name) if value is None or not value.strip(): - raise RuntimeCacheConfigurationError(f"{name} is required") + msg = f"{name} is required" + raise RuntimeCacheConfigurationError(msg) return value.strip() @@ -64,7 +65,8 @@ def _is_deployed(values: Mapping[str, str]) -> bool: def _validate_name(value: str, setting: str) -> str: if NAME_PATTERN.fullmatch(value) is None: - raise RuntimeCacheConfigurationError(f"{setting} is invalid") + msg = f"{setting} is invalid" + raise RuntimeCacheConfigurationError(msg) return value @@ -73,38 +75,30 @@ def _parse_url(raw_url: str, *, mode: str) -> SplitResult: parsed = urlsplit(raw_url) port = parsed.port except ValueError as error: - raise RuntimeCacheConfigurationError( - f"{RUNTIME_CACHE_URL} is invalid" - ) from error + msg = f"{RUNTIME_CACHE_URL} is invalid" + raise RuntimeCacheConfigurationError(msg) from error if parsed.scheme not in {"redis", "rediss"} or not parsed.hostname: - raise RuntimeCacheConfigurationError( - f"{RUNTIME_CACHE_URL} must use redis:// or rediss://" - ) + msg = f"{RUNTIME_CACHE_URL} must use redis:// or rediss://" + raise RuntimeCacheConfigurationError(msg) if parsed.query or parsed.fragment or parsed.path not in {"", "/0"}: - raise RuntimeCacheConfigurationError( - f"{RUNTIME_CACHE_URL} must select database 0 without query or fragment" - ) + msg = f"{RUNTIME_CACHE_URL} must select database 0 without query or fragment" + raise RuntimeCacheConfigurationError(msg) if port is None: - raise RuntimeCacheConfigurationError( - f"{RUNTIME_CACHE_URL} must include an explicit port" - ) + msg = f"{RUNTIME_CACHE_URL} must include an explicit port" + raise RuntimeCacheConfigurationError(msg) if mode == "deployed": if parsed.scheme != "rediss": - raise RuntimeCacheConfigurationError( - f"{RUNTIME_CACHE_URL} must require TLS in deployed mode" - ) + msg = f"{RUNTIME_CACHE_URL} must require TLS in deployed mode" + raise RuntimeCacheConfigurationError(msg) if parsed.hostname.lower() in LOCAL_HOSTS: - raise RuntimeCacheConfigurationError( - f"{RUNTIME_CACHE_URL} cannot use localhost in deployed mode" - ) + msg = f"{RUNTIME_CACHE_URL} cannot use localhost in deployed mode" + raise RuntimeCacheConfigurationError(msg) if parsed.password is None: - raise RuntimeCacheConfigurationError( - f"{RUNTIME_CACHE_URL} requires authentication in deployed mode" - ) + msg = f"{RUNTIME_CACHE_URL} requires authentication in deployed mode" + raise RuntimeCacheConfigurationError(msg) elif parsed.hostname.lower() not in LOCAL_HOSTS: - raise RuntimeCacheConfigurationError( - f"{RUNTIME_CACHE_URL} local mode requires an explicit local endpoint" - ) + msg = f"{RUNTIME_CACHE_URL} local mode requires an explicit local endpoint" + raise RuntimeCacheConfigurationError(msg) return parsed @@ -112,9 +106,8 @@ def _parse_ca_cert(values: Mapping[str, str], *, tls: bool) -> SecretStr | None: raw_ca_cert = values.get(RUNTIME_CACHE_CA_CERT, "") if not tls: if raw_ca_cert.strip(): - raise RuntimeCacheConfigurationError( - f"{RUNTIME_CACHE_CA_CERT} requires a TLS cache URL" - ) + msg = f"{RUNTIME_CACHE_CA_CERT} requires a TLS cache URL" + raise RuntimeCacheConfigurationError(msg) return None ca_cert = _required(values, RUNTIME_CACHE_CA_CERT) if ( @@ -122,9 +115,8 @@ def _parse_ca_cert(values: Mapping[str, str], *, tls: bool) -> SecretStr | None: or not ca_cert.endswith("-----END CERTIFICATE-----") or "\x00" in ca_cert ): - raise RuntimeCacheConfigurationError( - f"{RUNTIME_CACHE_CA_CERT} must contain a PEM certificate bundle" - ) + msg = f"{RUNTIME_CACHE_CA_CERT} must contain a PEM certificate bundle" + raise RuntimeCacheConfigurationError(msg) return SecretStr(ca_cert) @@ -137,20 +129,21 @@ def load_runtime_cache_settings( deployed_runtime = _is_deployed(values) mode = raw_mode or ("deployed" if deployed_runtime else "disabled") if mode not in CACHE_MODES: - raise RuntimeCacheConfigurationError( - f"{RUNTIME_CACHE_MODE} must be disabled, local, or deployed" - ) + msg = f"{RUNTIME_CACHE_MODE} must be disabled, local, or deployed" + raise RuntimeCacheConfigurationError(msg) if deployed_runtime and mode != "deployed": - raise RuntimeCacheConfigurationError( + msg = ( f"{RUNTIME_CACHE_MODE} must be deployed when K_SERVICE identifies " "a deployed runtime" ) + raise RuntimeCacheConfigurationError(msg) if mode == "disabled": if values.get(RUNTIME_CACHE_URL) or values.get(RUNTIME_CACHE_CA_CERT): - raise RuntimeCacheConfigurationError( + msg = ( "runtime cache secret configuration requires an explicit " "local or deployed mode" ) + raise RuntimeCacheConfigurationError(msg) return RuntimeCacheSettings( mode=mode, environment="test", diff --git a/policyengine_api/services/budget_window_cache.py b/policyengine_api/services/budget_window_cache.py index edff6530b..836820dda 100644 --- a/policyengine_api/services/budget_window_cache.py +++ b/policyengine_api/services/budget_window_cache.py @@ -62,7 +62,8 @@ def require_fields_for_status(self) -> BudgetWindowCacheState: if getattr(self, field) is None ] if missing: - raise ValueError(f"{self.status} state requires: {', '.join(missing)}") + msg = f"{self.status} state requires: {', '.join(missing)}" + raise ValueError(msg) return self @classmethod @@ -165,9 +166,8 @@ def get_state(self, cache_key: str) -> BudgetWindowCacheState | None: event="coordination-failed", started_at=started_at, ) - raise CacheCoordinationError( - "budget-window coordination state is unavailable" - ) from error + msg = "budget-window coordination state is unavailable" + raise CacheCoordinationError(msg) from error payload = decode_envelope( encoded, @@ -281,18 +281,16 @@ def store_submitted( event="coordination-failed", started_at=started_at, ) - raise CacheCoordinationError( - "budget-window coordination state is unavailable" - ) from error + msg = "budget-window coordination state is unavailable" + raise CacheCoordinationError(msg) from error if not stored: self._handle_cache_error( "write-submitted-state", event="coordination-failed", started_at=started_at, ) - raise CacheCoordinationError( - "budget-window submitted state could not be stored" - ) + msg = "budget-window submitted state could not be stored" + raise CacheCoordinationError(msg) record_cache_event( family=BUDGET_WINDOW_CACHE_FAMILY, event="coordination-write", diff --git a/policyengine_api/services/economy_service.py b/policyengine_api/services/economy_service.py index c2307b38d..1a7f0b377 100644 --- a/policyengine_api/services/economy_service.py +++ b/policyengine_api/services/economy_service.py @@ -332,7 +332,8 @@ def _parse_json_object(value: JSONValue) -> dict[str, Any]: """Accept ORM-decoded objects and legacy JSON text at a read boundary.""" parsed = json.loads(value) if isinstance(value, str) else value if not isinstance(parsed, dict): - raise TypeError("Expected a JSON object") + msg = "Expected a JSON object" + raise TypeError(msg) return parsed @observability_runtime.span(ECONOMY_ANNUAL_STAGES.name(Stage.ECONOMY_REQUEST)) @@ -555,9 +556,8 @@ def get_budget_window_economic_impact( ) break else: - raise CacheCoordinationError( - "budget-window submission ownership changed repeatedly" - ) + msg = "budget-window submission ownership changed repeatedly" + raise CacheCoordinationError(msg) if owns_claim: cache_status = "miss" @@ -816,9 +816,10 @@ def _get_budget_window_result_from_batch_job_id( cache_status=cache_status, ) - raise ValueError( + msg = ( f"Unexpected budget-window batch execution state: {batch_execution.status}" ) + raise ValueError(msg) def _build_budget_window_computing_result( self, @@ -862,9 +863,8 @@ def _build_economic_impact_setup_options( target: Literal["general", "cliff"] = "general", ) -> EconomicImpactSetupOptions: if dataset != "default": - raise ValueError( - "Custom datasets are not supported for economy calculations" - ) + msg = "Custom datasets are not supported for economy calculations" + raise ValueError(msg) resolved_spm = validate_worker_spm( country_id, options.get("spm"), @@ -1007,9 +1007,8 @@ def _get_or_create_economic_impact( ) break else: - raise CacheCoordinationError( - "reform-impact submission ownership changed repeatedly" - ) + msg = "reform-impact submission ownership changed repeatedly" + raise CacheCoordinationError(msg) # The identifier above was selected while the runtime-resolution # stage was current. Reapply it now so the containing cache-decision @@ -1051,7 +1050,8 @@ def _get_or_create_economic_impact( self._release_reform_impact_start(setup_options) return result - raise ValueError(f"Unexpected impact action: {impact_action}") + msg = f"Unexpected impact action: {impact_action}" + raise ValueError(msg) def _resolve_runtime_bundle_for_setup_options( self, @@ -1081,7 +1081,8 @@ def _reform_impact_start_claim_scope_arguments( setup_options: EconomicImpactSetupOptions, ) -> dict[str, Any]: if not setup_options.options_hash: - raise ValueError("resolved reform-impact options hash is required") + msg = "resolved reform-impact options hash is required" + raise ValueError(msg) return { "country_id": setup_options.country_id, "policy_id": setup_options.reform_policy_id, @@ -1101,7 +1102,8 @@ def _reform_impact_start_claim_arguments( ) -> dict[str, Any]: resolved_observability_id = observability_id or setup_options.observability_id if resolved_observability_id is None: - raise ValueError("reform-impact observability identifier is required") + msg = "reform-impact observability identifier is required" + raise ValueError(msg) return { **self._reform_impact_start_claim_scope_arguments(setup_options), "claim_token": setup_options.submission_claim_id, @@ -1223,7 +1225,8 @@ def _determine_impact_action( return ImpactAction.FAILED if status == ImpactStatus.COMPUTING.value: return ImpactAction.COMPUTING - raise ValueError(f"Unknown impact status: {status}") + msg = f"Unknown impact status: {status}" + raise ValueError(msg) @observability_runtime.span( ECONOMY_ANNUAL_STAGES.name(Stage.ECONOMY_HANDLE_EXECUTION_STATE) @@ -1292,7 +1295,8 @@ def _handle_execution_state( ) return EconomicImpactResult.computing() - raise ValueError(f"Unexpected sim API execution state: {execution_state}") + msg = f"Unexpected sim API execution state: {execution_state}" + raise ValueError(msg) @observability_runtime.span( ECONOMY_ANNUAL_STAGES.name(Stage.ECONOMY_READ_COMPLETED) @@ -1484,10 +1488,11 @@ def _handle_create_impact( except ReformImpactHandoffError: raise except Exception as error: - raise ReformImpactHandoffError( + msg = ( "simulation was submitted but its execution state could not be " "handed off" - ) from error + ) + raise ReformImpactHandoffError(msg) from error return EconomicImpactResult.computing() @@ -1666,16 +1671,19 @@ def _validate_us_region(self, region: str) -> None: if region.startswith("state/"): state_code = region[len("state/") :] if state_code.lower() not in get_valid_state_codes(): - raise ValueError(f"Invalid US state: '{state_code}'") + msg = f"Invalid US state: '{state_code}'" + raise ValueError(msg) elif region.startswith("place/"): place_code = region[len("place/") :] validate_place_code(place_code) elif region.startswith("congressional_district/"): district_id = region[len("congressional_district/") :] if district_id.lower() not in get_valid_congressional_districts(): - raise ValueError(f"Invalid congressional district: '{district_id}'") + msg = f"Invalid congressional district: '{district_id}'" + raise ValueError(msg) else: - raise ValueError(f"Invalid US region: '{region}'") + msg = f"Invalid US region: '{region}'" + raise ValueError(msg) def _build_simulation_telemetry( self, diff --git a/policyengine_api/services/household_calculation_service.py b/policyengine_api/services/household_calculation_service.py index f8ab17a67..8ceb14170 100644 --- a/policyengine_api/services/household_calculation_service.py +++ b/policyengine_api/services/household_calculation_service.py @@ -95,11 +95,13 @@ def _validated_spm_provenance( if expected_selection is None: if spm_provenance is None: return None - raise ValueError("Unexpected SPM provenance for a calculation without SPM") + msg = "Unexpected SPM provenance for a calculation without SPM" + raise ValueError(msg) receipt = validate_spm_calculation_provenance(spm_provenance) canonical_config = resolved_spm_configuration(receipt).model_dump(mode="json") if canonical_config != expected_selection: - raise ValueError("SPM calculation configuration differs from the request") + msg = "SPM calculation configuration differs from the request" + raise ValueError(msg) return receipt.model_dump(mode="json", by_alias=True) diff --git a/policyengine_api/services/household_mirroring.py b/policyengine_api/services/household_mirroring.py index bcf370718..8abcae4e1 100644 --- a/policyengine_api/services/household_mirroring.py +++ b/policyengine_api/services/household_mirroring.py @@ -176,6 +176,5 @@ def after_processed_commit( result=destination_result, failure_category=_failure_category(error), ) - raise HouseholdMirrorUnavailableError( - "The committed v1 household event could not be copied to v2" - ) from error + msg = "The committed v1 household event could not be copied to v2" + raise HouseholdMirrorUnavailableError(msg) from error diff --git a/policyengine_api/services/household_service.py b/policyengine_api/services/household_service.py index dd3b57010..9dcd50f60 100644 --- a/policyengine_api/services/household_service.py +++ b/policyengine_api/services/household_service.py @@ -52,8 +52,9 @@ def household_storage_json( household's replay assert a measurement the caller never asked for. """ if "spm" in household_json: + msg = "SPM_SETTINGS_INVALID" raise SPMValidationError( - "SPM_SETTINGS_INVALID", "Supply spm beside data, not inside household data." + msg, "Supply spm beside data, not inside household data." ) result = deepcopy(household_json) selected = normalize_spm_selection(country_id, spm) @@ -152,9 +153,8 @@ def get_household( Raise Exception if ``household_id`` is not a non-negative int. """ if type(household_id) is not int or household_id < 0: - raise Exception( - f"Invalid household ID: {household_id}. Must be a positive integer." - ) + msg = f"Invalid household ID: {household_id}. Must be a positive integer." + raise Exception(msg) with self._sessions() as session: return self._get_household(session, country_id, household_id) @@ -249,32 +249,27 @@ def _decode_mirror_event( event: HouseholdMirrorEvent, ) -> PendingHouseholdMirrorEvent: if event.payload_schema_version != HOUSEHOLD_MIRROR_PAYLOAD_SCHEMA_VERSION: - raise HouseholdMirrorEventIntegrityError( - "household mirror event payload version is unsupported" - ) + msg = "household mirror event payload version is unsupported" + raise HouseholdMirrorEventIntegrityError(msg) payload = event.payload_json if not isinstance(payload, dict) or set(payload) != {"snapshot"}: - raise HouseholdMirrorEventIntegrityError( - "household mirror event payload has an invalid shape" - ) + msg = "household mirror event payload has an invalid shape" + raise HouseholdMirrorEventIntegrityError(msg) try: snapshot = LegacyHouseholdSnapshot.model_validate(payload["snapshot"]) except (TypeError, ValueError) as error: - raise HouseholdMirrorEventIntegrityError( - "household mirror event snapshot is invalid" - ) from error + msg = "household mirror event snapshot is invalid" + raise HouseholdMirrorEventIntegrityError(msg) from error if ( snapshot.country_id != event.country_id or snapshot.legacy_household_id != event.legacy_household_id ): - raise HouseholdMirrorEventIntegrityError( - "household mirror event identity conflicts with its payload" - ) + msg = "household mirror event identity conflicts with its payload" + raise HouseholdMirrorEventIntegrityError(msg) fingerprint = legacy_household_fingerprint(snapshot) if fingerprint != event.source_fingerprint_sha256: - raise HouseholdMirrorEventIntegrityError( - "household mirror event fingerprint conflicts with its payload" - ) + msg = "household mirror event fingerprint conflicts with its payload" + raise HouseholdMirrorEventIntegrityError(msg) return PendingHouseholdMirrorEvent( event_id=event.id, snapshot=snapshot, @@ -309,14 +304,12 @@ def process_mirror_event( .with_for_update() ) if event is None: - raise HouseholdMirrorEventIntegrityError( - "household mirror request has no retained event" - ) + msg = "household mirror request has no retained event" + raise HouseholdMirrorEventIntegrityError(msg) pending = self._decode_mirror_event(event) if require_pending and pending.was_processed: - raise HouseholdMirrorEventIntegrityError( - "household mirror event is already processed" - ) + msg = "household mirror event is already processed" + raise HouseholdMirrorEventIntegrityError(msg) result = processor(pending) if event.processed_at is None: event.processed_at = datetime.now(UTC).replace(tzinfo=None) diff --git a/policyengine_api/services/metadata_service.py b/policyengine_api/services/metadata_service.py index 35bdf86b2..95d1e27e0 100644 --- a/policyengine_api/services/metadata_service.py +++ b/policyengine_api/services/metadata_service.py @@ -11,8 +11,7 @@ def get_metadata(self, country_id: str) -> JSONObject: """Return the country's metadata; raise RuntimeError for an unknown country.""" country = COUNTRIES.get(country_id) if country is None: - raise RuntimeError( - f"Attempted to get metadata for a nonexistant country: '{country_id}'" - ) + msg = f"Attempted to get metadata for a nonexistant country: '{country_id}'" + raise RuntimeError(msg) return country.metadata diff --git a/policyengine_api/services/policy_mirroring.py b/policyengine_api/services/policy_mirroring.py index 1de90ee88..dab6b4f42 100644 --- a/policyengine_api/services/policy_mirroring.py +++ b/policyengine_api/services/policy_mirroring.py @@ -140,9 +140,8 @@ def mirror_policy_after_commit( actual_write_sources=["cloud_sql"], failure_category=_failure_category(error), ) - raise PolicyMirrorUnavailableError( - "The committed v1 policy could not be mirrored to v2" - ) from error + msg = "The committed v1 policy could not be mirrored to v2" + raise PolicyMirrorUnavailableError(msg) from error _log_mirror_event( snapshot=snapshot, diff --git a/policyengine_api/services/policy_service.py b/policyengine_api/services/policy_service.py index d74f21c06..7c4033a1b 100644 --- a/policyengine_api/services/policy_service.py +++ b/policyengine_api/services/policy_service.py @@ -55,9 +55,8 @@ def _sessions(self) -> sessionmaker[Session]: @staticmethod def _validate_policy_id(policy_id: int) -> None: if type(policy_id) is not int or policy_id < 0: - raise Exception( - f"Invalid policy ID: {policy_id}. Must be a positive integer." - ) + msg = f"Invalid policy ID: {policy_id}. Must be a positive integer." + raise Exception(msg) def get_policy( self, @@ -71,7 +70,8 @@ def get_policy( """ self._validate_policy_id(policy_id) if not country_id: - raise ValueError("country_id cannot be empty or None") + msg = "country_id cannot be empty or None" + raise ValueError(msg) with self._sessions() as session: return self._get_policy(session, country_id, policy_id) @@ -163,7 +163,8 @@ def set_policy( """ country_id = country_id.lower() if country_id not in COUNTRY_PACKAGE_VERSIONS: - raise ValueError(f"Invalid country_id: {country_id}") + msg = f"Invalid country_id: {country_id}" + raise ValueError(msg) policy_hash = hash_object(policy_json) with self._sessions.begin() as session: diff --git a/policyengine_api/services/reform_impacts_service.py b/policyengine_api/services/reform_impacts_service.py index e199a18b0..5ac0ba95c 100644 --- a/policyengine_api/services/reform_impacts_service.py +++ b/policyengine_api/services/reform_impacts_service.py @@ -217,9 +217,8 @@ def set_reform_impact( observability_id=observability_id, ) if not self._cache.set(impact): - raise ReformImpactHandoffError( - "submitted reform-impact execution could not be stored" - ) + msg = "submitted reform-impact execution could not be stored" + raise ReformImpactHandoffError(msg) return impact def delete_reform_impact( diff --git a/policyengine_api/services/report_output_alias_service.py b/policyengine_api/services/report_output_alias_service.py index 14feae677..7263e3f52 100644 --- a/policyengine_api/services/report_output_alias_service.py +++ b/policyengine_api/services/report_output_alias_service.py @@ -27,9 +27,8 @@ def resolve_canonical_report_output_id( if alias is not None: canonical_id = alias.canonical_report_output_id if session.get(ReportOutput, canonical_id) is None: - raise ValueError( - f"Alias points to missing canonical report output #{canonical_id}" - ) + msg = f"Alias points to missing canonical report output #{canonical_id}" + raise ValueError(msg) return canonical_id report = session.get(ReportOutput, requested_report_output_id) return report.id if report is not None else None @@ -49,24 +48,24 @@ def set_alias( """ legacy = session.get(ReportOutput, legacy_report_output_id) if legacy is None: - raise ValueError( - f"Legacy report output #{legacy_report_output_id} not found" - ) + msg = f"Legacy report output #{legacy_report_output_id} not found" + raise ValueError(msg) canonical = session.get(ReportOutput, canonical_report_output_id) if canonical is None: - raise ValueError( - f"Canonical report output #{canonical_report_output_id} not found" - ) + msg = f"Canonical report output #{canonical_report_output_id} not found" + raise ValueError(msg) if legacy_report_output_id == canonical_report_output_id: - raise ValueError("Legacy and canonical report outputs must be different") + msg = "Legacy and canonical report outputs must be different" + raise ValueError(msg) existing = self.get_alias(session, legacy_report_output_id) if existing is not None: if existing.canonical_report_output_id == canonical_report_output_id: return True - raise ValueError( + msg = ( "Legacy report output alias already points to canonical report output " f"#{existing.canonical_report_output_id}" ) + raise ValueError(msg) logical_fields = ( "country_id", "simulation_1_id", @@ -77,9 +76,8 @@ def set_alias( getattr(legacy, field) != getattr(canonical, field) for field in logical_fields ): - raise ValueError( - "Legacy and canonical report outputs must describe the same report" - ) + msg = "Legacy and canonical report outputs must describe the same report" + raise ValueError(msg) session.add( LegacyReportOutputAlias( legacy_report_output_id=legacy_report_output_id, diff --git a/policyengine_api/services/report_output_service.py b/policyengine_api/services/report_output_service.py index 96dd213b1..e2c4227f1 100644 --- a/policyengine_api/services/report_output_service.py +++ b/policyengine_api/services/report_output_service.py @@ -32,18 +32,19 @@ def _canonicalize_annual_spm_output( ) -> JSONObject: """Validate and serialize compact SPM provenance on one economy result.""" if "spm_config" in output: - raise ValueError( - "Completed US economy output must not include legacy spm_config" - ) + msg = "Completed US economy output must not include legacy spm_config" + raise ValueError(msg) if "spm_provenance" not in output: - raise ValueError("Completed US economy output requires SPM provenance") + msg = "Completed US economy output requires SPM provenance" + raise ValueError(msg) comparison = SPMComparisonProvenance.model_validate(output["spm_provenance"]) if expected_year is not None and any( expected_year not in execution.receipt.years for execution in (comparison.baseline, comparison.reform) ): - raise ValueError("SPM receipt does not cover the report year") + msg = "SPM receipt does not cover the report year" + raise ValueError(msg) return { **output, "spm_provenance": cast( @@ -70,11 +71,13 @@ def _canonicalize_society_wide_spm_output( annual_impacts = output.get("annualImpacts") if not isinstance(annual_impacts, list): - raise ValueError("SPM budget-window output requires annual impact objects") + msg = "SPM budget-window output requires annual impact objects" + raise ValueError(msg) canonical_rows: list[JSONValue] = [] for row in annual_impacts: if not isinstance(row, dict): - raise ValueError("SPM budget-window output requires annual impact objects") + msg = "SPM budget-window output requires annual impact objects" + raise ValueError(msg) row_year = row.get("year") canonical_rows.append( _canonicalize_annual_spm_output( @@ -219,18 +222,20 @@ def get_simulation(simulation_id: int) -> Simulation | None: simulation_1 = get_simulation(report_output.simulation_1_id) if simulation_1 is None: - raise ValueError( + msg = ( "Report output references missing simulation " f"#{report_output.simulation_1_id}" ) + raise ValueError(msg) simulation_2 = None if report_output.simulation_2_id is not None: simulation_2 = get_simulation(report_output.simulation_2_id) if simulation_2 is None: - raise ValueError( + msg = ( "Report output references missing simulation " f"#{report_output.simulation_2_id}" ) + raise ValueError(msg) return simulation_1, simulation_2 @staticmethod @@ -441,7 +446,8 @@ def _ensure_report_output_dual_write_state( session, report_output_id, country_id, for_update=True ) if report_output is None: - raise ValueError(f"Report output #{report_output_id} not found") + msg = f"Report output #{report_output_id} not found" + raise ValueError(msg) try: simulation_1, simulation_2 = self._get_linked_simulations( session, @@ -526,9 +532,8 @@ def _require_simulation( ) ) if simulation is None: - raise ValueError( - f"Report output references missing simulation #{simulation_id}" - ) + msg = f"Report output references missing simulation #{simulation_id}" + raise ValueError(msg) return simulation def _create_report_output( @@ -567,10 +572,11 @@ def _get_report_output( self, session: Session, country_id: str, report_output_id: int ) -> ReportOutput | None: if type(report_output_id) is not int or report_output_id < 0: - raise Exception( + msg = ( f"Invalid report output ID: {report_output_id}. " "Must be a positive integer." ) + raise Exception(msg) return self._select_report_output(session, report_output_id, country_id) @staticmethod @@ -667,10 +673,11 @@ def resolve_report_output( int. """ if type(report_output_id) is not int or report_output_id < 0: - raise Exception( + msg = ( f"Invalid report output ID: {report_output_id}. " "Must be a positive integer." ) + raise Exception(msg) with self._sessions.begin() as session: requested = self._get_report_output(session, country_id, report_output_id) if requested is None: @@ -723,7 +730,8 @@ def update_report_output( session, report_id, country_id, for_update=True ) if report_output is None: - raise LookupError(f"Report output #{report_id} not found") + msg = f"Report output #{report_id} not found" + raise LookupError(msg) self._update_report_output( session, report_output, @@ -743,10 +751,11 @@ def _update_report_output( if requested_status == "running": runs = self._list_runs_descending(session, report_output.id) if not self._has_mutable_running_run(report_output, runs): - raise ValueError( + msg = ( "Cannot mark report output running without an active pending " "or running report run" ) + raise ValueError(msg) effective_status = values.get("status", report_output.status) effective_output = values.get("output", report_output.output) if ( @@ -760,10 +769,11 @@ def _update_report_output( expected_year=report_output.year, ) except (ValueError, TypeError, KeyError) as error: - raise ValueError( + msg = ( "Completed US economy report has invalid SPM provenance: " f"{error_message(error)}" - ) from error + ) + raise ValueError(msg) from error for field, value in values.items(): setattr(report_output, field, value) self._ensure_report_output_dual_write_state( diff --git a/policyengine_api/services/report_run_service.py b/policyengine_api/services/report_run_service.py index 7db920901..be1285dec 100644 --- a/policyengine_api/services/report_run_service.py +++ b/policyengine_api/services/report_run_service.py @@ -58,7 +58,8 @@ def create_report_output_run( .with_for_update() ) if parent is None: - raise ValueError(f"Report output #{report_output_id} not found") + msg = f"Report output #{report_output_id} not found" + raise ValueError(msg) sequence = ( session.scalar( select(func.max(ReportOutputRun.run_sequence)).where( diff --git a/policyengine_api/services/report_spec_service.py b/policyengine_api/services/report_spec_service.py index a3aa8444b..a48b630b7 100644 --- a/policyengine_api/services/report_spec_service.py +++ b/policyengine_api/services/report_spec_service.py @@ -59,9 +59,8 @@ class ReportSpecService: @staticmethod def _validate_schema_version(schema_version: int | None) -> None: if schema_version != REPORT_SPEC_SCHEMA_VERSION: - raise ValueError( - f"Unsupported report spec schema version: {schema_version}" - ) + msg = f"Unsupported report spec schema version: {schema_version}" + raise ValueError(msg) @staticmethod def _get_linked_simulations( @@ -69,18 +68,20 @@ def _get_linked_simulations( ) -> tuple[Simulation, Simulation | None]: simulation_1 = session.get(Simulation, report_output.simulation_1_id) if simulation_1 is None: - raise ValueError( + msg = ( "Report output references missing simulation " f"#{report_output.simulation_1_id}" ) + raise ValueError(msg) simulation_2 = None if report_output.simulation_2_id is not None: simulation_2 = session.get(Simulation, report_output.simulation_2_id) if simulation_2 is None: - raise ValueError( + msg = ( "Report output references missing simulation " f"#{report_output.simulation_2_id}" ) + raise ValueError(msg) return simulation_1, simulation_2 @staticmethod @@ -90,24 +91,28 @@ def _validate_report_simulation_linkage( simulation_2: Simulation | None, ) -> None: if simulation_1.id != report_output.simulation_1_id: - raise ValueError( + msg = ( "Simulation 1 must match report_output.simulation_1_id to build a " "report spec" ) + raise ValueError(msg) if report_output.simulation_2_id is None: if simulation_2 is not None: - raise ValueError("Report output does not reference a second simulation") + msg = "Report output does not reference a second simulation" + raise ValueError(msg) return if simulation_2 is None: - raise ValueError( + msg = ( "Report output requires a second simulation to build a comparison " "report spec" ) + raise ValueError(msg) if simulation_2.id != report_output.simulation_2_id: - raise ValueError( + msg = ( "Simulation 2 must match report_output.simulation_2_id to build a " "report spec" ) + raise ValueError(msg) @staticmethod def _validate_report_country( @@ -116,18 +121,20 @@ def _validate_report_country( simulation_2: Simulation | None, ) -> None: if simulation_1.country_id != report_output.country_id: - raise ValueError( + msg = ( "Simulation 1 country must match report output country to build a " "report spec" ) + raise ValueError(msg) if ( simulation_2 is not None and simulation_2.country_id != report_output.country_id ): - raise ValueError( + msg = ( "Simulation 2 country must match report output country to build a " "report spec" ) + raise ValueError(msg) @staticmethod def infer_report_kind( @@ -139,14 +146,14 @@ def infer_report_kind( """ population_type = simulation_1.population_type if simulation_2 is not None and simulation_2.population_type != population_type: - raise ValueError( - "Simulation population types must match to build a report spec" - ) + msg = "Simulation population types must match to build a report spec" + raise ValueError(msg) if population_type == "household": return "household_comparison" if simulation_2 else "household_single" if population_type == "geography": return "economy_comparison" if simulation_2 else "economy_single" - raise ValueError(f"Unsupported simulation population type: {population_type}") + msg = f"Unsupported simulation population type: {population_type}" + raise ValueError(msg) def build_report_spec( self, @@ -179,9 +186,8 @@ def build_report_spec( for simulation in (simulation_1, simulation_2) ] if household_ids[0] != household_ids[1]: - raise ValueError( - "Household comparison report specs require matching household IDs" - ) + msg = "Household comparison report specs require matching household IDs" + raise ValueError(msg) return HouseholdReportSpec( country_id=report_output.country_id, report_kind=report_kind, @@ -205,9 +211,8 @@ def build_report_spec( simulation_2 is not None and simulation_2.population_id != simulation_1.population_id ): - raise ValueError( - "Economy comparison report specs require matching geography IDs" - ) + msg = "Economy comparison report specs require matching geography IDs" + raise ValueError(msg) return EconomyReportSpec( country_id=report_output.country_id, report_kind=report_kind, @@ -252,7 +257,8 @@ def _validate_report_spec_matches_model( ), ) if report_spec != expected: - raise ValueError("Report spec must match the linked report and simulations") + msg = "Report spec must match the linked report and simulations" + raise ValueError(msg) @staticmethod def _parse_report_spec(report_kind: str, raw_spec: dict) -> ReportSpec: @@ -260,7 +266,8 @@ def _parse_report_spec(report_kind: str, raw_spec: dict) -> ReportSpec: return HouseholdReportSpec.model_validate(raw_spec) if report_kind in ECONOMY_REPORT_KINDS: return EconomyReportSpec.model_validate(raw_spec) - raise ValueError(f"Unsupported report kind: {report_kind}") + msg = f"Unsupported report kind: {report_kind}" + raise ValueError(msg) def get_report_spec( self, session: Session, report_output_id: int @@ -297,11 +304,13 @@ def set_report_spec( mismatched spec. Always return True; the caller owns the transaction. """ if report_spec_status not in REPORT_SPEC_STATUSES: - raise ValueError(f"Unsupported report spec status: {report_spec_status}") + msg = f"Unsupported report spec status: {report_spec_status}" + raise ValueError(msg) self._validate_schema_version(schema_version) report_output = session.get(ReportOutput, report_output_id) if report_output is None: - raise ValueError(f"Report output #{report_output_id} not found") + msg = f"Report output #{report_output_id} not found" + raise ValueError(msg) self._validate_report_spec_matches_model(session, report_output, report_spec) report_output.report_kind = report_spec.report_kind report_output.report_spec_json = report_spec.model_dump() diff --git a/policyengine_api/services/simulation_run_service.py b/policyengine_api/services/simulation_run_service.py index 91b3929fa..b54a3a890 100644 --- a/policyengine_api/services/simulation_run_service.py +++ b/policyengine_api/services/simulation_run_service.py @@ -45,7 +45,8 @@ def create_simulation_run( select(Simulation).where(Simulation.id == simulation_id).with_for_update() ) if parent is None: - raise ValueError(f"Simulation #{simulation_id} not found") + msg = f"Simulation #{simulation_id} not found" + raise ValueError(msg) sequence = ( session.scalar( select(func.max(SimulationRun.run_sequence)).where( diff --git a/policyengine_api/services/simulation_service.py b/policyengine_api/services/simulation_service.py index da9e0a8b0..9a09fc92e 100644 --- a/policyengine_api/services/simulation_service.py +++ b/policyengine_api/services/simulation_service.py @@ -93,7 +93,8 @@ def _ensure_simulation_dual_write_state( for_update=True, ) if simulation is None: - raise ValueError(f"Simulation #{simulation_id} not found") + msg = f"Simulation #{simulation_id} not found" + raise ValueError(msg) if self._has_stale_spm_output(simulation.output): simulation.status = "pending" @@ -268,9 +269,8 @@ def get_simulation( Raise Exception if ``simulation_id`` is not a non-negative int. """ if type(simulation_id) is not int or simulation_id < 0: - raise Exception( - f"Invalid simulation ID: {simulation_id}. Must be a positive integer." - ) + msg = f"Invalid simulation ID: {simulation_id}. Must be a positive integer." + raise Exception(msg) with self._sessions() as session: return self._select_simulation(session, simulation_id, country_id) @@ -303,9 +303,8 @@ def update_simulation( values["output"] = json.loads(values["output"]) output_value = values.get("output") if isinstance(output_value, dict) and "spm_config" in output_value: - raise ValueError( - "Completed simulation output must not include legacy spm_config" - ) + msg = "Completed simulation output must not include legacy spm_config" + raise ValueError(msg) if isinstance(output_value, dict) and "spm_provenance" in output_value: receipt = validate_spm_calculation_provenance( output_value["spm_provenance"] @@ -322,7 +321,8 @@ def update_simulation( for_update=True, ) if simulation is None: - raise LookupError(f"Simulation #{simulation_id} not found") + msg = f"Simulation #{simulation_id} not found" + raise LookupError(msg) for key, value in values.items(): setattr(simulation, key, value) simulation.api_version = COUNTRY_PACKAGE_VERSIONS.get(country_id) diff --git a/policyengine_api/services/simulation_spec_service.py b/policyengine_api/services/simulation_spec_service.py index d75f6a76f..18733d037 100644 --- a/policyengine_api/services/simulation_spec_service.py +++ b/policyengine_api/services/simulation_spec_service.py @@ -25,9 +25,8 @@ class SimulationSpecService: def _validate_schema_version(self, schema_version: int | None) -> None: if schema_version != SIMULATION_SPEC_SCHEMA_VERSION: - raise ValueError( - f"Unsupported simulation spec schema version: {schema_version}" - ) + msg = f"Unsupported simulation spec schema version: {schema_version}" + raise ValueError(msg) def _validate_simulation_spec_matches_model( self, @@ -41,7 +40,8 @@ def _validate_simulation_spec_matches_model( "policy_id": simulation.policy_id, } if simulation_spec.model_dump() != expected_spec: - raise ValueError("Simulation spec must match the linked simulation row") + msg = "Simulation spec must match the linked simulation row" + raise ValueError(msg) def build_simulation_spec(self, simulation: Simulation) -> SimulationSpec: """Return the spec implied by a simulation's country, population and policy.""" @@ -91,7 +91,8 @@ def set_simulation_spec( self._validate_schema_version(schema_version) simulation = session.get(Simulation, simulation_id) if simulation is None: - raise ValueError(f"Simulation #{simulation_id} not found") + msg = f"Simulation #{simulation_id} not found" + raise ValueError(msg) self._validate_simulation_spec_matches_model(simulation, simulation_spec) simulation.simulation_spec_json = simulation_spec.model_dump() simulation.simulation_spec_schema_version = schema_version diff --git a/policyengine_api/services/user_policy_mirroring.py b/policyengine_api/services/user_policy_mirroring.py index 4b28a7aac..d91bcfcb0 100644 --- a/policyengine_api/services/user_policy_mirroring.py +++ b/policyengine_api/services/user_policy_mirroring.py @@ -193,9 +193,8 @@ def mirror_user_policy_after_commit( actual_write_sources=["cloud_sql"], failure_category=_failure_category(error), ) - raise UserPolicyMirrorUnavailableError( - "The committed v1 saved policy could not be mirrored to v2" - ) from error + msg = "The committed v1 saved policy could not be mirrored to v2" + raise UserPolicyMirrorUnavailableError(msg) from error _log_mirror_operation( country_id=snapshot.country_id, @@ -247,9 +246,8 @@ def process( event.snapshot.reform_id, ) if reform_snapshot is None: - raise UserPolicyMirrorUnavailableError( - "The saved policy references an unavailable reform policy" - ) + msg = "The saved policy references an unavailable reform policy" + raise UserPolicyMirrorUnavailableError(msg) result = _run_user_policy_mirror( event.snapshot, reform_snapshot, @@ -316,6 +314,5 @@ def after_processed_commit( result=result, failure_category=_failure_category(failure), ) - raise UserPolicyMirrorUnavailableError( - "The committed v1 saved policy event could not be mirrored to v2" - ) from error + msg = "The committed v1 saved policy event could not be mirrored to v2" + raise UserPolicyMirrorUnavailableError(msg) from error diff --git a/policyengine_api/services/user_policy_service.py b/policyengine_api/services/user_policy_service.py index 98d26eb12..0fa1a4473 100644 --- a/policyengine_api/services/user_policy_service.py +++ b/policyengine_api/services/user_policy_service.py @@ -221,61 +221,51 @@ def _decode_mirror_event( event: UserPolicyMirrorEvent, ) -> PendingUserPolicyMirrorEvent: if event.payload_schema_version != USER_POLICY_MIRROR_PAYLOAD_SCHEMA_VERSION: - raise UserPolicyMirrorEventIntegrityError( - "saved-policy mirror event payload version is unsupported" - ) + msg = "saved-policy mirror event payload version is unsupported" + raise UserPolicyMirrorEventIntegrityError(msg) payload = event.payload_json if not isinstance(payload, dict) or set(payload) != { "snapshot", "changed_fields", }: - raise UserPolicyMirrorEventIntegrityError( - "saved-policy mirror event payload has an invalid shape" - ) + msg = "saved-policy mirror event payload has an invalid shape" + raise UserPolicyMirrorEventIntegrityError(msg) changed_fields = payload["changed_fields"] if not isinstance(changed_fields, list) or not all( isinstance(field, str) for field in changed_fields ): - raise UserPolicyMirrorEventIntegrityError( - "saved-policy mirror event changed fields are invalid" - ) + msg = "saved-policy mirror event changed fields are invalid" + raise UserPolicyMirrorEventIntegrityError(msg) if ( changed_fields != sorted(set(changed_fields)) or not set(changed_fields) <= USER_POLICY_MUTABLE_FIELDS ): - raise UserPolicyMirrorEventIntegrityError( - "saved-policy mirror event changed fields are unsupported" - ) + msg = "saved-policy mirror event changed fields are unsupported" + raise UserPolicyMirrorEventIntegrityError(msg) try: snapshot = LegacyUserPolicySnapshot.model_validate(payload["snapshot"]) except (TypeError, ValueError) as error: - raise UserPolicyMirrorEventIntegrityError( - "saved-policy mirror event snapshot is invalid" - ) from error + msg = "saved-policy mirror event snapshot is invalid" + raise UserPolicyMirrorEventIntegrityError(msg) from error if ( snapshot.country_id != event.country_id or snapshot.legacy_user_policy_id != event.legacy_user_policy_id ): - raise UserPolicyMirrorEventIntegrityError( - "saved-policy mirror event source identity conflicts with its payload" - ) + msg = "saved-policy mirror event source identity conflicts with its payload" + raise UserPolicyMirrorEventIntegrityError(msg) fingerprint = fingerprint_legacy_user_policy(snapshot) if fingerprint != event.source_fingerprint_sha256: - raise UserPolicyMirrorEventIntegrityError( - "saved-policy mirror event fingerprint conflicts with its payload" - ) + msg = "saved-policy mirror event fingerprint conflicts with its payload" + raise UserPolicyMirrorEventIntegrityError(msg) if event.event_type not in {"create", "update"}: - raise UserPolicyMirrorEventIntegrityError( - "saved-policy mirror event type is unsupported" - ) + msg = "saved-policy mirror event type is unsupported" + raise UserPolicyMirrorEventIntegrityError(msg) if event.event_type == "create" and changed_fields: - raise UserPolicyMirrorEventIntegrityError( - "saved-policy create mirror event contains changed fields" - ) + msg = "saved-policy create mirror event contains changed fields" + raise UserPolicyMirrorEventIntegrityError(msg) if event.event_type == "update" and not changed_fields: - raise UserPolicyMirrorEventIntegrityError( - "saved-policy update mirror event has no changed fields" - ) + msg = "saved-policy update mirror event has no changed fields" + raise UserPolicyMirrorEventIntegrityError(msg) return PendingUserPolicyMirrorEvent( event_id=event.id, source_revision=event.source_revision, @@ -458,9 +448,8 @@ def process_pending_mirror_events( .with_for_update() ) if event is None: - raise UserPolicyMirrorEventIntegrityError( - "saved-policy mirror request has no retained event" - ) + msg = "saved-policy mirror request has no retained event" + raise UserPolicyMirrorEventIntegrityError(msg) pending = self._decode_mirror_event(event) processed_result = processor(pending) latest_result = processed_result diff --git a/policyengine_api/services/user_service.py b/policyengine_api/services/user_service.py index e60a1a11b..11bea8be9 100644 --- a/policyengine_api/services/user_service.py +++ b/policyengine_api/services/user_service.py @@ -79,7 +79,8 @@ def get_profile( ``user_id`` wins when both are given. Raise ValueError if neither is given. """ if auth0_id is None and user_id is None: - raise ValueError("you must specify either auth0_id or user_id") + msg = "you must specify either auth0_id or user_id" + raise ValueError(msg) with self._sessions() as session: return self._get_profile(session, auth0_id, user_id) @@ -108,7 +109,8 @@ def update_profile( Raise ValueError if ``user_id`` is None. """ if user_id is None: - raise ValueError("you must specify either auth0_id or user_id") + msg = "you must specify either auth0_id or user_id" + raise ValueError(msg) with self._sessions.begin() as session: return self._update_profile( session, diff --git a/policyengine_api/services/v2/comparison_runs/services.py b/policyengine_api/services/v2/comparison_runs/services.py index 5d94fad94..80fb08b3f 100644 --- a/policyengine_api/services/v2/comparison_runs/services.py +++ b/policyengine_api/services/v2/comparison_runs/services.py @@ -70,18 +70,16 @@ def create_or_resolve_report( if created_id is not None: row = read_comparison_report(session, created_id, lock=False) if row is None: - raise ComparisonRunIdentityError( - "created comparison report could not be reloaded" - ) + msg = "created comparison report could not be reloaded" + raise ComparisonRunIdentityError(msg) return ComparisonReportPersistenceResult( record=report_record(row), created=True, ) row = read_comparison_report_by_identity(session, record) if row is None: - raise ComparisonRunIdentityError( - "comparison report conflict did not resolve to an existing identity" - ) + msg = "comparison report conflict did not resolve to an existing identity" + raise ComparisonRunIdentityError(msg) existing = report_record(row) require_report_conflict_matches(existing, record) return ComparisonReportPersistenceResult(record=existing, created=False) @@ -99,26 +97,23 @@ def create_or_resolve_simulation( """ parent_row = read_comparison_report(session, record.evaluation_id, lock=False) if parent_row is None: - raise ComparisonRunNotFoundError( - f"comparison report {record.evaluation_id} does not exist" - ) + msg = f"comparison report {record.evaluation_id} does not exist" + raise ComparisonRunNotFoundError(msg) require_simulation_parent_matches(report_record(parent_row), record) created_id = create_comparison_simulation(session, record) if created_id is not None: row = read_comparison_simulation(session, created_id, lock=False) if row is None: - raise ComparisonRunIdentityError( - "created comparison simulation could not be reloaded" - ) + msg = "created comparison simulation could not be reloaded" + raise ComparisonRunIdentityError(msg) return ComparisonSimulationPersistenceResult( record=simulation_record(row), created=True, ) row = read_comparison_simulation_by_identity(session, record) if row is None: - raise ComparisonRunIdentityError( - "comparison simulation conflict did not resolve to an existing identity" - ) + msg = "comparison simulation conflict did not resolve to an existing identity" + raise ComparisonRunIdentityError(msg) existing = simulation_record(row) require_simulation_conflict_matches(existing, record) return ComparisonSimulationPersistenceResult(record=existing, created=False) @@ -136,9 +131,8 @@ def replace_report_lifecycle( """ row = read_comparison_report(session, record.evaluation_id, lock=True) if row is None: - raise ComparisonRunNotFoundError( - f"comparison report {record.evaluation_id} does not exist" - ) + msg = f"comparison report {record.evaluation_id} does not exist" + raise ComparisonRunNotFoundError(msg) existing = report_record(row) require_report_identity(existing, record) require_result_comparison_unchanged(existing, record) @@ -165,9 +159,8 @@ def replace_report_result_comparison( """ row = read_comparison_report(session, record.evaluation_id, lock=True) if row is None: - raise ComparisonRunNotFoundError( - f"comparison report {record.evaluation_id} does not exist" - ) + msg = f"comparison report {record.evaluation_id} does not exist" + raise ComparisonRunNotFoundError(msg) existing = report_record(row) require_comparison_update_only(existing, record) require_result_comparison_transition( @@ -199,9 +192,8 @@ def replace_simulation_lifecycle( lock=True, ) if row is None: - raise ComparisonRunNotFoundError( - f"comparison simulation {record.simulation_execution_id} does not exist" - ) + msg = f"comparison simulation {record.simulation_execution_id} does not exist" + raise ComparisonRunNotFoundError(msg) existing = simulation_record(row) require_simulation_identity(existing, record) require_lifecycle_transition(existing.status, record.status) @@ -263,7 +255,6 @@ def get_report(self, evaluation_id: UUID) -> ComparisonReportRecord: with self._database_session.read() as session: row = read_comparison_report(session, evaluation_id, lock=False) if row is None: - raise ComparisonRunNotFoundError( - f"comparison report {evaluation_id} does not exist" - ) + msg = f"comparison report {evaluation_id} does not exist" + raise ComparisonRunNotFoundError(msg) return report_record(row) diff --git a/policyengine_api/services/v2/comparison_runs/types.py b/policyengine_api/services/v2/comparison_runs/types.py index 00a127455..27ef6d839 100644 --- a/policyengine_api/services/v2/comparison_runs/types.py +++ b/policyengine_api/services/v2/comparison_runs/types.py @@ -27,12 +27,15 @@ def _validate_retention(created_at: datetime, retention_expires_at: datetime) -> None: if created_at.tzinfo is None or retention_expires_at.tzinfo is None: - raise ValueError("comparison retention timestamps must include a timezone") + msg = "comparison retention timestamps must include a timezone" + raise ValueError(msg) retention = retention_expires_at - created_at if retention <= timedelta(0): - raise ValueError("comparison retention must be greater than zero") + msg = "comparison retention must be greater than zero" + raise ValueError(msg) if retention > MAX_COMPARISON_RUN_ARTIFACT_RETENTION: - raise ValueError("comparison retention must not exceed 30 days") + msg = "comparison retention must not exceed 30 days" + raise ValueError(msg) class ComparisonRunLifecycleStatus(StrEnum): @@ -134,14 +137,18 @@ def validate_state(self) -> ComparisonReportRecord: if any(value is not None for value in output_fields) and not all( value is not None for value in output_fields ): - raise ValueError("aggregate output fields must be supplied together") + msg = "aggregate output fields must be supplied together" + raise ValueError(msg) if self.status is ComparisonRunLifecycleStatus.SUCCEEDED: if self.aggregation_status is not ComparisonRunAggregationStatus.SUCCEEDED: - raise ValueError("a successful report requires successful aggregation") + msg = "a successful report requires successful aggregation" + raise ValueError(msg) if not all(value is not None for value in output_fields): - raise ValueError("a successful report requires an aggregate output") + msg = "a successful report requires an aggregate output" + raise ValueError(msg) if self.completed_at is None: - raise ValueError("a successful report requires completed_at") + msg = "a successful report requires completed_at" + raise ValueError(msg) if ( self.status in { @@ -150,7 +157,8 @@ def validate_state(self) -> ComparisonReportRecord: } and self.error_code is None ): - raise ValueError("a failed or skipped report requires an error_code") + msg = "a failed or skipped report requires an error_code" + raise ValueError(msg) comparison_output_fields = ( self.comparison_output_uri, self.comparison_output_sha256, @@ -162,38 +170,43 @@ def validate_state(self) -> ComparisonReportRecord: ResultComparisonStatus.RUNNING, }: if any(value is not None for value in comparison_output_fields): - raise ValueError( - "an incomplete comparison cannot reference an output artifact" - ) + msg = "an incomplete comparison cannot reference an output artifact" + raise ValueError(msg) if self.comparison_completed_at is not None: - raise ValueError("an incomplete comparison cannot have completed_at") + msg = "an incomplete comparison cannot have completed_at" + raise ValueError(msg) if self.comparison_error_code is not None: - raise ValueError("an incomplete comparison cannot have an error_code") + msg = "an incomplete comparison cannot have an error_code" + raise ValueError(msg) if self.comparison_error_summary is not None: - raise ValueError( - "an incomplete comparison cannot have an error_summary" - ) + msg = "an incomplete comparison cannot have an error_summary" + raise ValueError(msg) elif self.comparison_status in { ResultComparisonStatus.MATCHED, ResultComparisonStatus.DIFFERENT, }: if not all(value is not None for value in comparison_output_fields): - raise ValueError("a completed comparison requires an output artifact") + msg = "a completed comparison requires an output artifact" + raise ValueError(msg) if self.comparison_completed_at is None: - raise ValueError("a completed comparison requires completed_at") + msg = "a completed comparison requires completed_at" + raise ValueError(msg) if self.comparison_error_code is not None: - raise ValueError("a completed comparison cannot have an error_code") + msg = "a completed comparison cannot have an error_code" + raise ValueError(msg) if self.comparison_error_summary is not None: - raise ValueError("a completed comparison cannot have an error_summary") + msg = "a completed comparison cannot have an error_summary" + raise ValueError(msg) else: if any(value is not None for value in comparison_output_fields): - raise ValueError( - "a failed comparison cannot reference an output artifact" - ) + msg = "a failed comparison cannot reference an output artifact" + raise ValueError(msg) if self.comparison_completed_at is None: - raise ValueError("a failed comparison requires completed_at") + msg = "a failed comparison requires completed_at" + raise ValueError(msg) if self.comparison_error_code is None: - raise ValueError("a failed comparison requires an error_code") + msg = "a failed comparison requires an error_code" + raise ValueError(msg) return self @@ -246,17 +259,22 @@ def validate_state(self) -> ComparisonSimulationRecord: if any(value is not None for value in output_fields) and not all( value is not None for value in output_fields ): - raise ValueError("simulation output fields must be supplied together") + msg = "simulation output fields must be supplied together" + raise ValueError(msg) if self.row_identity_columns is not None: if not self.row_identity_columns: - raise ValueError("row_identity_columns must not be empty") + msg = "row_identity_columns must not be empty" + raise ValueError(msg) if len(self.row_identity_columns) != len(set(self.row_identity_columns)): - raise ValueError("row_identity_columns must be unique") + msg = "row_identity_columns must be unique" + raise ValueError(msg) if self.status is ComparisonRunLifecycleStatus.SUCCEEDED: if not all(value is not None for value in output_fields): - raise ValueError("a successful simulation requires an output") + msg = "a successful simulation requires an output" + raise ValueError(msg) if self.completed_at is None: - raise ValueError("a successful simulation requires completed_at") + msg = "a successful simulation requires completed_at" + raise ValueError(msg) if ( self.status in { @@ -265,7 +283,8 @@ def validate_state(self) -> ComparisonSimulationRecord: } and self.error_code is None ): - raise ValueError("a failed or skipped simulation requires an error_code") + msg = "a failed or skipped simulation requires an error_code" + raise ValueError(msg) return self diff --git a/policyengine_api/services/v2/comparison_runs/validators.py b/policyengine_api/services/v2/comparison_runs/validators.py index 45c104a9d..278cd061f 100644 --- a/policyengine_api/services/v2/comparison_runs/validators.py +++ b/policyengine_api/services/v2/comparison_runs/validators.py @@ -126,9 +126,8 @@ def _require_equal_fields( ) -> None: for field_name in fields: if getattr(existing, field_name) != getattr(candidate, field_name): - raise ComparisonRunIdentityError( - f"comparison-run field {field_name} is immutable for an existing identity" - ) + msg = f"comparison-run field {field_name} is immutable for an existing identity" + raise ComparisonRunIdentityError(msg) def require_report_identity( @@ -177,9 +176,8 @@ def require_simulation_parent_matches( ) -> None: """Require a child to retain the parent's release and retention identity.""" if child.evaluation_id != parent.evaluation_id: - raise ComparisonRunIdentityError( - "comparison child names a different parent evaluation_id" - ) + msg = "comparison child names a different parent evaluation_id" + raise ComparisonRunIdentityError(msg) for field_name in ( "contract_version", "worker_version", @@ -189,9 +187,8 @@ def require_simulation_parent_matches( "retention_expires_at", ): if getattr(child, field_name) != getattr(parent, field_name): - raise ComparisonRunIdentityError( - f"comparison child {field_name} does not match its parent" - ) + msg = f"comparison child {field_name} does not match its parent" + raise ComparisonRunIdentityError(msg) def require_successful_report_replay( @@ -339,9 +336,8 @@ def require_lifecycle_transition( incomplete runs may return to running. """ if candidate not in ALLOWED_TRANSITIONS[current]: - raise ComparisonRunStateTransitionError( - f"comparison-run lifecycle cannot move from {current.value} to {candidate.value}" - ) + msg = f"comparison-run lifecycle cannot move from {current.value} to {candidate.value}" + raise ComparisonRunStateTransitionError(msg) def require_aggregation_transition( @@ -353,10 +349,11 @@ def require_aggregation_transition( Every status may repeat; succeeded is final and failed may return to running. """ if candidate not in ALLOWED_AGGREGATION_TRANSITIONS[current]: - raise ComparisonRunStateTransitionError( + msg = ( "comparison-run aggregation cannot move " f"from {current.value} to {candidate.value}" ) + raise ComparisonRunStateTransitionError(msg) def require_result_comparison_transition( @@ -368,6 +365,5 @@ def require_result_comparison_transition( Not-requested, matched, different and failed are all final. """ if candidate not in ALLOWED_RESULT_COMPARISON_TRANSITIONS[current]: - raise ComparisonRunStateTransitionError( - f"result comparison cannot move from {current.value} to {candidate.value}" - ) + msg = f"result comparison cannot move from {current.value} to {candidate.value}" + raise ComparisonRunStateTransitionError(msg) diff --git a/policyengine_api/services/v2/households/services.py b/policyengine_api/services/v2/households/services.py index c698ea951..f4444ee3b 100644 --- a/policyengine_api/services/v2/households/services.py +++ b/policyengine_api/services/v2/households/services.py @@ -99,15 +99,13 @@ def create_normalized_household( content_hash=content.content_hash, ) if existing is None: - raise HouseholdCreationIntegrityError( - "household hash conflict did not resolve to a stored household" - ) + msg = "household hash conflict did not resolve to a stored household" + raise HouseholdCreationIntegrityError(msg) if canonical_household_document(stored_household_creation_input(existing)) != ( content.document ): - raise HouseholdContentHashCollisionError( - "stored household content differs for the same canonical version and hash" - ) + msg = "stored household content differs for the same canonical version and hash" + raise HouseholdContentHashCollisionError(msg) return HouseholdCreationResult(household_id=existing.id, created=False) @@ -121,7 +119,8 @@ def read_complete_household( household_id=household_id, ) if household is None: - raise HouseholdNotFoundError(f"household {household_id} was not found") + msg = f"household {household_id} was not found" + raise HouseholdNotFoundError(msg) return household_read(household) @@ -207,9 +206,8 @@ def mirror_legacy_household_in_session( lock=False, ) if concurrent is None: - raise LegacyHouseholdMappingIntegrityError( - "legacy household mapping conflict did not resolve to a stored row" - ) + msg = "legacy household mapping conflict did not resolve to a stored row" + raise LegacyHouseholdMappingIntegrityError(msg) verify_legacy_household_mapping( concurrent, fingerprint_version=LEGACY_HOUSEHOLD_FINGERPRINT_VERSION, diff --git a/policyengine_api/services/v2/households/transformations.py b/policyengine_api/services/v2/households/transformations.py index a8cf8127f..04e970288 100644 --- a/policyengine_api/services/v2/households/transformations.py +++ b/policyengine_api/services/v2/households/transformations.py @@ -68,7 +68,8 @@ def _canonical_json( for key in sorted(value) ) return "{" + ",".join(members) + "}" - raise TypeError("canonical household content contains a non-JSON value") + msg = "canonical household content contains a non-JSON value" + raise TypeError(msg) def canonical_household_document(household_input: HouseholdCreationInput) -> bytes: @@ -179,10 +180,11 @@ def _derive_default_year(values: list[object]) -> int | None: return next(iter(years)) if all(item is not None for item in explicit_periods): return None - raise LegacyHouseholdTranslationError( + msg = ( "legacy household has values without explicit periods and no " "unambiguous default year" ) + raise LegacyHouseholdTranslationError(msg) def translate_legacy_household( @@ -206,12 +208,12 @@ def translate_legacy_household( allowed_source.add("spm") unsupported = sorted(set(source) - allowed_source) if unsupported: - raise LegacyHouseholdTranslationError( - f"legacy household contains unsupported collections: {unsupported}" - ) + msg = f"legacy household contains unsupported collections: {unsupported}" + raise LegacyHouseholdTranslationError(msg) people_source = source.get("people", {}) if type(people_source) is not dict: - raise LegacyHouseholdTranslationError("legacy people must be an object") + msg = "legacy people must be an object" + raise LegacyHouseholdTranslationError(msg) person_ids = _stable_ids("person", list(people_source)) all_values: list[object] = [] normalized_groups: dict[str, list[dict[str, Any]]] = {} @@ -221,17 +223,15 @@ def translate_legacy_household( source_name = LEGACY_COLLECTION_NAMES[collection] source_groups = source.get(source_name, {}) if type(source_groups) is not dict: - raise LegacyHouseholdTranslationError( - f"legacy {source_name} must be an object" - ) + msg = f"legacy {source_name} must be an object" + raise LegacyHouseholdTranslationError(msg) group_ids = _stable_ids(collection, list(source_groups)) group_records: list[dict[str, Any]] = [] for group_name in sorted(source_groups): group = source_groups[group_name] if type(group) is not dict: - raise LegacyHouseholdTranslationError( - f"legacy {source_name} records must be objects" - ) + msg = f"legacy {source_name} records must be objects" + raise LegacyHouseholdTranslationError(msg) values: dict[str, Any] = {} for field, value in group.items(): if field != "members": @@ -241,19 +241,19 @@ def translate_legacy_household( if type(value) is not list or not all( isinstance(item, str) and item in person_ids for item in value ): - raise LegacyHouseholdTranslationError( - f"legacy {source_name} members must reference named people" - ) + msg = f"legacy {source_name} members must reference named people" + raise LegacyHouseholdTranslationError(msg) for raw_person_name in value: assert isinstance(raw_person_name, str) person_name = raw_person_name memberships = person_memberships[person_name] previous = memberships.get(collection) if previous is not None and previous != group_ids[group_name]: - raise LegacyHouseholdTranslationError( + msg = ( f"legacy person {person_name!r} belongs to multiple " f"{source_name} records" ) + raise LegacyHouseholdTranslationError(msg) memberships[collection] = group_ids[group_name] group_records.append( { @@ -268,9 +268,8 @@ def translate_legacy_household( for person_name in sorted(people_source): person_values = people_source[person_name] if type(person_values) is not dict: - raise LegacyHouseholdTranslationError( - "legacy person records must be objects" - ) + msg = "legacy person records must be objects" + raise LegacyHouseholdTranslationError(msg) all_values.extend(person_values.values()) people.append( { diff --git a/policyengine_api/services/v2/households/types.py b/policyengine_api/services/v2/households/types.py index 23eefa1cb..73e9b9d4a 100644 --- a/policyengine_api/services/v2/households/types.py +++ b/policyengine_api/services/v2/households/types.py @@ -42,7 +42,8 @@ class HouseholdCreationInput(StrictHouseholdInput): def require_document(cls, value: object) -> object: """Reject household_data that is not a JSON object.""" if type(value) is not dict: - raise ValueError("household_data must be an object") + msg = "household_data must be an object" + raise ValueError(msg) return value @@ -67,7 +68,8 @@ class LegacyHouseholdSnapshot(StrictHouseholdInput): def require_legacy_document(cls, value: object) -> object: """Reject a v1 household_json that is not a JSON object.""" if type(value) is not dict: - raise ValueError("legacy household_json must be an object") + msg = "legacy household_json must be an object" + raise ValueError(msg) return value diff --git a/policyengine_api/services/v2/households/validators.py b/policyengine_api/services/v2/households/validators.py index 072faa299..83298c844 100644 --- a/policyengine_api/services/v2/households/validators.py +++ b/policyengine_api/services/v2/households/validators.py @@ -53,18 +53,22 @@ def require_json_value( ) -> object: """Reject values that are not standards-compliant JSON.""" if depth > MAXIMUM_JSON_NESTING: - raise ValueError("JSON values must not exceed 100 nested containers") + msg = "JSON values must not exceed 100 nested containers" + raise ValueError(msg) if value is None or type(value) in {str, bool, int}: return value if type(value) is float: if not math.isfinite(value): - raise ValueError("JSON numbers must be finite") + msg = "JSON numbers must be finite" + raise ValueError(msg) return value if type(value) not in {list, dict}: - raise ValueError("value must contain only standards-compliant JSON types") + msg = "value must contain only standards-compliant JSON types" + raise ValueError(msg) identity = id(value) if identity in containers: - raise ValueError("JSON values must not contain reference cycles") + msg = "JSON values must not contain reference cycles" + raise ValueError(msg) nested_containers = containers | {identity} if type(value) is list: for item in value: @@ -72,28 +76,30 @@ def require_json_value( return value for key, item in value.items(): if type(key) is not str: - raise ValueError("JSON object keys must be strings") + msg = "JSON object keys must be strings" + raise ValueError(msg) require_json_value(item, depth=depth + 1, containers=nested_containers) return value def _require_identifier(value: object, *, field: str) -> str: if not isinstance(value, str) or not value.strip() or len(value) > 255: - raise HouseholdValidationError( - f"{field} must be a non-empty string of at most 255 characters" - ) + msg = f"{field} must be a non-empty string of at most 255 characters" + raise HouseholdValidationError(msg) return value def _require_values(record: dict[str, Any], *, collection: str) -> None: values = record.get("values") if type(values) is not dict: - raise HouseholdValidationError(f"{collection} record values must be an object") + msg = f"{collection} record values must be an object" + raise HouseholdValidationError(msg) if len(values) > MAXIMUM_HOUSEHOLD_RECORD_VALUES: - raise HouseholdValidationError( + msg = ( f"{collection} records must contain at most " f"{MAXIMUM_HOUSEHOLD_RECORD_VALUES} variable values" ) + raise HouseholdValidationError(msg) require_json_value(values) @@ -110,28 +116,32 @@ def normalize_household_document( """Validate and normalize one country-specific household document.""" collections = SUPPORTED_ENTITY_COLLECTIONS.get(country_id) if collections is None: - raise HouseholdValidationError(f"unsupported country_id {country_id!r}") + msg = f"unsupported country_id {country_id!r}" + raise HouseholdValidationError(msg) if type(document) is not dict: - raise HouseholdValidationError("household_data must be an object") + msg = "household_data must be an object" + raise HouseholdValidationError(msg) allowed = {"people", *collections} if country_id == "us": allowed.add("spm") unsupported = sorted(set(document) - allowed) if unsupported: - raise HouseholdValidationError( - f"household_data contains unsupported collections: {unsupported}" - ) + msg = f"household_data contains unsupported collections: {unsupported}" + raise HouseholdValidationError(msg) if "people" not in document: - raise HouseholdValidationError("household_data must contain people") + msg = "household_data must contain people" + raise HouseholdValidationError(msg) normalized: dict[str, Any] = {} if "spm" in document: if type(document["spm"]) is not dict: - raise HouseholdValidationError("spm must be an object") + msg = "spm must be an object" + raise HouseholdValidationError(msg) try: SPMSelection.model_validate(document["spm"]) except ValueError as error: - raise HouseholdValidationError("spm selection is invalid") from error + msg = "spm selection is invalid" + raise HouseholdValidationError(msg) from error # Saved selections are immutable identity, not a request to recertify # against whichever scientific bundle is currently installed. normalized["spm"] = dict(document["spm"]) @@ -139,11 +149,11 @@ def normalize_household_document( for collection in ("people", *collections): records = document.get(collection, []) if type(records) is not list: - raise HouseholdValidationError(f"{collection} must be a list") + msg = f"{collection} must be a list" + raise HouseholdValidationError(msg) if len(records) > MAXIMUM_HOUSEHOLD_ENTITIES: - raise HouseholdValidationError( - f"{collection} must contain at most {MAXIMUM_HOUSEHOLD_ENTITIES} records" - ) + msg = f"{collection} must contain at most {MAXIMUM_HOUSEHOLD_ENTITIES} records" + raise HouseholdValidationError(msg) record_ids: set[str] = set() normalized_records: list[dict[str, Any]] = [] expected_keys = ( @@ -153,26 +163,26 @@ def normalize_household_document( ) for record in records: if type(record) is not dict: - raise HouseholdValidationError(f"{collection} records must be objects") + msg = f"{collection} records must be objects" + raise HouseholdValidationError(msg) unknown = sorted(set(record) - expected_keys) missing = sorted({"id", "values"} - set(record)) if unknown or missing: - raise HouseholdValidationError( + msg = ( f"{collection} record fields are invalid: " f"missing={missing}, unsupported={unknown}" ) + raise HouseholdValidationError(msg) record_id = _require_identifier(record["id"], field=f"{collection} id") if record_id in record_ids: - raise HouseholdValidationError( - f"{collection} contains duplicate id {record_id!r}" - ) + msg = f"{collection} contains duplicate id {record_id!r}" + raise HouseholdValidationError(msg) record_ids.add(record_id) _require_source_name(record, collection=collection) _require_values(record, collection=collection) if collection == "people" and type(record.get("memberships")) is not dict: - raise HouseholdValidationError( - "people record memberships must be an object" - ) + msg = "people record memberships must be an object" + raise HouseholdValidationError(msg) normalized_records.append(dict(record)) identifiers[collection] = record_ids normalized[collection] = normalized_records @@ -182,19 +192,21 @@ def normalize_household_document( memberships = person["memberships"] assert isinstance(memberships, dict) if set(memberships) != required_memberships: - raise HouseholdValidationError( + msg = ( "people record memberships must identify every country-supported " "entity collection" ) + raise HouseholdValidationError(msg) for collection, entity_id in memberships.items(): linked_id = _require_identifier( entity_id, field=f"person {collection} membership", ) if linked_id not in identifiers[collection]: - raise HouseholdValidationError( + msg = ( f"person membership references absent {collection} id {linked_id!r}" ) + raise HouseholdValidationError(msg) return normalized @@ -210,13 +222,11 @@ def verify_legacy_household_mapping( mapping.fingerprint_version != fingerprint_version or mapping.fingerprint_sha256 != fingerprint_sha256 ): - raise LegacyHouseholdMappingIntegrityError( - "legacy household identity was presented with different source content" - ) + msg = "legacy household identity was presented with different source content" + raise LegacyHouseholdMappingIntegrityError(msg) if ( expected_household_id is not None and mapping.household_id != expected_household_id ): - raise LegacyHouseholdMappingIntegrityError( - "legacy household mapping does not match translated immutable content" - ) + msg = "legacy household mapping does not match translated immutable content" + raise LegacyHouseholdMappingIntegrityError(msg) diff --git a/policyengine_api/services/v2/metadata/database_connectors/reads.py b/policyengine_api/services/v2/metadata/database_connectors/reads.py index bd91984b2..27f5db17a 100644 --- a/policyengine_api/services/v2/metadata/database_connectors/reads.py +++ b/policyengine_api/services/v2/metadata/database_connectors/reads.py @@ -47,6 +47,5 @@ def read_rows( try: return list(session.exec(statement).all()) except SQLAlchemyError as error: - raise MetadataCatalogUnavailableError( - "the v2 metadata catalog cannot be queried" - ) from error + msg = "the v2 metadata catalog cannot be queried" + raise MetadataCatalogUnavailableError(msg) from error diff --git a/policyengine_api/services/v2/metadata/validators.py b/policyengine_api/services/v2/metadata/validators.py index 036722af4..60adf8818 100644 --- a/policyengine_api/services/v2/metadata/validators.py +++ b/policyengine_api/services/v2/metadata/validators.py @@ -27,9 +27,11 @@ def validate_metadata_page(offset: int, limit: int) -> tuple[int, int]: offset must be at least 0 and limit between 1 and 500. """ if offset < 0: - raise InvalidMetadataPageError("offset must be at least 0") + msg = "offset must be at least 0" + raise InvalidMetadataPageError(msg) if not 1 <= limit <= 500: - raise InvalidMetadataPageError("limit must be between 1 and 500") + msg = "limit must be between 1 and 500" + raise InvalidMetadataPageError(msg) return offset, limit @@ -41,7 +43,8 @@ def require_metadata_resource( ) -> ResourceT: """Return resource, or raise MetadataResourceNotFoundError naming description.""" if resource is None: - raise MetadataResourceNotFoundError(f"{description} was not found") + msg = f"{description} was not found" + raise MetadataResourceNotFoundError(msg) return resource @@ -54,13 +57,11 @@ def validate_economy_options( ) -> Dataset: """Validate preloaded records required by the economy-options response.""" if not any(region.code == country_id for region in regions): - raise MetadataCatalogUnavailableError( - f"the {country_id} national v2 region is absent" - ) + msg = f"the {country_id} national v2 region is absent" + raise MetadataCatalogUnavailableError(msg) if national_dataset is None: - raise MetadataCatalogUnavailableError( - f"the {country_id} national v2 dataset is absent" - ) + msg = f"the {country_id} national v2 dataset is absent" + raise MetadataCatalogUnavailableError(msg) time_periods = selected.model_version.metadata_time_periods if ( not isinstance(selected.model_version.current_law_id, int) @@ -68,7 +69,6 @@ def validate_economy_options( or not time_periods or any(not isinstance(year, int) for year in time_periods) ): - raise MetadataCatalogUnavailableError( - f"the {country_id} v2 model-version options are incomplete" - ) + msg = f"the {country_id} v2 model-version options are incomplete" + raise MetadataCatalogUnavailableError(msg) return national_dataset diff --git a/policyengine_api/services/v2/policies/services.py b/policyengine_api/services/v2/policies/services.py index 9a8e0d3b2..cb4c53a3a 100644 --- a/policyengine_api/services/v2/policies/services.py +++ b/policyengine_api/services/v2/policies/services.py @@ -130,23 +130,20 @@ def create_resolved_policy( content_hash=content.content_hash, ) if existing is None: - raise PolicyCreationIntegrityError( - "policy hash conflict did not resolve to a stored policy" - ) + msg = "policy hash conflict did not resolve to a stored policy" + raise PolicyCreationIntegrityError(msg) model_version = read_model_version(session, existing.tax_benefit_model_version_id) if model_version is None: - raise PolicyCreationIntegrityError( - "stored policy references an absent model version" - ) + msg = "stored policy references an absent model version" + raise PolicyCreationIntegrityError(msg) stored_input = stored_policy_creation_input( existing, model_version, read_parameter_values(session, existing.id), ) if canonical_policy_document(stored_input) != content.document: - raise PolicyContentHashCollisionError( - "stored policy content differs for the same canonical version and hash" - ) + msg = "stored policy content differs for the same canonical version and hash" + raise PolicyContentHashCollisionError(msg) return PolicyCreationResult(policy_id=existing.id, created=False) @@ -156,7 +153,8 @@ def read_complete_policy( """Return one policy of this country, or raise PolicyNotFoundError.""" policy = read_policy_row(session, country_id=country_id, policy_id=policy_id) if policy is None: - raise PolicyNotFoundError(f"policy {policy_id} was not found") + msg = f"policy {policy_id} was not found" + raise PolicyNotFoundError(msg) values = policy_parameter_values_by_policy( [policy.id], read_parameter_values_with_names(session, [policy.id]) ) @@ -262,9 +260,8 @@ def mirror_legacy_policy_in_session( lock=False, ) if concurrent is None: - raise LegacyPolicyMappingIntegrityError( - "legacy policy mapping conflict did not resolve to a stored row" - ) + msg = "legacy policy mapping conflict did not resolve to a stored row" + raise LegacyPolicyMappingIntegrityError(msg) verify_legacy_policy_mapping( concurrent, source_policy_hash=snapshot.source_policy_hash, diff --git a/policyengine_api/services/v2/policies/transformations.py b/policyengine_api/services/v2/policies/transformations.py index ef124f337..8bb05633f 100644 --- a/policyengine_api/services/v2/policies/transformations.py +++ b/policyengine_api/services/v2/policies/transformations.py @@ -71,7 +71,8 @@ def _canonical_json( for key in sorted(value) ) return "{" + ",".join(members) + "}" - raise TypeError("canonical policy content contains a non-JSON value") + msg = "canonical policy content contains a non-JSON value" + raise TypeError(msg) def canonical_utc_datetime(value: datetime) -> str: @@ -217,9 +218,8 @@ def _utc_midnight(value: str) -> datetime: try: parsed = date.fromisoformat(value) except ValueError as error: - raise LegacyPolicyTranslationError( - f"legacy period {value!r} is invalid" - ) from error + msg = f"legacy period {value!r} is invalid" + raise LegacyPolicyTranslationError(msg) from error return datetime.combine(parsed, time.min, tzinfo=UTC) @@ -230,13 +230,13 @@ def parse_legacy_period(value: str) -> tuple[datetime, datetime]: ``2024``. Raise LegacyPolicyTranslationError for anything else. """ if not value or value != value.strip(): - raise LegacyPolicyTranslationError("legacy period must be non-empty") + msg = "legacy period must be non-empty" + raise LegacyPolicyTranslationError(msg) if "." in value: parts = value.split(".") if len(parts) != 2: - raise LegacyPolicyTranslationError( - f"legacy period {value!r} must contain one date range" - ) + msg = f"legacy period {value!r} must contain one date range" + raise LegacyPolicyTranslationError(msg) start_date, end_date = (_utc_midnight(item) for item in parts) else: try: @@ -244,13 +244,11 @@ def parse_legacy_period(value: str) -> tuple[datetime, datetime]: start_date = _utc_midnight(str(parsed.start)) end_date = _utc_midnight(str(parsed.stop)) except (TypeError, ValueError) as error: - raise LegacyPolicyTranslationError( - f"legacy period {value!r} is invalid" - ) from error + msg = f"legacy period {value!r} is invalid" + raise LegacyPolicyTranslationError(msg) from error if end_date < start_date: - raise LegacyPolicyTranslationError( - f"legacy period {value!r} ends before it starts" - ) + msg = f"legacy period {value!r} ends before it starts" + raise LegacyPolicyTranslationError(msg) return start_date, end_date @@ -269,29 +267,27 @@ def translate_legacy_policy( """ expected_api_version = country_package_versions.get(snapshot.country_id) if expected_api_version is None or snapshot.api_version != expected_api_version: - raise LegacyPolicyTranslationError( - "legacy policy api_version does not match the running country package" - ) + msg = "legacy policy api_version does not match the running country package" + raise LegacyPolicyTranslationError(msg) policy_json = snapshot.policy_json assert isinstance(policy_json, dict) parameter_names = set(policy_json) if set(parameters) != parameter_names: - raise LegacyPolicyTranslationError( - "every legacy parameter path must exist in the running catalog" - ) + msg = "every legacy parameter path must exist in the running catalog" + raise LegacyPolicyTranslationError(msg) parameter_values: list[PolicyParameterValueInput] = [] for parameter_name in sorted(parameter_names): period_values = policy_json[parameter_name] if type(period_values) is not dict: - raise LegacyPolicyTranslationError( + msg = ( f"legacy parameter {parameter_name!r} must contain period/value entries" ) + raise LegacyPolicyTranslationError(msg) for period_name, value in sorted(period_values.items()): if type(period_name) is not str: - raise LegacyPolicyTranslationError( - f"legacy parameter {parameter_name!r} has a non-string period" - ) + msg = f"legacy parameter {parameter_name!r} has a non-string period" + raise LegacyPolicyTranslationError(msg) start_date, end_date = parse_legacy_period(period_name) parameter_values.append( PolicyParameterValueInput( @@ -308,9 +304,8 @@ def translate_legacy_policy( parameter_values=parameter_values, ) except ValueError as error: - raise LegacyPolicyTranslationError( - "legacy parameter periods or values conflict" - ) from error + msg = "legacy parameter periods or values conflict" + raise LegacyPolicyTranslationError(msg) from error return validate_policy_catalog( policy_input, selected=selected, diff --git a/policyengine_api/services/v2/policies/types.py b/policyengine_api/services/v2/policies/types.py index fcf1647c3..1c6dc15e2 100644 --- a/policyengine_api/services/v2/policies/types.py +++ b/policyengine_api/services/v2/policies/types.py @@ -47,7 +47,8 @@ class PolicyParameterValueInput(StrictPolicyInput): def validate_period(self) -> PolicyParameterValueInput: """Reject a value whose end date precedes its start date.""" if self.end_date is not None and self.end_date < self.start_date: - raise ValueError("end_date must not precede start_date") + msg = "end_date must not precede start_date" + raise ValueError(msg) return self @@ -72,9 +73,8 @@ def reject_duplicate_effective_values( for value in values: identity = (value.parameter_id, value.start_date) if identity in identities: - raise ValueError( - "parameter_values must not repeat a parameter_id/start_date" - ) + msg = "parameter_values must not repeat a parameter_id/start_date" + raise ValueError(msg) identities.add(identity) return values @@ -107,7 +107,8 @@ class LegacyPolicySnapshot(StrictPolicyInput): def require_parameter_mapping(cls, value: object) -> object: """Reject a v1 policy_json that is not an object keyed by parameter path.""" if type(value) is not dict: - raise ValueError("legacy policy_json must be a parameter-path object") + msg = "legacy policy_json must be a parameter-path object" + raise ValueError(msg) return value diff --git a/policyengine_api/services/v2/policies/validators.py b/policyengine_api/services/v2/policies/validators.py index d0ecf8120..84ba23cbe 100644 --- a/policyengine_api/services/v2/policies/validators.py +++ b/policyengine_api/services/v2/policies/validators.py @@ -53,18 +53,22 @@ def require_json_value( ) -> object: """Reject values that cannot be represented as standards-compliant JSON.""" if depth > MAXIMUM_JSON_NESTING: - raise ValueError("JSON values must not exceed 100 nested containers") + msg = "JSON values must not exceed 100 nested containers" + raise ValueError(msg) if value is None or type(value) in {str, bool, int}: return value if type(value) is float: if not math.isfinite(value): - raise ValueError("JSON numbers must be finite") + msg = "JSON numbers must be finite" + raise ValueError(msg) return value if type(value) not in {list, dict}: - raise ValueError("value must contain only standards-compliant JSON types") + msg = "value must contain only standards-compliant JSON types" + raise ValueError(msg) identity = id(value) if identity in containers: - raise ValueError("JSON values must not contain reference cycles") + msg = "JSON values must not contain reference cycles" + raise ValueError(msg) nested_containers = containers | {identity} if type(value) is list: for item in value: @@ -72,7 +76,8 @@ def require_json_value( return value for key, item in value.items(): if type(key) is not str: - raise ValueError("JSON object keys must be strings") + msg = "JSON object keys must be strings" + raise ValueError(msg) require_json_value(item, depth=depth + 1, containers=nested_containers) return value @@ -80,7 +85,8 @@ def require_json_value( def normalize_utc(value: datetime) -> datetime: """Require a time-zone-aware value and normalize it to UTC.""" if value.tzinfo is None or value.utcoffset() is None: - raise ValueError("effective dates must include a UTC offset") + msg = "effective dates must include a UTC offset" + raise ValueError(msg) return value.astimezone(UTC) @@ -94,16 +100,14 @@ def validate_policy_catalog( from policyengine_api.services.v2.policies.types import ResolvedPolicyCreationInput if policy_input.tax_benefit_model_id != selected.model.id: - raise PolicyCatalogValidationError( - "tax_benefit_model_id does not match the selected country catalog" - ) + msg = "tax_benefit_model_id does not match the selected country catalog" + raise PolicyCatalogValidationError(msg) requested_parameter_ids = { value.parameter_id for value in policy_input.parameter_values } if resolved_parameter_ids != requested_parameter_ids: - raise PolicyCatalogValidationError( - "every parameter_id must belong to the selected model version" - ) + msg = "every parameter_id must belong to the selected model version" + raise PolicyCatalogValidationError(msg) return ResolvedPolicyCreationInput( country_id=policy_input.country_id, tax_benefit_model_id=selected.model.id, @@ -121,10 +125,8 @@ def verify_legacy_policy_mapping( ) -> None: """Validate an existing mapping without performing SQL.""" if mapping.source_policy_hash != source_policy_hash: - raise LegacyPolicyMappingIntegrityError( - "legacy policy identity was presented with a different source hash" - ) + msg = "legacy policy identity was presented with a different source hash" + raise LegacyPolicyMappingIntegrityError(msg) if expected_policy_id is not None and mapping.policy_id != expected_policy_id: - raise LegacyPolicyMappingIntegrityError( - "legacy policy mapping does not match translated immutable content" - ) + msg = "legacy policy mapping does not match translated immutable content" + raise LegacyPolicyMappingIntegrityError(msg) diff --git a/policyengine_api/services/v2/reports/types.py b/policyengine_api/services/v2/reports/types.py index cd6d8d2b1..149c73914 100644 --- a/policyengine_api/services/v2/reports/types.py +++ b/policyengine_api/services/v2/reports/types.py @@ -59,15 +59,20 @@ def require_aligned_simulations(self) -> ReportExecutionInput: must be unique. """ if self.baseline.role is not SimulationRole.BASELINE: - raise ValueError("baseline input must use the baseline role") + msg = "baseline input must use the baseline role" + raise ValueError(msg) if self.reform.role is not SimulationRole.REFORM: - raise ValueError("reform input must use the reform role") + msg = "reform input must use the reform role" + raise ValueError(msg) if self.baseline.evaluation_id != self.evaluation_id: - raise ValueError("baseline evaluation_id must match the report") + msg = "baseline evaluation_id must match the report" + raise ValueError(msg) if self.reform.evaluation_id != self.evaluation_id: - raise ValueError("reform evaluation_id must match the report") + msg = "reform evaluation_id must match the report" + raise ValueError(msg) if self.baseline.simulation_execution_id == self.reform.simulation_execution_id: - raise ValueError("baseline and reform simulation identifiers must differ") + msg = "baseline and reform simulation identifiers must differ" + raise ValueError(msg) comparable_fields = ( "population", "year", @@ -78,11 +83,11 @@ def require_aligned_simulations(self) -> ReportExecutionInput: ) for field_name in comparable_fields: if getattr(self.baseline, field_name) != getattr(self.reform, field_name): - raise ValueError( - f"baseline and reform {field_name} must match before execution" - ) + msg = f"baseline and reform {field_name} must match before execution" + raise ValueError(msg) if len(self.requested_aggregates) != len(set(self.requested_aggregates)): - raise ValueError("requested aggregates must be unique") + msg = "requested aggregates must be unique" + raise ValueError(msg) return self @@ -123,14 +128,16 @@ def require_unique_aggregates( ) -> tuple[ReportAggregate, ...]: """Reject a payload that lists any aggregate twice.""" if len(value) != len(set(value)): - raise ValueError("requested aggregates must be unique") + msg = "requested aggregates must be unique" + raise ValueError(msg) return value @model_validator(mode="after") def validate_spm_provenance(self) -> AggregateReportArtifactPayload: """Reject duplicate or noncanonical SPM data in aggregate artifacts.""" if "spm_config" in self.result: - raise ValueError("aggregate result must not include legacy spm_config") + msg = "aggregate result must not include legacy spm_config" + raise ValueError(msg) if "spm_provenance" in self.result: SPMComparisonProvenance.model_validate(self.result["spm_provenance"]) return self diff --git a/policyengine_api/services/v2/simulations/types.py b/policyengine_api/services/v2/simulations/types.py index 7a19b9793..407ec590f 100644 --- a/policyengine_api/services/v2/simulations/types.py +++ b/policyengine_api/services/v2/simulations/types.py @@ -183,9 +183,11 @@ class GeographySelection(StrictContractModel): def require_complete_filter(self) -> GeographySelection: """Require filter_field with filter_value, and both for filter_strategy.""" if (self.filter_field is None) != (self.filter_value is None): - raise ValueError("filter_field and filter_value must be supplied together") + msg = "filter_field and filter_value must be supplied together" + raise ValueError(msg) if self.filter_strategy is not None and self.filter_field is None: - raise ValueError("filter_strategy requires filter_field and filter_value") + msg = "filter_strategy requires filter_field and filter_value" + raise ValueError(msg) return self @@ -200,7 +202,8 @@ class RequestedSimulationOutput(StrictContractModel): def require_unique_variables(cls, value: tuple[str, ...]) -> tuple[str, ...]: """Reject a variable list that names any variable twice.""" if len(value) != len(set(value)): - raise ValueError("requested variables must be unique") + msg = "requested variables must be unique" + raise ValueError(msg) return value @@ -237,7 +240,8 @@ class RowIdentity(StrictContractModel): def require_unique_columns(cls, value: tuple[str, ...]) -> tuple[str, ...]: """Reject identifier columns that name any column twice.""" if len(value) != len(set(value)): - raise ValueError("row identifier columns must be unique") + msg = "row identifier columns must be unique" + raise ValueError(msg) return value diff --git a/policyengine_api/services/v2/user_households/types.py b/policyengine_api/services/v2/user_households/types.py index d968eb01c..3be8f86c7 100644 --- a/policyengine_api/services/v2/user_households/types.py +++ b/policyengine_api/services/v2/user_households/types.py @@ -49,11 +49,11 @@ def require_valid_supplied_field(self) -> UserHouseholdUpdateInput: mutable_fields = {"household_id", "name", "description"} supplied = self.model_fields_set.intersection(mutable_fields) if not supplied: - raise ValueError( - "At least one of household_id, name, or description must be supplied" - ) + msg = "At least one of household_id, name, or description must be supplied" + raise ValueError(msg) if "household_id" in supplied and self.household_id is None: - raise ValueError("household_id must not be null") + msg = "household_id must not be null" + raise ValueError(msg) return self diff --git a/policyengine_api/services/v2/user_households/validators.py b/policyengine_api/services/v2/user_households/validators.py index 4a1a5afe7..26b8c1fe7 100644 --- a/policyengine_api/services/v2/user_households/validators.py +++ b/policyengine_api/services/v2/user_households/validators.py @@ -40,9 +40,8 @@ def require_user_household( ) -> UserHouseholdAssociation: """Return the association, or raise UserHouseholdNotFoundError if it is None.""" if association is None: - raise UserHouseholdNotFoundError( - f"user-household association {association_id} was not found" - ) + msg = f"user-household association {association_id} was not found" + raise UserHouseholdNotFoundError(msg) return association @@ -54,9 +53,8 @@ def validate_association_creation( ) -> None: """Check that the referenced user exists and the household fits the association.""" if user is None: - raise AssociationUserNotFoundError( - f"user {association_input.user_id} was not found" - ) + msg = f"user {association_input.user_id} was not found" + raise AssociationUserNotFoundError(msg) validate_association_household( country_id=association_input.country_id, household_id=association_input.household_id, @@ -75,10 +73,8 @@ def validate_association_household( Raise AssociationHouseholdNotFoundError or AssociationCountryConflictError. """ if household is None: - raise AssociationHouseholdNotFoundError( - f"household {household_id} was not found" - ) + msg = f"household {household_id} was not found" + raise AssociationHouseholdNotFoundError(msg) if household.country_id != country_id: - raise AssociationCountryConflictError( - "Association country_id must match the referenced household" - ) + msg = "Association country_id must match the referenced household" + raise AssociationCountryConflictError(msg) diff --git a/policyengine_api/services/v2/user_policies/services.py b/policyengine_api/services/v2/user_policies/services.py index 9d7e3fc27..148b818ec 100644 --- a/policyengine_api/services/v2/user_policies/services.py +++ b/policyengine_api/services/v2/user_policies/services.py @@ -267,9 +267,8 @@ def persist_legacy_user_policy_mapping( lock=False, ) if concurrent is None: - raise LegacyUserPolicyIntegrityError( - "legacy user-policy mapping conflict did not resolve to a stored row" - ) + msg = "legacy user-policy mapping conflict did not resolve to a stored row" + raise LegacyUserPolicyIntegrityError(msg) return apply_existing_legacy_user_policy_mapping( session, mapping=concurrent, diff --git a/policyengine_api/services/v2/user_policies/types.py b/policyengine_api/services/v2/user_policies/types.py index 0e80ac0fb..5ccc205a9 100644 --- a/policyengine_api/services/v2/user_policies/types.py +++ b/policyengine_api/services/v2/user_policies/types.py @@ -49,7 +49,8 @@ class UserPolicyUpdateInput(StrictAssociationInput): def require_supplied_field(self) -> UserPolicyUpdateInput: """Require at least one of name or description to be supplied.""" if not self.model_fields_set.intersection({"name", "description"}): - raise ValueError("At least one of name or description must be supplied") + msg = "At least one of name or description must be supplied" + raise ValueError(msg) return self diff --git a/policyengine_api/services/v2/user_policies/validators.py b/policyengine_api/services/v2/user_policies/validators.py index 5e33b74e5..ba2f5ee60 100644 --- a/policyengine_api/services/v2/user_policies/validators.py +++ b/policyengine_api/services/v2/user_policies/validators.py @@ -50,9 +50,8 @@ def require_user_policy( ) -> UserPolicy: """Return the association, or raise UserPolicyNotFoundError if it is None.""" if association is None: - raise UserPolicyNotFoundError( - f"user-policy association {association_id} was not found" - ) + msg = f"user-policy association {association_id} was not found" + raise UserPolicyNotFoundError(msg) return association @@ -68,17 +67,14 @@ def validate_association_creation( AssociationCountryConflictError. """ if user is None: - raise AssociationUserNotFoundError( - f"user {association_input.user_id} was not found" - ) + msg = f"user {association_input.user_id} was not found" + raise AssociationUserNotFoundError(msg) if policy is None: - raise AssociationPolicyNotFoundError( - f"policy {association_input.policy_id} was not found" - ) + msg = f"policy {association_input.policy_id} was not found" + raise AssociationPolicyNotFoundError(msg) if policy.country_id != association_input.country_id: - raise AssociationCountryConflictError( - "Association country_id must match the referenced policy" - ) + msg = "Association country_id must match the referenced policy" + raise AssociationCountryConflictError(msg) def validate_existing_legacy_user_mapping( @@ -86,9 +82,8 @@ def validate_existing_legacy_user_mapping( ) -> UUID: """Return the mapped v2 user id, or raise if that user no longer exists.""" if user is None: - raise LegacyUserPolicyIntegrityError( - "legacy user mapping has no referenced v2 user" - ) + msg = "legacy user mapping has no referenced v2 user" + raise LegacyUserPolicyIntegrityError(msg) return mapping.user_id @@ -97,9 +92,8 @@ def validate_legacy_user_mapping_conflict( ) -> UUID: """Return the user id of the mapping that won the race, or raise if unusable.""" if mapping is None or user is None: - raise LegacyUserPolicyIntegrityError( - "legacy user mapping conflict did not resolve to a v2 user" - ) + msg = "legacy user mapping conflict did not resolve to a v2 user" + raise LegacyUserPolicyIntegrityError(msg) return mapping.user_id @@ -111,16 +105,14 @@ def validate_legacy_user_policy_input( ) -> None: """Require a positive revision and a saved policy that uses the reform snapshot.""" if source_revision <= 0: - raise LegacyUserPolicyIntegrityError( - "legacy user-policy source revision must be positive" - ) + msg = "legacy user-policy source revision must be positive" + raise LegacyUserPolicyIntegrityError(msg) if ( snapshot.country_id != reform_snapshot.country_id or snapshot.reform_id != reform_snapshot.legacy_policy_id ): - raise LegacyUserPolicyIntegrityError( - "saved policy does not reference the supplied reform snapshot" - ) + msg = "saved policy does not reference the supplied reform snapshot" + raise LegacyUserPolicyIntegrityError(msg) def validate_existing_legacy_user_policy_mapping( @@ -142,31 +134,26 @@ def validate_existing_legacy_user_policy_mapping( has a different fingerprint, or if a revision was skipped. """ if association is None: - raise LegacyUserPolicyIntegrityError( - "legacy user-policy mapping has no association" - ) + msg = "legacy user-policy mapping has no association" + raise LegacyUserPolicyIntegrityError(msg) if ( association.policy_id != policy_id or association.country_id != country_id or association.user_id != user_id ): - raise LegacyUserPolicyIntegrityError( - "legacy user-policy mapping conflicts with immutable association fields" - ) + msg = "legacy user-policy mapping conflicts with immutable association fields" + raise LegacyUserPolicyIntegrityError(msg) if mapping.fingerprint_version != fingerprint_version: - raise LegacyUserPolicyIntegrityError( - "legacy user-policy fingerprint version is unsupported" - ) + msg = "legacy user-policy fingerprint version is unsupported" + raise LegacyUserPolicyIntegrityError(msg) if source_revision < mapping.last_applied_source_revision: return LegacyUserPolicyMappingAction.STALE, association if source_revision == mapping.last_applied_source_revision: if mapping.fingerprint_sha256 != fingerprint: - raise LegacyUserPolicyIntegrityError( - "legacy user-policy revision conflicts with its stored fingerprint" - ) + msg = "legacy user-policy revision conflicts with its stored fingerprint" + raise LegacyUserPolicyIntegrityError(msg) return LegacyUserPolicyMappingAction.REPLAY, association if source_revision != mapping.last_applied_source_revision + 1: - raise LegacyUserPolicyIntegrityError( - "legacy user-policy revision has an unapplied predecessor" - ) + msg = "legacy user-policy revision has an unapplied predecessor" + raise LegacyUserPolicyIntegrityError(msg) return LegacyUserPolicyMappingAction.UPDATE, association diff --git a/policyengine_api/specification.py b/policyengine_api/specification.py index f4ae4f5a2..a0c411f85 100644 --- a/policyengine_api/specification.py +++ b/policyengine_api/specification.py @@ -44,7 +44,8 @@ def _openapi_30_schema(value: JSONValue) -> JSONValue: if {"type": "null"} in variants: non_null = [item for item in variants if item != {"type": "null"}] if len(non_null) != 1: - raise ValueError("SPM optional schemas must have one non-null type") + msg = "SPM optional schemas must have one non-null type" + raise ValueError(msg) result.pop("anyOf") result.update(non_null[0]) result["nullable"] = True @@ -92,7 +93,8 @@ def load_specification( """Load the static specification and set the deployed package version.""" document = yaml.safe_load(path.read_text(encoding="utf-8")) if not isinstance(document, dict) or not isinstance(document.get("info"), dict): - raise ValueError("OpenAPI specification must contain an info object") + msg = "OpenAPI specification must contain an info object" + raise ValueError(msg) document["info"]["version"] = version schemas = document.get("components", {}).get("schemas", {}) for name, model in { diff --git a/policyengine_api/spm.py b/policyengine_api/spm.py index 0222b472a..4c6d697b0 100644 --- a/policyengine_api/spm.py +++ b/policyengine_api/spm.py @@ -95,7 +95,8 @@ def serialize_selection( # noqa: ANN201 # pydantic would build the output sche def validate_as_of(cls, value: str | None) -> str | None: """Reject an ``as_of`` that is not an exact ISO date such as 2025-01-31.""" if value is not None and date.fromisoformat(value).isoformat() != value: - raise ValueError("as_of must be an ISO calendar date (YYYY-MM-DD)") + msg = "as_of must be an ISO calendar date (YYYY-MM-DD)" + raise ValueError(msg) return value @model_validator(mode="after") @@ -103,9 +104,11 @@ def validate_location(self) -> Self: """Require a non-blank ``geography_id`` for a metro; forbid it otherwise.""" if self.geography_kind == "metro": if not self.geography_id or not self.geography_id.strip(): - raise ValueError("An SPM area selection requires geography_id") + msg = "An SPM area selection requires geography_id" + raise ValueError(msg) elif self.geography_id is not None: - raise ValueError("Only an SPM area selection accepts geography_id") + msg = "Only an SPM area selection accepts geography_id" + raise ValueError(msg) return self @@ -125,7 +128,8 @@ class SPMResolvedConfiguration(BaseModel): def validate_as_of(cls, value: str | None) -> str | None: """Reject an ``as_of`` that is not an exact ISO date such as 2025-01-31.""" if value is not None and date.fromisoformat(value).isoformat() != value: - raise ValueError("as_of must be an ISO calendar date (YYYY-MM-DD)") + msg = "as_of must be an ISO calendar date (YYYY-MM-DD)" + raise ValueError(msg) return value @model_validator(mode="after") @@ -133,9 +137,11 @@ def validate_location(self) -> SPMResolvedConfiguration: """Require a non-blank ``geography_id`` for a metro; forbid it otherwise.""" if self.geography_kind == "metro": if self.geography_id is None or not self.geography_id.strip(): - raise ValueError("An SPM area selection requires geography_id") + msg = "An SPM area selection requires geography_id" + raise ValueError(msg) elif self.geography_id is not None: - raise ValueError("Only an SPM area selection accepts geography_id") + msg = "Only an SPM area selection accepts geography_id" + raise ValueError(msg) return self @@ -161,9 +167,11 @@ def validate_years(cls, years: tuple[str, ...]) -> tuple[str, ...]: if any( len(year) != 4 or not year.isascii() or not year.isdigit() for year in years ): - raise ValueError("years must contain four-digit calendar years") + msg = "years must contain four-digit calendar years" + raise ValueError(msg) if tuple(sorted(set(years))) != years: - raise ValueError("years must be sorted and unique") + msg = "years must be sorted and unique" + raise ValueError(msg) return years @model_validator(mode="after") @@ -171,9 +179,11 @@ def validate_location(self) -> SPMProvenance: """Require a non-blank ``geography_id`` on a metro receipt, else forbid it.""" if self.geography_kind == "metro": if self.geography_id is None or not self.geography_id.strip(): - raise ValueError("A metro SPM receipt requires geography_id") + msg = "A metro SPM receipt requires geography_id" + raise ValueError(msg) elif self.geography_id is not None: - raise ValueError("Only a metro SPM receipt accepts geography_id") + msg = "Only a metro SPM receipt accepts geography_id" + raise ValueError(msg) return self @@ -210,7 +220,8 @@ class SPMComparisonProvenance(BaseModel): def validate_compatible_receipts(self) -> SPMComparisonProvenance: """Reject a comparison whose baseline and reform used different SPM receipts.""" if self.baseline.receipt != self.reform.receipt: - raise ValueError("baseline and reform SPM receipts differ") + msg = "baseline and reform SPM receipts differ" + raise ValueError(msg) return self @@ -224,9 +235,11 @@ def build_spm_provenance( ) -> SPMProvenance: """Build one compact receipt from typed calculation inputs.""" if selection.forecast_content_sha256 != forecast_sha256: - raise ValueError("SPM receipt differs from the SPM configuration") + msg = "SPM receipt differs from the SPM configuration" + raise ValueError(msg) if selection.scenario is None: - raise ValueError("Resolved SPM configuration must include scenario") + msg = "Resolved SPM configuration must include scenario" + raise ValueError(msg) return SPMProvenance( schema_version="canonical-spm-provenance-v2", forecast_id=forecast_id, @@ -244,7 +257,8 @@ def build_spm_provenance( def _required_string(source: Mapping[str, object], field: str) -> str: value = source.get(field) if not isinstance(value, str) or not value: - raise ValueError(f"Country SPM receipt requires {field}") + msg = f"Country SPM receipt requires {field}" + raise ValueError(msg) return value @@ -261,20 +275,24 @@ def build_spm_calculation_provenance( or scenario != config.scenario or geography_kind != config.geography_kind ): - raise ValueError("Country SPM receipt differs from the SPM configuration") + msg = "Country SPM receipt differs from the SPM configuration" + raise ValueError(msg) source_years = receipt.get("years") if not isinstance(source_years, Mapping): - raise ValueError("Country SPM receipt requires a years mapping") + msg = "Country SPM receipt requires a years mapping" + raise ValueError(msg) years: list[str] = [] for year in source_years: if not isinstance(year, str): - raise ValueError("Country SPM receipt years must be strings") + msg = "Country SPM receipt years must be strings" + raise ValueError(msg) years.append(year) source_versions = receipt.get("runtime_versions") if not isinstance(source_versions, Mapping): - raise ValueError("Country SPM receipt requires runtime_versions") + msg = "Country SPM receipt requires runtime_versions" + raise ValueError(msg) runtime_versions = SPMRuntimeVersions.model_validate(source_versions) return build_spm_provenance( forecast_id=_required_string(receipt, "forecast_id"), @@ -304,10 +322,12 @@ def _collapse_execution_receipts( receipts: Sequence[SPMProvenance], ) -> SPMExecutionProvenance: if not receipts: - raise ValueError(f"{side} SPM receipts are empty") + msg = f"{side} SPM receipts are empty" + raise ValueError(msg) first = receipts[0] if any(receipt != first for receipt in receipts[1:]): - raise ValueError(f"{side} SPM receipts differ") + msg = f"{side} SPM receipts differ" + raise ValueError(msg) return SPMExecutionProvenance(receipt=first, execution_count=len(receipts)) @@ -496,56 +516,56 @@ def normalize_spm_selection( """ if country_id != "us": if selection is not None: - raise SPMValidationError( - "SPM_SETTINGS_UNSUPPORTED", "SPM settings are only available for the US" - ) + msg = "SPM_SETTINGS_UNSUPPORTED" + raise SPMValidationError(msg, "SPM settings are only available for the US") return None try: chosen = SPMSelection.model_validate({} if selection is None else selection) except ValidationError as error: - raise SPMValidationError( - "SPM_SETTINGS_INVALID", readable_validation_error(error) - ) from error + msg = "SPM_SETTINGS_INVALID" + raise SPMValidationError(msg, readable_validation_error(error)) from error bundle = _current_bundle() if not isinstance(bundle, dict): - raise SPMValidationError( - "SPM_CONFIGURATION_UNAVAILABLE", "The installed bundle manifest is invalid" - ) + msg = "SPM_CONFIGURATION_UNAVAILABLE" + raise SPMValidationError(msg, "The installed bundle manifest is invalid") measurements = bundle.get("measurements") configured = measurements.get("spm") if isinstance(measurements, dict) else None if not isinstance(configured, dict): if _installed_country_implements_spm(country_id): # A model that can run canonical SPM must never run it uncertified, # whichever settings the caller did or did not send. + msg = "SPM_CONFIGURATION_UNAVAILABLE" raise SPMValidationError( - "SPM_CONFIGURATION_UNAVAILABLE", + msg, "The installed bundle has no certified SPM measurement configuration", ) if selection is None: return None + msg = "SPM_SETTINGS_UNSUPPORTED" raise SPMValidationError( - "SPM_SETTINGS_UNSUPPORTED", + msg, "This US model bundle does not support canonical SPM settings", ) try: defaults = SPMSelection.model_validate(configured) if defaults.forecast_content_sha256 is None or defaults.scenario is None: - raise ValueError("The bundle must pin the SPM artifact hash and scenario") + msg = "The bundle must pin the SPM artifact hash and scenario" + raise ValueError(msg) simulation_type = importlib.import_module("policyengine_us").Simulation if not simulation_supports_spm(simulation_type): - raise ValueError("The installed US model does not support canonical SPM") + msg = "The installed US model does not support canonical SPM" + raise ValueError(msg) forecast = _selected_forecast(defaults.forecast_content_sha256) except (AttributeError, ImportError, ValueError, TypeError, OSError) as error: # A country package installed without its Simulation raises AttributeError # here. The capability probe already reads that as "no canonical model"; # letting it escape instead would make /readiness-check raise rather than # report not-ready, and every US request a 500 rather than a typed 400. - raise SPMValidationError( - "SPM_CONFIGURATION_UNAVAILABLE", error_message(error) - ) from error + msg = "SPM_CONFIGURATION_UNAVAILABLE" + raise SPMValidationError(msg, error_message(error)) from error if chosen.forecast_content_sha256 not in (None, defaults.forecast_content_sha256): # A saved selection is identity, not a request. The caller sent nothing @@ -553,13 +573,15 @@ def normalize_spm_selection( # was measured against, which is the same class of failure as an # uncertified bundle rather than a correctable caller mistake. if stored: + msg = "SPM_CONFIGURATION_UNAVAILABLE" raise SPMValidationError( - "SPM_CONFIGURATION_UNAVAILABLE", + msg, "This deployment does not have the SPM artifact this household's " "saved selection was measured against", ) + msg = "SPM_SETTINGS_INVALID" raise SPMValidationError( - "SPM_SETTINGS_INVALID", + msg, "SPM selection does not match this bundle's artifact hash", ) values = {name: getattr(defaults, name) for name in SPMSelection.model_fields} @@ -578,11 +600,11 @@ def normalize_spm_selection( forecast.years[0], scenario=resolved.scenario, as_of=resolved.as_of ) if resolved.county_vintage != "2020": - raise ValueError("Unsupported county vintage: use 2020") + msg = "Unsupported county vintage: use 2020" + raise ValueError(msg) except ValueError as error: - raise SPMValidationError( - "SPM_SETTINGS_INVALID", error_message(error) - ) from error + msg = "SPM_SETTINGS_INVALID" + raise SPMValidationError(msg, error_message(error)) from error if resolved.geography_kind == "metro": _validate_metro_selection(forecast, resolved) return resolved.model_dump(mode="json") @@ -609,8 +631,9 @@ def _validate_metro_selection(forecast: SPMForecast, selection: SPMSelection) -> except ValueError: continue return + msg = "SPM_GEOGRAPHY_UNAVAILABLE" raise SPMValidationError( - "SPM_GEOGRAPHY_UNAVAILABLE", + msg, f"SPM area is unavailable in the selected artifact: {selection.geography_id}", ) @@ -639,18 +662,21 @@ def calculation_spm_receipt(simulation: Simulation) -> dict: try: resolved = resolved_spm_settings(simulation.spm_config) if resolved is None: - raise ValueError("Country SPM configuration is incomplete") + msg = "Country SPM configuration is incomplete" + raise ValueError(msg) source = simulation.spm_provenance() if not isinstance(source, Mapping): - raise ValueError("Country SPM receipt must be an object") + msg = "Country SPM receipt must be an object" + raise ValueError(msg) calculation = build_spm_calculation_provenance( SPMSelection.model_validate(resolved), source, ) return {"spm_provenance": calculation.model_dump(mode="json", by_alias=True)} except (ValidationError, ValueError) as error: + msg = "SPM_CONFIGURATION_UNAVAILABLE" raise SPMValidationError( - "SPM_CONFIGURATION_UNAVAILABLE", + msg, "The installed country model reported an SPM receipt this API cannot " f"certify: {error_message(error)}", ) from error diff --git a/policyengine_api/utils/budget_window.py b/policyengine_api/utils/budget_window.py index d827c66eb..ca086a7e1 100644 --- a/policyengine_api/utils/budget_window.py +++ b/policyengine_api/utils/budget_window.py @@ -41,17 +41,18 @@ def build_budget_window_request_setup( 1..BUDGET_WINDOW_MAX_YEARS, or a last year after BUDGET_WINDOW_MAX_END_YEAR. """ if target != "general": - raise ValueError("Budget-window calculations only support target='general'") + msg = "Budget-window calculations only support target='general'" + raise ValueError(msg) start_year_int = int(start_year) if not 1 <= window_size <= BUDGET_WINDOW_MAX_YEARS: - raise ValueError(f"window_size must be between 1 and {BUDGET_WINDOW_MAX_YEARS}") + msg = f"window_size must be between 1 and {BUDGET_WINDOW_MAX_YEARS}" + raise ValueError(msg) end_year = start_year_int + window_size - 1 if end_year > BUDGET_WINDOW_MAX_END_YEAR: - raise ValueError( - f"budget-window end_year must be {BUDGET_WINDOW_MAX_END_YEAR} or earlier" - ) + msg = f"budget-window end_year must be {BUDGET_WINDOW_MAX_END_YEAR} or earlier" + raise ValueError(msg) normalized_start_year = str(start_year_int) return BudgetWindowRequestSetup( diff --git a/policyengine_api/worker_spm.py b/policyengine_api/worker_spm.py index f0e446830..d83587911 100644 --- a/policyengine_api/worker_spm.py +++ b/policyengine_api/worker_spm.py @@ -62,22 +62,21 @@ def validate_worker_spm( "contract_version", "defaults", }: - raise ValueError( - "The selected worker has no certified canonical SPM capability" - ) + msg = "The selected worker has no certified canonical SPM capability" + raise ValueError(msg) if capability["contract_version"] != "canonical-spm-v1": - raise ValueError("The selected worker uses an unsupported SPM contract") + msg = "The selected worker uses an unsupported SPM contract" + raise ValueError(msg) defaults = SPMSelection.model_validate(capability["defaults"]) if not defaults.scenario or not defaults.forecast_content_sha256: - raise ValueError( - "The worker capability must pin SPM artifact hash and scenario" - ) + msg = "The worker capability must pin SPM artifact hash and scenario" + raise ValueError(msg) if defaults.forecast_content_sha256 != resolved["forecast_content_sha256"]: - raise ValueError("The API and worker SPM artifact identities differ") + msg = "The API and worker SPM artifact identities differ" + raise ValueError(msg) except ValueError as exc: - raise SPMValidationError( - "SPM_CONFIGURATION_UNAVAILABLE", error_message(exc) - ) from exc + msg = "SPM_CONFIGURATION_UNAVAILABLE" + raise SPMValidationError(msg, error_message(exc)) from exc return resolved @@ -100,41 +99,49 @@ def validate_worker_result( return try: if not isinstance(result, dict): - raise ValueError("Worker SPM result must be an object") + msg = "Worker SPM result must be an object" + raise ValueError(msg) if result.get("kind") == "budgetWindow": if expected_year is not None: - raise ValueError("Expected an annual SPM result") + msg = "Expected an annual SPM result" + raise ValueError(msg) rows = result.get("annualImpacts") size = result.get("windowSize") if type(size) is not int or size < 1: - raise ValueError("Budget window size must be a positive integer") + msg = "Budget window size must be a positive integer" + raise ValueError(msg) if not isinstance(rows, list) or not all( isinstance(row, dict) for row in rows ): - raise ValueError("Budget window annual impacts must be objects") + msg = "Budget window annual impacts must be objects" + raise ValueError(msg) if len(rows) != size: - raise ValueError("Budget window has incomplete SPM receipts") + msg = "Budget window has incomplete SPM receipts" + raise ValueError(msg) actual = [str(row.get("year")) for row in rows] if any( len(year) != 4 or not year.isascii() or not year.isdigit() for year in actual ): - raise ValueError("Budget window annual impacts require calendar years") + msg = "Budget window annual impacts require calendar years" + raise ValueError(msg) if len(set(actual)) != len(actual): - raise ValueError("Budget window SPM result years differ from request") + msg = "Budget window SPM result years differ from request" + raise ValueError(msg) if expected_years is not None: wanted = [str(year) for year in expected_years] if actual != wanted: - raise ValueError( - "Budget window SPM result years differ from request" - ) + msg = "Budget window SPM result years differ from request" + raise ValueError(msg) for row in rows: validate_worker_result(row, selection, expected_year=row.get("year")) return if expected_years is not None: - raise ValueError("Expected a budget window SPM result") + msg = "Expected a budget window SPM result" + raise ValueError(msg) if "spm_config" in result: - raise ValueError("Worker result must not include legacy spm_config") + msg = "Worker result must not include legacy spm_config" + raise ValueError(msg) comparison = SPMComparisonProvenance.model_validate( result.get("spm_provenance") ) @@ -143,13 +150,14 @@ def validate_worker_result( receipt = validate_spm_calculation_provenance(execution.receipt) resolved = resolved_spm_configuration(receipt).model_dump(mode="json") if resolved != selection: - raise ValueError("Worker result SPM settings differ from the request") + msg = "Worker result SPM settings differ from the request" + raise ValueError(msg) if expected_year is not None and str(expected_year) not in receipt.years: - raise ValueError("Worker SPM receipt does not cover requested year") + msg = "Worker SPM receipt does not cover requested year" + raise ValueError(msg) except (ValueError, TypeError, KeyError) as exc: - raise SPMValidationError( - "SPM_CONFIGURATION_UNAVAILABLE", error_message(exc) - ) from exc + msg = "SPM_CONFIGURATION_UNAVAILABLE" + raise SPMValidationError(msg, error_message(exc)) from exc def raise_worker_spm_error(response: httpx.Response) -> None: diff --git a/policyengine_api/worker_spm_release.py b/policyengine_api/worker_spm_release.py index d5bee00c8..6389fefc6 100644 --- a/policyengine_api/worker_spm_release.py +++ b/policyengine_api/worker_spm_release.py @@ -47,14 +47,16 @@ def _bundle_package_versions() -> dict[str, str]: bundle = constants._policyengine_bundle packages = bundle.get("packages") if isinstance(bundle, dict) else None if not isinstance(packages, dict): - raise ValueError("The installed PolicyEngine bundle manifest has no packages") + msg = "The installed PolicyEngine bundle manifest has no packages" + raise ValueError(msg) versions: dict[str, str] = {} for package_name in REQUIRED_BUNDLE_PACKAGES: package = packages.get(package_name) package_version = package.get("version") if isinstance(package, dict) else None if not isinstance(package_version, str) or not package_version: - raise ValueError(f"The installed bundle does not identify {package_name}") + msg = f"The installed bundle does not identify {package_name}" + raise ValueError(msg) versions[package_name] = package_version return versions @@ -67,9 +69,8 @@ def _registered_application( routes = registry.get(route_kind) if isinstance(registry, dict) else None app = routes.get(route_version) if isinstance(routes, dict) else None if not isinstance(app, str) or not app: - raise ValueError( - f"The worker registry has no {route_kind} {route_version} application" - ) + msg = f"The worker registry has no {route_kind} {route_version} application" + raise ValueError(msg) return app @@ -77,15 +78,16 @@ def validate_installed_worker(registry: dict, expected_bundle: str) -> dict | No """Validate local package versions, worker routes, and SPM capability.""" versions = _bundle_package_versions() if versions["policyengine"] != expected_bundle: - raise ValueError("The bundle manifest differs from the release requirement") + msg = "The bundle manifest differs from the release requirement" + raise ValueError(msg) if constants.POLICYENGINE_VERSION != expected_bundle: - raise ValueError("The API runtime differs from the release requirement") + msg = "The API runtime differs from the release requirement" + raise ValueError(msg) for package_name, expected_version in versions.items(): if distribution_version(package_name) != expected_version: - raise ValueError( - f"Installed {package_name} does not match the bundle manifest" - ) + msg = f"Installed {package_name} does not match the bundle manifest" + raise ValueError(msg) bundle_app = _registered_application(registry, "policyengine", expected_bundle) for country_id, package_name in ( @@ -98,9 +100,8 @@ def validate_installed_worker(registry: dict, expected_bundle: str) -> dict | No versions[package_name], ) if country_app != bundle_app: - raise ValueError( - f"The {country_id} route resolves to a different worker application" - ) + msg = f"The {country_id} route resolves to a different worker application" + raise ValueError(msg) return validate_worker_spm( "us", @@ -113,9 +114,8 @@ def validate_installed_worker(registry: dict, expected_bundle: str) -> dict | No def main() -> None: """Read a registry document from stdin and validate one bundle version.""" if len(sys.argv) != 2: - raise SystemExit( - "Usage: python -m policyengine_api.worker_spm_release BUNDLE_VERSION" - ) + msg = "Usage: python -m policyengine_api.worker_spm_release BUNDLE_VERSION" + raise SystemExit(msg) selection = validate_installed_worker(json.load(sys.stdin), sys.argv[1]) if selection is None: print("The installed legacy bundle and registered worker agree") diff --git a/scripts/measure_cloud_run_runtime.py b/scripts/measure_cloud_run_runtime.py index 3d1c049d3..9ca1ddd99 100644 --- a/scripts/measure_cloud_run_runtime.py +++ b/scripts/measure_cloud_run_runtime.py @@ -68,9 +68,8 @@ async def _wait_for_readiness( while True: waited = time.monotonic() - started if waited > timeout_seconds: - raise RuntimeError( - f"{READINESS_PATH} not ready after {timeout_seconds:.0f}s" - ) + msg = f"{READINESS_PATH} not ready after {timeout_seconds:.0f}s" + raise RuntimeError(msg) try: response = await client.get(READINESS_PATH, timeout=20.0) if response.status_code == 200: diff --git a/scripts/process_v1_household_mirror_event.py b/scripts/process_v1_household_mirror_event.py index 61908eba0..6bfa6f00e 100644 --- a/scripts/process_v1_household_mirror_event.py +++ b/scripts/process_v1_household_mirror_event.py @@ -36,7 +36,8 @@ class HouseholdEventCommandConfigurationError(RuntimeError): def _required(environ: Mapping[str, str], name: str) -> str: value = environ.get(name, "").strip() if not value: - raise HouseholdEventCommandConfigurationError(f"{name} is required") + msg = f"{name} is required" + raise HouseholdEventCommandConfigurationError(msg) return value @@ -48,13 +49,11 @@ def validate_command_environment( deployment_environment = _required(environ, "DEPLOYMENT_ENVIRONMENT") supabase_environment = _required(environ, "V2_SUPABASE_ENVIRONMENT") if deployment_environment != selected_environment: - raise HouseholdEventCommandConfigurationError( - "DEPLOYMENT_ENVIRONMENT does not match --environment" - ) + msg = "DEPLOYMENT_ENVIRONMENT does not match --environment" + raise HouseholdEventCommandConfigurationError(msg) if supabase_environment != selected_environment: - raise HouseholdEventCommandConfigurationError( - "V2_SUPABASE_ENVIRONMENT does not match --environment" - ) + msg = "V2_SUPABASE_ENVIRONMENT does not match --environment" + raise HouseholdEventCommandConfigurationError(msg) cloud_sql_instance = _required( environ, @@ -71,22 +70,18 @@ def validate_command_environment( ) if selected_environment == "staging": if cloud_sql_instance == production_cloud_sql_instance: - raise HouseholdEventCommandConfigurationError( - "staging Cloud SQL instance must differ from production" - ) + msg = "staging Cloud SQL instance must differ from production" + raise HouseholdEventCommandConfigurationError(msg) if supabase_project == production_supabase_project: - raise HouseholdEventCommandConfigurationError( - "staging Supabase project must differ from production" - ) + msg = "staging Supabase project must differ from production" + raise HouseholdEventCommandConfigurationError(msg) else: if cloud_sql_instance != production_cloud_sql_instance: - raise HouseholdEventCommandConfigurationError( - "production Cloud SQL instance does not match its declared identity" - ) + msg = "production Cloud SQL instance does not match its declared identity" + raise HouseholdEventCommandConfigurationError(msg) if supabase_project != production_supabase_project: - raise HouseholdEventCommandConfigurationError( - "production Supabase project does not match its declared identity" - ) + msg = "production Supabase project does not match its declared identity" + raise HouseholdEventCommandConfigurationError(msg) operator_identity = _required(environ, "HOUSEHOLD_MIRROR_OPERATOR_IDENTITY") allowed_operator_identity = _required( @@ -94,9 +89,8 @@ def validate_command_environment( "ALLOWED_HOUSEHOLD_MIRROR_OPERATOR_IDENTITY", ) if operator_identity != allowed_operator_identity: - raise HouseholdEventCommandConfigurationError( - "declared operator identity is not authorized for household event processing" - ) + msg = "declared operator identity is not authorized for household event processing" + raise HouseholdEventCommandConfigurationError(msg) return { "environment": selected_environment, "cloud_sql_instance": cloud_sql_instance, @@ -115,13 +109,11 @@ def _proxy_event_service( try: port = int(raw_port) except ValueError as error: - raise HouseholdEventCommandConfigurationError( - "POLICYENGINE_DB_PROXY_PORT must be an integer" - ) from error + msg = "POLICYENGINE_DB_PROXY_PORT must be an integer" + raise HouseholdEventCommandConfigurationError(msg) from error if not 1 <= port <= 65535: - raise HouseholdEventCommandConfigurationError( - "POLICYENGINE_DB_PROXY_PORT is outside the valid range" - ) + msg = "POLICYENGINE_DB_PROXY_PORT is outside the valid range" + raise HouseholdEventCommandConfigurationError(msg) password = _required(environ, "POLICYENGINE_DB_PASSWORD") engine = create_engine( diff --git a/scripts/v1_database_migration.py b/scripts/v1_database_migration.py index aa63effc3..679631d8f 100644 --- a/scripts/v1_database_migration.py +++ b/scripts/v1_database_migration.py @@ -84,7 +84,8 @@ def describe_metadata_difference(difference: object) -> str: if isinstance(item, list) and len(item) == 1: item = item[0] if not isinstance(item, tuple) or not item: - raise ValueError("Unsupported metadata difference shape") + msg = "Unsupported metadata difference shape" + raise ValueError(msg) operation = item[0] if operation == "remove_table" and len(item) >= 2: @@ -98,7 +99,8 @@ def describe_metadata_difference(difference: object) -> str: existing = "none" if item[5] is None else "present" target = "none" if item[6] is None else "present" return f"default:{item[2]}.{item[3]}:{existing}->{target}" - raise ValueError(f"Unsupported metadata difference operation: {operation}") + msg = f"Unsupported metadata difference operation: {operation}" + raise ValueError(msg) def _config(connection: Connection) -> Config: @@ -143,12 +145,12 @@ def verify_head_schema(connection: Connection) -> None: """Raise RuntimeError unless the database is at the head with no schema drift.""" state = database_state(connection) if state is not DatabaseState.HEAD: - raise RuntimeError(f"Database is not at the Alembic head; found {state}") + msg = f"Database is not at the Alembic head; found {state}" + raise RuntimeError(msg) differences = metadata_differences(connection) if differences: - raise RuntimeError( - f"Database metadata drift remains after migration: {sorted(differences)}" - ) + msg = f"Database metadata drift remains after migration: {sorted(differences)}" + raise RuntimeError(msg) def _acquire_lock(connection: Connection) -> None: @@ -156,7 +158,8 @@ def _acquire_lock(connection: Connection) -> None: text("SELECT GET_LOCK(:name, 60)"), {"name": MIGRATION_LOCK_NAME} ) if acquired != 1: - raise RuntimeError("Could not acquire the v1 Alembic migration lock") + msg = "Could not acquire the v1 Alembic migration lock" + raise RuntimeError(msg) def _release_lock(connection: Connection) -> None: @@ -175,15 +178,17 @@ def upgrade_database(connection: Connection, *, backup_id: str) -> None: try: state = database_state(connection) if state in {DatabaseState.UNVERSIONED, DatabaseState.INVALID}: - raise RuntimeError( + msg = ( "database has no valid Alembic revision; automatic baseline " "stamping is disabled" ) + raise RuntimeError(msg) if state is DatabaseState.HEAD: verify_head_schema(connection) return if not backup_id.strip(): - raise ValueError("A completed Cloud SQL backup ID is required") + msg = "A completed Cloud SQL backup ID is required" + raise ValueError(msg) command.upgrade(_config(connection), "head") verify_head_schema(connection) @@ -208,7 +213,8 @@ def _database_target(mode: str) -> str | URL: ) password = os.environ.get(password_env_name) if not password: - raise RuntimeError(f"{url_env_name} or {password_env_name} is required") + msg = f"{url_env_name} or {password_env_name} is required" + raise RuntimeError(msg) return build_database_url( username=READONLY_USER if readonly else MIGRATION_USER, password=password, diff --git a/tests/contract/test_v1_route_contracts.py b/tests/contract/test_v1_route_contracts.py index 5fa890323..34e487f1f 100644 --- a/tests/contract/test_v1_route_contracts.py +++ b/tests/contract/test_v1_route_contracts.py @@ -202,7 +202,8 @@ def contract_client(request) -> ContractClient: return FlaskContractClient(app) if request.param == "fastapi_fallback": return ASGIContractClient(app) - raise AssertionError(f"Unknown contract client: {request.param}") + msg = f"Unknown contract client: {request.param}" + raise AssertionError(msg) def _resolved_path(path: str) -> str: @@ -570,7 +571,8 @@ def _expected_subset(contract: ContractRequest) -> dict: "queued_years": [], "error": None, } - raise AssertionError(f"Missing expected subset for {contract}") + msg = f"Missing expected subset for {contract}" + raise AssertionError(msg) @pytest.mark.parametrize( diff --git a/tests/fixtures/local_v1_database.py b/tests/fixtures/local_v1_database.py index d4712f94f..833d6cdde 100644 --- a/tests/fixtures/local_v1_database.py +++ b/tests/fixtures/local_v1_database.py @@ -19,7 +19,8 @@ def create_test_v1_schema(engine: Engine) -> None: if engine.dialect.name != "sqlite": - raise ValueError("the test-only v1 schema helper requires SQLite") + msg = "the test-only v1 schema helper requires SQLite" + raise ValueError(msg) V1Base.metadata.create_all( engine, tables=[ diff --git a/tests/fixtures/spm.py b/tests/fixtures/spm.py index 118964fd0..313764632 100644 --- a/tests/fixtures/spm.py +++ b/tests/fixtures/spm.py @@ -47,7 +47,8 @@ def spm_receipt(*, years, selection: dict | None = None) -> dict: """Return one calculation receipt covering the supplied years.""" resolved = INSTALLED_SPM_SELECTION if selection is None else selection if resolved is None: - raise ValueError("An SPM receipt requires a resolved selection") + msg = "An SPM receipt requires a resolved selection" + raise ValueError(msg) return { "schema_version": "canonical-spm-provenance-v2", "forecast_id": "test-only", diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 9f4b532f7..c386efe41 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -118,8 +118,11 @@ def _poll_live_endpoint( try: response.raise_for_status() except httpx.HTTPStatusError as error: + msg = ( + f"{error}\n{route_name} response: {_response_summary(response)}" + ) raise httpx.HTTPStatusError( - f"{error}\n{route_name} response: {_response_summary(response)}", + msg, request=error.request, response=error.response, ) from error @@ -131,10 +134,11 @@ def _poll_live_endpoint( last_response = payload if time.monotonic() >= deadline: - raise AssertionError( + msg = ( f"Timed out polling the {route_name} route; " f"last response was {last_response}" ) + raise AssertionError(msg) time.sleep(INTEGRATION_POLL_INTERVAL_SECONDS) diff --git a/tests/integration/test_live_v2_policies.py b/tests/integration/test_live_v2_policies.py index 368209ba1..cb7131592 100644 --- a/tests/integration/test_live_v2_policies.py +++ b/tests/integration/test_live_v2_policies.py @@ -12,9 +12,8 @@ def _v2_database_url() -> str: value = os.environ.get("V2_MIGRATION_DATABASE_URL") if not value: - raise RuntimeError( - "V2_MIGRATION_DATABASE_URL is required for the live v2 probe" - ) + msg = "V2_MIGRATION_DATABASE_URL is required for the live v2 probe" + raise RuntimeError(msg) return value diff --git a/tests/integration/test_v1_household_dual_write.py b/tests/integration/test_v1_household_dual_write.py index f36de6a9f..2e8442a1f 100644 --- a/tests/integration/test_v1_household_dual_write.py +++ b/tests/integration/test_v1_household_dual_write.py @@ -144,8 +144,9 @@ class FailingMirror: def mirror_legacy_household(self, snapshot): with v2_sessions.begin() as session: mirror_legacy_household_in_session(session, snapshot) + msg = "forced transaction failure" raise OperationalError( - "forced transaction failure", + msg, {}, RuntimeError("forced"), ) diff --git a/tests/integration/test_v1_policy_dual_write.py b/tests/integration/test_v1_policy_dual_write.py index 4148fda52..42d59e303 100644 --- a/tests/integration/test_v1_policy_dual_write.py +++ b/tests/integration/test_v1_policy_dual_write.py @@ -212,8 +212,9 @@ class FailingMirror: def mirror_legacy_policy(self, snapshot): with v2_sessions.begin() as session: mirror_legacy_policy_in_session(session, snapshot) + msg = "forced transaction failure" raise OperationalError( - "forced transaction failure", + msg, {}, RuntimeError("forced"), ) diff --git a/tests/integration/test_v2_catalog_publication.py b/tests/integration/test_v2_catalog_publication.py index cb59127ce..06e48f64c 100644 --- a/tests/integration/test_v2_catalog_publication.py +++ b/tests/integration/test_v2_catalog_publication.py @@ -181,7 +181,8 @@ def test_failure_during_publication_rolls_back_every_catalog_write( ) -> None: def fail(point: str, _connection) -> None: if point == failure_point: - raise RuntimeError("injected failure") + msg = "injected failure" + raise RuntimeError(msg) with pytest.raises(RuntimeError, match="injected failure"): publish_catalog( diff --git a/tests/integration/test_v2_user_policy_mirroring.py b/tests/integration/test_v2_user_policy_mirroring.py index c2819f716..a0f667425 100644 --- a/tests/integration/test_v2_user_policy_mirroring.py +++ b/tests/integration/test_v2_user_policy_mirroring.py @@ -399,7 +399,8 @@ def test_complete_transaction_rolls_back_and_native_delete_is_isolated() -> None reform, source_revision=1, ) - raise RuntimeError("forced rollback") + msg = "forced rollback" + raise RuntimeError(msg) with sessions() as session: assert session.scalar(select(func.count()).select_from(Policy)) == 0 diff --git a/tests/to_refactor/api/test_api.py b/tests/to_refactor/api/test_api.py index 7fcb7f705..8625dc45a 100644 --- a/tests/to_refactor/api/test_api.py +++ b/tests/to_refactor/api/test_api.py @@ -36,13 +36,13 @@ def assert_response_data_matches_expected(data: dict, expected: dict): # has the same value. for key, value in expected.items(): if key not in data: - raise ValueError(f"Key {key} not found in response data.\n {data}") + msg = f"Key {key} not found in response data.\n {data}" + raise ValueError(msg) if isinstance(value, dict): assert_response_data_matches_expected(data[key], value) elif data[key] != value: - raise ValueError( - f"Value {data[key]} for key {key} does not match expected value {value}.\n {data}" - ) + msg = f"Value {data[key]} for key {key} does not match expected value {value}.\n {data}" + raise ValueError(msg) @pytest.mark.parametrize("test", test_data, ids=test_names) @@ -62,7 +62,8 @@ def test_response(client, test: dict): content_type="application/json", ) else: - raise ValueError(f"Unknown HTTP method: {test['method']}") + msg = f"Unknown HTTP method: {test['method']}" + raise ValueError(msg) assert response.status_code == test.get("response", {}).get("status", 200) if "data" in test.get("response", {}): diff --git a/tests/to_refactor/python/test_error_routes.py b/tests/to_refactor/python/test_error_routes.py index 34b24fbc4..04d1ac16c 100644 --- a/tests/to_refactor/python/test_error_routes.py +++ b/tests/to_refactor/python/test_error_routes.py @@ -30,7 +30,8 @@ def test_404_handler(app, client): @app.route("/nonexistent") def nonexistent(): - raise NotFound("Custom not found message") + msg = "Custom not found message" + raise NotFound(msg) response = client.get("/nonexistent") data = response.get_json() @@ -46,7 +47,8 @@ def test_400_handler(app, client): @app.route("/bad-request") def bad_request(): - raise BadRequest("Invalid parameters") + msg = "Invalid parameters" + raise BadRequest(msg) response = client.get("/bad-request") data = response.get_json() @@ -62,7 +64,8 @@ def test_401_handler(app, client): @app.route("/unauthorized") def unauthorized(): - raise Unauthorized("Invalid credentials") + msg = "Invalid credentials" + raise Unauthorized(msg) response = client.get("/unauthorized") data = response.get_json() @@ -78,7 +81,8 @@ def test_403_handler(app, client): @app.route("/forbidden") def forbidden(): - raise Forbidden("Access denied") + msg = "Access denied" + raise Forbidden(msg) response = client.get("/forbidden") data = response.get_json() @@ -94,7 +98,8 @@ def test_500_handler(app, client): @app.route("/server-error") def server_error(): - raise InternalServerError("Database connection failed") + msg = "Database connection failed" + raise InternalServerError(msg) response = client.get("/server-error") data = response.get_json() @@ -110,7 +115,8 @@ def test_generic_exception_handler(app, client): @app.route("/generic-error") def generic_error(): - raise ValueError("Something went wrong") + msg = "Something went wrong" + raise ValueError(msg) response = client.get("/generic-error") data = response.get_json() diff --git a/tests/unit/data/test_orm_sessions.py b/tests/unit/data/test_orm_sessions.py index 1d814e6c7..9b5e699c0 100644 --- a/tests/unit/data/test_orm_sessions.py +++ b/tests/unit/data/test_orm_sessions.py @@ -39,7 +39,8 @@ def test_session_factory_begin_commits_and_rolls_back(): with pytest.raises(RuntimeError, match="stop"): with factory.begin() as session: session.execute(text("INSERT INTO item (id) VALUES (2)")) - raise RuntimeError("stop") + msg = "stop" + raise RuntimeError(msg) with factory() as session: assert session.scalars(text("SELECT id FROM item ORDER BY id")).all() == [1] diff --git a/tests/unit/data/test_v1_database_migration.py b/tests/unit/data/test_v1_database_migration.py index b4271cb16..29d172aa7 100644 --- a/tests/unit/data/test_v1_database_migration.py +++ b/tests/unit/data/test_v1_database_migration.py @@ -174,7 +174,8 @@ def begin(self): return nullcontext(connection) def connect(self): - raise AssertionError("upgrade must use a committing transaction") + msg = "upgrade must use a committing transaction" + raise AssertionError(msg) def dispose(self): pass diff --git a/tests/unit/routes/test_calculate_error_statuses.py b/tests/unit/routes/test_calculate_error_statuses.py index 5972db9d3..a2e9dd23f 100644 --- a/tests/unit/routes/test_calculate_error_statuses.py +++ b/tests/unit/routes/test_calculate_error_statuses.py @@ -31,7 +31,8 @@ def prepare_calculation(self, household, policy, **_kwargs): class CrashingCountry(DummyCountry): def calculate(self, household, policy, **_kwargs): - raise RuntimeError("engine exploded") + msg = "engine exploded" + raise RuntimeError(msg) def make_client(monkeypatch, country, add_missing=False): diff --git a/tests/unit/routes/test_migration_context_logging.py b/tests/unit/routes/test_migration_context_logging.py index a449138f0..0e5790731 100644 --- a/tests/unit/routes/test_migration_context_logging.py +++ b/tests/unit/routes/test_migration_context_logging.py @@ -74,7 +74,8 @@ def get_metadata(self, country_id: str): class _FailingMetadataReader: def get_metadata(self, country_id: str): - raise RuntimeError("private metadata failure") + msg = "private metadata failure" + raise RuntimeError(msg) def _dependencies(): diff --git a/tests/unit/routes/test_spm_year_worker_polling.py b/tests/unit/routes/test_spm_year_worker_polling.py index ca323ea67..d0797633a 100644 --- a/tests/unit/routes/test_spm_year_worker_polling.py +++ b/tests/unit/routes/test_spm_year_worker_polling.py @@ -286,7 +286,8 @@ def transport(request): ) if request.url.path == "/versions/policyengine": return httpx.Response(200, json={"5.3.1": "test-worker"}) - raise AssertionError(f"Unexpected worker call: {request.url.path}") + msg = f"Unexpected worker call: {request.url.path}" + raise AssertionError(msg) gateway = object.__new__(SimulationEntrypointClient) gateway.base_url = "https://worker.invalid" diff --git a/tests/unit/runtime_cache/test_core.py b/tests/unit/runtime_cache/test_core.py index 953a738ec..3d28d9c9b 100644 --- a/tests/unit/runtime_cache/test_core.py +++ b/tests/unit/runtime_cache/test_core.py @@ -131,10 +131,12 @@ def test_cache_connection_and_write_failures_emit_metric_events(monkeypatch) -> class Broken: def get(self, _key): - raise OSError("unavailable") + msg = "unavailable" + raise OSError(msg) def set(self, _key, _value, **_kwargs): - raise OSError("unavailable") + msg = "unavailable" + raise OSError(msg) cache = RecoverableJSONCache( Broken(), @@ -177,10 +179,12 @@ def test_claim_failures_are_coordination_errors_not_cache_misses(monkeypatch) -> class Broken: def set(self, *_args, **_kwargs): - raise OSError("unavailable") + msg = "unavailable" + raise OSError(msg) def eval(self, *_args, **_kwargs): - raise OSError("unavailable") + msg = "unavailable" + raise OSError(msg) claims = ExpiringClaimStore(Broken()) with pytest.raises(CacheCoordinationError): diff --git a/tests/unit/services/test_budget_window_cache.py b/tests/unit/services/test_budget_window_cache.py index 2acaf0752..b18fd22bb 100644 --- a/tests/unit/services/test_budget_window_cache.py +++ b/tests/unit/services/test_budget_window_cache.py @@ -29,20 +29,24 @@ def __init__(self, *, method): def get(self, key): if self.method == "get": - raise RuntimeError("redis unavailable") + msg = "redis unavailable" + raise RuntimeError(msg) return def set(self, key, value, nx=False, ex=None): if self.method == "set": - raise RuntimeError("redis unavailable") + msg = "redis unavailable" + raise RuntimeError(msg) return True def delete(self, key): if self.method == "delete": - raise RuntimeError("redis unavailable") + msg = "redis unavailable" + raise RuntimeError(msg) def eval(self, *_args, **_kwargs): - raise RuntimeError("redis unavailable") + msg = "redis unavailable" + raise RuntimeError(msg) @pytest.mark.parametrize( diff --git a/tests/unit/services/test_household_calculation_service.py b/tests/unit/services/test_household_calculation_service.py index 6f52213d0..25edb586a 100644 --- a/tests/unit/services/test_household_calculation_service.py +++ b/tests/unit/services/test_household_calculation_service.py @@ -220,7 +220,8 @@ class Country: } def prepare_calculation(self, household, policy, **_kwargs): - raise RuntimeError("invalid situation") + msg = "invalid situation" + raise RuntimeError(msg) service = HouseholdCalculationService( cache=_cache(), @@ -381,7 +382,8 @@ def test_failed_cache_write_does_not_invalidate_successful_calculation( class BrokenBackend(InMemoryCacheBackend): def set(self, *_args, **_kwargs): - raise OSError("cache unavailable") + msg = "cache unavailable" + raise OSError(msg) service = HouseholdCalculationService( primary_session_factory=orm_session_factory, diff --git a/tests/unit/services/test_household_mirroring.py b/tests/unit/services/test_household_mirroring.py index f8e26afc9..a33b904df 100644 --- a/tests/unit/services/test_household_mirroring.py +++ b/tests/unit/services/test_household_mirroring.py @@ -64,7 +64,8 @@ def process( ) result = processor(event) if fail_after_destination: - raise OperationalError("update secret", {}, Exception("credential")) + msg = "update secret" + raise OperationalError(msg, {}, Exception("credential")) after_processed_commit(event, result) return result diff --git a/tests/unit/services/test_household_service.py b/tests/unit/services/test_household_service.py index ee383d97d..f5ccf53c4 100644 --- a/tests/unit/services/test_household_service.py +++ b/tests/unit/services/test_household_service.py @@ -149,7 +149,8 @@ def fail_event_flush(session, *args, **kwargs): flush_count += 1 original_flush(session, *args, **kwargs) if flush_count == 2: - raise RuntimeError("event insert failed") + msg = "event insert failed" + raise RuntimeError(msg) monkeypatch.setattr(session_type, "flush", fail_event_flush) service = HouseholdService(orm_session_factory) @@ -182,7 +183,8 @@ def test_exact_event_processing_marks_completion_only_after_processor_success( ) def fail(_event): - raise RuntimeError("destination unavailable") + msg = "destination unavailable" + raise RuntimeError(msg) with pytest.raises(RuntimeError, match="destination unavailable"): service.process_mirror_event( diff --git a/tests/unit/services/test_report_service_owned_sessions.py b/tests/unit/services/test_report_service_owned_sessions.py index e855e7a7f..7bb269ba4 100644 --- a/tests/unit/services/test_report_service_owned_sessions.py +++ b/tests/unit/services/test_report_service_owned_sessions.py @@ -75,7 +75,8 @@ def test_create_rolls_back_all_rows_when_dual_write_fails( monkeypatch, ): def fail_dual_write(*args, **kwargs): - raise RuntimeError("report dual write failed") + msg = "report dual write failed" + raise RuntimeError(msg) monkeypatch.setattr( service, "_ensure_report_output_dual_write_state", fail_dual_write diff --git a/tests/unit/services/test_service_owned_sessions.py b/tests/unit/services/test_service_owned_sessions.py index 98e76f979..52cb9bf73 100644 --- a/tests/unit/services/test_service_owned_sessions.py +++ b/tests/unit/services/test_service_owned_sessions.py @@ -99,7 +99,8 @@ def test_core_services_roll_back_failed_writes( def fail_after_flush(session, *args, **kwargs): original_flush(session, *args, **kwargs) - raise RuntimeError("forced failure") + msg = "forced failure" + raise RuntimeError(msg) monkeypatch.setattr(session_type, "flush", fail_after_flush) service = HouseholdService(orm_session_factory) diff --git a/tests/unit/services/test_simulation_service.py b/tests/unit/services/test_simulation_service.py index 464ae50b9..4520f30f8 100644 --- a/tests/unit/services/test_simulation_service.py +++ b/tests/unit/services/test_simulation_service.py @@ -88,7 +88,8 @@ def test_get_or_create_rolls_back_simulation_when_dual_write_fails( monkeypatch, ): def fail_dual_write(*args, **kwargs): - raise RuntimeError("dual write sync failed") + msg = "dual write sync failed" + raise RuntimeError(msg) monkeypatch.setattr(service, "_ensure_simulation_dual_write_state", fail_dual_write) diff --git a/tests/unit/test_asgi_factory.py b/tests/unit/test_asgi_factory.py index cb2d1b0d4..b309ba2bc 100644 --- a/tests/unit/test_asgi_factory.py +++ b/tests/unit/test_asgi_factory.py @@ -679,7 +679,8 @@ def test_cors_preflight_rejects_a_method_outside_the_public_http_contract(): def test_cors_preflight_does_not_invoke_the_mounted_flask_application(): def failing_wsgi_app(_environ, _start_response): - raise AssertionError("preflight reached Flask") + msg = "preflight reached Flask" + raise AssertionError(msg) client = TestClient(create_asgi_app(failing_wsgi_app)) @@ -761,7 +762,8 @@ def test_cors_exposes_response_request_id_to_browser_clients(): def test_cors_headers_are_present_on_unhandled_native_errors(): def raise_unhandled_error() -> bool: - raise RuntimeError("unavailable") + msg = "unavailable" + raise RuntimeError(msg) dependencies = replace( _stage6_dependencies(), diff --git a/tests/unit/test_country.py b/tests/unit/test_country.py index 10e1d57e0..ea2c753fe 100644 --- a/tests/unit/test_country.py +++ b/tests/unit/test_country.py @@ -152,7 +152,8 @@ class BrokenSimulation: __slots__ = () def calculate(self, variable_name, period): - raise RuntimeError("calculation failed") + msg = "calculation failed" + raise RuntimeError(msg) def get_population(self, entity_plural): return SimpleNamespace(count=5) diff --git a/tests/unit/test_spm_settings.py b/tests/unit/test_spm_settings.py index fa8fa1788..dc4dd64cf 100644 --- a/tests/unit/test_spm_settings.py +++ b/tests/unit/test_spm_settings.py @@ -28,14 +28,17 @@ def certified_bundle(monkeypatch): def entry(year, *, scenario, as_of): if scenario not in {"baseline", "alternative"}: - raise ValueError("Unknown forecast scenario") + msg = "Unknown forecast scenario" + raise ValueError(msg) if as_of is not None and date.fromisoformat(as_of) < date(2026, 1, 1): - raise ValueError("Forecast information date follows requested as_of") + msg = "Forecast information date follows requested as_of" + raise ValueError(msg) return {} def geography_factor(year, tenure, *, geoid, **kwargs): if geoid != "31080": - raise ValueError("Forecast geography unavailable") + msg = "Forecast geography unavailable" + raise ValueError(msg) return {} forecast = SimpleNamespace( @@ -70,7 +73,8 @@ def test_public_settings_schema_forbids_extra_fields(): @pytest.mark.parametrize("country_id", ["uk", "ca", "ng", "il"]) def test_other_countries_omit_spm_without_reading_us_bundle(monkeypatch, country_id): def must_not_load(): - raise AssertionError("Non-US request must not read SPM configuration") + msg = "Non-US request must not read SPM configuration" + raise AssertionError(msg) monkeypatch.setattr(spm, "_current_bundle", must_not_load) assert spm.normalize_spm_selection(country_id, None) is None @@ -209,7 +213,8 @@ def test_mismatched_installed_artifact_cannot_enable_metadata( ): def mismatched_artifact(expected): assert expected == ARTIFACT_HASH - raise ValueError("Forecast content SHA256 mismatch") + msg = "Forecast content SHA256 mismatch" + raise ValueError(msg) monkeypatch.setattr(spm, "_selected_forecast", mismatched_artifact) with pytest.raises(spm.SPMValidationError) as caught: @@ -243,7 +248,8 @@ def test_selected_area_must_exist_in_artifact(certified_bundle): def test_area_available_in_later_year_can_be_selected(certified_bundle, monkeypatch): def geography_factor(year, tenure, **kwargs): if year < 2025: - raise ValueError("Area unavailable before the boundary changed") + msg = "Area unavailable before the boundary changed" + raise ValueError(msg) return {} forecast = SimpleNamespace( @@ -263,7 +269,8 @@ def geography_factor(year, tenure, **kwargs): @pytest.mark.parametrize("wrapped", [False, True]) def test_missing_forecast_year_message_alone_does_not_reclassify_errors(wrapped): try: - raise ValueError("Forecast has no entry for 2036") + msg = "Forecast has no entry for 2036" + raise ValueError(msg) except ValueError as error: if wrapped: outer = RuntimeError("An unrelated dependency failed") diff --git a/tests/unit/test_warmup.py b/tests/unit/test_warmup.py index 8bf1f9c2f..8048cf367 100644 --- a/tests/unit/test_warmup.py +++ b/tests/unit/test_warmup.py @@ -71,7 +71,8 @@ def calculate(self, household, reform): def test_run_startup_warmup_swallows_calculate_errors(monkeypatch): class BoomCountry: def calculate(self, household, reform): - raise RuntimeError("boom") + msg = "boom" + raise RuntimeError(msg) _inject_fake_countries(monkeypatch, {"us": BoomCountry()}) monkeypatch.setenv("POLICYENGINE_API_WARMUP_COUNTRIES", "us") diff --git a/tests/unit/v2/test_catalog_initialization.py b/tests/unit/v2/test_catalog_initialization.py index 6f7dc38ae..e77681fcd 100644 --- a/tests/unit/v2/test_catalog_initialization.py +++ b/tests/unit/v2/test_catalog_initialization.py @@ -83,10 +83,12 @@ def publish(selected_engine, catalog): def test_extraction_failure_opens_no_engine() -> None: def fail_extraction(): - raise RuntimeError("extraction failed") + msg = "extraction failed" + raise RuntimeError(msg) def reject_engine(_settings): - raise AssertionError("engine must not be built") + msg = "engine must not be built" + raise AssertionError(msg) with pytest.raises(RuntimeError, match="extraction failed"): initialization.initialize_catalog( @@ -107,7 +109,8 @@ def test_main_emits_typed_success_and_redacted_unexpected_failure( assert success.err == "" def fail(): - raise RuntimeError("postgresql://user:do-not-print@secret-host/database") + msg = "postgresql://user:do-not-print@secret-host/database" + raise RuntimeError(msg) monkeypatch.setattr(initialization, "initialize_catalog", fail) assert initialization.main() == 1 diff --git a/tests/unit/v2/test_household_migration_qualification.py b/tests/unit/v2/test_household_migration_qualification.py index 0b012f254..38199cdc9 100644 --- a/tests/unit/v2/test_household_migration_qualification.py +++ b/tests/unit/v2/test_household_migration_qualification.py @@ -159,7 +159,8 @@ def test_main_redacts_unexpected_errors( capsys: pytest.CaptureFixture[str], ) -> None: def fail() -> None: - raise RuntimeError("postgresql://user:secret@private-host/database") + msg = "postgresql://user:secret@private-host/database" + raise RuntimeError(msg) monkeypatch.setattr(qualification, "qualify_household_migration_if_pending", fail) diff --git a/tests/unit/v2/test_household_routes.py b/tests/unit/v2/test_household_routes.py index 64c0e1343..ec3b21bfb 100644 --- a/tests/unit/v2/test_household_routes.py +++ b/tests/unit/v2/test_household_routes.py @@ -443,7 +443,8 @@ def test_browser_preflight_and_cross_origin_errors_use_application_cors() -> Non def test_native_household_routes_do_not_use_cloud_sql_or_flask(monkeypatch) -> None: def reject_cloud_sql(): - raise AssertionError("Cloud SQL must not be selected") + msg = "Cloud SQL must not be selected" + raise AssertionError(msg) monkeypatch.setattr( "policyengine_api.data.orm.get_v1_session_factory", diff --git a/tests/unit/v2/test_policy_legacy_translation.py b/tests/unit/v2/test_policy_legacy_translation.py index 7f5d3f236..fbdec10e3 100644 --- a/tests/unit/v2/test_policy_legacy_translation.py +++ b/tests/unit/v2/test_policy_legacy_translation.py @@ -190,7 +190,8 @@ def serialize_stored_json(value): ) def unavailable_mirror(): - raise RuntimeError("controlled destination failure") + msg = "controlled destination failure" + raise RuntimeError(msg) with pytest.raises(PolicyMirrorUnavailableError): mirror_policy_after_commit( diff --git a/tests/unit/v2/test_policy_migration_qualification.py b/tests/unit/v2/test_policy_migration_qualification.py index 8fa8b8506..54a711ef4 100644 --- a/tests/unit/v2/test_policy_migration_qualification.py +++ b/tests/unit/v2/test_policy_migration_qualification.py @@ -165,7 +165,8 @@ def test_main_redacts_unexpected_errors( capsys: pytest.CaptureFixture[str], ) -> None: def fail() -> None: - raise RuntimeError("postgresql://user:secret@private-host/database") + msg = "postgresql://user:secret@private-host/database" + raise RuntimeError(msg) monkeypatch.setattr(qualification, "qualify_policy_migration_if_pending", fail) diff --git a/tests/unit/v2/test_policy_routes.py b/tests/unit/v2/test_policy_routes.py index e8385d593..4d2c8db50 100644 --- a/tests/unit/v2/test_policy_routes.py +++ b/tests/unit/v2/test_policy_routes.py @@ -414,7 +414,8 @@ def test_native_policy_routes_do_not_use_cloud_sql_or_flask( monkeypatch: pytest.MonkeyPatch, ) -> None: def reject_cloud_sql(): - raise AssertionError("Cloud SQL must not be selected") + msg = "Cloud SQL must not be selected" + raise AssertionError(msg) monkeypatch.setattr( "policyengine_api.data.orm.get_v1_session_factory", diff --git a/tests/unit/v2/test_settings.py b/tests/unit/v2/test_settings.py index 9baccde13..38085e680 100644 --- a/tests/unit/v2/test_settings.py +++ b/tests/unit/v2/test_settings.py @@ -125,7 +125,8 @@ def test_runtime_rejects_ambiguous_or_invalid_secret_sources( def test_runtime_secret_resolution_error_hides_internal_details() -> None: def fail_to_load(resource: str) -> str: - raise RuntimeError(f"private failure for {resource}") + msg = f"private failure for {resource}" + raise RuntimeError(msg) with pytest.raises(V2ConfigurationError) as raised: load_v2_runtime_database_settings( diff --git a/tests/unit/v2/test_stage12_comparison_models.py b/tests/unit/v2/test_stage12_comparison_models.py index 91c7fd1e9..3637bee7a 100644 --- a/tests/unit/v2/test_stage12_comparison_models.py +++ b/tests/unit/v2/test_stage12_comparison_models.py @@ -141,7 +141,8 @@ def test_parent_identity_is_unique_for_worker_and_manifest() -> None: except IntegrityError: session.rollback() else: - raise AssertionError("duplicate parent identity was accepted") + msg = "duplicate parent identity was accepted" + raise AssertionError(msg) engine.dispose() @@ -157,7 +158,8 @@ def test_child_identity_is_unique_within_a_parent() -> None: except IntegrityError: session.rollback() else: - raise AssertionError("duplicate child identity was accepted") + msg = "duplicate child identity was accepted" + raise AssertionError(msg) engine.dispose() @@ -204,7 +206,8 @@ def test_database_rejects_success_without_complete_output_metadata() -> None: except IntegrityError: session.rollback() else: - raise AssertionError("successful report without output was accepted") + msg = "successful report without output was accepted" + raise AssertionError(msg) engine.dispose() @@ -217,7 +220,8 @@ def test_database_rejects_failed_state_without_bounded_error_code() -> None: except IntegrityError: session.rollback() else: - raise AssertionError("failed report without error code was accepted") + msg = "failed report without error code was accepted" + raise AssertionError(msg) engine.dispose() diff --git a/tests/unit/v2/test_user_household_routes.py b/tests/unit/v2/test_user_household_routes.py index cbd179b7c..f1ab68f90 100644 --- a/tests/unit/v2/test_user_household_routes.py +++ b/tests/unit/v2/test_user_household_routes.py @@ -406,7 +406,8 @@ def test_patch_preflight_and_native_routes_avoid_cloud_sql_and_flask( monkeypatch, ) -> None: def reject_cloud_sql(): - raise AssertionError("Cloud SQL must not be selected") + msg = "Cloud SQL must not be selected" + raise AssertionError(msg) monkeypatch.setattr( "policyengine_api.data.orm.get_v1_session_factory", diff --git a/tests/unit/v2/test_user_policy_routes.py b/tests/unit/v2/test_user_policy_routes.py index 3bd62949b..d884998a6 100644 --- a/tests/unit/v2/test_user_policy_routes.py +++ b/tests/unit/v2/test_user_policy_routes.py @@ -411,7 +411,8 @@ def test_native_association_routes_do_not_use_cloud_sql_or_flask( monkeypatch: pytest.MonkeyPatch, ) -> None: def reject_cloud_sql(): - raise AssertionError("Cloud SQL must not be selected") + msg = "Cloud SQL must not be selected" + raise AssertionError(msg) monkeypatch.setattr( "policyengine_api.data.orm.get_v1_session_factory", From 1c82aadc6f22af36ce5cee33a978b1192105ac3c Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 13 Oct 2026 17:51:16 +0400 Subject: [PATCH 02/12] Move SPM and worker validation raises out of try blocks The checks that get_spm_capability, validate_worker_spm, validate_worker_result, normalize_spm_selection and calculation_spm_receipt wrapped in try blocks now live in helpers, so each handler still receives the same exceptions (TRY301). A failed isinstance check raises TypeError where every handler of it already treats TypeError and ValueError alike, or nothing catches it (TRY004). It stays ValueError where calculation_spm_receipt certifies only ValueError or a Pydantic validator reports it, and RuntimeError where the bundle tests expect it, each with an inline reason. Co-Authored-By: Claude Opus 5.5 --- policyengine_api/constants.py | 2 +- .../libs/simulation_entrypoint.py | 103 +++++++----- policyengine_api/query_parameters.py | 2 +- .../runtime_cache/reform_impacts.py | 2 +- policyengine_api/specification.py | 2 +- policyengine_api/spm.py | 86 ++++++---- policyengine_api/worker_spm.py | 159 ++++++++++-------- policyengine_api/worker_spm_release.py | 2 +- 8 files changed, 206 insertions(+), 152 deletions(-) diff --git a/policyengine_api/constants.py b/policyengine_api/constants.py index 65ef84081..e12fde045 100644 --- a/policyengine_api/constants.py +++ b/policyengine_api/constants.py @@ -69,7 +69,7 @@ def _load_policyengine_bundle() -> dict | None: raise RuntimeError(msg) from exc if not isinstance(manifest, dict): msg = "PolicyEngine .py bundle manifest must be a JSON object." - raise RuntimeError(msg) + raise RuntimeError(msg) # noqa: TRY004 # tests expect RuntimeError for a non-object manifest return manifest diff --git a/policyengine_api/libs/simulation_entrypoint.py b/policyengine_api/libs/simulation_entrypoint.py index 1691b3a58..ebf115511 100644 --- a/policyengine_api/libs/simulation_entrypoint.py +++ b/policyengine_api/libs/simulation_entrypoint.py @@ -71,6 +71,60 @@ def _attach_current_request_id(request: httpx.Request) -> None: request.headers[OBSERVABILITY_ID_HEADER] = observability_id +def _worker_bundle_version( + versions: object, + country: str, + version: str | None, + policyengine_version: str | None, +) -> str: + """Return the bundle version the worker's ``/versions`` routes resolve. + + The routes for ``policyengine_version``, or else ``country``/``version``, + name the bundle whose SPM capability applies. Raise KeyError for a missing + route, TypeError when the document or route map is not an object, and + ValueError for any other malformed or ambiguous route. + """ + if not isinstance(versions, dict): + msg = "Invalid worker versions response" + raise TypeError(msg) + route_kind = "policyengine" if policyengine_version else country + route_version = policyengine_version or version + routes = versions[route_kind] + if not isinstance(routes, dict): + msg = "Invalid worker route map" + raise TypeError(msg) + if route_version is None or route_version == "latest": + route_version = routes["latest"] + if not isinstance(route_version, str) or not route_version: + msg = "Invalid worker route version" + raise ValueError(msg) + app_name = routes[route_version] + if not isinstance(app_name, str) or not app_name: + msg = "Invalid worker application identity" + raise ValueError(msg) + # Registry bundles and their capabilities are keyed by wrapper + # version. Country-version routes identify only the deployed app; + # resolve its unique bundle instead of treating the app as a key. + if policyengine_version: + return route_version + bundle_routes = versions["policyengine"] + if not isinstance(bundle_routes, dict) or any( + not isinstance(bundle, str) or not bundle or not isinstance(app, str) or not app + for bundle, app in bundle_routes.items() + ): + msg = "Invalid worker bundle route map" + raise ValueError(msg) + candidates: list[str] = [ + bundle + for bundle, app in bundle_routes.items() + if bundle != "latest" and app == app_name + ] + if len(candidates) != 1: + msg = "Worker route has no unambiguous bundle identity" + raise ValueError(msg) + return candidates[0] + + @dataclass class ModalSimulationExecution: """Represents a Modal simulation job execution.""" @@ -331,49 +385,12 @@ def get_spm_capability( response = self.client.get(f"{self.base_url}/versions") response.raise_for_status() versions = response.json() - if not isinstance(versions, dict): - msg = "Invalid worker versions response" - raise ValueError(msg) - route_kind = "policyengine" if policyengine_version else country - route_version = policyengine_version or version - routes = versions[route_kind] - if not isinstance(routes, dict): - msg = "Invalid worker route map" - raise ValueError(msg) - if route_version is None or route_version == "latest": - route_version = routes["latest"] - if not isinstance(route_version, str) or not route_version: - msg = "Invalid worker route version" - raise ValueError(msg) - app_name = routes[route_version] - if not isinstance(app_name, str) or not app_name: - msg = "Invalid worker application identity" - raise ValueError(msg) - # Registry bundles and their capabilities are keyed by wrapper - # version. Country-version routes identify only the deployed app; - # resolve its unique bundle instead of treating the app as a key. - if policyengine_version: - bundle_version = route_version - else: - bundle_routes = versions["policyengine"] - if not isinstance(bundle_routes, dict) or any( - not isinstance(bundle, str) - or not bundle - or not isinstance(app, str) - or not app - for bundle, app in bundle_routes.items() - ): - msg = "Invalid worker bundle route map" - raise ValueError(msg) - candidates = [ - bundle - for bundle, app in bundle_routes.items() - if bundle != "latest" and app == app_name - ] - if len(candidates) != 1: - msg = "Worker route has no unambiguous bundle identity" - raise ValueError(msg) - bundle_version = candidates[0] + bundle_version = _worker_bundle_version( + versions, + country, + version, + policyengine_version, + ) capabilities = versions.get("spm_capabilities") return ( capabilities.get(bundle_version) diff --git a/policyengine_api/query_parameters.py b/policyengine_api/query_parameters.py index 4fcceb11d..d4d773e58 100644 --- a/policyengine_api/query_parameters.py +++ b/policyengine_api/query_parameters.py @@ -121,7 +121,7 @@ def parse_spm_query_value(value: object) -> dict[str, object] | SPMSelection: value = json.loads(value, object_pairs_hook=_unique_json_object) if not isinstance(value, (dict, SPMSelection)): msg = "spm must be a JSON object" - raise ValueError(msg) + raise ValueError(msg) # noqa: TRY004 # Pydantic reports ValueError as a validation error; TypeError would escape as a 500 return value diff --git a/policyengine_api/runtime_cache/reform_impacts.py b/policyengine_api/runtime_cache/reform_impacts.py index 50e0c776e..a0ee93751 100644 --- a/policyengine_api/runtime_cache/reform_impacts.py +++ b/policyengine_api/runtime_cache/reform_impacts.py @@ -97,7 +97,7 @@ def _datetime_from_wire(value: object) -> datetime | None: return None if not isinstance(value, str): msg = "invalid cached datetime" - raise ValueError(msg) + raise TypeError(msg) parsed = datetime.fromisoformat(value) if parsed.tzinfo is not None: parsed = parsed.astimezone(UTC).replace(tzinfo=None) diff --git a/policyengine_api/specification.py b/policyengine_api/specification.py index a0c411f85..7d25e31d0 100644 --- a/policyengine_api/specification.py +++ b/policyengine_api/specification.py @@ -94,7 +94,7 @@ def load_specification( document = yaml.safe_load(path.read_text(encoding="utf-8")) if not isinstance(document, dict) or not isinstance(document.get("info"), dict): msg = "OpenAPI specification must contain an info object" - raise ValueError(msg) + raise TypeError(msg) document["info"]["version"] = version schemas = document.get("components", {}).get("schemas", {}) for name, model in { diff --git a/policyengine_api/spm.py b/policyengine_api/spm.py index 4c6d697b0..382b1c0fd 100644 --- a/policyengine_api/spm.py +++ b/policyengine_api/spm.py @@ -281,18 +281,18 @@ def build_spm_calculation_provenance( source_years = receipt.get("years") if not isinstance(source_years, Mapping): msg = "Country SPM receipt requires a years mapping" - raise ValueError(msg) + raise ValueError(msg) # noqa: TRY004 # calculation_spm_receipt maps only ValueError to SPM_CONFIGURATION_UNAVAILABLE years: list[str] = [] for year in source_years: if not isinstance(year, str): msg = "Country SPM receipt years must be strings" - raise ValueError(msg) + raise ValueError(msg) # noqa: TRY004 # calculation_spm_receipt maps only ValueError to SPM_CONFIGURATION_UNAVAILABLE years.append(year) source_versions = receipt.get("runtime_versions") if not isinstance(source_versions, Mapping): msg = "Country SPM receipt requires runtime_versions" - raise ValueError(msg) + raise ValueError(msg) # noqa: TRY004 # calculation_spm_receipt maps only ValueError to SPM_CONFIGURATION_UNAVAILABLE runtime_versions = SPMRuntimeVersions.model_validate(source_versions) return build_spm_provenance( forecast_id=_required_string(receipt, "forecast_id"), @@ -498,6 +498,36 @@ def _selected_forecast(expected_sha256: str) -> SPMForecast: return load_forecast(expected_sha256=expected_sha256) +def _certified_bundle_spm(configured: dict) -> tuple[SPMSelection, SPMForecast]: + """Return the bundle's pinned SPM defaults and the forecast artifact they name. + + Raise ValueError when the bundle pins no artifact hash or scenario, or the + installed US model lacks the canonical constructor. Importing the model or + loading the forecast can also raise AttributeError, ImportError, TypeError + or OSError. + """ + defaults = SPMSelection.model_validate(configured) + if defaults.forecast_content_sha256 is None or defaults.scenario is None: + msg = "The bundle must pin the SPM artifact hash and scenario" + raise ValueError(msg) + simulation_type = importlib.import_module("policyengine_us").Simulation + if not simulation_supports_spm(simulation_type): + msg = "The installed US model does not support canonical SPM" + raise ValueError(msg) + return defaults, _selected_forecast(defaults.forecast_content_sha256) + + +def _validated_spm_selection(values: dict, forecast: SPMForecast) -> SPMSelection: + """Validate merged settings against the artifact; raise ValueError if invalid.""" + resolved = SPMSelection.model_validate(values) + # The artifact validates scenario/information date without household input. + forecast.entry(forecast.years[0], scenario=resolved.scenario, as_of=resolved.as_of) + if resolved.county_vintage != "2020": + msg = "Unsupported county vintage: use 2020" + raise ValueError(msg) + return resolved + + def normalize_spm_selection( country_id: str, selection: object, @@ -550,15 +580,7 @@ def normalize_spm_selection( ) try: - defaults = SPMSelection.model_validate(configured) - if defaults.forecast_content_sha256 is None or defaults.scenario is None: - msg = "The bundle must pin the SPM artifact hash and scenario" - raise ValueError(msg) - simulation_type = importlib.import_module("policyengine_us").Simulation - if not simulation_supports_spm(simulation_type): - msg = "The installed US model does not support canonical SPM" - raise ValueError(msg) - forecast = _selected_forecast(defaults.forecast_content_sha256) + defaults, forecast = _certified_bundle_spm(configured) except (AttributeError, ImportError, ValueError, TypeError, OSError) as error: # A country package installed without its Simulation raises AttributeError # here. The capability probe already reads that as "no canonical model"; @@ -594,14 +616,7 @@ def normalize_spm_selection( values["forecast_content_sha256"] = defaults.forecast_content_sha256 values["scenario"] = chosen.scenario or defaults.scenario try: - resolved = SPMSelection.model_validate(values) - # The artifact validates scenario/information date without household input. - forecast.entry( - forecast.years[0], scenario=resolved.scenario, as_of=resolved.as_of - ) - if resolved.county_vintage != "2020": - msg = "Unsupported county vintage: use 2020" - raise ValueError(msg) + resolved = _validated_spm_selection(values, forecast) except ValueError as error: msg = "SPM_SETTINGS_INVALID" raise SPMValidationError(msg, error_message(error)) from error @@ -660,19 +675,7 @@ def calculation_spm_receipt(simulation: Simulation) -> dict: if not hasattr(simulation, "spm_config"): return {} try: - resolved = resolved_spm_settings(simulation.spm_config) - if resolved is None: - msg = "Country SPM configuration is incomplete" - raise ValueError(msg) - source = simulation.spm_provenance() - if not isinstance(source, Mapping): - msg = "Country SPM receipt must be an object" - raise ValueError(msg) - calculation = build_spm_calculation_provenance( - SPMSelection.model_validate(resolved), - source, - ) - return {"spm_provenance": calculation.model_dump(mode="json", by_alias=True)} + return _country_spm_receipt(simulation) except (ValidationError, ValueError) as error: msg = "SPM_CONFIGURATION_UNAVAILABLE" raise SPMValidationError( @@ -680,3 +683,20 @@ def calculation_spm_receipt(simulation: Simulation) -> dict: "The installed country model reported an SPM receipt this API cannot " f"certify: {error_message(error)}", ) from error + + +def _country_spm_receipt(simulation: Simulation) -> dict: + """Reduce the country's receipt; raise ValueError if it cannot be certified.""" + resolved = resolved_spm_settings(simulation.spm_config) + if resolved is None: + msg = "Country SPM configuration is incomplete" + raise ValueError(msg) + source = simulation.spm_provenance() + if not isinstance(source, Mapping): + msg = "Country SPM receipt must be an object" + raise ValueError(msg) # noqa: TRY004 # calculation_spm_receipt maps only ValueError to SPM_CONFIGURATION_UNAVAILABLE + calculation = build_spm_calculation_provenance( + SPMSelection.model_validate(resolved), + source, + ) + return {"spm_provenance": calculation.model_dump(mode="json", by_alias=True)} diff --git a/policyengine_api/worker_spm.py b/policyengine_api/worker_spm.py index d83587911..b14577e6c 100644 --- a/policyengine_api/worker_spm.py +++ b/policyengine_api/worker_spm.py @@ -58,28 +58,33 @@ def validate_worker_spm( country_id, model_version, policyengine_version=policyengine_version ) try: - if not isinstance(capability, dict) or set(capability) != { - "contract_version", - "defaults", - }: - msg = "The selected worker has no certified canonical SPM capability" - raise ValueError(msg) - if capability["contract_version"] != "canonical-spm-v1": - msg = "The selected worker uses an unsupported SPM contract" - raise ValueError(msg) - defaults = SPMSelection.model_validate(capability["defaults"]) - if not defaults.scenario or not defaults.forecast_content_sha256: - msg = "The worker capability must pin SPM artifact hash and scenario" - raise ValueError(msg) - if defaults.forecast_content_sha256 != resolved["forecast_content_sha256"]: - msg = "The API and worker SPM artifact identities differ" - raise ValueError(msg) + _check_worker_capability(capability, resolved) except ValueError as exc: msg = "SPM_CONFIGURATION_UNAVAILABLE" raise SPMValidationError(msg, error_message(exc)) from exc return resolved +def _check_worker_capability(capability: object, resolved: dict) -> None: + """Raise ValueError unless the worker certifies the API's SPM artifact.""" + if not isinstance(capability, dict) or set(capability) != { + "contract_version", + "defaults", + }: + msg = "The selected worker has no certified canonical SPM capability" + raise ValueError(msg) + if capability["contract_version"] != "canonical-spm-v1": + msg = "The selected worker uses an unsupported SPM contract" + raise ValueError(msg) + defaults = SPMSelection.model_validate(capability["defaults"]) + if not defaults.scenario or not defaults.forecast_content_sha256: + msg = "The worker capability must pin SPM artifact hash and scenario" + raise ValueError(msg) + if defaults.forecast_content_sha256 != resolved["forecast_content_sha256"]: + msg = "The API and worker SPM artifact identities differ" + raise ValueError(msg) + + def validate_worker_result( result: dict, selection: dict | None, @@ -98,66 +103,78 @@ def validate_worker_result( if selection is None: return try: - if not isinstance(result, dict): - msg = "Worker SPM result must be an object" + _check_worker_result( + result, + selection, + expected_year=expected_year, + expected_years=expected_years, + ) + except (ValueError, TypeError, KeyError) as exc: + msg = "SPM_CONFIGURATION_UNAVAILABLE" + raise SPMValidationError(msg, error_message(exc)) from exc + + +def _check_worker_result( + result: dict, + selection: dict, + *, + expected_year: str | int | None, + expected_years: list[str] | None, +) -> None: + """Raise TypeError, ValueError or KeyError unless the result's receipts match.""" + if not isinstance(result, dict): + msg = "Worker SPM result must be an object" + raise TypeError(msg) + if result.get("kind") == "budgetWindow": + if expected_year is not None: + msg = "Expected an annual SPM result" raise ValueError(msg) - if result.get("kind") == "budgetWindow": - if expected_year is not None: - msg = "Expected an annual SPM result" - raise ValueError(msg) - rows = result.get("annualImpacts") - size = result.get("windowSize") - if type(size) is not int or size < 1: - msg = "Budget window size must be a positive integer" - raise ValueError(msg) - if not isinstance(rows, list) or not all( - isinstance(row, dict) for row in rows - ): - msg = "Budget window annual impacts must be objects" - raise ValueError(msg) - if len(rows) != size: - msg = "Budget window has incomplete SPM receipts" - raise ValueError(msg) - actual = [str(row.get("year")) for row in rows] - if any( - len(year) != 4 or not year.isascii() or not year.isdigit() - for year in actual - ): - msg = "Budget window annual impacts require calendar years" - raise ValueError(msg) - if len(set(actual)) != len(actual): + rows = result.get("annualImpacts") + size = result.get("windowSize") + if type(size) is not int or size < 1: + msg = "Budget window size must be a positive integer" + raise ValueError(msg) + if not isinstance(rows, list) or not all(isinstance(row, dict) for row in rows): + msg = "Budget window annual impacts must be objects" + raise ValueError(msg) + if len(rows) != size: + msg = "Budget window has incomplete SPM receipts" + raise ValueError(msg) + actual = [str(row.get("year")) for row in rows] + if any( + len(year) != 4 or not year.isascii() or not year.isdigit() + for year in actual + ): + msg = "Budget window annual impacts require calendar years" + raise ValueError(msg) + if len(set(actual)) != len(actual): + msg = "Budget window SPM result years differ from request" + raise ValueError(msg) + if expected_years is not None: + wanted = [str(year) for year in expected_years] + if actual != wanted: msg = "Budget window SPM result years differ from request" raise ValueError(msg) - if expected_years is not None: - wanted = [str(year) for year in expected_years] - if actual != wanted: - msg = "Budget window SPM result years differ from request" - raise ValueError(msg) - for row in rows: - validate_worker_result(row, selection, expected_year=row.get("year")) - return - if expected_years is not None: - msg = "Expected a budget window SPM result" + for row in rows: + validate_worker_result(row, selection, expected_year=row.get("year")) + return + if expected_years is not None: + msg = "Expected a budget window SPM result" + raise ValueError(msg) + if "spm_config" in result: + msg = "Worker result must not include legacy spm_config" + raise ValueError(msg) + comparison = SPMComparisonProvenance.model_validate(result.get("spm_provenance")) + for side in ("baseline", "reform"): + execution = getattr(comparison, side) + receipt = validate_spm_calculation_provenance(execution.receipt) + resolved = resolved_spm_configuration(receipt).model_dump(mode="json") + if resolved != selection: + msg = "Worker result SPM settings differ from the request" raise ValueError(msg) - if "spm_config" in result: - msg = "Worker result must not include legacy spm_config" + if expected_year is not None and str(expected_year) not in receipt.years: + msg = "Worker SPM receipt does not cover requested year" raise ValueError(msg) - comparison = SPMComparisonProvenance.model_validate( - result.get("spm_provenance") - ) - for side in ("baseline", "reform"): - execution = getattr(comparison, side) - receipt = validate_spm_calculation_provenance(execution.receipt) - resolved = resolved_spm_configuration(receipt).model_dump(mode="json") - if resolved != selection: - msg = "Worker result SPM settings differ from the request" - raise ValueError(msg) - if expected_year is not None and str(expected_year) not in receipt.years: - msg = "Worker SPM receipt does not cover requested year" - raise ValueError(msg) - except (ValueError, TypeError, KeyError) as exc: - msg = "SPM_CONFIGURATION_UNAVAILABLE" - raise SPMValidationError(msg, error_message(exc)) from exc def raise_worker_spm_error(response: httpx.Response) -> None: diff --git a/policyengine_api/worker_spm_release.py b/policyengine_api/worker_spm_release.py index 6389fefc6..f83c9ebf2 100644 --- a/policyengine_api/worker_spm_release.py +++ b/policyengine_api/worker_spm_release.py @@ -48,7 +48,7 @@ def _bundle_package_versions() -> dict[str, str]: packages = bundle.get("packages") if isinstance(bundle, dict) else None if not isinstance(packages, dict): msg = "The installed PolicyEngine bundle manifest has no packages" - raise ValueError(msg) + raise TypeError(msg) versions: dict[str, str] = {} for package_name in REQUIRED_BUNDLE_PACKAGES: From f4fb368eb9bb641e0e6809916000cede82a4389d Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 13 Oct 2026 17:51:23 +0400 Subject: [PATCH 03/12] Give the v1 services typed exceptions and plain re-raises The household, policy, report output and simulation services raise InvalidHouseholdIdError, InvalidPolicyIdError, InvalidReportOutputIdError and InvalidSimulationIdError instead of a bare Exception (TRY002). Each subclasses Exception directly, so every existing except Exception and the Flask generic error handler still catch them, with the same message. The budget-window method moves its stored-failure replay and its submission-claim loop into helpers, so their raises leave the logging try block (TRY301), and the reform-impact persistence handlers re-raise with a bare raise (TRY201). Budget-window SPM output whose annual impacts are not a list of objects raises TypeError, which its only handler already treats like ValueError (TRY004). Co-Authored-By: Claude Opus 5.5 --- policyengine_api/services/economy_service.py | 107 +++++++++++------- .../services/household_service.py | 8 +- policyengine_api/services/policy_service.py | 10 +- .../services/report_output_service.py | 16 ++- .../services/simulation_service.py | 8 +- 5 files changed, 96 insertions(+), 53 deletions(-) diff --git a/policyengine_api/services/economy_service.py b/policyengine_api/services/economy_service.py index 1a7f0b377..d1fda67ce 100644 --- a/policyengine_api/services/economy_service.py +++ b/policyengine_api/services/economy_service.py @@ -57,7 +57,10 @@ CachedReformImpact, ReformImpactStartClaim, ) -from policyengine_api.services.budget_window_cache import BudgetWindowCache +from policyengine_api.services.budget_window_cache import ( + BudgetWindowCache, + BudgetWindowCacheState, +) from policyengine_api.services.policy_service import PolicyService from policyengine_api.services.reform_impacts_service import ( ReformImpactHandoffError, @@ -469,19 +472,7 @@ def get_budget_window_economic_impact( ) if cached_state is not None and cached_state.status == "failed": - if cached_state.failure_type == "spm_validation": - cached_error = cached_state.error or {} - raise SPMValidationError( - code=str(cached_error.get("code", "SPM_VALIDATION_ERROR")), - message=str( - cached_error.get( - "message", "Stored budget-window validation failed" - ) - ), - ) - return BudgetWindowEconomicImpactResult.model_validate( - cached_state.error - ).model_copy(update={"cache_status": "failure-hit"}) + return self._replay_budget_window_failure(cached_state) if cached_state is not None and cached_state.status == "completed": cached_result = cached_state.result or {} @@ -535,29 +526,12 @@ def get_budget_window_economic_impact( observability_id_candidate = resolve_observability_id( incoming_observability_id() ) - owns_claim = False - claimed_state = None - for _attempt in range(BUDGET_WINDOW_CLAIM_ATTEMPTS): - owns_claim = self._budget_window_cache.claim_batch_start( - cache_key, - claim_token, - observability_id_candidate, - ) - if owns_claim: - setup_options.observability_id = start_observability_id( - observability_id_candidate - ) - break - claimed_state = self._budget_window_cache.get_state(cache_key) - if claimed_state is not None: - self._restore_budget_window_observability_id( - claimed_state.observability_id, - setup_options=setup_options, - ) - break - else: - msg = "budget-window submission ownership changed repeatedly" - raise CacheCoordinationError(msg) + owns_claim = self._claim_budget_window_start( + cache_key, + claim_token, + observability_id_candidate, + setup_options=setup_options, + ) if owns_claim: cache_status = "miss" @@ -620,6 +594,59 @@ def get_budget_window_economic_impact( ) raise + @staticmethod + def _replay_budget_window_failure( + cached_state: BudgetWindowCacheState, + ) -> BudgetWindowEconomicImpactResult: + """Replay a stored failure: raise its typed SPM error or return its result.""" + if cached_state.failure_type == "spm_validation": + cached_error = cached_state.error or {} + raise SPMValidationError( + code=str(cached_error.get("code", "SPM_VALIDATION_ERROR")), + message=str( + cached_error.get( + "message", "Stored budget-window validation failed" + ) + ), + ) + return BudgetWindowEconomicImpactResult.model_validate( + cached_state.error + ).model_copy(update={"cache_status": "failure-hit"}) + + def _claim_budget_window_start( + self, + cache_key: str, + claim_token: str, + observability_id_candidate: str, + *, + setup_options: EconomicImpactSetupOptions, + ) -> bool: + """Claim this report's batch submission, or adopt another request's state. + + Return True when this request now owns the submission and False once another + request's stored state is found. Raise CacheCoordinationError if ownership + keeps changing. + """ + for _attempt in range(BUDGET_WINDOW_CLAIM_ATTEMPTS): + if self._budget_window_cache.claim_batch_start( + cache_key, + claim_token, + observability_id_candidate, + ): + setup_options.observability_id = start_observability_id( + observability_id_candidate + ) + return True + claimed_state = self._budget_window_cache.get_state(cache_key) + if claimed_state is not None: + self._restore_budget_window_observability_id( + claimed_state.observability_id, + setup_options=setup_options, + ) + return False + msg = "budget-window submission ownership changed repeatedly" + raise CacheCoordinationError(msg) + def _build_budget_window_cache_key( self, setup_options: EconomicImpactSetupOptions, @@ -1770,7 +1797,7 @@ def _set_reform_impact_computing( **setup_options.model_dump(), } ) - raise e + raise @observability_runtime.span( ECONOMY_ANNUAL_STAGES.name(Stage.ECONOMY_PERSIST_COMPLETED) @@ -1801,7 +1828,7 @@ def _set_reform_impact_complete( **setup_options.model_dump(), } ) - raise e + raise @observability_runtime.span( ECONOMY_ANNUAL_STAGES.name(Stage.ECONOMY_PERSIST_FAILED) @@ -1834,7 +1861,7 @@ def _set_reform_impact_error( **setup_options.model_dump(), } ) - raise e + raise def _create_submission_claim_id(self) -> str: """Create an opaque token for one submission ownership claim.""" diff --git a/policyengine_api/services/household_service.py b/policyengine_api/services/household_service.py index 9dcd50f60..df28f4353 100644 --- a/policyengine_api/services/household_service.py +++ b/policyengine_api/services/household_service.py @@ -70,6 +70,10 @@ class HouseholdMirrorEventIntegrityError(RuntimeError): """Raised when retained household copy input is invalid or inconsistent.""" +class InvalidHouseholdIdError(Exception): + """Raised when a household ID is not a non-negative integer.""" + + class HouseholdPersistenceFailureCategory(StrEnum): """Safe-to-report reasons a household write failed.""" @@ -150,11 +154,11 @@ def get_household( ) -> Household | None: """Return the stored household in this country, or None if there is none. - Raise Exception if ``household_id`` is not a non-negative int. + Raise InvalidHouseholdIdError if ``household_id`` is not a non-negative int. """ if type(household_id) is not int or household_id < 0: msg = f"Invalid household ID: {household_id}. Must be a positive integer." - raise Exception(msg) + raise InvalidHouseholdIdError(msg) with self._sessions() as session: return self._get_household(session, country_id, household_id) diff --git a/policyengine_api/services/policy_service.py b/policyengine_api/services/policy_service.py index 7c4033a1b..0a8358f72 100644 --- a/policyengine_api/services/policy_service.py +++ b/policyengine_api/services/policy_service.py @@ -22,6 +22,10 @@ from policyengine_api.json_types import JSONValue +class InvalidPolicyIdError(Exception): + """Raised when a policy ID is not a non-negative integer.""" + + @dataclass(frozen=True) class PolicySetResult: """Existing v1 return values and an optional detached mirror snapshot.""" @@ -56,7 +60,7 @@ def _sessions(self) -> sessionmaker[Session]: def _validate_policy_id(policy_id: int) -> None: if type(policy_id) is not int or policy_id < 0: msg = f"Invalid policy ID: {policy_id}. Must be a positive integer." - raise Exception(msg) + raise InvalidPolicyIdError(msg) def get_policy( self, @@ -65,8 +69,8 @@ def get_policy( ) -> Policy | None: """Return the stored policy in this country, or None if there is none. - Raise Exception if ``policy_id`` is not a non-negative int, and ValueError for - an empty ``country_id``. + Raise InvalidPolicyIdError if ``policy_id`` is not a non-negative int, and + ValueError for an empty ``country_id``. """ self._validate_policy_id(policy_id) if not country_id: diff --git a/policyengine_api/services/report_output_service.py b/policyengine_api/services/report_output_service.py index e2c4227f1..c69e60531 100644 --- a/policyengine_api/services/report_output_service.py +++ b/policyengine_api/services/report_output_service.py @@ -72,12 +72,12 @@ def _canonicalize_society_wide_spm_output( annual_impacts = output.get("annualImpacts") if not isinstance(annual_impacts, list): msg = "SPM budget-window output requires annual impact objects" - raise ValueError(msg) + raise TypeError(msg) canonical_rows: list[JSONValue] = [] for row in annual_impacts: if not isinstance(row, dict): msg = "SPM budget-window output requires annual impact objects" - raise ValueError(msg) + raise TypeError(msg) row_year = row.get("year") canonical_rows.append( _canonicalize_annual_spm_output( @@ -88,6 +88,10 @@ def _canonicalize_society_wide_spm_output( return {**typed_output, "annualImpacts": canonical_rows} +class InvalidReportOutputIdError(Exception): + """Raised when a report output ID is not a non-negative integer.""" + + @dataclass(frozen=True) class ReportOutputView: """A report output as served: the record, the run to display and the ID to report. @@ -576,7 +580,7 @@ def _get_report_output( f"Invalid report output ID: {report_output_id}. " "Must be a positive integer." ) - raise Exception(msg) + raise InvalidReportOutputIdError(msg) return self._select_report_output(session, report_output_id, country_id) @staticmethod @@ -669,15 +673,15 @@ def resolve_report_output( A report stored under an outdated cache version is served as the current report for the same simulations and year (created if needed), with the requested ID - kept as ``response_id``. Raise Exception for an ID that is not a non-negative - int. + kept as ``response_id``. Raise InvalidReportOutputIdError for an ID that is not + a non-negative int. """ if type(report_output_id) is not int or report_output_id < 0: msg = ( f"Invalid report output ID: {report_output_id}. " "Must be a positive integer." ) - raise Exception(msg) + raise InvalidReportOutputIdError(msg) with self._sessions.begin() as session: requested = self._get_report_output(session, country_id, report_output_id) if requested is None: diff --git a/policyengine_api/services/simulation_service.py b/policyengine_api/services/simulation_service.py index 9a09fc92e..eba3b501c 100644 --- a/policyengine_api/services/simulation_service.py +++ b/policyengine_api/services/simulation_service.py @@ -17,6 +17,10 @@ ) +class InvalidSimulationIdError(Exception): + """Raised when a simulation ID is not a non-negative integer.""" + + @dataclass(frozen=True) class SimulationCreateResult: """A simulation and whether this call created it.""" @@ -266,11 +270,11 @@ def get_simulation( ) -> Simulation | None: """Return the simulation in this country, or None. - Raise Exception if ``simulation_id`` is not a non-negative int. + Raise InvalidSimulationIdError if ``simulation_id`` is not a non-negative int. """ if type(simulation_id) is not int or simulation_id < 0: msg = f"Invalid simulation ID: {simulation_id}. Must be a positive integer." - raise Exception(msg) + raise InvalidSimulationIdError(msg) with self._sessions() as session: return self._select_simulation(session, simulation_id, country_id) From 6f6c826a059c17c06191c93ff9c3353b40270800 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 13 Oct 2026 17:51:35 +0400 Subject: [PATCH 04/12] Restructure exception handling in the Flask and FastAPI routes The report-output and simulation PATCH routes check for "No fields to update" in a helper, so the BadRequest leaves the try block yet still reaches the same HTTPException re-raise (TRY301). The household calculation routes log a fixed message with the traceback instead of passing the exception object to logging.exception (TRY401); the traceback still ends with the exception and its message. The household create request's model validator keeps raising ValueError, which Pydantic reports as a 422, with an inline reason (TRY004). Co-Authored-By: Claude Opus 5.5 --- .../v2/households/request_models.py | 2 +- policyengine_api/routes/household_routes.py | 10 ++++--- .../routes/report_output_routes.py | 27 ++++++++++++------- policyengine_api/routes/simulation_routes.py | 26 +++++++++++------- 4 files changed, 42 insertions(+), 23 deletions(-) diff --git a/policyengine_api/fastapi_routes/v2/households/request_models.py b/policyengine_api/fastapi_routes/v2/households/request_models.py index eccb65bb1..0d58d5180 100644 --- a/policyengine_api/fastapi_routes/v2/households/request_models.py +++ b/policyengine_api/fastapi_routes/v2/households/request_models.py @@ -32,7 +32,7 @@ def require_country_document(self) -> HouseholdCreateRequest: ) if not isinstance(self.household_data, expected_type): msg = "household_data collections must match country_id" - raise ValueError(msg) + raise ValueError(msg) # noqa: TRY004 # Pydantic reports ValueError as a validation error; TypeError would escape as a 500 return self def to_service_input(self) -> NativeHouseholdCreationInput: diff --git a/policyengine_api/routes/household_routes.py b/policyengine_api/routes/household_routes.py index 603ea42e6..919b62dce 100644 --- a/policyengine_api/routes/household_routes.py +++ b/policyengine_api/routes/household_routes.py @@ -410,7 +410,11 @@ def get_household_under_policy( response = _spm_error_response(error) if response is not None: return response - logging.exception(error) + logging.exception( + "Error calculating household #%s under policy #%s", + household_id, + policy_id, + ) return _make_error_response( f"Error calculating household #{household_id} under policy " f"#{policy_id}: {error}", @@ -440,7 +444,7 @@ def _calculate() -> dict | Response: if response is not None: return response start_observability_id() - logging.exception(error) + logging.exception("Error calculating household under policy") return _make_error_response( f"Error calculating household under policy: {error}", 500, @@ -470,7 +474,7 @@ def _calculate() -> dict | Response: response = _spm_error_response(error) if response is not None: return response - logging.exception(error) + logging.exception("Error calculating household under policy") return _make_error_response( f"Error calculating household under policy: {error}", 500, diff --git a/policyengine_api/routes/report_output_routes.py b/policyengine_api/routes/report_output_routes.py index c8decc990..3048358e3 100644 --- a/policyengine_api/routes/report_output_routes.py +++ b/policyengine_api/routes/report_output_routes.py @@ -38,6 +38,16 @@ def _serialize_v1_report_output(view: ReportOutputView) -> dict: return result +def _require_updated_report_output( + view: ReportOutputView | None, +) -> ReportOutputView: + """Return the updated report output, or raise BadRequest if nothing changed.""" + if view is None: + msg = "No fields to update" + raise BadRequest(msg) + return view + + @report_output_bp.route("//report", methods=["POST"]) @validate_country def create_report_output(country_id: str) -> Response: @@ -201,16 +211,15 @@ def update_report_output(country_id: str) -> Response: raise BadRequest(msg) try: - view = report_output_service.update_report_output( - country_id=country_id, - report_id=report_id, - status=status, - output=output, - error_message=error_message, + view = _require_updated_report_output( + report_output_service.update_report_output( + country_id=country_id, + report_id=report_id, + status=status, + output=output, + error_message=error_message, + ) ) - if view is None: - msg = "No fields to update" - raise BadRequest(msg) result = _serialize_v1_report_output(view) response_body = dict( diff --git a/policyengine_api/routes/simulation_routes.py b/policyengine_api/routes/simulation_routes.py index 6c4ea211f..bffb45680 100644 --- a/policyengine_api/routes/simulation_routes.py +++ b/policyengine_api/routes/simulation_routes.py @@ -45,6 +45,14 @@ def _serialize_v1_simulation(simulation: Simulation) -> dict: return response +def _require_updated_simulation(simulation: Simulation | None) -> Simulation: + """Return the updated simulation, or raise BadRequest if nothing changed.""" + if simulation is None: + msg = "No fields to update" + raise BadRequest(msg) + return simulation + + @simulation_bp.route("//simulation", methods=["POST"]) @validate_country def create_simulation(country_id: str) -> Response: @@ -214,18 +222,16 @@ def update_simulation(country_id: str) -> Response: raise BadRequest(msg) try: - simulation = simulation_service.update_simulation( - country_id=country_id, - simulation_id=simulation_id, - status=status, - output=output, - error_message=error_message, + simulation = _require_updated_simulation( + simulation_service.update_simulation( + country_id=country_id, + simulation_id=simulation_id, + status=status, + output=output, + error_message=error_message, + ) ) - if simulation is None: - msg = "No fields to update" - raise BadRequest(msg) - result = _serialize_v1_simulation(simulation) response_body = dict( From 323d261bde23426e2f2b7d108c59b5e0c807fb55 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 13 Oct 2026 17:51:40 +0400 Subject: [PATCH 05/12] Raise TypeError for non-object bundle manifests in the version script find-api-model-versions.py raises TypeError when the installed bundle manifest or its package mapping is not an object (TRY004). Nothing catches these errors: the script still exits with a traceback, so the bundle-update script fails exactly as before. Co-Authored-By: Claude Opus 5.5 --- .github/find-api-model-versions.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/find-api-model-versions.py b/.github/find-api-model-versions.py index f70ec327c..9ae2ab7d4 100644 --- a/.github/find-api-model-versions.py +++ b/.github/find-api-model-versions.py @@ -31,7 +31,7 @@ def _manifest_versions(bundle: dict) -> dict[str, str]: packages = bundle.get("packages") if not isinstance(packages, dict): msg = "PolicyEngine bundle manifest has no package mapping." - raise RuntimeError(msg) + raise TypeError(msg) versions = {} for package_name in REQUIRED_PACKAGES: @@ -57,7 +57,7 @@ def find_api_model_versions() -> dict[str, str]: bundle = get_current_bundle() if not isinstance(bundle, dict): msg = "Installed PolicyEngine bundle manifest is not an object." - raise RuntimeError(msg) + raise TypeError(msg) manifest_versions = _manifest_versions(bundle) for package_name, manifest_version in manifest_versions.items(): From 25841a3d095fa64f10d531c99e55f18dc22b97ff Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 13 Oct 2026 17:51:40 +0400 Subject: [PATCH 06/12] Raise the forecast-year test error from a helper The test that builds a raised ValueError now raises it in a helper, so the raise leaves the try block (TRY301) and the error keeps its traceback. Co-Authored-By: Claude Opus 5.5 --- tests/unit/test_spm_settings.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/unit/test_spm_settings.py b/tests/unit/test_spm_settings.py index dc4dd64cf..1690da847 100644 --- a/tests/unit/test_spm_settings.py +++ b/tests/unit/test_spm_settings.py @@ -266,11 +266,15 @@ def geography_factor(year, tenure, **kwargs): ) +def _raise_missing_forecast_year(): + msg = "Forecast has no entry for 2036" + raise ValueError(msg) + + @pytest.mark.parametrize("wrapped", [False, True]) def test_missing_forecast_year_message_alone_does_not_reclassify_errors(wrapped): try: - msg = "Forecast has no entry for 2036" - raise ValueError(msg) + _raise_missing_forecast_year() except ValueError as error: if wrapped: outer = RuntimeError("An unrelated dependency failed") From 5497590a82197ed2a863f4e80453aa56192c8a23 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 13 Oct 2026 17:51:48 +0400 Subject: [PATCH 07/12] Decide each blind except: narrow one, keep the deliberate ones The reform normalizer's float() coercion now catches only TypeError, ValueError and OverflowError, the only errors float() raises for the JSON values a policy holds. Every other except Exception is a deliberate catch-all and keeps its behaviour with an inline reason: telemetry, logging and metric calls that must never fail a request or change a result, cache reads and writes that degrade to a miss or False, best-effort cleanup, the health probe, the search route's JSON 500, the committed-write mirror fallbacks, and the import-time version fallback (BLE001). The health probe also returns its result from an else block (TRY300). Co-Authored-By: Claude Opus 5.5 --- policyengine_api/asgi_factory.py | 16 ++++++++-------- policyengine_api/constants.py | 2 +- policyengine_api/country.py | 2 +- .../fastapi_routes/v2/metadata/common.py | 2 +- policyengine_api/gcp_logging.py | 2 +- policyengine_api/libs/simulation_entrypoint.py | 5 +++-- policyengine_api/migration_logging.py | 8 ++++---- policyengine_api/observability/runtime.py | 2 +- policyengine_api/request_context.py | 2 +- policyengine_api/routes/household_routes.py | 2 +- policyengine_api/routes/policy_routes.py | 9 ++++----- policyengine_api/runtime_cache/core.py | 4 ++-- policyengine_api/runtime_cache/reform_impacts.py | 8 ++++---- policyengine_api/services/budget_window_cache.py | 2 +- policyengine_api/services/economy_service.py | 4 ++-- policyengine_api/services/household_mirroring.py | 2 +- policyengine_api/services/policy_mirroring.py | 2 +- .../services/user_policy_mirroring.py | 2 +- scripts/process_v1_household_mirror_event.py | 4 ++-- 19 files changed, 40 insertions(+), 40 deletions(-) diff --git a/policyengine_api/asgi_factory.py b/policyengine_api/asgi_factory.py index 7b0c91354..925ffb5e6 100644 --- a/policyengine_api/asgi_factory.py +++ b/policyengine_api/asgi_factory.py @@ -208,13 +208,13 @@ async def add_request_context_and_migration_logging( request.state.policyengine_request_id = request_id _asgi_request_id.reset(context_token) context_token = _asgi_request_id.set(request_id) - except Exception: + except Exception: # noqa: BLE001 # telemetry must never fail the request pass try: request_runtime.set_context( request_id=request_id, ) - except Exception: + except Exception: # noqa: BLE001 # telemetry must never fail the request pass def log_native_route(status_code: int) -> None: @@ -233,7 +233,7 @@ def log_native_route(status_code: int) -> None: ), route_impl=RouteImplementation.FASTAPI_NATIVE, ) - except Exception: + except Exception: # noqa: BLE001 # migration logging must never fail the request pass def finish_native_route( @@ -245,18 +245,18 @@ def finish_native_route( resolved_route = getattr(request.scope.get("route"), "path", initial_route) try: request_runtime.update_request_route(resolved_route) - except Exception: + except Exception: # noqa: BLE001 # telemetry must never fail the request pass try: request_runtime.update_request_status(status_code) - except Exception: + except Exception: # noqa: BLE001 # telemetry must never fail the request pass try: request_runtime.end_request( status_code=status_code, error=error, ) - except Exception: + except Exception: # noqa: BLE001 # telemetry must never fail the request pass try: @@ -280,13 +280,13 @@ def finish_native_route( request_runtime.set_context( observability_id=response_observability_id ) - except Exception: + except Exception: # noqa: BLE001 # telemetry must never fail the request pass if native_request: try: for name, value in request_runtime.response_headers().items(): response.headers[name] = value - except Exception: + except Exception: # noqa: BLE001 # telemetry must never fail the request pass _apply_request_id_header(response, request_id) if response_observability_id is not None: diff --git a/policyengine_api/constants.py b/policyengine_api/constants.py index e12fde045..e9c97ff61 100644 --- a/policyengine_api/constants.py +++ b/policyengine_api/constants.py @@ -164,7 +164,7 @@ def get_bundle_default_dataset_option(country_id: str) -> dict: country: _resolve_distribution_version(_dist_versions, package_name) for country, package_name in zip(COUNTRIES, COUNTRY_PACKAGE_NAMES) } -except Exception: +except Exception: # noqa: BLE001 # unreadable package metadata falls back to 0.0.0; import must not fail _dist_versions = {} COUNTRY_PACKAGE_VERSIONS = {country: "0.0.0" for country in COUNTRIES} diff --git a/policyengine_api/country.py b/policyengine_api/country.py index ec0499113..da57c41a9 100644 --- a/policyengine_api/country.py +++ b/policyengine_api/country.py @@ -635,7 +635,7 @@ def _normalize_reform_values( node_type = float try: value = float(value) - except Exception: + except (TypeError, ValueError, OverflowError): pass normalized_reform[parameter_name][time_period] = node_type(value) return normalized_reform diff --git a/policyengine_api/fastapi_routes/v2/metadata/common.py b/policyengine_api/fastapi_routes/v2/metadata/common.py index 899243334..90a3c2aac 100644 --- a/policyengine_api/fastapi_routes/v2/metadata/common.py +++ b/policyengine_api/fastapi_routes/v2/metadata/common.py @@ -102,5 +102,5 @@ def read_resource( if reader is not None: try: reader.close() - except Exception: + except Exception: # noqa: BLE001 # best-effort close must not replace the typed response pass diff --git a/policyengine_api/gcp_logging.py b/policyengine_api/gcp_logging.py index 8e89aafe2..f03cdfb27 100644 --- a/policyengine_api/gcp_logging.py +++ b/policyengine_api/gcp_logging.py @@ -42,7 +42,7 @@ def log_struct( severity=severity, attributes=attributes, ) - except Exception: + except Exception: # noqa: BLE001 # logging must never affect the caller pass diff --git a/policyengine_api/libs/simulation_entrypoint.py b/policyengine_api/libs/simulation_entrypoint.py index ebf115511..f807eceaf 100644 --- a/policyengine_api/libs/simulation_entrypoint.py +++ b/policyengine_api/libs/simulation_entrypoint.py @@ -587,9 +587,10 @@ def health_check(self) -> bool: """ try: response = self.client.get(f"{self.base_url}/health") - return response.status_code == 200 - except Exception: + except Exception: # noqa: BLE001 # a health probe reports any failure as unhealthy return False + else: + return response.status_code == 200 # Compatibility aliases preserve existing imports and generated-client consumers. diff --git a/policyengine_api/migration_logging.py b/policyengine_api/migration_logging.py index ce8a25524..4de643c09 100644 --- a/policyengine_api/migration_logging.py +++ b/policyengine_api/migration_logging.py @@ -86,7 +86,7 @@ def set_request_migration_context() -> None: flask.g.request_started_at = time.time() try: captured = runtime.capture_context() if runtime is not None else {} - except Exception: + except Exception: # noqa: BLE001 # telemetry must never fail the request captured = {} flask.g.request_id = captured.get("request_id") or ( flask.request.headers.get(REQUEST_ID_HEADER) or generate_request_id() @@ -98,7 +98,7 @@ def set_request_migration_context() -> None: if runtime is not None: try: runtime.set_context(request_id=flask.g.request_id) - except Exception: + except Exception: # noqa: BLE001 # telemetry must never fail the request pass @app.after_request @@ -137,10 +137,10 @@ def log_request_migration_context(response: flask.Response) -> flask.Response: country_id=country_id, route_impl=RouteImplementation.FLASK_FALLBACK, ) - except Exception: + except Exception: # noqa: BLE001 # logs the traceback below, guarded so logging cannot fail the response try: app.logger.exception("Failed to log migration request context") - except Exception: + except Exception: # noqa: BLE001 # a logging failure must not replace the response pass return response diff --git a/policyengine_api/observability/runtime.py b/policyengine_api/observability/runtime.py index a560b5378..fe4af6731 100644 --- a/policyengine_api/observability/runtime.py +++ b/policyengine_api/observability/runtime.py @@ -76,5 +76,5 @@ def set_runtime_context(**attributes: object) -> None: """Bind local telemetry attributes without affecting application behavior.""" try: runtime.set_context(**attributes) - except Exception: + except Exception: # noqa: BLE001 # telemetry must never affect application behavior pass diff --git a/policyengine_api/request_context.py b/policyengine_api/request_context.py index 8c344594d..832a75f05 100644 --- a/policyengine_api/request_context.py +++ b/policyengine_api/request_context.py @@ -63,7 +63,7 @@ def _bind_observability_id(observability_id: str) -> str: from policyengine_api.observability import get_runtime get_runtime().set_context(observability_id=observability_id) - except Exception: + except Exception: # noqa: BLE001 # telemetry must never fail the request pass return observability_id diff --git a/policyengine_api/routes/household_routes.py b/policyengine_api/routes/household_routes.py index 919b62dce..d302f17c3 100644 --- a/policyengine_api/routes/household_routes.py +++ b/policyengine_api/routes/household_routes.py @@ -105,7 +105,7 @@ def _household_persistence_failure( }, severity="ERROR", ) - except Exception: + except Exception: # noqa: BLE001 # metric logging must not replace the error response pass message = ( "Household database is temporarily unavailable; please try again later." diff --git a/policyengine_api/routes/policy_routes.py b/policyengine_api/routes/policy_routes.py index df2f345a9..dc1433e28 100644 --- a/policyengine_api/routes/policy_routes.py +++ b/policyengine_api/routes/policy_routes.py @@ -114,8 +114,7 @@ def _user_policy_persistence_failure( }, severity="ERROR", ) - except Exception: - # A logging failure must not replace the persistence response. + except Exception: # noqa: BLE001 # a logging failure must not replace the persistence response pass message = ( @@ -278,7 +277,7 @@ def get_policy_search(country_id: str) -> Response: status=200, mimetype="application/json", ) - except Exception: + except Exception: # noqa: BLE001 # any search failure returns the JSON 500 response return _make_error_response( "Internal server error; please try again later.", 500, @@ -360,7 +359,7 @@ def set_user_policy(country_id: str) -> Response: ) except UserPolicyMirrorUnavailableError: return _user_policy_mirror_unavailable() - except Exception: + except Exception: # noqa: BLE001 # the v1 write is committed; any mirror failure is mirror-unavailable return _user_policy_mirror_unavailable() if not creation.created: @@ -487,7 +486,7 @@ def update_user_policy(country_id: str) -> Response: ) except UserPolicyMirrorUnavailableError: return _user_policy_mirror_unavailable() - except Exception: + except Exception: # noqa: BLE001 # the v1 write is committed; any mirror failure is mirror-unavailable return _user_policy_mirror_unavailable() return Response( diff --git a/policyengine_api/runtime_cache/core.py b/policyengine_api/runtime_cache/core.py index c4c4026fc..5c1540f43 100644 --- a/policyengine_api/runtime_cache/core.py +++ b/policyengine_api/runtime_cache/core.py @@ -250,7 +250,7 @@ def get(self, inputs: dict[str, Any]) -> object: started_at = time.perf_counter() try: value = self.client.get(self.key(inputs)) - except Exception: + except Exception: # noqa: BLE001 # a cache outage is a miss, never a failed request record_cache_event( family=self.family, event="connection-failed", @@ -279,7 +279,7 @@ def set(self, inputs: dict[str, Any], payload: object) -> bool: encode_envelope(self.family, self.schema_version, payload), ex=jittered_ttl(self.ttl_seconds), ) - except Exception: + except Exception: # noqa: BLE001 # a failed cache write returns False instead of failing the request record_cache_event( family=self.family, event="write-failed", diff --git a/policyengine_api/runtime_cache/reform_impacts.py b/policyengine_api/runtime_cache/reform_impacts.py index a0ee93751..4d204fe66 100644 --- a/policyengine_api/runtime_cache/reform_impacts.py +++ b/policyengine_api/runtime_cache/reform_impacts.py @@ -436,7 +436,7 @@ def set( ) pipeline.expire(index, ttl_seconds) pipeline.execute() - except Exception: + except Exception: # noqa: BLE001 # a failed cache write returns False instead of failing the request record_cache_event( family=self.family, event="write-failed", @@ -450,7 +450,7 @@ def get_by_execution_id(self, execution_id: str) -> CachedReformImpact | None: """Return the job's impact, or None if absent, unreadable or unreachable.""" try: value = self.client.get(self._record_key(execution_id)) - except Exception: + except Exception: # noqa: BLE001 # a cache outage reads as absent, never a failed request return None return _impact_from_wire( decode_envelope( @@ -464,7 +464,7 @@ def _from_index(self, index: str, limit: int) -> list[CachedReformImpact]: try: keys = self.client.zrevrange(index, 0, max(limit - 1, 0)) # type: ignore[attr-defined] values = [self.client.get(key) for key in keys] - except Exception: + except Exception: # noqa: BLE001 # a cache outage reads as empty, never a failed request return [] impacts = [ _impact_from_wire( @@ -603,7 +603,7 @@ def delete_matching_computing( pipeline.zrem(self._scope_index(impact), key) pipeline.zrem(self._recent_index(), key) pipeline.execute() - except Exception: + except Exception: # noqa: BLE001 # best-effort cleanup of stale computing records return diff --git a/policyengine_api/services/budget_window_cache.py b/policyengine_api/services/budget_window_cache.py index 836820dda..9fe255965 100644 --- a/policyengine_api/services/budget_window_cache.py +++ b/policyengine_api/services/budget_window_cache.py @@ -371,7 +371,7 @@ def _set_recoverable_state( self._encoded_state(state), ex=jittered_ttl(BUDGET_WINDOW_RESULT_TTL_SECONDS), ) - except Exception: + except Exception: # noqa: BLE001 # a failed cache write returns False instead of failing the request self._handle_cache_error( operation, event="write-failed", diff --git a/policyengine_api/services/economy_service.py b/policyengine_api/services/economy_service.py index d1fda67ce..a91ffa945 100644 --- a/policyengine_api/services/economy_service.py +++ b/policyengine_api/services/economy_service.py @@ -1384,7 +1384,7 @@ def _record_uncertifiable_stored_impact( error_code=error.code, execution_id=most_recent_impact.execution_id, ) - except Exception: + except Exception: # noqa: BLE001 # best effort: the typed SPM error is the answer, and the failure is already logged pass @observability_runtime.span(ECONOMY_ANNUAL_STAGES.name(Stage.ECONOMY_READ_FAILED)) @@ -1608,7 +1608,7 @@ def _should_refresh_cached_impact( policyengine_version=setup_options.policyengine_version, ) ) - except Exception: + except Exception: # noqa: BLE001 # an unresolvable worker app keeps the cached impact return False setup_options.runtime_app_name = runtime_app_name diff --git a/policyengine_api/services/household_mirroring.py b/policyengine_api/services/household_mirroring.py index 8abcae4e1..2e7feb5d5 100644 --- a/policyengine_api/services/household_mirroring.py +++ b/policyengine_api/services/household_mirroring.py @@ -113,7 +113,7 @@ def _log_copy_operation( }, severity="INFO" if outcome == "ok" else "ERROR", ) - except Exception: + except Exception: # noqa: BLE001 # metric logging must not change the mirror outcome pass diff --git a/policyengine_api/services/policy_mirroring.py b/policyengine_api/services/policy_mirroring.py index dab6b4f42..22ba5f023 100644 --- a/policyengine_api/services/policy_mirroring.py +++ b/policyengine_api/services/policy_mirroring.py @@ -117,7 +117,7 @@ def _log_mirror_event( }, severity="INFO" if outcome == "ok" else "ERROR", ) - except Exception: + except Exception: # noqa: BLE001 # metric logging must not change the mirror outcome pass diff --git a/policyengine_api/services/user_policy_mirroring.py b/policyengine_api/services/user_policy_mirroring.py index d91bcfcb0..bd18019e8 100644 --- a/policyengine_api/services/user_policy_mirroring.py +++ b/policyengine_api/services/user_policy_mirroring.py @@ -144,7 +144,7 @@ def _log_mirror_operation( }, severity="INFO" if outcome == "ok" else "ERROR", ) - except Exception: + except Exception: # noqa: BLE001 # metric logging must not change the mirror outcome pass diff --git a/scripts/process_v1_household_mirror_event.py b/scripts/process_v1_household_mirror_event.py index 6bfa6f00e..4348aba38 100644 --- a/scripts/process_v1_household_mirror_event.py +++ b/scripts/process_v1_household_mirror_event.py @@ -222,7 +222,7 @@ def main( outcome="error", error_type=type(error).__name__, ) - except Exception: + except Exception: # noqa: BLE001 # outcome logging must not change the command result pass safe_types = (HouseholdEventCommandConfigurationError,) message = ( @@ -254,7 +254,7 @@ def main( outcome="ok", destination_household_id=result.household_id, ) - except Exception: + except Exception: # noqa: BLE001 # outcome logging must not change the command result pass print( json.dumps( From ac9e7caca064bab50d83d6c1118ff823e052b8e8 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 13 Oct 2026 17:51:59 +0400 Subject: [PATCH 08/12] Enable ruff group 5 (exceptions and messages) Select flake8-blind-except (BLE), flake8-errmsg (EM) and tryceratops (TRY). Tests follow all three: their exemptions stay limited to S101, D1, ANN and PLR2004. Co-Authored-By: Claude Opus 5.5 --- pyproject.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 29be9c2f8..329d43a06 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -93,6 +93,8 @@ select = [ "ANN", # Group 4: docstrings. "D", + # Group 5: exceptions and messages. + "BLE", "EM", "TRY", ] # Mutually exclusive pydocstyle pairs: keep D211 (no blank line before a class # docstring) and D212 (summary on the first line). From 4e82b1af750b5adb2ef95fb5357e022eaa26ee9d Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 13 Oct 2026 17:58:56 +0400 Subject: [PATCH 09/12] Name hoisted SPM error codes error_code, not msg SPMValidationError takes a stable error code and then the client-facing message, so the 18 variables the EM fix named msg are renamed error_code. Co-Authored-By: Claude Opus 5.5 --- .../libs/simulation_entrypoint.py | 8 ++-- policyengine_api/routes/household_routes.py | 10 ++-- .../services/household_service.py | 4 +- policyengine_api/spm.py | 48 ++++++++++--------- policyengine_api/worker_spm.py | 8 ++-- 5 files changed, 42 insertions(+), 36 deletions(-) diff --git a/policyengine_api/libs/simulation_entrypoint.py b/policyengine_api/libs/simulation_entrypoint.py index f807eceaf..1472dc35d 100644 --- a/policyengine_api/libs/simulation_entrypoint.py +++ b/policyengine_api/libs/simulation_entrypoint.py @@ -231,9 +231,9 @@ def _normalize_submission_payload(self, payload: dict) -> dict: if bundle_version == "latest": from policyengine_api.spm import SPMValidationError - msg = "SPM_SETTINGS_INVALID" + error_code = "SPM_SETTINGS_INVALID" raise SPMValidationError( - msg, + error_code, "Canonical SPM execution requires an exact PolicyEngine version", ) payload = { @@ -398,9 +398,9 @@ def get_spm_capability( else None ) except (httpx.HTTPError, KeyError, TypeError, ValueError) as exc: - msg = "SPM_CONFIGURATION_UNAVAILABLE" + error_code = "SPM_CONFIGURATION_UNAVAILABLE" raise SPMValidationError( - msg, + error_code, "Cannot validate the selected worker's canonical SPM capability", ) from exc diff --git a/policyengine_api/routes/household_routes.py b/policyengine_api/routes/household_routes.py index d302f17c3..f49aabf17 100644 --- a/policyengine_api/routes/household_routes.py +++ b/policyengine_api/routes/household_routes.py @@ -167,12 +167,14 @@ def _requested_spm(country_id: str, payload: dict) -> object: return None selection = payload["spm"] if country_id != "us": - msg = "SPM_SETTINGS_UNSUPPORTED" - raise SPMValidationError(msg, "SPM settings are only available for the US") + error_code = "SPM_SETTINGS_UNSUPPORTED" + raise SPMValidationError( + error_code, "SPM settings are only available for the US" + ) if selection is None: - msg = "SPM_SETTINGS_INVALID" + error_code = "SPM_SETTINGS_INVALID" raise SPMValidationError( - msg, + error_code, "spm must be an object; omit it to inherit the certified defaults.", ) return selection diff --git a/policyengine_api/services/household_service.py b/policyengine_api/services/household_service.py index df28f4353..0ff9ccc58 100644 --- a/policyengine_api/services/household_service.py +++ b/policyengine_api/services/household_service.py @@ -52,9 +52,9 @@ def household_storage_json( household's replay assert a measurement the caller never asked for. """ if "spm" in household_json: - msg = "SPM_SETTINGS_INVALID" + error_code = "SPM_SETTINGS_INVALID" raise SPMValidationError( - msg, "Supply spm beside data, not inside household data." + error_code, "Supply spm beside data, not inside household data." ) result = deepcopy(household_json) selected = normalize_spm_selection(country_id, spm) diff --git a/policyengine_api/spm.py b/policyengine_api/spm.py index 382b1c0fd..1640f9c8e 100644 --- a/policyengine_api/spm.py +++ b/policyengine_api/spm.py @@ -546,36 +546,40 @@ def normalize_spm_selection( """ if country_id != "us": if selection is not None: - msg = "SPM_SETTINGS_UNSUPPORTED" - raise SPMValidationError(msg, "SPM settings are only available for the US") + error_code = "SPM_SETTINGS_UNSUPPORTED" + raise SPMValidationError( + error_code, "SPM settings are only available for the US" + ) return None try: chosen = SPMSelection.model_validate({} if selection is None else selection) except ValidationError as error: - msg = "SPM_SETTINGS_INVALID" - raise SPMValidationError(msg, readable_validation_error(error)) from error + error_code = "SPM_SETTINGS_INVALID" + raise SPMValidationError( + error_code, readable_validation_error(error) + ) from error bundle = _current_bundle() if not isinstance(bundle, dict): - msg = "SPM_CONFIGURATION_UNAVAILABLE" - raise SPMValidationError(msg, "The installed bundle manifest is invalid") + error_code = "SPM_CONFIGURATION_UNAVAILABLE" + raise SPMValidationError(error_code, "The installed bundle manifest is invalid") measurements = bundle.get("measurements") configured = measurements.get("spm") if isinstance(measurements, dict) else None if not isinstance(configured, dict): if _installed_country_implements_spm(country_id): # A model that can run canonical SPM must never run it uncertified, # whichever settings the caller did or did not send. - msg = "SPM_CONFIGURATION_UNAVAILABLE" + error_code = "SPM_CONFIGURATION_UNAVAILABLE" raise SPMValidationError( - msg, + error_code, "The installed bundle has no certified SPM measurement configuration", ) if selection is None: return None - msg = "SPM_SETTINGS_UNSUPPORTED" + error_code = "SPM_SETTINGS_UNSUPPORTED" raise SPMValidationError( - msg, + error_code, "This US model bundle does not support canonical SPM settings", ) @@ -586,8 +590,8 @@ def normalize_spm_selection( # here. The capability probe already reads that as "no canonical model"; # letting it escape instead would make /readiness-check raise rather than # report not-ready, and every US request a 500 rather than a typed 400. - msg = "SPM_CONFIGURATION_UNAVAILABLE" - raise SPMValidationError(msg, error_message(error)) from error + error_code = "SPM_CONFIGURATION_UNAVAILABLE" + raise SPMValidationError(error_code, error_message(error)) from error if chosen.forecast_content_sha256 not in (None, defaults.forecast_content_sha256): # A saved selection is identity, not a request. The caller sent nothing @@ -595,15 +599,15 @@ def normalize_spm_selection( # was measured against, which is the same class of failure as an # uncertified bundle rather than a correctable caller mistake. if stored: - msg = "SPM_CONFIGURATION_UNAVAILABLE" + error_code = "SPM_CONFIGURATION_UNAVAILABLE" raise SPMValidationError( - msg, + error_code, "This deployment does not have the SPM artifact this household's " "saved selection was measured against", ) - msg = "SPM_SETTINGS_INVALID" + error_code = "SPM_SETTINGS_INVALID" raise SPMValidationError( - msg, + error_code, "SPM selection does not match this bundle's artifact hash", ) values = {name: getattr(defaults, name) for name in SPMSelection.model_fields} @@ -618,8 +622,8 @@ def normalize_spm_selection( try: resolved = _validated_spm_selection(values, forecast) except ValueError as error: - msg = "SPM_SETTINGS_INVALID" - raise SPMValidationError(msg, error_message(error)) from error + error_code = "SPM_SETTINGS_INVALID" + raise SPMValidationError(error_code, error_message(error)) from error if resolved.geography_kind == "metro": _validate_metro_selection(forecast, resolved) return resolved.model_dump(mode="json") @@ -646,9 +650,9 @@ def _validate_metro_selection(forecast: SPMForecast, selection: SPMSelection) -> except ValueError: continue return - msg = "SPM_GEOGRAPHY_UNAVAILABLE" + error_code = "SPM_GEOGRAPHY_UNAVAILABLE" raise SPMValidationError( - msg, + error_code, f"SPM area is unavailable in the selected artifact: {selection.geography_id}", ) @@ -677,9 +681,9 @@ def calculation_spm_receipt(simulation: Simulation) -> dict: try: return _country_spm_receipt(simulation) except (ValidationError, ValueError) as error: - msg = "SPM_CONFIGURATION_UNAVAILABLE" + error_code = "SPM_CONFIGURATION_UNAVAILABLE" raise SPMValidationError( - msg, + error_code, "The installed country model reported an SPM receipt this API cannot " f"certify: {error_message(error)}", ) from error diff --git a/policyengine_api/worker_spm.py b/policyengine_api/worker_spm.py index b14577e6c..757def5be 100644 --- a/policyengine_api/worker_spm.py +++ b/policyengine_api/worker_spm.py @@ -60,8 +60,8 @@ def validate_worker_spm( try: _check_worker_capability(capability, resolved) except ValueError as exc: - msg = "SPM_CONFIGURATION_UNAVAILABLE" - raise SPMValidationError(msg, error_message(exc)) from exc + error_code = "SPM_CONFIGURATION_UNAVAILABLE" + raise SPMValidationError(error_code, error_message(exc)) from exc return resolved @@ -110,8 +110,8 @@ def validate_worker_result( expected_years=expected_years, ) except (ValueError, TypeError, KeyError) as exc: - msg = "SPM_CONFIGURATION_UNAVAILABLE" - raise SPMValidationError(msg, error_message(exc)) from exc + error_code = "SPM_CONFIGURATION_UNAVAILABLE" + raise SPMValidationError(error_code, error_message(exc)) from exc def _check_worker_result( From 1eec0643ef907c31738f849fc94fb8a520c437e6 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 13 Oct 2026 17:58:56 +0400 Subject: [PATCH 10/12] Put BLE001 suppression reasons in the " # reason" form Ten older suppressions, active now that BLE is enabled, used the "- reason" form; they now match every other suppression. Co-Authored-By: Claude Opus 5.5 --- policyengine_api/data/v2/catalog/initialization.py | 2 +- policyengine_api/data/v2/household_migration_qualification.py | 2 +- policyengine_api/data/v2/policy_migration_qualification.py | 2 +- policyengine_api/fastapi_routes/v2/households/routes.py | 2 +- policyengine_api/fastapi_routes/v2/metadata/common.py | 2 +- policyengine_api/fastapi_routes/v2/policies/routes.py | 2 +- policyengine_api/fastapi_routes/v2/user_households/routes.py | 2 +- policyengine_api/fastapi_routes/v2/user_policies/routes.py | 2 +- policyengine_api/services/v2/households/transformations.py | 2 +- scripts/process_v1_household_mirror_event.py | 2 +- 10 files changed, 10 insertions(+), 10 deletions(-) diff --git a/policyengine_api/data/v2/catalog/initialization.py b/policyengine_api/data/v2/catalog/initialization.py index 9fcf31c54..10efdda6f 100644 --- a/policyengine_api/data/v2/catalog/initialization.py +++ b/policyengine_api/data/v2/catalog/initialization.py @@ -78,7 +78,7 @@ def main() -> int: """Run explicit initialization and return a shell-compatible status.""" try: evidence = initialize_catalog() - except Exception as error: # noqa: BLE001 - command must return safe evidence + except Exception as error: # noqa: BLE001 # command must return safe evidence print(json.dumps(_error_payload(error), sort_keys=True), file=sys.stderr) return 1 print(json.dumps(evidence.as_dict(), sort_keys=True)) diff --git a/policyengine_api/data/v2/household_migration_qualification.py b/policyengine_api/data/v2/household_migration_qualification.py index 0297a7d85..ce679323e 100644 --- a/policyengine_api/data/v2/household_migration_qualification.py +++ b/policyengine_api/data/v2/household_migration_qualification.py @@ -261,7 +261,7 @@ def main() -> int: """Run qualification and return a shell-compatible status.""" try: evidence = qualify_household_migration_if_pending() - except Exception as error: # noqa: BLE001 - command must emit safe evidence + except Exception as error: # noqa: BLE001 # command must emit safe evidence print(json.dumps(_error_payload(error), sort_keys=True), file=sys.stderr) return 1 print(json.dumps(evidence.as_dict(), sort_keys=True)) diff --git a/policyengine_api/data/v2/policy_migration_qualification.py b/policyengine_api/data/v2/policy_migration_qualification.py index 597539aff..beb03db24 100644 --- a/policyengine_api/data/v2/policy_migration_qualification.py +++ b/policyengine_api/data/v2/policy_migration_qualification.py @@ -246,7 +246,7 @@ def main() -> int: """Run qualification and return a shell-compatible status.""" try: evidence = qualify_policy_migration_if_pending() - except Exception as error: # noqa: BLE001 - command must emit safe evidence + except Exception as error: # noqa: BLE001 # command must emit safe evidence print(json.dumps(_error_payload(error), sort_keys=True), file=sys.stderr) return 1 print(json.dumps(evidence.as_dict(), sort_keys=True)) diff --git a/policyengine_api/fastapi_routes/v2/households/routes.py b/policyengine_api/fastapi_routes/v2/households/routes.py index 2c59174dd..e5964e47b 100644 --- a/policyengine_api/fastapi_routes/v2/households/routes.py +++ b/policyengine_api/fastapi_routes/v2/households/routes.py @@ -95,7 +95,7 @@ def _household_operation( return v2_error_response(500, "Stored household integrity failed") except (V2ConfigurationError, SQLAlchemyError): return v2_error_response(503, "V2 household persistence is unavailable") - except Exception: # noqa: BLE001 - return a secret-safe typed error + except Exception: # noqa: BLE001 # return a secret-safe typed error return v2_error_response(500, "V2 household operation failed") diff --git a/policyengine_api/fastapi_routes/v2/metadata/common.py b/policyengine_api/fastapi_routes/v2/metadata/common.py index 90a3c2aac..b46f759d4 100644 --- a/policyengine_api/fastapi_routes/v2/metadata/common.py +++ b/policyengine_api/fastapi_routes/v2/metadata/common.py @@ -96,7 +96,7 @@ def read_resource( return v2_error_response(404, str(error)) except (V2ConfigurationError, MetadataCatalogUnavailableError): return v2_error_response(503, "V2 metadata catalog is unavailable") - except Exception: # noqa: BLE001 - preview must return typed errors + except Exception: # noqa: BLE001 # preview must return typed errors return v2_error_response(500, "V2 metadata query failed") finally: if reader is not None: diff --git a/policyengine_api/fastapi_routes/v2/policies/routes.py b/policyengine_api/fastapi_routes/v2/policies/routes.py index 1e49445f3..c77000afa 100644 --- a/policyengine_api/fastapi_routes/v2/policies/routes.py +++ b/policyengine_api/fastapi_routes/v2/policies/routes.py @@ -100,7 +100,7 @@ def _policy_operation( return v2_error_response(500, "Stored policy integrity failed") except (V2ConfigurationError, MetadataCatalogUnavailableError, SQLAlchemyError): return v2_error_response(503, "V2 policy persistence is unavailable") - except Exception: # noqa: BLE001 - route must return a secret-safe typed error + except Exception: # noqa: BLE001 # route must return a secret-safe typed error return v2_error_response(500, "V2 policy operation failed") diff --git a/policyengine_api/fastapi_routes/v2/user_households/routes.py b/policyengine_api/fastapi_routes/v2/user_households/routes.py index 2d42a9246..d49f71948 100644 --- a/policyengine_api/fastapi_routes/v2/user_households/routes.py +++ b/policyengine_api/fastapi_routes/v2/user_households/routes.py @@ -81,7 +81,7 @@ def _association_operation( ) except (V2ConfigurationError, SQLAlchemyError): return v2_error_response(503, "V2 association persistence is unavailable") - except Exception: # noqa: BLE001 - return a secret-safe typed error + except Exception: # noqa: BLE001 # return a secret-safe typed error return v2_error_response(500, "V2 association operation failed") diff --git a/policyengine_api/fastapi_routes/v2/user_policies/routes.py b/policyengine_api/fastapi_routes/v2/user_policies/routes.py index c1ae65c03..8b45e6f62 100644 --- a/policyengine_api/fastapi_routes/v2/user_policies/routes.py +++ b/policyengine_api/fastapi_routes/v2/user_policies/routes.py @@ -77,7 +77,7 @@ def _association_operation( 503, "V2 association persistence is unavailable", ) - except Exception: # noqa: BLE001 - return a secret-safe typed error + except Exception: # noqa: BLE001 # return a secret-safe typed error return v2_error_response(500, "V2 association operation failed") diff --git a/policyengine_api/services/v2/households/transformations.py b/policyengine_api/services/v2/households/transformations.py index 04e970288..40670c871 100644 --- a/policyengine_api/services/v2/households/transformations.py +++ b/policyengine_api/services/v2/households/transformations.py @@ -166,7 +166,7 @@ def _period_years(value: object) -> set[int] | None: continue try: parsed = parse_policyengine_period(period_key) - except Exception: # noqa: BLE001 - parser exposes several value errors + except Exception: # noqa: BLE001 # parser exposes several value errors return None years.add(parsed.start.year) years.add(parsed.stop.year) diff --git a/scripts/process_v1_household_mirror_event.py b/scripts/process_v1_household_mirror_event.py index 4348aba38..5be2f988e 100644 --- a/scripts/process_v1_household_mirror_event.py +++ b/scripts/process_v1_household_mirror_event.py @@ -213,7 +213,7 @@ def main( event_service=event_service, require_pending=True, ) - except Exception as error: # noqa: BLE001 - command emits a safe result + except Exception as error: # noqa: BLE001 # command emits a safe result try: _record_command_outcome( identity=identity, From 58796b5f6cd5e57969095cff386c199df155c20e Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 13 Oct 2026 17:59:03 +0400 Subject: [PATCH 11/12] Register TRY, EM and BLE in the ruff-lint rule api.quality.ruff-lint now lists the group 5 families, and the rendered rules page and AGENTS.md Rules block are regenerated. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- changelog.d/ruff-exceptions.changed.md | 1 + docs/generated/rules.md | 8 ++++---- rules/registry.toml | 2 +- 4 files changed, 7 insertions(+), 6 deletions(-) create mode 100644 changelog.d/ruff-exceptions.changed.md diff --git a/AGENTS.md b/AGENTS.md index 29b111412..12fb57d2b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -122,7 +122,7 @@ To add or change a rule, read `docs/engineering/skills/rules-registry.md`. - `api.quality.environment-variables-load`: Required environment variables and credentials load in a deployed-like environment. (blocking: CI job `test_env_vars`) - `api.quality.mypy-v2`: Shared query models, FastAPI dependencies, and v2 routes and services pass mypy with untyped definitions disallowed. (blocking: mypy) - `api.quality.ruff-format`: All Python files are formatted with ruff format. (blocking: ruff format) -- `api.quality.ruff-lint`: All Python code passes ruff check with every rule family selected in pyproject.toml, with no ratchet; families are added one themed group at a time until the selection is ALL. (blocking: ruff check (ANN, D, E4, E7, E9, F, FLY, FURB, I, PIE, PYI, RET, RSE, TC, UP, W)) +- `api.quality.ruff-lint`: All Python code passes ruff check with every rule family selected in pyproject.toml, with no ratchet; families are added one themed group at a time until the selection is ALL. (blocking: ruff check (ANN, BLE, D, E4, E7, E9, EM, F, FLY, FURB, I, PIE, PYI, RET, RSE, TC, TRY, UP, W)) - `api.quality.tests-pass`: The unit, contract and to-refactor test suites pass. (blocking: CI job `test`) - `api.quality.tests-ruff-exemptions`: Test files may use pytest's bare assert and need no docstrings, annotations or named constants for magic numbers (S101, D1, ANN, PLR2004); every other ruff rule applies to tests. (blocking: ruff per-file ignore of ANN, D1, PLR2004, S101 for `**/tests/**`) diff --git a/changelog.d/ruff-exceptions.changed.md b/changelog.d/ruff-exceptions.changed.md new file mode 100644 index 000000000..79ad72554 --- /dev/null +++ b/changelog.d/ruff-exceptions.changed.md @@ -0,0 +1 @@ +Enabled ruff's exception rules (TRY, EM, BLE): exception messages are built before the raise, raises inside try blocks moved into helpers, the v1 services raise typed exceptions, and every deliberate catch-all carries a reasoned suppression; error responses and message text are unchanged. diff --git a/docs/generated/rules.md b/docs/generated/rules.md index 58ea8e39b..4817dae11 100644 --- a/docs/generated/rules.md +++ b/docs/generated/rules.md @@ -268,7 +268,7 @@ flowchart LR | `api.quality.environment-variables-load` | Required environment variables and credentials load in a deployed-like environment. | mechanical | enforced | yes | CI job `test_env_vars` | `pr.yml:test_env_vars` | — | | `api.quality.mypy-v2` | Shared query models, FastAPI dependencies, and v2 routes and services pass mypy with untyped definitions disallowed. | mechanical | enforced | yes | mypy | `pr.yml:typecheck-v2` | — | | `api.quality.ruff-format` | All Python files are formatted with ruff format. | mechanical | enforced | yes | ruff format | `pr.yml:lint` | — | -| `api.quality.ruff-lint` | All Python code passes ruff check with every rule family selected in pyproject.toml, with no ratchet; families are added one themed group at a time until the selection is ALL. | mechanical | enforced | yes | ruff check (ANN, D, E4, E7, E9, F, FLY, FURB, I, PIE, PYI, RET, RSE, TC, UP, W) | `pr.yml:lint` | — | +| `api.quality.ruff-lint` | All Python code passes ruff check with every rule family selected in pyproject.toml, with no ratchet; families are added one themed group at a time until the selection is ALL. | mechanical | enforced | yes | ruff check (ANN, BLE, D, E4, E7, E9, EM, F, FLY, FURB, I, PIE, PYI, RET, RSE, TC, TRY, UP, W) | `pr.yml:lint` | — | | `api.quality.tests-pass` | The unit, contract and to-refactor test suites pass. | mechanical | enforced | yes | CI job `test` | `pr.yml:test` | — | | `api.quality.tests-ruff-exemptions` | Test files may use pytest's bare assert and need no docstrings, annotations or named constants for magic numbers (S101, D1, ANN, PLR2004); every other ruff rule applies to tests. | mechanical | enforced | yes | ruff per-file ignore of ANN, D1, PLR2004, S101 for `**/tests/**` | `pr.yml:lint` | — | @@ -287,7 +287,7 @@ flowchart LR e_ruff_format["ruff format"] j_CI_pr_yml_lint["CI pr.yml:lint"] r_api_quality_ruff_lint("api.quality.ruff-lint (enforced)") - e_ruff_check__ANN__D__E4__E7__E9__F__FLY__FURB__I__PIE__PYI__RET__RSE__TC__UP__W_["ruff check (ANN, D, E4, E7, E9, F, FLY, FURB, I, PIE, PYI, RET, RSE, TC, UP, W)"] + e_ruff_check__ANN__BLE__D__E4__E7__E9__EM__F__FLY__FURB__I__PIE__PYI__RET__RSE__TC__TRY__UP__W_["ruff check (ANN, BLE, D, E4, E7, E9, EM, F, FLY, FURB, I, PIE, PYI, RET, RSE, TC, TRY, UP, W)"] 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"] @@ -301,8 +301,8 @@ flowchart LR e_mypy --> j_CI_pr_yml_typecheck_v2 r_api_quality_ruff_format --> e_ruff_format e_ruff_format --> j_CI_pr_yml_lint - r_api_quality_ruff_lint --> e_ruff_check__ANN__D__E4__E7__E9__F__FLY__FURB__I__PIE__PYI__RET__RSE__TC__UP__W_ - e_ruff_check__ANN__D__E4__E7__E9__F__FLY__FURB__I__PIE__PYI__RET__RSE__TC__UP__W_ --> j_CI_pr_yml_lint + r_api_quality_ruff_lint --> e_ruff_check__ANN__BLE__D__E4__E7__E9__EM__F__FLY__FURB__I__PIE__PYI__RET__RSE__TC__TRY__UP__W_ + e_ruff_check__ANN__BLE__D__E4__E7__E9__EM__F__FLY__FURB__I__PIE__PYI__RET__RSE__TC__TRY__UP__W_ --> j_CI_pr_yml_lint r_api_quality_tests_pass --> e_CI_job_test e_CI_job_test --> j_CI_pr_yml_test r_api_quality_tests_ruff_exemptions --> e_ruff_per_file_ignore_of_ANN__D1__PLR2004__S101_for____tests___ diff --git a/rules/registry.toml b/rules/registry.toml index b32d04314..d1e450584 100644 --- a/rules/registry.toml +++ b/rules/registry.toml @@ -679,7 +679,7 @@ autofix = true [rule.enforcer] kind = "ruff" mode = "lint" -codes = ["ANN", "D", "E4", "E7", "E9", "F", "FLY", "FURB", "I", "PIE", "PYI", "RET", "RSE", "TC", "UP", "W"] +codes = ["ANN", "BLE", "D", "E4", "E7", "E9", "EM", "F", "FLY", "FURB", "I", "PIE", "PYI", "RET", "RSE", "TC", "TRY", "UP", "W"] [[rule.ci]] workflow = ".github/workflows/pr.yml" job = "lint" From 201d03a1bbb08865daccd16bdebd796fd3842f24 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:40:57 +0400 Subject: [PATCH 12/12] Hoist the region filter messages that #3918 added into a msg variable #3918's _serialize_region_filter_value passes f-string messages straight to CatalogExtractionError, which EM102 and TRY003, enabled on this branch, report. Assign each message to msg first, as the rest of the extraction module does; the messages are unchanged. Co-Authored-By: Claude Opus 5.5 --- policyengine_api/data/v2/catalog/extraction.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/policyengine_api/data/v2/catalog/extraction.py b/policyengine_api/data/v2/catalog/extraction.py index 82df26b26..7a760a385 100644 --- a/policyengine_api/data/v2/catalog/extraction.py +++ b/policyengine_api/data/v2/catalog/extraction.py @@ -119,17 +119,14 @@ def _serialize_region_filter_value(value: object, *, region_code: str) -> str: value, ensure_ascii=False, separators=(",", ":"), allow_nan=False ) except ValueError as error: - raise CatalogExtractionError( - f"region {region_code!r} has an unsupported filter value" - ) from error + msg = f"region {region_code!r} has an unsupported filter value" + raise CatalogExtractionError(msg) from error else: - raise CatalogExtractionError( - f"region {region_code!r} has an unsupported filter value" - ) + msg = f"region {region_code!r} has an unsupported filter value" + raise CatalogExtractionError(msg) if len(serialized) > 255: - raise CatalogExtractionError( - f"region {region_code!r} filter value exceeds 255 characters" - ) + msg = f"region {region_code!r} filter value exceeds 255 characters" + raise CatalogExtractionError(msg) return serialized