sarj-python-lint 0.8.0__tar.gz → 0.9.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 (39) hide show
  1. {sarj_python_lint-0.8.0 → sarj_python_lint-0.9.0}/.gitignore +4 -0
  2. {sarj_python_lint-0.8.0 → sarj_python_lint-0.9.0}/PKG-INFO +1 -1
  3. {sarj_python_lint-0.8.0 → sarj_python_lint-0.9.0}/pyproject.toml +1 -1
  4. sarj_python_lint-0.9.0/src/sarj_python_lint/__init__.py +9 -0
  5. {sarj_python_lint-0.8.0 → sarj_python_lint-0.9.0}/src/sarj_python_lint/__main__.py +61 -4
  6. sarj_python_lint-0.9.0/src/sarj_python_lint/_secret_names.py +126 -0
  7. {sarj_python_lint-0.8.0 → sarj_python_lint-0.9.0}/src/sarj_python_lint/rule_base.py +18 -0
  8. {sarj_python_lint-0.8.0 → sarj_python_lint-0.9.0}/src/sarj_python_lint/rules/_logging.py +1 -1
  9. {sarj_python_lint-0.8.0 → sarj_python_lint-0.9.0}/src/sarj_python_lint/rules/_registry.py +6 -0
  10. sarj_python_lint-0.9.0/src/sarj_python_lint/rules/_sql.py +81 -0
  11. {sarj_python_lint-0.8.0 → sarj_python_lint-0.9.0}/src/sarj_python_lint/rules/inefficient_string_concat_in_loop.py +44 -20
  12. sarj_python_lint-0.9.0/src/sarj_python_lint/rules/no_aggregation_in_store_query.py +183 -0
  13. {sarj_python_lint-0.8.0 → sarj_python_lint-0.9.0}/src/sarj_python_lint/rules/no_comment_cruft.py +16 -13
  14. sarj_python_lint-0.9.0/src/sarj_python_lint/rules/no_fat_try_blocks.py +183 -0
  15. {sarj_python_lint-0.8.0 → sarj_python_lint-0.9.0}/src/sarj_python_lint/rules/no_fstring_in_log.py +21 -8
  16. {sarj_python_lint-0.8.0 → sarj_python_lint-0.9.0}/src/sarj_python_lint/rules/no_isinstance_union_chain.py +30 -25
  17. {sarj_python_lint-0.8.0 → sarj_python_lint-0.9.0}/src/sarj_python_lint/rules/no_query_with_many_joins.py +11 -27
  18. sarj_python_lint-0.9.0/src/sarj_python_lint/rules/no_repeated_string_literal.py +166 -0
  19. {sarj_python_lint-0.8.0 → sarj_python_lint-0.9.0}/src/sarj_python_lint/rules/no_secret_in_log.py +17 -16
  20. {sarj_python_lint-0.8.0 → sarj_python_lint-0.9.0}/src/sarj_python_lint/rules/no_select_star.py +48 -32
  21. sarj_python_lint-0.9.0/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +304 -0
  22. {sarj_python_lint-0.8.0 → sarj_python_lint-0.9.0}/src/sarj_python_lint/rules/no_sequential_await.py +50 -10
  23. {sarj_python_lint-0.8.0 → sarj_python_lint-0.9.0}/src/sarj_python_lint/rules/no_unreachable_after_terminal.py +31 -13
  24. {sarj_python_lint-0.8.0 → sarj_python_lint-0.9.0}/src/sarj_python_lint/rules/prefer_class_row.py +5 -4
  25. {sarj_python_lint-0.8.0 → sarj_python_lint-0.9.0}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +26 -22
  26. {sarj_python_lint-0.8.0 → sarj_python_lint-0.9.0}/src/sarj_python_lint/rules/prefer_str_enum.py +133 -99
  27. {sarj_python_lint-0.8.0 → sarj_python_lint-0.9.0}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +15 -28
  28. {sarj_python_lint-0.8.0 → sarj_python_lint-0.9.0}/src/sarj_python_lint/rules/prefer_timedelta_for_durations.py +66 -5
  29. {sarj_python_lint-0.8.0 → sarj_python_lint-0.9.0}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +27 -10
  30. sarj_python_lint-0.9.0/src/sarj_python_lint/rules/single_public_export.py +137 -0
  31. sarj_python_lint-0.9.0/src/sarj_python_lint/rules/stepdown.py +436 -0
  32. {sarj_python_lint-0.8.0 → sarj_python_lint-0.9.0}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +15 -30
  33. sarj_python_lint-0.8.0/src/sarj_python_lint/__init__.py +0 -3
  34. sarj_python_lint-0.8.0/src/sarj_python_lint/rules/no_aggregation_in_store_query.py +0 -134
  35. sarj_python_lint-0.8.0/src/sarj_python_lint/rules/no_fat_try_blocks.py +0 -106
  36. sarj_python_lint-0.8.0/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +0 -119
  37. {sarj_python_lint-0.8.0 → sarj_python_lint-0.9.0}/README.md +0 -0
  38. {sarj_python_lint-0.8.0 → sarj_python_lint-0.9.0}/src/sarj_python_lint/py.typed +0 -0
  39. {sarj_python_lint-0.8.0 → sarj_python_lint-0.9.0}/src/sarj_python_lint/rules/__init__.py +0 -0
