sarj-python-lint 0.36.0__tar.gz → 0.37.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 (95) hide show
  1. {sarj_python_lint-0.36.0 → sarj_python_lint-0.37.0}/PKG-INFO +1 -1
  2. {sarj_python_lint-0.36.0 → sarj_python_lint-0.37.0}/pyproject.toml +1 -1
  3. {sarj_python_lint-0.36.0 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/_registry.py +0 -2
  4. {sarj_python_lint-0.36.0 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/inefficient_string_concat_in_loop.py +32 -9
  5. {sarj_python_lint-0.36.0 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/no_comment_cruft.py +38 -1
  6. sarj_python_lint-0.36.0/src/sarj_python_lint/rules/no_implicit_attribute_access.py +0 -459
  7. {sarj_python_lint-0.36.0 → sarj_python_lint-0.37.0}/.gitignore +0 -0
  8. {sarj_python_lint-0.36.0 → sarj_python_lint-0.37.0}/README.md +0 -0
  9. {sarj_python_lint-0.36.0 → sarj_python_lint-0.37.0}/src/sarj_python_lint/__init__.py +0 -0
  10. {sarj_python_lint-0.36.0 → sarj_python_lint-0.37.0}/src/sarj_python_lint/__main__.py +0 -0
  11. {sarj_python_lint-0.36.0 → sarj_python_lint-0.37.0}/src/sarj_python_lint/_ratchet_cli.py +0 -0
  12. {sarj_python_lint-0.36.0 → sarj_python_lint-0.37.0}/src/sarj_python_lint/_secret_names.py +0 -0
  13. {sarj_python_lint-0.36.0 → sarj_python_lint-0.37.0}/src/sarj_python_lint/_version.py +0 -0
  14. {sarj_python_lint-0.36.0 → sarj_python_lint-0.37.0}/src/sarj_python_lint/py.typed +0 -0
  15. {sarj_python_lint-0.36.0 → sarj_python_lint-0.37.0}/src/sarj_python_lint/ratchet.py +0 -0
  16. {sarj_python_lint-0.36.0 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rule_base.py +0 -0
  17. {sarj_python_lint-0.36.0 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/__init__.py +0 -0
  18. {sarj_python_lint-0.36.0 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/_ast_index.py +0 -0
  19. {sarj_python_lint-0.36.0 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/_comments.py +0 -0
  20. {sarj_python_lint-0.36.0 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/_docstrings.py +0 -0
  21. {sarj_python_lint-0.36.0 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/_first_party.py +0 -0
  22. {sarj_python_lint-0.36.0 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/_logging.py +0 -0
  23. {sarj_python_lint-0.36.0 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/_paths.py +0 -0
  24. {sarj_python_lint-0.36.0 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/_pytest.py +0 -0
  25. {sarj_python_lint-0.36.0 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/_sql.py +0 -0
  26. {sarj_python_lint-0.36.0 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
  27. {sarj_python_lint-0.36.0 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/conditional_assertion_in_test.py +0 -0
  28. {sarj_python_lint-0.36.0 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
  29. {sarj_python_lint-0.36.0 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -0
  30. {sarj_python_lint-0.36.0 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/duplicate_test_body.py +0 -0
  31. {sarj_python_lint-0.36.0 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/duplicated_override_docstring.py +0 -0
  32. {sarj_python_lint-0.36.0 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/fixture_returns_bare_tuple.py +0 -0
  33. {sarj_python_lint-0.36.0 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/interaction_only_test.py +0 -0
  34. {sarj_python_lint-0.36.0 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/kwarg_heavy_construction_in_test.py +0 -0
  35. {sarj_python_lint-0.36.0 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/kwonly_same_type_params.py +0 -0
  36. {sarj_python_lint-0.36.0 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
  37. {sarj_python_lint-0.36.0 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/no_aggregation_in_store_query.py +0 -0
  38. {sarj_python_lint-0.36.0 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
  39. {sarj_python_lint-0.36.0 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/no_fat_try_blocks.py +0 -0
  40. {sarj_python_lint-0.36.0 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/no_file_level_escape_hatch_noqa.py +0 -0
  41. {sarj_python_lint-0.36.0 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/no_file_level_suppression.py +0 -0
  42. {sarj_python_lint-0.36.0 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
  43. {sarj_python_lint-0.36.0 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/no_fstring_in_log.py +0 -0
  44. {sarj_python_lint-0.36.0 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/no_gen_random_uuid_in_sql.py +0 -0
  45. {sarj_python_lint-0.36.0 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/no_isinstance_union_chain.py +0 -0
  46. {sarj_python_lint-0.36.0 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
  47. {sarj_python_lint-0.36.0 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/no_optional_tenant_predicate.py +0 -0
  48. {sarj_python_lint-0.36.0 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/no_query_with_many_joins.py +0 -0
  49. {sarj_python_lint-0.36.0 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/no_raw_sql_in_tests.py +0 -0
  50. {sarj_python_lint-0.36.0 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
  51. {sarj_python_lint-0.36.0 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
  52. {sarj_python_lint-0.36.0 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  53. {sarj_python_lint-0.36.0 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/no_select_star.py +0 -0
  54. {sarj_python_lint-0.36.0 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +0 -0
  55. {sarj_python_lint-0.36.0 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/no_sequential_await.py +0 -0
  56. {sarj_python_lint-0.36.0 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/no_sleep_in_test_body.py +0 -0
  57. {sarj_python_lint-0.36.0 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/no_stdlib_logging.py +0 -0
  58. {sarj_python_lint-0.36.0 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
  59. {sarj_python_lint-0.36.0 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/no_unreachable_after_terminal.py +0 -0
  60. {sarj_python_lint-0.36.0 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
  61. {sarj_python_lint-0.36.0 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/parametrize_case_needs_id.py +0 -0
  62. {sarj_python_lint-0.36.0 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
  63. {sarj_python_lint-0.36.0 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
  64. {sarj_python_lint-0.36.0 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
  65. {sarj_python_lint-0.36.0 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
  66. {sarj_python_lint-0.36.0 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
  67. {sarj_python_lint-0.36.0 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/prefer_match_pattern_destructuring.py +0 -0
  68. {sarj_python_lint-0.36.0 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +0 -0
  69. {sarj_python_lint-0.36.0 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
  70. {sarj_python_lint-0.36.0 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
  71. {sarj_python_lint-0.36.0 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
  72. {sarj_python_lint-0.36.0 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
  73. {sarj_python_lint-0.36.0 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/prefer_real_store_in_tests.py +0 -0
  74. {sarj_python_lint-0.36.0 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
  75. {sarj_python_lint-0.36.0 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
  76. {sarj_python_lint-0.36.0 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
  77. {sarj_python_lint-0.36.0 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/prefer_timedelta_for_durations.py +0 -0
  78. {sarj_python_lint-0.36.0 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
  79. {sarj_python_lint-0.36.0 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
  80. {sarj_python_lint-0.36.0 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
  81. {sarj_python_lint-0.36.0 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +0 -0
  82. {sarj_python_lint-0.36.0 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/redundant_class_docstring.py +0 -0
  83. {sarj_python_lint-0.36.0 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
  84. {sarj_python_lint-0.36.0 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/require_port_for_service.py +0 -0
  85. {sarj_python_lint-0.36.0 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/single_public_export.py +0 -0
  86. {sarj_python_lint-0.36.0 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/sleep_with_computed_arg_in_test.py +0 -0
  87. {sarj_python_lint-0.36.0 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/stepdown.py +0 -0
  88. {sarj_python_lint-0.36.0 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
  89. {sarj_python_lint-0.36.0 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/tautological_mock_assertion.py +0 -0
  90. {sarj_python_lint-0.36.0 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/test_loops_over_literal_cases.py +0 -0
  91. {sarj_python_lint-0.36.0 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
  92. {sarj_python_lint-0.36.0 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/trivially_true_assertion.py +0 -0
  93. {sarj_python_lint-0.36.0 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/unused_mock_setup.py +0 -0
  94. {sarj_python_lint-0.36.0 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/xfail_requires_strict.py +0 -0
  95. {sarj_python_lint-0.36.0 → sarj_python_lint-0.37.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.36.0
3
+ Version: 0.37.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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sarj-python-lint"
3
- version = "0.36.0"
3
+ version = "0.37.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" }]
@@ -36,7 +36,6 @@ from sarj_python_lint.rules.no_first_party_private_import import (
36
36
  )
37
37
  from sarj_python_lint.rules.no_fstring_in_log import NoFstringInLog
38
38
  from sarj_python_lint.rules.no_gen_random_uuid_in_sql import NoGenRandomUuidInSql
39
- from sarj_python_lint.rules.no_implicit_attribute_access import NoImplicitAttributeAccess
40
39
  from sarj_python_lint.rules.no_isinstance_union_chain import NoIsinstanceUnionChain
41
40
  from sarj_python_lint.rules.no_offset_pagination import NoOffsetPagination
42
41
  from sarj_python_lint.rules.no_optional_tenant_predicate import (
@@ -122,7 +121,6 @@ REGISTRY: dict[str, type[Rule]] = {
122
121
  PreferStrEnum.id: PreferStrEnum,
123
122
  NoFatTryBlocks.id: NoFatTryBlocks,
124
123
  NoIsinstanceUnionChain.id: NoIsinstanceUnionChain,
125
- NoImplicitAttributeAccess.id: NoImplicitAttributeAccess,
126
124
  NoOffsetPagination.id: NoOffsetPagination,
127
125
  PreferNamedtupleOverTupleReturn.id: PreferNamedtupleOverTupleReturn,
128
126
  NoCorsWildcardWithCredentials.id: NoCorsWildcardWithCredentials,
@@ -19,6 +19,29 @@ if TYPE_CHECKING:
19
19
  from pathlib import Path
20
20
 
21
21
 
22
+ def _src(node: ast.expr) -> str:
23
+ """`ast.unparse` for the shapes this rule compares, without `ast.unparse`'s cost.
24
+
25
+ The rule identifies an accumulator by the SOURCE TEXT of its target, so it unparses
26
+ once per Name/Attribute in every loop body. `ast.unparse` builds and runs a full
27
+ unparser on each call. For a bare `Name` the answer is the identifier, and for a
28
+ dotted chain over one it is the chain — byte-identical, far cheaper. Everything
29
+ else falls through to `ast.unparse`, so no comparison changes meaning.
30
+
31
+ This is why the rule stopped being the registry's performance outlier; see
32
+ `tests/test_perf.py`.
33
+
34
+ Returns:
35
+ The node's source text.
36
+
37
+ """
38
+ if isinstance(node, ast.Name):
39
+ return node.id
40
+ if isinstance(node, ast.Attribute) and isinstance(node.value, (ast.Name, ast.Attribute)):
41
+ return f"{_src(node.value)}.{node.attr}"
42
+ return ast.unparse(node)
43
+
44
+
22
45
  class InefficientStringConcatInLoop(Rule):
23
46
  id: str = "inefficient-string-concat-in-loop"
24
47
  code: str = "SARJ002"
@@ -99,7 +122,7 @@ class _ConcatVisitor(ast.NodeVisitor):
99
122
 
100
123
  def _is_probe_target(self, node: ast.AugAssign | ast.Assign) -> bool:
101
124
  """Report whether the concat target's intermediate values are consumed."""
102
- target_src = ast.unparse(self._accumulation_target(node))
125
+ target_src = _src(self._accumulation_target(node))
103
126
  if any(target_src in names for names in self._while_probe_names):
104
127
  return True
105
128
  # Only the INNERMOST loop's reads consume the growth per iteration; a
@@ -109,7 +132,7 @@ class _ConcatVisitor(ast.NodeVisitor):
109
132
  def _is_loop_local_target(self, node: ast.AugAssign | ast.Assign) -> bool:
110
133
  """Report whether the concat target is freshly rebound earlier this iteration."""
111
134
  target = self._accumulation_target(node)
112
- rebinds = self._loop_reassigns[-1].get(ast.unparse(target), ())
135
+ rebinds = self._loop_reassigns[-1].get(_src(target), ())
113
136
  return any(line < node.lineno for line in rebinds)
114
137
 
115
138
  def _accumulation_target(self, node: ast.AugAssign | ast.Assign) -> ast.expr:
@@ -149,7 +172,7 @@ class _ConcatVisitor(ast.NodeVisitor):
149
172
 
150
173
  def _test_names(test: ast.expr) -> set[str]:
151
174
  """Collect the source text of every Name/Attribute read in a while test."""
152
- return {ast.unparse(n) for n in walk(test) if isinstance(n, (ast.Name, ast.Attribute))}
175
+ return {_src(n) for n in walk(test) if isinstance(n, (ast.Name, ast.Attribute))}
153
176
 
154
177
 
155
178
  def _loop_read_names(loop: ast.For | ast.AsyncFor | ast.While) -> frozenset[str]:
@@ -170,7 +193,7 @@ def _loop_read_names(loop: ast.For | ast.AsyncFor | ast.While) -> frozenset[str]
170
193
  continue
171
194
  stack.extend(children(node))
172
195
  if isinstance(node, (ast.Name, ast.Attribute)) and isinstance(node.ctx, ast.Load):
173
- reads.add(ast.unparse(node))
196
+ reads.add(_src(node))
174
197
  return frozenset(reads)
175
198
 
176
199
 
@@ -192,13 +215,13 @@ def _collect_reassignments(node: ast.AST, reassigns: dict[str, list[int]]) -> No
192
215
  for target in node.targets:
193
216
  for bound in _iter_binding_targets(target):
194
217
  if not _is_accumulation_assign(bound, node.value):
195
- reassigns.setdefault(ast.unparse(bound), []).append(bound.lineno)
218
+ reassigns.setdefault(_src(bound), []).append(bound.lineno)
196
219
  elif (
197
220
  isinstance(node, ast.AnnAssign)
198
221
  and node.value is not None
199
222
  and not _is_accumulation_assign(node.target, node.value)
200
223
  ):
201
- reassigns.setdefault(ast.unparse(node.target), []).append(node.target.lineno)
224
+ reassigns.setdefault(_src(node.target), []).append(node.target.lineno)
202
225
  for child in children(node):
203
226
  _collect_reassignments(child, reassigns)
204
227
 
@@ -222,10 +245,10 @@ def _is_accumulation_assign(target: ast.expr, value: ast.expr) -> bool:
222
245
 
223
246
  def _other_add_operand(target: ast.expr, binop: ast.BinOp) -> ast.expr | None:
224
247
  """Return the non-target operand of `target + x` / `x + target`."""
225
- target_src = ast.unparse(target)
226
- if ast.unparse(binop.left) == target_src:
248
+ target_src = _src(target)
249
+ if _src(binop.left) == target_src:
227
250
  return binop.right
228
- if ast.unparse(binop.right) == target_src:
251
+ if _src(binop.right) == target_src:
229
252
  return binop.left
230
253
  return None
231
254
 
@@ -56,6 +56,22 @@ _LICENSE_RE = re.compile(
56
56
  re.IGNORECASE,
57
57
  )
58
58
 
59
+ # A licence header is a legally required block of text a contributor cannot
60
+ # restructure, and the `# ---------` rules that box it in are part of it. The
61
+ # rule already knows this -- `_flag_leading_preamble` returns early on
62
+ # `_LICENSE_RE` -- but the BANNER branch did not, so "structure code with
63
+ # functions, not ASCII rules" was reported against the one comment in the file
64
+ # that is not about structure at all. 831 findings across 590 corpus files sat
65
+ # on a punctuation rule in the first 8 lines of a file whose header carries a
66
+ # copyright or SPDX line: `bokeh/docs/bokeh/docserver.py:1`,
67
+ # `bokeh/release/checks.py:1`, `bokeh/release/__main__.py:1` are the shape --
68
+ # `# ---…---` immediately above `# Copyright (c) Anaconda, Inc.`.
69
+ #
70
+ # Scoped to the file HEADER, not to every copyright mention: a banner beside a
71
+ # licence reference in the middle of a file is an ordinary section rule.
72
+ _LICENSE_HEADER_MAX_LINE = 8
73
+ _LICENSE_HEADER_RADIUS = 4
74
+
59
75
  _BANNER_FULL_RE = re.compile(r"^[-=#*~_+.\s]{4,}$")
60
76
  # `[\u2500-\u257f]` is the Unicode box-drawing block. A `────────` rule is the
61
77
  # same section separator as `--------`, just prettier; 34 of them were sitting in
@@ -441,6 +457,24 @@ def _is_assign_or_call(snippet: str) -> bool:
441
457
  return isinstance(stmt, ast.Expr) and isinstance(stmt.value, ast.Call)
442
458
 
443
459
 
460
+ def _license_header_lines(standalone: list[tuple[int, int, str]]) -> frozenset[int]:
461
+ """Collect the lines belonging to a file-header licence block.
462
+
463
+ Returns:
464
+ Every line within `_LICENSE_HEADER_RADIUS` of a licence comment that
465
+ sits in the first `_LICENSE_HEADER_MAX_LINE` lines of the file.
466
+
467
+ """
468
+ anchors = [
469
+ line for line, _, body in standalone if line <= _LICENSE_HEADER_MAX_LINE and _LICENSE_RE.search(body)
470
+ ]
471
+ return frozenset(
472
+ line
473
+ for anchor in anchors
474
+ for line in range(anchor - _LICENSE_HEADER_RADIUS, anchor + _LICENSE_HEADER_RADIUS + 1)
475
+ )
476
+
477
+
444
478
  class NoCommentCruft(Rule):
445
479
  id: str = "no-comment-cruft"
446
480
  code: str = "SARJ016"
@@ -468,6 +502,7 @@ class NoCommentCruft(Rule):
468
502
  referenced = _externally_referenced_lines(standalone)
469
503
  nested = nested_comment_lines(source)
470
504
  enumerated = [line for line, _, body in standalone if _ENUMERATION_RE.match(body)]
505
+ license_header = _license_header_lines(standalone)
471
506
  for line, col, body in standalone:
472
507
  if _is_directive(body) or _is_coding_cookie(body) or line in skip:
473
508
  continue
@@ -478,6 +513,7 @@ class NoCommentCruft(Rule):
478
513
  narration_protected=line in referenced,
479
514
  isolated_enumeration=enumerated == [line],
480
515
  nested=line in nested,
516
+ in_license_header=line in license_header,
481
517
  )
482
518
  if msg is not None:
483
519
  diags[line] = Diagnostic(path=path, line=line, col=col + 1, code=self.code, message=msg)
@@ -492,6 +528,7 @@ class NoCommentCruft(Rule):
492
528
  narration_protected: bool,
493
529
  isolated_enumeration: bool,
494
530
  nested: bool,
531
+ in_license_header: bool,
495
532
  ) -> str | None:
496
533
  if _CODE_REGEN_CALL_RE.match(body):
497
534
  return None
@@ -500,7 +537,7 @@ class NoCommentCruft(Rule):
500
537
  return "Untracked TODO/FIXME marker — add an issue ticket or context link."
501
538
  return None
502
539
  if _is_banner(body):
503
- if _is_heading_underline(body, prev_body):
540
+ if _is_heading_underline(body, prev_body) or in_license_header:
504
541
  return None
505
542
  return "Section-banner / region comment — structure code with functions, not ASCII rules."
506
543
  if _looks_like_code(body):
@@ -1,459 +0,0 @@
1
- """SARJ083 — Forbid implicit dictionary accesses using string literals.
2
-
3
- Examples: https://github.com/sarj-ai/standards/blob/main/packages/python/tests/rules/test_no_implicit_attribute_access.py
4
- Evidence: https://github.com/sarj-ai/standards/blob/main/docs/rules/SARJ083.md
5
- """
6
-
7
- from __future__ import annotations
8
-
9
- import ast
10
- from dataclasses import dataclass
11
- from typing import TYPE_CHECKING, override
12
-
13
- from sarj_python_lint.rule_base import Diagnostic, Rule, parse_or_none
14
- from sarj_python_lint.rules._ast_index import nodes, walk
15
-
16
-
17
- if TYPE_CHECKING:
18
- from pathlib import Path
19
-
20
- _EXCLUDED_BASES = {
21
- "environ",
22
- "headers",
23
- "cookies",
24
- "session",
25
- "redis",
26
- "cache",
27
- "state",
28
- "config",
29
- "kwargs",
30
- "env",
31
- "os",
32
- "sys",
33
- }
34
-
35
-
36
- # Typing constructs subscripted with string literals. `Literal["x"]` is a type,
37
- # not a lookup, so the rule's advice ("parse declaratively with Pydantic") is
38
- # nonsensical there -- the annotation already IS the declarative schema.
39
- _TYPE_SUBSCRIPTS = frozenset(
40
- {
41
- "Literal",
42
- "Annotated",
43
- "TypedDict",
44
- "NamedTuple",
45
- "Field",
46
- "Doc",
47
- "Required",
48
- "NotRequired",
49
- "ReadOnly",
50
- }
51
- )
52
-
53
- # Generic wrappers that do not change what the annotated value IS. `Optional[X]`,
54
- # `Final[X]` and `Annotated[X, ...]` all still describe an `X`, so the search for
55
- # a TypedDict has to pass through them; `list[X]` does not, and is not here.
56
- _TYPE_WRAPPERS = frozenset(
57
- {
58
- "Optional",
59
- "Union",
60
- "Annotated",
61
- "Final",
62
- "ClassVar",
63
- "Required",
64
- "NotRequired",
65
- "ReadOnly",
66
- }
67
- )
68
-
69
- # In-place collection methods. A subscript that is one of these calls' receiver
70
- # is building the collection it indexes, not reading a field out of a payload.
71
- _MUTATION_METHODS = frozenset({"append", "add", "extend", "update", "insert", "discard", "setdefault"})
72
-
73
- # Namespaces the language itself defines. Their keys are CPython's, not a
74
- # schema anybody could have declared.
75
- _REFLECTION_BASES = frozenset({"f_globals", "f_locals", "__annotations__"})
76
- _REFLECTION_CALLS = frozenset({"globals", "locals", "get_type_hints"})
77
-
78
- # `ConfigParser.get(section, option, *, fallback=...)`. `dict.get` has no
79
- # `fallback` parameter, so the keyword alone identifies the call exactly.
80
- _CONFIGPARSER_POSITIONALS = 2
81
-
82
-
83
- def _looks_like_route_or_url(value: str) -> bool:
84
- """Report whether a `.get()` argument is a route path or URL rather than a key."""
85
- return value.startswith("/") or "://" in value
86
-
87
-
88
- def _get_base_name(node: ast.expr) -> str | None:
89
- if isinstance(node, ast.Name):
90
- return node.id
91
- if isinstance(node, ast.Attribute):
92
- return node.attr
93
- return None
94
-
95
-
96
- def _root_name(node: ast.expr) -> str | None:
97
- """Walk an attribute/subscript spine down to the identifier it starts at.
98
-
99
- Returns:
100
- The leftmost `Name`'s identifier, or None when the spine has no root name.
101
-
102
- """
103
- while isinstance(node, (ast.Attribute, ast.Subscript)):
104
- node = node.value
105
- return node.id if isinstance(node, ast.Name) else None
106
-
107
-
108
- def _is_dunder(key: str) -> bool:
109
- return key.startswith("__") and key.endswith("__") and len(key) > len("____")
110
-
111
-
112
- @dataclass(frozen=True, slots=True)
113
- class _FileFacts:
114
- """The whole-file context a single subscript cannot answer for itself."""
115
-
116
- annotation_nodes: frozenset[int]
117
- mutation_receivers: frozenset[int]
118
- schema_bound_names: frozenset[str]
119
- constant_tables: frozenset[str]
120
-
121
-
122
- class NoImplicitAttributeAccess(Rule):
123
- id: str = "no-implicit-attribute-access"
124
- code: str = "SARJ083"
125
- has_evidence: bool = True
126
- description: str = "Implicit dictionary access with string literals — parse declaratively with Pydantic."
127
-
128
- @override
129
- def check(self, path: Path, source: str) -> list[Diagnostic]:
130
- if _is_test_path(path) or _is_excluded_path(path):
131
- return []
132
- tree = parse_or_none(path, source)
133
- if tree is None:
134
- return []
135
-
136
- candidates: list[tuple[ast.Call | ast.Subscript, str]] = []
137
- for node in nodes(tree, ast.Call, ast.Subscript):
138
- key = _get_key(node) if isinstance(node, ast.Call) else _subscript_key(node)
139
- if key is not None:
140
- candidates.append((node, key))
141
- if not candidates:
142
- # The whole-file index below is only ever needed to *reject*, so a
143
- # file with nothing to reject must not pay for it.
144
- return []
145
-
146
- facts = _file_facts(tree)
147
- diags: list[Diagnostic] = []
148
- for node, key in candidates:
149
- if _is_exempt(node, key, facts):
150
- continue
151
- lookup = f".get('{key}')" if isinstance(node, ast.Call) else f"['{key}']"
152
- diags.append(
153
- Diagnostic(
154
- path=path,
155
- line=node.lineno,
156
- col=node.col_offset + 1,
157
- code=self.code,
158
- message=f"Imperative `{lookup}` lookup — use a declarative Pydantic model instead.",
159
- )
160
- )
161
-
162
- return diags
163
-
164
-
165
- def _get_key(node: ast.Call) -> str | None:
166
- """Read the string key of a `<base>.get("literal")` lookup worth reporting."""
167
- func = node.func
168
- if not isinstance(func, ast.Attribute) or func.attr != "get" or not node.args:
169
- return None
170
- first = node.args[0]
171
- if not isinstance(first, ast.Constant) or not isinstance(first.value, str):
172
- return None
173
- # `.get()` is also the HTTP verb and the route-registration decorator, and
174
- # both take a string first argument, so the method name alone cannot tell
175
- # them from a mapping lookup. The ARGUMENT can: a URL or a route path is not
176
- # a dictionary key. Measured on two first-party repos, this shape was 168 of the
177
- # rule's 1,756 findings (9.6%) -- `@router.get("/available-events")` and
178
- # `await self.http_client.get(url)` were reported as implicit schema access.
179
- if _looks_like_route_or_url(first.value):
180
- return None
181
- if _is_configparser_get(node):
182
- return None
183
- return None if _get_base_name(func.value) in _EXCLUDED_BASES else first.value
184
-
185
-
186
- def _is_configparser_get(node: ast.Call) -> bool:
187
- """Report whether a `.get(...)` call is `ConfigParser.get(section, option)`.
188
-
189
- `conf.get("api", "ssl_cert", fallback="")` reads an INI file by section and
190
- option; the two string arguments are not a key and a default. `dict.get`
191
- accepts no `fallback` keyword at all, so its presence alongside two
192
- positional strings identifies the configparser signature exactly and the
193
- guard costs no recall.
194
-
195
- Returns:
196
- True when the call carries `fallback=` and two positional string arguments.
197
-
198
- """
199
- if not any(kw.arg == "fallback" for kw in node.keywords):
200
- return False
201
- if len(node.args) < _CONFIGPARSER_POSITIONALS:
202
- return False
203
- return all(
204
- isinstance(arg, ast.Constant) and isinstance(arg.value, str) for arg in node.args[:_CONFIGPARSER_POSITIONALS]
205
- )
206
-
207
-
208
- def _subscript_key(node: ast.Subscript) -> str | None:
209
- """Read the string key of a `<base>["literal"]` lookup worth reporting."""
210
- # Writing to a mapping is the opposite of the defect. This rule is about
211
- # PLUCKING fields out of a payload whose schema is already known -- building
212
- # a dict up key by key (`field_dict["x"] = x`, `params["status"] = ...`) is
213
- # ordinary construction, and a Pydantic model does not replace it. Measured
214
- # on two first-party repos this was 503 of 1,756 findings (28.6%), the single
215
- # largest source, and every sampled instance was an assignment target. A
216
- # `d["k"] += 1` target is a `Store` too, so augmented assignment lands here.
217
- if isinstance(node.ctx, (ast.Store, ast.Del)):
218
- return None
219
- # `Literal["a"]`, `Annotated[T, "..."]` and friends are type expressions that
220
- # merely LOOK like subscripts. They are not dictionary access at all, and no
221
- # Pydantic model can replace them -- `Literal["user"]` IS the schema. 470 of
222
- # 1,756 findings (26.8%), second only to assignment targets. The positional
223
- # annotation guard in `_is_exempt` covers the rest of the same family, where
224
- # the wrapper is an ordinary generic (`Optional["Router"]`).
225
- base_name = _get_base_name(node.value)
226
- if base_name in _TYPE_SUBSCRIPTS:
227
- return None
228
- index = node.slice
229
- if not isinstance(index, ast.Constant) or not isinstance(index.value, str):
230
- return None
231
- return None if base_name in _EXCLUDED_BASES else index.value
232
-
233
-
234
- def _is_exempt(node: ast.Call | ast.Subscript, key: str, facts: _FileFacts) -> bool:
235
- """Report whether whole-file context clears a lookup the local test flagged.
236
-
237
- Returns:
238
- True when the lookup is one of the five measured non-defect shapes.
239
-
240
- """
241
- if _is_dunder(key):
242
- return True
243
- if id(node) in facts.annotation_nodes:
244
- return True
245
- if isinstance(node, ast.Subscript) and id(node) in facts.mutation_receivers:
246
- return True
247
- receiver = _receiver(node)
248
- if receiver is None:
249
- return False
250
- if _get_base_name(receiver) in _REFLECTION_BASES:
251
- return True
252
- if isinstance(receiver, ast.Call) and _get_base_name(receiver.func) in _REFLECTION_CALLS:
253
- return True
254
- root = _root_name(receiver)
255
- return root is not None and (root in facts.schema_bound_names or root in facts.constant_tables)
256
-
257
-
258
- def _receiver(node: ast.Call | ast.Subscript) -> ast.expr | None:
259
- """Read the expression a lookup is performed ON.
260
-
261
- Returns:
262
- `x` for `x["k"]` and for `x.get("k")`, or None when the shape is neither.
263
-
264
- """
265
- match node:
266
- case ast.Subscript(value=value):
267
- return value
268
- case ast.Call(func=ast.Attribute(value=value)):
269
- return value
270
- case _:
271
- return None
272
-
273
-
274
- def _file_facts(tree: ast.Module) -> _FileFacts:
275
- """Derive the whole-file context the per-node guards consult.
276
-
277
- Returns:
278
- The four indexes, each built from the memoized per-file node index.
279
-
280
- """
281
- typed_dicts = _typed_dict_class_names(tree)
282
- return _FileFacts(
283
- annotation_nodes=_annotation_nodes(tree),
284
- mutation_receivers=_mutation_receivers(tree),
285
- schema_bound_names=_schema_bound_names(tree, typed_dicts) if typed_dicts else frozenset(),
286
- constant_tables=_constant_tables(tree),
287
- )
288
-
289
-
290
- def _annotation_nodes(tree: ast.Module) -> frozenset[int]:
291
- """Collect the identity of every node sitting inside an annotation.
292
-
293
- Returns:
294
- `id()` of each node in a parameter, return or variable annotation subtree.
295
-
296
- """
297
- roots: list[ast.expr] = []
298
- for node in nodes(tree, ast.arg, ast.AnnAssign, ast.FunctionDef, ast.AsyncFunctionDef):
299
- match node:
300
- case ast.arg(annotation=ast.expr() as annotation):
301
- roots.append(annotation)
302
- case ast.AnnAssign(annotation=annotation):
303
- roots.append(annotation)
304
- case ast.FunctionDef(returns=ast.expr() as returns) | ast.AsyncFunctionDef(returns=ast.expr() as returns):
305
- roots.append(returns)
306
- case _:
307
- pass
308
- return frozenset(id(inner) for root in roots for inner in walk(root))
309
-
310
-
311
- def _mutation_receivers(tree: ast.Module) -> frozenset[int]:
312
- """Collect subscripts that are the receiver of an in-place collection method.
313
-
314
- Returns:
315
- `id()` of each `<sub>["k"]` that `.append`/`.update`/… is called on.
316
-
317
- """
318
- receivers: set[int] = set()
319
- for call in nodes(tree, ast.Call):
320
- func = call.func
321
- if isinstance(func, ast.Attribute) and func.attr in _MUTATION_METHODS:
322
- receivers.add(id(func.value))
323
- return frozenset(receivers)
324
-
325
-
326
- def _typed_dict_class_names(tree: ast.Module) -> frozenset[str]:
327
- """Collect the names of TypedDict types declared in this file.
328
-
329
- Both spellings are read — `class X(TypedDict)` and the functional
330
- `X = TypedDict("X", {...})` — and subclassing is followed to a fixed point,
331
- so a `class Y(X)` under a TypedDict `X` counts too.
332
-
333
- Returns:
334
- The TypedDict type names declared in this module.
335
-
336
- """
337
- declared: set[str] = set()
338
- derived: list[tuple[str, set[str]]] = []
339
- for cls in nodes(tree, ast.ClassDef):
340
- bases = {name for base in cls.bases if (name := _get_base_name(base)) is not None}
341
- if "TypedDict" in bases:
342
- declared.add(cls.name)
343
- elif bases:
344
- derived.append((cls.name, bases))
345
- for assign in nodes(tree, ast.Assign):
346
- value = assign.value
347
- if isinstance(value, ast.Call) and _get_base_name(value.func) == "TypedDict":
348
- declared.update(target.id for target in assign.targets if isinstance(target, ast.Name))
349
- grew = True
350
- while grew:
351
- grew = False
352
- for name, bases in derived:
353
- if name not in declared and bases & declared:
354
- declared.add(name)
355
- grew = True
356
- return frozenset(declared)
357
-
358
-
359
- def _annotation_heads(annotation: ast.expr) -> frozenset[str]:
360
- """Collect the type names an annotated value could be an instance of.
361
-
362
- `Optional[X]`, `Final[X]` and `X | None` all still describe an `X`, so the
363
- wrappers are unwrapped; `list[X]` is NOT, because a list of `X` is a list.
364
- A string forward reference is parsed and followed.
365
-
366
- Returns:
367
- The candidate type names, or an empty set for an unreadable annotation.
368
-
369
- """
370
- match annotation:
371
- case ast.Name(id=name):
372
- return frozenset({name})
373
- case ast.Attribute(attr=name):
374
- return frozenset({name})
375
- case ast.Constant(value=str() as text):
376
- inner = _parse_type_text(text)
377
- return _annotation_heads(inner) if inner is not None else frozenset()
378
- case ast.BinOp(op=ast.BitOr(), left=left, right=right):
379
- return _annotation_heads(left) | _annotation_heads(right)
380
- case ast.Subscript(value=value, slice=index) if _get_base_name(value) in _TYPE_WRAPPERS:
381
- elements = index.elts if isinstance(index, ast.Tuple) else [index]
382
- # Annotated rather than a bare `frozenset()`, whose element type is
383
- # unknown and makes the whole return type partially unknown.
384
- empty: frozenset[str] = frozenset()
385
- return empty.union(*(_annotation_heads(element) for element in elements))
386
- case ast.Subscript(value=value):
387
- head = _get_base_name(value)
388
- return frozenset({head}) if head is not None else frozenset()
389
- case _:
390
- return frozenset()
391
-
392
-
393
- def _parse_type_text(text: str) -> ast.expr | None:
394
- """Parse a string forward reference into the expression it names.
395
-
396
- Returns:
397
- The parsed expression, or None when the text is not one.
398
-
399
- """
400
- try:
401
- return ast.parse(text, mode="eval").body
402
- except SyntaxError, ValueError:
403
- return None
404
-
405
-
406
- def _schema_bound_names(tree: ast.Module, typed_dicts: frozenset[str]) -> frozenset[str]:
407
- """Collect names whose declared type is one of this file's TypedDicts.
408
-
409
- Returns:
410
- The parameter and annotated-variable names bound to a TypedDict type.
411
-
412
- """
413
- bound: set[str] = set()
414
- for node in nodes(tree, ast.arg, ast.AnnAssign):
415
- match node:
416
- case ast.arg(arg=name, annotation=ast.expr() as annotation):
417
- pass
418
- case ast.AnnAssign(target=ast.Name(id=name), annotation=annotation):
419
- pass
420
- case _:
421
- continue
422
- if _annotation_heads(annotation) & typed_dicts:
423
- bound.add(name)
424
- return frozenset(bound)
425
-
426
-
427
- def _constant_tables(tree: ast.Module) -> frozenset[str]:
428
- """Collect SCREAMING_CASE names bound to a dict/list literal at a declaration scope.
429
-
430
- Module body and class body only: a constant table is declared, not computed,
431
- and a local `TABLE = {...}` inside a function body is not what the shape
432
- describes.
433
-
434
- Returns:
435
- The constant lookup-table names declared in this file.
436
-
437
- """
438
- tables: set[str] = set()
439
- bodies: list[list[ast.stmt]] = [tree.body]
440
- bodies += [cls.body for cls in nodes(tree, ast.ClassDef)]
441
- for body in bodies:
442
- for stmt in body:
443
- match stmt:
444
- case ast.Assign(targets=targets, value=ast.Dict() | ast.List()):
445
- tables.update(t.id for t in targets if isinstance(t, ast.Name) and t.id.isupper())
446
- case ast.AnnAssign(target=ast.Name(id=name), value=ast.Dict() | ast.List()) if name.isupper():
447
- tables.add(name)
448
- case _:
449
- pass
450
- return frozenset(tables)
451
-
452
-
453
- def _is_test_path(path: Path) -> bool:
454
- return path.name.startswith("test_") or "tests" in path.parts
455
-
456
-
457
- def _is_excluded_path(path: Path) -> bool:
458
- excluded = {".uv-cache", ".venv", "venv", "node_modules", "site-packages"}
459
- return bool(excluded.intersection(path.parts))