sarj-python-lint 0.43.2__tar.gz → 0.44.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/PKG-INFO +20 -2
  2. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/README.md +19 -1
  3. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/pyproject.toml +1 -1
  4. sarj_python_lint-0.44.0/src/sarj_python_lint/rules/_fastapi.py +398 -0
  5. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/rules/_registry.py +2 -0
  6. sarj_python_lint-0.44.0/src/sarj_python_lint/rules/fastapi_openapi_contract.py +557 -0
  7. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +8 -0
  8. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/.gitignore +0 -0
  9. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/__init__.py +0 -0
  10. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/__main__.py +0 -0
  11. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/_ratchet_cli.py +0 -0
  12. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/_secret_names.py +0 -0
  13. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/_version.py +0 -0
  14. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/py.typed +0 -0
  15. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/ratchet.py +0 -0
  16. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/rule_base.py +0 -0
  17. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/rules/__init__.py +0 -0
  18. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/rules/_ast_index.py +0 -0
  19. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/rules/_comments.py +0 -0
  20. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/rules/_docstrings.py +0 -0
  21. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/rules/_first_party.py +0 -0
  22. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/rules/_logging.py +0 -0
  23. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/rules/_paths.py +0 -0
  24. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/rules/_prose_budget.py +0 -0
  25. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/rules/_pytest.py +0 -0
  26. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/rules/_sql.py +0 -0
  27. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
  28. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/rules/conditional_assertion_in_test.py +0 -0
  29. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
  30. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -0
  31. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/rules/duplicate_test_body.py +0 -0
  32. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/rules/duplicated_override_docstring.py +0 -0
  33. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/rules/fixture_returns_bare_tuple.py +0 -0
  34. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/rules/inefficient_string_concat_in_loop.py +0 -0
  35. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/rules/interaction_only_test.py +0 -0
  36. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/rules/kwarg_heavy_construction_in_test.py +0 -0
  37. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/rules/kwonly_same_type_params.py +0 -0
  38. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
  39. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/rules/no_aggregation_in_store_query.py +0 -0
  40. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
  41. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
  42. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/rules/no_fat_try_blocks.py +0 -0
  43. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/rules/no_file_level_escape_hatch_noqa.py +0 -0
  44. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/rules/no_file_level_suppression.py +0 -0
  45. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
  46. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/rules/no_fstring_in_log.py +0 -0
  47. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/rules/no_gen_random_uuid_in_sql.py +0 -0
  48. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/rules/no_isinstance_union_chain.py +0 -0
  49. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/rules/no_long_comment.py +0 -0
  50. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
  51. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/rules/no_optional_tenant_predicate.py +0 -0
  52. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/rules/no_query_with_many_joins.py +0 -0
  53. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/rules/no_raw_sql_in_tests.py +0 -0
  54. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
  55. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
  56. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  57. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/rules/no_select_star.py +0 -0
  58. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +0 -0
  59. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/rules/no_sequential_await.py +0 -0
  60. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/rules/no_sleep_in_test_body.py +0 -0
  61. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/rules/no_stdlib_logging.py +0 -0
  62. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
  63. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -0
  64. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/rules/no_unreachable_after_terminal.py +0 -0
  65. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
  66. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/rules/parametrize_case_needs_id.py +0 -0
  67. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/rules/phase_label_comment.py +0 -0
  68. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
  69. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
  70. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
  71. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
  72. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
  73. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +0 -0
  74. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
  75. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
  76. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +0 -0
  77. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
  78. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
  79. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/rules/prefer_real_store_in_tests.py +0 -0
  80. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
  81. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/rules/prefer_single_sentence_comment.py +0 -0
  82. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
  83. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
  84. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/rules/prefer_timedelta_for_durations.py +0 -0
  85. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
  86. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
  87. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
  88. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/rules/redundant_class_docstring.py +0 -0
  89. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
  90. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/rules/require_port_for_service.py +0 -0
  91. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/rules/restated_test_docstring.py +0 -0
  92. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/rules/single_public_export.py +0 -0
  93. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/rules/sleep_with_computed_arg_in_test.py +0 -0
  94. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/rules/stepdown.py +0 -0
  95. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
  96. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/rules/tautological_mock_assertion.py +0 -0
  97. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/rules/test_loops_over_literal_cases.py +0 -0
  98. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
  99. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/rules/trivially_true_assertion.py +0 -0
  100. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/rules/unused_mock_setup.py +0 -0
  101. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/rules/xfail_requires_strict.py +0 -0
  102. {sarj_python_lint-0.43.2 → sarj_python_lint-0.44.0}/src/sarj_python_lint/rules/zero_assertion_test.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sarj-python-lint
