sarj-python-lint 0.12.2__tar.gz → 0.14.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.12.2 → sarj_python_lint-0.14.0}/PKG-INFO +1 -1
- {sarj_python_lint-0.12.2 → sarj_python_lint-0.14.0}/pyproject.toml +1 -1
- sarj_python_lint-0.14.0/src/sarj_python_lint/rules/_paths.py +58 -0
- {sarj_python_lint-0.12.2 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/_registry.py +19 -0
- {sarj_python_lint-0.12.2 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/inefficient_string_concat_in_loop.py +91 -1
- sarj_python_lint-0.14.0/src/sarj_python_lint/rules/kwonly_same_type_params.py +261 -0
- {sarj_python_lint-0.12.2 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/no_comment_cruft.py +49 -1
- sarj_python_lint-0.14.0/src/sarj_python_lint/rules/no_file_level_suppression.py +228 -0
- sarj_python_lint-0.14.0/src/sarj_python_lint/rules/no_raw_sql_in_tests.py +162 -0
- {sarj_python_lint-0.12.2 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +69 -1
- {sarj_python_lint-0.12.2 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/no_sequential_await.py +33 -1
- {sarj_python_lint-0.12.2 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/no_sleep_in_test_body.py +22 -10
- {sarj_python_lint-0.12.2 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +53 -19
- sarj_python_lint-0.14.0/src/sarj_python_lint/rules/prefer_match_assert_never.py +624 -0
- sarj_python_lint-0.14.0/src/sarj_python_lint/rules/prefer_module_level_constant.py +639 -0
- {sarj_python_lint-0.12.2 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +55 -0
- {sarj_python_lint-0.12.2 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/prefer_str_enum.py +50 -1
- {sarj_python_lint-0.12.2 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +8 -0
- {sarj_python_lint-0.12.2 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/prefer_timedelta_for_durations.py +7 -0
- {sarj_python_lint-0.12.2 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +10 -2
- {sarj_python_lint-0.12.2 → sarj_python_lint-0.14.0}/.gitignore +0 -0
- {sarj_python_lint-0.12.2 → sarj_python_lint-0.14.0}/README.md +0 -0
- {sarj_python_lint-0.12.2 → sarj_python_lint-0.14.0}/src/sarj_python_lint/__init__.py +0 -0
- {sarj_python_lint-0.12.2 → sarj_python_lint-0.14.0}/src/sarj_python_lint/__main__.py +0 -0
- {sarj_python_lint-0.12.2 → sarj_python_lint-0.14.0}/src/sarj_python_lint/_secret_names.py +0 -0
- {sarj_python_lint-0.12.2 → sarj_python_lint-0.14.0}/src/sarj_python_lint/_version.py +0 -0
- {sarj_python_lint-0.12.2 → sarj_python_lint-0.14.0}/src/sarj_python_lint/py.typed +0 -0
- {sarj_python_lint-0.12.2 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rule_base.py +0 -0
- {sarj_python_lint-0.12.2 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/__init__.py +0 -0
- {sarj_python_lint-0.12.2 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/_logging.py +0 -0
- {sarj_python_lint-0.12.2 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/_sql.py +0 -0
- {sarj_python_lint-0.12.2 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/no_aggregation_in_store_query.py +0 -0
- {sarj_python_lint-0.12.2 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
- {sarj_python_lint-0.12.2 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/no_fat_try_blocks.py +0 -0
- {sarj_python_lint-0.12.2 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/no_fstring_in_log.py +0 -0
- {sarj_python_lint-0.12.2 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/no_isinstance_union_chain.py +0 -0
- {sarj_python_lint-0.12.2 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
- {sarj_python_lint-0.12.2 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/no_query_with_many_joins.py +0 -0
- {sarj_python_lint-0.12.2 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
- {sarj_python_lint-0.12.2 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
- {sarj_python_lint-0.12.2 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/no_select_star.py +0 -0
- {sarj_python_lint-0.12.2 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/no_unreachable_after_terminal.py +0 -0
- {sarj_python_lint-0.12.2 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
- {sarj_python_lint-0.12.2 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/single_public_export.py +0 -0
- {sarj_python_lint-0.12.2 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/stepdown.py +0 -0
- {sarj_python_lint-0.12.2 → sarj_python_lint-0.14.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.14.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
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Shared file-scope predicates for the file-scope-gated rules.
|
|
2
|
+
|
|
3
|
+
A single definition of "is this a test file?" so the test-scoped rules
|
|
4
|
+
(SARJ031 no-sleep-in-test-body, SARJ036 no-raw-sql-in-tests) and the rules
|
|
5
|
+
that *exempt* tests (SARJ014 prefer-timedelta-for-durations, SARJ015
|
|
6
|
+
prefer-struct-over-namedtuple, SARJ026 prefer-namedtuple-over-tuple-return,
|
|
7
|
+
SARJ034 kwonly-same-type-params) never diverge on what counts as a test file.
|
|
8
|
+
|
|
9
|
+
Also the single definition of "is this a generated file?" (SARJ016/034):
|
|
10
|
+
generated code mirrors whatever its generator emits, so style rules cannot be
|
|
11
|
+
acted on there.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import re
|
|
17
|
+
from typing import TYPE_CHECKING
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
_TEST_DIR_NAMES = frozenset({"tests", "test"})
|
|
25
|
+
|
|
26
|
+
_GENERATED_RE = re.compile(r"auto-?generated|generated by|do not edit", re.IGNORECASE)
|
|
27
|
+
|
|
28
|
+
_GENERATED_HEADER_LINES = 5
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def is_generated_source(source: str) -> bool:
|
|
32
|
+
"""Report whether `source` self-identifies as generated code.
|
|
33
|
+
|
|
34
|
+
Checks the first few lines for the conventional markers
|
|
35
|
+
("autogenerated", "generated by", "do not edit").
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
True when the file header carries a generated-code marker.
|
|
39
|
+
|
|
40
|
+
"""
|
|
41
|
+
head = source.splitlines()[:_GENERATED_HEADER_LINES]
|
|
42
|
+
return any(_GENERATED_RE.search(line) for line in head)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def is_test_path(path: Path) -> bool:
|
|
46
|
+
"""Report whether `path` is a test file.
|
|
47
|
+
|
|
48
|
+
A test file is `conftest.py`, a `test_*.py` / `*_test.py` stem, or any file
|
|
49
|
+
under a `tests` / `test` directory segment.
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
True when `path` belongs to the test tree.
|
|
53
|
+
|
|
54
|
+
"""
|
|
55
|
+
name = path.name
|
|
56
|
+
if name == "conftest.py" or name.startswith("test_") or name.endswith("_test.py"):
|
|
57
|
+
return True
|
|
58
|
+
return any(part in _TEST_DIR_NAMES for part in path.parts)
|
|
@@ -5,6 +5,7 @@ from typing import TYPE_CHECKING
|
|
|
5
5
|
from sarj_python_lint.rules.inefficient_string_concat_in_loop import (
|
|
6
6
|
InefficientStringConcatInLoop,
|
|
7
7
|
)
|
|
8
|
+
from sarj_python_lint.rules.kwonly_same_type_params import KwonlySameTypeParams
|
|
8
9
|
from sarj_python_lint.rules.no_aggregation_in_store_query import (
|
|
9
10
|
NoAggregationInStoreQuery,
|
|
10
11
|
)
|
|
@@ -13,10 +14,12 @@ from sarj_python_lint.rules.no_cors_wildcard_with_credentials import (
|
|
|
13
14
|
NoCorsWildcardWithCredentials,
|
|
14
15
|
)
|
|
15
16
|
from sarj_python_lint.rules.no_fat_try_blocks import NoFatTryBlocks
|
|
17
|
+
from sarj_python_lint.rules.no_file_level_suppression import NoFileLevelSuppression
|
|
16
18
|
from sarj_python_lint.rules.no_fstring_in_log import NoFstringInLog
|
|
17
19
|
from sarj_python_lint.rules.no_isinstance_union_chain import NoIsinstanceUnionChain
|
|
18
20
|
from sarj_python_lint.rules.no_offset_pagination import NoOffsetPagination
|
|
19
21
|
from sarj_python_lint.rules.no_query_with_many_joins import NoQueryWithManyJoins
|
|
22
|
+
from sarj_python_lint.rules.no_raw_sql_in_tests import NoRawSqlInTests
|
|
20
23
|
from sarj_python_lint.rules.no_repeated_string_literal import NoRepeatedStringLiteral
|
|
21
24
|
from sarj_python_lint.rules.no_secret_in_log import NoSecretInLog
|
|
22
25
|
from sarj_python_lint.rules.no_select_star import NoSelectStar
|
|
@@ -30,6 +33,10 @@ from sarj_python_lint.rules.prefer_class_row import PreferClassRow
|
|
|
30
33
|
from sarj_python_lint.rules.prefer_constant_time_secret_compare import (
|
|
31
34
|
PreferConstantTimeSecretCompare,
|
|
32
35
|
)
|
|
36
|
+
from sarj_python_lint.rules.prefer_match_assert_never import PreferMatchAssertNever
|
|
37
|
+
from sarj_python_lint.rules.prefer_module_level_constant import (
|
|
38
|
+
PreferModuleLevelConstant,
|
|
39
|
+
)
|
|
33
40
|
from sarj_python_lint.rules.prefer_namedtuple_over_tuple_return import (
|
|
34
41
|
PreferNamedtupleOverTupleReturn,
|
|
35
42
|
)
|
|
@@ -52,6 +59,13 @@ if TYPE_CHECKING:
|
|
|
52
59
|
from sarj_python_lint.rule_base import Rule
|
|
53
60
|
|
|
54
61
|
|
|
62
|
+
# Retired codes — never reuse these for new rules:
|
|
63
|
+
# SARJ004, SARJ005 (retired before the standards merge),
|
|
64
|
+
# SARJ027, SARJ029, SARJ030 (dropped in 0.11.1 as too noisy),
|
|
65
|
+
# SARJ033 httpx-client-requires-timeout, SARJ035 no-import-time-settings
|
|
66
|
+
# (dropped by user veto after the 0.13.x mined-rules review),
|
|
67
|
+
# SARJ037 no-trivial-single-use-helper (prototyped and dropped for FP rate;
|
|
68
|
+
# see the 0.13.1 inlining commit for the corpus analysis).
|
|
55
69
|
REGISTRY: dict[str, type[Rule]] = {
|
|
56
70
|
NoSequentialAwait.id: NoSequentialAwait,
|
|
57
71
|
InefficientStringConcatInLoop.id: InefficientStringConcatInLoop,
|
|
@@ -79,6 +93,11 @@ REGISTRY: dict[str, type[Rule]] = {
|
|
|
79
93
|
SinglePublicExport.id: SinglePublicExport,
|
|
80
94
|
Stepdown.id: Stepdown,
|
|
81
95
|
NoRepeatedStringLiteral.id: NoRepeatedStringLiteral,
|
|
96
|
+
PreferMatchAssertNever.id: PreferMatchAssertNever,
|
|
97
|
+
KwonlySameTypeParams.id: KwonlySameTypeParams,
|
|
98
|
+
NoRawSqlInTests.id: NoRawSqlInTests,
|
|
99
|
+
NoFileLevelSuppression.id: NoFileLevelSuppression,
|
|
100
|
+
PreferModuleLevelConstant.id: PreferModuleLevelConstant,
|
|
82
101
|
}
|
|
83
102
|
|
|
84
103
|
__all__ = ["REGISTRY"]
|
|
@@ -19,6 +19,17 @@ bounded, not cross-iteration accumulation. Only a target initialised BEFORE the
|
|
|
19
19
|
loop is a true O(n²) accumulator, so a preceding non-accumulating rebind of the
|
|
20
20
|
target inside the loop suppresses the diagnostic.
|
|
21
21
|
|
|
22
|
+
A target whose intermediate values are CONSUMED per iteration is a probe, not
|
|
23
|
+
an accumulator — `"".join` at the end cannot replace it. Two consumption
|
|
24
|
+
shapes are exempt (both minimized from pydantic's unique-name generation):
|
|
25
|
+
|
|
26
|
+
* the enclosing `while` test reads the target
|
|
27
|
+
(`while name in taken: name += "_"`), or
|
|
28
|
+
* the loop body reads the target outside its own accumulation statement
|
|
29
|
+
(`globals.setdefault(reference_name, model)` then `reference_name += "_"`).
|
|
30
|
+
|
|
31
|
+
A pure accumulator is only ever written inside the loop, so it keeps firing.
|
|
32
|
+
|
|
22
33
|
References:
|
|
23
34
|
- https://docs.python.org/3/library/stdtypes.html#str.join
|
|
24
35
|
- https://wiki.python.org/moin/PythonSpeed/PerformanceTips
|
|
@@ -71,29 +82,66 @@ class _ConcatVisitor(ast.NodeVisitor):
|
|
|
71
82
|
self._loop_depth: int = 0
|
|
72
83
|
self._string_vars: list[frozenset[str]] = [frozenset()]
|
|
73
84
|
self._loop_reassigns: list[dict[str, list[int]]] = []
|
|
85
|
+
self._loop_reads: list[frozenset[str]] = []
|
|
86
|
+
self._while_probe_names: list[frozenset[str]] = []
|
|
74
87
|
self.hits: list[ast.AugAssign | ast.Assign] = []
|
|
75
88
|
|
|
76
89
|
@override
|
|
77
90
|
def generic_visit(self, node: ast.AST) -> None:
|
|
78
91
|
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)):
|
|
79
92
|
saved_depth = self._loop_depth
|
|
93
|
+
saved_probes = self._while_probe_names
|
|
94
|
+
saved_reads = self._loop_reads
|
|
80
95
|
self._loop_depth = 0
|
|
96
|
+
self._while_probe_names = []
|
|
97
|
+
self._loop_reads = []
|
|
81
98
|
self._string_vars.append(_string_typed_locals(node))
|
|
82
99
|
super().generic_visit(node)
|
|
83
100
|
self._string_vars.pop()
|
|
84
101
|
self._loop_depth = saved_depth
|
|
102
|
+
self._while_probe_names = saved_probes
|
|
103
|
+
self._loop_reads = saved_reads
|
|
85
104
|
return
|
|
86
105
|
if isinstance(node, (ast.For, ast.AsyncFor, ast.While)):
|
|
87
106
|
self._loop_depth += 1
|
|
88
107
|
self._loop_reassigns.append(_loop_local_reassignments(node))
|
|
108
|
+
self._loop_reads.append(_loop_read_names(node))
|
|
109
|
+
if isinstance(node, ast.While):
|
|
110
|
+
self._while_probe_names.append(frozenset(_test_names(node.test)))
|
|
89
111
|
super().generic_visit(node)
|
|
112
|
+
if isinstance(node, ast.While):
|
|
113
|
+
self._while_probe_names.pop()
|
|
114
|
+
self._loop_reads.pop()
|
|
90
115
|
self._loop_reassigns.pop()
|
|
91
116
|
self._loop_depth -= 1
|
|
92
117
|
return
|
|
93
|
-
if
|
|
118
|
+
if (
|
|
119
|
+
self._loop_depth
|
|
120
|
+
and self._is_in_loop_concat(node)
|
|
121
|
+
and not self._is_loop_local_target(node)
|
|
122
|
+
and not self._is_probe_target(node)
|
|
123
|
+
):
|
|
94
124
|
self.hits.append(node)
|
|
95
125
|
super().generic_visit(node)
|
|
96
126
|
|
|
127
|
+
def _is_probe_target(self, node: ast.AugAssign | ast.Assign) -> bool:
|
|
128
|
+
"""Report whether the concat target's intermediate values are consumed.
|
|
129
|
+
|
|
130
|
+
A target read by an enclosing while test, or read inside a loop body
|
|
131
|
+
outside its own accumulation, is a probe (unique-name generation):
|
|
132
|
+
every intermediate value matters, so `join` cannot replace the growth.
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
True when the target is consumed per iteration rather than only grown.
|
|
136
|
+
|
|
137
|
+
"""
|
|
138
|
+
target_src = ast.unparse(self._accumulation_target(node))
|
|
139
|
+
if any(target_src in names for names in self._while_probe_names):
|
|
140
|
+
return True
|
|
141
|
+
# Only the INNERMOST loop's reads consume the growth per iteration; a
|
|
142
|
+
# read in an outer loop body sees only the finished inner accumulation.
|
|
143
|
+
return bool(self._loop_reads) and target_src in self._loop_reads[-1]
|
|
144
|
+
|
|
97
145
|
def _is_loop_local_target(self, node: ast.AugAssign | ast.Assign) -> bool:
|
|
98
146
|
"""Report whether the concat target is freshly rebound earlier this iteration.
|
|
99
147
|
|
|
@@ -153,6 +201,48 @@ class _ConcatVisitor(ast.NodeVisitor):
|
|
|
153
201
|
return False
|
|
154
202
|
|
|
155
203
|
|
|
204
|
+
def _test_names(test: ast.expr) -> set[str]:
|
|
205
|
+
"""Collect the source text of every Name/Attribute read in a while test.
|
|
206
|
+
|
|
207
|
+
Returns:
|
|
208
|
+
The unparsed reads appearing in the test expression.
|
|
209
|
+
|
|
210
|
+
"""
|
|
211
|
+
return {ast.unparse(n) for n in ast.walk(test) if isinstance(n, (ast.Name, ast.Attribute))}
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _loop_read_names(loop: ast.For | ast.AsyncFor | ast.While) -> frozenset[str]:
|
|
215
|
+
"""Collect names READ in the loop body outside their own accumulation.
|
|
216
|
+
|
|
217
|
+
An `s += x` stores (no Load of `s`); an `s = s + x` self-read is excluded.
|
|
218
|
+
Any other Load — a call argument, a subscript key, a comparison — consumes
|
|
219
|
+
the intermediate value, which marks the target as a probe. Nested
|
|
220
|
+
function/lambda bodies are excluded (they run in their own scope).
|
|
221
|
+
|
|
222
|
+
Returns:
|
|
223
|
+
The unparsed Name/Attribute reads in the loop's own body.
|
|
224
|
+
|
|
225
|
+
"""
|
|
226
|
+
reads: set[str] = set()
|
|
227
|
+
stack: list[ast.AST] = list(loop.body)
|
|
228
|
+
while stack:
|
|
229
|
+
node = stack.pop()
|
|
230
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda, ast.ClassDef)):
|
|
231
|
+
continue
|
|
232
|
+
if isinstance(node, ast.Assign) and len(node.targets) == 1:
|
|
233
|
+
target = node.targets[0]
|
|
234
|
+
if _is_accumulation_assign(target, node.value) and isinstance(node.value, ast.BinOp):
|
|
235
|
+
# Skip the self-read operand; still record reads in the other one.
|
|
236
|
+
other = _other_add_operand(target, node.value)
|
|
237
|
+
if other is not None:
|
|
238
|
+
stack.append(other)
|
|
239
|
+
continue
|
|
240
|
+
stack.extend(ast.iter_child_nodes(node))
|
|
241
|
+
if isinstance(node, (ast.Name, ast.Attribute)) and isinstance(node.ctx, ast.Load):
|
|
242
|
+
reads.add(ast.unparse(node))
|
|
243
|
+
return frozenset(reads)
|
|
244
|
+
|
|
245
|
+
|
|
156
246
|
def _loop_local_reassignments(loop: ast.For | ast.AsyncFor | ast.While) -> dict[str, list[int]]:
|
|
157
247
|
"""Map each target rebound inside this loop's own body to the lines that rebind it.
|
|
158
248
|
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""SARJ034: >=2 positional parameters with the same primitive annotation — swap-prone.
|
|
2
|
+
|
|
3
|
+
`def transfer(source_id: str, target_id: str)` accepts `transfer(target, source)`
|
|
4
|
+
without a whisper from the type checker: two positional parameters with the same
|
|
5
|
+
primitive type are indistinguishable at the call site, and the resulting swap
|
|
6
|
+
bugs pass typecheck, pass review, and fail in production. Reviewers asked for a
|
|
7
|
+
keyword-only marker (`*`) in ~11 PRs, always with the same suggestion block:
|
|
8
|
+
|
|
9
|
+
# flagged
|
|
10
|
+
def transfer(source_id: str, target_id: str) -> None: ...
|
|
11
|
+
|
|
12
|
+
# preferred — call sites must name the arguments
|
|
13
|
+
def transfer(*, source_id: str, target_id: str) -> None: ...
|
|
14
|
+
|
|
15
|
+
Fires only when EVERY narrowing gate holds:
|
|
16
|
+
|
|
17
|
+
* the function has >= 2 positional parameters (excluding a leading `self`/`cls`)
|
|
18
|
+
whose annotations are IDENTICAL and are a bare primitive (`str`, `int`,
|
|
19
|
+
`float`, `bool`) — primitives carry no domain meaning, so the call site has
|
|
20
|
+
nothing to disambiguate with. Same-typed domain objects
|
|
21
|
+
(`a: Money, b: Money`) are left to review: arithmetic/comparison helpers over
|
|
22
|
+
a domain type are often legitimately symmetric.
|
|
23
|
+
|
|
24
|
+
Never flags — these signatures cannot or should not change:
|
|
25
|
+
|
|
26
|
+
* dunder methods (`__init__`, `__eq__`, ... — protocol-pinned or pervasively
|
|
27
|
+
called positionally),
|
|
28
|
+
* `visit_*` / `test_*` functions (visitor dispatch and pytest fixtures are
|
|
29
|
+
framework-called with positional conventions),
|
|
30
|
+
* functions decorated `@override` / `@overload` / `@abstractmethod` (an
|
|
31
|
+
override cannot unilaterally change the parent's signature; overload/abstract
|
|
32
|
+
signatures are contracts),
|
|
33
|
+
* HTTP route handlers — any decorator of the shape
|
|
34
|
+
`@<name>.get/post/put/patch/delete/head/options/websocket(...)` (`router`,
|
|
35
|
+
`app`, `api`, ...): FastAPI binds handler parameters by NAME (path/query
|
|
36
|
+
keys), so the positional shape is never called swap-prone by a human,
|
|
37
|
+
* test files (`_paths.is_test_path`) — test fakes and helpers mirror the
|
|
38
|
+
signatures of the code under test and cannot unilaterally change them,
|
|
39
|
+
* generated files (`_paths.is_generated_source`) — the signature mirrors
|
|
40
|
+
whatever the generator emits (found via trio's `_generated_io_kqueue.py`),
|
|
41
|
+
* functions whose name is referenced as a VALUE anywhere in the module
|
|
42
|
+
(passed to a registry, returned, stored) — the signature is a callback
|
|
43
|
+
protocol shared with other implementations and cannot change unilaterally
|
|
44
|
+
(found via attrs' `fmt_setter` family and sphinx `app.connect` handlers),
|
|
45
|
+
* the implementation of `@overload`-decorated stubs — a same-named sibling
|
|
46
|
+
in the same scope carries `@overload`, so the impl's positional shape is
|
|
47
|
+
pinned by the declared overloads (found via trio's `getsockopt`),
|
|
48
|
+
* signatures whose same-typed params differ only by a numeric suffix
|
|
49
|
+
(`value_1: float, value_2: float`) — the numbering declares the function
|
|
50
|
+
symmetric, so argument order genuinely does not matter (found via
|
|
51
|
+
pydantic's `almost_equal_floats`),
|
|
52
|
+
* parameters that are already keyword-only (behind `*`) or positional-only
|
|
53
|
+
(before `/`, a deliberate positional API). Note this is per-parameter, not
|
|
54
|
+
per-signature: `def f(a: str, b: str, *, c: int)` is still flagged, because
|
|
55
|
+
`a`/`b` sit BEFORE the marker and remain swap-prone. A `*args` variadic
|
|
56
|
+
likewise does not shield same-type params in front of it.
|
|
57
|
+
|
|
58
|
+
Parameters with defaults still count (documented judgment call: a default does
|
|
59
|
+
not make the call site any less swappable).
|
|
60
|
+
|
|
61
|
+
Symmetric functions (`def add(x: int, y: int)`) where order genuinely does not
|
|
62
|
+
matter are suppressed with `# sarj-noqa: SARJ034 — <reason>`.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
from __future__ import annotations
|
|
66
|
+
|
|
67
|
+
import ast
|
|
68
|
+
import re
|
|
69
|
+
from typing import TYPE_CHECKING, override
|
|
70
|
+
|
|
71
|
+
from sarj_python_lint.rule_base import Diagnostic, Rule, parse_or_none
|
|
72
|
+
from sarj_python_lint.rules._paths import is_generated_source, is_test_path
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
if TYPE_CHECKING:
|
|
76
|
+
from pathlib import Path
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
_MIN_SAME_TYPE = 2
|
|
80
|
+
|
|
81
|
+
_PRIMITIVES = frozenset({"str", "int", "float", "bool"})
|
|
82
|
+
|
|
83
|
+
_EXEMPT_DECORATORS = frozenset({"override", "overload", "abstractmethod"})
|
|
84
|
+
|
|
85
|
+
_HTTP_ROUTE_METHODS = frozenset(
|
|
86
|
+
{"get", "post", "put", "patch", "delete", "head", "options", "websocket"}
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
_EXEMPT_NAME_PREFIXES = ("visit_", "test_")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class KwonlySameTypeParams(Rule):
|
|
93
|
+
""">=2 positional params sharing one primitive annotation — insert `*`."""
|
|
94
|
+
|
|
95
|
+
id: str = "kwonly-same-type-params"
|
|
96
|
+
code: str = "SARJ034"
|
|
97
|
+
description: str = (
|
|
98
|
+
"two or more positional parameters with the same primitive annotation "
|
|
99
|
+
"are swap-prone — make them keyword-only by inserting `*`."
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
@override
|
|
103
|
+
def check(self, path: Path, source: str) -> list[Diagnostic]:
|
|
104
|
+
if is_test_path(path) or is_generated_source(source):
|
|
105
|
+
return []
|
|
106
|
+
tree = parse_or_none(path, source)
|
|
107
|
+
if tree is None:
|
|
108
|
+
return []
|
|
109
|
+
value_referenced = _value_referenced_names(tree)
|
|
110
|
+
overload_names = _overload_stub_names(tree)
|
|
111
|
+
diags: list[Diagnostic] = []
|
|
112
|
+
for node in ast.walk(tree):
|
|
113
|
+
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
114
|
+
continue
|
|
115
|
+
if _is_exempt(node):
|
|
116
|
+
continue
|
|
117
|
+
if node.name in value_referenced or node.name in overload_names:
|
|
118
|
+
continue
|
|
119
|
+
offending = _swap_prone_annotation(node.args)
|
|
120
|
+
if offending is None:
|
|
121
|
+
continue
|
|
122
|
+
diags.append(
|
|
123
|
+
Diagnostic(
|
|
124
|
+
path=path,
|
|
125
|
+
line=node.lineno,
|
|
126
|
+
col=node.col_offset + 1,
|
|
127
|
+
code=self.code,
|
|
128
|
+
message=(
|
|
129
|
+
f"`{node.name}` takes multiple positional `{offending}` "
|
|
130
|
+
"parameters — swap-prone at call sites; insert `*` to "
|
|
131
|
+
"make them keyword-only."
|
|
132
|
+
),
|
|
133
|
+
)
|
|
134
|
+
)
|
|
135
|
+
diags.sort(key=lambda d: (d.line, d.col))
|
|
136
|
+
return diags
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _is_exempt(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
|
|
140
|
+
name = node.name
|
|
141
|
+
if name.startswith("__") and name.endswith("__"):
|
|
142
|
+
return True
|
|
143
|
+
if name.startswith(_EXEMPT_NAME_PREFIXES):
|
|
144
|
+
return True
|
|
145
|
+
return any(
|
|
146
|
+
(isinstance(dec, ast.Name) and dec.id in _EXEMPT_DECORATORS)
|
|
147
|
+
or (isinstance(dec, ast.Attribute) and dec.attr in _EXEMPT_DECORATORS)
|
|
148
|
+
or _is_route_decorator(dec)
|
|
149
|
+
for dec in node.decorator_list
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _is_route_decorator(dec: ast.expr) -> bool:
|
|
154
|
+
"""Report whether `dec` is an HTTP-route decorator like `@router.get(...)`.
|
|
155
|
+
|
|
156
|
+
Matches `<Name>.<http method>` — optionally called — for any receiver name
|
|
157
|
+
(`router`, `app`, `api`, ...). FastAPI binds handler parameters by name, so
|
|
158
|
+
a route handler's positional shape is not swap-prone at any call site.
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
True when the decorator is an HTTP-route registration.
|
|
162
|
+
|
|
163
|
+
"""
|
|
164
|
+
target = dec.func if isinstance(dec, ast.Call) else dec
|
|
165
|
+
match target:
|
|
166
|
+
case ast.Attribute(value=ast.Name(), attr=attr) if attr in _HTTP_ROUTE_METHODS:
|
|
167
|
+
return True
|
|
168
|
+
case _:
|
|
169
|
+
return False
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _swap_prone_annotation(args: ast.arguments) -> str | None:
|
|
173
|
+
"""Find a primitive annotation shared by >= 2 swap-prone positional parameters.
|
|
174
|
+
|
|
175
|
+
A leading `self`/`cls` is excluded. Only bare-`Name` primitive annotations
|
|
176
|
+
participate — `str | None`, `Literal[...]`, and domain types never group.
|
|
177
|
+
Only `args.args` (positional-or-keyword) parameters count: keyword-only
|
|
178
|
+
parameters (behind `*`) cannot be swapped positionally, and positional-only
|
|
179
|
+
parameters (before `/`) are a deliberate positional API. A `*`/`*args`/`/`
|
|
180
|
+
marker therefore exempts exactly the parameters it protects — never the
|
|
181
|
+
same-type pair sitting in front of it.
|
|
182
|
+
|
|
183
|
+
A group whose parameter names differ only by a numeric suffix
|
|
184
|
+
(`value_1`/`value_2`) is a declared-symmetric signature and never groups.
|
|
185
|
+
|
|
186
|
+
Returns:
|
|
187
|
+
The offending primitive name, or None when the signature is fine.
|
|
188
|
+
|
|
189
|
+
"""
|
|
190
|
+
params = list(args.args)
|
|
191
|
+
if params and params[0].arg in {"self", "cls"}:
|
|
192
|
+
params = params[1:]
|
|
193
|
+
groups: dict[str, list[str]] = {}
|
|
194
|
+
for p in params:
|
|
195
|
+
if isinstance(ann := p.annotation, ast.Name) and ann.id in _PRIMITIVES:
|
|
196
|
+
groups.setdefault(ann.id, []).append(p.arg)
|
|
197
|
+
for name, arg_names in sorted(groups.items(), key=lambda kv: -len(kv[1])):
|
|
198
|
+
if len(arg_names) >= _MIN_SAME_TYPE and not _is_symmetric_numbering(arg_names):
|
|
199
|
+
return name
|
|
200
|
+
return None
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
_NUMERIC_SUFFIX_RE = re.compile(r"_?\d+$")
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _is_symmetric_numbering(arg_names: list[str]) -> bool:
|
|
207
|
+
"""Report whether every name in the group is one stem plus a numeric suffix.
|
|
208
|
+
|
|
209
|
+
`value_1`/`value_2` (or `x1`/`x2`) declare a symmetric function — argument
|
|
210
|
+
order genuinely does not matter, so the group is not swap-prone.
|
|
211
|
+
|
|
212
|
+
Returns:
|
|
213
|
+
True when all names share one stem and differ only by a number.
|
|
214
|
+
|
|
215
|
+
"""
|
|
216
|
+
stems = {_NUMERIC_SUFFIX_RE.sub("", name) for name in arg_names}
|
|
217
|
+
return len(stems) == 1 and all(_NUMERIC_SUFFIX_RE.search(name) for name in arg_names)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _value_referenced_names(tree: ast.AST) -> frozenset[str]:
|
|
221
|
+
"""Names referenced as a VALUE (loaded but not called) anywhere in the module.
|
|
222
|
+
|
|
223
|
+
A function whose name appears as a bare value — passed to a registry,
|
|
224
|
+
returned, stored in a collection — implements a callback protocol whose
|
|
225
|
+
positional shape is shared with other implementations; it cannot go
|
|
226
|
+
keyword-only unilaterally.
|
|
227
|
+
|
|
228
|
+
Returns:
|
|
229
|
+
The set of names loaded outside call position.
|
|
230
|
+
|
|
231
|
+
"""
|
|
232
|
+
call_funcs = {
|
|
233
|
+
id(node.func) for node in ast.walk(tree) if isinstance(node, ast.Call)
|
|
234
|
+
}
|
|
235
|
+
return frozenset(
|
|
236
|
+
node.id
|
|
237
|
+
for node in ast.walk(tree)
|
|
238
|
+
if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Load) and id(node) not in call_funcs
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _overload_stub_names(tree: ast.AST) -> frozenset[str]:
|
|
243
|
+
"""Names carrying an `@overload` decorator anywhere in the module.
|
|
244
|
+
|
|
245
|
+
The undecorated implementation of an overloaded function shares its name
|
|
246
|
+
with the stubs; its positional shape is pinned by the declared overloads.
|
|
247
|
+
|
|
248
|
+
Returns:
|
|
249
|
+
The set of `@overload`-decorated function names.
|
|
250
|
+
|
|
251
|
+
"""
|
|
252
|
+
return frozenset(
|
|
253
|
+
node.name
|
|
254
|
+
for node in ast.walk(tree)
|
|
255
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
|
|
256
|
+
and any(
|
|
257
|
+
(isinstance(dec, ast.Name) and dec.id == "overload")
|
|
258
|
+
or (isinstance(dec, ast.Attribute) and dec.attr == "overload")
|
|
259
|
+
for dec in node.decorator_list
|
|
260
|
+
)
|
|
261
|
+
)
|
{sarj_python_lint-0.12.2 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/no_comment_cruft.py
RENAMED
|
@@ -25,7 +25,17 @@ under a prose lead-in (`# For example:`, a wrapped sentence) or carries
|
|
|
25
25
|
pseudo-code markers (`%sent%`, `[opt]`, `<FunctionBody>`, `...`); and directive
|
|
26
26
|
comments — `# type:`, `# noqa`, `# sarj-noqa`,
|
|
27
27
|
`# pragma:`, `# pyright:`, `# mypy:`, `# fmt:`, `# isort:`, `# ruff:`,
|
|
28
|
-
`# nosec`, `# TODO`, `# FIXME`, shebangs, and
|
|
28
|
+
`# nosec`, `# TODO`, `# FIXME`, `# language=` (IDE injection), shebangs, and
|
|
29
|
+
coding declarations.
|
|
30
|
+
|
|
31
|
+
Also NOT flagged (famous-repo sweep hardening):
|
|
32
|
+
- generated files (`_paths.is_generated_source`) — their banners are the
|
|
33
|
+
generator's warning header, not hand-written cruft;
|
|
34
|
+
- a punctuation-only "banner" directly beneath a texty comment line — that is
|
|
35
|
+
an RST-style heading underline or an ASCII-diagram row inside a prose comment
|
|
36
|
+
essay (trio's epoll/socket design essays), not a code-section separator;
|
|
37
|
+
- step narration that carries a rationale marker (`because`, `since`,
|
|
38
|
+
`so that`, `otherwise`) — "First, take the lock, because ..." is a *why*.
|
|
29
39
|
|
|
30
40
|
Suppress an intentional case with `# sarj-noqa: SARJ016 — <reason>`.
|
|
31
41
|
"""
|
|
@@ -39,6 +49,7 @@ import tokenize
|
|
|
39
49
|
from typing import TYPE_CHECKING, override
|
|
40
50
|
|
|
41
51
|
from sarj_python_lint.rule_base import Diagnostic, Rule
|
|
52
|
+
from sarj_python_lint.rules._paths import is_generated_source
|
|
42
53
|
|
|
43
54
|
|
|
44
55
|
if TYPE_CHECKING:
|
|
@@ -67,6 +78,7 @@ _DIRECTIVE_PREFIXES = (
|
|
|
67
78
|
"hack",
|
|
68
79
|
"xxx:",
|
|
69
80
|
"-*-",
|
|
81
|
+
"language=",
|
|
70
82
|
)
|
|
71
83
|
|
|
72
84
|
_LICENSE_RE = re.compile(
|
|
@@ -108,6 +120,10 @@ _STEP_NARRATION_RE = re.compile(
|
|
|
108
120
|
re.IGNORECASE,
|
|
109
121
|
)
|
|
110
122
|
|
|
123
|
+
# A rationale marker turns step narration into a legitimate *why* comment
|
|
124
|
+
# ("First, we take the outer send lock, because of Trio's standard semantics").
|
|
125
|
+
_RATIONALE_RE = re.compile(r"\b(?:because|since|so that|otherwise)\b", re.IGNORECASE)
|
|
126
|
+
|
|
111
127
|
# Self-admitted meta-commentary — the "why later", not the why. Owner-tagged
|
|
112
128
|
# directive markers are handled elsewhere (as directives) and kept.
|
|
113
129
|
_META_COMMENTARY_RE = re.compile(
|
|
@@ -150,9 +166,33 @@ def _is_redundant_narration(body: str) -> bool:
|
|
|
150
166
|
c = body.strip()
|
|
151
167
|
if not c or _looks_like_code(c):
|
|
152
168
|
return False
|
|
169
|
+
if _RATIONALE_RE.search(c):
|
|
170
|
+
return False # "First, take the lock, because ..." carries a why
|
|
153
171
|
return bool(_STEP_NARRATION_RE.search(c) or _META_COMMENTARY_RE.search(c))
|
|
154
172
|
|
|
155
173
|
|
|
174
|
+
def _is_heading_underline(body: str, prev_body: str | None) -> bool:
|
|
175
|
+
"""Report whether a punctuation-only banner underlines a texty comment line.
|
|
176
|
+
|
|
177
|
+
An RST-style heading underline (`# Literature review` / `# -----------`) or
|
|
178
|
+
an ASCII-diagram row directly beneath a texty row lives INSIDE a prose
|
|
179
|
+
comment block — it is typography, not a code-section separator.
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
True when the banner sits directly under a texty, non-banner comment.
|
|
183
|
+
|
|
184
|
+
"""
|
|
185
|
+
if not _BANNER_FULL_RE.match(body):
|
|
186
|
+
return False
|
|
187
|
+
if prev_body is None:
|
|
188
|
+
return False
|
|
189
|
+
return (
|
|
190
|
+
any(_is_word_char(ch) for ch in prev_body)
|
|
191
|
+
and not _is_banner(prev_body)
|
|
192
|
+
and not _looks_like_code(prev_body)
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
|
|
156
196
|
def _is_banner(body: str) -> bool:
|
|
157
197
|
if not body:
|
|
158
198
|
return False
|
|
@@ -236,6 +276,12 @@ class NoCommentCruft(Rule):
|
|
|
236
276
|
|
|
237
277
|
@override
|
|
238
278
|
def check(self, path: Path, source: str) -> list[Diagnostic]:
|
|
279
|
+
if is_generated_source(source):
|
|
280
|
+
return []
|
|
281
|
+
# A Sphinx `docs/**/conf.py` is quickstart-generated boilerplate whose
|
|
282
|
+
# `# -- Section ----` banners are the tool's own convention.
|
|
283
|
+
if path.name == "conf.py" and "docs" in path.parts:
|
|
284
|
+
return []
|
|
239
285
|
try:
|
|
240
286
|
standalone, first_code_line = _standalone_comments(source)
|
|
241
287
|
except tokenize.TokenError, IndentationError, SyntaxError:
|
|
@@ -255,6 +301,8 @@ class NoCommentCruft(Rule):
|
|
|
255
301
|
@staticmethod
|
|
256
302
|
def _classify(body: str, prev_body: str | None) -> str | None:
|
|
257
303
|
if _is_banner(body):
|
|
304
|
+
if _is_heading_underline(body, prev_body):
|
|
305
|
+
return None
|
|
258
306
|
return "Section-banner / region comment — structure code with functions, not ASCII rules."
|
|
259
307
|
if _looks_like_code(body):
|
|
260
308
|
if prev_body is not None and _is_prose_line(prev_body):
|