@@ -30,3 +30,7 @@ Thumbs.db
30
30
  .vscode/
31
31
  .zed/
32
32
  *.swp
33
+
34
+ # Claude Code local state (shared settings.json is tracked)
35
+ .claude/*
36
+ !.claude/settings.json
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sarj-python-lint
3
- Version: 0.8.0
3
+ Version: 0.9.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.8.0"
3
+ version = "0.9.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" }]
@@ -0,0 +1,9 @@
1
+ """sarj-python-lint — custom Python + SQL lint rules."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+
6
+ try:
7
+ __version__ = version("sarj-python-lint")
8
+ except PackageNotFoundError: # running from an uninstalled source tree
9
+ __version__ = "0.0.0.dev0"
@@ -1,7 +1,10 @@
1
- """CLI: sarj-python-lint check --rule <id> [--rule <id2>] <files>"""
1
+ """CLI: sarj-python-lint check --rule <id> [--rule <id2>] [--baseline <json>] <files>"""
2
+
2
3
  from __future__ import annotations
3
4
 
4
5
  import argparse
6
+ from collections import Counter
7
+ import json
5
8
  from pathlib import Path
6
9
  import sys
7
10
 
@@ -11,9 +14,21 @@ from sarj_python_lint.rules import REGISTRY
11
14
 
12
15
 
13
16
  SKIP_DIR_NAMES = {
14
- "node_modules", ".venv", "venv", ".git", "dist", "build", ".next",
15
- "coverage", "__pycache__", ".pytest_cache", ".ruff_cache", ".mypy_cache",
16
- ".turbo", ".yarn", ".pnpm-store",
17
+ "node_modules",
18
+ ".venv",
19
+ "venv",
20
+ ".git",
21
+ "dist",
22
+ "build",
23
+ ".next",
24
+ "coverage",
25
+ "__pycache__",
26
+ ".pytest_cache",
27
+ ".ruff_cache",
28
+ ".mypy_cache",
29
+ ".turbo",
30
+ ".yarn",
31
+ ".pnpm-store",
17
32
  }
18
33
 
19
34
  # Skip files larger than this — they are almost always generated/vendored, not
@@ -70,12 +85,36 @@ class _Args(argparse.Namespace):
70
85
  cmd: str | None
71
86
  rule: list[str]
72
87
  files: list[Path]
88
+ baseline: Path | None
89
+ update_baseline: Path | None
73
90
 
74
91
  def __init__(self) -> None:
75
92
  super().__init__()
76
93
  self.cmd = None
77
94
  self.rule = []
78
95
  self.files = []
96
+ self.baseline = None
97
+ self.update_baseline = None
98
+
99
+
100
+ def _baseline_counts(diags: list[Diagnostic]) -> dict[str, dict[str, int]]:
101
+ counts: dict[str, dict[str, int]] = {}
102
+ for d in diags:
103
+ counts.setdefault(str(d.path), {})
104
+ counts[str(d.path)][d.code] = counts[str(d.path)].get(d.code, 0) + 1
105
+ return counts
106
+
107
+
108
+ def _apply_baseline(diags: list[Diagnostic], baseline: dict[str, dict[str, int]]) -> list[Diagnostic]:
109
+ """Suppress up to the baselined count per (path, code); excess diags survive."""
110
+ seen: Counter[tuple[str, str]] = Counter()
111
+ out: list[Diagnostic] = []
112
+ for d in diags:
113
+ key = (str(d.path), d.code)
114
+ seen[key] += 1
115
+ if seen[key] > baseline.get(key[0], {}).get(key[1], 0):
116
+ out.append(d)
117
+ return out
79
118
 
80
119
 
81
120
  def main(argv: list[str] | None = None) -> int:
@@ -93,6 +132,16 @@ def main(argv: list[str] | None = None) -> int:
93
132
  required=True,
94
133
  help="Rule ID (repeat for multiple).",
95
134
  )
135
+ check_p.add_argument(
136
+ "--baseline",
137
+ type=Path,
138
+ help="Per-file shrink-only baseline JSON: {path: {CODE: count}}. Diags up to the baselined count are suppressed.",
139
+ )
140
+ check_p.add_argument(
141
+ "--update-baseline",
142
+ type=Path,
143
+ help="Write the current per-file diagnostic counts to this JSON and exit 0.",
144
+ )
96
145
  check_p.add_argument("files", nargs="+", type=Path)
97
146
 
98
147
  sub.add_parser("list-rules", help="List available rule IDs.")
@@ -106,6 +155,14 @@ def main(argv: list[str] | None = None) -> int:
106
155
  return 0
107
156
 
108
157
  diags = _check(args.rule, args.files)
158
+ if args.update_baseline is not None:
159
+ args.update_baseline.write_text(json.dumps(_baseline_counts(diags), indent=2, sort_keys=True) + "\n")
160
+ sys.stdout.write(
161
+ f"baseline written: {args.update_baseline} ({len(diags)} diagnostics over {len(_baseline_counts(diags))} files)\n"
162
+ )
163
+ return 0
164
+ if args.baseline is not None:
165
+ diags = _apply_baseline(diags, json.loads(args.baseline.read_text()))
109
166
  for d in diags:
110
167
  sys.stdout.write(d.format() + "\n")
111
168
  return 1 if diags else 0
@@ -0,0 +1,126 @@
1
+ """Shared predicate for deciding whether an identifier names secret material.
2
+
3
+ Used by both SARJ011 (`prefer-constant-time-secret-compare`) and SARJ012
4
+ (`no-secret-in-log`) so the two rules never diverge on what counts as a secret.
5
+
6
+ The historical implementation matched a secret word as a bare *substring*, which
7
+ misfired on a large false-positive class observed in a real audit:
8
+
9
+ - LLM usage counters that merely embed `token`: `token_count`, `prompt_tokens`,
10
+ `completion_tokens`, `total_tokens`, `max_tokens`, `n_tokens`, `num_tokens`,
11
+ `tokenize`, `tokenizer`, `token_budget`.
12
+ - Row-id / handle names: `api_key_id`, `*_key_id` — the id of a key row, not the
13
+ key material.
14
+ - Boolean feature / presence / state flags: `password_enabled`,
15
+ `token_present`, `password_set`, `password_configured` — a boolean answering
16
+ "is it there / was it set", not the credential itself. A `type` discriminator
17
+ is the same: `token_type` is `"Bearer"`, `credential_type` is a class name.
18
+ - Innocent words embedding a secret word: `secretary` (embeds `secret`).
19
+
20
+ We fix this with two changes:
21
+
22
+ 1. Match a secret word only as a WHOLE token (after snake_case / camelCase
23
+ splitting), never a substring. This alone clears `tokenize`, `tokenizer`,
24
+ `secretary`, and every *pluralized* `tokens` counter (plural `tokens` is not
25
+ the singular secret word `token`).
26
+ 2. Disqualify an identifier whose TRAILING token is a counter / row-id / flag
27
+ marker (`count`, `budget`, `id`, `enabled`, ...) even when a secret word is
28
+ also present — this clears `token_count`, `api_key_id`, `password_enabled`,
29
+ while still catching a credential that merely leads with such a word
30
+ (`valid_token`, `present_token` are secrets, not flags).
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ from itertools import pairwise
36
+ import re
37
+
38
+
39
+ _SECRET_WORDS = frozenset(
40
+ {
41
+ "token",
42
+ "secret",
43
+ "password",
44
+ "passwd",
45
+ "jwt",
46
+ "secrets",
47
+ "passwords",
48
+ "credential",
49
+ "credentials",
50
+ "authorization",
51
+ "signature",
52
+ "hmac",
53
+ "digest",
54
+ "hash",
55
+ "apikey",
56
+ }
57
+ )
58
+
59
+ # Tokens that mark a counter, row-id, feature flag, or boolean presence/state
60
+ # marker. As the TRAILING token they mean the identifier is metadata *about* a
61
+ # secret, not the secret itself, so it is not a leak / timing surface even when a
62
+ # secret word is also present: `token_present`, `password_set`, and
63
+ # `password_configured` are booleans, not credentials. Leading such a word does
64
+ # not disqualify — `valid_token` / `present_token` are credentials.
65
+ _INNOCUOUS_WORDS = frozenset(
66
+ {
67
+ "count",
68
+ "counts",
69
+ "budget",
70
+ "limit",
71
+ "limits",
72
+ "id",
73
+ "ids",
74
+ "enabled",
75
+ "disabled",
76
+ "flag",
77
+ "flags",
78
+ "present",
79
+ "set",
80
+ "unset",
81
+ "configured",
82
+ "missing",
83
+ "required",
84
+ "valid",
85
+ "invalid",
86
+ "exists",
87
+ "type",
88
+ "types",
89
+ }
90
+ )
91
+
92
+ # camelCase / PascalCase / ALLCAPS / digit run splitter, applied to each
93
+ # snake/kebab segment. `APIKey` -> ["API", "Key"], `authToken` -> ["auth", "Token"].
94
+ _CAMEL_RE = re.compile(r"[A-Z]+(?=[A-Z][a-z])|[A-Z]?[a-z]+|[A-Z]+|\d+")
95
+ _SEGMENT_RE = re.compile(r"[^A-Za-z0-9]+")
96
+
97
+
98
+ def _tokens(identifier: str) -> list[str]:
99
+ """Ordered lowercase tokens from snake_case + camelCase decomposition.
100
+
101
+ Also yields each whole snake/kebab segment lowercased, so a pathological
102
+ mixed-case single word like `ToKeN` (which camel-splitting shreds into
103
+ `to`/`ke`/`n`) still surfaces its intended `token` form.
104
+ """
105
+ tokens: list[str] = []
106
+ for segment in _SEGMENT_RE.split(identifier):
107
+ if not segment:
108
+ continue
109
+ tokens.append(segment.lower())
110
+ tokens.extend(part.lower() for part in _CAMEL_RE.findall(segment))
111
+ return tokens
112
+
113
+
114
+ def is_secret_name(identifier: str) -> bool:
115
+ """True if `identifier` names raw secret material (a credential, not metadata)."""
116
+ tokens = _tokens(identifier)
117
+ if tokens and tokens[-1] in _INNOCUOUS_WORDS:
118
+ return False
119
+ if any(tok in _SECRET_WORDS for tok in tokens):
120
+ return True
121
+ return _has_api_key(tokens)
122
+
123
+
124
+ def _has_api_key(tokens: list[str]) -> bool:
125
+ """True if `api` is immediately followed by `key` (the split form of `api_key`)."""
126
+ return any(a == "api" and b == "key" for a, b in pairwise(tokens))
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from abc import ABC, abstractmethod
6
+ import ast
6
7
  from dataclasses import dataclass
7
8
  import re
8
9
  from typing import TYPE_CHECKING
@@ -75,3 +76,20 @@ class Rule(ABC):
75
76
  def check(self, path: Path, source: str) -> list[Diagnostic]:
76
77
  """Inspect the given source. Return zero or more diagnostics."""
77
78
  raise NotImplementedError
79
+
80
+
81
+ _last_parse: tuple[tuple[str, int, int], ast.Module | None] | None = None
82
+
83
+
84
+ def parse_or_none(path: Path, source: str) -> ast.Module | None:
85
+ """Parse `source`, memoizing the most recent file so N rules share one parse."""
86
+ global _last_parse # ruff:ignore[global-statement] — single-slot memo; the CLI runs rules per file sequentially
87
+ key = (str(path), len(source), hash(source))
88
+ if _last_parse is not None and _last_parse[0] == key:
89
+ return _last_parse[1]
90
+ try:
91
+ tree = ast.parse(source, filename=str(path))
92
+ except SyntaxError:
93
+ tree = None
94
+ _last_parse = (key, tree)
95
+ return tree
@@ -12,7 +12,7 @@ import ast
12
12
 
13
13
  _LOGGER_NAMES = frozenset({"logger", "log", "logging", "loguru", "_logger", "_log"})
14
14
 
15
- _LOGGER_FACTORIES = frozenset({"getlogger", "getchild"})
15
+ _LOGGER_FACTORIES = frozenset({"getlogger", "get_logger"})
16
16
 
17
17
 
18
18
  def is_logger_expr(expr: ast.expr) -> bool:
@@ -13,6 +13,7 @@ from sarj_python_lint.rules.no_fat_try_blocks import NoFatTryBlocks
13
13
  from sarj_python_lint.rules.no_fstring_in_log import NoFstringInLog
14
14
  from sarj_python_lint.rules.no_isinstance_union_chain import NoIsinstanceUnionChain
15
15
  from sarj_python_lint.rules.no_query_with_many_joins import NoQueryWithManyJoins
16
+ from sarj_python_lint.rules.no_repeated_string_literal import NoRepeatedStringLiteral
16
17
  from sarj_python_lint.rules.no_secret_in_log import NoSecretInLog
17
18
  from sarj_python_lint.rules.no_select_star import NoSelectStar
18
19
  from sarj_python_lint.rules.no_sentinel_return_on_except import NoSentinelReturnOnExcept
@@ -32,6 +33,8 @@ from sarj_python_lint.rules.prefer_timedelta_for_durations import (
32
33
  PreferTimedeltaForDurations,
33
34
  )
34
35
  from sarj_python_lint.rules.pydantic_at_boundaries import PydanticAtBoundaries
36
+ from sarj_python_lint.rules.single_public_export import SinglePublicExport
37
+ from sarj_python_lint.rules.stepdown import Stepdown
35
38
  from sarj_python_lint.rules.store_insert_requires_on_conflict import (
36
39
  StoreInsertRequiresOnConflict,
37
40
  )
@@ -61,6 +64,9 @@ REGISTRY: dict[str, type[Rule]] = {
61
64
  NoQueryWithManyJoins.id: NoQueryWithManyJoins,
62
65
  NoAggregationInStoreQuery.id: NoAggregationInStoreQuery,
63
66
  NoSelectStar.id: NoSelectStar,
67
+ SinglePublicExport.id: SinglePublicExport,
68
+ Stepdown.id: Stepdown,
69
+ NoRepeatedStringLiteral.id: NoRepeatedStringLiteral,
64
70
  }
65
71
 
66
72
  __all__ = ["REGISTRY"]
@@ -0,0 +1,81 @@
1
+ """Shared SQL-literal helpers for the SQL-aware store-lint rules (SARJ018-021).
2
+
3
+ These rules scan raw SQL embedded in Python string literals for keywords
4
+ (`JOIN`, `COUNT`, `ON CONFLICT`, `*`, ...). Scanning the raw text conflates SQL
5
+ *code* with SQL string-literal *values*: `WHERE p = 'join'` holds no JOIN, a
6
+ `--` inside a quoted value is not a comment, and a backtick inside a value is not
7
+ a BigQuery table quote. `strip_sql_noise` neutralizes both classes of noise
8
+ before any keyword or comment scan.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import ast
14
+
15
+
16
+ def sql_string_value(node: ast.expr) -> str | None:
17
+ """Reconstruct a (possibly `+`-concatenated) string literal, else None."""
18
+ if isinstance(node, ast.Constant) and isinstance(node.value, str):
19
+ return node.value
20
+ if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add):
21
+ left = sql_string_value(node.left)
22
+ right = sql_string_value(node.right)
23
+ if left is not None and right is not None:
24
+ return left + right
25
+ return None
26
+
27
+
28
+ def strip_sql_noise(text: str) -> str:
29
+ """Blank out SQL string-literal contents and comment bodies.
30
+
31
+ A single left-to-right scan, so precedence between strings and comments is
32
+ correct: a `--` or quote inside a string literal is protected (masked as
33
+ string data, never read as a comment), and a quote inside a comment is
34
+ ignored. Every masked character becomes a space except newlines, which are
35
+ preserved so line offsets — and therefore diagnostic positions — do not
36
+ shift. Doubled quotes (`''` / `""`) are SQL's in-string escape and keep the
37
+ scanner inside the literal.
38
+ """
39
+ out = list(text)
40
+ n = len(text)
41
+ i = 0
42
+ while i < n:
43
+ ch = text[i]
44
+ if ch in {"'", '"'}:
45
+ out[i] = " "
46
+ i += 1
47
+ while i < n:
48
+ c = text[i]
49
+ if c == ch:
50
+ if i + 1 < n and text[i + 1] == ch:
51
+ out[i] = out[i + 1] = " "
52
+ i += 2
53
+ continue
54
+ out[i] = " "
55
+ i += 1
56
+ break
57
+ if c != "\n":
58
+ out[i] = " "
59
+ i += 1
60
+ continue
61
+ if ch == "-" and i + 1 < n and text[i + 1] == "-":
62
+ while i < n and text[i] != "\n":
63
+ out[i] = " "
64
+ i += 1
65
+ continue
66
+ if ch == "/" and i + 1 < n and text[i + 1] == "*":
67
+ out[i] = out[i + 1] = " "
68
+ i += 2
69
+ while i < n and not (text[i] == "*" and i + 1 < n and text[i + 1] == "/"):
70
+ if text[i] != "\n":
71
+ out[i] = " "
72
+ i += 1
73
+ if i < n:
74
+ out[i] = " "
75
+ i += 1
76
+ if i < n:
77
+ out[i] = " "
78
+ i += 1
79
+ continue
80
+ i += 1
81
+ return "".join(out)
@@ -14,7 +14,7 @@ from __future__ import annotations
14
14
  import ast
15
15
  from typing import TYPE_CHECKING, override
16
16
 
17
- from sarj_python_lint.rule_base import Diagnostic, Rule
17
+ from sarj_python_lint.rule_base import Diagnostic, Rule, parse_or_none
18
18
 
19
19
 
20
20
  if TYPE_CHECKING:
@@ -30,9 +30,8 @@ class InefficientStringConcatInLoop(Rule):
30
30
 
31
31
  @override
32
32
  def check(self, path: Path, source: str) -> list[Diagnostic]:
33
- try:
34
- tree = ast.parse(source, filename=str(path))
35
- except SyntaxError:
33
+ tree = parse_or_none(path, source)
34
+ if tree is None:
36
35
  return []
37
36
  visitor = _ConcatVisitor()
38
37
  visitor.visit(tree)
@@ -42,38 +41,56 @@ class InefficientStringConcatInLoop(Rule):
42
41
  line=node.lineno,
43
42
  col=node.col_offset + 1,
44
43
  code=self.code,
45
- message=(
46
- "`+=` string concat in a loop is O(n²). "
47
- "Append to a list and `''.join(...)`."
48
- ),
44
+ message=("`+=` string concat in a loop is O(n²). Append to a list and `''.join(...)`."),
49
45
  )
50
46
  for node in visitor.hits
51
47
  ]
52
48
 
53
49
 
54
50
  class _ConcatVisitor(ast.NodeVisitor):
55
- """Single O(n) pass flagging each in-loop string `+=` exactly once."""
51
+ """Single O(n) pass flagging each in-loop string concat exactly once."""
56
52
 
57
53
  def __init__(self) -> None:
58
54
  self._loop_depth: int = 0
59
- self.hits: list[ast.AugAssign] = []
55
+ self.hits: list[ast.AugAssign | ast.Assign] = []
60
56
 
61
57
  @override
62
58
  def generic_visit(self, node: ast.AST) -> None:
63
- if isinstance(node, (ast.For, ast.While)):
59
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)):
60
+ saved = self._loop_depth
61
+ self._loop_depth = 0
62
+ super().generic_visit(node)
63
+ self._loop_depth = saved
64
+ return
65
+ if isinstance(node, (ast.For, ast.AsyncFor, ast.While)):
64
66
  self._loop_depth += 1
65
67
  super().generic_visit(node)
66
68
  self._loop_depth -= 1
67
69
  return
68
- if (
69
- self._loop_depth
70
- and isinstance(node, ast.AugAssign)
71
- and isinstance(node.op, ast.Add)
72
- and _looks_like_string(node.value)
73
- ):
70
+ if self._loop_depth and self._is_in_loop_concat(node):
74
71
  self.hits.append(node)
75
72
  super().generic_visit(node)
76
73
 
74
+ @staticmethod
75
+ def _is_in_loop_concat(node: ast.AST) -> bool:
76
+ if isinstance(node, ast.AugAssign):
77
+ return isinstance(node.op, ast.Add) and _looks_like_string(node.value)
78
+ if isinstance(node, ast.Assign):
79
+ return _looks_like_string(node.value) and any(
80
+ _target_referenced_in(target, node.value) for target in node.targets
81
+ )
82
+ return False
83
+
84
+
85
+ def _target_referenced_in(target: ast.expr, value: ast.expr) -> bool:
86
+ """True when the assignment target is read on the RHS (self-accumulation)."""
87
+ target_src = ast.unparse(target)
88
+ return any(
89
+ ast.unparse(sub) == target_src
90
+ for sub in ast.walk(value)
91
+ if isinstance(sub, (ast.Name, ast.Attribute, ast.Subscript))
92
+ )
93
+
77
94
 
78
95
  def _looks_like_string(node: ast.AST) -> bool:
79
96
  """Heuristic for 'this RHS is probably a string at runtime'."""
@@ -81,13 +98,20 @@ def _looks_like_string(node: ast.AST) -> bool:
81
98
  return True
82
99
  if isinstance(node, ast.JoinedStr): # f-string
83
100
  return True
101
+ if isinstance(node, ast.NamedExpr): # walrus `(y := <str>)`
102
+ return _looks_like_string(node.value)
103
+ if isinstance(node, ast.IfExp): # ternary — string only if both branches are
104
+ return _looks_like_string(node.body) and _looks_like_string(node.orelse)
84
105
  if isinstance(node, ast.Call):
85
106
  # str(...) / repr(...) / format / strftime — usually string
86
107
  if isinstance(node.func, ast.Name) and node.func.id in {"str", "repr", "format"}:
87
108
  return True
88
109
  if isinstance(node.func, ast.Attribute):
89
110
  return node.func.attr in {"format", "strftime", "join"}
90
- if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add):
91
- # `+` propagates string-ness if either side is a string
92
- return _looks_like_string(node.left) or _looks_like_string(node.right)
111
+ if isinstance(node, ast.BinOp):
112
+ if isinstance(node.op, ast.Add):
113
+ # `+` propagates string-ness if either side is a string
114
+ return _looks_like_string(node.left) or _looks_like_string(node.right)
115
+ if isinstance(node.op, ast.Mod): # `"row %s" % x` — left operand decides
116
+ return _looks_like_string(node.left)
93
117
  return False