sarj-python-lint 0.16.0__tar.gz → 0.18.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 (58) hide show
  1. {sarj_python_lint-0.16.0 → sarj_python_lint-0.18.0}/PKG-INFO +1 -1
  2. {sarj_python_lint-0.16.0 → sarj_python_lint-0.18.0}/pyproject.toml +1 -1
  3. {sarj_python_lint-0.16.0 → sarj_python_lint-0.18.0}/src/sarj_python_lint/__main__.py +29 -1
  4. {sarj_python_lint-0.16.0 → sarj_python_lint-0.18.0}/src/sarj_python_lint/_secret_names.py +3 -2
  5. {sarj_python_lint-0.16.0 → sarj_python_lint-0.18.0}/src/sarj_python_lint/rules/_paths.py +6 -1
  6. {sarj_python_lint-0.16.0 → sarj_python_lint-0.18.0}/src/sarj_python_lint/rules/_sql.py +21 -0
  7. {sarj_python_lint-0.16.0 → sarj_python_lint-0.18.0}/src/sarj_python_lint/rules/fixture_returns_bare_tuple.py +38 -1
  8. {sarj_python_lint-0.16.0 → sarj_python_lint-0.18.0}/src/sarj_python_lint/rules/inefficient_string_concat_in_loop.py +9 -0
  9. {sarj_python_lint-0.16.0 → sarj_python_lint-0.18.0}/src/sarj_python_lint/rules/kwarg_heavy_construction_in_test.py +62 -3
  10. {sarj_python_lint-0.16.0 → sarj_python_lint-0.18.0}/src/sarj_python_lint/rules/kwonly_same_type_params.py +184 -2
  11. sarj_python_lint-0.18.0/src/sarj_python_lint/rules/mock_without_spec.py +414 -0
  12. {sarj_python_lint-0.16.0 → sarj_python_lint-0.18.0}/src/sarj_python_lint/rules/no_aggregation_in_store_query.py +43 -1
  13. {sarj_python_lint-0.16.0 → sarj_python_lint-0.18.0}/src/sarj_python_lint/rules/no_comment_cruft.py +111 -4
  14. {sarj_python_lint-0.16.0 → sarj_python_lint-0.18.0}/src/sarj_python_lint/rules/no_fat_try_blocks.py +156 -1
  15. sarj_python_lint-0.18.0/src/sarj_python_lint/rules/no_fstring_in_log.py +314 -0
  16. {sarj_python_lint-0.16.0 → sarj_python_lint-0.18.0}/src/sarj_python_lint/rules/no_repeated_string_literal.py +75 -9
  17. {sarj_python_lint-0.16.0 → sarj_python_lint-0.18.0}/src/sarj_python_lint/rules/no_secret_in_log.py +28 -0
  18. {sarj_python_lint-0.16.0 → sarj_python_lint-0.18.0}/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +136 -1
  19. {sarj_python_lint-0.16.0 → sarj_python_lint-0.18.0}/src/sarj_python_lint/rules/no_sequential_await.py +94 -8
  20. {sarj_python_lint-0.16.0 → sarj_python_lint-0.18.0}/src/sarj_python_lint/rules/no_sleep_in_test_body.py +71 -11
  21. sarj_python_lint-0.18.0/src/sarj_python_lint/rules/no_unreachable_after_terminal.py +178 -0
  22. {sarj_python_lint-0.16.0 → sarj_python_lint-0.18.0}/src/sarj_python_lint/rules/parametrize_case_needs_id.py +57 -4
  23. {sarj_python_lint-0.16.0 → sarj_python_lint-0.18.0}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +53 -0
  24. {sarj_python_lint-0.16.0 → sarj_python_lint-0.18.0}/src/sarj_python_lint/rules/prefer_module_level_constant.py +42 -5
  25. sarj_python_lint-0.18.0/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +415 -0
  26. {sarj_python_lint-0.16.0 → sarj_python_lint-0.18.0}/src/sarj_python_lint/rules/prefer_str_enum.py +386 -50
  27. {sarj_python_lint-0.16.0 → sarj_python_lint-0.18.0}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +16 -0
  28. {sarj_python_lint-0.16.0 → sarj_python_lint-0.18.0}/src/sarj_python_lint/rules/prefer_timedelta_for_durations.py +118 -1
  29. {sarj_python_lint-0.16.0 → sarj_python_lint-0.18.0}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +149 -24
  30. {sarj_python_lint-0.16.0 → sarj_python_lint-0.18.0}/src/sarj_python_lint/rules/single_public_export.py +49 -2
  31. {sarj_python_lint-0.16.0 → sarj_python_lint-0.18.0}/src/sarj_python_lint/rules/stepdown.py +23 -0
  32. {sarj_python_lint-0.16.0 → sarj_python_lint-0.18.0}/src/sarj_python_lint/rules/test_loops_over_literal_cases.py +37 -4
  33. {sarj_python_lint-0.16.0 → sarj_python_lint-0.18.0}/src/sarj_python_lint/rules/xfail_requires_strict.py +43 -1
  34. {sarj_python_lint-0.16.0 → sarj_python_lint-0.18.0}/src/sarj_python_lint/rules/zero_assertion_test.py +150 -12
  35. sarj_python_lint-0.16.0/src/sarj_python_lint/rules/mock_without_spec.py +0 -225
  36. sarj_python_lint-0.16.0/src/sarj_python_lint/rules/no_fstring_in_log.py +0 -176
  37. sarj_python_lint-0.16.0/src/sarj_python_lint/rules/no_unreachable_after_terminal.py +0 -102
  38. sarj_python_lint-0.16.0/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -228
  39. {sarj_python_lint-0.16.0 → sarj_python_lint-0.18.0}/.gitignore +0 -0
  40. {sarj_python_lint-0.16.0 → sarj_python_lint-0.18.0}/README.md +0 -0
  41. {sarj_python_lint-0.16.0 → sarj_python_lint-0.18.0}/src/sarj_python_lint/__init__.py +0 -0
  42. {sarj_python_lint-0.16.0 → sarj_python_lint-0.18.0}/src/sarj_python_lint/_version.py +0 -0
  43. {sarj_python_lint-0.16.0 → sarj_python_lint-0.18.0}/src/sarj_python_lint/py.typed +0 -0
  44. {sarj_python_lint-0.16.0 → sarj_python_lint-0.18.0}/src/sarj_python_lint/rule_base.py +0 -0
  45. {sarj_python_lint-0.16.0 → sarj_python_lint-0.18.0}/src/sarj_python_lint/rules/__init__.py +0 -0
  46. {sarj_python_lint-0.16.0 → sarj_python_lint-0.18.0}/src/sarj_python_lint/rules/_logging.py +0 -0
  47. {sarj_python_lint-0.16.0 → sarj_python_lint-0.18.0}/src/sarj_python_lint/rules/_registry.py +0 -0
  48. {sarj_python_lint-0.16.0 → sarj_python_lint-0.18.0}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
  49. {sarj_python_lint-0.16.0 → sarj_python_lint-0.18.0}/src/sarj_python_lint/rules/no_file_level_suppression.py +0 -0
  50. {sarj_python_lint-0.16.0 → sarj_python_lint-0.18.0}/src/sarj_python_lint/rules/no_isinstance_union_chain.py +0 -0
  51. {sarj_python_lint-0.16.0 → sarj_python_lint-0.18.0}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
  52. {sarj_python_lint-0.16.0 → sarj_python_lint-0.18.0}/src/sarj_python_lint/rules/no_query_with_many_joins.py +0 -0
  53. {sarj_python_lint-0.16.0 → sarj_python_lint-0.18.0}/src/sarj_python_lint/rules/no_raw_sql_in_tests.py +0 -0
  54. {sarj_python_lint-0.16.0 → sarj_python_lint-0.18.0}/src/sarj_python_lint/rules/no_select_star.py +0 -0
  55. {sarj_python_lint-0.16.0 → sarj_python_lint-0.18.0}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
  56. {sarj_python_lint-0.16.0 → sarj_python_lint-0.18.0}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
  57. {sarj_python_lint-0.16.0 → sarj_python_lint-0.18.0}/src/sarj_python_lint/rules/sleep_with_computed_arg_in_test.py +0 -0
  58. {sarj_python_lint-0.16.0 → sarj_python_lint-0.18.0}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sarj-python-lint
