sarj-python-lint 0.12.0__tar.gz → 0.12.2__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.0 → sarj_python_lint-0.12.2}/PKG-INFO +1 -1
- {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.2}/pyproject.toml +1 -1
- sarj_python_lint-0.12.2/src/sarj_python_lint/__init__.py +6 -0
- {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.2}/src/sarj_python_lint/__main__.py +7 -2
- {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.2}/src/sarj_python_lint/_secret_names.py +19 -5
- sarj_python_lint-0.12.2/src/sarj_python_lint/_version.py +20 -0
- {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.2}/src/sarj_python_lint/rule_base.py +18 -4
- {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.2}/src/sarj_python_lint/rules/_logging.py +5 -1
- {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.2}/src/sarj_python_lint/rules/_sql.py +15 -2
- {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.2}/src/sarj_python_lint/rules/inefficient_string_concat_in_loop.py +131 -12
- {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.2}/src/sarj_python_lint/rules/no_comment_cruft.py +44 -1
- {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.2}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +12 -2
- {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.2}/src/sarj_python_lint/rules/no_fat_try_blocks.py +53 -24
- {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.2}/src/sarj_python_lint/rules/no_fstring_in_log.py +15 -2
- {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.2}/src/sarj_python_lint/rules/no_isinstance_union_chain.py +28 -19
- {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.2}/src/sarj_python_lint/rules/no_offset_pagination.py +3 -1
- {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.2}/src/sarj_python_lint/rules/no_query_with_many_joins.py +3 -1
- {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.2}/src/sarj_python_lint/rules/no_secret_in_log.py +14 -4
- {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.2}/src/sarj_python_lint/rules/no_select_star.py +5 -1
- {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.2}/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +244 -48
- {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.2}/src/sarj_python_lint/rules/no_sequential_await.py +21 -3
- {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.2}/src/sarj_python_lint/rules/no_unreachable_after_terminal.py +9 -4
- {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.2}/src/sarj_python_lint/rules/prefer_class_row.py +7 -1
- sarj_python_lint-0.12.2/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +213 -0
- {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.2}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +32 -15
- {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.2}/src/sarj_python_lint/rules/prefer_str_enum.py +181 -9
- {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.2}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +1 -0
- {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.2}/src/sarj_python_lint/rules/prefer_timedelta_for_durations.py +9 -0
- {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.2}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +39 -6
- {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.2}/src/sarj_python_lint/rules/single_public_export.py +3 -2
- {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.2}/src/sarj_python_lint/rules/stepdown.py +187 -33
- sarj_python_lint-0.12.0/src/sarj_python_lint/__init__.py +0 -9
- sarj_python_lint-0.12.0/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -105
- {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.2}/.gitignore +0 -0
- {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.2}/README.md +0 -0
- {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.2}/src/sarj_python_lint/py.typed +0 -0
- {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.2}/src/sarj_python_lint/rules/__init__.py +0 -0
- {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.2}/src/sarj_python_lint/rules/_registry.py +0 -0
- {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.2}/src/sarj_python_lint/rules/no_aggregation_in_store_query.py +0 -0
- {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.2}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
- {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.2}/src/sarj_python_lint/rules/no_sleep_in_test_body.py +0 -0
- {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.2}/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.12.
|
|
3
|
+
Version: 0.12.2
|
|
4
4
|
Summary: Custom Python lint rules — AST-based, pre-commit-friendly, hypermodern defaults
|
|
5
5
|
Project-URL: Homepage, https://github.com/sarj-ai/standards/tree/main/packages/python
|
|
6
6
|
Project-URL: Repository, https://github.com/sarj-ai/standards
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""CLI: sarj-python-lint check --rule <id> [--rule <id2>] [--baseline <json>] <files
|
|
1
|
+
"""CLI: sarj-python-lint check --rule <id> [--rule <id2>] [--baseline <json>] <files>."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
@@ -106,7 +106,12 @@ def _baseline_counts(diags: list[Diagnostic]) -> dict[str, dict[str, int]]:
|
|
|
106
106
|
|
|
107
107
|
|
|
108
108
|
def _apply_baseline(diags: list[Diagnostic], baseline: dict[str, dict[str, int]]) -> list[Diagnostic]:
|
|
109
|
-
"""Suppress up to the baselined count per (path, code); excess diags survive.
|
|
109
|
+
"""Suppress up to the baselined count per (path, code); excess diags survive.
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
The diagnostics that exceed the baselined count for their (path, code).
|
|
113
|
+
|
|
114
|
+
"""
|
|
110
115
|
seen: Counter[tuple[str, str]] = Counter()
|
|
111
116
|
out: list[Diagnostic] = []
|
|
112
117
|
for d in diags:
|
|
@@ -95,12 +95,16 @@ _CAMEL_RE = re.compile(r"[A-Z]+(?=[A-Z][a-z])|[A-Z]?[a-z]+|[A-Z]+|\d+")
|
|
|
95
95
|
_SEGMENT_RE = re.compile(r"[^A-Za-z0-9]+")
|
|
96
96
|
|
|
97
97
|
|
|
98
|
-
def
|
|
99
|
-
"""
|
|
98
|
+
def identifier_tokens(identifier: str) -> list[str]:
|
|
99
|
+
"""Return ordered lowercase tokens from snake_case + camelCase decomposition.
|
|
100
100
|
|
|
101
101
|
Also yields each whole snake/kebab segment lowercased, so a pathological
|
|
102
102
|
mixed-case single word like `ToKeN` (which camel-splitting shreds into
|
|
103
103
|
`to`/`ke`/`n`) still surfaces its intended `token` form.
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
The ordered lowercase tokens decomposed from `identifier`.
|
|
107
|
+
|
|
104
108
|
"""
|
|
105
109
|
tokens: list[str] = []
|
|
106
110
|
for segment in _SEGMENT_RE.split(identifier):
|
|
@@ -112,8 +116,13 @@ def _tokens(identifier: str) -> list[str]:
|
|
|
112
116
|
|
|
113
117
|
|
|
114
118
|
def is_secret_name(identifier: str) -> bool:
|
|
115
|
-
"""
|
|
116
|
-
|
|
119
|
+
"""Report whether `identifier` names raw secret material (a credential, not metadata).
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
True when `identifier` denotes a credential rather than metadata.
|
|
123
|
+
|
|
124
|
+
"""
|
|
125
|
+
tokens = identifier_tokens(identifier)
|
|
117
126
|
if tokens and tokens[-1] in _INNOCUOUS_WORDS:
|
|
118
127
|
return False
|
|
119
128
|
if any(tok in _SECRET_WORDS for tok in tokens):
|
|
@@ -122,5 +131,10 @@ def is_secret_name(identifier: str) -> bool:
|
|
|
122
131
|
|
|
123
132
|
|
|
124
133
|
def _has_api_key(tokens: list[str]) -> bool:
|
|
125
|
-
"""
|
|
134
|
+
"""Report whether `api` is immediately followed by `key` (the split form of `api_key`).
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
True when an `api` token is directly followed by a `key` token.
|
|
138
|
+
|
|
139
|
+
"""
|
|
126
140
|
return any(a == "api" and b == "key" for a, b in pairwise(tokens))
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Resolve the installed package version, with a source-tree fallback."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _resolve_version() -> str:
|
|
7
|
+
"""Return the installed distribution version, or a dev sentinel from source.
|
|
8
|
+
|
|
9
|
+
Returns:
|
|
10
|
+
The distribution version string, or ``"0.0.0.dev0"`` when the package is
|
|
11
|
+
not installed (running straight from a source checkout).
|
|
12
|
+
|
|
13
|
+
"""
|
|
14
|
+
try:
|
|
15
|
+
return version("sarj-python-lint")
|
|
16
|
+
except PackageNotFoundError:
|
|
17
|
+
return "0.0.0.dev0"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
__version__ = _resolve_version()
|
|
@@ -5,13 +5,13 @@ from __future__ import annotations
|
|
|
5
5
|
from abc import ABC, abstractmethod
|
|
6
6
|
import ast
|
|
7
7
|
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
8
9
|
import re
|
|
9
10
|
from typing import TYPE_CHECKING
|
|
10
11
|
|
|
11
12
|
|
|
12
13
|
if TYPE_CHECKING:
|
|
13
14
|
from collections.abc import Sequence
|
|
14
|
-
from pathlib import Path
|
|
15
15
|
|
|
16
16
|
|
|
17
17
|
# Suppression syntax. Two forms supported:
|
|
@@ -28,9 +28,13 @@ _SARJ_NOQA_RE = re.compile(
|
|
|
28
28
|
|
|
29
29
|
|
|
30
30
|
def is_suppressed(source_lines: Sequence[str], line: int, code: str) -> bool:
|
|
31
|
-
"""
|
|
31
|
+
"""Report whether the diagnostic's line carries a `# sarj-noqa[: CODE]` comment.
|
|
32
32
|
|
|
33
33
|
`line` is 1-based to match Diagnostic.line.
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
True when the line is suppressed for `code`.
|
|
37
|
+
|
|
34
38
|
"""
|
|
35
39
|
if line < 1 or line > len(source_lines):
|
|
36
40
|
return False
|
|
@@ -57,7 +61,12 @@ class Diagnostic:
|
|
|
57
61
|
message: str
|
|
58
62
|
|
|
59
63
|
def format(self) -> str:
|
|
60
|
-
"""
|
|
64
|
+
"""Render the finding ruff-compatibly as `path:line:col: CODE message`.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
The formatted single-line diagnostic string.
|
|
68
|
+
|
|
69
|
+
"""
|
|
61
70
|
return f"{self.path}:{self.line}:{self.col}: {self.code} {self.message}"
|
|
62
71
|
|
|
63
72
|
|
|
@@ -82,7 +91,12 @@ _last_parse: tuple[tuple[str, int, int], ast.Module | None] | None = None
|
|
|
82
91
|
|
|
83
92
|
|
|
84
93
|
def parse_or_none(path: Path, source: str) -> ast.Module | None:
|
|
85
|
-
"""Parse `source`, memoizing the most recent file so N rules share one parse.
|
|
94
|
+
"""Parse `source`, memoizing the most recent file so N rules share one parse.
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
The parsed module, or None when `source` has a syntax error.
|
|
98
|
+
|
|
99
|
+
"""
|
|
86
100
|
global _last_parse # ruff:ignore[global-statement] — single-slot memo; the CLI runs rules per file sequentially
|
|
87
101
|
key = (str(path), len(source), hash(source))
|
|
88
102
|
if _last_parse is not None and _last_parse[0] == key:
|
|
@@ -16,11 +16,15 @@ _LOGGER_FACTORIES = frozenset({"getlogger", "get_logger"})
|
|
|
16
16
|
|
|
17
17
|
|
|
18
18
|
def is_logger_expr(expr: ast.expr) -> bool:
|
|
19
|
-
"""
|
|
19
|
+
"""Report whether `expr` evaluates to a logger.
|
|
20
20
|
|
|
21
21
|
Resolves the whole receiver chain so adapter/builder/factory calls are
|
|
22
22
|
caught: `logger.bind(...).info(...)`, `logger.opt(lazy=True).debug(...)`,
|
|
23
23
|
`logging.getLogger(__name__).info(...)`, `self.logger.error(...)`.
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
True when `expr` resolves to a logger receiver.
|
|
27
|
+
|
|
24
28
|
"""
|
|
25
29
|
if isinstance(expr, ast.Name):
|
|
26
30
|
return expr.id.lower() in _LOGGER_NAMES
|
|
@@ -19,19 +19,28 @@ if TYPE_CHECKING:
|
|
|
19
19
|
|
|
20
20
|
|
|
21
21
|
def is_store_module(path: Path) -> bool:
|
|
22
|
-
"""
|
|
22
|
+
"""Report whether `path` is a store-layer module: basename ends `_store.py`, or lives under a `stores/` directory.
|
|
23
23
|
|
|
24
24
|
The SQL store-lint rules (SARJ018/020/021) encode store-write semantics —
|
|
25
25
|
column-naming, ON-CONFLICT upserts, no Postgres-side aggregation — that only
|
|
26
26
|
apply to the store layer. Non-store SQL (Flask view handlers, a Django ORM
|
|
27
27
|
SQL generator) legitimately writes `SELECT *`, bare `INSERT`, and `COUNT()`,
|
|
28
28
|
so those files are out of scope.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
True when `path` belongs to the store layer.
|
|
32
|
+
|
|
29
33
|
"""
|
|
30
34
|
return path.name.endswith("_store.py") or "stores" in path.parts
|
|
31
35
|
|
|
32
36
|
|
|
33
37
|
def sql_string_value(node: ast.expr) -> str | None:
|
|
34
|
-
"""Reconstruct a (possibly `+`-concatenated) string literal, else None.
|
|
38
|
+
"""Reconstruct a (possibly `+`-concatenated) string literal, else None.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
The reconstructed string, or None when `node` is not a string literal.
|
|
42
|
+
|
|
43
|
+
"""
|
|
35
44
|
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
|
36
45
|
return node.value
|
|
37
46
|
if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add):
|
|
@@ -52,6 +61,10 @@ def strip_sql_noise(text: str) -> str:
|
|
|
52
61
|
preserved so line offsets — and therefore diagnostic positions — do not
|
|
53
62
|
shift. Doubled quotes (`''` / `""`) are SQL's in-string escape and keep the
|
|
54
63
|
scanner inside the literal.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
`text` with string-literal contents and comment bodies blanked out.
|
|
67
|
+
|
|
55
68
|
"""
|
|
56
69
|
out = list(text)
|
|
57
70
|
n = len(text)
|
|
@@ -12,20 +12,29 @@ are either the prescribed remedy or a bounded per-iteration transform, not the
|
|
|
12
12
|
O(n²) defect. Per-slot writes (`parts[i] = ...`) and idempotent rebinding
|
|
13
13
|
(`x = f(x)`) are likewise excluded.
|
|
14
14
|
|
|
15
|
+
A target that is freshly (re)bound earlier in the same loop body — `desc = ...`
|
|
16
|
+
then `desc += suffix`, or a tuple unpack `obj, path = q.popleft()` then
|
|
17
|
+
`path += ...` — is loop-local: it starts empty each iteration, so the growth is
|
|
18
|
+
bounded, not cross-iteration accumulation. Only a target initialised BEFORE the
|
|
19
|
+
loop is a true O(n²) accumulator, so a preceding non-accumulating rebind of the
|
|
20
|
+
target inside the loop suppresses the diagnostic.
|
|
21
|
+
|
|
15
22
|
References:
|
|
16
23
|
- https://docs.python.org/3/library/stdtypes.html#str.join
|
|
17
24
|
- https://wiki.python.org/moin/PythonSpeed/PerformanceTips
|
|
25
|
+
|
|
18
26
|
"""
|
|
19
27
|
|
|
20
28
|
from __future__ import annotations
|
|
21
29
|
|
|
22
30
|
import ast
|
|
23
|
-
from typing import TYPE_CHECKING, override
|
|
31
|
+
from typing import TYPE_CHECKING, TypeGuard, override
|
|
24
32
|
|
|
25
33
|
from sarj_python_lint.rule_base import Diagnostic, Rule, parse_or_none
|
|
26
34
|
|
|
27
35
|
|
|
28
36
|
if TYPE_CHECKING:
|
|
37
|
+
from collections.abc import Iterator
|
|
29
38
|
from pathlib import Path
|
|
30
39
|
|
|
31
40
|
|
|
@@ -61,6 +70,7 @@ class _ConcatVisitor(ast.NodeVisitor):
|
|
|
61
70
|
def __init__(self) -> None:
|
|
62
71
|
self._loop_depth: int = 0
|
|
63
72
|
self._string_vars: list[frozenset[str]] = [frozenset()]
|
|
73
|
+
self._loop_reassigns: list[dict[str, list[int]]] = []
|
|
64
74
|
self.hits: list[ast.AugAssign | ast.Assign] = []
|
|
65
75
|
|
|
66
76
|
@override
|
|
@@ -75,14 +85,38 @@ class _ConcatVisitor(ast.NodeVisitor):
|
|
|
75
85
|
return
|
|
76
86
|
if isinstance(node, (ast.For, ast.AsyncFor, ast.While)):
|
|
77
87
|
self._loop_depth += 1
|
|
88
|
+
self._loop_reassigns.append(_loop_local_reassignments(node))
|
|
78
89
|
super().generic_visit(node)
|
|
90
|
+
self._loop_reassigns.pop()
|
|
79
91
|
self._loop_depth -= 1
|
|
80
92
|
return
|
|
81
|
-
if self._loop_depth and self._is_in_loop_concat(node):
|
|
93
|
+
if self._loop_depth and self._is_in_loop_concat(node) and not self._is_loop_local_target(node):
|
|
82
94
|
self.hits.append(node)
|
|
83
95
|
super().generic_visit(node)
|
|
84
96
|
|
|
85
|
-
def
|
|
97
|
+
def _is_loop_local_target(self, node: ast.AugAssign | ast.Assign) -> bool:
|
|
98
|
+
"""Report whether the concat target is freshly rebound earlier this iteration.
|
|
99
|
+
|
|
100
|
+
A target rebound (not self-accumulated) before the concat inside the same
|
|
101
|
+
innermost loop body starts empty each pass, so its growth is bounded.
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
True when the target is loop-local rather than a cross-iteration accumulator.
|
|
105
|
+
|
|
106
|
+
"""
|
|
107
|
+
target = self._accumulation_target(node)
|
|
108
|
+
rebinds = self._loop_reassigns[-1].get(ast.unparse(target), ())
|
|
109
|
+
return any(line < node.lineno for line in rebinds)
|
|
110
|
+
|
|
111
|
+
def _accumulation_target(self, node: ast.AugAssign | ast.Assign) -> ast.expr:
|
|
112
|
+
if isinstance(node, ast.AugAssign):
|
|
113
|
+
return node.target
|
|
114
|
+
for target in node.targets:
|
|
115
|
+
if self._is_self_add_growth(target, node.value):
|
|
116
|
+
return target
|
|
117
|
+
return node.targets[0]
|
|
118
|
+
|
|
119
|
+
def _is_in_loop_concat(self, node: ast.AST) -> TypeGuard[ast.AugAssign | ast.Assign]:
|
|
86
120
|
if isinstance(node, ast.AugAssign):
|
|
87
121
|
return isinstance(node.op, ast.Add) and self._is_string_growth(node.target, node.value)
|
|
88
122
|
if isinstance(node, ast.Assign):
|
|
@@ -90,7 +124,12 @@ class _ConcatVisitor(ast.NodeVisitor):
|
|
|
90
124
|
return False
|
|
91
125
|
|
|
92
126
|
def _is_self_add_growth(self, target: ast.expr, value: ast.expr) -> bool:
|
|
93
|
-
"""`s = s + <str>`
|
|
127
|
+
"""Report whether `s = s + <str>` rebinds the target to itself-plus-more.
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
True when the assignment is a BinOp(Add) accumulation onto the target.
|
|
131
|
+
|
|
132
|
+
"""
|
|
94
133
|
if not isinstance(value, ast.BinOp) or not isinstance(value.op, ast.Add):
|
|
95
134
|
return False
|
|
96
135
|
other = _other_add_operand(target, value)
|
|
@@ -99,7 +138,12 @@ class _ConcatVisitor(ast.NodeVisitor):
|
|
|
99
138
|
return self._is_string_growth(target, other)
|
|
100
139
|
|
|
101
140
|
def _is_string_growth(self, target: ast.expr, rhs: ast.expr) -> bool:
|
|
102
|
-
"""
|
|
141
|
+
"""Report whether appending `rhs` to `target` is single-string accumulation.
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
True when the append grows a string-typed target.
|
|
145
|
+
|
|
146
|
+
"""
|
|
103
147
|
if isinstance(target, ast.Subscript):
|
|
104
148
|
return False
|
|
105
149
|
if _looks_like_string(rhs):
|
|
@@ -109,8 +153,76 @@ class _ConcatVisitor(ast.NodeVisitor):
|
|
|
109
153
|
return False
|
|
110
154
|
|
|
111
155
|
|
|
156
|
+
def _loop_local_reassignments(loop: ast.For | ast.AsyncFor | ast.While) -> dict[str, list[int]]:
|
|
157
|
+
"""Map each target rebound inside this loop's own body to the lines that rebind it.
|
|
158
|
+
|
|
159
|
+
Only rebinds that are NOT self-accumulation (`s = s + x`) count — those are the
|
|
160
|
+
defect itself, not a fresh reset. Nested loops / functions / classes are their
|
|
161
|
+
own scope and are excluded.
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
Target source string → line numbers where it is freshly (re)bound.
|
|
165
|
+
|
|
166
|
+
"""
|
|
167
|
+
reassigns: dict[str, list[int]] = {}
|
|
168
|
+
for stmt in loop.body:
|
|
169
|
+
_collect_reassignments(stmt, reassigns)
|
|
170
|
+
return reassigns
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _collect_reassignments(node: ast.AST, reassigns: dict[str, list[int]]) -> None:
|
|
174
|
+
if isinstance(
|
|
175
|
+
node,
|
|
176
|
+
(ast.For, ast.AsyncFor, ast.While, ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda, ast.ClassDef),
|
|
177
|
+
):
|
|
178
|
+
return
|
|
179
|
+
if isinstance(node, ast.Assign):
|
|
180
|
+
for target in node.targets:
|
|
181
|
+
for bound in _iter_binding_targets(target):
|
|
182
|
+
if not _is_accumulation_assign(bound, node.value):
|
|
183
|
+
reassigns.setdefault(ast.unparse(bound), []).append(bound.lineno)
|
|
184
|
+
elif (
|
|
185
|
+
isinstance(node, ast.AnnAssign)
|
|
186
|
+
and node.value is not None
|
|
187
|
+
and not _is_accumulation_assign(node.target, node.value)
|
|
188
|
+
):
|
|
189
|
+
reassigns.setdefault(ast.unparse(node.target), []).append(node.target.lineno)
|
|
190
|
+
for child in ast.iter_child_nodes(node):
|
|
191
|
+
_collect_reassignments(child, reassigns)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _iter_binding_targets(target: ast.expr) -> Iterator[ast.Name | ast.Attribute]:
|
|
195
|
+
"""Yield the Name / Attribute leaves a binding target rebinds.
|
|
196
|
+
|
|
197
|
+
Subscript leaves (`acc[i] = ...`) are per-slot writes, not a rebind of the
|
|
198
|
+
accumulator itself, so they are skipped.
|
|
199
|
+
|
|
200
|
+
Yields:
|
|
201
|
+
Each Name / Attribute node the target binds.
|
|
202
|
+
|
|
203
|
+
"""
|
|
204
|
+
if isinstance(target, (ast.Tuple, ast.List)):
|
|
205
|
+
for elt in target.elts:
|
|
206
|
+
yield from _iter_binding_targets(elt)
|
|
207
|
+
elif isinstance(target, ast.Starred):
|
|
208
|
+
yield from _iter_binding_targets(target.value)
|
|
209
|
+
elif isinstance(target, (ast.Name, ast.Attribute)):
|
|
210
|
+
yield target
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _is_accumulation_assign(target: ast.expr, value: ast.expr) -> bool:
|
|
214
|
+
if isinstance(value, ast.BinOp) and isinstance(value.op, ast.Add):
|
|
215
|
+
return _other_add_operand(target, value) is not None
|
|
216
|
+
return False
|
|
217
|
+
|
|
218
|
+
|
|
112
219
|
def _other_add_operand(target: ast.expr, binop: ast.BinOp) -> ast.expr | None:
|
|
113
|
-
"""
|
|
220
|
+
"""Return the non-target operand of `target + x` / `x + target`.
|
|
221
|
+
|
|
222
|
+
Returns:
|
|
223
|
+
The other operand, or None if neither side matches the target.
|
|
224
|
+
|
|
225
|
+
"""
|
|
114
226
|
target_src = ast.unparse(target)
|
|
115
227
|
if ast.unparse(binop.left) == target_src:
|
|
116
228
|
return binop.right
|
|
@@ -119,17 +231,20 @@ def _other_add_operand(target: ast.expr, binop: ast.BinOp) -> ast.expr | None:
|
|
|
119
231
|
return None
|
|
120
232
|
|
|
121
233
|
|
|
122
|
-
def _string_typed_locals(func: ast.
|
|
123
|
-
"""
|
|
234
|
+
def _string_typed_locals(func: ast.FunctionDef | ast.AsyncFunctionDef | ast.Lambda) -> frozenset[str]:
|
|
235
|
+
"""Collect names assigned a string-literal-ish value in this function's own body.
|
|
124
236
|
|
|
125
237
|
Used as the string-typed signal for bare-`Name` accumulation (`buf += line`):
|
|
126
238
|
a numeric accumulator (`total = 0`) is absent, so `total += x` stays clean.
|
|
239
|
+
|
|
240
|
+
Returns:
|
|
241
|
+
The frozenset of locally string-typed names.
|
|
242
|
+
|
|
127
243
|
"""
|
|
128
|
-
|
|
129
|
-
if not isinstance(body, list):
|
|
244
|
+
if isinstance(func, ast.Lambda):
|
|
130
245
|
return frozenset()
|
|
131
246
|
names: set[str] = set()
|
|
132
|
-
for stmt in body:
|
|
247
|
+
for stmt in func.body:
|
|
133
248
|
_collect_string_targets(stmt, names)
|
|
134
249
|
return frozenset(names)
|
|
135
250
|
|
|
@@ -153,11 +268,15 @@ def _collect_string_targets(node: ast.AST, names: set[str]) -> None:
|
|
|
153
268
|
|
|
154
269
|
|
|
155
270
|
def _looks_like_string(node: ast.AST) -> bool:
|
|
156
|
-
"""
|
|
271
|
+
"""Report whether this expression is obviously a string at runtime.
|
|
157
272
|
|
|
158
273
|
Deliberately conservative: a bare call (`str(x)`, `",".join(...)`,
|
|
159
274
|
`os.path.join(...)`) is NOT treated as a string — those shapes also appear in
|
|
160
275
|
benign one-shot reassignment and are not the accumulation defect.
|
|
276
|
+
|
|
277
|
+
Returns:
|
|
278
|
+
True when the expression is heuristically string-typed.
|
|
279
|
+
|
|
161
280
|
"""
|
|
162
281
|
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
|
163
282
|
return True
|
{sarj_python_lint-0.12.0 → sarj_python_lint-0.12.2}/src/sarj_python_lint/rules/no_comment_cruft.py
RENAMED
|
@@ -99,6 +99,26 @@ _ASSIGN_OR_CALL_RE = re.compile(r"^[A-Za-z_][\w.\[\]]*\s*(?:=|:=|\+=|-=|\*=|/=)\
|
|
|
99
99
|
# illustration inside a doc comment, not a line that was once executed.
|
|
100
100
|
_PSEUDOCODE_RE = re.compile(r"%[^%\s]+%|\[opt\]|<[^<>]+>|\.\.\.")
|
|
101
101
|
|
|
102
|
+
# Step-narration lead-ins ("First, ...", "Then, ...", "Finally, ...", "Step 2:").
|
|
103
|
+
# A trailing comma/colon is required so English adverbs ("finally the invariant
|
|
104
|
+
# holds") aren't mistaken for an enumeration marker.
|
|
105
|
+
_STEP_NARRATION_RE = re.compile(
|
|
106
|
+
r"^(?:first(?:ly)?|second(?:ly)?|third(?:ly)?|then|next|after(?:wards| that)?"
|
|
107
|
+
r"|finally|lastly|now)\s*[,:]\s*\S|^step\s+\d+\b",
|
|
108
|
+
re.IGNORECASE,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
# Self-admitted meta-commentary — the "why later", not the why. Owner-tagged
|
|
112
|
+
# directive markers are handled elsewhere (as directives) and kept.
|
|
113
|
+
_META_COMMENTARY_RE = re.compile(
|
|
114
|
+
r"\b(?:for now|keeping (?:it|this) simple|could be (?:refactored|improved|cleaned up|simplified)"
|
|
115
|
+
r"|refactor(?:ed|ing)? (?:later|this)|not sure (?:if|whether|why|how)"
|
|
116
|
+
r"|quick[- ](?:and[- ]dirty|fix)|(?:a |bit of a )?hacky|is a hack"
|
|
117
|
+
r"|temporary (?:solution|workaround|fix|hack)|revisit (?:this|later|below)"
|
|
118
|
+
r"|clean (?:this|it) up|not ideal|placeholder for now)\b",
|
|
119
|
+
re.IGNORECASE,
|
|
120
|
+
)
|
|
121
|
+
|
|
102
122
|
|
|
103
123
|
def _comment_body(raw: str) -> str:
|
|
104
124
|
return raw.lstrip("#").strip()
|
|
@@ -120,6 +140,19 @@ def _is_directive(body: str) -> bool:
|
|
|
120
140
|
return False
|
|
121
141
|
|
|
122
142
|
|
|
143
|
+
def _is_redundant_narration(body: str) -> bool:
|
|
144
|
+
"""Whether a comment merely narrates the code (step markers, meta-commentary).
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
True for step-narration lead-ins and self-admitted meta-commentary.
|
|
148
|
+
|
|
149
|
+
"""
|
|
150
|
+
c = body.strip()
|
|
151
|
+
if not c or _looks_like_code(c):
|
|
152
|
+
return False
|
|
153
|
+
return bool(_STEP_NARRATION_RE.search(c) or _META_COMMENTARY_RE.search(c))
|
|
154
|
+
|
|
155
|
+
|
|
123
156
|
def _is_banner(body: str) -> bool:
|
|
124
157
|
if not body:
|
|
125
158
|
return False
|
|
@@ -150,12 +183,16 @@ def _looks_like_code(body: str) -> bool:
|
|
|
150
183
|
|
|
151
184
|
|
|
152
185
|
def _is_prose_line(body: str) -> bool:
|
|
153
|
-
"""
|
|
186
|
+
"""Report whether `body` reads as a natural-language sentence, not code.
|
|
154
187
|
|
|
155
188
|
Used to spot a doc/prose comment that immediately precedes a code-shaped
|
|
156
189
|
line: `# For example:` above `# result = {**a, **b}`, or a wrapped sentence
|
|
157
190
|
whose second line happens to parse as an expression. Such a line is an
|
|
158
191
|
illustration / prose continuation, not commented-out code.
|
|
192
|
+
|
|
193
|
+
Returns:
|
|
194
|
+
True when `body` reads as prose.
|
|
195
|
+
|
|
159
196
|
"""
|
|
160
197
|
c = body.strip()
|
|
161
198
|
if not c or _is_banner(c) or _is_directive(c) or _looks_like_code(c):
|
|
@@ -223,6 +260,8 @@ class NoCommentCruft(Rule):
|
|
|
223
260
|
if prev_body is not None and _is_prose_line(prev_body):
|
|
224
261
|
return None
|
|
225
262
|
return "Commented-out code — delete it; git history remembers."
|
|
263
|
+
if _is_redundant_narration(body):
|
|
264
|
+
return "Comment narrates the code — delete it or say why, not what. Code is self-documenting."
|
|
226
265
|
return None
|
|
227
266
|
|
|
228
267
|
def _flag_leading_preamble(
|
|
@@ -272,6 +311,10 @@ def _standalone_comments(source: str) -> tuple[list[tuple[int, int, str]], int]:
|
|
|
272
311
|
|
|
273
312
|
A comment is standalone when it is the only content on its line. `first code
|
|
274
313
|
line` is the row of the first real code token (a large sentinel if none).
|
|
314
|
+
|
|
315
|
+
Returns:
|
|
316
|
+
The standalone comments and the first code line's row.
|
|
317
|
+
|
|
275
318
|
"""
|
|
276
319
|
out: list[tuple[int, int, str]] = []
|
|
277
320
|
first_code_line = 1 << 30
|
|
@@ -15,6 +15,7 @@ would be a valid further tightening).
|
|
|
15
15
|
|
|
16
16
|
References:
|
|
17
17
|
- https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#credentialed_requests_and_wildcards
|
|
18
|
+
|
|
18
19
|
"""
|
|
19
20
|
|
|
20
21
|
from __future__ import annotations
|
|
@@ -75,15 +76,24 @@ class NoCorsWildcardWithCredentials(Rule):
|
|
|
75
76
|
|
|
76
77
|
|
|
77
78
|
def _is_true_literal(node: ast.expr) -> bool:
|
|
78
|
-
"""
|
|
79
|
+
"""Report whether `node` is the literal `True` (not `1`, not a truthy expression).
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
True only when `node` is the `True` constant.
|
|
83
|
+
|
|
84
|
+
"""
|
|
79
85
|
return isinstance(node, ast.Constant) and node.value is True
|
|
80
86
|
|
|
81
87
|
|
|
82
88
|
def _contains_star_literal(node: ast.expr) -> bool:
|
|
83
|
-
"""
|
|
89
|
+
"""Report whether a `"*"` string `Constant` appears anywhere in `node`'s subtree.
|
|
84
90
|
|
|
85
91
|
Walking the whole subtree catches both `["*"]` and the `allowed if flag else
|
|
86
92
|
["*"]` conditional branch. A dynamic `allow_origins=some_var` has no `"*"`
|
|
87
93
|
literal, so it does not fire.
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
True when a `"*"` literal appears in the subtree.
|
|
97
|
+
|
|
88
98
|
"""
|
|
89
99
|
return any(isinstance(child, ast.Constant) and child.value == "*" for child in ast.walk(node))
|