sarj-python-lint 0.70.0__tar.gz → 0.71.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 (108) hide show
  1. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/PKG-INFO +1 -1
  2. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/pyproject.toml +1 -1
  3. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/__main__.py +15 -6
  4. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/_comments.py +14 -8
  5. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/_fastapi.py +42 -25
  6. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/fixture_returns_bare_tuple.py +10 -6
  7. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/iac_source_coupled_test.py +2 -2
  8. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/invalid_pydantic_field_default.py +22 -11
  9. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +24 -12
  10. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/no_isinstance_union_chain.py +11 -4
  11. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/no_long_comment.py +1 -1
  12. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +53 -39
  13. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/over_mocked_test.py +18 -8
  14. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/prefer_match_assert_never.py +21 -9
  15. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +25 -12
  16. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/prefer_module_level_constant.py +11 -6
  17. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +190 -24
  18. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/prefer_self_documenting_constant.py +2 -2
  19. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/prefer_str_enum.py +29 -11
  20. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/prefer_timedelta_for_durations.py +39 -6
  21. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +16 -7
  22. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/production_derived_test_cases.py +15 -5
  23. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/redundant_docstring.py +5 -2
  24. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/redundant_module_docstring.py +1 -1
  25. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/repeated_static_call_cases.py +31 -18
  26. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/require_port_for_service.py +13 -5
  27. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/require_pydantic_for_external_json.py +164 -17
  28. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/source_coupled_test.py +7 -1
  29. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/trivially_true_assertion.py +11 -5
  30. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/uncontrolled_randomness_in_test.py +16 -8
  31. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/.gitignore +0 -0
  32. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/LICENSE +0 -0
  33. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/README.md +0 -0
  34. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/__init__.py +0 -0
  35. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/_filesystem.py +0 -0
  36. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/_ratchet_cli.py +0 -0
  37. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/_secret_names.py +0 -0
  38. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/_version.py +0 -0
  39. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/py.typed +0 -0
  40. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/ratchet.py +0 -0
  41. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rule_base.py +0 -0
  42. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/__init__.py +0 -0
  43. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/_ast_index.py +0 -0
  44. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/_docstrings.py +0 -0
  45. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/_first_party.py +0 -0
  46. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/_imports.py +0 -0
  47. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/_logging.py +0 -0
  48. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/_paths.py +0 -0
  49. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/_prose_budget.py +0 -0
  50. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/_pytest.py +0 -0
  51. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/_registry.py +0 -0
  52. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/_sql.py +0 -0
  53. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
  54. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/_test_assertions.py +0 -0
  55. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/created_at_order_requires_tiebreaker.py +0 -0
  56. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/defect_xfail_requires_strict.py +0 -0
  57. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
  58. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -0
  59. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/duplicate_test_body.py +0 -0
  60. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/duplicated_override_docstring.py +0 -0
  61. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/fastapi_openapi_contract.py +0 -0
  62. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/kwarg_heavy_construction_in_test.py +0 -0
  63. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
  64. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/negative_only_http_status_assertion.py +0 -0
  65. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/no_aggregation_in_store_query.py +0 -0
  66. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
  67. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
  68. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/no_duplicate_dunder_all_entry.py +0 -0
  69. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/no_file_level_escape_hatch_noqa.py +0 -0
  70. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/no_file_level_suppression.py +0 -0
  71. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
  72. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/no_frozen_after_validator_field_write.py +0 -0
  73. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/no_gen_random_uuid_in_sql.py +0 -0
  74. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/no_generic_single_export_module.py +0 -0
  75. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
  76. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/no_optional_tenant_predicate.py +0 -0
  77. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/no_query_with_many_joins.py +0 -0
  78. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
  79. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
  80. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  81. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/no_select_star.py +0 -0
  82. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/no_stdlib_logging.py +0 -0
  83. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/no_string_concat_in_loop.py +0 -0
  84. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
  85. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -0
  86. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/no_unique_violation_message_match.py +0 -0
  87. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/opaque_parametrize_case_needs_id.py +0 -0
  88. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/phase_label_comment.py +0 -0
  89. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
  90. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
  91. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
  92. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/prefer_immutable_module_constant.py +0 -0
  93. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
  94. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +0 -0
  95. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
  96. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
  97. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
  98. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
  99. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
  100. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
  101. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +0 -0
  102. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/redundant_class_docstring.py +0 -0
  103. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/require_keyword_only_swap_prone_params.py +0 -0
  104. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/restated_test_docstring.py +0 -0
  105. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/stepdown.py +0 -0
  106. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
  107. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
  108. {sarj_python_lint-0.70.0 → sarj_python_lint-0.71.0}/src/sarj_python_lint/rules/unused_mock_setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sarj-python-lint