3
- Version: 0.16.0
3
+ Version: 0.18.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.16.0"
3
+ version = "0.18.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" }]
@@ -105,6 +105,34 @@ def _baseline_counts(diags: list[Diagnostic]) -> dict[str, dict[str, int]]:
105
105
  return counts
106
106
 
107
107
 
108
+ def _read_baseline(path: Path) -> dict[str, dict[str, int]]:
109
+ """Load a baseline file, keeping only well-formed `{path: {CODE: count}}` entries.
110
+
111
+ `json.loads` returns `Any`, so the shape is narrowed here rather than
112
+ asserted — a hand-edited baseline should degrade to "not baselined" rather
113
+ than crash the run or silently suppress on a malformed entry.
114
+
115
+ Returns:
116
+ The baseline counts, with any entry of the wrong shape dropped.
117
+
118
+ """
119
+ raw: object = json.loads( # pyright: ignore[reportAny] — json.loads is an untyped stdlib boundary; the shape is narrowed below
120
+ path.read_text(encoding="utf-8")
121
+ )
122
+ if not isinstance(raw, dict):
123
+ return {}
124
+ counts: dict[str, dict[str, int]] = {}
125
+ for file_key, per_code in raw.items(): # pyright: ignore[reportUnknownVariableType] — json.loads yields Any leaves
126
+ if not isinstance(file_key, str) or not isinstance(per_code, dict):
127
+ continue
128
+ counts[file_key] = {
129
+ code: n
130
+ for code, n in per_code.items() # pyright: ignore[reportUnknownVariableType] — same
131
+ if isinstance(code, str) and isinstance(n, int)
132
+ }
133
+ return counts
134
+
135
+
108
136
  def _apply_baseline(diags: list[Diagnostic], baseline: dict[str, dict[str, int]]) -> list[Diagnostic]:
