sarj-python-lint 0.7.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.
- {sarj_python_lint-0.7.0 → sarj_python_lint-0.9.0}/.gitignore +4 -0
- {sarj_python_lint-0.7.0 → sarj_python_lint-0.9.0}/PKG-INFO +1 -2
- {sarj_python_lint-0.7.0 → sarj_python_lint-0.9.0}/README.md +0 -1
- {sarj_python_lint-0.7.0 → sarj_python_lint-0.9.0}/pyproject.toml +1 -1
- sarj_python_lint-0.9.0/src/sarj_python_lint/__init__.py +9 -0
- {sarj_python_lint-0.7.0 → sarj_python_lint-0.9.0}/src/sarj_python_lint/__main__.py +61 -4
- sarj_python_lint-0.9.0/src/sarj_python_lint/_secret_names.py +126 -0
- {sarj_python_lint-0.7.0 → sarj_python_lint-0.9.0}/src/sarj_python_lint/rule_base.py +18 -0
- {sarj_python_lint-0.7.0 → sarj_python_lint-0.9.0}/src/sarj_python_lint/rules/_logging.py +1 -1
- {sarj_python_lint-0.7.0 → sarj_python_lint-0.9.0}/src/sarj_python_lint/rules/_registry.py +6 -2
- sarj_python_lint-0.9.0/src/sarj_python_lint/rules/_sql.py +81 -0
- {sarj_python_lint-0.7.0 → sarj_python_lint-0.9.0}/src/sarj_python_lint/rules/inefficient_string_concat_in_loop.py +44 -20
- sarj_python_lint-0.9.0/src/sarj_python_lint/rules/no_aggregation_in_store_query.py +183 -0
- {sarj_python_lint-0.7.0 → sarj_python_lint-0.9.0}/src/sarj_python_lint/rules/no_comment_cruft.py +16 -13
- sarj_python_lint-0.9.0/src/sarj_python_lint/rules/no_fat_try_blocks.py +183 -0
- {sarj_python_lint-0.7.0 → sarj_python_lint-0.9.0}/src/sarj_python_lint/rules/no_fstring_in_log.py +21 -8
- {sarj_python_lint-0.7.0 → sarj_python_lint-0.9.0}/src/sarj_python_lint/rules/no_isinstance_union_chain.py +30 -25
- {sarj_python_lint-0.7.0 → sarj_python_lint-0.9.0}/src/sarj_python_lint/rules/no_query_with_many_joins.py +11 -27
- sarj_python_lint-0.9.0/src/sarj_python_lint/rules/no_repeated_string_literal.py +166 -0
- {sarj_python_lint-0.7.0 → sarj_python_lint-0.9.0}/src/sarj_python_lint/rules/no_secret_in_log.py +17 -16
- {sarj_python_lint-0.7.0 → sarj_python_lint-0.9.0}/src/sarj_python_lint/rules/no_select_star.py +48 -32
- sarj_python_lint-0.9.0/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +304 -0
- {sarj_python_lint-0.7.0 → sarj_python_lint-0.9.0}/src/sarj_python_lint/rules/no_sequential_await.py +50 -10
- {sarj_python_lint-0.7.0 → sarj_python_lint-0.9.0}/src/sarj_python_lint/rules/no_unreachable_after_terminal.py +31 -13
- {sarj_python_lint-0.7.0 → sarj_python_lint-0.9.0}/src/sarj_python_lint/rules/prefer_class_row.py +5 -4
- {sarj_python_lint-0.7.0 → sarj_python_lint-0.9.0}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +26 -22
- {sarj_python_lint-0.7.0 → sarj_python_lint-0.9.0}/src/sarj_python_lint/rules/prefer_str_enum.py +133 -99
- {sarj_python_lint-0.7.0 → sarj_python_lint-0.9.0}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +15 -28
- {sarj_python_lint-0.7.0 → sarj_python_lint-0.9.0}/src/sarj_python_lint/rules/prefer_timedelta_for_durations.py +66 -5
- {sarj_python_lint-0.7.0 → sarj_python_lint-0.9.0}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +27 -10
- sarj_python_lint-0.9.0/src/sarj_python_lint/rules/single_public_export.py +137 -0
- sarj_python_lint-0.9.0/src/sarj_python_lint/rules/stepdown.py +436 -0
- {sarj_python_lint-0.7.0 → sarj_python_lint-0.9.0}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +15 -30
- sarj_python_lint-0.7.0/src/sarj_python_lint/__init__.py +0 -3
- sarj_python_lint-0.7.0/src/sarj_python_lint/rules/no_aggregation_in_store_query.py +0 -134
- sarj_python_lint-0.7.0/src/sarj_python_lint/rules/no_fat_try_blocks.py +0 -106
- sarj_python_lint-0.7.0/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +0 -119
- sarj_python_lint-0.7.0/src/sarj_python_lint/rules/prefer_discriminated_union.py +0 -391
- {sarj_python_lint-0.7.0 → sarj_python_lint-0.9.0}/src/sarj_python_lint/py.typed +0 -0
- {sarj_python_lint-0.7.0 → sarj_python_lint-0.9.0}/src/sarj_python_lint/rules/__init__.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.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
|
|
@@ -32,7 +32,6 @@ uv tool install sarj-python-lint
|
|
|
32
32
|
hooks:
|
|
33
33
|
- id: sarj-no-sequential-await
|
|
34
34
|
- id: sarj-inefficient-string-concat-in-loop
|
|
35
|
-
- id: sarj-prefer-discriminated-union
|
|
36
35
|
- id: sarj-prefer-str-enum
|
|
37
36
|
- id: sarj-no-fat-try-blocks
|
|
38
37
|
- id: sarj-pydantic-at-boundaries
|
|
@@ -14,7 +14,6 @@ uv tool install sarj-python-lint
|
|
|
14
14
|
hooks:
|
|
15
15
|
- id: sarj-no-sequential-await
|
|
16
16
|
- id: sarj-inefficient-string-concat-in-loop
|
|
17
|
-
- id: sarj-prefer-discriminated-union
|
|
18
17
|
- id: sarj-prefer-str-enum
|
|
19
18
|
- id: sarj-no-fat-try-blocks
|
|
20
19
|
- id: sarj-pydantic-at-boundaries
|
|
@@ -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",
|
|
15
|
-
"
|
|
16
|
-
"
|
|
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", "
|
|
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
|
|
@@ -24,7 +25,6 @@ from sarj_python_lint.rules.prefer_class_row import PreferClassRow
|
|
|
24
25
|
from sarj_python_lint.rules.prefer_constant_time_secret_compare import (
|
|
25
26
|
PreferConstantTimeSecretCompare,
|
|
26
27
|
)
|
|
27
|
-
from sarj_python_lint.rules.prefer_discriminated_union import PreferDiscriminatedUnion
|
|
28
28
|
from sarj_python_lint.rules.prefer_str_enum import PreferStrEnum
|
|
29
29
|
from sarj_python_lint.rules.prefer_struct_over_namedtuple import (
|
|
30
30
|
PreferStructOverNamedtuple,
|
|
@@ -33,6 +33,8 @@ from sarj_python_lint.rules.prefer_timedelta_for_durations import (
|
|
|
33
33
|
PreferTimedeltaForDurations,
|
|
34
34
|
)
|
|
35
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
|
|
36
38
|
from sarj_python_lint.rules.store_insert_requires_on_conflict import (
|
|
37
39
|
StoreInsertRequiresOnConflict,
|
|
38
40
|
)
|
|
@@ -45,7 +47,6 @@ if TYPE_CHECKING:
|
|
|
45
47
|
REGISTRY: dict[str, type[Rule]] = {
|
|
46
48
|
NoSequentialAwait.id: NoSequentialAwait,
|
|
47
49
|
InefficientStringConcatInLoop.id: InefficientStringConcatInLoop,
|
|
48
|
-
PreferDiscriminatedUnion.id: PreferDiscriminatedUnion,
|
|
49
50
|
PreferClassRow.id: PreferClassRow,
|
|
50
51
|
PreferStrEnum.id: PreferStrEnum,
|
|
51
52
|
NoFatTryBlocks.id: NoFatTryBlocks,
|
|
@@ -63,6 +64,9 @@ REGISTRY: dict[str, type[Rule]] = {
|
|
|
63
64
|
NoQueryWithManyJoins.id: NoQueryWithManyJoins,
|
|
64
65
|
NoAggregationInStoreQuery.id: NoAggregationInStoreQuery,
|
|
65
66
|
NoSelectStar.id: NoSelectStar,
|
|
67
|
+
SinglePublicExport.id: SinglePublicExport,
|
|
68
|
+
Stepdown.id: Stepdown,
|
|
69
|
+
NoRepeatedStringLiteral.id: NoRepeatedStringLiteral,
|
|
66
70
|
}
|
|
67
71
|
|
|
68
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
|
-
|
|
34
|
-
|
|
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
|
|
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.
|
|
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)
|
|
91
|
-
|
|
92
|
-
|
|
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
|