3
- Version: 0.70.0
3
+ Version: 0.71.0
4
4
  Summary: Custom Python lint rules — AST-based, pre-commit-friendly, hypermodern defaults
5
5
  Project-URL: Homepage, https://code-standards.sarj.ai/rules/python/
6
6
  Project-URL: Documentation, https://code-standards.sarj.ai/rules/python/
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sarj-python-lint"
3
- version = "0.70.0"
3
+ version = "0.71.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" }]
@@ -9,6 +9,7 @@ import json
9
9
  from pathlib import Path
10
10
  import sys
11
11
  from types import MappingProxyType
12
+ from typing import NamedTuple
12
13
 
13
14
  from sarj_python_lint import __version__
14
15
  from sarj_python_lint._filesystem import atomic_write_text
@@ -120,6 +121,11 @@ _DIAGNOSTIC_PRECEDENCE = MappingProxyType(
120
121
  )
121
122
 
122
123
 
124
+ class _OwnerLocation(NamedTuple):
125
+ line: int
126
+ column: int
127
+
128
+
123
129
  def deduplicate_diagnostics(diags: list[Diagnostic], *, source: str | None = None) -> list[Diagnostic]:
124
130
  """Keep the most specific remediation at a source location."""
125
131
  codes = frozenset(diagnostic.code for diagnostic in diags)
@@ -130,15 +136,17 @@ def deduplicate_diagnostics(diags: list[Diagnostic], *, source: str | None = Non
130
136
  _function_signature_owner_locations(source) if source is not None and needs_signature_owners else {}
131
137
  )
132
138
 
133
- def owner_location(diagnostic: Diagnostic) -> tuple[int, int]:
139
+ def owner_location(diagnostic: Diagnostic) -> _OwnerLocation:
134
140
  if diagnostic.code in {"SARJ034", "SARJ093"}:
135
- return signature_owners.get(diagnostic.line, (diagnostic.line, diagnostic.col))
136
- return docstring_owners.get(diagnostic.line, (diagnostic.line, diagnostic.col))
141
+ line, column = signature_owners.get(diagnostic.line, (diagnostic.line, diagnostic.col))
142
+ else:
143
+ line, column = docstring_owners.get(diagnostic.line, (diagnostic.line, diagnostic.col))
144
+ return _OwnerLocation(line, column)
137
145
 
138
146
  present: dict[tuple[Path, int, int], dict[str, set[Severity]]] = {}
139
147
  for diagnostic in diags:
140
- line, col = owner_location(diagnostic)
141
- by_code = present.setdefault((diagnostic.path, line, col), {})
148
+ location = owner_location(diagnostic)
149
+ by_code = present.setdefault((diagnostic.path, location.line, location.column), {})
142
150
  by_code.setdefault(diagnostic.code, set()).add(diagnostic.severity)