109
137
  """Suppress up to the baselined count per (path, code); excess diags survive.
110
138
 
@@ -167,7 +195,7 @@ def main(argv: list[str] | None = None) -> int:
167
195
  )
168
196
  return 0
169
197
  if args.baseline is not None:
170
- diags = _apply_baseline(diags, json.loads(args.baseline.read_text()))
198
+ diags = _apply_baseline(diags, _read_baseline(args.baseline))
171
199
  for d in diags:
172
200
  sys.stdout.write(d.format() + "\n")
173
201
  return 1 if diags else 0
@@ -139,7 +139,8 @@ def identifier_tokens(identifier: str) -> list[str]:
139
139
  if not segment:
140
140
  continue
141
141
  tokens.append(segment.lower())
142
- tokens.extend(part.lower() for part in _CAMEL_RE.findall(segment))
142
+ camel_parts: list[str] = _CAMEL_RE.findall(segment)
143
+ tokens.extend(part.lower() for part in camel_parts)
143
144
  return tokens
144
145
 
145
146
 
@@ -159,7 +160,7 @@ def leading_word(identifier: str) -> str | None:
159
160
  for segment in _SEGMENT_RE.split(identifier):
160
161
  if not segment:
161
162
  continue
162
- parts = _CAMEL_RE.findall(segment)
163
+ parts: list[str] = _CAMEL_RE.findall(segment)
163
164
  return parts[0].lower() if parts else segment.lower()
164
165
  return None
165
166
 
@@ -23,7 +23,12 @@ if TYPE_CHECKING:
23
23
 
24
24
  _TEST_DIR_NAMES = frozenset({"tests", "test"})
25
25
 
26
- _GENERATED_RE = re.compile(r"auto-?generated|generated by|do not edit", re.IGNORECASE)
26
+ # `auto-?generated` misses "automatically generated", which is the phrase
27
+ # @generated tooling, protoc and OpenAPI generators emit most often.
28
+ _GENERATED_RE = re.compile(
29
+ r"auto(?:-|matically )?generated|generated by|do not edit|@generated",
30
+ re.IGNORECASE,
31
+ )
27
32
 
28
33
  _GENERATED_HEADER_LINES = 5
29
34
 
@@ -13,6 +13,8 @@ from __future__ import annotations
13
13
  import ast
14
14
  from typing import TYPE_CHECKING
15
15
 
16
+ from sarj_python_lint.rules._paths import is_test_path
17
+
16
18
 
17
19
  if TYPE_CHECKING:
18
20
  from pathlib import Path
@@ -27,10 +29,29 @@ def is_store_module(path: Path) -> bool:
27
29
  SQL generator) legitimately writes `SELECT *`, bare `INSERT`, and `COUNT()`,
28
30
  so those files are out of scope.
29
31
 
32
+ TEST FILES ARE NEVER STORE MODULES. `test_<x>_store.py` ends with `_store.py`,
33
+ so the naming test alone swept the *tests for* the store layer into the rules
34
+ written for the store layer itself. Every store-semantics premise fails there:
35
+ a test asserts over a handful of per-test fixture rows (so a `COUNT(*)` is not
36
+ a hot-path aggregation competing with OLTP traffic), and a fixture seeds a row
37
+ exactly once (so a bare `INSERT` needs no `ON CONFLICT` — idempotency is what
38
+ the per-test database reset provides). Raw SQL in tests already has its own
39
+ rule, SARJ036 no-raw-sql-in-tests, which judges it on test-appropriate terms.
40
+
41
+ Evidence, bulbul PR #4111 (all suppressed at PR head, none a defect):
42
+ - SARJ020 `python/bulbul/tests/store/test_batch_call_store.py:2092`, `:2538`
43
+ ("test assertion count over per-test fixture rows"),
44
+ `python/bulbul/tests/store/test_global_prompt_store.py:67`
45
+ ("test asserts exactly one row exists after the upsert").
46
+ - SARJ018 `python/bulbul/tests/store/test_sip_connection_store.py:37`
47
+ (`_insert_test_provider` seeding one `phone_provider` row per test).
48
+
30
49
  Returns:
31
50
  True when `path` belongs to the store layer.
32
51
 
33
52
  """
