sarj-python-lint 0.10.1__tar.gz → 0.11.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.
Files changed (39) hide show
  1. {sarj_python_lint-0.10.1 → sarj_python_lint-0.11.1}/PKG-INFO +1 -1
  2. {sarj_python_lint-0.10.1 → sarj_python_lint-0.11.1}/pyproject.toml +1 -1
  3. {sarj_python_lint-0.10.1 → sarj_python_lint-0.11.1}/src/sarj_python_lint/rules/_registry.py +12 -0
  4. sarj_python_lint-0.11.1/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +89 -0
  5. sarj_python_lint-0.11.1/src/sarj_python_lint/rules/no_offset_pagination.py +98 -0
  6. sarj_python_lint-0.11.1/src/sarj_python_lint/rules/no_sleep_in_test_body.py +146 -0
  7. sarj_python_lint-0.11.1/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +156 -0
  8. {sarj_python_lint-0.10.1 → sarj_python_lint-0.11.1}/.gitignore +0 -0
  9. {sarj_python_lint-0.10.1 → sarj_python_lint-0.11.1}/README.md +0 -0
  10. {sarj_python_lint-0.10.1 → sarj_python_lint-0.11.1}/src/sarj_python_lint/__init__.py +0 -0
  11. {sarj_python_lint-0.10.1 → sarj_python_lint-0.11.1}/src/sarj_python_lint/__main__.py +0 -0
  12. {sarj_python_lint-0.10.1 → sarj_python_lint-0.11.1}/src/sarj_python_lint/_secret_names.py +0 -0
  13. {sarj_python_lint-0.10.1 → sarj_python_lint-0.11.1}/src/sarj_python_lint/py.typed +0 -0
  14. {sarj_python_lint-0.10.1 → sarj_python_lint-0.11.1}/src/sarj_python_lint/rule_base.py +0 -0
  15. {sarj_python_lint-0.10.1 → sarj_python_lint-0.11.1}/src/sarj_python_lint/rules/__init__.py +0 -0
  16. {sarj_python_lint-0.10.1 → sarj_python_lint-0.11.1}/src/sarj_python_lint/rules/_logging.py +0 -0
  17. {sarj_python_lint-0.10.1 → sarj_python_lint-0.11.1}/src/sarj_python_lint/rules/_sql.py +0 -0
  18. {sarj_python_lint-0.10.1 → sarj_python_lint-0.11.1}/src/sarj_python_lint/rules/inefficient_string_concat_in_loop.py +0 -0
  19. {sarj_python_lint-0.10.1 → sarj_python_lint-0.11.1}/src/sarj_python_lint/rules/no_aggregation_in_store_query.py +0 -0
  20. {sarj_python_lint-0.10.1 → sarj_python_lint-0.11.1}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
  21. {sarj_python_lint-0.10.1 → sarj_python_lint-0.11.1}/src/sarj_python_lint/rules/no_fat_try_blocks.py +0 -0
  22. {sarj_python_lint-0.10.1 → sarj_python_lint-0.11.1}/src/sarj_python_lint/rules/no_fstring_in_log.py +0 -0
  23. {sarj_python_lint-0.10.1 → sarj_python_lint-0.11.1}/src/sarj_python_lint/rules/no_isinstance_union_chain.py +0 -0
  24. {sarj_python_lint-0.10.1 → sarj_python_lint-0.11.1}/src/sarj_python_lint/rules/no_query_with_many_joins.py +0 -0
  25. {sarj_python_lint-0.10.1 → sarj_python_lint-0.11.1}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
  26. {sarj_python_lint-0.10.1 → sarj_python_lint-0.11.1}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  27. {sarj_python_lint-0.10.1 → sarj_python_lint-0.11.1}/src/sarj_python_lint/rules/no_select_star.py +0 -0
  28. {sarj_python_lint-0.10.1 → sarj_python_lint-0.11.1}/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +0 -0
  29. {sarj_python_lint-0.10.1 → sarj_python_lint-0.11.1}/src/sarj_python_lint/rules/no_sequential_await.py +0 -0
  30. {sarj_python_lint-0.10.1 → sarj_python_lint-0.11.1}/src/sarj_python_lint/rules/no_unreachable_after_terminal.py +0 -0
  31. {sarj_python_lint-0.10.1 → sarj_python_lint-0.11.1}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
  32. {sarj_python_lint-0.10.1 → sarj_python_lint-0.11.1}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
  33. {sarj_python_lint-0.10.1 → sarj_python_lint-0.11.1}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
  34. {sarj_python_lint-0.10.1 → sarj_python_lint-0.11.1}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
  35. {sarj_python_lint-0.10.1 → sarj_python_lint-0.11.1}/src/sarj_python_lint/rules/prefer_timedelta_for_durations.py +0 -0
  36. {sarj_python_lint-0.10.1 → sarj_python_lint-0.11.1}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +0 -0
  37. {sarj_python_lint-0.10.1 → sarj_python_lint-0.11.1}/src/sarj_python_lint/rules/single_public_export.py +0 -0
  38. {sarj_python_lint-0.10.1 → sarj_python_lint-0.11.1}/src/sarj_python_lint/rules/stepdown.py +0 -0
  39. {sarj_python_lint-0.10.1 → sarj_python_lint-0.11.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.10.1
3
+ Version: 0.11.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,6 +1,6 @@
1
1
  [project]
2
2
  name = "sarj-python-lint"
3
- version = "0.10.1"
3
+ version = "0.11.1"
4
4
  description = "Custom Python lint rules — AST-based, pre-commit-friendly, hypermodern defaults"
5
5
  readme = "README.md"
6
6
  authors = [{ name = "sarj-ai" }]
@@ -9,15 +9,20 @@ from sarj_python_lint.rules.no_aggregation_in_store_query import (
9
9
  NoAggregationInStoreQuery,
10
10
  )
11
11
  from sarj_python_lint.rules.no_comment_cruft import NoCommentCruft
12
+ from sarj_python_lint.rules.no_cors_wildcard_with_credentials import (
13
+ NoCorsWildcardWithCredentials,
14
+ )
12
15
  from sarj_python_lint.rules.no_fat_try_blocks import NoFatTryBlocks
13
16
  from sarj_python_lint.rules.no_fstring_in_log import NoFstringInLog
14
17
  from sarj_python_lint.rules.no_isinstance_union_chain import NoIsinstanceUnionChain
18
+ from sarj_python_lint.rules.no_offset_pagination import NoOffsetPagination
15
19
  from sarj_python_lint.rules.no_query_with_many_joins import NoQueryWithManyJoins
16
20
  from sarj_python_lint.rules.no_repeated_string_literal import NoRepeatedStringLiteral
17
21
  from sarj_python_lint.rules.no_secret_in_log import NoSecretInLog
18
22
  from sarj_python_lint.rules.no_select_star import NoSelectStar
19
23
  from sarj_python_lint.rules.no_sentinel_return_on_except import NoSentinelReturnOnExcept
20
24
  from sarj_python_lint.rules.no_sequential_await import NoSequentialAwait
25
+ from sarj_python_lint.rules.no_sleep_in_test_body import NoSleepInTestBody
21
26
  from sarj_python_lint.rules.no_unreachable_after_terminal import (
22
27
  NoUnreachableAfterTerminal,
23
28
  )
@@ -25,6 +30,9 @@ from sarj_python_lint.rules.prefer_class_row import PreferClassRow
25
30
  from sarj_python_lint.rules.prefer_constant_time_secret_compare import (
26
31
  PreferConstantTimeSecretCompare,
27
32
  )
33
+ from sarj_python_lint.rules.prefer_namedtuple_over_tuple_return import (
34
+ PreferNamedtupleOverTupleReturn,
35
+ )
28
36
  from sarj_python_lint.rules.prefer_str_enum import PreferStrEnum
29
37
  from sarj_python_lint.rules.prefer_struct_over_namedtuple import (
30
38
  PreferStructOverNamedtuple,
@@ -51,6 +59,10 @@ REGISTRY: dict[str, type[Rule]] = {
51
59
  PreferStrEnum.id: PreferStrEnum,
52
60
  NoFatTryBlocks.id: NoFatTryBlocks,
53
61
  NoIsinstanceUnionChain.id: NoIsinstanceUnionChain,
62
+ NoOffsetPagination.id: NoOffsetPagination,
63
+ PreferNamedtupleOverTupleReturn.id: PreferNamedtupleOverTupleReturn,
64
+ NoCorsWildcardWithCredentials.id: NoCorsWildcardWithCredentials,
65
+ NoSleepInTestBody.id: NoSleepInTestBody,
54
66
  PydanticAtBoundaries.id: PydanticAtBoundaries,
55
67
  NoSentinelReturnOnExcept.id: NoSentinelReturnOnExcept,
56
68
  NoUnreachableAfterTerminal.id: NoUnreachableAfterTerminal,
@@ -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,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)