sarj-python-lint 0.26.0__tar.gz → 0.28.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 (94) hide show
  1. {sarj_python_lint-0.26.0 → sarj_python_lint-0.28.0}/PKG-INFO +1 -1
  2. {sarj_python_lint-0.26.0 → sarj_python_lint-0.28.0}/pyproject.toml +1 -1
  3. sarj_python_lint-0.28.0/src/sarj_python_lint/rules/_ast_index.py +180 -0
  4. {sarj_python_lint-0.26.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/_comments.py +35 -5
  5. sarj_python_lint-0.28.0/src/sarj_python_lint/rules/_logging.py +54 -0
  6. {sarj_python_lint-0.26.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/_pytest.py +3 -1
  7. {sarj_python_lint-0.26.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/_registry.py +0 -6
  8. {sarj_python_lint-0.26.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/_suppression_comments.py +22 -31
  9. {sarj_python_lint-0.26.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/conditional_assertion_in_test.py +15 -16
  10. {sarj_python_lint-0.26.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/duplicate_test_body.py +2 -1
  11. {sarj_python_lint-0.26.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/fixture_returns_bare_tuple.py +4 -3
  12. {sarj_python_lint-0.26.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/inefficient_string_concat_in_loop.py +5 -4
  13. {sarj_python_lint-0.26.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/interaction_only_test.py +6 -5
  14. {sarj_python_lint-0.26.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/kwarg_heavy_construction_in_test.py +2 -3
  15. {sarj_python_lint-0.26.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/kwonly_same_type_params.py +19 -18
  16. {sarj_python_lint-0.26.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/mock_without_spec.py +7 -8
  17. {sarj_python_lint-0.26.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/no_aggregation_in_store_query.py +3 -4
  18. {sarj_python_lint-0.26.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/no_comment_cruft.py +22 -6
  19. {sarj_python_lint-0.26.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +3 -4
  20. {sarj_python_lint-0.26.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/no_fat_try_blocks.py +3 -4
  21. {sarj_python_lint-0.26.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/no_file_level_escape_hatch_noqa.py +3 -3
  22. {sarj_python_lint-0.26.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/no_first_party_private_import.py +3 -2
  23. {sarj_python_lint-0.26.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/no_fstring_in_log.py +5 -4
  24. {sarj_python_lint-0.26.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/no_gen_random_uuid_in_sql.py +32 -2
  25. {sarj_python_lint-0.26.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/no_implicit_attribute_access.py +47 -27
  26. {sarj_python_lint-0.26.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/no_isinstance_union_chain.py +3 -4
  27. {sarj_python_lint-0.26.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/no_offset_pagination.py +3 -4
  28. {sarj_python_lint-0.26.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/no_optional_tenant_predicate.py +5 -4
  29. {sarj_python_lint-0.26.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/no_patching_system_under_test.py +70 -3
  30. {sarj_python_lint-0.26.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/no_query_with_many_joins.py +3 -4
  31. {sarj_python_lint-0.26.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/no_raw_sql_in_tests.py +2 -3
  32. {sarj_python_lint-0.26.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/no_repeated_string_literal.py +4 -3
  33. {sarj_python_lint-0.26.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/no_secret_in_log.py +2 -3
  34. {sarj_python_lint-0.26.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/no_select_star.py +3 -4
  35. {sarj_python_lint-0.26.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +14 -10
  36. {sarj_python_lint-0.26.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/no_sequential_await.py +74 -51
  37. {sarj_python_lint-0.26.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/no_sleep_in_test_body.py +2 -1
  38. {sarj_python_lint-0.26.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/no_stdlib_logging.py +32 -17
  39. {sarj_python_lint-0.26.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/no_tautological_expect.py +37 -5
  40. {sarj_python_lint-0.26.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/no_unreachable_after_terminal.py +15 -1
  41. {sarj_python_lint-0.26.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/over_mocked_test.py +4 -3
  42. {sarj_python_lint-0.26.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/parametrize_case_needs_id.py +2 -7
  43. {sarj_python_lint-0.26.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/prefer_class_row.py +2 -3
  44. {sarj_python_lint-0.26.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +36 -26
  45. {sarj_python_lint-0.26.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +3 -2
  46. {sarj_python_lint-0.26.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/prefer_library_fake.py +4 -3
  47. {sarj_python_lint-0.26.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/prefer_match_assert_never.py +7 -10
  48. {sarj_python_lint-0.26.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/prefer_match_pattern_destructuring.py +3 -2
  49. {sarj_python_lint-0.26.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +35 -109
  50. {sarj_python_lint-0.26.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/prefer_module_level_constant.py +3 -4
  51. {sarj_python_lint-0.26.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +4 -5
  52. {sarj_python_lint-0.26.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +4 -3
  53. {sarj_python_lint-0.26.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/prefer_or_pattern.py +3 -3
  54. {sarj_python_lint-0.26.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/prefer_real_store_in_tests.py +5 -4
  55. {sarj_python_lint-0.26.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +1 -1
  56. {sarj_python_lint-0.26.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/prefer_str_enum.py +10 -13
  57. {sarj_python_lint-0.26.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +3 -3
  58. {sarj_python_lint-0.26.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/prefer_timedelta_for_durations.py +4 -3
  59. {sarj_python_lint-0.26.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +8 -8
  60. {sarj_python_lint-0.26.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +3 -2
  61. {sarj_python_lint-0.26.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +4 -8
  62. {sarj_python_lint-0.26.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +4 -5
  63. {sarj_python_lint-0.26.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/redundant_docstring.py +2 -1
  64. {sarj_python_lint-0.26.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/require_port_for_service.py +21 -1
  65. {sarj_python_lint-0.26.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/stepdown.py +2 -1
  66. {sarj_python_lint-0.26.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +3 -4
  67. {sarj_python_lint-0.26.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/tautological_mock_assertion.py +183 -10
  68. {sarj_python_lint-0.26.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/test_loops_over_literal_cases.py +3 -2
  69. {sarj_python_lint-0.26.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/trivially_true_assertion.py +48 -172
  70. {sarj_python_lint-0.26.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/unused_mock_setup.py +8 -9
  71. {sarj_python_lint-0.26.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/xfail_requires_strict.py +3 -4
  72. {sarj_python_lint-0.26.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/zero_assertion_test.py +5 -5
  73. sarj_python_lint-0.26.0/src/sarj_python_lint/rules/_logging.py +0 -39
  74. sarj_python_lint-0.26.0/src/sarj_python_lint/rules/prefer_pattern_matching.py +0 -340
  75. sarj_python_lint-0.26.0/src/sarj_python_lint/rules/primary_export_file_name.py +0 -149
  76. {sarj_python_lint-0.26.0 → sarj_python_lint-0.28.0}/.gitignore +0 -0
  77. {sarj_python_lint-0.26.0 → sarj_python_lint-0.28.0}/README.md +0 -0
  78. {sarj_python_lint-0.26.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/__init__.py +0 -0
  79. {sarj_python_lint-0.26.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/__main__.py +0 -0
  80. {sarj_python_lint-0.26.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/_ratchet_cli.py +0 -0
  81. {sarj_python_lint-0.26.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/_secret_names.py +0 -0
  82. {sarj_python_lint-0.26.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/_version.py +0 -0
  83. {sarj_python_lint-0.26.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/py.typed +0 -0
  84. {sarj_python_lint-0.26.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/ratchet.py +0 -0
  85. {sarj_python_lint-0.26.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rule_base.py +0 -0
  86. {sarj_python_lint-0.26.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/__init__.py +0 -0
  87. {sarj_python_lint-0.26.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/_first_party.py +0 -0
  88. {sarj_python_lint-0.26.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/_paths.py +0 -0
  89. {sarj_python_lint-0.26.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/_sql.py +0 -0
  90. {sarj_python_lint-0.26.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/no_file_level_suppression.py +0 -0
  91. {sarj_python_lint-0.26.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
  92. {sarj_python_lint-0.26.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/single_public_export.py +0 -0
  93. {sarj_python_lint-0.26.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/sleep_with_computed_arg_in_test.py +0 -0
  94. {sarj_python_lint-0.26.0 → sarj_python_lint-0.28.0}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sarj-python-lint
3
- Version: 0.26.0
3
+ Version: 0.28.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.26.0"
3
+ version = "0.28.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,180 @@
1
+ """One tree traversal per file, shared by every rule that needs node lookups.
2
+
3
+ Profiling the whole registry over a 1,920-line module showed `ast.walk` and its
4
+ helpers (`iter_child_nodes` / `iter_fields`) accounting for ~60% of all rule
5
+ time. The cause is structural rather than any one rule's fault: the dominant
6
+ shape of a rule is
7
+
8
+ for node in ast.walk(tree):
9
+ if isinstance(node, ast.Something):
10
+ ...
11
+
12
+ so a file with N nodes and R rules asking that question pays R full traversals,
13
+ each one re-deriving the same partition of the tree by node type.
14
+
15
+ `nodes()` derives that partition **once per file** and answers every subsequent
16
+ type query from it. The index is memoized in a single slot keyed on tree
17
+ identity, mirroring `parse_or_none`: the CLI iterates files on the outer loop
18
+ and rules on the inner one, so one slot is all that is ever live. The slot holds
19
+ a strong reference to the tree, so a recycled `id()` can never alias a stale
20
+ index.
21
+
22
+ Two invariants make the substitution finding-preserving, not merely faster:
23
+
24
+ * **Order.** The index is built by breadth-first traversal, the same order
25
+ `ast.walk` yields, and every query preserves it. `nodes(tree, T)` is therefore
26
+ elementwise equal to `[n for n in ast.walk(tree) if isinstance(n, T)]`, so a
27
+ rule that takes the *first* match, or reports in discovery order, is
28
+ unaffected.
29
+ * **Subclasses.** Queries match by `isinstance`, not by exact type: the buckets
30
+ are keyed on exact class, and a query resolves to the set of present classes
31
+ that are subclasses of the requested types. Asking for `ast.stmt` still
32
+ returns every statement.
33
+
34
+ `walk()` is the same breadth-first order for the cases the index cannot serve —
35
+ traversing a *subtree* rather than the module, where containment is not
36
+ something a whole-module partition can answer.
37
+
38
+ No rule may mutate the tree. That was already required — every rule shares one
39
+ memoized parse, and `duplicate_test_body` deep-copies before normalizing — and
40
+ the index inherits the requirement, since it holds references into the tree it
41
+ was built from.
42
+ """
43
+
44
+ from __future__ import annotations
45
+
46
+ import ast
47
+ from typing import TYPE_CHECKING, final
48
+
49
+
50
+ if TYPE_CHECKING:
51
+ from collections.abc import Iterator
52
+
53
+
54
+ _AST = ast.AST
55
+
56
+
57
+ def children(node: ast.AST) -> list[ast.AST]:
58
+ """`node`'s direct children, in the same order as `ast.iter_child_nodes`.
59
+
60
+ A list rather than a generator: every caller either iterates it once or
61
+ extends a stack with it, and the hand-rolled walkers in this package call
62
+ this once per node, where the generator's setup cost dominates the work.
63
+
64
+ Returns:
65
+ The child nodes, in field-declaration order.
66
+
67
+ """
68
+ out: list[ast.AST] = []
69
+ for name in node._fields:
70
+ value: object = getattr(node, name, None)
71
+ if isinstance(value, list):
72
+ out += [item for item in value if isinstance(item, _AST)] # pyright: ignore[reportUnknownVariableType] — element narrowed by isinstance
73
+ elif isinstance(value, _AST):
74
+ out.append(value)
75
+ return out
76
+
77
+
78
+ def walk(node: ast.AST) -> Iterator[ast.AST]:
79
+ """Yield `node` and every descendant, breadth-first.
80
+
81
+ Identical in order to `ast.walk`, but reads each node's fields directly
82
+ instead of routing every node through two intermediate generators.
83
+
84
+ Yields:
85
+ `node` first, then its descendants breadth-first.
86
+
87
+ """
88
+ queue: list[ast.AST] = [node]
89
+ i = 0
90
+ while i < len(queue):
91
+ current = queue[i]
92
+ i += 1
93
+ for name in current._fields:
94
+ value: object = getattr(current, name, None)
95
+ if isinstance(value, list):
96
+ queue += [item for item in value if isinstance(item, _AST)] # pyright: ignore[reportUnknownVariableType] — element narrowed by isinstance
97
+ elif isinstance(value, _AST):
98
+ queue.append(value)
99
+ yield current
100
+
101
+
102
+ @final
103
+ class _NodeIndex:
104
+ """A module's nodes partitioned by exact class, in breadth-first order."""
105
+
106
+ __slots__ = ("_buckets", "_flat", "_queries")
107
+
108
+ def __init__(self, tree: ast.AST) -> None:
109
+ buckets: dict[type[ast.AST], list[ast.AST]] = {}
110
+ flat: list[ast.AST] = [tree]
111
+ i = 0
112
+ while i < len(flat):
113
+ current = flat[i]
114
+ i += 1
115
+ cls = current.__class__
116
+ bucket = buckets.get(cls)
117
+ if bucket is None:
118
+ buckets[cls] = [current]
119
+ else:
120
+ bucket.append(current)
121
+ for name in cls._fields:
122
+ value: object = getattr(current, name, None)
123
+ if isinstance(value, list):
124
+ flat += [item for item in value if isinstance(item, _AST)] # pyright: ignore[reportUnknownVariableType] — element narrowed by isinstance
125
+ elif isinstance(value, _AST):
126
+ flat.append(value)
127
+ self._buckets: dict[type[ast.AST], list[ast.AST]] = buckets
128
+ self._flat: list[ast.AST] = flat
129
+ self._queries: dict[tuple[type[ast.AST], ...], list[ast.AST]] = {}
130
+
131
+ def query(self, types: tuple[type[ast.AST], ...]) -> list[ast.AST]:
132
+ """Return every node matching `isinstance(node, types)`, breadth-first.
133
+
134
+ Returns:
135
+ The match list, cached per `types` and shared — treat as read-only.
136
+
137
+ """
138
+ hit = self._queries.get(types)
139
+ if hit is not None:
140
+ return hit
141
+ matched = frozenset(cls for cls in self._buckets if issubclass(cls, types))
142
+ if len(matched) == 1:
143
+ # The single bucket is already the answer, in breadth-first order.
144
+ result = self._buckets[next(iter(matched))]
145
+ elif len(matched) == len(self._buckets):
146
+ # Every class present matches — e.g. `nodes(tree, ast.AST)`, asked by
147
+ # a rule whose loop body inspects every node rather than a few types.
148
+ result = self._flat
149
+ elif matched:
150
+ result = [node for node in self._flat if node.__class__ in matched]
151
+ else:
152
+ result = []
153
+ self._queries[types] = result
154
+ return result
155
+
156
+
157
+ _last_index: tuple[ast.AST, _NodeIndex] | None = None
158
+
159
+
160
+ def nodes[NodeT: ast.AST](tree: ast.AST, *types: type[NodeT]) -> list[NodeT]:
161
+ """Every node of `tree` matching `types`, in `ast.walk` order.
162
+
163
+ A drop-in, memoized replacement for the ubiquitous
164
+ `[n for n in ast.walk(tree) if isinstance(n, types)]`. Pass the *module*
165
+ tree: the index is per-file, so calling this with a subtree builds a fresh
166
+ index and evicts the module's. Use `walk` for subtrees.
167
+
168
+ Returns:
169
+ The matching nodes, breadth-first. A fresh list per call; the partition
170
+ it is drawn from is what gets cached.
171
+
172
+ """
173
+ global _last_index # ruff: ignore[global-statement] — single-slot memo, mirroring `parse_or_none`
174
+ if _last_index is None or _last_index[0] is not tree:
175
+ _last_index = (tree, _NodeIndex(tree))
176
+ # The `isinstance` pass is what narrows `list[ast.AST]` to `list[NodeT]`
177
+ # honestly, rather than asserting it with a `cast` the house style bans. It
178
+ # runs over the matches only — never the tree — and every element passes by
179
+ # construction, so it costs one type check per node actually returned.
180
+ return [node for node in _last_index[1].query(types) if isinstance(node, types)]
@@ -397,7 +397,12 @@ def restates(comment_tokens: Sequence[str], code: Iterable[str]) -> bool:
397
397
  _LAYOUT_TOKENS = frozenset({tokenize.NL, tokenize.NEWLINE, tokenize.INDENT, tokenize.DEDENT})
398
398
  _NON_CODE_TOKENS = _LAYOUT_TOKENS | frozenset({tokenize.COMMENT, tokenize.ENCODING, tokenize.ENDMARKER})
399
399
 
400
- _Scan = tuple[list[tuple[int, int, str]], list[tuple[int, int, str]], set[int], int]
400
+ # `(line, col0, body, standalone)` for every comment, in source order. This is
401
+ # what `_suppression_comments` needs, and it is a by-product of the pass below
402
+ # rather than a reason to run a second one — see `all_comments`.
403
+ _Ordered = list[tuple[int, int, str, bool]]
404
+
405
+ _Scan = tuple[list[tuple[int, int, str]], list[tuple[int, int, str]], set[int], int, _Ordered]
401
406
 
402
407
  _last_scan: tuple[str, _Scan] | None = None
403
408
 
@@ -405,6 +410,7 @@ _last_scan: tuple[str, _Scan] | None = None
405
410
  def _scan(source: str) -> _Scan:
406
411
  standalone: list[tuple[int, int, str]] = []
407
412
  trailing: list[tuple[int, int, str]] = []
413
+ ordered: _Ordered = []
408
414
  nested: set[int] = set()
409
415
  first_code_line = 1 << 30
410
416
  prev_end_row = 0
@@ -412,8 +418,11 @@ def _scan(source: str) -> _Scan:
412
418
  readline = io.StringIO(source).readline
413
419
  for tok in tokenize.generate_tokens(readline):
414
420
  if tok.type == tokenize.COMMENT:
415
- entry = (tok.start[0], tok.start[1], tok.string.lstrip("#").strip())
416
- (trailing if tok.start[0] == prev_end_row else standalone).append(entry)
421
+ body = tok.string.lstrip("#").strip()
422
+ entry = (tok.start[0], tok.start[1], body)
423
+ is_standalone = tok.start[0] != prev_end_row
424
+ (standalone if is_standalone else trailing).append(entry)
425
+ ordered.append((tok.start[0], tok.start[1], body, is_standalone))
417
426
  if depth > 0:
418
427
  nested.add(tok.start[0])
419
428
  elif tok.type == tokenize.OP:
@@ -425,7 +434,28 @@ def _scan(source: str) -> _Scan:
425
434
  prev_end_row = tok.end[0]
426
435
  if tok.type not in _NON_CODE_TOKENS:
427
436
  first_code_line = min(first_code_line, tok.start[0])
428
- return standalone, trailing, nested, first_code_line
437
+ return standalone, trailing, nested, first_code_line, ordered
438
+
439
+
440
+ def all_comments(source: str) -> tuple[_Ordered, int]:
441
+ """Return every comment as `(line, col0, body, standalone)`, plus the first code line.
442
+
443
+ Exists so the suppression rules (SARJ038/054) can share this module's
444
+ tokenize pass instead of running a second one. Both scanners computed the
445
+ same three facts — comment text, whether it stands alone on its line, and
446
+ where the first real code token is — from identical token-class sets, so the
447
+ second pass was pure duplicated work: SARJ038 alone spent ~4% of total rule
448
+ time on it.
449
+
450
+ `col0` is 0-based, matching this module's other accessors; the suppression
451
+ layer adds one for its 1-based `Comment.col`.
452
+
453
+ Returns:
454
+ The ordered comments and the first code line's row.
455
+
456
+ """
457
+ _, _, _, first_code_line, ordered = _scan_memo(source)
458
+ return ordered, first_code_line
429
459
 
430
460
 
431
461
  def _scan_memo(source: str) -> _Scan:
@@ -482,7 +512,7 @@ def standalone_comments(source: str) -> tuple[list[tuple[int, int, str]], int]:
482
512
  The standalone comments and the first code line's row.
483
513
 
484
514
  """
485
- standalone, _, _, first_code_line = _scan_memo(source)
515
+ standalone, _, _, first_code_line, _ = _scan_memo(source)
486
516
  return standalone, first_code_line
487
517
 
488
518
 
@@ -0,0 +1,54 @@
1
+ """Shared logging-receiver detection for SARJ012/SARJ017.
2
+
3
+ A single resolver for "does this receiver expression evaluate to a logger?",
4
+ used by both the secret-in-log and f-string-in-log rules so they recognise the
5
+ same factory/builder forms.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import ast
11
+
12
+
13
+ _LOGGER_NAMES = frozenset({"logger", "log", "logging", "loguru", "_logger", "_log"})
14
+
15
+ _LOGGER_FACTORIES = frozenset({"getlogger", "get_logger"})
16
+
17
+
18
+ def is_logger_expr(expr: ast.expr) -> bool:
19
+ """Report whether `expr` evaluates to a logger.
20
+
21
+ Resolves the whole receiver chain so adapter/builder/factory calls are
22
+ caught: `logger.bind(...).info(...)`, `logger.opt(lazy=True).debug(...)`,
23
+ `logging.getLogger(__name__).info(...)`, `self.logger.error(...)`, and the
24
+ bare-name factory `get_logger().info(...)`.
25
+
26
+ Returns:
27
+ True when `expr` resolves to a logger receiver.
28
+
29
+ """
30
+ if isinstance(expr, ast.Name):
31
+ return expr.id.lower() in _LOGGER_NAMES
32
+ if isinstance(expr, ast.Attribute):
33
+ if expr.attr.lower() in _LOGGER_NAMES or expr.attr.lower() in _LOGGER_FACTORIES:
34
+ return True
35
+ return is_logger_expr(expr.value)
36
+ if isinstance(expr, ast.Call):
37
+ # A factory names a logger only when it is *called*: `get_logger()` is a
38
+ # logger, the bare name `get_logger` is a function — which is why the
39
+ # factories live in their own set and not in `_LOGGER_NAMES`. Both the
40
+ # dotted spelling (`structlog.get_logger()`, `logging.getLogger()`) and
41
+ # the bare one (`from structlog import get_logger`, then `get_logger()`)
42
+ # have to be recognised right here: a bare callee is an `ast.Name`, and
43
+ # recursing on it lands in the `_LOGGER_NAMES` branch, which by that
44
+ # design does not carry the factory names. Omitting this second check is
45
+ # a silent false negative rather than a crash, so it went unnoticed —
46
+ # `get_logger().info("auth", token=token)`, structlog's own documented
47
+ # module-level idiom, was unlinted by both SARJ012 and SARJ017.
48
+ callee = expr.func
49
+ if isinstance(callee, ast.Attribute) and callee.attr.lower() in _LOGGER_FACTORIES:
50
+ return True
51
+ if isinstance(callee, ast.Name) and callee.id.lower() in _LOGGER_FACTORIES:
52
+ return True
53
+ return is_logger_expr(callee)
54
+ return False
@@ -13,6 +13,8 @@ from __future__ import annotations
13
13
 
14
14
  import ast
15
15
 
16
+ from sarj_python_lint.rules._ast_index import walk
17
+
16
18
 
17
19
  # The pytest-benchmark fixture: the test measures time, it does not verify.
18
20
  _BENCHMARK = "benchmark"
@@ -33,7 +35,7 @@ def uses_benchmark_fixture(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool
33
35
  """
34
36
  args = node.args
35
37
  declared = any(arg.arg == _BENCHMARK for arg in (*args.posonlyargs, *args.args, *args.kwonlyargs))
36
- return declared and any(isinstance(child, ast.Name) and child.id == _BENCHMARK for child in ast.walk(node))
38
+ return declared and any(isinstance(child, ast.Name) and child.id == _BENCHMARK for child in walk(node))
37
39
 
38
40
 
39
41
  def has_benchmark_marker(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
@@ -69,7 +69,6 @@ from sarj_python_lint.rules.prefer_non_nullable_collection import (
69
69
  PreferNonNullableCollection,
70
70
  )
71
71
  from sarj_python_lint.rules.prefer_or_pattern import PreferOrPattern
72
- from sarj_python_lint.rules.prefer_pattern_matching import PreferPatternMatching
73
72
  from sarj_python_lint.rules.prefer_real_store_in_tests import PreferRealStoreInTests
74
73
  from sarj_python_lint.rules.prefer_self_type_annotation import PreferSelfTypeAnnotation
75
74
  from sarj_python_lint.rules.prefer_str_enum import PreferStrEnum
@@ -84,9 +83,6 @@ from sarj_python_lint.rules.prefer_walrus_comprehension_filter import (
84
83
  )
85
84
  from sarj_python_lint.rules.prefer_walrus_regex_match import PreferWalrusRegexMatch
86
85
  from sarj_python_lint.rules.prefer_walrus_stream_loop import PreferWalrusStreamLoop
87
- from sarj_python_lint.rules.primary_export_file_name import (
88
- PrimaryExportFileName,
89
- )
90
86
  from sarj_python_lint.rules.pydantic_at_boundaries import PydanticAtBoundaries
91
87
  from sarj_python_lint.rules.redundant_docstring import RedundantDocstring
92
88
  from sarj_python_lint.rules.require_port_for_service import RequirePortForService
@@ -173,11 +169,9 @@ REGISTRY: dict[str, type[Rule]] = {
173
169
  PreferFstringOverConcat.id: PreferFstringOverConcat,
174
170
  PreferMatchPatternDestructuring.id: PreferMatchPatternDestructuring,
175
171
  PreferOrPattern.id: PreferOrPattern,
176
- PreferPatternMatching.id: PreferPatternMatching,
177
172
  RequirePortForService.id: RequirePortForService,
178
173
  PreferNonNullableCollection.id: PreferNonNullableCollection,
179
174
  PreferMatchTypeDispatch.id: PreferMatchTypeDispatch,
180
- PrimaryExportFileName.id: PrimaryExportFileName,
181
175
  PreferWalrusRegexMatch.id: PreferWalrusRegexMatch,
182
176
  PreferWalrusComprehensionFilter.id: PreferWalrusComprehensionFilter,
183
177
  PreferWalrusStreamLoop.id: PreferWalrusStreamLoop,
@@ -9,17 +9,9 @@ drifting apart on what "file-level" means.
9
9
 
10
10
  from __future__ import annotations
11
11
 
12
- from dataclasses import dataclass, replace
13
- import io
14
- import tokenize
12
+ from dataclasses import dataclass
15
13
 
16
-
17
- # Sentinel row for "this file has no statement at all" (empty / comments-only):
18
- # every comment then counts as preceding the first statement.
19
- _NO_STATEMENT_LINE = 1 << 30
20
-
21
- _LAYOUT_TOKENS = frozenset({tokenize.NL, tokenize.NEWLINE, tokenize.INDENT, tokenize.DEDENT})
22
- _NON_STATEMENT_TOKENS = _LAYOUT_TOKENS | frozenset({tokenize.COMMENT, tokenize.ENCODING, tokenize.ENDMARKER})
14
+ from sarj_python_lint.rules._comments import all_comments
23
15
 
24
16
 
25
17
  @dataclass(frozen=True, slots=True)
@@ -34,7 +26,7 @@ class Comment:
34
26
 
35
27
 
36
28
  def scan_comments(source: str) -> list[Comment]:
37
- """Tokenize `source` and describe every comment in it.
29
+ """Describe every comment in `source`, in source order.
38
30
 
39
31
  A comment is standalone when no token ended on its line before it, and
40
32
  precedes the first statement when it sits above the first non-comment,
@@ -43,29 +35,28 @@ def scan_comments(source: str) -> list[Comment]:
43
35
  Anything `tokenize` cannot lex propagates as `tokenize.TokenError`,
44
36
  `IndentationError` or `SyntaxError`; callers treat that as "no diagnostics".
45
37
 
38
+ Built from `_comments.all_comments`, which is the same tokenize pass the
39
+ comment-hygiene rules already run. This module used to lex the file a second
40
+ time to compute exactly the same three facts from identical token-class
41
+ sets, which cost SARJ038 ~4% of total rule time for no additional
42
+ information. The memo now lives in one place rather than two.
43
+
46
44
  Returns:
47
- Every comment, in source order.
45
+ Every comment, in source order. `Comment` is frozen and the list is
46
+ read-only to callers.
48
47
 
49
48
  """
50
- comments: list[Comment] = []
51
- first_statement_line = _NO_STATEMENT_LINE
52
- prev_end_row = 0
53
- readline = io.StringIO(source).readline
54
- for tok in tokenize.generate_tokens(readline):
55
- if tok.type == tokenize.COMMENT:
56
- comments.append(
57
- Comment(
58
- line=tok.start[0],
59
- col=tok.start[1] + 1,
60
- body=comment_body(tok.string),
61
- standalone=tok.start[0] != prev_end_row,
62
- )
63
- )
64
- if tok.type not in _LAYOUT_TOKENS:
65
- prev_end_row = tok.end[0]
66
- if tok.type not in _NON_STATEMENT_TOKENS:
67
- first_statement_line = min(first_statement_line, tok.start[0])
68
- return [replace(c, before_first_statement=c.line < first_statement_line) for c in comments]
49
+ ordered, first_statement_line = all_comments(source)
50
+ return [
51
+ Comment(
52
+ line=line,
53
+ col=col + 1,
54
+ body=body,
55
+ standalone=standalone,
56
+ before_first_statement=line < first_statement_line,
57
+ )
58
+ for line, col, body, standalone in ordered
59
+ ]
69
60
 
70
61
 
71
62
  def comment_body(raw: str) -> str:
@@ -189,6 +189,7 @@ import re
189
189
  from typing import TYPE_CHECKING, override
190
190
 
191
191
  from sarj_python_lint.rule_base import Diagnostic, Rule, parse_or_none
192
+ from sarj_python_lint.rules._ast_index import nodes, walk
192
193
  from sarj_python_lint.rules._paths import is_test_path
193
194
 
194
195
 
@@ -407,7 +408,7 @@ def _is_exempt(node: ast.FunctionDef | ast.AsyncFunctionDef, helpers: frozenset[
407
408
  if any(_decorator_name(dec) in _PROPERTY_DECORATORS for dec in node.decorator_list):
408
409
  return True
409
410
  for stmt in node.body:
410
- for child in ast.walk(stmt):
411
+ for child in walk(stmt):
411
412
  # `with self.subTest(...)` drives a table whose emptiness this rule
412
413
  # cannot see; a nested `def` holding the assertions may be invoked
413
414
  # by a runner (`asyncio.run`, a callback registry) the rule cannot
@@ -592,7 +593,7 @@ def _loop_guarantees(stmt: ast.For | ast.AsyncFor, facts: _Facts) -> bool:
592
593
 
593
594
 
594
595
  def _bound_names(target: ast.expr) -> frozenset[str]:
595
- return frozenset(node.id for node in ast.walk(target) if isinstance(node, ast.Name))
596
+ return frozenset(node.id for node in walk(target) if isinstance(node, ast.Name))
596
597
 
597
598
 
598
599
  def _try_guarantees(stmt: ast.Try, facts: _Facts) -> bool:
@@ -622,7 +623,7 @@ def _is_capability_probe(test: ast.expr) -> bool:
622
623
  True when the condition reads a capability flag or calls `hasattr`.
623
624
 
624
625
  """
625
- for node in ast.walk(test):
626
+ for node in walk(test):
626
627
  if isinstance(node, ast.Name) and _CAPABILITY_RE.search(node.id):
627
628
  return True
628
629
  if isinstance(node, ast.Attribute) and _CAPABILITY_RE.search(node.attr):
@@ -655,7 +656,7 @@ def _expr_names_assertion(expr: ast.expr, helpers: frozenset[str]) -> bool:
655
656
 
656
657
 
657
658
  def _contains_assertion(node: ast.AST, helpers: frozenset[str]) -> bool:
658
- return any(_is_assertion(child, helpers) for child in ast.walk(node))
659
+ return any(_is_assertion(child, helpers) for child in walk(node))
659
660
 
660
661
 
661
662
  def _body_contains_assertion(node: ast.FunctionDef | ast.AsyncFunctionDef, helpers: frozenset[str]) -> bool:
@@ -666,7 +667,7 @@ def _body_contains_assertion(node: ast.FunctionDef | ast.AsyncFunctionDef, helpe
666
667
 
667
668
 
668
669
  def _holds_assertion_call(stmt: ast.stmt, helpers: frozenset[str]) -> bool:
669
- return any(_is_assertion(child, helpers) for child in ast.walk(stmt))
670
+ return any(_is_assertion(child, helpers) for child in walk(stmt))
670
671
 
671
672
 
672
673
  def _is_assertion(child: ast.AST, helpers: frozenset[str]) -> bool:
@@ -711,9 +712,8 @@ def _asserting_helper_names(tree: ast.Module) -> frozenset[str]:
711
712
 
712
713
  """
713
714
  defs: dict[str, ast.FunctionDef | ast.AsyncFunctionDef] = {}
714
- for node in ast.walk(tree):
715
- if isinstance(node, _FUNC_NODES):
716
- defs.setdefault(node.name, node)
715
+ for node in nodes(tree, *_FUNC_NODES):
716
+ defs.setdefault(node.name, node)
717
717
  verifying = {name for name, node in defs.items() if _contains_assertion(node, frozenset())}
718
718
  pending = {name: _called_names(node) for name, node in defs.items() if name not in verifying}
719
719
  while True:
@@ -726,7 +726,7 @@ def _asserting_helper_names(tree: ast.Module) -> frozenset[str]:
726
726
 
727
727
  def _called_names(node: ast.AST) -> set[str]:
728
728
  names: set[str] = set()
729
- for child in ast.walk(node):
729
+ for child in walk(node):
730
730
  if isinstance(child, ast.Call):
731
731
  func = child.func
732
732
  if isinstance(func, ast.Attribute):
@@ -779,7 +779,7 @@ def _facts_for(
779
779
  helpers: frozenset[str],
780
780
  ) -> _Facts:
781
781
  bindings = dict(module_bindings)
782
- bindings.update(_bindings_in(list(ast.walk(node))))
782
+ bindings.update(_bindings_in(list(walk(node))))
783
783
  bindings.update(_default_bindings(node))
784
784
  nonempty = _nonempty_claims(node, helpers) | set(module_nonempty) | _parametrized_nonempty(node)
785
785
  base = _Facts(nonempty=frozenset(nonempty), bindings=bindings, imported=imported, helpers=helpers)
@@ -827,10 +827,10 @@ def _accumulators_filled(node: ast.FunctionDef | ast.AsyncFunctionDef, facts: _F
827
827
 
828
828
  """
829
829
  filled: set[str] = set()
830
- for child in ast.walk(node):
830
+ for child in walk(node):
831
831
  if not isinstance(child, _LOOP_NODES) or not _iterable_is_nonempty(child.iter, facts, frozenset()):
832
832
  continue
833
- for inner in ast.walk(child):
833
+ for inner in walk(child):
834
834
  if not (isinstance(inner, ast.Call) and isinstance(inner.func, ast.Attribute)):
835
835
  continue
836
836
  receiver = inner.func.value
@@ -847,9 +847,8 @@ def _imported_names(tree: ast.Module) -> frozenset[str]:
847
847
 
848
848
  """
849
849
  names: set[str] = set()
850
- for node in ast.walk(tree):
851
- if isinstance(node, (ast.Import, ast.ImportFrom)):
852
- names.update(alias.asname or alias.name.split(".")[0] for alias in node.names)
850
+ for node in nodes(tree, ast.Import, ast.ImportFrom):
851
+ names.update(alias.asname or alias.name.split(".")[0] for alias in node.names)
853
852
  return frozenset(names)
854
853
 
855
854
 
@@ -861,7 +860,7 @@ def _nonempty_claims(node: ast.AST, helpers: frozenset[str]) -> set[str]:
861
860
 
862
861
  """
863
862
  nonempty: set[str] = set()
864
- for child in ast.walk(node):
863
+ for child in walk(node):
865
864
  if isinstance(child, ast.Assert):
866
865
  nonempty |= _proves_nonempty(child.test)
867
866
  elif isinstance(child, ast.Call) and _names_assertion(child.func, helpers):
@@ -198,6 +198,7 @@ import tokenize
198
198
  from typing import TYPE_CHECKING, override
199
199
 
200
200
  from sarj_python_lint.rule_base import Diagnostic, Rule, parse_or_none
201
+ from sarj_python_lint.rules._ast_index import children
201
202
  from sarj_python_lint.rules._comments import standalone_comments, trailing_comments
202
203
  from sarj_python_lint.rules._paths import is_generated_source, is_test_path
203
204
 
@@ -656,7 +657,7 @@ def _decorator_shape(node: ast.FunctionDef | ast.AsyncFunctionDef) -> str:
656
657
 
657
658
  def _walk(node: ast.AST) -> Iterator[ast.AST]:
658
659
  yield node
659
- for child in ast.iter_child_nodes(node):
660
+ for child in children(node):
660
661
  yield from _walk(child)
661
662
 
662
663
 
@@ -51,6 +51,7 @@ import ast
51
51
  from typing import TYPE_CHECKING, override
52
52
 
53
53
  from sarj_python_lint.rule_base import Diagnostic, Rule, parse_or_none
54
+ from sarj_python_lint.rules._ast_index import children, nodes
54
55
  from sarj_python_lint.rules._paths import is_test_path
55
56
 
56
57
 
@@ -108,8 +109,8 @@ class FixtureReturnsBareTuple(Rule):
108
109
 
109
110
  def _bare_tuple_results(tree: ast.Module) -> list[tuple[ast.expr, int]]:
110
111
  hits: list[tuple[ast.expr, int]] = []
111
- for node in ast.walk(tree):
112
- if not isinstance(node, _FUNC_NODES) or not _is_fixture(node):
112
+ for node in nodes(tree, *_FUNC_NODES):
113
+ if not _is_fixture(node):
113
114
  continue
114
115
  if _returns_distinctly_typed_tuple(node):
115
116
  continue
@@ -175,7 +176,7 @@ def _scan_for_results(node: ast.AST) -> list[tuple[ast.expr, int]]:
175
176
  count = _bare_tuple_arity(value)
176
177
  if count >= _MIN_FIELDS:
177
178
  found.append((value, count))
178
- for child in ast.iter_child_nodes(node):
179
+ for child in children(node):
179
180
  found.extend(_scan_for_results(child))
180
181
  return found
181
182
 
@@ -48,6 +48,7 @@ import ast
48
48
  from typing import TYPE_CHECKING, TypeGuard, override
49
49
 
50
50
  from sarj_python_lint.rule_base import Diagnostic, Rule, parse_or_none
51
+ from sarj_python_lint.rules._ast_index import children, walk
51
52
  from sarj_python_lint.rules._paths import is_generated_source
52
53
 
53
54
 
@@ -219,7 +220,7 @@ def _test_names(test: ast.expr) -> set[str]:
219
220
  The unparsed reads appearing in the test expression.
220
221
 
221
222
  """
222
- return {ast.unparse(n) for n in ast.walk(test) if isinstance(n, (ast.Name, ast.Attribute))}
223
+ return {ast.unparse(n) for n in walk(test) if isinstance(n, (ast.Name, ast.Attribute))}
223
224
 
224
225
 
225
226
  def _loop_read_names(loop: ast.For | ast.AsyncFor | ast.While) -> frozenset[str]:
@@ -248,7 +249,7 @@ def _loop_read_names(loop: ast.For | ast.AsyncFor | ast.While) -> frozenset[str]
248
249
  if other is not None:
249
250
  stack.append(other)
250
251
  continue
251
- stack.extend(ast.iter_child_nodes(node))
252
+ stack.extend(children(node))
252
253
  if isinstance(node, (ast.Name, ast.Attribute)) and isinstance(node.ctx, ast.Load):
253
254
  reads.add(ast.unparse(node))
254
255
  return frozenset(reads)
@@ -288,7 +289,7 @@ def _collect_reassignments(node: ast.AST, reassigns: dict[str, list[int]]) -> No
288
289
  and not _is_accumulation_assign(node.target, node.value)
289
290
  ):
290
291
  reassigns.setdefault(ast.unparse(node.target), []).append(node.target.lineno)
291
- for child in ast.iter_child_nodes(node):
292
+ for child in children(node):
292
293
  _collect_reassignments(child, reassigns)
293
294
 
294
295
 
@@ -364,7 +365,7 @@ def _collect_string_targets(node: ast.AST, names: set[str]) -> None:
364
365
  and _looks_like_string(node.value)
365
366
  ):
366
367
  names.add(node.target.id)
367
- for child in ast.iter_child_nodes(node):
368
+ for child in children(node):
368
369
  _collect_string_targets(child, names)
369
370
 
370
371