53
+ if is_test_path(path):
54
+ return False
34
55
  return path.name.endswith("_store.py") or "stores" in path.parts
35
56
 
36
57
 
@@ -34,7 +34,15 @@ Deliberately NOT flagged:
34
34
  mistaken for the smell,
35
35
  * a single-element tuple — nothing to mis-order,
36
36
  * a starred tuple (`return *pair, extra`) — the arity is not statically known,
37
- * a tuple returned from a nested helper or closure inside the fixture.
37
+ * a tuple returned from a nested helper or closure inside the fixture,
38
+ * **a fixture annotated `-> tuple[A, B, ...]` whose element types are all
39
+ syntactically distinct.** The rule's whole argument is that a reorder fails
40
+ silently — and with distinct static types it does not: swapping the elements
41
+ is a type error the checker reports at the call site, which is the same
42
+ protection a `NamedTuple` would buy. Found in bulbul PR #4111 on
43
+ `python/bulbul/bulbul/tests/fixtures/stores.py:421`, which returns
44
+ `-> tuple[PsqlOrganizationStore, PsqlUserStore]`. A repeated type
45
+ (`tuple[str, str]`) still fires: there the reorder really is silent.
38
46
  """
39
47
 
40
48
  from __future__ import annotations
@@ -103,10 +111,39 @@ def _bare_tuple_results(tree: ast.Module) -> list[tuple[ast.expr, int]]:
103
111
  for node in ast.walk(tree):
104
112
  if not isinstance(node, _FUNC_NODES) or not _is_fixture(node):
105
113
  continue
114
+ if _returns_distinctly_typed_tuple(node):
115
+ continue
106
116
  hits.extend(_tuple_results_of(node))
107
117
  return hits
108
118
 
109
119
 
120
+ def _returns_distinctly_typed_tuple(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
121
+ """Report whether the fixture is annotated as a tuple of all-distinct types.
122
+
123
+ A reorder of `tuple[Store, User]` is a type error the checker catches; a
124
+ reorder of `tuple[str, str]` is silent. Only the latter is what this rule
125
+ exists to prevent, so the former is exempt.
126
+
127
+ Returns:
128
+ True when the return annotation is a `tuple[...]` whose element types
129
+ are pairwise distinct.
130
+
131
+ """
132
+ returns = node.returns
133
+ if not isinstance(returns, ast.Subscript):
134
+ return False
135
+ base = returns.value
136
+ base_name = base.attr if isinstance(base, ast.Attribute) else base.id if isinstance(base, ast.Name) else None
137
+ if base_name not in {"tuple", "Tuple"}:
138
+ return False
139
+ elts = returns.slice.elts if isinstance(returns.slice, ast.Tuple) else [returns.slice]
140
+ # `tuple[str, ...]` is a homogeneous sequence, not a record — not our shape.
141
+ if any(isinstance(e, ast.Constant) and e.value is Ellipsis for e in elts):
142
+ return False
143
+ rendered = [ast.dump(e) for e in elts]
144
+ return len(rendered) >= _MIN_FIELDS and len(set(rendered)) == len(rendered)
145
+
146
+
110
147
  def _is_fixture(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
111
148
  return any(_names_fixture(dec) for dec in node.decorator_list)
112
149
 
@@ -34,6 +34,12 @@ References:
34
34
  - https://docs.python.org/3/library/stdtypes.html#str.join
35
35
  - https://wiki.python.org/moin/PythonSpeed/PerformanceTips
36
36
 
37
+ * **generated files** (`_paths.is_generated_source`). Their layout is the
38
+ generator's, and re-running the generator discards any edit, so a finding
39
+ there can never be acted on in place. Measured on the 69 `DO NOT EDIT`
40
+ files git-tracked across bulbul and noura-be — Speakeasy's
41
+ `python/sdk/src/sarj_platform_sdk/` accounts for all of them.
42
+
37
43
  """
38
44
 
39
45
  from __future__ import annotations
@@ -42,6 +48,7 @@ import ast
42
48
  from typing import TYPE_CHECKING, TypeGuard, override
43
49
 
44
50
  from sarj_python_lint.rule_base import Diagnostic, Rule, parse_or_none
51
+ from sarj_python_lint.rules._paths import is_generated_source
45
52
 
46
53
 
47
54
  if TYPE_CHECKING:
