Skip to content

Commit b144246

Browse files
authored
Merge pull request #3913 from PolicyEngine/ruff-exceptions
Enable ruff exception rules (TRY, EM, BLE)
2 parents 8206f67 + a118c34 commit b144246

162 files changed

Lines changed: 1799 additions & 1458 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/find-api-model-versions.py‎

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -30,15 +30,15 @@
3030
def _manifest_versions(bundle: dict) -> dict[str, str]:
3131
packages = bundle.get("packages")
3232
if not isinstance(packages, dict):
33-
raise RuntimeError("PolicyEngine bundle manifest has no package mapping.")
33+
msg = "PolicyEngine bundle manifest has no package mapping."
34+
raise TypeError(msg)
3435

3536
versions = {}
3637
for package_name in REQUIRED_PACKAGES:
3738
package = packages.get(package_name)
3839
if not isinstance(package, dict) or not package.get("version"):
39-
raise RuntimeError(
40-
f"PolicyEngine bundle manifest has no version for {package_name}."
41-
)
40+
msg = f"PolicyEngine bundle manifest has no version for {package_name}."
41+
raise RuntimeError(msg)
4242
versions[package_name] = str(package["version"])
4343
return versions
4444

@@ -47,26 +47,27 @@ def _data_release_version(bundle: dict, country_id: str) -> str:
4747
data_releases = bundle.get("data_releases")
4848
release = data_releases.get(country_id) if isinstance(data_releases, dict) else None
4949
if not isinstance(release, dict) or not release.get("version"):
50-
raise RuntimeError(
51-
f"PolicyEngine bundle manifest has no {country_id.upper()} data release."
52-
)
50+
msg = f"PolicyEngine bundle manifest has no {country_id.upper()} data release."
51+
raise RuntimeError(msg)
5352
return str(release["version"])
5453

5554

5655
def find_api_model_versions() -> dict[str, str]:
5756
"""Find and validate the package and data versions in the installed bundle."""
5857
bundle = get_current_bundle()
5958
if not isinstance(bundle, dict):
60-
raise RuntimeError("Installed PolicyEngine bundle manifest is not an object.")
59+
msg = "Installed PolicyEngine bundle manifest is not an object."
60+
raise TypeError(msg)
6161

