sarj-python-lint 0.11.0__tar.gz → 0.12.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.11.0 → sarj_python_lint-0.12.0}/PKG-INFO +1 -1
- {sarj_python_lint-0.11.0 → sarj_python_lint-0.12.0}/pyproject.toml +1 -1
- {sarj_python_lint-0.11.0 → sarj_python_lint-0.12.0}/src/sarj_python_lint/rules/_registry.py +0 -6
- {sarj_python_lint-0.11.0 → sarj_python_lint-0.12.0}/src/sarj_python_lint/rules/_sql.py +17 -0
- sarj_python_lint-0.12.0/src/sarj_python_lint/rules/inefficient_string_concat_in_loop.py +175 -0
- {sarj_python_lint-0.11.0 → sarj_python_lint-0.12.0}/src/sarj_python_lint/rules/no_aggregation_in_store_query.py +3 -1
- {sarj_python_lint-0.11.0 → sarj_python_lint-0.12.0}/src/sarj_python_lint/rules/no_comment_cruft.py +35 -3
- {sarj_python_lint-0.11.0 → sarj_python_lint-0.12.0}/src/sarj_python_lint/rules/no_fstring_in_log.py +47 -3
- sarj_python_lint-0.12.0/src/sarj_python_lint/rules/no_isinstance_union_chain.py +249 -0
- {sarj_python_lint-0.11.0 → sarj_python_lint-0.12.0}/src/sarj_python_lint/rules/no_select_star.py +3 -1
- {sarj_python_lint-0.11.0 → sarj_python_lint-0.12.0}/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +208 -27
- {sarj_python_lint-0.11.0 → sarj_python_lint-0.12.0}/src/sarj_python_lint/rules/prefer_str_enum.py +147 -102
- {sarj_python_lint-0.11.0 → sarj_python_lint-0.12.0}/src/sarj_python_lint/rules/single_public_export.py +34 -3
- {sarj_python_lint-0.11.0 → sarj_python_lint-0.12.0}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +3 -1
- sarj_python_lint-0.11.0/src/sarj_python_lint/rules/inefficient_string_concat_in_loop.py +0 -117
- sarj_python_lint-0.11.0/src/sarj_python_lint/rules/json_response_not_parsed.py +0 -110
- sarj_python_lint-0.11.0/src/sarj_python_lint/rules/len_as_truthiness.py +0 -126
- sarj_python_lint-0.11.0/src/sarj_python_lint/rules/no_isinstance_union_chain.py +0 -201
- sarj_python_lint-0.11.0/src/sarj_python_lint/rules/no_manual_log_prefix.py +0 -146
- {sarj_python_lint-0.11.0 → sarj_python_lint-0.12.0}/.gitignore +0 -0
- {sarj_python_lint-0.11.0 → sarj_python_lint-0.12.0}/README.md +0 -0
- {sarj_python_lint-0.11.0 → sarj_python_lint-0.12.0}/src/sarj_python_lint/__init__.py +0 -0
- {sarj_python_lint-0.11.0 → sarj_python_lint-0.12.0}/src/sarj_python_lint/__main__.py +0 -0
- {sarj_python_lint-0.11.0 → sarj_python_lint-0.12.0}/src/sarj_python_lint/_secret_names.py +0 -0
- {sarj_python_lint-0.11.0 → sarj_python_lint-0.12.0}/src/sarj_python_lint/py.typed +0 -0
- {sarj_python_lint-0.11.0 → sarj_python_lint-0.12.0}/src/sarj_python_lint/rule_base.py +0 -0
- {sarj_python_lint-0.11.0 → sarj_python_lint-0.12.0}/src/sarj_python_lint/rules/__init__.py +0 -0
- {sarj_python_lint-0.11.0 → sarj_python_lint-0.12.0}/src/sarj_python_lint/rules/_logging.py +0 -0
- {sarj_python_lint-0.11.0 → sarj_python_lint-0.12.0}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
- {sarj_python_lint-0.11.0 → sarj_python_lint-0.12.0}/src/sarj_python_lint/rules/no_fat_try_blocks.py +0 -0
- {sarj_python_lint-0.11.0 → sarj_python_lint-0.12.0}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
- {sarj_python_lint-0.11.0 → sarj_python_lint-0.12.0}/src/sarj_python_lint/rules/no_query_with_many_joins.py +0 -0
- {sarj_python_lint-0.11.0 → sarj_python_lint-0.12.0}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
- {sarj_python_lint-0.11.0 → sarj_python_lint-0.12.0}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
- {sarj_python_lint-0.11.0 → sarj_python_lint-0.12.0}/src/sarj_python_lint/rules/no_sequential_await.py +0 -0
- {sarj_python_lint-0.11.0 → sarj_python_lint-0.12.0}/src/sarj_python_lint/rules/no_sleep_in_test_body.py +0 -0
- {sarj_python_lint-0.11.0 → sarj_python_lint-0.12.0}/src/sarj_python_lint/rules/no_unreachable_after_terminal.py +0 -0
- {sarj_python_lint-0.11.0 → sarj_python_lint-0.12.0}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
- {sarj_python_lint-0.11.0 → sarj_python_lint-0.12.0}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
- {sarj_python_lint-0.11.0 → sarj_python_lint-0.12.0}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
- {sarj_python_lint-0.11.0 → sarj_python_lint-0.12.0}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
- {sarj_python_lint-0.11.0 → sarj_python_lint-0.12.0}/src/sarj_python_lint/rules/prefer_timedelta_for_durations.py +0 -0
- {sarj_python_lint-0.11.0 → sarj_python_lint-0.12.0}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +0 -0
- {sarj_python_lint-0.11.0 → sarj_python_lint-0.12.0}/src/sarj_python_lint/rules/stepdown.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.12.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
|
|
@@ -5,8 +5,6 @@ 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.json_response_not_parsed import JsonResponseNotParsed
|
|
9
|
-
from sarj_python_lint.rules.len_as_truthiness import LenAsTruthiness
|
|
10
8
|
from sarj_python_lint.rules.no_aggregation_in_store_query import (
|
|
11
9
|
NoAggregationInStoreQuery,
|
|
12
10
|
)
|
|
@@ -17,7 +15,6 @@ from sarj_python_lint.rules.no_cors_wildcard_with_credentials import (
|
|
|
17
15
|
from sarj_python_lint.rules.no_fat_try_blocks import NoFatTryBlocks
|
|
18
16
|
from sarj_python_lint.rules.no_fstring_in_log import NoFstringInLog
|
|
19
17
|
from sarj_python_lint.rules.no_isinstance_union_chain import NoIsinstanceUnionChain
|
|
20
|
-
from sarj_python_lint.rules.no_manual_log_prefix import NoManualLogPrefix
|
|
21
18
|
from sarj_python_lint.rules.no_offset_pagination import NoOffsetPagination
|
|
22
19
|
from sarj_python_lint.rules.no_query_with_many_joins import NoQueryWithManyJoins
|
|
23
20
|
from sarj_python_lint.rules.no_repeated_string_literal import NoRepeatedStringLiteral
|
|
@@ -64,10 +61,7 @@ REGISTRY: dict[str, type[Rule]] = {
|
|
|
64
61
|
NoIsinstanceUnionChain.id: NoIsinstanceUnionChain,
|
|
65
62
|
NoOffsetPagination.id: NoOffsetPagination,
|
|
66
63
|
PreferNamedtupleOverTupleReturn.id: PreferNamedtupleOverTupleReturn,
|
|
67
|
-
LenAsTruthiness.id: LenAsTruthiness,
|
|
68
64
|
NoCorsWildcardWithCredentials.id: NoCorsWildcardWithCredentials,
|
|
69
|
-
NoManualLogPrefix.id: NoManualLogPrefix,
|
|
70
|
-
JsonResponseNotParsed.id: JsonResponseNotParsed,
|
|
71
65
|
NoSleepInTestBody.id: NoSleepInTestBody,
|
|
72
66
|
PydanticAtBoundaries.id: PydanticAtBoundaries,
|
|
73
67
|
NoSentinelReturnOnExcept.id: NoSentinelReturnOnExcept,
|
|
@@ -11,6 +11,23 @@ before any keyword or comment scan.
|
|
|
11
11
|
from __future__ import annotations
|
|
12
12
|
|
|
13
13
|
import ast
|
|
14
|
+
from typing import TYPE_CHECKING
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def is_store_module(path: Path) -> bool:
|
|
22
|
+
"""True for a store-layer module: basename ends `_store.py`, or the file lives under a `stores/` directory.
|
|
23
|
+
|
|
24
|
+
The SQL store-lint rules (SARJ018/020/021) encode store-write semantics —
|
|
25
|
+
column-naming, ON-CONFLICT upserts, no Postgres-side aggregation — that only
|
|
26
|
+
apply to the store layer. Non-store SQL (Flask view handlers, a Django ORM
|
|
27
|
+
SQL generator) legitimately writes `SELECT *`, bare `INSERT`, and `COUNT()`,
|
|
28
|
+
so those files are out of scope.
|
|
29
|
+
"""
|
|
30
|
+
return path.name.endswith("_store.py") or "stores" in path.parts
|
|
14
31
|
|
|
15
32
|
|
|
16
33
|
def sql_string_value(node: ast.expr) -> str | None:
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""SARJ002: detect O(n²) single-accumulator string growth inside loops.
|
|
2
|
+
|
|
3
|
+
Growing a string with `s += <str>` or `s = s + <str>` inside a loop is O(n²)
|
|
4
|
+
in CPython because strings are immutable — each step allocates a new string and
|
|
5
|
+
copies the previous one. Append to a list and `"".join(parts)` at the end for
|
|
6
|
+
O(n).
|
|
7
|
+
|
|
8
|
+
The rule fires only on genuine single-string accumulation. It deliberately does
|
|
9
|
+
NOT treat a `str()/repr()/format()` coercion, a `.join()` / `.format()` /
|
|
10
|
+
`.strftime()` call, or an `os.path.join(...)`-style call as accumulation — those
|
|
11
|
+
are either the prescribed remedy or a bounded per-iteration transform, not the
|
|
12
|
+
O(n²) defect. Per-slot writes (`parts[i] = ...`) and idempotent rebinding
|
|
13
|
+
(`x = f(x)`) are likewise excluded.
|
|
14
|
+
|
|
15
|
+
References:
|
|
16
|
+
- https://docs.python.org/3/library/stdtypes.html#str.join
|
|
17
|
+
- https://wiki.python.org/moin/PythonSpeed/PerformanceTips
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import ast
|
|
23
|
+
from typing import TYPE_CHECKING, override
|
|
24
|
+
|
|
25
|
+
from sarj_python_lint.rule_base import Diagnostic, Rule, parse_or_none
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class InefficientStringConcatInLoop(Rule):
|
|
33
|
+
"""O(n²) string concatenation in a loop."""
|
|
34
|
+
|
|
35
|
+
id: str = "inefficient-string-concat-in-loop"
|
|
36
|
+
code: str = "SARJ002"
|
|
37
|
+
description: str = "`s += '...'` / `s = s + '...'` in a loop is O(n²); append to a list and join."
|
|
38
|
+
|
|
39
|
+
@override
|
|
40
|
+
def check(self, path: Path, source: str) -> list[Diagnostic]:
|
|
41
|
+
tree = parse_or_none(path, source)
|
|
42
|
+
if tree is None:
|
|
43
|
+
return []
|
|
44
|
+
visitor = _ConcatVisitor()
|
|
45
|
+
visitor.visit(tree)
|
|
46
|
+
return [
|
|
47
|
+
Diagnostic(
|
|
48
|
+
path=path,
|
|
49
|
+
line=node.lineno,
|
|
50
|
+
col=node.col_offset + 1,
|
|
51
|
+
code=self.code,
|
|
52
|
+
message="String concat in a loop is O(n²). Append to a list and `''.join(...)`.",
|
|
53
|
+
)
|
|
54
|
+
for node in visitor.hits
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class _ConcatVisitor(ast.NodeVisitor):
|
|
59
|
+
"""Single O(n) pass flagging each in-loop string accumulation exactly once."""
|
|
60
|
+
|
|
61
|
+
def __init__(self) -> None:
|
|
62
|
+
self._loop_depth: int = 0
|
|
63
|
+
self._string_vars: list[frozenset[str]] = [frozenset()]
|
|
64
|
+
self.hits: list[ast.AugAssign | ast.Assign] = []
|
|
65
|
+
|
|
66
|
+
@override
|
|
67
|
+
def generic_visit(self, node: ast.AST) -> None:
|
|
68
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)):
|
|
69
|
+
saved_depth = self._loop_depth
|
|
70
|
+
self._loop_depth = 0
|
|
71
|
+
self._string_vars.append(_string_typed_locals(node))
|
|
72
|
+
super().generic_visit(node)
|
|
73
|
+
self._string_vars.pop()
|
|
74
|
+
self._loop_depth = saved_depth
|
|
75
|
+
return
|
|
76
|
+
if isinstance(node, (ast.For, ast.AsyncFor, ast.While)):
|
|
77
|
+
self._loop_depth += 1
|
|
78
|
+
super().generic_visit(node)
|
|
79
|
+
self._loop_depth -= 1
|
|
80
|
+
return
|
|
81
|
+
if self._loop_depth and self._is_in_loop_concat(node):
|
|
82
|
+
self.hits.append(node)
|
|
83
|
+
super().generic_visit(node)
|
|
84
|
+
|
|
85
|
+
def _is_in_loop_concat(self, node: ast.AST) -> bool:
|
|
86
|
+
if isinstance(node, ast.AugAssign):
|
|
87
|
+
return isinstance(node.op, ast.Add) and self._is_string_growth(node.target, node.value)
|
|
88
|
+
if isinstance(node, ast.Assign):
|
|
89
|
+
return any(self._is_self_add_growth(target, node.value) for target in node.targets)
|
|
90
|
+
return False
|
|
91
|
+
|
|
92
|
+
def _is_self_add_growth(self, target: ast.expr, value: ast.expr) -> bool:
|
|
93
|
+
"""`s = s + <str>` — a BinOp(Add) rebinding the target to itself-plus-more."""
|
|
94
|
+
if not isinstance(value, ast.BinOp) or not isinstance(value.op, ast.Add):
|
|
95
|
+
return False
|
|
96
|
+
other = _other_add_operand(target, value)
|
|
97
|
+
if other is None:
|
|
98
|
+
return False
|
|
99
|
+
return self._is_string_growth(target, other)
|
|
100
|
+
|
|
101
|
+
def _is_string_growth(self, target: ast.expr, rhs: ast.expr) -> bool:
|
|
102
|
+
"""True when appending `rhs` to `target` is single-string accumulation."""
|
|
103
|
+
if isinstance(target, ast.Subscript):
|
|
104
|
+
return False
|
|
105
|
+
if _looks_like_string(rhs):
|
|
106
|
+
return True
|
|
107
|
+
if isinstance(rhs, ast.Name) and isinstance(target, ast.Name):
|
|
108
|
+
return target.id in self._string_vars[-1]
|
|
109
|
+
return False
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _other_add_operand(target: ast.expr, binop: ast.BinOp) -> ast.expr | None:
|
|
113
|
+
"""The non-target operand of `target + x` / `x + target`, or None if absent."""
|
|
114
|
+
target_src = ast.unparse(target)
|
|
115
|
+
if ast.unparse(binop.left) == target_src:
|
|
116
|
+
return binop.right
|
|
117
|
+
if ast.unparse(binop.right) == target_src:
|
|
118
|
+
return binop.left
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _string_typed_locals(func: ast.AST) -> frozenset[str]:
|
|
123
|
+
"""Names assigned a string-literal-ish value in this function's own body.
|
|
124
|
+
|
|
125
|
+
Used as the string-typed signal for bare-`Name` accumulation (`buf += line`):
|
|
126
|
+
a numeric accumulator (`total = 0`) is absent, so `total += x` stays clean.
|
|
127
|
+
"""
|
|
128
|
+
body = getattr(func, "body", None)
|
|
129
|
+
if not isinstance(body, list):
|
|
130
|
+
return frozenset()
|
|
131
|
+
names: set[str] = set()
|
|
132
|
+
for stmt in body:
|
|
133
|
+
_collect_string_targets(stmt, names)
|
|
134
|
+
return frozenset(names)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _collect_string_targets(node: ast.AST, names: set[str]) -> None:
|
|
138
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda, ast.ClassDef)):
|
|
139
|
+
return
|
|
140
|
+
if isinstance(node, ast.Assign) and _looks_like_string(node.value):
|
|
141
|
+
for target in node.targets:
|
|
142
|
+
if isinstance(target, ast.Name):
|
|
143
|
+
names.add(target.id)
|
|
144
|
+
if (
|
|
145
|
+
isinstance(node, ast.AnnAssign)
|
|
146
|
+
and node.value is not None
|
|
147
|
+
and isinstance(node.target, ast.Name)
|
|
148
|
+
and _looks_like_string(node.value)
|
|
149
|
+
):
|
|
150
|
+
names.add(node.target.id)
|
|
151
|
+
for child in ast.iter_child_nodes(node):
|
|
152
|
+
_collect_string_targets(child, names)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _looks_like_string(node: ast.AST) -> bool:
|
|
156
|
+
"""Heuristic for 'this expression is obviously a string at runtime'.
|
|
157
|
+
|
|
158
|
+
Deliberately conservative: a bare call (`str(x)`, `",".join(...)`,
|
|
159
|
+
`os.path.join(...)`) is NOT treated as a string — those shapes also appear in
|
|
160
|
+
benign one-shot reassignment and are not the accumulation defect.
|
|
161
|
+
"""
|
|
162
|
+
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
|
163
|
+
return True
|
|
164
|
+
if isinstance(node, ast.JoinedStr): # f-string
|
|
165
|
+
return True
|
|
166
|
+
if isinstance(node, ast.NamedExpr): # walrus `(y := <str>)`
|
|
167
|
+
return _looks_like_string(node.value)
|
|
168
|
+
if isinstance(node, ast.IfExp): # ternary — string only if both branches are
|
|
169
|
+
return _looks_like_string(node.body) and _looks_like_string(node.orelse)
|
|
170
|
+
if isinstance(node, ast.BinOp):
|
|
171
|
+
if isinstance(node.op, ast.Add):
|
|
172
|
+
return _looks_like_string(node.left) or _looks_like_string(node.right)
|
|
173
|
+
if isinstance(node.op, ast.Mod): # `"row %s" % x` — left operand decides
|
|
174
|
+
return _looks_like_string(node.left)
|
|
175
|
+
return False
|
|
@@ -38,7 +38,7 @@ import re
|
|
|
38
38
|
from typing import TYPE_CHECKING, override
|
|
39
39
|
|
|
40
40
|
from sarj_python_lint.rule_base import Diagnostic, Rule, parse_or_none
|
|
41
|
-
from sarj_python_lint.rules._sql import sql_string_value, strip_sql_noise
|
|
41
|
+
from sarj_python_lint.rules._sql import is_store_module, sql_string_value, strip_sql_noise
|
|
42
42
|
|
|
43
43
|
|
|
44
44
|
if TYPE_CHECKING:
|
|
@@ -121,6 +121,8 @@ class NoAggregationInStoreQuery(Rule):
|
|
|
121
121
|
|
|
122
122
|
@override
|
|
123
123
|
def check(self, path: Path, source: str) -> list[Diagnostic]:
|
|
124
|
+
if not is_store_module(path):
|
|
125
|
+
return []
|
|
124
126
|
# A diagnostic needs some string literal that carries both a query verb
|
|
125
127
|
# and an aggregation keyword; `source` is a strict superset of every
|
|
126
128
|
# literal, so if either class is absent from the whole file no diagnostic
|
{sarj_python_lint-0.11.0 → sarj_python_lint-0.12.0}/src/sarj_python_lint/rules/no_comment_cruft.py
RENAMED
|
@@ -20,7 +20,10 @@ readability audit, not this rule):
|
|
|
20
20
|
block of `#` lines.
|
|
21
21
|
|
|
22
22
|
Deliberately NOT flagged: trailing/standalone *prose* comments (the legitimate
|
|
23
|
-
"why")
|
|
23
|
+
"why"); code-shaped *illustrations* — a line that parses as Python but sits
|
|
24
|
+
under a prose lead-in (`# For example:`, a wrapped sentence) or carries
|
|
25
|
+
pseudo-code markers (`%sent%`, `[opt]`, `<FunctionBody>`, `...`); and directive
|
|
26
|
+
comments — `# type:`, `# noqa`, `# sarj-noqa`,
|
|
24
27
|
`# pragma:`, `# pyright:`, `# mypy:`, `# fmt:`, `# isort:`, `# ruff:`,
|
|
25
28
|
`# nosec`, `# TODO`, `# FIXME`, shebangs, and coding declarations.
|
|
26
29
|
|
|
@@ -43,6 +46,7 @@ if TYPE_CHECKING:
|
|
|
43
46
|
|
|
44
47
|
|
|
45
48
|
_LEADING_PREAMBLE_MIN = 4
|
|
49
|
+
_PROSE_MIN_WORDS = 3
|
|
46
50
|
|
|
47
51
|
_DIRECTIVE_PREFIXES = (
|
|
48
52
|
"type:",
|
|
@@ -90,6 +94,11 @@ _CODE_HEADER_RE = re.compile(
|
|
|
90
94
|
)
|
|
91
95
|
_ASSIGN_OR_CALL_RE = re.compile(r"^[A-Za-z_][\w.\[\]]*\s*(?:=|:=|\+=|-=|\*=|/=)\s*\S|^[A-Za-z_][\w.]*\(")
|
|
92
96
|
|
|
97
|
+
# Pseudo-code / grammar-example markers (`%sent%`, `[opt]`, `<FunctionBody>`,
|
|
98
|
+
# `...`). Real commented-out code doesn't carry these — they mark an
|
|
99
|
+
# illustration inside a doc comment, not a line that was once executed.
|
|
100
|
+
_PSEUDOCODE_RE = re.compile(r"%[^%\s]+%|\[opt\]|<[^<>]+>|\.\.\.")
|
|
101
|
+
|
|
93
102
|
|
|
94
103
|
def _comment_body(raw: str) -> str:
|
|
95
104
|
return raw.lstrip("#").strip()
|
|
@@ -125,6 +134,8 @@ def _looks_like_code(body: str) -> bool:
|
|
|
125
134
|
c = body.strip()
|
|
126
135
|
if not c:
|
|
127
136
|
return False
|
|
137
|
+
if _PSEUDOCODE_RE.search(c):
|
|
138
|
+
return False
|
|
128
139
|
if _CODE_STMT_RE.match(c):
|
|
129
140
|
return _compiles(c)
|
|
130
141
|
if _RISKY_STMT_RE.match(c):
|
|
@@ -138,6 +149,23 @@ def _looks_like_code(body: str) -> bool:
|
|
|
138
149
|
return False
|
|
139
150
|
|
|
140
151
|
|
|
152
|
+
def _is_prose_line(body: str) -> bool:
|
|
153
|
+
"""Return True if `body` reads as a natural-language sentence, not code.
|
|
154
|
+
|
|
155
|
+
Used to spot a doc/prose comment that immediately precedes a code-shaped
|
|
156
|
+
line: `# For example:` above `# result = {**a, **b}`, or a wrapped sentence
|
|
157
|
+
whose second line happens to parse as an expression. Such a line is an
|
|
158
|
+
illustration / prose continuation, not commented-out code.
|
|
159
|
+
"""
|
|
160
|
+
c = body.strip()
|
|
161
|
+
if not c or _is_banner(c) or _is_directive(c) or _looks_like_code(c):
|
|
162
|
+
return False
|
|
163
|
+
if c.endswith(":"):
|
|
164
|
+
return True
|
|
165
|
+
words = [w for w in re.split(r"\s+", c) if any(ch.isalpha() for ch in w)]
|
|
166
|
+
return len(words) >= _PROSE_MIN_WORDS
|
|
167
|
+
|
|
168
|
+
|
|
141
169
|
def _compiles(snippet: str) -> bool:
|
|
142
170
|
try:
|
|
143
171
|
ast.parse(snippet)
|
|
@@ -176,20 +204,24 @@ class NoCommentCruft(Rule):
|
|
|
176
204
|
except tokenize.TokenError, IndentationError, SyntaxError:
|
|
177
205
|
return []
|
|
178
206
|
diags: dict[int, Diagnostic] = {}
|
|
207
|
+
by_line = {line: body for line, _, body in standalone}
|
|
179
208
|
for line, col, body in standalone:
|
|
180
209
|
if _is_directive(body):
|
|
181
210
|
continue
|
|
182
|
-
|
|
211
|
+
prev_body = by_line.get(line - 1)
|
|
212
|
+
msg = self._classify(body, prev_body)
|
|
183
213
|
if msg is not None:
|
|
184
214
|
diags[line] = Diagnostic(path=path, line=line, col=col + 1, code=self.code, message=msg)
|
|
185
215
|
self._flag_leading_preamble(standalone, first_code_line, path, diags)
|
|
186
216
|
return [diags[k] for k in sorted(diags)]
|
|
187
217
|
|
|
188
218
|
@staticmethod
|
|
189
|
-
def _classify(body: str) -> str | None:
|
|
219
|
+
def _classify(body: str, prev_body: str | None) -> str | None:
|
|
190
220
|
if _is_banner(body):
|
|
191
221
|
return "Section-banner / region comment — structure code with functions, not ASCII rules."
|
|
192
222
|
if _looks_like_code(body):
|
|
223
|
+
if prev_body is not None and _is_prose_line(prev_body):
|
|
224
|
+
return None
|
|
193
225
|
return "Commented-out code — delete it; git history remembers."
|
|
194
226
|
return None
|
|
195
227
|
|
{sarj_python_lint-0.11.0 → sarj_python_lint-0.12.0}/src/sarj_python_lint/rules/no_fstring_in_log.py
RENAMED
|
@@ -18,9 +18,16 @@ To keep false positives near zero we require BOTH a logger-like receiver
|
|
|
18
18
|
(`logger`/`log`/`logging`/`loguru` and common aliases) AND a logging method
|
|
19
19
|
name — an f-string passed to some unrelated `.info(...)` is not flagged. The
|
|
20
20
|
receiver chain is resolved, so builder/factory forms are still caught:
|
|
21
|
-
`logger.bind(...).info(...)`, `logger.opt(lazy=True).debug(...)
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
`logger.bind(...).info(...)`, `logger.opt(lazy=True).debug(...)`. Only the first
|
|
22
|
+
positional argument (the message) is inspected.
|
|
23
|
+
|
|
24
|
+
The structured-keyword advice is loguru-specific: stdlib `logging` treats
|
|
25
|
+
trailing positional args as %-format parameters and reserves `exc_info` /
|
|
26
|
+
`stack_info` / `extra` keywords, so rewriting a stdlib call to
|
|
27
|
+
`logger.info("msg", key=value)` would silently break it. We therefore suppress
|
|
28
|
+
calls that carry a stdlib tell — a `logging.getLogger(...)` factory anywhere in
|
|
29
|
+
the receiver chain, or an `exc_info` / `stack_info` / `extra` keyword — and keep
|
|
30
|
+
firing on the loguru-shaped calls the advice actually applies to.
|
|
24
31
|
|
|
25
32
|
Suppress an intentional case with `# sarj-noqa: SARJ017 — <reason>`.
|
|
26
33
|
"""
|
|
@@ -54,6 +61,11 @@ _LOG_METHODS = frozenset(
|
|
|
54
61
|
}
|
|
55
62
|
)
|
|
56
63
|
|
|
64
|
+
# Keyword arguments defined by stdlib `logging` (and never structured fields).
|
|
65
|
+
# Their presence marks the call as a stdlib logger, for which the loguru-style
|
|
66
|
+
# structured-keyword rewrite is wrong.
|
|
67
|
+
_STDLIB_ONLY_KWARGS = frozenset({"exc_info", "stack_info", "extra"})
|
|
68
|
+
|
|
57
69
|
|
|
58
70
|
class NoFstringInLog(Rule):
|
|
59
71
|
"""f-string passed as a logging message — use structured keyword arguments."""
|
|
@@ -76,6 +88,8 @@ class NoFstringInLog(Rule):
|
|
|
76
88
|
continue
|
|
77
89
|
if not node.args:
|
|
78
90
|
continue
|
|
91
|
+
if _is_stdlib_logging_call(node):
|
|
92
|
+
continue
|
|
79
93
|
offending = _interpolating_fstring(node.args[0])
|
|
80
94
|
if offending is not None:
|
|
81
95
|
diags.append(
|
|
@@ -100,6 +114,36 @@ def _is_logging_call(node: ast.Call) -> bool:
|
|
|
100
114
|
return is_logger_expr(func.value)
|
|
101
115
|
|
|
102
116
|
|
|
117
|
+
def _is_stdlib_logging_call(node: ast.Call) -> bool:
|
|
118
|
+
"""True when the call carries a stdlib-`logging` tell the loguru advice breaks.
|
|
119
|
+
|
|
120
|
+
Either a stdlib-reserved keyword (`exc_info`/`stack_info`/`extra`) or a
|
|
121
|
+
`logging.getLogger(...)` factory anywhere in the receiver chain marks the
|
|
122
|
+
logger as stdlib, whose message API is %-style positional, not structured
|
|
123
|
+
keywords — so the rule must stay silent to avoid recommending a broken fix.
|
|
124
|
+
"""
|
|
125
|
+
if any(kw.arg in _STDLIB_ONLY_KWARGS for kw in node.keywords):
|
|
126
|
+
return True
|
|
127
|
+
func = node.func
|
|
128
|
+
return isinstance(func, ast.Attribute) and _chain_has_getlogger(func.value)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _chain_has_getlogger(expr: ast.expr) -> bool:
|
|
132
|
+
node = expr
|
|
133
|
+
while True:
|
|
134
|
+
if isinstance(node, ast.Call):
|
|
135
|
+
called = node.func
|
|
136
|
+
if isinstance(called, ast.Attribute) and called.attr == "getLogger":
|
|
137
|
+
return True
|
|
138
|
+
if isinstance(called, ast.Name) and called.id == "getLogger":
|
|
139
|
+
return True
|
|
140
|
+
node = called
|
|
141
|
+
elif isinstance(node, ast.Attribute):
|
|
142
|
+
node = node.value
|
|
143
|
+
else:
|
|
144
|
+
return False
|
|
145
|
+
|
|
146
|
+
|
|
103
147
|
def _interpolating_fstring(node: ast.expr) -> ast.JoinedStr | None:
|
|
104
148
|
"""Find an interpolating f-string in `node`, descending `+`-concat operands.
|
|
105
149
|
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""SARJ003: flag `if/elif isinstance(...)` chains that dispatch over a *local* closed union.
|
|
2
|
+
|
|
3
|
+
A chain of `if isinstance(x, A): ... elif isinstance(x, B): ... else: raise` where every
|
|
4
|
+
`A`, `B`, ... is a class **defined in this same module** and the chain terminates
|
|
5
|
+
exhaustively is dispatch over a locally-owned discriminated union. `match`/`case` with
|
|
6
|
+
`assert_never` in the fallthrough is strictly better: pyright reports an error the moment a
|
|
7
|
+
new variant is added and a branch is missed — a plain `isinstance` chain silently falls
|
|
8
|
+
through.
|
|
9
|
+
|
|
10
|
+
# flagged
|
|
11
|
+
class ApiKeySubject: ...
|
|
12
|
+
class JwtSubject: ...
|
|
13
|
+
|
|
14
|
+
if isinstance(subject, ApiKeySubject):
|
|
15
|
+
...
|
|
16
|
+
elif isinstance(subject, JwtSubject):
|
|
17
|
+
...
|
|
18
|
+
else:
|
|
19
|
+
assert_never(subject)
|
|
20
|
+
|
|
21
|
+
# preferred
|
|
22
|
+
match subject:
|
|
23
|
+
case ApiKeySubject():
|
|
24
|
+
...
|
|
25
|
+
case JwtSubject():
|
|
26
|
+
...
|
|
27
|
+
case _:
|
|
28
|
+
assert_never(subject)
|
|
29
|
+
|
|
30
|
+
The rule fires ONLY when both gates hold, because only then is a mechanical rewrite to an
|
|
31
|
+
exhaustive `match` both correct and beneficial:
|
|
32
|
+
|
|
33
|
+
1. **Local-union gate.** Every `isinstance` arm tests a bare `ast.Name` that resolves to an
|
|
34
|
+
`ast.ClassDef` in this module — not an imported name, not a dotted `pkg.Cls`, not a
|
|
35
|
+
builtin/stdlib type. Probing open-set types the module does not own
|
|
36
|
+
(`property`, `cached_property`, `Path`, `Decimal`, `dataclasses.Field`, ...) is a
|
|
37
|
+
legitimate runtime check, not closed-union dispatch, and is never flagged.
|
|
38
|
+
2. **Exhaustiveness gate.** The chain ends in a terminal `else`/final branch that raises,
|
|
39
|
+
returns, asserts, or calls an `assert_never`-style helper. An *open* chain — no `else`,
|
|
40
|
+
or a permissive `else` that silently falls through — is not equivalent to an exhaustive
|
|
41
|
+
`match` and must not be flagged, since converting it would change behavior.
|
|
42
|
+
|
|
43
|
+
This is still a heuristic (a locally-defined class could be re-exported, an imported class
|
|
44
|
+
could be the real union member). Suppress a deliberate boundary chain with
|
|
45
|
+
`# sarj-noqa: SARJ003 — <reason>`.
|
|
46
|
+
|
|
47
|
+
References:
|
|
48
|
+
- https://docs.python.org/3/library/typing.html#typing.assert_never
|
|
49
|
+
- https://typing.python.org/en/latest/spec/narrowing.html#assert-never-and-exhaustiveness-checking
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
from __future__ import annotations
|
|
53
|
+
|
|
54
|
+
import ast
|
|
55
|
+
from typing import TYPE_CHECKING, override
|
|
56
|
+
|
|
57
|
+
from sarj_python_lint.rule_base import Diagnostic, Rule, parse_or_none
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
if TYPE_CHECKING:
|
|
61
|
+
from pathlib import Path
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# A dispatch chain needs at least this many `isinstance` arms to be flagged.
|
|
65
|
+
_MIN_CHAIN_LENGTH = 2
|
|
66
|
+
|
|
67
|
+
# `isinstance(x, T)` takes exactly two positional arguments.
|
|
68
|
+
_ISINSTANCE_ARG_COUNT = 2
|
|
69
|
+
|
|
70
|
+
# Belt-and-suspenders: names that must never count as a local union member even if a
|
|
71
|
+
# same-named class happens to be defined in the module (a domain class named `Sequence`
|
|
72
|
+
# shadowing the ABC, a local `class Exception`, etc.). The primary gate is still
|
|
73
|
+
# "resolves to a local ClassDef"; this denylist only ever *removes* names from that set.
|
|
74
|
+
_EXCLUDED_TYPE_NAMES = frozenset(
|
|
75
|
+
{
|
|
76
|
+
"dict",
|
|
77
|
+
"str",
|
|
78
|
+
"list",
|
|
79
|
+
"tuple",
|
|
80
|
+
"set",
|
|
81
|
+
"frozenset",
|
|
82
|
+
"int",
|
|
83
|
+
"float",
|
|
84
|
+
"bool",
|
|
85
|
+
"complex",
|
|
86
|
+
"bytes",
|
|
87
|
+
"bytearray",
|
|
88
|
+
"type",
|
|
89
|
+
"object",
|
|
90
|
+
"Exception",
|
|
91
|
+
"BaseException",
|
|
92
|
+
"NoneType",
|
|
93
|
+
"Unset",
|
|
94
|
+
"datetime",
|
|
95
|
+
"date",
|
|
96
|
+
"time",
|
|
97
|
+
"timedelta",
|
|
98
|
+
"Mapping",
|
|
99
|
+
"MutableMapping",
|
|
100
|
+
"Sequence",
|
|
101
|
+
"MutableSequence",
|
|
102
|
+
"Iterable",
|
|
103
|
+
"Iterator",
|
|
104
|
+
"Collection",
|
|
105
|
+
"Container",
|
|
106
|
+
"Set",
|
|
107
|
+
"Hashable",
|
|
108
|
+
"Callable",
|
|
109
|
+
}
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class NoIsinstanceUnionChain(Rule):
|
|
114
|
+
"""`if/elif isinstance` chains over local classes — prefer match/case + assert_never."""
|
|
115
|
+
|
|
116
|
+
id: str = "no-isinstance-union-chain"
|
|
117
|
+
code: str = "SARJ003"
|
|
118
|
+
description: str = (
|
|
119
|
+
"if/elif isinstance chain over locally-defined classes with an exhaustive "
|
|
120
|
+
"terminal — prefer match/case with assert_never for compile-time exhaustiveness."
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
@override
|
|
124
|
+
def check(self, path: Path, source: str) -> list[Diagnostic]:
|
|
125
|
+
tree = parse_or_none(path, source)
|
|
126
|
+
if tree is None:
|
|
127
|
+
return []
|
|
128
|
+
local_classes = frozenset(
|
|
129
|
+
node.name for node in ast.walk(tree) if isinstance(node, ast.ClassDef)
|
|
130
|
+
)
|
|
131
|
+
elif_nodes: set[int] = set()
|
|
132
|
+
diags: list[Diagnostic] = []
|
|
133
|
+
for node in ast.walk(tree):
|
|
134
|
+
if not isinstance(node, ast.If):
|
|
135
|
+
continue
|
|
136
|
+
if len(node.orelse) == 1 and isinstance(node.orelse[0], ast.If):
|
|
137
|
+
elif_nodes.add(id(node.orelse[0]))
|
|
138
|
+
if id(node) in elif_nodes:
|
|
139
|
+
continue
|
|
140
|
+
count = _qualifying_chain_length(node, local_classes)
|
|
141
|
+
if count >= _MIN_CHAIN_LENGTH:
|
|
142
|
+
diags.append(
|
|
143
|
+
Diagnostic(
|
|
144
|
+
path=path,
|
|
145
|
+
line=node.lineno,
|
|
146
|
+
col=node.col_offset + 1,
|
|
147
|
+
code=self.code,
|
|
148
|
+
message=(
|
|
149
|
+
f"if/elif isinstance chain over {count} local classes — prefer "
|
|
150
|
+
"match/case with assert_never for exhaustiveness."
|
|
151
|
+
),
|
|
152
|
+
)
|
|
153
|
+
)
|
|
154
|
+
return diags
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _qualifying_chain_length(head: ast.If, local_classes: frozenset[str]) -> int:
|
|
158
|
+
"""Number of arms if `head` is a local-closed-union dispatch chain, else 0.
|
|
159
|
+
|
|
160
|
+
Requires every arm to be `isinstance(<same target>, <local ClassDef name>)` and the
|
|
161
|
+
chain to end in an exhaustive terminal `else` (raise / return / assert / assert_never).
|
|
162
|
+
"""
|
|
163
|
+
first_target: ast.expr | None = None
|
|
164
|
+
count = 0
|
|
165
|
+
current: ast.If | None = head
|
|
166
|
+
while current is not None:
|
|
167
|
+
parsed = _isinstance_single_type(current.test)
|
|
168
|
+
if parsed is None:
|
|
169
|
+
return 0
|
|
170
|
+
target, type_node = parsed
|
|
171
|
+
if not isinstance(type_node, ast.Name):
|
|
172
|
+
return 0
|
|
173
|
+
type_name = type_node.id
|
|
174
|
+
if type_name in _EXCLUDED_TYPE_NAMES or type_name not in local_classes:
|
|
175
|
+
return 0
|
|
176
|
+
if first_target is None:
|
|
177
|
+
first_target = target
|
|
178
|
+
elif not _ast_equal(target, first_target):
|
|
179
|
+
return 0
|
|
180
|
+
count += 1
|
|
181
|
+
orelse = current.orelse
|
|
182
|
+
if len(orelse) == 1 and isinstance(orelse[0], ast.If):
|
|
183
|
+
current = orelse[0]
|
|
184
|
+
else:
|
|
185
|
+
if not _is_exhaustive_terminal(orelse):
|
|
186
|
+
return 0
|
|
187
|
+
current = None
|
|
188
|
+
return count
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _is_exhaustive_terminal(orelse: list[ast.stmt]) -> bool:
|
|
192
|
+
"""True if the trailing `else` block terminates instead of silently falling through.
|
|
193
|
+
|
|
194
|
+
An open chain (no `else`) or a permissive `else` that just does work and continues is
|
|
195
|
+
NOT equivalent to an exhaustive `match`, so it does not qualify.
|
|
196
|
+
"""
|
|
197
|
+
if not orelse:
|
|
198
|
+
return False
|
|
199
|
+
return any(_stmt_terminates(stmt) for stmt in orelse)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _stmt_terminates(stmt: ast.stmt) -> bool:
|
|
203
|
+
match stmt:
|
|
204
|
+
case ast.Raise() | ast.Return() | ast.Assert():
|
|
205
|
+
return True
|
|
206
|
+
case ast.Expr(value=ast.Call(func=func)):
|
|
207
|
+
return _is_assert_never(func)
|
|
208
|
+
case _:
|
|
209
|
+
return False
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _is_assert_never(func: ast.expr) -> bool:
|
|
213
|
+
match func:
|
|
214
|
+
case ast.Name(id="assert_never"):
|
|
215
|
+
return True
|
|
216
|
+
case ast.Attribute(attr="assert_never"):
|
|
217
|
+
return True
|
|
218
|
+
case _:
|
|
219
|
+
return False
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _ast_equal(a: object, b: object) -> bool:
|
|
223
|
+
"""Structural equality equivalent to `ast.dump(a) == ast.dump(b)`, without building
|
|
224
|
+
the dump strings. Recursively compares node types and their `_fields`; leaves compare
|
|
225
|
+
by type + repr to mirror ast.dump's value rendering (e.g. `1` vs `1.0`, `True` vs `1`)
|
|
226
|
+
exactly while avoiding the per-subtree string allocation ast.dump pays."""
|
|
227
|
+
if isinstance(a, ast.AST):
|
|
228
|
+
if type(a) is not type(b):
|
|
229
|
+
return False
|
|
230
|
+
return all(_ast_equal(getattr(a, field, None), getattr(b, field, None)) for field in a._fields)
|
|
231
|
+
if isinstance(a, list):
|
|
232
|
+
if not isinstance(b, list) or len(a) != len(b):
|
|
233
|
+
return False
|
|
234
|
+
return all(map(_ast_equal, a, b, strict=True))
|
|
235
|
+
return type(a) is type(b) and repr(a) == repr(b)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _isinstance_single_type(test: ast.expr) -> tuple[ast.expr, ast.expr] | None:
|
|
239
|
+
"""If `test` is `isinstance(x, T)` with a single (non-tuple) type argument, return
|
|
240
|
+
(target, type_node); else None. Tuple-form `isinstance(x, (A, B))` returns a Tuple
|
|
241
|
+
type_node, which the caller rejects (not an `ast.Name`)."""
|
|
242
|
+
if not isinstance(test, ast.Call):
|
|
243
|
+
return None
|
|
244
|
+
if not (isinstance(test.func, ast.Name) and test.func.id == "isinstance"):
|
|
245
|
+
return None
|
|
246
|
+
if len(test.args) != _ISINSTANCE_ARG_COUNT or test.keywords:
|
|
247
|
+
return None
|
|
248
|
+
target, type_node = test.args
|
|
249
|
+
return target, type_node
|