@@ -58,6 +65,8 @@ class InefficientStringConcatInLoop(Rule):
58
65
 
59
66
  @override
60
67
  def check(self, path: Path, source: str) -> list[Diagnostic]:
68
+ if is_generated_source(source):
69
+ return []
61
70
  tree = parse_or_none(path, source)
62
71
  if tree is None:
63
72
  return []
@@ -31,17 +31,34 @@ a one-line import of a `build_sarj_beneficiary` helper that already exists in
31
31
 
32
32
  Deliberately NOT flagged:
33
33
 
34
+ * **a callee constructed only once in the whole file.** The message promises
35
+ "every other test repeats the same boilerplate" — so the rule now checks that
36
+ premise instead of asserting it. A single construction of a domain model with
37
+ many required fields has no duplication to extract, and telling the author to
38
+ build a factory for one call site trades real ceremony for nothing. Found in
39
+ bulbul PR #4111: removing one `# pyright: ignore` forced
40
+ `python/bulbul/tests/observability/test_analytics_events.py:227` to build a
41
+ real `Batch(...)` with 12 required fields — the only `Batch(` in the file —
42
+ and the rule blocked CI over it. Two or more constructions of the same callee
43
+ still fire: that is the shape a builder actually fixes.
34
44
  * calls inside a fixture, a `_make_*` helper, or any non-test function — that is
35
45
  the factory, and it is allowed to be verbose exactly once,
36
46
  * positional arguments — a call with many positionals is a different smell, and
37
47
  ruff's own rules already discourage it,
38
48
  * `dict(...)` and literal dict displays — those are data, not a domain object,
