sarj-python-lint 0.12.0__tar.gz → 0.12.1__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.1}/PKG-INFO +1 -1
- {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.1}/pyproject.toml +1 -1
- sarj_python_lint-0.12.1/src/sarj_python_lint/__init__.py +6 -0
- {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.1}/src/sarj_python_lint/__main__.py +7 -2
- {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.1}/src/sarj_python_lint/_secret_names.py +19 -5
- sarj_python_lint-0.12.1/src/sarj_python_lint/_version.py +20 -0
- {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.1}/src/sarj_python_lint/rule_base.py +18 -4
- {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.1}/src/sarj_python_lint/rules/_logging.py +5 -1
- {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.1}/src/sarj_python_lint/rules/_sql.py +15 -2
- {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.1}/src/sarj_python_lint/rules/inefficient_string_concat_in_loop.py +131 -12
- {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.1}/src/sarj_python_lint/rules/no_comment_cruft.py +9 -1
- {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.1}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +12 -2
- {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.1}/src/sarj_python_lint/rules/no_fat_try_blocks.py +53 -24
- {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.1}/src/sarj_python_lint/rules/no_fstring_in_log.py +15 -2
- {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.1}/src/sarj_python_lint/rules/no_isinstance_union_chain.py +28 -19
- {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.1}/src/sarj_python_lint/rules/no_offset_pagination.py +3 -1
- {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.1}/src/sarj_python_lint/rules/no_query_with_many_joins.py +3 -1
- {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.1}/src/sarj_python_lint/rules/no_secret_in_log.py +14 -4
- {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.1}/src/sarj_python_lint/rules/no_select_star.py +5 -1
- {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.1}/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +244 -48
- {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.1}/src/sarj_python_lint/rules/no_sequential_await.py +21 -3
- {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.1}/src/sarj_python_lint/rules/no_unreachable_after_terminal.py +9 -4
- {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.1}/src/sarj_python_lint/rules/prefer_class_row.py +7 -1
- sarj_python_lint-0.12.1/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +213 -0
- {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.1}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +32 -15
- {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.1}/src/sarj_python_lint/rules/prefer_str_enum.py +181 -9
- {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.1}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +1 -0
- {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.1}/src/sarj_python_lint/rules/prefer_timedelta_for_durations.py +9 -0
- {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.1}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +39 -6
- {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.1}/src/sarj_python_lint/rules/single_public_export.py +3 -2
- {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.1}/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.1}/.gitignore +0 -0
- {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.1}/README.md +0 -0
- {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.1}/src/sarj_python_lint/py.typed +0 -0
- {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.1}/src/sarj_python_lint/rules/__init__.py +0 -0
- {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.1}/src/sarj_python_lint/rules/_registry.py +0 -0
- {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.1}/src/sarj_python_lint/rules/no_aggregation_in_store_query.py +0 -0
- {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.1}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
- {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.1}/src/sarj_python_lint/rules/no_sleep_in_test_body.py +0 -0
- {sarj_python_lint-0.12.0 → sarj_python_lint-0.12.1}/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.1
|
|
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.1}/src/sarj_python_lint/rules/no_comment_cruft.py
RENAMED
|
@@ -150,12 +150,16 @@ def _looks_like_code(body: str) -> bool:
|
|
|
150
150
|
|
|
151
151
|
|
|
152
152
|
def _is_prose_line(body: str) -> bool:
|
|
153
|
-
"""
|
|
153
|
+
"""Report whether `body` reads as a natural-language sentence, not code.
|
|
154
154
|
|
|
155
155
|
Used to spot a doc/prose comment that immediately precedes a code-shaped
|
|
156
156
|
line: `# For example:` above `# result = {**a, **b}`, or a wrapped sentence
|
|
157
157
|
whose second line happens to parse as an expression. Such a line is an
|
|
158
158
|
illustration / prose continuation, not commented-out code.
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
True when `body` reads as prose.
|
|
162
|
+
|
|
159
163
|
"""
|
|
160
164
|
c = body.strip()
|
|
161
165
|
if not c or _is_banner(c) or _is_directive(c) or _looks_like_code(c):
|
|
@@ -272,6 +276,10 @@ def _standalone_comments(source: str) -> tuple[list[tuple[int, int, str]], int]:
|
|
|
272
276
|
|
|
273
277
|
A comment is standalone when it is the only content on its line. `first code
|
|
274
278
|
line` is the row of the first real code token (a large sentinel if none).
|
|
279
|
+
|
|
280
|
+
Returns:
|
|
281
|
+
The standalone comments and the first code line's row.
|
|
282
|
+
|
|
275
283
|
"""
|
|
276
284
|
out: list[tuple[int, int, str]] = []
|
|
277
285
|
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))
|
{sarj_python_lint-0.12.0 → sarj_python_lint-0.12.1}/src/sarj_python_lint/rules/no_fat_try_blocks.py
RENAMED
|
@@ -48,6 +48,7 @@ Prefer:
|
|
|
48
48
|
References:
|
|
49
49
|
- https://docs.python.org/3/tutorial/errors.html#handling-exceptions
|
|
50
50
|
- https://docs.python.org/3/library/ast.html#ast.Try
|
|
51
|
+
|
|
51
52
|
"""
|
|
52
53
|
|
|
53
54
|
from __future__ import annotations
|
|
@@ -66,31 +67,45 @@ if TYPE_CHECKING:
|
|
|
66
67
|
|
|
67
68
|
_MAX_TRY_BODY_STATEMENTS = 3
|
|
68
69
|
|
|
69
|
-
_NESTED_SCOPES = (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)
|
|
70
|
-
|
|
71
70
|
|
|
72
71
|
def _walk_same_scope(node: ast.AST) -> Iterator[ast.AST]:
|
|
73
|
-
"""
|
|
74
|
-
|
|
75
|
-
`try` executes, so calls inside
|
|
76
|
-
|
|
77
|
-
fields are walked normally.
|
|
72
|
+
"""Walk `node` without descending into nested `def` / `async def` / `lambda` bodies.
|
|
73
|
+
|
|
74
|
+
Those bodies do not run when the enclosing `try` executes, so calls inside
|
|
75
|
+
them must not count as throwing. Decorators and default-argument expressions
|
|
76
|
+
still run at definition time, so their fields are walked normally.
|
|
77
|
+
|
|
78
|
+
Yields:
|
|
79
|
+
Each AST node in the same execution scope as `node`.
|
|
80
|
+
|
|
81
|
+
"""
|
|
78
82
|
stack: list[ast.AST] = [node]
|
|
79
83
|
while stack:
|
|
80
84
|
current = stack.pop()
|
|
81
85
|
yield current
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
86
|
+
skipped_body = _nested_scope_body_ids(current)
|
|
87
|
+
stack.extend(child for child in ast.iter_child_nodes(current) if id(child) not in skipped_body)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _nested_scope_body_ids(node: ast.AST) -> frozenset[int]:
|
|
91
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
92
|
+
return frozenset(id(stmt) for stmt in node.body)
|
|
93
|
+
if isinstance(node, ast.Lambda):
|
|
94
|
+
return frozenset({id(node.body)})
|
|
95
|
+
return frozenset()
|
|
87
96
|
|
|
88
97
|
|
|
89
98
|
def _can_raise(stmt: ast.stmt) -> bool:
|
|
90
|
-
"""
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
99
|
+
"""Report whether the statement can plausibly raise when the `try` runs.
|
|
100
|
+
|
|
101
|
+
A statement can raise when its same-scope subtree contains a call or `await`.
|
|
102
|
+
Pure assignments / rebinds and inert `def` / `lambda` definitions (whose
|
|
103
|
+
bodies never execute here) are free.
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
True when the statement may throw.
|
|
107
|
+
|
|
108
|
+
"""
|
|
94
109
|
return any(isinstance(n, (ast.Call, ast.Await)) for n in _walk_same_scope(stmt))
|
|
95
110
|
|
|
96
111
|
|
|
@@ -101,9 +116,16 @@ class _Exit(enum.Enum):
|
|
|
101
116
|
|
|
102
117
|
|
|
103
118
|
def _stmt_exits(stmt: ast.stmt) -> set[_Exit]:
|
|
104
|
-
"""
|
|
105
|
-
|
|
106
|
-
|
|
119
|
+
"""Compute the set of ways control can leave `stmt`.
|
|
120
|
+
|
|
121
|
+
Control can propagate an exception (`RAISE`), diverge without raising via
|
|
122
|
+
return/break/continue (`SWALLOW`), or complete normally and fall through to
|
|
123
|
+
the next statement (`FALL`).
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
The set of exit modes for `stmt`.
|
|
127
|
+
|
|
128
|
+
"""
|
|
107
129
|
match stmt:
|
|
108
130
|
case ast.Raise():
|
|
109
131
|
return {_Exit.RAISE}
|
|
@@ -130,11 +152,18 @@ def _body_exits(stmts: list[ast.stmt]) -> set[_Exit]:
|
|
|
130
152
|
|
|
131
153
|
|
|
132
154
|
def _all_handlers_reraise(handlers: list[ast.ExceptHandler]) -> bool:
|
|
133
|
-
"""
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
155
|
+
"""Report whether every `except` handler is guaranteed to re-raise on all paths.
|
|
156
|
+
|
|
157
|
+
When it does, the block is uniform error-context/metric wrapping, not
|
|
158
|
+
swallowing, so its width is intentional. A handler with any path that returns
|
|
159
|
+
/ continues / passes / falls through (including a conditional early return
|
|
160
|
+
before a tail `raise`) is swallowing and makes this False, so the block still
|
|
161
|
+
fires.
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
True when all handlers unconditionally re-raise.
|
|
165
|
+
|
|
166
|
+
"""
|
|
138
167
|
return bool(handlers) and all(_body_exits(h.body) == {_Exit.RAISE} for h in handlers)
|
|
139
168
|
|
|
140
169
|
|