6262
manifest_versions = _manifest_versions(bundle)
6363
for package_name, manifest_version in manifest_versions.items():
6464
installed_version = distribution_version(package_name)
6565
if installed_version != manifest_version:
66-
raise RuntimeError(
66+
msg = (
6767
f"Installed {package_name} version {installed_version} does not match "
6868
f"bundle manifest version {manifest_version}."
6969
)
70+
raise RuntimeError(msg)
7071

7172
expected_constants = {
7273
"policyengine": POLICYENGINE_VERSION,
@@ -76,10 +77,11 @@ def find_api_model_versions() -> dict[str, str]:
7677
}
7778
for package_name, constant_version in expected_constants.items():
7879
if constant_version != manifest_versions[package_name]:
79-
raise RuntimeError(
80+
msg = (
8081
f"API version for {package_name} is {constant_version}, but the bundle "
8182
f"manifest specifies {manifest_versions[package_name]}."
8283
)
84+
raise RuntimeError(msg)
8385

8486
return {
8587
"POLICYENGINE_VERSION": manifest_versions["policyengine"],

‎.github/scripts/verify_observability_collector.py‎

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,8 @@ class VerificationProbe(NamedTuple):
7474
def _authority(endpoint: str) -> str:
7575
parsed = urlparse(endpoint)
7676
if parsed.scheme != "https" or not parsed.hostname:
77-
raise ValueError("collector endpoint must be an HTTPS URL")
77+
msg = "collector endpoint must be an HTTPS URL"
78+
raise ValueError(msg)
7879
return f"{parsed.hostname}:{parsed.port or 443}"
7980

8081

@@ -193,7 +194,8 @@ def _raise_for_partial_rejection(
193194
rejected = getattr(partial, rejected_field, 0)
194195
if rejected:
195196
message = getattr(partial, "error_message", "")
196-
raise RuntimeError(f"collector rejected {rejected} {signal}: {message}")
197+
msg = f"collector rejected {rejected} {signal}: {message}"
198+
raise RuntimeError(msg)
197199

198200

199201
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:
208210
if error.code == 404:
209211
return None
210212
detail = error.read().decode("utf-8", errors="replace")
211-
raise RuntimeError(
212-
f"Google Cloud verification request failed ({error.code}): {detail}"
213-
) from error
213+
msg = f"Google Cloud verification request failed ({error.code}): {detail}"
214+
raise RuntimeError(msg) from error
214215

215216

216217
def _trace_available(
@@ -302,7 +303,8 @@ def _wait_for_delivery(
302303
)
303304
if not found
304305
)
305-
raise RuntimeError(f"collector verification data was not stored: {missing}")
306+
msg = f"collector verification data was not stored: {missing}"
307+
raise RuntimeError(msg)
306308
time.sleep(min(poll_seconds, remaining))
307309

308310

@@ -357,7 +359,8 @@ def verify(
357359
if error.code() is not grpc.StatusCode.UNIMPLEMENTED:
358360
raise
359361
else:
360-
raise RuntimeError("collector unexpectedly accepted application logs")
362+
msg = "collector unexpectedly accepted application logs"
363+
raise RuntimeError(msg)
361364
_wait_for_delivery(
362365
project_id=project_id,
363366
trace_id=trace_id,

‎AGENTS.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ To add or change a rule, read `docs/engineering/skills/rules-registry.md`.
123123
- `api.quality.environment-variables-load`: Required environment variables and credentials load in a deployed-like environment. (blocking: CI job `test_env_vars`)
124124
- `api.quality.mypy-v2`: Shared query models, FastAPI dependencies, v2 routes and services, and the user sign-in and account-deletion libraries pass mypy with untyped definitions disallowed. (blocking: mypy)
125125
- `api.quality.ruff-format`: All Python files are formatted with ruff format. (blocking: ruff format)
126-
- `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))
126+
- `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))
127127
- `api.quality.runtime-evaluated-models-resolve`: Every package model is one ruff treats as runtime-evaluated (a base resolves, through the import in its own module, to a name in runtime-evaluated-base-classes, or is a same-module class that does), and every Pydantic model and SQLAlchemy mapper resolves its annotations once the package is imported. (blocking: pytest `tests/unit/test_runtime_evaluated_annotations.py`)
128128
- `api.quality.tests-pass`: The unit, contract and to-refactor test suites pass. (blocking: CI job `test`)
129129
- `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/**`)
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
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, best-effort telemetry and logging calls use a commented contextlib.suppress block, and every other deliberate catch-all carries a reasoned suppression. Period parsing and the simulation health probe now catch only the errors they expect, and a non-object .py bundle manifest raises TypeError. Error responses and message text are unchanged, except that a non-HTTP failure in the simulation health probe now gets the route's existing 503 for a client failure.

‎docs/generated/rules.md‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -272,7 +272,7 @@ flowchart LR
272272
| `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` | — |
273273
| `api.quality.mypy-v2` | Shared query models, FastAPI dependencies, v2 routes and services, and the user sign-in and account-deletion libraries pass mypy with untyped definitions disallowed. | mechanical | enforced | yes | mypy | `pr.yml:typecheck-v2` | — |
274274
| `api.quality.ruff-format` | All Python files are formatted with ruff format. | mechanical | enforced | yes | ruff format | `pr.yml:lint` | — |
275-
| `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` | — |
275+
| `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` | — |
276276
| `api.quality.runtime-evaluated-models-resolve` | Every package model is one ruff treats as runtime-evaluated (a base resolves, through the import in its own module, to a name in runtime-evaluated-base-classes, or is a same-module class that does), and every Pydantic model and SQLAlchemy mapper resolves its annotations once the package is imported. | mechanical | enforced | yes | pytest `tests/unit/test_runtime_evaluated_annotations.py` | `pr.yml:test` | **unproven** |
277277
| `api.quality.tests-pass` | The unit, contract and to-refactor test suites pass. | mechanical | enforced | yes | CI job `test` | `pr.yml:test` | — |
278278
| `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` | — |
@@ -292,7 +292,7 @@ flowchart LR
292292
e_ruff_format["ruff format"]
293293
j_CI_pr_yml_lint["CI pr.yml:lint"]
294294
r_api_quality_ruff_lint("api.quality.ruff-lint (enforced)")
295-
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)"]
295+
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)"]
296296
r_api_quality_runtime_evaluated_models_resolve("api.quality.runtime-evaluated-models-resolve (enforced)")
297297
e_pytest_tests_unit_test_runtime_evaluated_annotations_py["pytest tests/unit/test_runtime_evaluated_annotations.py"]
298298
j_CI_pr_yml_test["CI pr.yml:test"]
@@ -308,8 +308,8 @@ flowchart LR
308308
e_mypy --> j_CI_pr_yml_typecheck_v2
309309
r_api_quality_ruff_format --> e_ruff_format
310310
e_ruff_format --> j_CI_pr_yml_lint
311-
r_api_quality_ruff_lint --> e_ruff_check__ANN__D__E4__E7__E9__F__FLY__FURB__I__PIE__PYI__RET__RSE__TC__UP__W_
312-
e_ruff_check__ANN__D__E4__E7__E9__F__FLY__FURB__I__PIE__PYI__RET__RSE__TC__UP__W_ --> j_CI_pr_yml_lint
311+
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_
312+
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
313313
r_api_quality_runtime_evaluated_models_resolve --> e_pytest_tests_unit_test_runtime_evaluated_annotations_py
314314
e_pytest_tests_unit_test_runtime_evaluated_annotations_py --> j_CI_pr_yml_test
315315
r_api_quality_tests_pass --> e_CI_job_test

‎migrations/v1/env.py‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,18 +22,21 @@
2222
provided_connection = config.attributes.get("connection")
2323
if provided_connection is not None:
2424
if provided_connection.dialect.name != "mysql":
25-
raise RuntimeError("Alembic migrations must target a MySQL database")
25+
msg = "Alembic migrations must target a MySQL database"
26+
raise RuntimeError(msg)
2627
else:
2728
database_url = os.environ.get("ALEMBIC_DATABASE_URL") or config.get_main_option(
2829
"sqlalchemy.url"
2930
)
3031
if not database_url:
31-
raise RuntimeError(
32+
msg = (
3233
"ALEMBIC_DATABASE_URL is required; the local SQLite cache is not an "
3334
"Alembic migration target"
3435
)
36+
raise RuntimeError(msg)
3537
if make_url(database_url).get_backend_name() != "mysql":
36-
raise RuntimeError("Alembic migrations must target a MySQL database")
38+
msg = "Alembic migrations must target a MySQL database"
39+
raise RuntimeError(msg)
3740
config.set_main_option("sqlalchemy.url", database_url.replace("%", "%%"))
3841

3942
target_metadata = V1Base.metadata

‎migrations/v2/env.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,10 +65,11 @@ def _configure(connection: Connection) -> None:
6565

6666
def run_migrations_offline() -> None:
6767
"""Raise, because v2 migrations need a live connection to qualify the target."""
68-
raise RuntimeError(
68+
msg = (
6969
"v2 migrations require an online connection so target identity and "
7070
"generated application-data before/after states can be verified"
7171
)
72+
raise RuntimeError(msg)
7273

7374

7475
def run_migrations_online() -> None:

‎policyengine_api/asgi_factory.py‎

Lines changed: 15 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
import contextlib
56
import time
67
from contextlib import asynccontextmanager
78
from typing import TYPE_CHECKING, Any
@@ -236,19 +237,19 @@ async def add_request_context_and_migration_logging(
236237
request.state.policyengine_request_id = request_id
237238
_asgi_request_id.reset(context_token)
238239
context_token = _asgi_request_id.set(request_id)
239-
except Exception:
240+
except Exception: # noqa: BLE001 # telemetry must never fail the request
240241
pass
241-
try:
242+
# Telemetry must never fail the request.
243+
with contextlib.suppress(Exception):
242244
request_runtime.set_context(
243245
request_id=request_id,
244246
)
245-
except Exception:
246-
pass
247247

248248
def log_native_route(status_code: int) -> None:
249249
if not native_request:
250250
return
251-
try:
251+
# Migration logging must never fail the request.
252+
with contextlib.suppress(Exception):
252253
log_migration_request(
253254
request_id=request_id,
254255
method=request.method,
@@ -261,8 +262,6 @@ def log_native_route(status_code: int) -> None:
261262
),
262263
route_impl=RouteImplementation.FASTAPI_NATIVE,
263264
)
264-
except Exception:
265-
pass
266265

267266
def finish_native_route(
268267
status_code: int,
@@ -271,21 +270,18 @@ def finish_native_route(
271270
if not native_request:
272271
return
273272
resolved_route = getattr(request.scope.get("route"), "path", initial_route)
274-
try:
273+
# Telemetry must never fail the request.
274+
with contextlib.suppress(Exception):
275275
request_runtime.update_request_route(resolved_route)
276-
except Exception:
277-
pass
278-
try:
276+
# Telemetry must never fail the request.
277+
with contextlib.suppress(Exception):
279278
request_runtime.update_request_status(status_code)
280-
except Exception:
281-
pass
282-
try:
279+
# Telemetry must never fail the request.
280+
with contextlib.suppress(Exception):
283281
request_runtime.end_request(
284282
status_code=status_code,
285283
error=error,
286284
)
287-
except Exception:
288-
pass
289285

290286
try:
291287
try:
@@ -301,20 +297,19 @@ def finish_native_route(
301297
)
302298
)
303299
if native_request and response_observability_id is not None:
304-
try:
300+
# Telemetry must never fail the request.
301+
with contextlib.suppress(Exception):
305302
# Route-level spans have completed at this point, so this
306303
# call applies the selected calculation identifier to the
307304
# still-current server request span.
308305
request_runtime.set_context(
309306
observability_id=response_observability_id
310307
)
311-
except Exception:
312-
pass
313308
if native_request:
314309
try:
315310
for name, value in request_runtime.response_headers().items():
316311
response.headers[name] = value
317-
except Exception:
312+
except Exception: # noqa: BLE001 # telemetry must never fail the request
318313
pass
319314
_apply_request_id_header(response, request_id)
320315
if response_observability_id is not None:

‎policyengine_api/constants.py‎

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -62,12 +62,14 @@ def _load_policyengine_bundle() -> dict | None:
6262
with open(get_py_manifest()) as manifest_file:
6363
manifest = json.load(manifest_file)
6464
except Exception as exc:
65-
raise RuntimeError(
65+
msg = (
6666
"Could not read PolicyEngine .py bundle manifest from the installed "
6767
"policyengine wheel."
68-
) from exc
68+
)
69+
raise RuntimeError(msg) from exc
6970
if not isinstance(manifest, dict):
70-
raise RuntimeError("PolicyEngine .py bundle manifest must be a JSON object.")
71+
msg = "PolicyEngine .py bundle manifest must be a JSON object."
72+
raise TypeError(msg)
7173
return manifest
7274

7375

@@ -162,7 +164,7 @@ def get_bundle_default_dataset_option(country_id: str) -> dict:
162164
country: _resolve_distribution_version(_dist_versions, package_name)
163165
for country, package_name in zip(COUNTRIES, COUNTRY_PACKAGE_NAMES)
164166
}
165-
except Exception:
167+
except Exception: # noqa: BLE001 # unreadable package metadata falls back to 0.0.0; import must not fail
166168
_dist_versions = {}
167169
COUNTRY_PACKAGE_VERSIONS = {country: "0.0.0" for country in COUNTRIES}
168170

@@ -204,9 +206,8 @@ def _build_runtime_cache_version(
204206
prefix = "e" if scope == "economy_impact" else "r"
205207
digest_length = 10 - len(prefix) - len(schema_version)
206208
if digest_length < 4:
207-
raise ValueError(
208-
f"Runtime cache version for {scope} does not fit in VARCHAR(10)"
209-
)
209+
msg = f"Runtime cache version for {scope} does not fit in VARCHAR(10)"
210+
raise ValueError(msg)
210211

211212
raw = "|".join(
212213
(

‎policyengine_api/country.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -635,7 +635,7 @@ def _normalize_reform_values(
635635
node_type = float
636636
try:
637637
value = float(value)
638-
except Exception:
638+
except (TypeError, ValueError, OverflowError):
639639
pass
640640
normalized_reform[parameter_name][time_period] = node_type(value)
641641
return normalized_reform

0 commit comments

Comments
 (0)