39
- and naming their keys is the point rather than the problem.
49
+ and naming their keys is the point rather than the problem,
50
+ * `<mapping>.update(...)` — the same data case one call further on. The keywords
51
+ are mapping entries being spread, not constructor fields, so there is no
52
+ object for a builder to build. A 2,657-file third-party sweep produced 14
53
+ findings and the widest of them (29 keywords) was rich's
54
+ `table.box.__dict__.update(top_left="a", top="b", ...)`, which relabels box
55
+ characters wholesale.
40
56
  """
41
57
 
42
58
  from __future__ import annotations
43
59
 
44
60
  import ast
61
+ from collections import Counter
45
62
  from typing import TYPE_CHECKING, override
46
63
 
47
64
  from sarj_python_lint.rule_base import Diagnostic, Rule, parse_or_none
@@ -54,9 +71,16 @@ if TYPE_CHECKING:
54
71
 
55
72
  _MAX_KEYWORDS = 8
56
73
 
74
+ # A builder only pays for itself once the same callee is built more than once.
75
+ _MIN_CONSTRUCTIONS = 2
76
+
57
77
  # `dict(a=1, b=2, ...)` is a mapping literal, not a domain object.
58
78
  _DATA_CALLABLES = frozenset({"dict"})
59
79
 
80
+ # `<mapping>.update(a=1, b=2, ...)` spreads mapping entries — data again, and no
81
+ # object a builder could construct.
82
+ _DATA_METHODS = frozenset({"update"})
83
+
60
84
 
61
85
  class KwargHeavyConstructionInTest(Rule):
62
86
  """A >8-keyword construction directly in a test body wants a builder."""
@@ -93,7 +117,7 @@ class KwargHeavyConstructionInTest(Rule):
93
117
  "defaults and override only what this test is about."
94
118
  ),
95
119
  )
96
- for node, count in visitor.hits
120
+ for node, count in visitor.repeated_hits(tree)
97
121
  ]
98
122
  diags.sort(key=lambda d: (d.line, d.col))
99
123
  return diags
@@ -135,10 +159,45 @@ class _KwargHeavyVisitor(ast.NodeVisitor):
135
159
  self.hits.append((node, len(named)))
136
160
  self.generic_visit(node)
137
161
 
162
+ def repeated_hits(self, tree: ast.Module) -> list[tuple[ast.Call, int]]:
163
+ """Keep only hits whose callee is constructed more than once in the file.
164
+
165
+ Returns:
166
+ The wide constructions that actually have duplication to extract.
167
+
168
+ """
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
+ )
172
+ return [
173
+ (node, count)
174
+ for node, count in self.hits
175
+ # A callee with no stable name (a subscript, a call result) cannot be
176
+ # counted, so it can never clear the repetition bar.
177
+ if (name := _callee_name(node.func)) is not None and counts[name] >= _MIN_CONSTRUCTIONS
178
+ ]
179
+
138
180
  def _in_test_function(self) -> bool:
139
181
  nearest = self._func_names[-1] if self._func_names else None
140
182
  return nearest is not None and nearest.startswith("test_")
141
183
 
142
184
 
143
185
  def _is_data_callable(func: ast.expr) -> bool:
144
- return isinstance(func, ast.Name) and func.id in _DATA_CALLABLES
186
+ if isinstance(func, ast.Name):
187
+ return func.id in _DATA_CALLABLES
188
+ return isinstance(func, ast.Attribute) and func.attr in _DATA_METHODS
189
+
190
+
191
+ def _callee_name(func: ast.expr) -> str | None:
192
+ """Render the callee as a comparable name, so repeats can be counted.
193
+
194
+ Returns:
195
+ The bare or dotted-final name, or None when the callee is an expression
196
+ with no stable name (a subscript, a call result).
197
+
198
+ """
199
+ if isinstance(func, ast.Name):
200
+ return func.id
201
+ if isinstance(func, ast.Attribute):
202
+ return func.attr
203
+ return None
@@ -34,6 +34,12 @@ Never flags — these signatures cannot or should not change:
34
34
  `@<name>.get/post/put/patch/delete/head/options/websocket(...)` (`router`,
35
35
  `app`, `api`, ...): FastAPI binds handler parameters by NAME (path/query
36
36
  keys), so the positional shape is never called swap-prone by a human,
37
+ * CLI command handlers — `@click.*` / `@typer.*`, or `@<name>.command(...)` /
38
+ `@<name>.group(...)`: click and typer bind handler parameters by NAME from
39
+ the declared options/arguments and the human call site is a shell command
40
+ line, not a Python call (3 corpus hits: `httpx/_main.py:452`,
41
+ `black/src/blackd/__init__.py:96`,
42
+ `black/scripts/diff_shades_gha_helper.py:167`),
37
43
  * test files (`_paths.is_test_path`) — test fakes and helpers mirror the
38
44
  signatures of the code under test and cannot unilaterally change them,
39
45
  * generated files (`_paths.is_generated_source`) — the signature mirrors
@@ -49,6 +55,31 @@ Never flags — these signatures cannot or should not change:
49
55
  (`value_1: float, value_2: float`) — the numbering declares the function
50
56
  symmetric, so argument order genuinely does not matter (found via
51
57
  pydantic's `almost_equal_floats`),
58
+ * methods that provably override a base-class method — the body calls
59
+ `super().<same name>(...)`. An override cannot narrow the inherited calling
60
+ convention without breaking every caller that holds the base type
61
+ (`httpx/_models.py:1257`, `_CookieCompatRequest.add_unredirected_header`),
62
+ * methods implementing a duck-typed stdlib protocol (`seek`, `read`, `write`,
63
+ `add_unredirected_header`, `recv`, `setsockopt`, ...). The stdlib itself is
64
+ the caller and calls them POSITIONALLY — `io` calls `f.seek(0, 2)`,
65
+ `http.cookiejar` calls `req.add_unredirected_header("Cookie", v)` — so
66
+ inserting `*` is not a style change, it is a `TypeError` at runtime (5
67
+ corpus hits: `requests/cookies.py:89` and `:95`, `httpx/_models.py:1257`,
68
+ `anyio/streams/file.py:97`, `rich/progress.py:270`),
69
+ * parameters named `__x` (leading double underscore, no trailing) — PEP 484
70
+ spells positional-only parameters that way, so they cannot be made
71
+ keyword-only at all (`rich/_null_file.py:24`, `NullFile.seek`),
72
+ * same-typed groups drawn entirely from a conventional ordered vocabulary —
73
+ `x`/`y`/`z`, `width`/`height`, `red`/`green`/`blue`, `start`/`stop`/`step`,
74
+ `row`/`column`, `top`/`right`/`bottom`/`left`, `year`/`month`/`day`,
75
+ `hour`/`minute`/`second`. Position IS the notation for these: nobody reads
76
+ `Control.move(2, 5)` as ambiguous, and `move(*, x=2, y=5)` is worse (11
77
+ corpus hits, e.g. `rich/control.py:79`, `rich/segment.py:462`,
78
+ `rich/color.py:409` `from_rgb`, `anyio/itertools.py:271` `count`),
79
+ The vocabularies are closed sets of domain notation, NOT a general
80
+ short-name escape: single-letter placeholders stay flagged
81
+ (`def _newer(a: str, b: str)`, `blib2to3/pgen2/driver.py:287`), because
82
+ there the call site genuinely cannot tell the two apart.
52
83
  * parameters that are already keyword-only (behind `*`) or positional-only
53
84
  (before `/`, a deliberate positional API). Note this is per-parameter, not
54
85
  per-signature: `def f(a: str, b: str, *, c: int)` is still flagged, because
@@ -84,6 +115,57 @@ _EXEMPT_DECORATORS = frozenset({"override", "overload", "abstractmethod"})
84
115
 
85
116
  _HTTP_ROUTE_METHODS = frozenset({"get", "post", "put", "patch", "delete", "head", "options", "websocket"})
86
117
 
118
+ #: Decorator receivers whose attribute access marks a CLI command handler.
119
+ _CLI_DECORATOR_MODULES = frozenset({"click", "typer"})
120
+
121
+ #: `@<name>.command(...)` / `@<name>.group(...)` — click groups and typer apps.
122
+ _CLI_DECORATOR_ATTRS = frozenset({"command", "group"})
123
+
124
+ #: Methods that implement a duck-typed stdlib protocol. The stdlib is the
125
+ #: caller and calls them positionally, so `*` here is a runtime TypeError.
126
+ _DUCK_PROTOCOL_METHODS = frozenset(
127
+ {
128
+ "read",
129
+ "read1",
130
+ "readinto",
131
+ "readinto1",
132
+ "readline",
133
+ "readlines",
134
+ "seek",
135
+ "truncate",
136
+ "write",
137
+ "writelines",
138
+ "connect",
139
+ "connect_ex",
140
+ "getsockopt",
141
+ "setsockopt",
142
+ "recv",
143
+ "recv_into",
144
+ "recvfrom",
145
+ "recvfrom_into",
146
+ "send",
147
+ "sendall",
148
+ "sendto",
149
+ "add_header",
150
+ "add_unredirected_header",
151
+ "get_header",
152
+ "has_header",
153
+ }
154
+ )
155
+
156
+ #: Parameter-name vocabularies whose ORDER is the notation. A same-typed group
157
+ #: drawn entirely from one of these reads unambiguously positionally.
158
+ _CONVENTIONAL_ORDER_GROUPS = (
159
+ frozenset({"x", "y", "z"}),
160
+ frozenset({"width", "height", "depth"}),
161
+ frozenset({"red", "green", "blue", "alpha"}),
162
+ frozenset({"row", "column"}),
163
+ frozenset({"top", "right", "bottom", "left"}),
164
+ frozenset({"year", "month", "day"}),
165
+ frozenset({"hour", "minute", "second", "microsecond"}),
166
+ frozenset({"start", "stop", "step"}),
167
+ )
168
+
87
169
  _EXEMPT_NAME_PREFIXES = ("visit_", "test_")
88
170
 
89
171
 
@@ -106,6 +188,7 @@ class KwonlySameTypeParams(Rule):
106
188
  return []
107
189
  value_referenced = _value_referenced_names(tree)
108
190
  overload_names = _overload_stub_names(tree)
191
+ method_ids = _method_node_ids(tree)
109
192
  diags: list[Diagnostic] = []
110
193
  for node in ast.walk(tree):
111
194
  if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
@@ -117,6 +200,10 @@ class KwonlySameTypeParams(Rule):
117
200
  offending = _swap_prone_annotation(node.args)
118
201
  if offending is None:
119
202
  continue
203
+ # Checked last: `_calls_super_same_name` walks the body, so it runs
204
+ # only for the few signatures that would otherwise be reported.
205
+ if id(node) in method_ids and (node.name in _DUCK_PROTOCOL_METHODS or _calls_super_same_name(node)):
206
+ continue
120
207
  diags.append(
121
208
  Diagnostic(
122
209
  path=path,
@@ -144,10 +231,72 @@ def _is_exempt(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
144
231
  (isinstance(dec, ast.Name) and dec.id in _EXEMPT_DECORATORS)
145
232
  or (isinstance(dec, ast.Attribute) and dec.attr in _EXEMPT_DECORATORS)
146
233
  or _is_route_decorator(dec)
234
+ or _is_cli_command_decorator(dec)
147
235
  for dec in node.decorator_list
148
236
  )
149
237
 
150
238
 
239
+ def _is_cli_command_decorator(dec: ast.expr) -> bool:
240
+ """Report whether `dec` registers the function as a click/typer CLI handler.
241
+
242
+ Matches `@click.*` / `@typer.*` (any attribute: `command`, `option`,
243
+ `argument`, ...) and `@<name>.command(...)` / `@<name>.group(...)` for a
244
+ click group or typer app. Both frameworks bind handler parameters by NAME
245
+ from the declared options, and the human-facing call site is a shell
246
+ command line — the positional shape is never typed by a caller.
247
+
248
+ Returns:
249
+ True when the decorator is a CLI command registration.
250
+
251
+ """
252
+ target = dec.func if isinstance(dec, ast.Call) else dec
253
+ match target:
254
+ case ast.Attribute(value=ast.Name(id=receiver)) if receiver in _CLI_DECORATOR_MODULES:
255
+ return True
256
+ case ast.Attribute(value=ast.Name(), attr=attr) if attr in _CLI_DECORATOR_ATTRS:
257
+ return True
258
+ case _:
259
+ return False
260
+
261
+
262
+ def _method_node_ids(tree: ast.AST) -> frozenset[int]:
263
+ """Identify the defs that are methods — direct children of a class body.
264
+
265
+ Returns:
266
+ The set of `id(FunctionDef)` for every method in the module.
267
+
268
+ """
269
+ return frozenset(
270
+ id(child)
271
+ for node in ast.walk(tree)
272
+ if isinstance(node, ast.ClassDef)
273
+ for child in node.body
274
+ if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef))
275
+ )
276
+
277
+
278
+ def _calls_super_same_name(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
279
+ """Report whether the body calls `super().<this method's name>(...)`.
280
+
281
+ That call is proof the method overrides an inherited one: its calling
282
+ convention belongs to the base class, and narrowing it to keyword-only
283
+ breaks every caller holding the base type.
284
+
285
+ Returns:
286
+ True when the method provably overrides a base-class method.
287
+
288
+ """
289
+ return any(
290
+ isinstance(call.func, ast.Attribute)
291
+ and call.func.attr == node.name
292
+ and isinstance(inner := call.func.value, ast.Call)
293
+ and isinstance(inner.func, ast.Name)
294
+ and inner.func.id == "super"
295
+ for call in ast.walk(node)
296
+ if isinstance(call, ast.Call)
297
+ )
298
+
299
+
151
300
  def _is_route_decorator(dec: ast.expr) -> bool:
152
301
  """Report whether `dec` is an HTTP-route decorator like `@router.get(...)`.
