sarj-python-lint 0.43.2__tar.gz → 0.45.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 (103) hide show
  1. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/PKG-INFO +44 -2
  2. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/README.md +43 -1
  3. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/pyproject.toml +1 -1
  4. sarj_python_lint-0.45.0/src/sarj_python_lint/rules/_fastapi.py +398 -0
  5. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/_registry.py +6 -0
  6. sarj_python_lint-0.45.0/src/sarj_python_lint/rules/fastapi_openapi_contract.py +557 -0
  7. sarj_python_lint-0.45.0/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +701 -0
  8. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +8 -0
  9. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/.gitignore +0 -0
  10. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/src/sarj_python_lint/__init__.py +0 -0
  11. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/src/sarj_python_lint/__main__.py +0 -0
  12. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/src/sarj_python_lint/_ratchet_cli.py +0 -0
  13. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/src/sarj_python_lint/_secret_names.py +0 -0
  14. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/src/sarj_python_lint/_version.py +0 -0
  15. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/src/sarj_python_lint/py.typed +0 -0
  16. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/src/sarj_python_lint/ratchet.py +0 -0
  17. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rule_base.py +0 -0
  18. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/__init__.py +0 -0
  19. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/_ast_index.py +0 -0
  20. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/_comments.py +0 -0
  21. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/_docstrings.py +0 -0
  22. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/_first_party.py +0 -0
  23. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/_logging.py +0 -0
  24. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/_paths.py +0 -0
  25. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/_prose_budget.py +0 -0
  26. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/_pytest.py +0 -0
  27. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/_sql.py +0 -0
  28. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
  29. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/conditional_assertion_in_test.py +0 -0
  30. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
  31. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -0
  32. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/duplicate_test_body.py +0 -0
  33. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/duplicated_override_docstring.py +0 -0
  34. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/fixture_returns_bare_tuple.py +0 -0
  35. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/inefficient_string_concat_in_loop.py +0 -0
  36. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/interaction_only_test.py +0 -0
  37. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/kwarg_heavy_construction_in_test.py +0 -0
  38. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/kwonly_same_type_params.py +0 -0
  39. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
  40. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/no_aggregation_in_store_query.py +0 -0
  41. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
  42. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
  43. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/no_fat_try_blocks.py +0 -0
  44. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/no_file_level_escape_hatch_noqa.py +0 -0
  45. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/no_file_level_suppression.py +0 -0
  46. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
  47. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/no_fstring_in_log.py +0 -0
  48. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/no_gen_random_uuid_in_sql.py +0 -0
  49. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/no_isinstance_union_chain.py +0 -0
  50. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/no_long_comment.py +0 -0
  51. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
  52. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/no_optional_tenant_predicate.py +0 -0
  53. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/no_query_with_many_joins.py +0 -0
  54. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/no_raw_sql_in_tests.py +0 -0
  55. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
  56. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
  57. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  58. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/no_select_star.py +0 -0
  59. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +0 -0
  60. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/no_sequential_await.py +0 -0
  61. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/no_sleep_in_test_body.py +0 -0
  62. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/no_stdlib_logging.py +0 -0
  63. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
  64. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -0
  65. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/no_unreachable_after_terminal.py +0 -0
  66. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
  67. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/parametrize_case_needs_id.py +0 -0
  68. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/phase_label_comment.py +0 -0
  69. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
  70. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
  71. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
  72. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
  73. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
  74. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +0 -0
  75. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
  76. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
  77. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +0 -0
  78. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
  79. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
  80. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/prefer_real_store_in_tests.py +0 -0
  81. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
  82. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/prefer_single_sentence_comment.py +0 -0
  83. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
  84. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
  85. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/prefer_timedelta_for_durations.py +0 -0
  86. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
  87. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
  88. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
  89. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/redundant_class_docstring.py +0 -0
  90. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
  91. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/require_port_for_service.py +0 -0
  92. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/restated_test_docstring.py +0 -0
  93. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/single_public_export.py +0 -0
  94. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/sleep_with_computed_arg_in_test.py +0 -0
  95. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/stepdown.py +0 -0
  96. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
  97. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/tautological_mock_assertion.py +0 -0
  98. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/test_loops_over_literal_cases.py +0 -0
  99. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
  100. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/trivially_true_assertion.py +0 -0
  101. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/unused_mock_setup.py +0 -0
  102. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/xfail_requires_strict.py +0 -0
  103. {sarj_python_lint-0.43.2 → sarj_python_lint-0.45.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.45.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,15 @@ 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.45.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
39
+ - id: sarj-no-hidden-constructor-fallback # SARJ095 (warning)
38
40
  - id: sarj-prefer-class-row
39
41
  - id: sarj-prefer-timedelta-for-durations
40
42
  - id: sarj-prefer-struct-over-namedtuple
@@ -43,6 +45,46 @@ uv tool install sarj-python-lint
43
45
  - id: sarj-prefer-non-nullable-collection # SARJ074
44
46
  ```
45
47
 
48
+ ### FastAPI OpenAPI contracts (0.44.0)
49
+
50
+ `SARJ094` complements Ruff's `ANN*` and `FAST001`-`FAST003` checks. It requires
51
+ schema-visible operations to declare their summary, description and status;
52
+ uses described `Annotated` request markers; rejects schema-erasing response
53
+ shapes and response projections; requires explicit content schemas for direct
54
+ response objects; and keeps direct errors, custom responses, bodyless statuses,
55
+ GET/HEAD inputs and local route ordering honest in OpenAPI. Missing Python
56
+ annotations remain owned by Ruff's `ANN*` rules, so enable both policies.
57
+
58
+ The rule resolves module-level FastAPI imports and locally constructed or
59
+ aliased routers without guessing from names. Dynamic decorator mappings,
60
+ function-local framework imports, imported router instances and the assembled
61
+ `app.openapi()` document remain application-level integration-test concerns.
62
+ Existing projects can adopt the default-enabled rule with `--update-baseline`
63
+ and then shrink that baseline as endpoint contracts are repaired.
64
+
65
+ ### Hidden constructor settings fallback (0.45.0)
66
+
67
+ `SARJ095` warns when a keyword-only constructor parameter defaults to `None`
68
+ and the constructor silently replaces it with a proven pydantic-settings value.
69
+ The effective dependency is invisible at the call site, and `value or
70
+ settings.VALUE` also treats an explicit falsey value as omitted. Make the
71
+ argument required and resolve the setting at the application composition root;
72
+ the annotation may remain nullable when `None` is still a valid explicit value.
73
+
74
+ The rule resolves same-module settings objects, imports, aliases and re-exports
75
+ back to an instance of a `pydantic_settings.BaseSettings` subclass. Literal and
76
+ enum defaults, mutable-container initialization, arbitrary factories and
77
+ clients, module constants, environment-variable APIs, other parameters and
78
+ instance state are deliberately outside v1. No autofix is offered because
79
+ changing constructor optionality requires coordinated call-site edits.
80
+
81
+ Measured over 4,638 tracked Python files in 33 first-party repositories: three
82
+ constructor warnings across two repositories, all three actionable. A pinned
83
+ 15-repository OSS sweep covered 29,203 files and produced zero reports, which is
84
+ compatibility evidence rather than a precision claim. An environment-variable
85
+ arm was rejected before shipping: it reported a public first-party library and
86
+ two intentional LiteLLM integration constructors, all non-actionable.
87
+
46
88
  ### Test-quality rules (0.15.0)
47
89
 
48
90
  Mined from an AST audit of ~7,500 test functions across two production repos.
@@ -10,13 +10,15 @@ 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.45.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
21
+ - id: sarj-no-hidden-constructor-fallback # SARJ095 (warning)
20
22
  - id: sarj-prefer-class-row
21
23
  - id: sarj-prefer-timedelta-for-durations
22
24
  - id: sarj-prefer-struct-over-namedtuple
@@ -25,6 +27,46 @@ uv tool install sarj-python-lint
25
27
  - id: sarj-prefer-non-nullable-collection # SARJ074
26
28
  ```
27
29
 
30
+ ### FastAPI OpenAPI contracts (0.44.0)
31
+
32
+ `SARJ094` complements Ruff's `ANN*` and `FAST001`-`FAST003` checks. It requires
33
+ schema-visible operations to declare their summary, description and status;
34
+ uses described `Annotated` request markers; rejects schema-erasing response
35
+ shapes and response projections; requires explicit content schemas for direct
36
+ response objects; and keeps direct errors, custom responses, bodyless statuses,
37
+ GET/HEAD inputs and local route ordering honest in OpenAPI. Missing Python
38
+ annotations remain owned by Ruff's `ANN*` rules, so enable both policies.
39
+
40
+ The rule resolves module-level FastAPI imports and locally constructed or
41
+ aliased routers without guessing from names. Dynamic decorator mappings,
42
+ function-local framework imports, imported router instances and the assembled
43
+ `app.openapi()` document remain application-level integration-test concerns.
44
+ Existing projects can adopt the default-enabled rule with `--update-baseline`
45
+ and then shrink that baseline as endpoint contracts are repaired.
46
+
47
+ ### Hidden constructor settings fallback (0.45.0)
48
+
49
+ `SARJ095` warns when a keyword-only constructor parameter defaults to `None`
50
+ and the constructor silently replaces it with a proven pydantic-settings value.
51
+ The effective dependency is invisible at the call site, and `value or
52
+ settings.VALUE` also treats an explicit falsey value as omitted. Make the
53
+ argument required and resolve the setting at the application composition root;
54
+ the annotation may remain nullable when `None` is still a valid explicit value.
55
+
56
+ The rule resolves same-module settings objects, imports, aliases and re-exports
57
+ back to an instance of a `pydantic_settings.BaseSettings` subclass. Literal and
58
+ enum defaults, mutable-container initialization, arbitrary factories and
59
+ clients, module constants, environment-variable APIs, other parameters and
60
+ instance state are deliberately outside v1. No autofix is offered because
61
+ changing constructor optionality requires coordinated call-site edits.
62
+
63
+ Measured over 4,638 tracked Python files in 33 first-party repositories: three
64
+ constructor warnings across two repositories, all three actionable. A pinned
65
+ 15-repository OSS sweep covered 29,203 files and produced zero reports, which is
66
+ compatibility evidence rather than a precision claim. An environment-variable
67
+ arm was rejected before shipping: it reported a public first-party library and
68
+ two intentional LiteLLM integration constructors, all non-actionable.
69
+
28
70
  ### Test-quality rules (0.15.0)
29
71
 
30
72
  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.45.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,
@@ -36,6 +37,9 @@ from sarj_python_lint.rules.no_first_party_private_import import (
36
37
  )
37
38
  from sarj_python_lint.rules.no_fstring_in_log import NoFstringInLog
38
39
  from sarj_python_lint.rules.no_gen_random_uuid_in_sql import NoGenRandomUuidInSql
40
+ from sarj_python_lint.rules.no_hidden_constructor_fallback import (
41
+ NoHiddenConstructorFallback,
42
+ )
39
43
  from sarj_python_lint.rules.no_isinstance_union_chain import NoIsinstanceUnionChain
40
44
  from sarj_python_lint.rules.no_long_comment import NoLongComment
41
45
  from sarj_python_lint.rules.no_offset_pagination import NoOffsetPagination
@@ -131,6 +135,7 @@ REGISTRY: dict[str, type[Rule]] = {
131
135
  NoCorsWildcardWithCredentials.id: NoCorsWildcardWithCredentials,
132
136
  NoSleepInTestBody.id: NoSleepInTestBody,
133
137
  PydanticAtBoundaries.id: PydanticAtBoundaries,
138
+ FastapiOpenapiContract.id: FastapiOpenapiContract,
134
139
  NoSentinelReturnOnExcept.id: NoSentinelReturnOnExcept,
135
140
  NoUnreachableAfterTerminal.id: NoUnreachableAfterTerminal,
136
141
  PreferConstantTimeSecretCompare.id: PreferConstantTimeSecretCompare,
@@ -165,6 +170,7 @@ REGISTRY: dict[str, type[Rule]] = {
165
170
  TrailingValueNarration.id: TrailingValueNarration,
166
171
  NoStdlibLogging.id: NoStdlibLogging,
167
172
  NoGenRandomUuidInSql.id: NoGenRandomUuidInSql,
173
+ NoHiddenConstructorFallback.id: NoHiddenConstructorFallback,
168
174
  NoFileLevelEscapeHatchNoqa.id: NoFileLevelEscapeHatchNoqa,
169
175
  NoOptionalTenantPredicate.id: NoOptionalTenantPredicate,
170
176
  NoTautologicalExpect.id: NoTautologicalExpect,