3
- Version: 0.43.2
3
+ Version: 0.44.0
4
4
  Summary: Custom Python lint rules — AST-based, pre-commit-friendly, hypermodern defaults
5
5
  Project-URL: Homepage, https://github.com/sarj-ai/standards/tree/main/packages/python
6
6
  Project-URL: Repository, https://github.com/sarj-ai/standards
@@ -28,13 +28,14 @@ uv tool install sarj-python-lint
28
28
 
29
29
  ```yaml
30
30
  - repo: https://github.com/sarj-ai/standards
31
- rev: python-v0.24.0
31
+ rev: python-v0.44.0
32
32
  hooks:
33
33
  - id: sarj-no-sequential-await
34
34
  - id: sarj-inefficient-string-concat-in-loop
35
35
  - id: sarj-prefer-str-enum
36
36
  - id: sarj-no-fat-try-blocks
37
37
  - id: sarj-pydantic-at-boundaries
38
+ - id: sarj-fastapi-openapi-contract # SARJ094
38
39
  - id: sarj-prefer-class-row
39
40
  - id: sarj-prefer-timedelta-for-durations
40
41
  - id: sarj-prefer-struct-over-namedtuple
@@ -43,6 +44,23 @@ uv tool install sarj-python-lint
43
44
  - id: sarj-prefer-non-nullable-collection # SARJ074
44
45
  ```
45
46
 
47
+ ### FastAPI OpenAPI contracts (0.44.0)
48
+
49
+ `SARJ094` complements Ruff's `ANN*` and `FAST001`-`FAST003` checks. It requires
50
+ schema-visible operations to declare their summary, description and status;
51
+ uses described `Annotated` request markers; rejects schema-erasing response
52
+ shapes and response projections; requires explicit content schemas for direct
53
+ response objects; and keeps direct errors, custom responses, bodyless statuses,
54
+ GET/HEAD inputs and local route ordering honest in OpenAPI. Missing Python
55
+ annotations remain owned by Ruff's `ANN*` rules, so enable both policies.
56
+
57
+ The rule resolves module-level FastAPI imports and locally constructed or
58
+ aliased routers without guessing from names. Dynamic decorator mappings,
59
+ function-local framework imports, imported router instances and the assembled
60
+ `app.openapi()` document remain application-level integration-test concerns.
61
+ Existing projects can adopt the default-enabled rule with `--update-baseline`
62
+ and then shrink that baseline as endpoint contracts are repaired.
63
+
46
64
  ### Test-quality rules (0.15.0)
47
65
 
48
66
  Mined from an AST audit of ~7,500 test functions across two production repos.
@@ -10,13 +10,14 @@ uv tool install sarj-python-lint
10
10
 
11
11
  ```yaml
12
12
  - repo: https://github.com/sarj-ai/standards
13
- rev: python-v0.24.0
13
+ rev: python-v0.44.0
14
14
  hooks:
15
15
  - id: sarj-no-sequential-await
16
16
  - id: sarj-inefficient-string-concat-in-loop
17
17
  - id: sarj-prefer-str-enum
18
18
  - id: sarj-no-fat-try-blocks
19
19
  - id: sarj-pydantic-at-boundaries
20
+ - id: sarj-fastapi-openapi-contract # SARJ094
20
21
  - id: sarj-prefer-class-row
21
22
  - id: sarj-prefer-timedelta-for-durations
22
23
  - id: sarj-prefer-struct-over-namedtuple
@@ -25,6 +26,23 @@ uv tool install sarj-python-lint
25
26
  - id: sarj-prefer-non-nullable-collection # SARJ074
26
27
  ```
27
28
 