153
302
 
@@ -178,8 +327,12 @@ def _swap_prone_annotation(args: ast.arguments) -> str | None:
178
327
  marker therefore exempts exactly the parameters it protects — never the
179
328
  same-type pair sitting in front of it.
180
329
 
330
+ A parameter named `__x` is positional-only by the PEP 484 spelling and so
331
+ cannot be made keyword-only at all; it never groups.
332
+
181
333
  A group whose parameter names differ only by a numeric suffix
182
- (`value_1`/`value_2`) is a declared-symmetric signature and never groups.
334
+ (`value_1`/`value_2`) or are drawn entirely from a conventional ordered
335
+ vocabulary (`x`/`y`, `width`/`height`) is not swap-prone and never groups.
183
336
 
184
337
  Returns:
185
338
  The offending primitive name, or None when the signature is fine.
@@ -190,14 +343,43 @@ def _swap_prone_annotation(args: ast.arguments) -> str | None:
190
343
  params = params[1:]
191
344
  groups: dict[str, list[str]] = {}
192
345
  for p in params:
346
+ if _is_dunder_prefixed(p.arg):
347
+ continue
193
348
  if isinstance(ann := p.annotation, ast.Name) and ann.id in _PRIMITIVES:
194
349
  groups.setdefault(ann.id, []).append(p.arg)
