sarj-python-lint 0.11.0__tar.gz → 0.12.0__tar.gz

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