29
+ ### FastAPI OpenAPI contracts (0.44.0)
30
+
31
+ `SARJ094` complements Ruff's `ANN*` and `FAST001`-`FAST003` checks. It requires
32
+ schema-visible operations to declare their summary, description and status;
33
+ uses described `Annotated` request markers; rejects schema-erasing response
34
+ shapes and response projections; requires explicit content schemas for direct
35
+ response objects; and keeps direct errors, custom responses, bodyless statuses,
36
+ GET/HEAD inputs and local route ordering honest in OpenAPI. Missing Python
37
+ annotations remain owned by Ruff's `ANN*` rules, so enable both policies.
38
+
39
+ The rule resolves module-level FastAPI imports and locally constructed or
40
+ aliased routers without guessing from names. Dynamic decorator mappings,
41
+ function-local framework imports, imported router instances and the assembled
42
+ `app.openapi()` document remain application-level integration-test concerns.
43
+ Existing projects can adopt the default-enabled rule with `--update-baseline`
44
+ and then shrink that baseline as endpoint contracts are repaired.
45
+
28
46
  ### Test-quality rules (0.15.0)
29
47
 
30
48
  Mined from an AST audit of ~7,500 test functions across two production repos.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sarj-python-lint"
3
- version = "0.43.2"
3
+ version = "0.44.0"
4
4
  description = "Custom Python lint rules — AST-based, pre-commit-friendly, hypermodern defaults"
5
5
  readme = "README.md"
6
6
  authors = [{ name = "sarj-ai" }]