195
350
  for name, arg_names in sorted(groups.items(), key=lambda kv: -len(kv[1])):
196
- if len(arg_names) >= _MIN_SAME_TYPE and not _is_symmetric_numbering(arg_names):
351
+ if len(arg_names) >= _MIN_SAME_TYPE and not (
352
+ _is_symmetric_numbering(arg_names) or _is_conventional_order(arg_names)
353
+ ):
197
354
  return name
198
355
  return None
199
356
 
200
357
 
358
+ def _is_dunder_prefixed(arg: str) -> bool:
359
+ """Report whether `arg` uses the PEP 484 positional-only naming convention.
360
+
361
+ Returns:
362
+ True for a `__name` parameter (leading dunder, no trailing dunder).
363
+
364
+ """
365
+ return arg.startswith("__") and not arg.endswith("__")
366
+
367
+
368
+ def _is_conventional_order(arg_names: list[str]) -> bool:
369
+ """Report whether every name comes from one conventional ordered vocabulary.
370
+
371
+ `x`/`y`, `width`/`height`, `red`/`green`/`blue`, `start`/`stop`/`step`:
372
+ position IS the notation, so the call site is not ambiguous and inserting
373
+ `*` makes it noisier, not safer.
374
+
375
+ Returns:
376
+ True when the whole group sits inside one ordered vocabulary.
377
+
378
+ """
379
+ names = set(arg_names)
380
+ return any(names <= vocabulary for vocabulary in _CONVENTIONAL_ORDER_GROUPS)
381
+
382
+
201
383
  _NUMERIC_SUFFIX_RE = re.compile(r"_?\d+$")
202
384
 
203
385