143
151
  suppressed = {
144
152
  (location, generic, generic_severity)
@@ -156,7 +164,8 @@ def deduplicate_diagnostics(diags: list[Diagnostic], *, source: str | None = Non
156
164
  (
157
165
  (
158
166
  diagnostic.path,
159
- *owner_location(diagnostic),
167
+ owner_location(diagnostic).line,
168
+ owner_location(diagnostic).column,
160
169
  ),
161
170
  diagnostic.code,
162
171
  diagnostic.severity,
@@ -314,15 +314,21 @@ class _CommentScan[T](NamedTuple):
314
314
  first_code_line: int
315
315
 
316
316
 
317
- _Scan = tuple[list[tuple[int, int, str]], list[tuple[int, int, str]], set[int], int, _Ordered]
317
+ class _Scan(NamedTuple):
318
+ standalone: list[tuple[int, int, str]]
319
+ trailing: list[tuple[int, int, str]]
320
+ nested: set[int]
321
+ first_code_line: int
322
+ ordered: _Ordered
323
+
318
324
 
319
325
  _last_scan: tuple[str, _Scan] | None = None
320
326
 
321
327
 
322
328
  def all_comments(source: str) -> _CommentScan[_Ordered]:
323
329
  """Return every comment as `(line, col0, body, standalone)`, plus the first code line."""
324
- _, _, _, first_code_line, ordered = _scan_memo(source)
325
- return _CommentScan(ordered, first_code_line)
330
+ scan = _scan_memo(source)
331
+ return _CommentScan(scan.ordered, scan.first_code_line)
326
332
 
327
333
 
328
334
  def _scan_memo(source: str) -> _Scan:
@@ -361,23 +367,23 @@ def _scan(source: str) -> _Scan:
361
367
  prev_end_row = tok.end[0]
362
368
  if tok.type not in _NON_CODE_TOKENS:
363
369
  first_code_line = min(first_code_line, tok.start[0])
364
- return standalone, trailing, nested, first_code_line, ordered
370
+ return _Scan(standalone, trailing, nested, first_code_line, ordered)
365
371
 
366
372
 
367
373
  def trailing_comments(source: str) -> list[tuple[int, int, str]]:
368
374
  """Return every comment that shares its line with code, as `(line, col, body)`."""
369
- return _scan_memo(source)[1]
375
+ return _scan_memo(source).trailing
370
376
 
371
377
 
372
378
  def nested_comment_lines(source: str) -> set[int]:
373
379
  """Return the lines of comments sitting INSIDE a bracketed expression."""
374
- return _scan_memo(source)[2]
380
+ return _scan_memo(source).nested
375
381
 
376
382
 
377
383
  def standalone_comments(source: str) -> _CommentScan[list[tuple[int, int, str]]]:
378
384
  """Return every own-line comment as `(line, col, body)`, plus the first code line."""
379
- standalone, _, _, first_code_line, _ = _scan_memo(source)
380
- return _CommentScan(standalone, first_code_line)
385
+ scan = _scan_memo(source)
386
+ return _CommentScan(scan.standalone, scan.first_code_line)
381
387
 
382
388
 
383
389
  def comment_runs(standalone: Sequence[tuple[int, int, str]]) -> list[list[tuple[int, int, str]]]:
@@ -74,6 +74,16 @@ class ParameterMarker(NamedTuple):
74
74
  call: ast.Call
75
75
 
76
76
 
77
+ class _ReceiverKey(NamedTuple):
78
+ scope: int
79
+ name: str
80
+
81
+
82
+ class _DecoratorBinding(NamedTuple):
83
+ receiver: str
84
+ method: str
85
+
86
+
77
87
  @dataclass(frozen=True, slots=True)
78
88
  class _ImportedReference:
79
89
  module: str
@@ -113,12 +123,12 @@ class FastapiIndex:
113
123
  self.annotated: set[str] = set()
114
124
  self.symbols: dict[str, str] = {}
115
125
  self.type_aliases: dict[str, ast.expr] = {}
116
- self.receivers: set[tuple[int, str]] = set()
117
- self.receiver_origins: dict[tuple[int, str], tuple[int, str]] = {}
118
- self.receiver_kinds: dict[tuple[int, str], Literal["FastAPI", "APIRouter"]] = {}
119
- self.hidden_receivers: set[tuple[int, str]] = set()
120
- self.decorators: dict[tuple[int, str], tuple[str, str]] = {}
121
- self.bound_names: set[tuple[int, str]] = set()
126
+ self.receivers: set[_ReceiverKey] = set()
127
+ self.receiver_origins: dict[_ReceiverKey, _ReceiverKey] = {}
128
+ self.receiver_kinds: dict[_ReceiverKey, Literal["FastAPI", "APIRouter"]] = {}
129
+ self.hidden_receivers: set[_ReceiverKey] = set()
130
+ self.decorators: dict[_ReceiverKey, _DecoratorBinding] = {}
131
+ self.bound_names: set[_ReceiverKey] = set()
122
132
  self._node_scopes: dict[int, int] = {}
123
133
  self._node_classes: dict[int, int | None] = {}
124
134
  self._route_scopes: dict[int, int] = {}
@@ -182,31 +192,37 @@ class FastapiIndex:
182
192
  assignments: list[tuple[int, str, ast.expr]] = []
183
193
  for node in ast.walk(tree):
184
194
  if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
185
- self.bound_names.add((self._node_scopes[id(node)], node.name))
195
+ self.bound_names.add(_ReceiverKey(self._node_scopes[id(node)], node.name))
186
196
  arguments = [*node.args.posonlyargs, *node.args.args, *node.args.kwonlyargs]
187
197
  if node.args.vararg is not None:
188
198
  arguments.append(node.args.vararg)
189
199
  if node.args.kwarg is not None:
190
200
  arguments.append(node.args.kwarg)
191
- self.bound_names.update((node.lineno, argument.arg) for argument in arguments)
201
+ self.bound_names.update(_ReceiverKey(node.lineno, argument.arg) for argument in arguments)
192
202
  elif isinstance(node, ast.ClassDef):
193
- self.bound_names.add((self._node_scopes[id(node)], node.name))
203
+ self.bound_names.add(_ReceiverKey(self._node_scopes[id(node)], node.name))
194
204
  elif isinstance(node, (ast.Import, ast.ImportFrom)):
195
205
  self.bound_names.update(
196
- (self._node_scopes[id(node)], alias.asname or alias.name.split(".")[0]) for alias in node.names
206
+ _ReceiverKey(self._node_scopes[id(node)], alias.asname or alias.name.split(".")[0])
207
+ for alias in node.names
197
208
  )
198
209
  elif isinstance(node, (ast.For, ast.AsyncFor)):
199
- self.bound_names.update((self._node_scopes[id(node)], name) for name in self._target_names(node.target))
210
+ self.bound_names.update(
211
+ _ReceiverKey(self._node_scopes[id(node)], name) for name in self._target_names(node.target)
212
+ )
200
213
  elif isinstance(node, ast.With):
201
214
  for item in node.items:
202
215
  if item.optional_vars is not None:
203
216
  self.bound_names.update(
204
- (self._node_scopes[id(node)], name) for name in self._target_names(item.optional_vars)
217
+ _ReceiverKey(self._node_scopes[id(node)], name)
218
+ for name in self._target_names(item.optional_vars)
205
219
  )
206
220
  elif isinstance(node, ast.ExceptHandler) and node.name is not None:
207
- self.bound_names.add((self._node_scopes[id(node)], node.name))
221
+ self.bound_names.add(_ReceiverKey(self._node_scopes[id(node)], node.name))
208
222
  elif isinstance(node, ast.NamedExpr):
209
- self.bound_names.update((self._node_scopes[id(node)], name) for name in self._target_names(node.target))
223
+ self.bound_names.update(
224
+ _ReceiverKey(self._node_scopes[id(node)], name) for name in self._target_names(node.target)
225
+ )
210
226
  if isinstance(node, ast.Assign) and len(node.targets) == 1:
211
227
  name = _binding_name(node.targets[0])
212
228
  if name:
@@ -219,12 +235,12 @@ class FastapiIndex:
219
235
  assignments.append((self._node_scopes[id(node)], node.name.id, node.value))
220
236
 
221
237
  changed = True
222
- counts = Counter((scope, name) for scope, name, _value in assignments)
238
+ counts = Counter(_ReceiverKey(scope, name) for scope, name, _value in assignments)
223
239
  self.bound_names.update(counts)
224
240
  while changed:
225
241
  changed = False
226
242
  for scope, name, value in assignments:
227
- key = (scope, name)
243
+ key = _ReceiverKey(scope, name)
228
244
  if counts[key] != 1:
229
245
  continue
230
246
  if key in self.receivers or key in self.decorators:
@@ -250,7 +266,7 @@ class FastapiIndex:
250
266
  if isinstance(value, ast.Attribute) and value.attr in HTTP_METHODS:
251
267
  receiver = _binding_name(value.value)
252
268
  if self._receiver_key(scope, receiver) is not None:
253
- self.decorators[key] = (receiver, value.attr)
269
+ self.decorators[key] = _DecoratorBinding(receiver, value.attr)
254
270
  changed = True
255
271
  continue
256
272
  if self._is_annotated(value) or (isinstance(value, ast.Name) and value.id in self.type_aliases):
@@ -268,12 +284,12 @@ class FastapiIndex:
268
284
  return self._node_classes[id(node)] or self._node_scopes[id(node)]
269
285
  return self._node_scopes[id(node)]
270
286
 
271
- def _receiver_key(self, scope: int, name: str) -> tuple[int, str] | None:
287
+ def _receiver_key(self, scope: int, name: str) -> _ReceiverKey | None:
272
288
  if not name:
273
289
  return None
274
290
  current: int | None = scope
275
291
  while current is not None:
276
- binding = (current, name)
292
+ binding = _ReceiverKey(current, name)
277
293
  if binding in self.receivers:
278
294
  return binding
279
295
  if binding in self.bound_names:
@@ -325,7 +341,8 @@ class FastapiIndex:
325
341
  if receiver_key is not None:
326
342
  method = decorator.func.attr
327
343
  elif isinstance(decorator.func, ast.Name) and (resolved := self._decorator(scope, decorator.func.id)):
328
- receiver, method = resolved
344
+ receiver = resolved.receiver
345
+ method = resolved.method
329
346
  receiver_key = self._receiver_key(scope, receiver)
330
347
  if method not in HTTP_METHODS or receiver_key is None:
331
348
  continue
@@ -336,11 +353,11 @@ class FastapiIndex:
336
353
  )
337
354
  path = path_node.value if isinstance(path_node, ast.Constant) and isinstance(path_node.value, str) else None
338
355
  route_methods = self._route_methods(decorator) if method == "api_route" else (method,)
339
- origin_scope, origin_name = self.receiver_origins[receiver_key]
356
+ origin = self.receiver_origins[receiver_key]
340
357
  routes.extend(
341
358
  Route(
342
359
  decorator=decorator,
343
- receiver=f"{origin_scope}:{origin_name}",
360
+ receiver=f"{origin.scope}:{origin.name}",
344
361
  method=route_method,
345
362
  path=path,
346
363
  receiver_kind=self.receiver_kinds[receiver_key],
@@ -350,10 +367,10 @@ class FastapiIndex:
350
367
  )
351
368
  return tuple(routes)
352
369
 
353
- def _decorator(self, scope: int, name: str) -> tuple[str, str] | None:
370
+ def _decorator(self, scope: int, name: str) -> _DecoratorBinding | None:
354
371
  current: int | None = scope
355
372
  while current is not None:
356
- binding = (current, name)
373
+ binding = _ReceiverKey(current, name)
357
374
  if binding in self.decorators:
358
375
  return self.decorators[binding]
359
376
  if binding in self.bound_names:
@@ -473,7 +490,7 @@ class FastapiIndex:
473
490
  # it, so begin with that function's parent scope.
474
491
  scope: int | None = self._scope_parents.get(node_scope)
475
492
  while scope is not None and scope != 0:
476
- if (scope, root) in self.bound_names:
493
+ if _ReceiverKey(scope, root) in self.bound_names:
477
494
  return True
478
495
  scope = self._scope_parents.get(scope)
479
496
  return False
@@ -7,7 +7,7 @@ from __future__ import annotations
7
7
 
8
8
  import ast
9
9
  from pathlib import PurePosixPath
10
- from typing import TYPE_CHECKING, ClassVar, override
10
+ from typing import TYPE_CHECKING, ClassVar, NamedTuple, override
11
11
 
12
12
  from sarj_python_lint.rule_base import (
13
13
  Diagnostic,
@@ -34,6 +34,11 @@ _MIN_FIELDS = 2
34
34
  _FUNC_NODES = (ast.FunctionDef, ast.AsyncFunctionDef)
35
35
 
36
36
 
37
+ class _FixtureDecorators(NamedTuple):
38
+ names: frozenset[str]
39
+ roots: frozenset[str]
40
+
41
+
37
42
  class FixtureReturnsBareTuple(Rule):
38
43
  id: str = "fixture-returns-bare-tuple"
39
44
  code: str = "SARJ044"
@@ -132,7 +137,7 @@ def _bare_tuple_results(tree: ast.Module) -> list[tuple[ast.expr, int]]:
132
137
  return hits
133
138
 
134
139
 
135
- def _fixture_decorator_names(tree: ast.Module) -> tuple[frozenset[str], frozenset[str]]:
140
+ def _fixture_decorator_names(tree: ast.Module) -> _FixtureDecorators:
136
141
  names: set[str] = set()
137
142
  roots: set[str] = set()
138
143
  for statement in tree.body:
@@ -144,15 +149,14 @@ def _fixture_decorator_names(tree: ast.Module) -> tuple[frozenset[str], frozense
144
149
  for alias in statement.names:
145
150
  if alias.name == "fixture":
146
151
  names.add(alias.asname or alias.name)
147
- return frozenset(names), frozenset(roots)
152
+ return _FixtureDecorators(frozenset(names), frozenset(roots))
148
153
 
149
154
 
150
155
  def _is_fixture(
151
156
  node: ast.FunctionDef | ast.AsyncFunctionDef,
152
- fixture_imports: tuple[frozenset[str], frozenset[str]],
157
+ fixture_imports: _FixtureDecorators,
153
158
  ) -> bool:
154
- names, roots = fixture_imports
155
- return any(_names_fixture(dec, names, roots) for dec in node.decorator_list)
159
+ return any(_names_fixture(dec, fixture_imports.names, fixture_imports.roots) for dec in node.decorator_list)
156
160
 
157
161
 
158
162
  def _names_fixture(dec: ast.expr, fixture_names: frozenset[str], fixture_roots: frozenset[str]) -> bool:
@@ -45,7 +45,7 @@ class IacSourceCoupledTest(Rule):
45
45
  limitations=(
46
46
  "The rule follows local aliases, path collections, context-managed reads, and common normalization; interprocedural flows remain unreported.",
47
47
  "Files produced beneath recognized temporary-directory fixtures are generated outputs and remain unreported.",
48
- "The warning-stage rule remains suppressible for calibration; promotion may make the locked policy non-suppressible.",
48
+ "The rule remains suppressible for exceptional compatibility boundaries.",
49
49
  ),
50
50
  examples=(
51
51
  RuleExample(
@@ -98,7 +98,7 @@ class IacSourceCoupledTest(Rule):
98
98
  line=assertion.lineno,
99
99
  col=assertion.col_offset + 1,
100
100
  code=self.code,
101
- severity=Severity.WARNING,
101
+ severity=Severity.ERROR,
102
102
  message=(
103
103
  "raw IaC source text is the test oracle; inspect rendered plan JSON, provider state, or runtime behavior instead."
104
104
  ),
@@ -8,7 +8,7 @@ from __future__ import annotations
8
8
  import ast
9
9
  from dataclasses import dataclass
10
10
  from pathlib import PurePosixPath
11
- from typing import TYPE_CHECKING, final, override
11
+ from typing import TYPE_CHECKING, NamedTuple, final, override
12
12
 
13
13
  from sarj_python_lint.rule_base import (
14
14
  AutofixPolicy,
@@ -84,7 +84,17 @@ _MISSING = object()
84
84
 
85
85
  @dataclass(frozen=True, slots=True)
86
86
  class _LiteralDomain:
87
- values: frozenset[tuple[type[object], object]]
87
+ values: frozenset[_LiteralKey]
88
+
89
+
90
+ class _LiteralKey(NamedTuple):
91
+ value_type: type[object]
92
+ value: object
93
+
94
+
95
+ class _BoundViolation(NamedTuple):
96
+ bound_name: str
97
+ bound: object
88
98
 
89
99
 
90
100
  @final
@@ -221,7 +231,7 @@ def _invalid_default_message(
221
231
 
222
232
  domain = _literal_domain(annotation, imports)
223
233
  if domain is not None and not isinstance(literal, float) and _literal_key(literal) not in domain.values:
224
- allowed = ", ".join(sorted(repr(value) for _, value in domain.values))
234
+ allowed = ", ".join(sorted(repr(item.value) for item in domain.values))
225
235
  return (
226
236
  f"`{field_name}` defaults to {literal!r}, outside its direct `Literal` domain ({allowed}); "
227
237
  "use one of the declared literal values."
@@ -229,7 +239,8 @@ def _invalid_default_message(
229
239
 
230
240
  violation = _bound_violation(literal, call)
231
241
  if violation is not None:
232
- bound_name, bound = violation
242
+ bound_name = violation.bound_name
243
+ bound = violation.bound
233
244
  return (
234
245
  f"`{field_name}` defaults to {literal!r}, which violates `Field({bound_name}={bound!r})`; "
235
246
  "choose a default inside the declared bounds."
@@ -249,8 +260,8 @@ def _literal_value(node: ast.expr) -> object:
249
260
  return _MISSING
250
261
 
251
262
 
252
- def _literal_key(value: object) -> tuple[type[object], object]:
253
- return type(value), value
263
+ def _literal_key(value: object) -> _LiteralKey:
264
+ return _LiteralKey(type(value), value)
254
265
 
255
266
 
256
267
  def _literal_domain(node: ast.expr, imports: ImportIndex) -> _LiteralDomain | None:
@@ -273,7 +284,7 @@ def _literal_domain(node: ast.expr, imports: ImportIndex) -> _LiteralDomain | No
273
284
  return _merge_domains(tuple(_literal_domain(member, imports) for member in _slice_members(node.slice)))
274
285
  if not imports.resolves(node.value, sources=_TYPING_SOURCES, symbol="Literal"):
275
286
  return None
276
- values: set[tuple[type[object], object]] = set()
287
+ values: set[_LiteralKey] = set()
277
288
  for member in _slice_members(node.slice):
278
289
  value = _literal_value(member)
279
290
  if value is _MISSING or isinstance(value, float):
@@ -285,7 +296,7 @@ def _literal_domain(node: ast.expr, imports: ImportIndex) -> _LiteralDomain | No
285
296
  def _merge_domains(domains: tuple[_LiteralDomain | None, ...]) -> _LiteralDomain | None:
286
297
  if not domains or any(domain is None for domain in domains):
287
298
  return None
288
- values: set[tuple[type[object], object]] = set()
299
+ values: set[_LiteralKey] = set()
289
300
  for domain in domains:
290
301
  if domain is not None:
291
302
  values.update(domain.values)
@@ -318,7 +329,7 @@ def _provably_non_null(node: ast.expr, imports: ImportIndex) -> bool:
318
329
  )
319
330
 
320
331
 
321
- def _bound_violation(default: object, call: ast.Call) -> tuple[str, object] | None:
332
+ def _bound_violation(default: object, call: ast.Call) -> _BoundViolation | None:
322
333
  bounds = {keyword.arg: _literal_value(keyword.value) for keyword in call.keywords if keyword.arg is not None}
323
334
  if isinstance(default, (int, float)) and not isinstance(default, bool):
324
335
  for name in ("gt", "ge", "lt", "le"):
@@ -326,7 +337,7 @@ def _bound_violation(default: object, call: ast.Call) -> tuple[str, object] | No
326
337
  if not isinstance(bound, (int, float)) or isinstance(bound, bool):
327
338
  continue
328
339
  if _numeric_bound_is_violated(name, default, bound):
329
- return name, bound
340
+ return _BoundViolation(name, bound)
330
341
  return None
331
342
  if isinstance(default, (str, bytes)):
332
343
  for name in ("min_length", "max_length"):
@@ -334,7 +345,7 @@ def _bound_violation(default: object, call: ast.Call) -> tuple[str, object] | No
334
345
  if not isinstance(bound, int) or isinstance(bound, bool) or bound < 0:
335
346
  continue
336
347
  if (name == "min_length" and len(default) < bound) or (name == "max_length" and len(default) > bound):
337
- return name, bound
348
+ return _BoundViolation(name, bound)
338
349
  return None
339
350
 
340
351
 
@@ -10,7 +10,7 @@ from dataclasses import dataclass
10
10
  from functools import lru_cache
11
11
  import os
12
12
  from pathlib import Path, PurePosixPath
13
- from typing import TYPE_CHECKING, ClassVar, final, override
13
+ from typing import TYPE_CHECKING, ClassVar, NamedTuple, final, override
14
14
 
15
15
  from sarj_python_lint.rule_base import (
16
16
  Diagnostic,
@@ -68,6 +68,16 @@ class _HiddenParameter:
68
68
  uses_boolean_or: bool
69
69
 
70
70
 
71
+ class _LoadedModule(NamedTuple):
72
+ tree: ast.Module
73
+ path: Path
74
+
75
+
76
+ class _CanonicalSymbol(NamedTuple):
77
+ module: str
78
+ symbol: str
79
+
80
+
71
81
  @final
72
82
  class NoHiddenConstructorFallback(Rule):
73
83
  id = "no-hidden-constructor-fallback"
@@ -379,7 +389,8 @@ class _RuntimeConfigResolver:
379
389
  if loaded is None:
380
390
  self._settings_cache[key] = False
381
391
  return False
382
- tree, module_path = loaded
392
+ tree = loaded.tree
393
+ module_path = loaded.path
383
394
  imports = _imports(tree, module, is_package=module_path.name == "__init__.py")
384
395
  classes = _base_settings_classes(tree, imports)
385
396
  factory = _assigned_factory(tree, symbol)
@@ -419,7 +430,8 @@ class _RuntimeConfigResolver:
419
430
  if loaded is None:
420
431
  self._settings_class_cache[key] = False
421
432
  return False
422
- tree, module_path = loaded
433
+ tree = loaded.tree
434
+ module_path = loaded.path
423
435
  imports = _imports(tree, module, is_package=module_path.name == "__init__.py")
424
436
  if symbol in _base_settings_classes(tree, imports):
425
437
  self._settings_class_cache[key] = True
@@ -441,13 +453,13 @@ class _RuntimeConfigResolver:
441
453
  self._settings_class_cache[key] = result
442
454
  return result
443
455
 
444
- def _load_module(self, module: str) -> tuple[ast.Module, Path] | None:
456
+ def _load_module(self, module: str) -> _LoadedModule | None:
445
457
  if module == self._module:
446
- return self._tree, self._path
458
+ return _LoadedModule(self._tree, self._path)
447
459
  path = _module_path(module, self._root)
448
460
  if path is None:
449
461
  return None
450
- return _read_module(path), path
462
+ return _LoadedModule(_read_module(path), path)
451
463
 
452
464
 
453
465
  def _imports(tree: ast.Module, current_module: str | None, *, is_package: bool = False) -> dict[str, _Binding]:
@@ -635,13 +647,13 @@ def _distribution_calls_class(root: Path, target_module: str, class_name: str) -
635
647
  if called_symbol != class_name:
636
648
  continue
637
649
  canonical = _canonical_symbol(root, called_module, called_symbol)
638
- if canonical == (target_module, class_name):
650
+ if canonical == _CanonicalSymbol(target_module, class_name):
639
651
  return True
640
652
  return False
641
653
 
642
654
 
643
655
  @lru_cache(maxsize=4096)
644
- def _canonical_symbol(root: Path, module: str, symbol: str) -> tuple[str, str]:
656
+ def _canonical_symbol(root: Path, module: str, symbol: str) -> _CanonicalSymbol:
645
657
  return _canonical_symbol_inner(root, module, symbol, frozenset())
646
658
 
647
659
 
@@ -650,16 +662,16 @@ def _canonical_symbol_inner(
650
662
  module: str,
651
663
  symbol: str,
652
664
  seen: frozenset[tuple[str, str]],
653
- ) -> tuple[str, str]:
665
+ ) -> _CanonicalSymbol:
654
666
  key = (module, symbol)
655
667
  if key in seen:
656
- return key
668
+ return _CanonicalSymbol(module, symbol)
657
669
  path = _module_path(module, root)
658
670
  if path is None:
659
- return key
671
+ return _CanonicalSymbol(module, symbol)
660
672
  binding = _imports(_read_module(path), module, is_package=path.name == "__init__.py").get(symbol)
661
673
  if binding is None or binding.symbol is None:
662
- return key
674
+ return _CanonicalSymbol(module, symbol)
663
675
  return _canonical_symbol_inner(root, binding.module, binding.symbol, seen | {key})
664
676
 
665
677
 
@@ -7,7 +7,7 @@ from __future__ import annotations
7
7
 
8
8
  import ast
9
9
  from pathlib import PurePosixPath
10
- from typing import TYPE_CHECKING, final, override
10
+ from typing import TYPE_CHECKING, NamedTuple, final, override
11
11
 
12
12
  from sarj_python_lint.rule_base import (
13
13
  AutofixPolicy,
@@ -34,6 +34,12 @@ _MIN_CHAIN_LENGTH = 2
34
34
  # `isinstance(x, T)` takes exactly two positional arguments.
35
35
  _ISINSTANCE_ARG_COUNT = 2
36
36
 
37
+
38
+ class _IsinstanceTypeTest(NamedTuple):
39
+ subject: ast.expr
40
+ type_expression: ast.expr
41
+
42
+
37
43
  # Exclude builtins and ABCs even when a same-named local class shadows them.
38
44
  _EXCLUDED_TYPE_NAMES = frozenset(
39
45
  {
@@ -218,7 +224,8 @@ def _qualifying_chain_length(head: ast.If, local_classes: frozenset[str]) -> int
218
224
  parsed = _isinstance_single_type(current.test)
219
225
  if parsed is None:
220
226
  return 0
221
- target, type_node = parsed
227
+ target = parsed.subject
228
+ type_node = parsed.type_expression
222
229
  if not isinstance(target, ast.Name):
223
230
  # Match evaluates its subject once, while an isinstance ladder
224
231
  # repeats attributes, subscriptions, and calls for every arm.
@@ -299,7 +306,7 @@ def _ast_equal(a: ast.expr, b: ast.expr) -> bool:
299
306
  return ast.dump(a) == ast.dump(b)
300
307
 
301
308
 
302
- def _isinstance_single_type(test: ast.expr) -> tuple[ast.expr, ast.expr] | None:
309
+ def _isinstance_single_type(test: ast.expr) -> _IsinstanceTypeTest | None:
303
310
  """Parse `test` as `isinstance(x, T)` with a single (non-tuple) type argument."""
304
311
  if not isinstance(test, ast.Call):
305
312
  return None
@@ -308,4 +315,4 @@ def _isinstance_single_type(test: ast.expr) -> tuple[ast.expr, ast.expr] | None:
308
315
  if len(test.args) != _ISINSTANCE_ARG_COUNT or test.keywords:
309
316
  return None
310
317
  target, type_node = test.args
311
- return target, type_node
318
+ return _IsinstanceTypeTest(target, type_node)
@@ -89,7 +89,7 @@ class NoLongComment(Rule):
89
89
  group.col,
90
90
  self.code,
91
91
  self.description,
92
- Severity.WARNING,
92
+ Severity.ERROR,
93
93
  column_encoding=group.column_encoding,
94
94
  )
95
95
  for group in groups(path, source)