@@ -0,0 +1,398 @@
1
+ """Deterministic FastAPI syntax resolution shared by route-aware rules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ from collections import Counter
7
+ from dataclasses import dataclass
8
+
9
+
10
+ HTTP_METHODS = frozenset({"get", "post", "put", "patch", "delete", "options", "head", "trace", "api_route"})
11
+ PARAM_MARKERS = frozenset({"Path", "Query", "Header", "Cookie", "Body", "Form", "File", "Depends", "Security"})
12
+ SCHEMA_MARKERS = PARAM_MARKERS - {"Depends", "Security"}
13
+ INJECTION_TYPES = frozenset({"Request", "Response", "WebSocket", "HTTPConnection", "BackgroundTasks", "SecurityScopes"})
14
+ RESPONSE_TYPES = frozenset(
15
+ {
16
+ "Response",
17
+ "JSONResponse",
18
+ "HTMLResponse",
19
+ "PlainTextResponse",
20
+ "RedirectResponse",
21
+ "StreamingResponse",
22
+ "FileResponse",
23
+ "ORJSONResponse",
24
+ "UJSONResponse",
25
+ }
26
+ )
27
+
28
+
29
+ @dataclass(frozen=True, slots=True)
30
+ class Route:
31
+ decorator: ast.Call
32
+ receiver: str
33
+ method: str
34
+ path: str | None
35
+ inherited_hidden: bool = False
36
+
37
+ @property
38
+ def keywords(self) -> dict[str, ast.expr]:
39
+ return {keyword.arg: keyword.value for keyword in self.decorator.keywords if keyword.arg is not None}
40
+
41
+ @property
42
+ def has_unpack(self) -> bool:
43
+ return any(keyword.arg is None for keyword in self.decorator.keywords)
44
+
45
+ @property
46
+ def is_hidden(self) -> bool:
47
+ if self.inherited_hidden:
48
+ return True
49
+ value = self.keywords.get("include_in_schema")
50
+ return isinstance(value, ast.Constant) and value.value is False
51
+
52
+
53
+ def flat_name(node: ast.expr) -> str:
54
+ if isinstance(node, ast.Name):
55
+ return node.id
56
+ if isinstance(node, ast.Attribute):
57
+ return node.attr
58
+ return ""
59
+
60
+
61
+ def _binding_name(node: ast.expr) -> str:
62
+ if isinstance(node, ast.Name):
63
+ return node.id
64
+ if isinstance(node, ast.Attribute) and isinstance(node.value, ast.Name) and node.value.id == "self":
65
+ return f"self.{node.attr}"
66
+ return ""
67
+
68
+
69
+ class FastapiIndex:
70
+ """Resolve only FastAPI bindings whose provenance is visible in one module."""
71
+
72
+ def __init__(self, tree: ast.Module) -> None:
73
+ self.modules: set[str] = set()
74
+ self.module_symbols: dict[str, str] = {}
75
+ self.http_modules: set[str] = set()
76
+ self.annotation_modules: set[str] = set()
77
+ self.constructors: set[str] = set()
78
+ self.annotated: set[str] = set()
79
+ self.symbols: dict[str, str] = {}
80
+ self.type_aliases: dict[str, ast.expr] = {}
81
+ self.receivers: set[tuple[int, str]] = set()
82
+ self.receiver_origins: dict[tuple[int, str], tuple[int, str]] = {}
83
+ self.hidden_receivers: set[tuple[int, str]] = set()
84
+ self.decorators: dict[tuple[int, str], tuple[str, str]] = {}
85
+ self.bound_names: set[tuple[int, str]] = set()
86
+ self._node_scopes: dict[int, int] = {}
87
+ self._node_classes: dict[int, int | None] = {}
88
+ self._route_scopes: dict[int, int] = {}
89
+ self._scope_parents: dict[int, int | None] = {0: None}
90
+ self._index_scopes(tree)
91
+ self._read_imports(tree)
92
+ self._read_aliases(tree)
93
+
94
+ def _index_scopes(self, tree: ast.Module) -> None:
95
+ def visit(node: ast.AST, scope: int, class_owner: int | None) -> None:
96
+ self._node_scopes[id(node)] = scope
97
+ if isinstance(node, ast.ClassDef):
98
+ class_owner = node.lineno
99
+ self._scope_parents[node.lineno] = scope
100
+ scope = node.lineno
101
+ self._node_classes[id(node)] = class_owner
102
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
103
+ self._route_scopes[id(node)] = scope
104
+ self._scope_parents[node.lineno] = scope
105
+ scope = node.lineno
106
+ for child in ast.iter_child_nodes(node):
107
+ visit(child, scope, class_owner)
108
+
109
+ visit(tree, 0, None)
110
+
111
+ def _read_imports(self, tree: ast.Module) -> None:
112
+ for node in ast.walk(tree):
113
+ if self._node_scopes[id(node)] != 0:
114
+ continue
115
+ if isinstance(node, ast.Import):
116
+ for alias in node.names:
117
+ local = alias.asname or alias.name.split(".")[0]
118
+ if alias.name == "fastapi" or alias.name.startswith(("fastapi.", "starlette.")):
119
+ self.modules.add(local)
120
+ self.module_symbols[local] = alias.name.rsplit(".", 1)[-1]
121
+ if alias.name in {"typing", "typing_extensions"}:
122
+ self.annotation_modules.add(local)
123
+ if alias.name == "http":
124
+ self.http_modules.add(local)
125
+ elif isinstance(node, ast.ImportFrom):
126
+ module = node.module or ""
127
+ if module in {"typing", "typing_extensions"}:
128
+ for alias in node.names:
129
+ if alias.name == "Annotated":
130
+ self.annotated.add(alias.asname or alias.name)
131
+ continue
132
+ if module == "http":
133
+ for alias in node.names:
134
+ if alias.name == "HTTPStatus":
135
+ self.symbols[alias.asname or alias.name] = "HTTPStatus"
136
+ continue
137
+ if module != "fastapi" and not module.startswith(("fastapi.", "starlette.")):
138
+ continue
139
+ for alias in node.names:
140
+ local = alias.asname or alias.name
141
+ self.symbols[local] = alias.name
142
+ if alias.name in {"FastAPI", "APIRouter"}:
143
+ self.constructors.add(local)
144
+
145
+ def _read_aliases(self, tree: ast.Module) -> None:
146
+ assignments: list[tuple[int, str, ast.expr]] = []
147
+ for node in ast.walk(tree):
148
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
149
+ self.bound_names.add((self._node_scopes[id(node)], node.name))
150
+ arguments = [*node.args.posonlyargs, *node.args.args, *node.args.kwonlyargs]
151
+ if node.args.vararg is not None:
152
+ arguments.append(node.args.vararg)
153
+ if node.args.kwarg is not None:
154
+ arguments.append(node.args.kwarg)
155
+ self.bound_names.update((node.lineno, argument.arg) for argument in arguments)
156
+ elif isinstance(node, ast.ClassDef):
157
+ self.bound_names.add((self._node_scopes[id(node)], node.name))
158
+ elif isinstance(node, (ast.Import, ast.ImportFrom)):
159
+ self.bound_names.update(
160
+ (self._node_scopes[id(node)], alias.asname or alias.name.split(".")[0]) for alias in node.names
161
+ )
162
+ elif isinstance(node, (ast.For, ast.AsyncFor)):
163
+ self.bound_names.update((self._node_scopes[id(node)], name) for name in self._target_names(node.target))
164
+ elif isinstance(node, ast.With):
165
+ for item in node.items:
166
+ if item.optional_vars is not None:
167
+ self.bound_names.update(
168
+ (self._node_scopes[id(node)], name) for name in self._target_names(item.optional_vars)
169
+ )
170
+ elif isinstance(node, ast.ExceptHandler) and node.name is not None:
171
+ self.bound_names.add((self._node_scopes[id(node)], node.name))
172
+ elif isinstance(node, ast.NamedExpr):
173
+ self.bound_names.update((self._node_scopes[id(node)], name) for name in self._target_names(node.target))
174
+ if isinstance(node, ast.Assign) and len(node.targets) == 1:
175
+ name = _binding_name(node.targets[0])
176
+ if name:
177
+ assignments.append((self._assignment_scope(node, name), name, node.value))
178
+ elif isinstance(node, ast.AnnAssign) and node.value is not None:
179
+ name = _binding_name(node.target)
180
+ if name:
181
+ assignments.append((self._assignment_scope(node, name), name, node.value))
182
+ elif isinstance(node, ast.TypeAlias):
183
+ self.type_aliases[node.name.id] = node.value
184
+
185
+ changed = True
186
+ counts = Counter((scope, name) for scope, name, _value in assignments)
187
+ self.bound_names.update(counts)
188
+ while changed:
189
+ changed = False
190
+ for scope, name, value in assignments:
191
+ key = (scope, name)
192
+ if counts[key] != 1:
193
+ continue
194
+ if key in self.receivers or key in self.decorators:
195
+ continue
196
+ source = self._receiver_key(scope, _binding_name(value))
197
+ if self._is_constructor_call(value) or source is not None:
198
+ self.receivers.add(key)
199
+ self.receiver_origins[key] = key if source is None else self.receiver_origins[source]
200
+ if self._constructor_is_hidden(value) or (source is not None and source in self.hidden_receivers):
201
+ self.hidden_receivers.add(key)
202
+ changed = True
203
+ continue
204
+ if isinstance(value, ast.Attribute) and value.attr in HTTP_METHODS:
205
+ receiver = _binding_name(value.value)
206
+ if self._receiver_key(scope, receiver) is not None:
207
+ self.decorators[key] = (receiver, value.attr)
208
+ changed = True
209
+ continue
210
+ if self._is_annotated(value):
211
+ self.type_aliases[name] = value
212
+
213
+ def _target_names(self, node: ast.expr) -> tuple[str, ...]:
214
+ if isinstance(node, ast.Name):
215
+ return (node.id,)
216
+ if isinstance(node, (ast.Tuple, ast.List)):
217
+ return tuple(name for item in node.elts for name in self._target_names(item))
218
+ return ()
219
+
220
+ def _assignment_scope(self, node: ast.Assign | ast.AnnAssign, name: str) -> int:
221
+ if name.startswith("self."):
222
+ return self._node_classes[id(node)] or self._node_scopes[id(node)]
223
+ return self._node_scopes[id(node)]
224
+
225
+ def _receiver_key(self, scope: int, name: str) -> tuple[int, str] | None:
226
+ if not name:
227
+ return None
228
+ current: int | None = scope
229
+ while current is not None:
230
+ binding = (current, name)
231
+ if binding in self.receivers:
232
+ return binding
233
+ if binding in self.bound_names:
234
+ return None
235
+ current = self._scope_parents[current]
236
+ return None
237
+
238
+ def _decorator(self, scope: int, name: str) -> tuple[str, str] | None:
239
+ current: int | None = scope
240
+ while current is not None:
241
+ binding = (current, name)
242
+ if binding in self.decorators:
243
+ return self.decorators[binding]
244
+ if binding in self.bound_names:
245
+ return None
246
+ current = self._scope_parents[current]
247
+ return None
248
+
249
+ def _constructor_is_hidden(self, node: ast.expr) -> bool:
250
+ if not self._is_constructor_call(node) or not isinstance(node, ast.Call):
251
+ return False
252
+ value = next((keyword.value for keyword in node.keywords if keyword.arg == "include_in_schema"), None)
253
+ return isinstance(value, ast.Constant) and value.value is False
254
+
255
+ def _is_constructor_call(self, node: ast.expr) -> bool:
256
+ if not isinstance(node, ast.Call):
257
+ return False
258
+ if isinstance(node.func, ast.Name):
259
+ return node.func.id in self.constructors
260
+ return (
261
+ isinstance(node.func, ast.Attribute)
262
+ and isinstance(node.func.value, ast.Name)
263
+ and node.func.value.id in self.modules
264
+ and node.func.attr in {"FastAPI", "APIRouter"}
265
+ )
266
+
267
+ def routes(self, function: ast.FunctionDef | ast.AsyncFunctionDef) -> tuple[Route, ...]:
268
+ routes: list[Route] = []
269
+ scope = self._route_scopes.get(id(function), 0)
270
+ for decorator in function.decorator_list:
271
+ if not isinstance(decorator, ast.Call):
272
+ continue
273
+ method = ""
274
+ receiver = ""
275
+ receiver_key = None
276
+ if isinstance(decorator.func, ast.Attribute):
277
+ receiver = _binding_name(decorator.func.value)
278
+ lookup_scope = self._node_classes[id(function)] if receiver.startswith("self.") else scope
279
+ receiver_key = self._receiver_key(lookup_scope or scope, receiver)
280
+ if receiver_key is not None:
281
+ method = decorator.func.attr
282
+ elif isinstance(decorator.func, ast.Name) and (resolved := self._decorator(scope, decorator.func.id)):
283
+ receiver, method = resolved
284
+ receiver_key = self._receiver_key(scope, receiver)
285
+ if method not in HTTP_METHODS or receiver_key is None:
286
+ continue
287
+ path_node = (
288
+ decorator.args[0]
289
+ if decorator.args
290
+ else next((keyword.value for keyword in decorator.keywords if keyword.arg == "path"), None)
291
+ )
292
+ path = path_node.value if isinstance(path_node, ast.Constant) and isinstance(path_node.value, str) else None
293
+ route_methods = self._route_methods(decorator) if method == "api_route" else (method,)
294
+ origin_scope, origin_name = self.receiver_origins[receiver_key]
295
+ routes.extend(
296
+ Route(
297
+ decorator=decorator,
298
+ receiver=f"{origin_scope}:{origin_name}",
299
+ method=route_method,
300
+ path=path,
301
+ inherited_hidden=receiver_key in self.hidden_receivers,
302
+ )
303
+ for route_method in route_methods
304
+ )
305
+ return tuple(routes)
306
+
307
+ @staticmethod
308
+ def _route_methods(decorator: ast.Call) -> tuple[str, ...]:
309
+ value = next((keyword.value for keyword in decorator.keywords if keyword.arg == "methods"), None)
310
+ if value is None:
311
+ return ("get",)
312
+ if not isinstance(value, (ast.List, ast.Tuple, ast.Set)):
313
+ return ("*",)
314
+ methods = {
315
+ item.value.lower()
316
+ for item in value.elts
317
+ if isinstance(item, ast.Constant)
318
+ and isinstance(item.value, str)
319
+ and item.value.lower() in HTTP_METHODS - {"api_route"}
320
+ }
321
+ return tuple(sorted(methods)) or ("*",)
322
+
323
+ def canonical(self, node: ast.expr) -> str:
324
+ if isinstance(node, ast.Name):
325
+ if node.id in self.modules:
326
+ return self.module_symbols[node.id]
327
+ return self.symbols.get(node.id, "")
328
+ if isinstance(node, ast.Attribute) and self._root_name(node) in self.modules:
329
+ return node.attr
330
+ if isinstance(node, ast.Attribute) and self._root_name(node) in self.http_modules:
331
+ return node.attr
332
+ return ""
333
+
334
+ @staticmethod
335
+ def _root_name(node: ast.Attribute) -> str:
336
+ value: ast.expr = node
337
+ while isinstance(value, ast.Attribute):
338
+ value = value.value
339
+ return value.id if isinstance(value, ast.Name) else ""
340
+
341
+ def resolve_annotation(self, node: ast.expr | None) -> ast.expr | None:
342
+ if isinstance(node, ast.Constant) and isinstance(node.value, str):
343
+ try:
344
+ node = ast.parse(node.value.strip(), mode="eval").body
345
+ except SyntaxError:
346
+ return None
347
+ seen: set[str] = set()
348
+ while isinstance(node, ast.Name) and node.id in self.type_aliases and node.id not in seen:
349
+ seen.add(node.id)
350
+ node = self.type_aliases[node.id]
351
+ return node
352
+
353
+ def annotated_parts(self, node: ast.expr | None) -> tuple[ast.expr, tuple[ast.expr, ...]] | None:
354
+ resolved = self.resolve_annotation(node)
355
+ if not self._is_annotated(resolved) or not isinstance(resolved, ast.Subscript):
356
+ return None
357
+ elements = resolved.slice.elts if isinstance(resolved.slice, ast.Tuple) else [resolved.slice]
358
+ if not elements:
359
+ return None
360
+ return elements[0], tuple(elements[1:])
361
+
362
+ def _is_annotated(self, node: ast.expr | None) -> bool:
363
+ if not isinstance(node, ast.Subscript):
364
+ return False
365
+ value = node.value
366
+ if isinstance(value, ast.Name):
367
+ return value.id in self.annotated
368
+ return (
369
+ isinstance(value, ast.Attribute)
370
+ and value.attr == "Annotated"
371
+ and self._root_name(value) in self.annotation_modules
372
+ )
373
+
374
+ def marker(self, node: ast.expr) -> tuple[str, ast.Call] | None:
375
+ if not isinstance(node, ast.Call):
376
+ return None
377
+ canonical = self.canonical(node.func)
378
+ if canonical not in PARAM_MARKERS:
379
+ return None
380
+ return canonical, node
381
+
382
+ def is_injection(self, node: ast.expr | None) -> bool:
383
+ resolved = self.resolve_annotation(node)
384
+ return isinstance(resolved, (ast.Name, ast.Attribute)) and self.canonical(resolved) in INJECTION_TYPES
385
+
386
+ def is_response(self, node: ast.expr | None) -> bool:
387
+ resolved = self.resolve_annotation(node)
388
+ return isinstance(resolved, (ast.Name, ast.Attribute)) and self.canonical(resolved) in RESPONSE_TYPES
389
+
390
+ def response_name(self, node: ast.expr | None) -> str:
391
+ resolved = self.resolve_annotation(node)
392
+ if not isinstance(resolved, (ast.Name, ast.Attribute)):
393
+ return ""
394
+ name = self.canonical(resolved)
395
+ return name if name in RESPONSE_TYPES else ""
396
+
397
+ def is_http_exception(self, node: ast.expr) -> bool:
398
+ return self.canonical(node) == "HTTPException"
@@ -13,6 +13,7 @@ from sarj_python_lint.rules.duplicate_test_body import DuplicateTestBody
13
13
  from sarj_python_lint.rules.duplicated_override_docstring import (
14
14
  DuplicatedOverrideDocstring,
15
15
  )
16
+ from sarj_python_lint.rules.fastapi_openapi_contract import FastapiOpenapiContract
16
17
  from sarj_python_lint.rules.fixture_returns_bare_tuple import FixtureReturnsBareTuple
17
18
  from sarj_python_lint.rules.inefficient_string_concat_in_loop import (
18
19
  InefficientStringConcatInLoop,
@@ -131,6 +132,7 @@ REGISTRY: dict[str, type[Rule]] = {
131
132
  NoCorsWildcardWithCredentials.id: NoCorsWildcardWithCredentials,
132
133
  NoSleepInTestBody.id: NoSleepInTestBody,
133
134
  PydanticAtBoundaries.id: PydanticAtBoundaries,
135
+ FastapiOpenapiContract.id: FastapiOpenapiContract,
134
136
  NoSentinelReturnOnExcept.id: NoSentinelReturnOnExcept,
135
137
  NoUnreachableAfterTerminal.id: NoUnreachableAfterTerminal,
136
138
  PreferConstantTimeSecretCompare.id: PreferConstantTimeSecretCompare,