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.
- {sarj_python_lint-0.16.0 → sarj_python_lint-0.18.0}/PKG-INFO +1 -1
- {sarj_python_lint-0.16.0 → sarj_python_lint-0.18.0}/pyproject.toml +1 -1
- {sarj_python_lint-0.16.0 → sarj_python_lint-0.18.0}/src/sarj_python_lint/__main__.py +29 -1
- {sarj_python_lint-0.16.0 → sarj_python_lint-0.18.0}/src/sarj_python_lint/_secret_names.py +3 -2
- {sarj_python_lint-0.16.0 → sarj_python_lint-0.18.0}/src/sarj_python_lint/rules/_paths.py +6 -1
- {sarj_python_lint-0.16.0 → sarj_python_lint-0.18.0}/src/sarj_python_lint/rules/_sql.py +21 -0
- {sarj_python_lint-0.16.0 → sarj_python_lint-0.18.0}/src/sarj_python_lint/rules/fixture_returns_bare_tuple.py +38 -1
- {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
- {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
- {sarj_python_lint-0.16.0 → sarj_python_lint-0.18.0}/src/sarj_python_lint/rules/kwonly_same_type_params.py +184 -2
- sarj_python_lint-0.18.0/src/sarj_python_lint/rules/mock_without_spec.py +414 -0
- {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
- {sarj_python_lint-0.16.0 → sarj_python_lint-0.18.0}/src/sarj_python_lint/rules/no_comment_cruft.py +111 -4
- {sarj_python_lint-0.16.0 → sarj_python_lint-0.18.0}/src/sarj_python_lint/rules/no_fat_try_blocks.py +156 -1
- sarj_python_lint-0.18.0/src/sarj_python_lint/rules/no_fstring_in_log.py +314 -0
- {sarj_python_lint-0.16.0 → sarj_python_lint-0.18.0}/src/sarj_python_lint/rules/no_repeated_string_literal.py +75 -9
- {sarj_python_lint-0.16.0 → sarj_python_lint-0.18.0}/src/sarj_python_lint/rules/no_secret_in_log.py +28 -0
- {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
- {sarj_python_lint-0.16.0 → sarj_python_lint-0.18.0}/src/sarj_python_lint/rules/no_sequential_await.py +94 -8
- {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
- sarj_python_lint-0.18.0/src/sarj_python_lint/rules/no_unreachable_after_terminal.py +178 -0
- {sarj_python_lint-0.16.0 → sarj_python_lint-0.18.0}/src/sarj_python_lint/rules/parametrize_case_needs_id.py +57 -4
- {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
- {sarj_python_lint-0.16.0 → sarj_python_lint-0.18.0}/src/sarj_python_lint/rules/prefer_module_level_constant.py +42 -5
- sarj_python_lint-0.18.0/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +415 -0
- {sarj_python_lint-0.16.0 → sarj_python_lint-0.18.0}/src/sarj_python_lint/rules/prefer_str_enum.py +386 -50
- {sarj_python_lint-0.16.0 → sarj_python_lint-0.18.0}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +16 -0
- {sarj_python_lint-0.16.0 → sarj_python_lint-0.18.0}/src/sarj_python_lint/rules/prefer_timedelta_for_durations.py +118 -1
- {sarj_python_lint-0.16.0 → sarj_python_lint-0.18.0}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +149 -24
- {sarj_python_lint-0.16.0 → sarj_python_lint-0.18.0}/src/sarj_python_lint/rules/single_public_export.py +49 -2
- {sarj_python_lint-0.16.0 → sarj_python_lint-0.18.0}/src/sarj_python_lint/rules/stepdown.py +23 -0
- {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
- {sarj_python_lint-0.16.0 → sarj_python_lint-0.18.0}/src/sarj_python_lint/rules/xfail_requires_strict.py +43 -1
- {sarj_python_lint-0.16.0 → sarj_python_lint-0.18.0}/src/sarj_python_lint/rules/zero_assertion_test.py +150 -12
- sarj_python_lint-0.16.0/src/sarj_python_lint/rules/mock_without_spec.py +0 -225
- sarj_python_lint-0.16.0/src/sarj_python_lint/rules/no_fstring_in_log.py +0 -176
- sarj_python_lint-0.16.0/src/sarj_python_lint/rules/no_unreachable_after_terminal.py +0 -102
- sarj_python_lint-0.16.0/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -228
- {sarj_python_lint-0.16.0 → sarj_python_lint-0.18.0}/.gitignore +0 -0
- {sarj_python_lint-0.16.0 → sarj_python_lint-0.18.0}/README.md +0 -0
- {sarj_python_lint-0.16.0 → sarj_python_lint-0.18.0}/src/sarj_python_lint/__init__.py +0 -0
- {sarj_python_lint-0.16.0 → sarj_python_lint-0.18.0}/src/sarj_python_lint/_version.py +0 -0
- {sarj_python_lint-0.16.0 → sarj_python_lint-0.18.0}/src/sarj_python_lint/py.typed +0 -0
- {sarj_python_lint-0.16.0 → sarj_python_lint-0.18.0}/src/sarj_python_lint/rule_base.py +0 -0
- {sarj_python_lint-0.16.0 → sarj_python_lint-0.18.0}/src/sarj_python_lint/rules/__init__.py +0 -0
- {sarj_python_lint-0.16.0 → sarj_python_lint-0.18.0}/src/sarj_python_lint/rules/_logging.py +0 -0
- {sarj_python_lint-0.16.0 → sarj_python_lint-0.18.0}/src/sarj_python_lint/rules/_registry.py +0 -0
- {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
- {sarj_python_lint-0.16.0 → sarj_python_lint-0.18.0}/src/sarj_python_lint/rules/no_file_level_suppression.py +0 -0
- {sarj_python_lint-0.16.0 → sarj_python_lint-0.18.0}/src/sarj_python_lint/rules/no_isinstance_union_chain.py +0 -0
- {sarj_python_lint-0.16.0 → sarj_python_lint-0.18.0}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
- {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
- {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
- {sarj_python_lint-0.16.0 → sarj_python_lint-0.18.0}/src/sarj_python_lint/rules/no_select_star.py +0 -0
- {sarj_python_lint-0.16.0 → sarj_python_lint-0.18.0}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
- {sarj_python_lint-0.16.0 → sarj_python_lint-0.18.0}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
- {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
- {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.
|
|
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
|
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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`)
|
|
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
|
|
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
|
|