sarj-python-lint 0.26.0__tar.gz → 0.27.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.27.0}/PKG-INFO +1 -1
  2. {sarj_python_lint-0.26.0 → sarj_python_lint-0.27.0}/pyproject.toml +1 -1
  3. sarj_python_lint-0.27.0/src/sarj_python_lint/rules/_ast_index.py +180 -0
  4. sarj_python_lint-0.27.0/src/sarj_python_lint/rules/_logging.py +54 -0
  5. {sarj_python_lint-0.26.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/_pytest.py +3 -1
  6. {sarj_python_lint-0.26.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/_suppression_comments.py +18 -0
  7. {sarj_python_lint-0.26.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/conditional_assertion_in_test.py +15 -16
  8. {sarj_python_lint-0.26.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/duplicate_test_body.py +2 -1
  9. {sarj_python_lint-0.26.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/fixture_returns_bare_tuple.py +4 -3
  10. {sarj_python_lint-0.26.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/inefficient_string_concat_in_loop.py +5 -4
  11. {sarj_python_lint-0.26.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/interaction_only_test.py +6 -5
  12. {sarj_python_lint-0.26.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/kwarg_heavy_construction_in_test.py +2 -3
  13. {sarj_python_lint-0.26.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/kwonly_same_type_params.py +19 -18
  14. {sarj_python_lint-0.26.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/mock_without_spec.py +7 -8
  15. {sarj_python_lint-0.26.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/no_aggregation_in_store_query.py +3 -4
  16. {sarj_python_lint-0.26.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/no_comment_cruft.py +22 -6
  17. {sarj_python_lint-0.26.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +3 -4
  18. {sarj_python_lint-0.26.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/no_fat_try_blocks.py +3 -4
  19. {sarj_python_lint-0.26.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/no_file_level_escape_hatch_noqa.py +3 -3
  20. {sarj_python_lint-0.26.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/no_first_party_private_import.py +3 -2
  21. {sarj_python_lint-0.26.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/no_fstring_in_log.py +5 -4
  22. {sarj_python_lint-0.26.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/no_gen_random_uuid_in_sql.py +3 -2
  23. {sarj_python_lint-0.26.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/no_implicit_attribute_access.py +47 -27
  24. {sarj_python_lint-0.26.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/no_isinstance_union_chain.py +3 -4
  25. {sarj_python_lint-0.26.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/no_offset_pagination.py +3 -4
  26. {sarj_python_lint-0.26.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/no_optional_tenant_predicate.py +5 -4
  27. {sarj_python_lint-0.26.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/no_patching_system_under_test.py +70 -3
  28. {sarj_python_lint-0.26.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/no_query_with_many_joins.py +3 -4
  29. {sarj_python_lint-0.26.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/no_raw_sql_in_tests.py +2 -3
  30. {sarj_python_lint-0.26.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/no_repeated_string_literal.py +4 -3
  31. {sarj_python_lint-0.26.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/no_secret_in_log.py +2 -3
  32. {sarj_python_lint-0.26.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/no_select_star.py +3 -4
  33. {sarj_python_lint-0.26.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +14 -10
  34. {sarj_python_lint-0.26.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/no_sequential_await.py +74 -51
  35. {sarj_python_lint-0.26.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/no_sleep_in_test_body.py +2 -1
  36. {sarj_python_lint-0.26.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/no_stdlib_logging.py +32 -17
  37. {sarj_python_lint-0.26.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/no_tautological_expect.py +37 -5
  38. {sarj_python_lint-0.26.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/no_unreachable_after_terminal.py +15 -1
  39. {sarj_python_lint-0.26.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/over_mocked_test.py +4 -3
  40. {sarj_python_lint-0.26.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/parametrize_case_needs_id.py +2 -7
  41. {sarj_python_lint-0.26.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/prefer_class_row.py +2 -3
  42. {sarj_python_lint-0.26.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +36 -26
  43. {sarj_python_lint-0.26.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +3 -2
  44. {sarj_python_lint-0.26.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/prefer_library_fake.py +4 -3
  45. {sarj_python_lint-0.26.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/prefer_match_assert_never.py +7 -10
  46. {sarj_python_lint-0.26.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/prefer_match_pattern_destructuring.py +3 -2
  47. {sarj_python_lint-0.26.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +1 -1
  48. {sarj_python_lint-0.26.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/prefer_module_level_constant.py +3 -4
  49. {sarj_python_lint-0.26.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +4 -5
  50. {sarj_python_lint-0.26.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +4 -3
  51. {sarj_python_lint-0.26.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/prefer_or_pattern.py +3 -3
  52. {sarj_python_lint-0.26.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/prefer_pattern_matching.py +6 -3
  53. {sarj_python_lint-0.26.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/prefer_real_store_in_tests.py +5 -4
  54. {sarj_python_lint-0.26.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +1 -1
  55. {sarj_python_lint-0.26.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/prefer_str_enum.py +10 -13
  56. {sarj_python_lint-0.26.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +3 -3
  57. {sarj_python_lint-0.26.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/prefer_timedelta_for_durations.py +4 -3
  58. {sarj_python_lint-0.26.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +8 -8
  59. {sarj_python_lint-0.26.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +3 -2
  60. {sarj_python_lint-0.26.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +4 -8
  61. {sarj_python_lint-0.26.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +4 -5
  62. {sarj_python_lint-0.26.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/redundant_docstring.py +2 -1
  63. {sarj_python_lint-0.26.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/require_port_for_service.py +21 -1
  64. {sarj_python_lint-0.26.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/stepdown.py +2 -1
  65. {sarj_python_lint-0.26.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +3 -4
  66. {sarj_python_lint-0.26.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/tautological_mock_assertion.py +183 -10
  67. {sarj_python_lint-0.26.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/test_loops_over_literal_cases.py +3 -2
  68. {sarj_python_lint-0.26.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/trivially_true_assertion.py +48 -172
  69. {sarj_python_lint-0.26.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/unused_mock_setup.py +8 -9
  70. {sarj_python_lint-0.26.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/xfail_requires_strict.py +3 -4
  71. {sarj_python_lint-0.26.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/zero_assertion_test.py +5 -5
  72. sarj_python_lint-0.26.0/src/sarj_python_lint/rules/_logging.py +0 -39
  73. {sarj_python_lint-0.26.0 → sarj_python_lint-0.27.0}/.gitignore +0 -0
  74. {sarj_python_lint-0.26.0 → sarj_python_lint-0.27.0}/README.md +0 -0
  75. {sarj_python_lint-0.26.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/__init__.py +0 -0
  76. {sarj_python_lint-0.26.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/__main__.py +0 -0
  77. {sarj_python_lint-0.26.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/_ratchet_cli.py +0 -0
  78. {sarj_python_lint-0.26.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/_secret_names.py +0 -0
  79. {sarj_python_lint-0.26.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/_version.py +0 -0
  80. {sarj_python_lint-0.26.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/py.typed +0 -0
  81. {sarj_python_lint-0.26.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/ratchet.py +0 -0
  82. {sarj_python_lint-0.26.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rule_base.py +0 -0
  83. {sarj_python_lint-0.26.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/__init__.py +0 -0
  84. {sarj_python_lint-0.26.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/_comments.py +0 -0
  85. {sarj_python_lint-0.26.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/_first_party.py +0 -0
  86. {sarj_python_lint-0.26.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/_paths.py +0 -0
  87. {sarj_python_lint-0.26.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/_registry.py +0 -0
  88. {sarj_python_lint-0.26.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/_sql.py +0 -0
  89. {sarj_python_lint-0.26.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/no_file_level_suppression.py +0 -0
  90. {sarj_python_lint-0.26.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
  91. {sarj_python_lint-0.26.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/primary_export_file_name.py +0 -0
  92. {sarj_python_lint-0.26.0 → sarj_python_lint-0.27.0}/src/sarj_python_lint/rules/single_public_export.py +0 -0
  93. {sarj_python_lint-0.26.0 → sarj_python_lint-0.27.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.27.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.27.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.27.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)]
@@ -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:
@@ -33,6 +33,9 @@ class Comment:
33
33
  before_first_statement: bool = False
34
34
 
35
35
 
36
+ _last_scan: tuple[str, list[Comment]] | None = None
37
+
38
+
36
39
  def scan_comments(source: str) -> list[Comment]:
37
40
  """Tokenize `source` and describe every comment in it.
38
41
 
@@ -43,10 +46,25 @@ def scan_comments(source: str) -> list[Comment]:
43
46
  Anything `tokenize` cannot lex propagates as `tokenize.TokenError`,
44
47
  `IndentationError` or `SyntaxError`; callers treat that as "no diagnostics".
45
48
 
49
+ Memoized in a single slot, like `_comments._scan_memo` and
50
+ `rule_base.parse_or_none`: both suppression rules ask the same question about
51
+ the same file, and the CLI runs rules per file, so one slot removes the
52
+ second tokenize pass. `Comment` is frozen and the returned list is read-only
53
+ to callers.
54
+
46
55
  Returns:
47
56
  Every comment, in source order.
48
57
 
49
58
  """
59
+ global _last_scan # ruff: ignore[global-statement] — single-slot memo; the CLI runs rules per file sequentially
60
+ if _last_scan is not None and _last_scan[0] is source:
61
+ return _last_scan[1]
62
+ result = _scan(source)
63
+ _last_scan = (source, result)
64
+ return result
65
+
66
+
67
+ def _scan(source: str) -> list[Comment]:
50
68
  comments: list[Comment] = []
51
69
  first_statement_line = _NO_STATEMENT_LINE
52
70
  prev_end_row = 0
@@ -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
 
@@ -170,6 +170,7 @@ import re
170
170
  from typing import TYPE_CHECKING, override
171
171
 
172
172
  from sarj_python_lint.rule_base import Diagnostic, Rule, parse_or_none
173
+ from sarj_python_lint.rules._ast_index import nodes, walk
173
174
  from sarj_python_lint.rules._paths import is_test_path
174
175
 
175
176
 
@@ -471,7 +472,7 @@ def _function_defs(tree: ast.Module) -> list[ast.FunctionDef | ast.AsyncFunction
471
472
  The definitions, in `ast.walk` order.
472
473
 
473
474
  """
474
- return [node for node in ast.walk(tree) if isinstance(node, _FUNC_NODES)]
475
+ return nodes(tree, *_FUNC_NODES)
475
476
 
476
477
 
477
478
  def _collectible_tests(tree: ast.Module) -> list[ast.FunctionDef | ast.AsyncFunctionDef]:
@@ -531,7 +532,7 @@ def _direct_counts(node: ast.FunctionDef | ast.AsyncFunctionDef, local: frozense
531
532
  interaction = 0
532
533
  negative = 0
533
534
  targets: set[str] = set()
534
- for child in ast.walk(node):
535
+ for child in walk(node):
535
536
  kind = _classify(child, local)
536
537
  if kind is None:
537
538
  continue
@@ -557,7 +558,7 @@ def _interaction_targets(expr: ast.AST) -> set[str]:
557
558
 
558
559
  """
559
560
  targets: set[str] = set()
560
- for node in ast.walk(expr):
561
+ for node in walk(expr):
561
562
  if isinstance(node, ast.Attribute) and node.attr in _MOCK_STATE_ATTRS:
562
563
  targets.add(_dotted(node.value))
563
564
  elif (
@@ -640,7 +641,7 @@ def _call_name(func: ast.expr) -> str | None:
640
641
 
641
642
 
642
643
  def _mentions_mock_state(expr: ast.expr) -> bool:
643
- return any(_is_mock_state_read(node) for node in ast.walk(expr))
644
+ return any(_is_mock_state_read(node) for node in walk(expr))
644
645
 
645
646
 
646
647
  def _is_mock_state_read(node: ast.AST) -> bool:
@@ -702,7 +703,7 @@ def _chain_has_fluent_marker(node: ast.expr) -> bool:
702
703
 
703
704
  def _called_names(node: ast.AST) -> set[str]:
704
705
  names: set[str] = set()
705
- for child in ast.walk(node):
706
+ for child in walk(node):
706
707
  if isinstance(child, ast.Call):
707
708
  func = child.func
708
709
  if isinstance(func, ast.Attribute):
@@ -62,6 +62,7 @@ from collections import Counter
62
62
  from typing import TYPE_CHECKING, override
63
63
 
64
64
  from sarj_python_lint.rule_base import Diagnostic, Rule, parse_or_none
65
+ from sarj_python_lint.rules._ast_index import nodes
65
66
  from sarj_python_lint.rules._paths import is_test_path
66
67
 
67
68
 
@@ -166,9 +167,7 @@ class _KwargHeavyVisitor(ast.NodeVisitor):
166
167
  The wide constructions that actually have duplication to extract.
167
168
 
168
169
  """
169
- counts = Counter(
170
- name for n in ast.walk(tree) if isinstance(n, ast.Call) and (name := _callee_name(n.func)) is not None
171
- )
170
+ counts = Counter(name for n in nodes(tree, ast.Call) if (name := _callee_name(n.func)) is not None)
172
171
  return [
173
172
  (node, count)
174
173
  for node, count in self.hits
@@ -100,6 +100,7 @@ import re
100
100
  from typing import TYPE_CHECKING, override
101
101
 
102
102
  from sarj_python_lint.rule_base import Diagnostic, Rule, parse_or_none
103
+ from sarj_python_lint.rules._ast_index import nodes, walk
103
104
  from sarj_python_lint.rules._paths import is_generated_source, is_test_path
104
105
 
105
106
 
@@ -200,20 +201,24 @@ class KwonlySameTypeParams(Rule):
200
201
  tree = parse_or_none(path, source)
201
202
  if tree is None:
202
203
  return []
204
+ # The signature test is a local, allocation-free predicate; the three
205
+ # name/id tables each scan the whole module. Screening on the signature
206
+ # first means a module with no swap-prone signature at all — the common
207
+ # case — never builds a table.
208
+ candidates = [
209
+ (node, offending)
210
+ for node in nodes(tree, ast.FunctionDef, ast.AsyncFunctionDef)
211
+ if not _is_exempt(node) and (offending := _swap_prone_annotation(node.args)) is not None
212
+ ]
213
+ if not candidates:
214
+ return []
203
215
  value_referenced = _value_referenced_names(tree)
204
216
  overload_names = _overload_stub_names(tree)
205
217
  method_ids = _method_node_ids(tree)
206
218
  diags: list[Diagnostic] = []
207
- for node in ast.walk(tree):
208
- if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
209
- continue
210
- if _is_exempt(node):
211
- continue
219
+ for node, offending in candidates:
212
220
  if node.name in value_referenced or node.name in overload_names:
213
221
  continue
214
- offending = _swap_prone_annotation(node.args)
215
- if offending is None:
216
- continue
217
222
  # Checked last: `_calls_super_same_name` walks the body, so it runs
218
223
  # only for the few signatures that would otherwise be reported.
219
224
  if id(node) in method_ids and (node.name in _DUCK_PROTOCOL_METHODS or _calls_super_same_name(node)):
@@ -282,8 +287,7 @@ def _method_node_ids(tree: ast.AST) -> frozenset[int]:
282
287
  """
283
288
  return frozenset(
284
289
  id(child)
285
- for node in ast.walk(tree)
286
- if isinstance(node, ast.ClassDef)
290
+ for node in nodes(tree, ast.ClassDef)
287
291
  for child in node.body
288
292
  if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef))
289
293
  )
@@ -306,7 +310,7 @@ def _calls_super_same_name(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool
306
310
  and isinstance(inner := func.value, ast.Call)
307
311
  and isinstance(inner.func, ast.Name)
308
312
  and inner.func.id == "super"
309
- for call in ast.walk(node)
313
+ for call in walk(node)
310
314
  if isinstance(call, ast.Call)
311
315
  )
312
316
 
@@ -444,11 +448,9 @@ def _value_referenced_names(tree: ast.AST) -> frozenset[str]:
444
448
  The set of names loaded outside call position.
445
449
 
446
450
  """
447
- call_funcs = {id(node.func) for node in ast.walk(tree) if isinstance(node, ast.Call)}
451
+ call_funcs = {id(node.func) for node in nodes(tree, ast.Call)}
448
452
  return frozenset(
449
- node.id
450
- for node in ast.walk(tree)
451
- if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Load) and id(node) not in call_funcs
453
+ node.id for node in nodes(tree, ast.Name) if isinstance(node.ctx, ast.Load) and id(node) not in call_funcs
452
454
  )
453
455
 
454
456
 
@@ -464,9 +466,8 @@ def _overload_stub_names(tree: ast.AST) -> frozenset[str]:
464
466
  """
465
467
  return frozenset(
466
468
  node.name
467
- for node in ast.walk(tree)
468
- if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
469
- and any(
469
+ for node in nodes(tree, ast.FunctionDef, ast.AsyncFunctionDef)
470
+ if any(
470
471
  (isinstance(dec, ast.Name) and dec.id == "overload")
471
472
  or (isinstance(dec, ast.Attribute) and dec.attr == "overload")
472
473
  for dec in node.decorator_list