sarj-python-lint 0.10.1__tar.gz → 0.11.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.10.1 → sarj_python_lint-0.11.0}/PKG-INFO +1 -1
- {sarj_python_lint-0.10.1 → sarj_python_lint-0.11.0}/pyproject.toml +1 -1
- {sarj_python_lint-0.10.1 → sarj_python_lint-0.11.0}/src/sarj_python_lint/rules/_registry.py +18 -0
- sarj_python_lint-0.11.0/src/sarj_python_lint/rules/json_response_not_parsed.py +110 -0
- sarj_python_lint-0.11.0/src/sarj_python_lint/rules/len_as_truthiness.py +126 -0
- sarj_python_lint-0.11.0/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +89 -0
- sarj_python_lint-0.11.0/src/sarj_python_lint/rules/no_manual_log_prefix.py +146 -0
- sarj_python_lint-0.11.0/src/sarj_python_lint/rules/no_offset_pagination.py +98 -0
- sarj_python_lint-0.11.0/src/sarj_python_lint/rules/no_sleep_in_test_body.py +146 -0
- sarj_python_lint-0.11.0/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +156 -0
- {sarj_python_lint-0.10.1 → sarj_python_lint-0.11.0}/.gitignore +0 -0
- {sarj_python_lint-0.10.1 → sarj_python_lint-0.11.0}/README.md +0 -0
- {sarj_python_lint-0.10.1 → sarj_python_lint-0.11.0}/src/sarj_python_lint/__init__.py +0 -0
- {sarj_python_lint-0.10.1 → sarj_python_lint-0.11.0}/src/sarj_python_lint/__main__.py +0 -0
- {sarj_python_lint-0.10.1 → sarj_python_lint-0.11.0}/src/sarj_python_lint/_secret_names.py +0 -0
- {sarj_python_lint-0.10.1 → sarj_python_lint-0.11.0}/src/sarj_python_lint/py.typed +0 -0
- {sarj_python_lint-0.10.1 → sarj_python_lint-0.11.0}/src/sarj_python_lint/rule_base.py +0 -0
- {sarj_python_lint-0.10.1 → sarj_python_lint-0.11.0}/src/sarj_python_lint/rules/__init__.py +0 -0
- {sarj_python_lint-0.10.1 → sarj_python_lint-0.11.0}/src/sarj_python_lint/rules/_logging.py +0 -0
- {sarj_python_lint-0.10.1 → sarj_python_lint-0.11.0}/src/sarj_python_lint/rules/_sql.py +0 -0
- {sarj_python_lint-0.10.1 → sarj_python_lint-0.11.0}/src/sarj_python_lint/rules/inefficient_string_concat_in_loop.py +0 -0
- {sarj_python_lint-0.10.1 → sarj_python_lint-0.11.0}/src/sarj_python_lint/rules/no_aggregation_in_store_query.py +0 -0
- {sarj_python_lint-0.10.1 → sarj_python_lint-0.11.0}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
- {sarj_python_lint-0.10.1 → sarj_python_lint-0.11.0}/src/sarj_python_lint/rules/no_fat_try_blocks.py +0 -0
- {sarj_python_lint-0.10.1 → sarj_python_lint-0.11.0}/src/sarj_python_lint/rules/no_fstring_in_log.py +0 -0
- {sarj_python_lint-0.10.1 → sarj_python_lint-0.11.0}/src/sarj_python_lint/rules/no_isinstance_union_chain.py +0 -0
- {sarj_python_lint-0.10.1 → sarj_python_lint-0.11.0}/src/sarj_python_lint/rules/no_query_with_many_joins.py +0 -0
- {sarj_python_lint-0.10.1 → sarj_python_lint-0.11.0}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
- {sarj_python_lint-0.10.1 → sarj_python_lint-0.11.0}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
- {sarj_python_lint-0.10.1 → sarj_python_lint-0.11.0}/src/sarj_python_lint/rules/no_select_star.py +0 -0
- {sarj_python_lint-0.10.1 → sarj_python_lint-0.11.0}/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +0 -0
- {sarj_python_lint-0.10.1 → sarj_python_lint-0.11.0}/src/sarj_python_lint/rules/no_sequential_await.py +0 -0
- {sarj_python_lint-0.10.1 → sarj_python_lint-0.11.0}/src/sarj_python_lint/rules/no_unreachable_after_terminal.py +0 -0
- {sarj_python_lint-0.10.1 → sarj_python_lint-0.11.0}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
- {sarj_python_lint-0.10.1 → sarj_python_lint-0.11.0}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
- {sarj_python_lint-0.10.1 → sarj_python_lint-0.11.0}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
- {sarj_python_lint-0.10.1 → sarj_python_lint-0.11.0}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
- {sarj_python_lint-0.10.1 → sarj_python_lint-0.11.0}/src/sarj_python_lint/rules/prefer_timedelta_for_durations.py +0 -0
- {sarj_python_lint-0.10.1 → sarj_python_lint-0.11.0}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +0 -0
- {sarj_python_lint-0.10.1 → sarj_python_lint-0.11.0}/src/sarj_python_lint/rules/single_public_export.py +0 -0
- {sarj_python_lint-0.10.1 → sarj_python_lint-0.11.0}/src/sarj_python_lint/rules/stepdown.py +0 -0
- {sarj_python_lint-0.10.1 → sarj_python_lint-0.11.0}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sarj-python-lint
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.11.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,19 +5,27 @@ 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
|
|
8
10
|
from sarj_python_lint.rules.no_aggregation_in_store_query import (
|
|
9
11
|
NoAggregationInStoreQuery,
|
|
10
12
|
)
|
|
11
13
|
from sarj_python_lint.rules.no_comment_cruft import NoCommentCruft
|
|
14
|
+
from sarj_python_lint.rules.no_cors_wildcard_with_credentials import (
|
|
15
|
+
NoCorsWildcardWithCredentials,
|
|
16
|
+
)
|
|
12
17
|
from sarj_python_lint.rules.no_fat_try_blocks import NoFatTryBlocks
|
|
13
18
|
from sarj_python_lint.rules.no_fstring_in_log import NoFstringInLog
|
|
14
19
|
from sarj_python_lint.rules.no_isinstance_union_chain import NoIsinstanceUnionChain
|
|
20
|
+
from sarj_python_lint.rules.no_manual_log_prefix import NoManualLogPrefix
|
|
21
|
+
from sarj_python_lint.rules.no_offset_pagination import NoOffsetPagination
|
|
15
22
|
from sarj_python_lint.rules.no_query_with_many_joins import NoQueryWithManyJoins
|
|
16
23
|
from sarj_python_lint.rules.no_repeated_string_literal import NoRepeatedStringLiteral
|
|
17
24
|
from sarj_python_lint.rules.no_secret_in_log import NoSecretInLog
|
|
18
25
|
from sarj_python_lint.rules.no_select_star import NoSelectStar
|
|
19
26
|
from sarj_python_lint.rules.no_sentinel_return_on_except import NoSentinelReturnOnExcept
|
|
20
27
|
from sarj_python_lint.rules.no_sequential_await import NoSequentialAwait
|
|
28
|
+
from sarj_python_lint.rules.no_sleep_in_test_body import NoSleepInTestBody
|
|
21
29
|
from sarj_python_lint.rules.no_unreachable_after_terminal import (
|
|
22
30
|
NoUnreachableAfterTerminal,
|
|
23
31
|
)
|
|
@@ -25,6 +33,9 @@ from sarj_python_lint.rules.prefer_class_row import PreferClassRow
|
|
|
25
33
|
from sarj_python_lint.rules.prefer_constant_time_secret_compare import (
|
|
26
34
|
PreferConstantTimeSecretCompare,
|
|
27
35
|
)
|
|
36
|
+
from sarj_python_lint.rules.prefer_namedtuple_over_tuple_return import (
|
|
37
|
+
PreferNamedtupleOverTupleReturn,
|
|
38
|
+
)
|
|
28
39
|
from sarj_python_lint.rules.prefer_str_enum import PreferStrEnum
|
|
29
40
|
from sarj_python_lint.rules.prefer_struct_over_namedtuple import (
|
|
30
41
|
PreferStructOverNamedtuple,
|
|
@@ -51,6 +62,13 @@ REGISTRY: dict[str, type[Rule]] = {
|
|
|
51
62
|
PreferStrEnum.id: PreferStrEnum,
|
|
52
63
|
NoFatTryBlocks.id: NoFatTryBlocks,
|
|
53
64
|
NoIsinstanceUnionChain.id: NoIsinstanceUnionChain,
|
|
65
|
+
NoOffsetPagination.id: NoOffsetPagination,
|
|
66
|
+
PreferNamedtupleOverTupleReturn.id: PreferNamedtupleOverTupleReturn,
|
|
67
|
+
LenAsTruthiness.id: LenAsTruthiness,
|
|
68
|
+
NoCorsWildcardWithCredentials.id: NoCorsWildcardWithCredentials,
|
|
69
|
+
NoManualLogPrefix.id: NoManualLogPrefix,
|
|
70
|
+
JsonResponseNotParsed.id: JsonResponseNotParsed,
|
|
71
|
+
NoSleepInTestBody.id: NoSleepInTestBody,
|
|
54
72
|
PydanticAtBoundaries.id: PydanticAtBoundaries,
|
|
55
73
|
NoSentinelReturnOnExcept.id: NoSentinelReturnOnExcept,
|
|
56
74
|
NoUnreachableAfterTerminal.id: NoUnreachableAfterTerminal,
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""SARJ030: flag an external JSON payload consumed untyped.
|
|
2
|
+
|
|
3
|
+
A `.json()` call (no args) or a `json.loads(...)` / `json.load(...)` result used
|
|
4
|
+
*directly* — subscripted (`["k"]`), `.get(...)`-ed, or attribute-chained — reaches
|
|
5
|
+
into an external payload's shape without ever parsing it into a pydantic model.
|
|
6
|
+
When the vendor changes that shape the access breaks silently at runtime instead
|
|
7
|
+
of at a validated boundary.
|
|
8
|
+
|
|
9
|
+
resp.json()["access_token"] # fires
|
|
10
|
+
resp.json().get("data") # fires
|
|
11
|
+
(await client.get(u)).json()["k"] # fires
|
|
12
|
+
json.loads(body)["id"] # fires
|
|
13
|
+
|
|
14
|
+
Parse the payload first: `Token.model_validate(resp.json())`, then read typed
|
|
15
|
+
attributes off the model.
|
|
16
|
+
|
|
17
|
+
This is an access-shape signal, distinct from the annotation-based SARJ008 /
|
|
18
|
+
ANN401 (`dict[str, Any]` parameters): those catch an untyped *type*, this catches
|
|
19
|
+
an untyped *use* of a live external response with no annotation at all.
|
|
20
|
+
|
|
21
|
+
Deliberately NOT flagged (keeps false positives at zero):
|
|
22
|
+
- assign-then-subscript (`d = resp.json(); d["k"]`) — only DIRECT chaining off the
|
|
23
|
+
`.json()` / `json.loads()` call is tracked; a bound name could be anything,
|
|
24
|
+
- a `.json(...)` call WITH arguments — that receiver is not the payload accessor,
|
|
25
|
+
- the payload passed to a parser (`Model.model_validate(resp.json())`) rather than
|
|
26
|
+
subscripted — that IS the parse we want,
|
|
27
|
+
- a plain dict/list literal subscript (`{"a": 1}["a"]`),
|
|
28
|
+
- a bare `.json` attribute that is never called, and `json.dumps(...)`.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import ast
|
|
34
|
+
from typing import TYPE_CHECKING, override
|
|
35
|
+
|
|
36
|
+
from sarj_python_lint.rule_base import Diagnostic, Rule, parse_or_none
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
if TYPE_CHECKING:
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class JsonResponseNotParsed(Rule):
|
|
44
|
+
"""External JSON payload consumed untyped instead of parsed into a model."""
|
|
45
|
+
|
|
46
|
+
id: str = "json-response-not-parsed"
|
|
47
|
+
code: str = "SARJ030"
|
|
48
|
+
description: str = (
|
|
49
|
+
"A `.json()` / `json.loads()` result is subscripted, `.get()`-ed, or "
|
|
50
|
+
"attribute-chained directly — parse it into a pydantic model first."
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
@override
|
|
54
|
+
def check(self, path: Path, source: str) -> list[Diagnostic]:
|
|
55
|
+
tree = parse_or_none(path, source)
|
|
56
|
+
if tree is None:
|
|
57
|
+
return []
|
|
58
|
+
diags: list[Diagnostic] = []
|
|
59
|
+
for node in ast.walk(tree):
|
|
60
|
+
value = _direct_accessed_value(node)
|
|
61
|
+
if value is None or not _is_json_payload_call(value):
|
|
62
|
+
continue
|
|
63
|
+
diags.append(
|
|
64
|
+
Diagnostic(
|
|
65
|
+
path=path,
|
|
66
|
+
line=node.lineno,
|
|
67
|
+
col=node.col_offset + 1,
|
|
68
|
+
code=self.code,
|
|
69
|
+
message=(
|
|
70
|
+
"External JSON payload consumed untyped — parse the "
|
|
71
|
+
"`.json()` / `json.loads()` result into a pydantic model "
|
|
72
|
+
"before subscripting or attribute access."
|
|
73
|
+
),
|
|
74
|
+
)
|
|
75
|
+
)
|
|
76
|
+
diags.sort(key=lambda d: (d.line, d.col))
|
|
77
|
+
return diags
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _direct_accessed_value(node: ast.AST) -> ast.expr | None:
|
|
81
|
+
"""The receiver being read off, if `node` reaches directly into a value.
|
|
82
|
+
|
|
83
|
+
Covers the three access shapes — `<value>[...]` (Subscript), `<value>.attr`
|
|
84
|
+
(Attribute, which also covers `<value>.get(...)` since `.get` is an
|
|
85
|
+
attribute), and nothing else. Returns the `<value>` expression, or None.
|
|
86
|
+
"""
|
|
87
|
+
if isinstance(node, ast.Subscript):
|
|
88
|
+
return node.value
|
|
89
|
+
if isinstance(node, ast.Attribute):
|
|
90
|
+
return node.value
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _is_json_payload_call(node: ast.expr) -> bool:
|
|
95
|
+
"""True for a JSON-payload-producing call: `<x>.json()` with NO args, or
|
|
96
|
+
`json.loads(...)` / `json.load(...)`.
|
|
97
|
+
|
|
98
|
+
A `.json(...)` call with any positional or keyword argument is not the payload
|
|
99
|
+
accessor and is skipped.
|
|
100
|
+
"""
|
|
101
|
+
if not isinstance(node, ast.Call):
|
|
102
|
+
return False
|
|
103
|
+
func = node.func
|
|
104
|
+
if not isinstance(func, ast.Attribute):
|
|
105
|
+
return False
|
|
106
|
+
if func.attr == "json":
|
|
107
|
+
return not node.args and not node.keywords
|
|
108
|
+
if func.attr in {"loads", "load"}:
|
|
109
|
+
return isinstance(func.value, ast.Name) and func.value.id == "json"
|
|
110
|
+
return False
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""SARJ027: flag `len(x) <cmp> 0|1` comparisons that are just a truthiness test.
|
|
2
|
+
|
|
3
|
+
Six zero-boundary forms of `len(x)` compared against `0` or `1` are exactly
|
|
4
|
+
equivalent to testing the container's truthiness — an empty container is falsy,
|
|
5
|
+
a non-empty one is truthy:
|
|
6
|
+
|
|
7
|
+
len(x) == 0 -> not x
|
|
8
|
+
len(x) < 1 -> not x
|
|
9
|
+
len(x) <= 0 -> not x
|
|
10
|
+
len(x) != 0 -> x
|
|
11
|
+
len(x) > 0 -> x
|
|
12
|
+
len(x) >= 1 -> x
|
|
13
|
+
|
|
14
|
+
`len(x)` allocates nothing but reads as size arithmetic; the truthiness form is
|
|
15
|
+
shorter and states the intent. pylint shipped this as C1802/C1803 but neither
|
|
16
|
+
check was ever ported to ruff, so it is genuinely uncovered.
|
|
17
|
+
|
|
18
|
+
Deliberately NOT flagged — these are real size checks, not truthiness:
|
|
19
|
+
- exact-count / other-boundary comparisons (`len(x) == 1`, `>= 2`, `> 1`, `< 2`,
|
|
20
|
+
`== 3`, ...); only the six zero-boundary forms above collapse to truthiness,
|
|
21
|
+
- a right-hand constant other than `0`/`1` (or a bool literal `True`/`False`),
|
|
22
|
+
- anything that is not literally `len(<one expr>)` on the left — a different
|
|
23
|
+
builtin (`count(x) == 0`), `len` with 0/2+ args, or `len` called via attribute,
|
|
24
|
+
- chained comparisons (`0 < len(x) < 5`) — more than one operator,
|
|
25
|
+
- `x == []` / `x == {}` — a different check (it excludes `None`; see the
|
|
26
|
+
separate empty-collection-literal rule).
|
|
27
|
+
|
|
28
|
+
The yoda form `0 == len(x)` is intentionally not handled (rare; would widen the
|
|
29
|
+
signal for little gain).
|
|
30
|
+
|
|
31
|
+
References:
|
|
32
|
+
- https://pylint.readthedocs.io/en/latest/user_guide/messages/refactor/use-implicit-booleaness-not-len.html
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import ast
|
|
38
|
+
from typing import TYPE_CHECKING, override
|
|
39
|
+
|
|
40
|
+
from sarj_python_lint.rule_base import Diagnostic, Rule, parse_or_none
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
if TYPE_CHECKING:
|
|
44
|
+
from pathlib import Path
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# (comparison-operator type, right-hand int constant) pairs that are equivalent to a
|
|
48
|
+
# plain truthiness test, mapped to the truthy suggestion ("x") or falsy one ("not x").
|
|
49
|
+
_TRUTHINESS_FORMS: dict[tuple[type[ast.cmpop], int], str] = {
|
|
50
|
+
(ast.Eq, 0): "not x",
|
|
51
|
+
(ast.Lt, 1): "not x",
|
|
52
|
+
(ast.LtE, 0): "not x",
|
|
53
|
+
(ast.NotEq, 0): "x",
|
|
54
|
+
(ast.Gt, 0): "x",
|
|
55
|
+
(ast.GtE, 1): "x",
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class LenAsTruthiness(Rule):
|
|
60
|
+
"""`len(x) <cmp> 0|1` zero-boundary comparisons — prefer a plain truthiness test."""
|
|
61
|
+
|
|
62
|
+
id: str = "len-as-truthiness"
|
|
63
|
+
code: str = "SARJ027"
|
|
64
|
+
description: str = (
|
|
65
|
+
"len(x) compared against 0/1 at a zero boundary is a truthiness test — "
|
|
66
|
+
"use `x` / `not x` instead of `len(x)`."
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
@override
|
|
70
|
+
def check(self, path: Path, source: str) -> list[Diagnostic]:
|
|
71
|
+
tree = parse_or_none(path, source)
|
|
72
|
+
if tree is None:
|
|
73
|
+
return []
|
|
74
|
+
diags: list[Diagnostic] = []
|
|
75
|
+
for node in ast.walk(tree):
|
|
76
|
+
if not isinstance(node, ast.Compare):
|
|
77
|
+
continue
|
|
78
|
+
suggestion = _truthiness_suggestion(node)
|
|
79
|
+
if suggestion is None:
|
|
80
|
+
continue
|
|
81
|
+
diags.append(
|
|
82
|
+
Diagnostic(
|
|
83
|
+
path=path,
|
|
84
|
+
line=node.lineno,
|
|
85
|
+
col=node.col_offset + 1,
|
|
86
|
+
code=self.code,
|
|
87
|
+
message=(
|
|
88
|
+
f"len(...) compared against a zero boundary is a truthiness "
|
|
89
|
+
f"test — use `{suggestion}` instead of `len(...)`."
|
|
90
|
+
),
|
|
91
|
+
)
|
|
92
|
+
)
|
|
93
|
+
diags.sort(key=lambda d: (d.line, d.col))
|
|
94
|
+
return diags
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _truthiness_suggestion(node: ast.Compare) -> str | None:
|
|
98
|
+
"""The `x`/`not x` suggestion if `node` is a zero-boundary `len(...)` compare, else None."""
|
|
99
|
+
if len(node.ops) != 1 or len(node.comparators) != 1:
|
|
100
|
+
return None
|
|
101
|
+
if not _is_len_call(node.left):
|
|
102
|
+
return None
|
|
103
|
+
const = _int_constant(node.comparators[0])
|
|
104
|
+
if const is None:
|
|
105
|
+
return None
|
|
106
|
+
return _TRUTHINESS_FORMS.get((type(node.ops[0]), const))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _is_len_call(expr: ast.expr) -> bool:
|
|
110
|
+
"""True iff `expr` is literally `len(<single positional expr>)`."""
|
|
111
|
+
if not isinstance(expr, ast.Call):
|
|
112
|
+
return False
|
|
113
|
+
if not (isinstance(expr.func, ast.Name) and expr.func.id == "len"):
|
|
114
|
+
return False
|
|
115
|
+
if len(expr.args) != 1 or expr.keywords:
|
|
116
|
+
return False
|
|
117
|
+
return not isinstance(expr.args[0], ast.Starred)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _int_constant(expr: ast.expr) -> int | None:
|
|
121
|
+
"""The int value of a plain integer literal, else None. Bool literals are excluded."""
|
|
122
|
+
if not isinstance(expr, ast.Constant):
|
|
123
|
+
return None
|
|
124
|
+
if type(expr.value) is not int:
|
|
125
|
+
return None
|
|
126
|
+
return expr.value
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""SARJ028: detect Starlette/FastAPI CORS that echoes any Origin with credentials.
|
|
2
|
+
|
|
3
|
+
`CORSMiddleware(allow_credentials=True, allow_origins=[..., "*", ...])` tells the
|
|
4
|
+
browser to reflect the request's `Origin` back in
|
|
5
|
+
`Access-Control-Allow-Origin` AND to send `Access-Control-Allow-Credentials:
|
|
6
|
+
true`. Together these let *any* website read authenticated (cookie/session)
|
|
7
|
+
responses — a cross-origin credential-theft surface.
|
|
8
|
+
|
|
9
|
+
The rule fires on an `ast.Call` that has BOTH `allow_credentials=True`
|
|
10
|
+
(literal) AND an `allow_origins` value whose subtree contains a `"*"` string
|
|
11
|
+
literal anywhere — so it catches the bare `["*"]` form as well as the
|
|
12
|
+
`allowed if flag else ["*"]` conditional branch. The keyword pair is unique to
|
|
13
|
+
Starlette's CORSMiddleware, so matching the callee name is unnecessary (though it
|
|
14
|
+
would be a valid further tightening).
|
|
15
|
+
|
|
16
|
+
References:
|
|
17
|
+
- https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#credentialed_requests_and_wildcards
|
|
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 NoCorsWildcardWithCredentials(Rule):
|
|
33
|
+
"""CORS that reflects any Origin while allowing credentials."""
|
|
34
|
+
|
|
35
|
+
id: str = "no-cors-wildcard-with-credentials"
|
|
36
|
+
code: str = "SARJ028"
|
|
37
|
+
description: str = (
|
|
38
|
+
'CORS `allow_credentials=True` with `"*"` in `allow_origins` lets any '
|
|
39
|
+
"site read authenticated responses."
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
@override
|
|
43
|
+
def check(self, path: Path, source: str) -> list[Diagnostic]:
|
|
44
|
+
tree = parse_or_none(path, source)
|
|
45
|
+
if tree is None:
|
|
46
|
+
return []
|
|
47
|
+
diags: list[Diagnostic] = []
|
|
48
|
+
for node in ast.walk(tree):
|
|
49
|
+
if not isinstance(node, ast.Call):
|
|
50
|
+
continue
|
|
51
|
+
keywords = {kw.arg: kw.value for kw in node.keywords if kw.arg is not None}
|
|
52
|
+
credentials = keywords.get("allow_credentials")
|
|
53
|
+
origins = keywords.get("allow_origins")
|
|
54
|
+
if credentials is None or origins is None:
|
|
55
|
+
continue
|
|
56
|
+
if not _is_true_literal(credentials):
|
|
57
|
+
continue
|
|
58
|
+
if not _contains_star_literal(origins):
|
|
59
|
+
continue
|
|
60
|
+
diags.append(
|
|
61
|
+
Diagnostic(
|
|
62
|
+
path=path,
|
|
63
|
+
line=node.lineno,
|
|
64
|
+
col=node.col_offset + 1,
|
|
65
|
+
code=self.code,
|
|
66
|
+
message=(
|
|
67
|
+
'CORS reflects any Origin (`"*"` in `allow_origins`) while '
|
|
68
|
+
"`allow_credentials=True` — any site can read authenticated "
|
|
69
|
+
"responses. Enumerate explicit trusted origins instead."
|
|
70
|
+
),
|
|
71
|
+
)
|
|
72
|
+
)
|
|
73
|
+
diags.sort(key=lambda d: (d.line, d.col))
|
|
74
|
+
return diags
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _is_true_literal(node: ast.expr) -> bool:
|
|
78
|
+
"""True only for the literal `True` (not `1`, not a truthy expression)."""
|
|
79
|
+
return isinstance(node, ast.Constant) and node.value is True
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _contains_star_literal(node: ast.expr) -> bool:
|
|
83
|
+
"""True if a `"*"` string `Constant` appears anywhere in `node`'s subtree.
|
|
84
|
+
|
|
85
|
+
Walking the whole subtree catches both `["*"]` and the `allowed if flag else
|
|
86
|
+
["*"]` conditional branch. A dynamic `allow_origins=some_var` has no `"*"`
|
|
87
|
+
literal, so it does not fire.
|
|
88
|
+
"""
|
|
89
|
+
return any(isinstance(child, ast.Constant) and child.value == "*" for child in ast.walk(node))
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""SARJ029: flag a manual `"[Component] ..."` bracket tag in a logging message.
|
|
2
|
+
|
|
3
|
+
The house logging style is structured: identify the emitting component with a
|
|
4
|
+
bound field so aggregators can filter on it, rather than baking the label into
|
|
5
|
+
the message text where it can only be grepped:
|
|
6
|
+
|
|
7
|
+
# flagged
|
|
8
|
+
logger.info("[STT] transcription finished")
|
|
9
|
+
|
|
10
|
+
# preferred
|
|
11
|
+
logger.bind(component="STT").info("transcription finished")
|
|
12
|
+
|
|
13
|
+
A leading `[Tag]` in the literal message duplicates context that belongs in
|
|
14
|
+
structured metadata and fragments template grouping. This is distinct from
|
|
15
|
+
SARJ017 (no-fstring-in-log) / ruff G004, which fire only on *interpolating*
|
|
16
|
+
f-strings; the messages caught here are plain string literals.
|
|
17
|
+
|
|
18
|
+
To keep false positives near zero the rule requires BOTH a logger-like receiver
|
|
19
|
+
(resolved through builder/factory chains via `is_logger_expr`) AND a logging
|
|
20
|
+
method name, and only inspects the message argument (arg 1 for
|
|
21
|
+
`logger.log(LEVEL, msg)`, arg 0 otherwise).
|
|
22
|
+
|
|
23
|
+
The bracket tag must be a *component* label, not incidental data. The match
|
|
24
|
+
pattern allows only letters, spaces, underscores and hyphens inside the
|
|
25
|
+
brackets and requires at least one letter, so data-shaped brackets never match:
|
|
26
|
+
`[Errno 2]` (digit), `[1, 2]` (digit/comma), `['a', 'b']` (quotes/comma),
|
|
27
|
+
`[12:34]` (colon) are all excluded, while `[STT]`, `[TTS Pronunciation]` and
|
|
28
|
+
`[AgentDispatch]` match. Empirically this matches 0 data brackets across the
|
|
29
|
+
bulbul (273 hits) and noura (24 hits) corpora.
|
|
30
|
+
|
|
31
|
+
Suppress an intentional case with `# sarj-noqa: SARJ029 — <reason>`.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import ast
|
|
37
|
+
import re
|
|
38
|
+
from typing import TYPE_CHECKING, override
|
|
39
|
+
|
|
40
|
+
from sarj_python_lint.rule_base import Diagnostic, Rule, parse_or_none
|
|
41
|
+
from sarj_python_lint.rules._logging import is_logger_expr
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
if TYPE_CHECKING:
|
|
45
|
+
from pathlib import Path
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
_LOG_METHODS = frozenset(
|
|
49
|
+
{
|
|
50
|
+
"debug",
|
|
51
|
+
"info",
|
|
52
|
+
"warning",
|
|
53
|
+
"warn",
|
|
54
|
+
"error",
|
|
55
|
+
"exception",
|
|
56
|
+
"critical",
|
|
57
|
+
"fatal",
|
|
58
|
+
"trace",
|
|
59
|
+
"success",
|
|
60
|
+
"log",
|
|
61
|
+
}
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
_PREFIX_RE = re.compile(r"^\[[A-Za-z_ -]*[A-Za-z][A-Za-z_ -]*\]")
|
|
65
|
+
|
|
66
|
+
_LOG_LEVEL_AND_MESSAGE_ARGC = 2
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class NoManualLogPrefix(Rule):
|
|
70
|
+
"""Manual `[Component]` prefix in a logging message — bind it as structured context."""
|
|
71
|
+
|
|
72
|
+
id: str = "no-manual-log-prefix"
|
|
73
|
+
code: str = "SARJ029"
|
|
74
|
+
description: str = (
|
|
75
|
+
"manual '[Component]' prefix in a logging message — bind the component as "
|
|
76
|
+
"structured context (logger.bind(component=...)) so aggregators filter on a "
|
|
77
|
+
"field instead of grepping baked-in text."
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
@override
|
|
81
|
+
def check(self, path: Path, source: str) -> list[Diagnostic]:
|
|
82
|
+
tree = parse_or_none(path, source)
|
|
83
|
+
if tree is None:
|
|
84
|
+
return []
|
|
85
|
+
diags: list[Diagnostic] = []
|
|
86
|
+
for node in ast.walk(tree):
|
|
87
|
+
if not isinstance(node, ast.Call) or not _is_logging_call(node):
|
|
88
|
+
continue
|
|
89
|
+
message = _message_arg(node)
|
|
90
|
+
if message is None:
|
|
91
|
+
continue
|
|
92
|
+
leading = _leading_static_str(message)
|
|
93
|
+
if leading is None or not _PREFIX_RE.match(leading):
|
|
94
|
+
continue
|
|
95
|
+
diags.append(
|
|
96
|
+
Diagnostic(
|
|
97
|
+
path=path,
|
|
98
|
+
line=node.lineno,
|
|
99
|
+
col=node.col_offset + 1,
|
|
100
|
+
code=self.code,
|
|
101
|
+
message=(
|
|
102
|
+
"manual '[Component]' prefix in a logging message — bind it as "
|
|
103
|
+
"structured context (logger.bind(component=...)) instead."
|
|
104
|
+
),
|
|
105
|
+
)
|
|
106
|
+
)
|
|
107
|
+
diags.sort(key=lambda d: (d.line, d.col))
|
|
108
|
+
return diags
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _is_logging_call(node: ast.Call) -> bool:
|
|
112
|
+
func = node.func
|
|
113
|
+
if not isinstance(func, ast.Attribute) or func.attr not in _LOG_METHODS:
|
|
114
|
+
return False
|
|
115
|
+
return is_logger_expr(func.value)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _message_arg(node: ast.Call) -> ast.expr | None:
|
|
119
|
+
"""The message argument: index 1 for `logger.log(LEVEL, msg)`, else index 0.
|
|
120
|
+
|
|
121
|
+
A `.log` call carrying fewer than two positionals has no message argument (the
|
|
122
|
+
lone positional is the level), so there is nothing to inspect.
|
|
123
|
+
"""
|
|
124
|
+
if isinstance(node.func, ast.Attribute) and node.func.attr == "log":
|
|
125
|
+
return node.args[1] if len(node.args) >= _LOG_LEVEL_AND_MESSAGE_ARGC else None
|
|
126
|
+
return node.args[0] if node.args else None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _leading_static_str(node: ast.expr) -> str | None:
|
|
130
|
+
"""The leading static string of a message expr, or None if there isn't one.
|
|
131
|
+
|
|
132
|
+
Handles a bare string literal, the leading literal chunk of an f-string, and
|
|
133
|
+
the left operand of a `+`-concatenation (recursively), so a baked-in prefix
|
|
134
|
+
is found even when the value is later interpolated or concatenated.
|
|
135
|
+
"""
|
|
136
|
+
match node:
|
|
137
|
+
case ast.Constant(value=str() as value):
|
|
138
|
+
return value
|
|
139
|
+
case ast.JoinedStr(values=[ast.Constant(value=str() as value), *_]):
|
|
140
|
+
return value
|
|
141
|
+
case ast.JoinedStr():
|
|
142
|
+
return None
|
|
143
|
+
case ast.BinOp(op=ast.Add(), left=left):
|
|
144
|
+
return _leading_static_str(left)
|
|
145
|
+
case _:
|
|
146
|
+
return None
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""SARJ025: no `OFFSET` pagination in a store query — use a keyset cursor.
|
|
2
|
+
|
|
3
|
+
`LIMIT n OFFSET m` makes the database scan and discard every one of the `m`
|
|
4
|
+
skipped rows before returning page contents, so page N costs O(N): deep pages get
|
|
5
|
+
linearly slower and, under concurrent inserts, rows shift between pages (an item
|
|
6
|
+
can be shown twice or skipped). Keyset / cursor pagination
|
|
7
|
+
(`WHERE id > :cursor ORDER BY id LIMIT n`) is O(page) and stable. This mirrors the
|
|
8
|
+
SQL-migration linter's `no-limit-offset` (SARJ107), but for the SQL embedded in
|
|
9
|
+
Python store queries — where application pagination actually lives.
|
|
10
|
+
|
|
11
|
+
The rule walks SQL string literals embedded in `.py`, neutralizes string-literal
|
|
12
|
+
values and `--` / `/* */` comments first (so an `'offset'` value or a prose
|
|
13
|
+
`"offset out of range"` message is never mistaken for the keyword), and flags an
|
|
14
|
+
`OFFSET` keyword immediately followed by a value/param token (`%s`, `%(name)s`,
|
|
15
|
+
`:name`, `@name`, `$1`, or a digit) — the real pagination construct. Requiring the
|
|
16
|
+
value token excludes the English word and BigQuery's `UNNEST(...) WITH OFFSET AS
|
|
17
|
+
col` array indexing (which has no value after `OFFSET`).
|
|
18
|
+
|
|
19
|
+
# flagged
|
|
20
|
+
"SELECT id, status FROM call ORDER BY created_at LIMIT %s OFFSET %s"
|
|
21
|
+
" LIMIT %s OFFSET %s" # a paginated-query fragment
|
|
22
|
+
|
|
23
|
+
# preferred
|
|
24
|
+
"SELECT id, status FROM call WHERE id > %s ORDER BY id LIMIT %s"
|
|
25
|
+
|
|
26
|
+
Suppress a deliberate case (e.g. a bounded admin export) with
|
|
27
|
+
`# sarj-noqa: SARJ025 — <reason>`.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import ast
|
|
33
|
+
import re
|
|
34
|
+
from typing import TYPE_CHECKING, override
|
|
35
|
+
|
|
36
|
+
from sarj_python_lint.rule_base import Diagnostic, Rule, parse_or_none
|
|
37
|
+
from sarj_python_lint.rules._sql import sql_string_value, strip_sql_noise
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
if TYPE_CHECKING:
|
|
41
|
+
from pathlib import Path
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# `OFFSET` followed by a value/param token — the real pagination construct. This
|
|
45
|
+
# excludes the English word ("no base offset"), `'offset'` dict keys, and BigQuery
|
|
46
|
+
# `UNNEST(...) WITH OFFSET AS col` (array indexing, no value token after OFFSET).
|
|
47
|
+
_OFFSET_PAGINATION = re.compile(
|
|
48
|
+
r"\bOFFSET\s+(?:%s|%\(\w+\)s|:\w+|@\w+|\$\d+|\d+)", re.IGNORECASE
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class NoOffsetPagination(Rule):
|
|
53
|
+
"""`OFFSET` pagination in a store query — use a keyset cursor instead."""
|
|
54
|
+
|
|
55
|
+
id: str = "no-offset-pagination"
|
|
56
|
+
code: str = "SARJ025"
|
|
57
|
+
description: str = (
|
|
58
|
+
"OFFSET pagination is O(N) and unstable under concurrent writes — use a "
|
|
59
|
+
"keyset cursor (WHERE id > :cursor ORDER BY id LIMIT n)."
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
@override
|
|
63
|
+
def check(self, path: Path, source: str) -> list[Diagnostic]:
|
|
64
|
+
tree = parse_or_none(path, source)
|
|
65
|
+
if tree is None:
|
|
66
|
+
return []
|
|
67
|
+
|
|
68
|
+
diags: list[Diagnostic] = []
|
|
69
|
+
consumed: set[int] = set()
|
|
70
|
+
for node in ast.walk(tree):
|
|
71
|
+
if not isinstance(node, ast.Constant | ast.BinOp):
|
|
72
|
+
continue
|
|
73
|
+
if id(node) in consumed:
|
|
74
|
+
continue
|
|
75
|
+
text = sql_string_value(node)
|
|
76
|
+
if text is None:
|
|
77
|
+
continue
|
|
78
|
+
consumed.update(id(sub) for sub in ast.walk(node))
|
|
79
|
+
|
|
80
|
+
sql = strip_sql_noise(text)
|
|
81
|
+
if _OFFSET_PAGINATION.search(sql) is None:
|
|
82
|
+
continue
|
|
83
|
+
|
|
84
|
+
diags.append(
|
|
85
|
+
Diagnostic(
|
|
86
|
+
path=path,
|
|
87
|
+
line=node.lineno,
|
|
88
|
+
col=node.col_offset + 1,
|
|
89
|
+
code=self.code,
|
|
90
|
+
message=(
|
|
91
|
+
"Store query uses OFFSET pagination (O(N), unstable under "
|
|
92
|
+
"concurrent writes) — use a keyset cursor (WHERE id > :cursor "
|
|
93
|
+
"ORDER BY id LIMIT n). Suppress with `# sarj-noqa: SARJ025`."
|
|
94
|
+
),
|
|
95
|
+
)
|
|
96
|
+
)
|
|
97
|
+
diags.sort(key=lambda d: (d.line, d.col))
|
|
98
|
+
return diags
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""SARJ031: a nonzero `sleep()` directly in a `test_*` body is a flaky-test smell.
|
|
2
|
+
|
|
3
|
+
`asyncio.sleep(0.01)` / `time.sleep(1)` placed straight in a test function body is
|
|
4
|
+
the canonical flaky-test pattern: under CI load the fixed delay is nondeterministic
|
|
5
|
+
(too short → the awaited work has not finished; the test flakes), and it slows the
|
|
6
|
+
suite for no benefit. The fix is to synchronize on the actual signal — await the
|
|
7
|
+
awaitable, wait on an `Event`, or poll a condition with a timeout.
|
|
8
|
+
|
|
9
|
+
This is test-scoped and genuinely uncovered: ruff ASYNC251 only flags blocking
|
|
10
|
+
`time.sleep` inside an `async def`, and nothing flags `asyncio.sleep(nonzero)`.
|
|
11
|
+
|
|
12
|
+
Fires only on the exact shape:
|
|
13
|
+
|
|
14
|
+
* a call `asyncio.sleep(<arg>)` or `time.sleep(<arg>)` (receiver is the bare name
|
|
15
|
+
`asyncio` or `time`),
|
|
16
|
+
* where `<arg>` is a **nonzero numeric literal** (`int`/`float` `ast.Constant`) —
|
|
17
|
+
`sleep(0)` is a cooperative yield, not a timing hack, and a non-literal
|
|
18
|
+
`sleep(delay)` is a deliberate configured wait, so both are skipped, and
|
|
19
|
+
* whose **nearest enclosing function is a `test_*`-named** `def`/`async def`.
|
|
20
|
+
|
|
21
|
+
Critical false-positive guard: the sleep must sit DIRECTLY in the test body, with
|
|
22
|
+
no intervening nested `def`/`async def`/`lambda`. A sleep inside a nested helper /
|
|
23
|
+
fake coroutine declared within the test (`_hang`, `_slow`, `mock_*`) deliberately
|
|
24
|
+
simulates latency to exercise cancellation/timeout paths — that is the intended
|
|
25
|
+
use, not a flaky sync, and it must not fire. Because the check keys off the
|
|
26
|
+
*nearest* enclosing function, such a nested helper (not `test_*`-named) is excluded
|
|
27
|
+
automatically.
|
|
28
|
+
|
|
29
|
+
Applies only in test files (stem `test_*.py`, `*_test.py`, `conftest.py`, or a path
|
|
30
|
+
under a `tests`/`test` directory).
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import ast
|
|
36
|
+
from typing import TYPE_CHECKING, override
|
|
37
|
+
|
|
38
|
+
from sarj_python_lint.rule_base import Diagnostic, Rule, parse_or_none
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
if TYPE_CHECKING:
|
|
42
|
+
from pathlib import Path
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
_SLEEP_RECEIVERS = frozenset({"asyncio", "time"})
|
|
46
|
+
_FUNC_NODES = (ast.FunctionDef, ast.AsyncFunctionDef)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _is_test_path(path: Path) -> bool:
|
|
50
|
+
name = path.name
|
|
51
|
+
if name == "conftest.py" or name.startswith("test_") or name.endswith("_test.py"):
|
|
52
|
+
return True
|
|
53
|
+
return any(part in {"tests", "test"} for part in path.parts)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _is_nonzero_numeric_literal(node: ast.expr) -> bool:
|
|
57
|
+
return (
|
|
58
|
+
isinstance(node, ast.Constant)
|
|
59
|
+
and isinstance(node.value, (int, float))
|
|
60
|
+
and not isinstance(node.value, bool)
|
|
61
|
+
and node.value != 0
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _is_sleep_call(node: ast.Call) -> bool:
|
|
66
|
+
func = node.func
|
|
67
|
+
return (
|
|
68
|
+
isinstance(func, ast.Attribute)
|
|
69
|
+
and func.attr == "sleep"
|
|
70
|
+
and isinstance(func.value, ast.Name)
|
|
71
|
+
and func.value.id in _SLEEP_RECEIVERS
|
|
72
|
+
and len(node.args) >= 1
|
|
73
|
+
and _is_nonzero_numeric_literal(node.args[0])
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class NoSleepInTestBody(Rule):
|
|
78
|
+
"""Nonzero `sleep()` directly in a `test_*` body — flaky timing sync."""
|
|
79
|
+
|
|
80
|
+
id: str = "no-sleep-in-test-body"
|
|
81
|
+
code: str = "SARJ031"
|
|
82
|
+
description: str = "Nonzero `sleep()` in a test body — synchronize on the signal, don't sleep."
|
|
83
|
+
|
|
84
|
+
@override
|
|
85
|
+
def check(self, path: Path, source: str) -> list[Diagnostic]:
|
|
86
|
+
if not _is_test_path(path):
|
|
87
|
+
return []
|
|
88
|
+
tree = parse_or_none(path, source)
|
|
89
|
+
if tree is None:
|
|
90
|
+
return []
|
|
91
|
+
visitor = _SleepInTestBodyVisitor()
|
|
92
|
+
visitor.visit(tree)
|
|
93
|
+
diags = [
|
|
94
|
+
Diagnostic(
|
|
95
|
+
path=path,
|
|
96
|
+
line=node.lineno,
|
|
97
|
+
col=node.col_offset + 1,
|
|
98
|
+
code=self.code,
|
|
99
|
+
message=(
|
|
100
|
+
"nonzero `sleep()` directly in a test body flakes under CI load — "
|
|
101
|
+
"await the awaitable, wait on an `Event`, or poll the condition instead."
|
|
102
|
+
),
|
|
103
|
+
)
|
|
104
|
+
for node in visitor.hits
|
|
105
|
+
]
|
|
106
|
+
diags.sort(key=lambda d: (d.line, d.col))
|
|
107
|
+
return diags
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class _SleepInTestBodyVisitor(ast.NodeVisitor):
|
|
111
|
+
"""Flag sleep calls whose NEAREST enclosing function is a `test_*` def.
|
|
112
|
+
|
|
113
|
+
Maintains a stack of enclosing-function names (`None` for a lambda, which has
|
|
114
|
+
no name and can never be a test). Only the top of the stack — the nearest
|
|
115
|
+
enclosing function — is consulted, so a sleep inside a nested helper/fake
|
|
116
|
+
coroutine declared within a test is attributed to that helper, not the test,
|
|
117
|
+
and does not fire.
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
def __init__(self) -> None:
|
|
121
|
+
super().__init__()
|
|
122
|
+
self._func_names: list[str | None] = []
|
|
123
|
+
self.hits: list[ast.Call] = []
|
|
124
|
+
|
|
125
|
+
def _visit_function(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None:
|
|
126
|
+
self._func_names.append(node.name)
|
|
127
|
+
self.generic_visit(node)
|
|
128
|
+
self._func_names.pop()
|
|
129
|
+
|
|
130
|
+
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
|
|
131
|
+
self._visit_function(node)
|
|
132
|
+
|
|
133
|
+
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
|
|
134
|
+
self._visit_function(node)
|
|
135
|
+
|
|
136
|
+
def visit_Lambda(self, node: ast.Lambda) -> None:
|
|
137
|
+
self._func_names.append(None)
|
|
138
|
+
self.generic_visit(node)
|
|
139
|
+
self._func_names.pop()
|
|
140
|
+
|
|
141
|
+
def visit_Call(self, node: ast.Call) -> None:
|
|
142
|
+
if self._func_names and _is_sleep_call(node):
|
|
143
|
+
nearest = self._func_names[-1]
|
|
144
|
+
if nearest is not None and nearest.startswith("test_"):
|
|
145
|
+
self.hits.append(node)
|
|
146
|
+
self.generic_visit(node)
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""SARJ026: flag public functions returning a bare positional `tuple[A, B, ...]`.
|
|
2
|
+
|
|
3
|
+
A multi-field value returned across a boundary — from a public function (name not
|
|
4
|
+
starting `_`) — must be a `NamedTuple` (or a frozen pydantic model when it needs
|
|
5
|
+
validation), never a positional `tuple[A, B]` the caller has to unpack by position.
|
|
6
|
+
A bare `tuple[bytes, dict, str | None]` return forces every caller to remember which
|
|
7
|
+
slot is which; a typo swaps two fields silently. A named result gives each field a
|
|
8
|
+
name and lets pyright catch a wrong-order access.
|
|
9
|
+
|
|
10
|
+
# flagged
|
|
11
|
+
def download_to_memory(...) -> tuple[bytes, dict[str, str], str | None]:
|
|
12
|
+
...
|
|
13
|
+
|
|
14
|
+
# preferred
|
|
15
|
+
class Download(NamedTuple):
|
|
16
|
+
body: bytes
|
|
17
|
+
headers: dict[str, str]
|
|
18
|
+
content_type: str | None
|
|
19
|
+
|
|
20
|
+
def download_to_memory(...) -> Download:
|
|
21
|
+
...
|
|
22
|
+
|
|
23
|
+
The three tuple uses CLAUDE.md permits are deliberately NOT flagged:
|
|
24
|
+
- `tuple[X, ...]` — an immutable homogeneous sequence (Ellipsis form),
|
|
25
|
+
- `tuple[X, X]` — structurally homogeneous (every element identical, e.g.
|
|
26
|
+
`tuple[int, int]`), a pair of the same thing rather than distinct fields,
|
|
27
|
+
- `tuple[Literal["both"], A, B]` — a discriminated-union tag (first element a
|
|
28
|
+
`Literal[...]`).
|
|
29
|
+
|
|
30
|
+
Also NOT flagged: private (`_`-prefixed) functions, single-element `tuple[X]`,
|
|
31
|
+
a bare unsubscripted `tuple`, and any non-tuple / unannotated return.
|
|
32
|
+
|
|
33
|
+
Suppress a deliberate positional return with `# sarj-noqa: SARJ026 — <reason>`.
|
|
34
|
+
|
|
35
|
+
References:
|
|
36
|
+
- https://docs.python.org/3/library/typing.html#typing.NamedTuple
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
import ast
|
|
42
|
+
from typing import TYPE_CHECKING, override
|
|
43
|
+
|
|
44
|
+
from sarj_python_lint.rule_base import Diagnostic, Rule, parse_or_none
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
if TYPE_CHECKING:
|
|
48
|
+
from pathlib import Path
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
_TUPLE_NAMES = frozenset({"tuple", "Tuple"})
|
|
52
|
+
_LITERAL_NAMES = frozenset({"Literal"})
|
|
53
|
+
|
|
54
|
+
_MIN_ELEMENTS = 2
|
|
55
|
+
|
|
56
|
+
_MSG = (
|
|
57
|
+
"public function returns a bare positional tuple[...] — callers must unpack by "
|
|
58
|
+
"position; prefer a NamedTuple (or a frozen pydantic model for boundary values)."
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class PreferNamedtupleOverTupleReturn(Rule):
|
|
63
|
+
"""Public function returning a bare positional `tuple[A, B, ...]` — prefer a NamedTuple."""
|
|
64
|
+
|
|
65
|
+
id: str = "prefer-namedtuple-over-tuple-return"
|
|
66
|
+
code: str = "SARJ026"
|
|
67
|
+
description: str = (
|
|
68
|
+
"public function returning a bare positional tuple[A, B, ...] — prefer a "
|
|
69
|
+
"NamedTuple or frozen pydantic model so callers don't unpack by position."
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
@override
|
|
73
|
+
def check(self, path: Path, source: str) -> list[Diagnostic]:
|
|
74
|
+
tree = parse_or_none(path, source)
|
|
75
|
+
if tree is None:
|
|
76
|
+
return []
|
|
77
|
+
diags: list[Diagnostic] = []
|
|
78
|
+
for node in ast.walk(tree):
|
|
79
|
+
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
80
|
+
continue
|
|
81
|
+
if node.name.startswith("_"):
|
|
82
|
+
continue
|
|
83
|
+
if node.returns is None:
|
|
84
|
+
continue
|
|
85
|
+
if not _is_bare_positional_tuple(node.returns):
|
|
86
|
+
continue
|
|
87
|
+
diags.append(
|
|
88
|
+
Diagnostic(
|
|
89
|
+
path=path,
|
|
90
|
+
line=node.lineno,
|
|
91
|
+
col=node.col_offset + 1,
|
|
92
|
+
code=self.code,
|
|
93
|
+
message=_MSG,
|
|
94
|
+
)
|
|
95
|
+
)
|
|
96
|
+
diags.sort(key=lambda d: (d.line, d.col))
|
|
97
|
+
return diags
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _is_bare_positional_tuple(annotation: ast.expr) -> bool:
|
|
101
|
+
"""True if `annotation` is `tuple[A, B, ...]` with >=2 heterogeneous elements.
|
|
102
|
+
|
|
103
|
+
Exempts the three permitted forms: `tuple[X, ...]` (Ellipsis), structurally
|
|
104
|
+
homogeneous `tuple[X, X]`, and the `tuple[Literal[...], ...]` discriminated tag.
|
|
105
|
+
"""
|
|
106
|
+
if not isinstance(annotation, ast.Subscript):
|
|
107
|
+
return False
|
|
108
|
+
if _name_of(annotation.value) not in _TUPLE_NAMES:
|
|
109
|
+
return False
|
|
110
|
+
if not isinstance(annotation.slice, ast.Tuple):
|
|
111
|
+
return False
|
|
112
|
+
elements = annotation.slice.elts
|
|
113
|
+
if len(elements) < _MIN_ELEMENTS:
|
|
114
|
+
return False
|
|
115
|
+
if any(_is_ellipsis(el) for el in elements):
|
|
116
|
+
return False
|
|
117
|
+
if _all_equal(elements):
|
|
118
|
+
return False
|
|
119
|
+
return not _is_literal(elements[0])
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _all_equal(elements: list[ast.expr]) -> bool:
|
|
123
|
+
"""True if every element is structurally identical (a homogeneous pair/tuple)."""
|
|
124
|
+
first = elements[0]
|
|
125
|
+
return all(_ast_equal(el, first) for el in elements[1:])
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _is_ellipsis(node: ast.expr) -> bool:
|
|
129
|
+
return isinstance(node, ast.Constant) and node.value is Ellipsis
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _is_literal(node: ast.expr) -> bool:
|
|
133
|
+
"""True if `node` is a `Literal[...]` subscript (discriminated-union tag)."""
|
|
134
|
+
return isinstance(node, ast.Subscript) and _name_of(node.value) in _LITERAL_NAMES
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _name_of(node: ast.expr) -> str | None:
|
|
138
|
+
"""Trailing name of a reference: `tuple` / `typing.Tuple` -> the trailing id."""
|
|
139
|
+
if isinstance(node, ast.Name):
|
|
140
|
+
return node.id
|
|
141
|
+
if isinstance(node, ast.Attribute):
|
|
142
|
+
return node.attr
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _ast_equal(a: object, b: object) -> bool:
|
|
147
|
+
"""Structural equality equivalent to `ast.dump(a) == ast.dump(b)` without the dump."""
|
|
148
|
+
if isinstance(a, ast.AST):
|
|
149
|
+
if type(a) is not type(b):
|
|
150
|
+
return False
|
|
151
|
+
return all(_ast_equal(getattr(a, field, None), getattr(b, field, None)) for field in a._fields)
|
|
152
|
+
if isinstance(a, list):
|
|
153
|
+
if not isinstance(b, list) or len(a) != len(b):
|
|
154
|
+
return False
|
|
155
|
+
return all(map(_ast_equal, a, b, strict=True))
|
|
156
|
+
return type(a) is type(b) and repr(a) == repr(b)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.10.1 → sarj_python_lint-0.11.0}/src/sarj_python_lint/rules/no_comment_cruft.py
RENAMED
|
File without changes
|
{sarj_python_lint-0.10.1 → sarj_python_lint-0.11.0}/src/sarj_python_lint/rules/no_fat_try_blocks.py
RENAMED
|
File without changes
|
{sarj_python_lint-0.10.1 → sarj_python_lint-0.11.0}/src/sarj_python_lint/rules/no_fstring_in_log.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.10.1 → sarj_python_lint-0.11.0}/src/sarj_python_lint/rules/no_secret_in_log.py
RENAMED
|
File without changes
|
{sarj_python_lint-0.10.1 → sarj_python_lint-0.11.0}/src/sarj_python_lint/rules/no_select_star.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.10.1 → sarj_python_lint-0.11.0}/src/sarj_python_lint/rules/prefer_class_row.py
RENAMED
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.10.1 → sarj_python_lint-0.11.0}/src/sarj_python_lint/rules/prefer_str_enum.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|