sarj-python-lint 0.13.1__tar.gz → 0.14.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.13.1 → sarj_python_lint-0.14.0}/PKG-INFO +1 -1
- {sarj_python_lint-0.13.1 → sarj_python_lint-0.14.0}/pyproject.toml +1 -1
- {sarj_python_lint-0.13.1 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/_registry.py +6 -0
- sarj_python_lint-0.14.0/src/sarj_python_lint/rules/no_file_level_suppression.py +228 -0
- {sarj_python_lint-0.13.1 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/prefer_match_assert_never.py +202 -0
- sarj_python_lint-0.14.0/src/sarj_python_lint/rules/prefer_module_level_constant.py +639 -0
- {sarj_python_lint-0.13.1 → sarj_python_lint-0.14.0}/.gitignore +0 -0
- {sarj_python_lint-0.13.1 → sarj_python_lint-0.14.0}/README.md +0 -0
- {sarj_python_lint-0.13.1 → sarj_python_lint-0.14.0}/src/sarj_python_lint/__init__.py +0 -0
- {sarj_python_lint-0.13.1 → sarj_python_lint-0.14.0}/src/sarj_python_lint/__main__.py +0 -0
- {sarj_python_lint-0.13.1 → sarj_python_lint-0.14.0}/src/sarj_python_lint/_secret_names.py +0 -0
- {sarj_python_lint-0.13.1 → sarj_python_lint-0.14.0}/src/sarj_python_lint/_version.py +0 -0
- {sarj_python_lint-0.13.1 → sarj_python_lint-0.14.0}/src/sarj_python_lint/py.typed +0 -0
- {sarj_python_lint-0.13.1 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rule_base.py +0 -0
- {sarj_python_lint-0.13.1 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/__init__.py +0 -0
- {sarj_python_lint-0.13.1 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/_logging.py +0 -0
- {sarj_python_lint-0.13.1 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/_paths.py +0 -0
- {sarj_python_lint-0.13.1 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/_sql.py +0 -0
- {sarj_python_lint-0.13.1 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/inefficient_string_concat_in_loop.py +0 -0
- {sarj_python_lint-0.13.1 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/kwonly_same_type_params.py +0 -0
- {sarj_python_lint-0.13.1 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/no_aggregation_in_store_query.py +0 -0
- {sarj_python_lint-0.13.1 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
- {sarj_python_lint-0.13.1 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
- {sarj_python_lint-0.13.1 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/no_fat_try_blocks.py +0 -0
- {sarj_python_lint-0.13.1 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/no_fstring_in_log.py +0 -0
- {sarj_python_lint-0.13.1 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/no_isinstance_union_chain.py +0 -0
- {sarj_python_lint-0.13.1 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
- {sarj_python_lint-0.13.1 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/no_query_with_many_joins.py +0 -0
- {sarj_python_lint-0.13.1 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/no_raw_sql_in_tests.py +0 -0
- {sarj_python_lint-0.13.1 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
- {sarj_python_lint-0.13.1 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
- {sarj_python_lint-0.13.1 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/no_select_star.py +0 -0
- {sarj_python_lint-0.13.1 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +0 -0
- {sarj_python_lint-0.13.1 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/no_sequential_await.py +0 -0
- {sarj_python_lint-0.13.1 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/no_sleep_in_test_body.py +0 -0
- {sarj_python_lint-0.13.1 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/no_unreachable_after_terminal.py +0 -0
- {sarj_python_lint-0.13.1 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
- {sarj_python_lint-0.13.1 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
- {sarj_python_lint-0.13.1 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
- {sarj_python_lint-0.13.1 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
- {sarj_python_lint-0.13.1 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
- {sarj_python_lint-0.13.1 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/prefer_timedelta_for_durations.py +0 -0
- {sarj_python_lint-0.13.1 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +0 -0
- {sarj_python_lint-0.13.1 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/single_public_export.py +0 -0
- {sarj_python_lint-0.13.1 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/stepdown.py +0 -0
- {sarj_python_lint-0.13.1 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sarj-python-lint
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.14.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
|
|
@@ -14,6 +14,7 @@ from sarj_python_lint.rules.no_cors_wildcard_with_credentials import (
|
|
|
14
14
|
NoCorsWildcardWithCredentials,
|
|
15
15
|
)
|
|
16
16
|
from sarj_python_lint.rules.no_fat_try_blocks import NoFatTryBlocks
|
|
17
|
+
from sarj_python_lint.rules.no_file_level_suppression import NoFileLevelSuppression
|
|
17
18
|
from sarj_python_lint.rules.no_fstring_in_log import NoFstringInLog
|
|
18
19
|
from sarj_python_lint.rules.no_isinstance_union_chain import NoIsinstanceUnionChain
|
|
19
20
|
from sarj_python_lint.rules.no_offset_pagination import NoOffsetPagination
|
|
@@ -33,6 +34,9 @@ from sarj_python_lint.rules.prefer_constant_time_secret_compare import (
|
|
|
33
34
|
PreferConstantTimeSecretCompare,
|
|
34
35
|
)
|
|
35
36
|
from sarj_python_lint.rules.prefer_match_assert_never import PreferMatchAssertNever
|
|
37
|
+
from sarj_python_lint.rules.prefer_module_level_constant import (
|
|
38
|
+
PreferModuleLevelConstant,
|
|
39
|
+
)
|
|
36
40
|
from sarj_python_lint.rules.prefer_namedtuple_over_tuple_return import (
|
|
37
41
|
PreferNamedtupleOverTupleReturn,
|
|
38
42
|
)
|
|
@@ -92,6 +96,8 @@ REGISTRY: dict[str, type[Rule]] = {
|
|
|
92
96
|
PreferMatchAssertNever.id: PreferMatchAssertNever,
|
|
93
97
|
KwonlySameTypeParams.id: KwonlySameTypeParams,
|
|
94
98
|
NoRawSqlInTests.id: NoRawSqlInTests,
|
|
99
|
+
NoFileLevelSuppression.id: NoFileLevelSuppression,
|
|
100
|
+
PreferModuleLevelConstant.id: PreferModuleLevelConstant,
|
|
95
101
|
}
|
|
96
102
|
|
|
97
103
|
__all__ = ["REGISTRY"]
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""SARJ038: module-scope unscoped suppression blanket — scope it or fix the findings.
|
|
2
|
+
|
|
3
|
+
A per-line suppression is a claim about one line. A module-scope *unscoped*
|
|
4
|
+
blanket is a claim about every line in the file, forever: it switches the whole
|
|
5
|
+
checker off for the file, including the rules that do not exist yet. The next
|
|
6
|
+
person adds a function to the file and their new violations are pre-silenced by
|
|
7
|
+
a decision someone else made months ago, in a comment they will never scroll
|
|
8
|
+
past. Nothing in review or CI says a word. This is the file-level-suppression
|
|
9
|
+
escape hatch raised in review (`sarj-ai/bulbul#2881`).
|
|
10
|
+
|
|
11
|
+
A SCOPED suppression is the opposite: naming the codes it silences makes it a
|
|
12
|
+
reviewed, legible, bounded decision, and it keeps working exactly as intended
|
|
13
|
+
when new rules land. Scoped forms are NEVER flagged by this rule.
|
|
14
|
+
|
|
15
|
+
Fires on exactly three shapes:
|
|
16
|
+
|
|
17
|
+
1. Bare `# ruff: noqa` — anywhere in the file, since ruff honours the
|
|
18
|
+
file-level exemption wherever the comment appears. A trailing prose reason
|
|
19
|
+
(`# ruff: noqa — legacy module`) is still unscoped: it names no codes.
|
|
20
|
+
2. Standalone `# type: ignore` (mypy) appearing BEFORE the module's first
|
|
21
|
+
statement — that position is what makes it file-level rather than per-line.
|
|
22
|
+
3. Standalone `# pyright: ignore` before the module's first statement.
|
|
23
|
+
|
|
24
|
+
"Standalone" means the comment is the only thing on its line. "Before the first
|
|
25
|
+
statement" means above the line of the first token that is not a comment or
|
|
26
|
+
layout — a module docstring IS a statement, so a `# type: ignore` under the
|
|
27
|
+
docstring is not file-level.
|
|
28
|
+
|
|
29
|
+
Deliberately NOT flagged:
|
|
30
|
+
|
|
31
|
+
* every scoped counterpart — `# ruff: noqa: E501`, `# ruff: noqa: E501, F401`,
|
|
32
|
+
`# type: ignore[attr-defined]`, `# pyright: ignore[reportUnusedImport]`;
|
|
33
|
+
* trailing per-line suppressions — `x = foo() # type: ignore`,
|
|
34
|
+
`y = bar() # pyright: ignore` — these bind to one line by construction, and
|
|
35
|
+
their position in the file is irrelevant;
|
|
36
|
+
* a bare per-line `# noqa` with no `ruff:` prefix: it silences one line, not the
|
|
37
|
+
file, and belongs to a different rule;
|
|
38
|
+
* other `ruff:` directives that are not `noqa` (`# ruff:ignore[...]`), and
|
|
39
|
+
pyright's configuration comments (`# pyright: strict`);
|
|
40
|
+
* shebangs, encoding cookies (`# -*- coding: utf-8 -*-`) and license headers,
|
|
41
|
+
which legitimately precede the first statement.
|
|
42
|
+
|
|
43
|
+
The rule is token-based rather than AST-based because comments do not survive
|
|
44
|
+
`ast.parse`. Malformed input yields no diagnostics rather than an exception.
|
|
45
|
+
|
|
46
|
+
A file-level blanket that is genuinely justified (vendored code, a generated
|
|
47
|
+
module) is suppressed with `# sarj-noqa: SARJ038 — <reason>`, which puts the
|
|
48
|
+
reason in the diff where a reviewer sees it.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
from __future__ import annotations
|
|
52
|
+
|
|
53
|
+
from dataclasses import dataclass, replace
|
|
54
|
+
import io
|
|
55
|
+
import re
|
|
56
|
+
import tokenize
|
|
57
|
+
from typing import TYPE_CHECKING, override
|
|
58
|
+
|
|
59
|
+
from sarj_python_lint.rule_base import Diagnostic, Rule
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
if TYPE_CHECKING:
|
|
63
|
+
from pathlib import Path
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# Sentinel row for "this file has no statement at all" (empty / comments-only):
|
|
67
|
+
# every comment then counts as preceding the first statement.
|
|
68
|
+
_NO_STATEMENT_LINE = 1 << 30
|
|
69
|
+
|
|
70
|
+
# Directive heads, spelled as the tools themselves accept them: no space before
|
|
71
|
+
# the colon, optional space after. `ruff:` is matched case-insensitively (ruff
|
|
72
|
+
# accepts `NOQA`); mypy and pyright only honour their directives in lowercase.
|
|
73
|
+
_RUFF_NOQA_RE = re.compile(r"^ruff:\s*noqa(?P<rest>.*)", re.IGNORECASE)
|
|
74
|
+
_TYPE_IGNORE_RE = re.compile(r"^type:\s*ignore(?P<rest>.*)")
|
|
75
|
+
_PYRIGHT_IGNORE_RE = re.compile(r"^pyright:\s*ignore(?P<rest>.*)")
|
|
76
|
+
|
|
77
|
+
# What a SCOPED suppression looks like after the directive head: ruff lists its
|
|
78
|
+
# codes after a colon, mypy and pyright inside brackets. At least one word
|
|
79
|
+
# character is required, since a trailing bare colon names no codes either.
|
|
80
|
+
_RUFF_CODES_RE = re.compile(r"^\s*:\s*\w")
|
|
81
|
+
_BRACKET_CODES_RE = re.compile(r"^\s*\[\s*\w")
|
|
82
|
+
|
|
83
|
+
# `type: ignored` / `ruff: noqas` are different words, not the directive.
|
|
84
|
+
_WORD_CONTINUATION_RE = re.compile(r"^\w")
|
|
85
|
+
|
|
86
|
+
_LAYOUT_TOKENS = frozenset({tokenize.NL, tokenize.NEWLINE, tokenize.INDENT, tokenize.DEDENT})
|
|
87
|
+
_NON_STATEMENT_TOKENS = _LAYOUT_TOKENS | frozenset({tokenize.COMMENT, tokenize.ENCODING, tokenize.ENDMARKER})
|
|
88
|
+
|
|
89
|
+
_RUFF_MESSAGE = (
|
|
90
|
+
"bare `# ruff: noqa` exempts this entire file from every ruff rule, "
|
|
91
|
+
"including ones added later — scope it (`# ruff: noqa: E501`) or fix the findings."
|
|
92
|
+
)
|
|
93
|
+
_TYPE_IGNORE_MESSAGE = (
|
|
94
|
+
"file-level `# type: ignore` silences every mypy error in this file, "
|
|
95
|
+
"including ones added later — scope it (`# type: ignore[attr-defined]`) "
|
|
96
|
+
"or fix the findings."
|
|
97
|
+
)
|
|
98
|
+
_PYRIGHT_IGNORE_MESSAGE = (
|
|
99
|
+
"file-level `# pyright: ignore` silences every pyright diagnostic in this file, "
|
|
100
|
+
"including ones added later — scope it (`# pyright: ignore[reportUnusedImport]`) "
|
|
101
|
+
"or fix the findings."
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class NoFileLevelSuppression(Rule):
|
|
106
|
+
"""Module-scope unscoped suppression blanket — scope it to the codes it silences."""
|
|
107
|
+
|
|
108
|
+
id: str = "no-file-level-suppression"
|
|
109
|
+
code: str = "SARJ038"
|
|
110
|
+
description: str = (
|
|
111
|
+
"An unscoped file-level suppression (`# ruff: noqa`, `# type: ignore`, "
|
|
112
|
+
"`# pyright: ignore`) switches a whole checker off for the file, "
|
|
113
|
+
"including rules added later — scope it to the codes it silences."
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
@override
|
|
117
|
+
def check(self, path: Path, source: str) -> list[Diagnostic]:
|
|
118
|
+
"""Report every unscoped module-scope suppression blanket in `source`.
|
|
119
|
+
|
|
120
|
+
Input that cannot be lexed (unterminated string, bad indentation)
|
|
121
|
+
yields no diagnostics rather than an exception.
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
The diagnostics, sorted by (line, col).
|
|
125
|
+
|
|
126
|
+
"""
|
|
127
|
+
try:
|
|
128
|
+
comments = _scan_comments(source)
|
|
129
|
+
except tokenize.TokenError, IndentationError, SyntaxError:
|
|
130
|
+
return []
|
|
131
|
+
diags = [
|
|
132
|
+
Diagnostic(path=path, line=comment.line, col=comment.col, code=self.code, message=message)
|
|
133
|
+
for comment in comments
|
|
134
|
+
if (message := _blanket_message(comment)) is not None
|
|
135
|
+
]
|
|
136
|
+
diags.sort(key=lambda d: (d.line, d.col))
|
|
137
|
+
return diags
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@dataclass(frozen=True, slots=True)
|
|
141
|
+
class _Comment:
|
|
142
|
+
"""One comment token, with the context that decides whether it is file-level."""
|
|
143
|
+
|
|
144
|
+
line: int
|
|
145
|
+
col: int
|
|
146
|
+
body: str
|
|
147
|
+
standalone: bool
|
|
148
|
+
before_first_statement: bool = False
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _blanket_message(comment: _Comment) -> str | None:
|
|
152
|
+
"""Classify a comment as one of the three unscoped blankets.
|
|
153
|
+
|
|
154
|
+
A bare `# ruff: noqa` counts wherever it sits; the mypy and pyright forms
|
|
155
|
+
only count when they stand alone above the module's first statement, since
|
|
156
|
+
anywhere else they are per-line suppressions.
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
The diagnostic message, or None when the comment is not a blanket.
|
|
160
|
+
|
|
161
|
+
"""
|
|
162
|
+
if _is_unscoped(comment.body, _RUFF_NOQA_RE, _RUFF_CODES_RE):
|
|
163
|
+
return _RUFF_MESSAGE
|
|
164
|
+
if not (comment.standalone and comment.before_first_statement):
|
|
165
|
+
return None
|
|
166
|
+
if _is_unscoped(comment.body, _TYPE_IGNORE_RE, _BRACKET_CODES_RE):
|
|
167
|
+
return _TYPE_IGNORE_MESSAGE
|
|
168
|
+
if _is_unscoped(comment.body, _PYRIGHT_IGNORE_RE, _BRACKET_CODES_RE):
|
|
169
|
+
return _PYRIGHT_IGNORE_MESSAGE
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _is_unscoped(body: str, directive: re.Pattern[str], codes: re.Pattern[str]) -> bool:
|
|
174
|
+
"""Report whether `body` is `directive` with no code list after it.
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
True when the directive matches and names no codes.
|
|
178
|
+
|
|
179
|
+
"""
|
|
180
|
+
match = directive.match(body)
|
|
181
|
+
if match is None:
|
|
182
|
+
return False
|
|
183
|
+
rest = match["rest"]
|
|
184
|
+
if _WORD_CONTINUATION_RE.match(rest):
|
|
185
|
+
return False
|
|
186
|
+
return not codes.match(rest)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _scan_comments(source: str) -> list[_Comment]:
|
|
190
|
+
"""Tokenize `source` and describe every comment in it.
|
|
191
|
+
|
|
192
|
+
A comment is standalone when no token ended on its line before it, and
|
|
193
|
+
precedes the first statement when it sits above the first non-comment,
|
|
194
|
+
non-layout token.
|
|
195
|
+
|
|
196
|
+
Returns:
|
|
197
|
+
Every comment, in source order.
|
|
198
|
+
|
|
199
|
+
"""
|
|
200
|
+
comments: list[_Comment] = []
|
|
201
|
+
first_statement_line = _NO_STATEMENT_LINE
|
|
202
|
+
prev_end_row = 0
|
|
203
|
+
readline = io.StringIO(source).readline
|
|
204
|
+
for tok in tokenize.generate_tokens(readline):
|
|
205
|
+
if tok.type == tokenize.COMMENT:
|
|
206
|
+
comments.append(
|
|
207
|
+
_Comment(
|
|
208
|
+
line=tok.start[0],
|
|
209
|
+
col=tok.start[1] + 1,
|
|
210
|
+
body=_comment_body(tok.string),
|
|
211
|
+
standalone=tok.start[0] != prev_end_row,
|
|
212
|
+
)
|
|
213
|
+
)
|
|
214
|
+
if tok.type not in _LAYOUT_TOKENS:
|
|
215
|
+
prev_end_row = tok.end[0]
|
|
216
|
+
if tok.type not in _NON_STATEMENT_TOKENS:
|
|
217
|
+
first_statement_line = min(first_statement_line, tok.start[0])
|
|
218
|
+
return [replace(c, before_first_statement=c.line < first_statement_line) for c in comments]
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _comment_body(raw: str) -> str:
|
|
222
|
+
"""Strip a comment token down to its directive text.
|
|
223
|
+
|
|
224
|
+
Returns:
|
|
225
|
+
The comment text without its leading `#` markers or surrounding space.
|
|
226
|
+
|
|
227
|
+
"""
|
|
228
|
+
return raw.lstrip("#").strip()
|
|
@@ -79,6 +79,32 @@ payload shapes dominate and are deliberate fall-throughs, not bugs):
|
|
|
79
79
|
else:
|
|
80
80
|
return None # new Status member silently ignored
|
|
81
81
|
|
|
82
|
+
3. **Handler dict that does not cover the enum.** A `dict` literal whose keys
|
|
83
|
+
are all members of ONE enum defined at module scope of this module, whose
|
|
84
|
+
values all look like handlers (a name, an attribute, or a lambda — never a
|
|
85
|
+
literal), and which names FEWER members than the enum declares. This is the
|
|
86
|
+
lookup-table spelling of the same bug: `HANDLERS[kind]` raises `KeyError` on
|
|
87
|
+
the missed member if you are lucky and `HANDLERS.get(kind)` silently returns
|
|
88
|
+
`None` if you are not, and neither pyright nor a `match` statement is there
|
|
89
|
+
to notice. The message names the shortfall so the missing members are
|
|
90
|
+
obvious.
|
|
91
|
+
|
|
92
|
+
# flagged — Kind has three members, the map has two
|
|
93
|
+
class Kind(StrEnum):
|
|
94
|
+
A = "a"
|
|
95
|
+
B = "b"
|
|
96
|
+
C = "c"
|
|
97
|
+
|
|
98
|
+
HANDLERS = {Kind.A: handle_a, Kind.B: handle_b}
|
|
99
|
+
|
|
100
|
+
Deliberately NOT flagged here: a map whose values are literals (that is a
|
|
101
|
+
lookup table of data — a partial one is routinely intentional, e.g. "only
|
|
102
|
+
these two members have a display colour"); a map that spreads `**other`
|
|
103
|
+
(its real key set is not visible); a map whose name is later `.update(...)`d
|
|
104
|
+
or assigned into by subscript anywhere in the file (it is built in pieces on
|
|
105
|
+
purpose); and any map over an imported enum, since the rule cannot see how
|
|
106
|
+
many members that enum has and guessing would invent findings.
|
|
107
|
+
|
|
82
108
|
A deliberate ignore-the-rest dispatch (e.g. classifying external ids where the
|
|
83
109
|
provider can add values at any time) is suppressed with
|
|
84
110
|
`# sarj-noqa: SARJ032 — <reason>`.
|
|
@@ -106,6 +132,9 @@ _MIN_ARMS = 2
|
|
|
106
132
|
|
|
107
133
|
_ENUM_BASES = frozenset({"Enum", "StrEnum", "IntEnum", "Flag", "IntFlag", "ReprEnum"})
|
|
108
134
|
|
|
135
|
+
# Methods that grow a dict in place — a map built in pieces is not incomplete.
|
|
136
|
+
_DICT_GROWING_METHODS = frozenset({"update", "setdefault"})
|
|
137
|
+
|
|
109
138
|
|
|
110
139
|
class PreferMatchAssertNever(Rule):
|
|
111
140
|
"""Silent fallthrough on closed-set dispatch — raise or `assert_never` instead."""
|
|
@@ -134,6 +163,8 @@ class PreferMatchAssertNever(Rule):
|
|
|
134
163
|
)
|
|
135
164
|
)
|
|
136
165
|
member_owners = local_classes | _importfrom_bound_names(tree)
|
|
166
|
+
enum_members = _enum_member_names(module_classdefs, local_enums)
|
|
167
|
+
grown_maps = _grown_dict_names(tree)
|
|
137
168
|
diags: list[Diagnostic] = []
|
|
138
169
|
consumed_elifs: set[int] = set()
|
|
139
170
|
for node in ast.walk(tree):
|
|
@@ -170,6 +201,24 @@ class PreferMatchAssertNever(Rule):
|
|
|
170
201
|
),
|
|
171
202
|
)
|
|
172
203
|
)
|
|
204
|
+
elif isinstance(node, (ast.Assign, ast.AnnAssign)):
|
|
205
|
+
shortfall = _incomplete_dispatch_map(node, enum_members, grown_maps)
|
|
206
|
+
if shortfall is not None:
|
|
207
|
+
enum_name, covered, total, missing = shortfall
|
|
208
|
+
diags.append(
|
|
209
|
+
Diagnostic(
|
|
210
|
+
path=path,
|
|
211
|
+
line=node.lineno,
|
|
212
|
+
col=node.col_offset + 1,
|
|
213
|
+
code=self.code,
|
|
214
|
+
message=(
|
|
215
|
+
f"dispatch map covers {covered} of `{enum_name}`'s {total} "
|
|
216
|
+
f"members (missing {missing}) — a new member falls through to "
|
|
217
|
+
"a KeyError or a silent None; cover every member and "
|
|
218
|
+
"`assert_never` (or raise) on a lookup miss."
|
|
219
|
+
),
|
|
220
|
+
)
|
|
221
|
+
)
|
|
173
222
|
diags.sort(key=lambda d: (d.line, d.col))
|
|
174
223
|
return diags
|
|
175
224
|
|
|
@@ -195,6 +244,159 @@ def _module_scope_classdefs(tree: ast.Module) -> list[ast.ClassDef]:
|
|
|
195
244
|
return found
|
|
196
245
|
|
|
197
246
|
|
|
247
|
+
def _enum_member_names(
|
|
248
|
+
classdefs: list[ast.ClassDef], local_enums: frozenset[str]
|
|
249
|
+
) -> dict[str, frozenset[str]]:
|
|
250
|
+
"""Map each module-scope enum's name to the member names it declares.
|
|
251
|
+
|
|
252
|
+
A member is a plain `NAME = <value>` assignment in the class body. Methods,
|
|
253
|
+
annotations without a value (`x: int`), and private/dunder names are not
|
|
254
|
+
members. Members sharing one constant value are ALIASES of a single member
|
|
255
|
+
(`AKA = "open"` beside `OPEN = "open"`), so they are counted once —
|
|
256
|
+
over-counting members would invent a shortfall that does not exist.
|
|
257
|
+
|
|
258
|
+
Returns:
|
|
259
|
+
A mapping of enum class name to its member-name set.
|
|
260
|
+
|
|
261
|
+
"""
|
|
262
|
+
members: dict[str, frozenset[str]] = {}
|
|
263
|
+
for classdef in classdefs:
|
|
264
|
+
if classdef.name not in local_enums:
|
|
265
|
+
continue
|
|
266
|
+
by_value: dict[str, str] = {}
|
|
267
|
+
names: set[str] = set()
|
|
268
|
+
for stmt in classdef.body:
|
|
269
|
+
if not isinstance(stmt, ast.Assign) or len(stmt.targets) != 1:
|
|
270
|
+
continue
|
|
271
|
+
target = stmt.targets[0]
|
|
272
|
+
if not isinstance(target, ast.Name) or target.id.startswith("_"):
|
|
273
|
+
continue
|
|
274
|
+
if isinstance(stmt.value, ast.Constant):
|
|
275
|
+
key = f"{type(stmt.value.value).__name__}:{stmt.value.value!r}"
|
|
276
|
+
if key in by_value:
|
|
277
|
+
# An alias of an already-counted member.
|
|
278
|
+
continue
|
|
279
|
+
by_value[key] = target.id
|
|
280
|
+
names.add(target.id)
|
|
281
|
+
members[classdef.name] = frozenset(names)
|
|
282
|
+
return members
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _grown_dict_names(tree: ast.Module) -> frozenset[str]:
|
|
286
|
+
"""Collect names of dicts that are grown after their literal is written.
|
|
287
|
+
|
|
288
|
+
`HANDLERS.update(...)`, `HANDLERS.setdefault(k, v)` and `HANDLERS[k] = v`
|
|
289
|
+
all mean the literal is a starting point rather than the whole map, so its
|
|
290
|
+
key count says nothing about coverage.
|
|
291
|
+
|
|
292
|
+
Returns:
|
|
293
|
+
The set of names that are extended somewhere in the module.
|
|
294
|
+
|
|
295
|
+
"""
|
|
296
|
+
grown: set[str] = set()
|
|
297
|
+
for node in ast.walk(tree):
|
|
298
|
+
match node:
|
|
299
|
+
case ast.Call(
|
|
300
|
+
func=ast.Attribute(value=ast.Name(id=name), attr=attr)
|
|
301
|
+
) if attr in _DICT_GROWING_METHODS:
|
|
302
|
+
grown.add(name)
|
|
303
|
+
case ast.Assign(targets=targets):
|
|
304
|
+
grown.update(
|
|
305
|
+
subscript.value.id
|
|
306
|
+
for subscript in targets
|
|
307
|
+
if isinstance(subscript, ast.Subscript)
|
|
308
|
+
and isinstance(subscript.value, ast.Name)
|
|
309
|
+
)
|
|
310
|
+
case _:
|
|
311
|
+
pass
|
|
312
|
+
return frozenset(grown)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _incomplete_dispatch_map(
|
|
316
|
+
node: ast.Assign | ast.AnnAssign,
|
|
317
|
+
enum_members: dict[str, frozenset[str]],
|
|
318
|
+
grown_maps: frozenset[str],
|
|
319
|
+
) -> tuple[str, int, int, str] | None:
|
|
320
|
+
"""Return the shortfall when `node` binds a handler dict that misses enum members.
|
|
321
|
+
|
|
322
|
+
Requires a single `Name` target, a `dict` literal value with no `**` spread,
|
|
323
|
+
at least `_MIN_ARMS` keys that are ALL members of one module-scope enum, all
|
|
324
|
+
values handler-shaped (name / attribute / lambda), and a name that is never
|
|
325
|
+
grown elsewhere in the module.
|
|
326
|
+
|
|
327
|
+
Returns:
|
|
328
|
+
`(enum name, covered, total, missing members)`, or None when the
|
|
329
|
+
assignment is not an incomplete dispatch map.
|
|
330
|
+
|
|
331
|
+
"""
|
|
332
|
+
target = _single_name_target(node)
|
|
333
|
+
if target is None or target in grown_maps or not isinstance(node.value, ast.Dict):
|
|
334
|
+
return None
|
|
335
|
+
mapping = node.value
|
|
336
|
+
if any(key is None for key in mapping.keys):
|
|
337
|
+
# `**other` — the real key set is not visible here.
|
|
338
|
+
return None
|
|
339
|
+
if len(mapping.keys) < _MIN_ARMS:
|
|
340
|
+
return None
|
|
341
|
+
if not all(_is_handler_value(value) for value in mapping.values):
|
|
342
|
+
return None
|
|
343
|
+
owners = {_member_owner(key) for key in mapping.keys}
|
|
344
|
+
if len(owners) != 1:
|
|
345
|
+
return None
|
|
346
|
+
owner = next(iter(owners))
|
|
347
|
+
if owner is None or owner not in enum_members:
|
|
348
|
+
return None
|
|
349
|
+
declared = enum_members[owner]
|
|
350
|
+
covered = {
|
|
351
|
+
key.attr for key in mapping.keys if isinstance(key, ast.Attribute) and key.attr in declared
|
|
352
|
+
}
|
|
353
|
+
if len(covered) != len(mapping.keys) or not covered < declared:
|
|
354
|
+
return None
|
|
355
|
+
missing = ", ".join(f"{owner}.{name}" for name in sorted(declared - covered))
|
|
356
|
+
return owner, len(covered), len(declared), missing
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _single_name_target(node: ast.Assign | ast.AnnAssign) -> str | None:
|
|
360
|
+
"""Return the bound name when `node` assigns to exactly one plain name.
|
|
361
|
+
|
|
362
|
+
Returns:
|
|
363
|
+
The target name, or None for tuple/attribute/subscript targets.
|
|
364
|
+
|
|
365
|
+
"""
|
|
366
|
+
targets = node.targets if isinstance(node, ast.Assign) else [node.target]
|
|
367
|
+
if len(targets) != 1:
|
|
368
|
+
return None
|
|
369
|
+
target = targets[0]
|
|
370
|
+
return target.id if isinstance(target, ast.Name) else None
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _is_handler_value(value: ast.expr | None) -> bool:
|
|
374
|
+
"""Report whether a dict value looks like a handler rather than data.
|
|
375
|
+
|
|
376
|
+
A literal value makes the dict a data table, where a partial mapping is
|
|
377
|
+
routinely deliberate; a name, attribute or lambda makes it a dispatch table.
|
|
378
|
+
|
|
379
|
+
Returns:
|
|
380
|
+
True when the value is handler-shaped.
|
|
381
|
+
|
|
382
|
+
"""
|
|
383
|
+
return isinstance(value, (ast.Name, ast.Attribute, ast.Lambda))
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _member_owner(key: ast.expr | None) -> str | None:
|
|
387
|
+
"""Return the class name in a `Owner.MEMBER` dict key.
|
|
388
|
+
|
|
389
|
+
Returns:
|
|
390
|
+
The owner name, or None when the key is not simple member access.
|
|
391
|
+
|
|
392
|
+
"""
|
|
393
|
+
match key:
|
|
394
|
+
case ast.Attribute(value=ast.Name(id=owner)):
|
|
395
|
+
return owner
|
|
396
|
+
case _:
|
|
397
|
+
return None
|
|
398
|
+
|
|
399
|
+
|
|
198
400
|
def _importfrom_bound_names(tree: ast.Module) -> frozenset[str]:
|
|
199
401
|
"""Collect names bound by `from x import Name [as Alias]` statements.
|
|
200
402
|
|
|
@@ -0,0 +1,639 @@
|
|
|
1
|
+
"""SARJ039: literal-only constant collection built inside a function — hoist it.
|
|
2
|
+
|
|
3
|
+
Python port of the TypeScript rule `prefer-module-level-constant`. A lookup
|
|
4
|
+
table, allow-list, membership `frozenset` or validation regex written at the top
|
|
5
|
+
of a function is rebuilt on every single call. Three costs, in increasing order
|
|
6
|
+
of severity:
|
|
7
|
+
|
|
8
|
+
1. Allocation — every call re-walks the display and re-allocates the list /
|
|
9
|
+
dict / set / tuple, and `re.compile` re-parses the pattern (the module-level
|
|
10
|
+
`re` cache is bounded and evicted wholesale, so it is not a substitute).
|
|
11
|
+
2. Discoverability — a domain constant buried in a function body is invisible
|
|
12
|
+
to the next reader and cannot be imported, tested, or reused, so it gets
|
|
13
|
+
duplicated in the next function that needs it. That is the defect class: the
|
|
14
|
+
duplicated copies drift apart.
|
|
15
|
+
3. Review churn — this was the single most frequent recurring review comment in
|
|
16
|
+
the mined PR corpus, in Python and TypeScript alike.
|
|
17
|
+
|
|
18
|
+
Fires on a local binding (`x = ...` / `x: T = ...`) inside a `def` / `async def`
|
|
19
|
+
whose value is one of:
|
|
20
|
+
|
|
21
|
+
* a list / dict / set / tuple display, or `frozenset([...])`, with at least
|
|
22
|
+
`_MIN_ELEMENTS` top-level entries and where EVERY leaf (dict values and keys
|
|
23
|
+
included) is an `ast.Constant` — or a signed numeric constant, or a nested
|
|
24
|
+
display of the same, up to `_MAX_LITERAL_DEPTH`; or
|
|
25
|
+
* `re.compile("<literal>")`, optionally with constant flags
|
|
26
|
+
(`re.I`, `re.I | re.M`, a plain int).
|
|
27
|
+
|
|
28
|
+
"Every leaf is a constant" is the load-bearing gate, not a stylistic
|
|
29
|
+
preference. A `Name`, `Attribute`, `Call`, comprehension or f-string leaf means
|
|
30
|
+
the value can capture a parameter or observe call-time state, so hoisting it
|
|
31
|
+
would be a `NameError` or a behaviour change. Gating on constants kills that
|
|
32
|
+
entire false-positive class — including the common
|
|
33
|
+
`allowed = [user_id, "admin"]` shape — outright.
|
|
34
|
+
|
|
35
|
+
Escape / mutation analysis is deliberately STRICT, and stricter than the
|
|
36
|
+
TypeScript original. A module-level Python constant is import-time-shared
|
|
37
|
+
mutable state living for the life of the process: a wrong hoist is a
|
|
38
|
+
cross-request data-corruption bug, not a style regression. So the rule bails
|
|
39
|
+
unless EVERY reference to the binding inside the enclosing function is a
|
|
40
|
+
provably non-mutating, non-escaping read.
|
|
41
|
+
|
|
42
|
+
Deliberately NOT flagged:
|
|
43
|
+
|
|
44
|
+
* **Rebound names.** The name must be bound exactly ONCE in the function. Any
|
|
45
|
+
second binding bails: re-assignment, `x += [...]`, a walrus rebind, `del x`,
|
|
46
|
+
a `global` / `nonlocal` declaration, a `for` target, `with ... as x`,
|
|
47
|
+
`except ... as x`, a comprehension or `match` capture target, an `import as`,
|
|
48
|
+
a same-named nested `def` / `class`, or a parameter of the same name.
|
|
49
|
+
* **Mutated collections.** Any method call on the binding that is not on the
|
|
50
|
+
explicit safe list (`get`, `keys`, `values`, `items`, `copy`, `index`,
|
|
51
|
+
`count`) is treated as mutating — default-DENY, so `.append` / `.extend` /
|
|
52
|
+
`.insert` / `.remove` / `.pop` / `.clear` / `.sort` / `.reverse` / `.update` /
|
|
53
|
+
`.add` / `.discard` / `.setdefault` / `.popitem` / `.__setitem__` are covered
|
|
54
|
+
along with anything a future stdlib grows. A subscript STORE (`x[k] = v`),
|
|
55
|
+
`del x[k]`, or an attribute store (`x.attr = v`) likewise bails. A compiled
|
|
56
|
+
regex gets its own safe-method list (`match`, `search`, `fullmatch`,
|
|
57
|
+
`findall`, `finditer`, `split`, `sub`, `subn`) since `re.Pattern` is immutable.
|
|
58
|
+
* **Escaping values the caller may mutate.** `return x`, `yield x`, passing `x`
|
|
59
|
+
bare as a positional or keyword argument to a call that is not a known
|
|
60
|
+
non-retaining consumer, `self.x = x`, `d[k] = x`, embedding it in a
|
|
61
|
+
`[x]` / `{...}` display, aliasing it (`y = x`), a `*x` / `**x` spread, or
|
|
62
|
+
capturing it in a nested `def` / `lambda` / `class` body. Once the value
|
|
63
|
+
leaves the function this rule cannot see what happens to it.
|
|
64
|
+
* **Safe consumers still fire** — `len(x)`, `sorted(x)`, `set`/`frozenset`/
|
|
65
|
+
`list`/`tuple`/`dict`, `any`, `all`, `min`, `max`, `sum`, `enumerate`,
|
|
66
|
+
`reversed`, `iter`, `json.dumps`, the non-mutating methods above, `x` as a
|
|
67
|
+
`for`/comprehension iterable, `k in x`, a subscript LOAD (`x[0]`), a
|
|
68
|
+
comparison, and f-string interpolation. Note `sorted(x)` and `list(x)` COPY,
|
|
69
|
+
so they are reads, while `x.sort()` mutates in place and bails.
|
|
70
|
+
* **Tiny displays** (`< _MIN_ELEMENTS` entries) read better next to their use.
|
|
71
|
+
* **Test files and generated files** (`_paths.is_test_path` /
|
|
72
|
+
`_paths.is_generated_source`): fixture tables belong next to the assertion
|
|
73
|
+
that explains them, and generated code mirrors its generator.
|
|
74
|
+
|
|
75
|
+
One real difference from the TypeScript original: that rule must never hoist a
|
|
76
|
+
`/g` or `/y` regex, because a JavaScript RegExp object carries `lastIndex`
|
|
77
|
+
across calls to `.test()` / `.exec()`, so a shared instance resumes mid-string
|
|
78
|
+
on the next call. A Python compiled pattern carries no per-object scan state —
|
|
79
|
+
position is an argument to `.match()` / `.search()`, and `re.finditer` returns a
|
|
80
|
+
fresh iterator — so there is no equivalent carve-out here and every constant
|
|
81
|
+
`re.compile` is reported.
|
|
82
|
+
|
|
83
|
+
There is no autofix: hoisting has to pick an insertion point and may collide
|
|
84
|
+
with an existing module-scope name, and a wrong automated hoist is worse than
|
|
85
|
+
the warning.
|
|
86
|
+
|
|
87
|
+
A deliberate per-call rebuild (a fresh mutable default the rule cannot see
|
|
88
|
+
through, say) is suppressed with `# sarj-noqa: SARJ039 — <reason>`.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
from __future__ import annotations
|
|
92
|
+
|
|
93
|
+
import ast
|
|
94
|
+
from dataclasses import dataclass
|
|
95
|
+
from typing import TYPE_CHECKING, override
|
|
96
|
+
|
|
97
|
+
from sarj_python_lint.rule_base import Diagnostic, Rule, parse_or_none
|
|
98
|
+
from sarj_python_lint.rules._paths import is_generated_source, is_test_path
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
if TYPE_CHECKING:
|
|
102
|
+
from collections.abc import Iterator
|
|
103
|
+
from pathlib import Path
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
type _Function = ast.FunctionDef | ast.AsyncFunctionDef
|
|
107
|
+
|
|
108
|
+
#: A display smaller than this reads better next to its use than as a
|
|
109
|
+
#: module-level constant, so this is the floor at which hoisting pays for itself.
|
|
110
|
+
_MIN_ELEMENTS = 3
|
|
111
|
+
|
|
112
|
+
#: How deep a nested display may go before we stop trying to prove it constant.
|
|
113
|
+
_MAX_LITERAL_DEPTH = 4
|
|
114
|
+
|
|
115
|
+
#: `re.compile(pattern[, flags])` — anything longer is not the shape we model.
|
|
116
|
+
_COMPILE_MAX_ARGS = 2
|
|
117
|
+
|
|
118
|
+
_REGEX_KIND = "regex"
|
|
119
|
+
_FROZENSET_KIND = "frozenset"
|
|
120
|
+
|
|
121
|
+
_RE_COMPILE = "re.compile"
|
|
122
|
+
_FROZENSET = "frozenset"
|
|
123
|
+
_RE_FLAG_PREFIX = "re."
|
|
124
|
+
|
|
125
|
+
#: Callables that read their argument and return a fresh value without retaining
|
|
126
|
+
#: or mutating the original, so passing the binding to one is still a read.
|
|
127
|
+
_SAFE_CALLEES = frozenset(
|
|
128
|
+
{
|
|
129
|
+
"all",
|
|
130
|
+
"any",
|
|
131
|
+
"dict",
|
|
132
|
+
"enumerate",
|
|
133
|
+
"frozenset",
|
|
134
|
+
"iter",
|
|
135
|
+
"json.dumps",
|
|
136
|
+
"len",
|
|
137
|
+
"list",
|
|
138
|
+
"max",
|
|
139
|
+
"min",
|
|
140
|
+
"reversed",
|
|
141
|
+
"set",
|
|
142
|
+
"sorted",
|
|
143
|
+
"sum",
|
|
144
|
+
"tuple",
|
|
145
|
+
}
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
#: Collection methods known not to mutate the receiver. Everything else is
|
|
149
|
+
#: assumed to mutate — default-deny, so an unrecognised method suppresses the
|
|
150
|
+
#: report rather than risking a hoist that shares mutable state across calls.
|
|
151
|
+
_SAFE_METHODS = frozenset({"copy", "count", "get", "index", "items", "keys", "values"})
|
|
152
|
+
|
|
153
|
+
#: The `re.Pattern` API. A compiled pattern is immutable and, unlike a JavaScript
|
|
154
|
+
#: RegExp, carries no scan state, so these are all pure reads — but the list is
|
|
155
|
+
#: still explicit, so an unrecognised method on a pattern bails like any other.
|
|
156
|
+
_SAFE_REGEX_METHODS = frozenset(
|
|
157
|
+
{
|
|
158
|
+
"findall",
|
|
159
|
+
"finditer",
|
|
160
|
+
"fullmatch",
|
|
161
|
+
"match",
|
|
162
|
+
"search",
|
|
163
|
+
"split",
|
|
164
|
+
"sub",
|
|
165
|
+
"subn",
|
|
166
|
+
}
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
_FUNCTION_NODES = (ast.FunctionDef, ast.AsyncFunctionDef)
|
|
170
|
+
|
|
171
|
+
#: Nodes that open a scope of their own. A reference to the binding inside one
|
|
172
|
+
#: is a capture, which outlives the call and therefore bails.
|
|
173
|
+
_INNER_SCOPE_NODES = (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda, ast.ClassDef)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class PreferModuleLevelConstant(Rule):
|
|
177
|
+
"""Literal-only collection or compiled regex rebuilt per call — hoist to module scope."""
|
|
178
|
+
|
|
179
|
+
id: str = "prefer-module-level-constant"
|
|
180
|
+
code: str = "SARJ039"
|
|
181
|
+
description: str = (
|
|
182
|
+
"a literal-only collection or compiled regex built inside a function is "
|
|
183
|
+
"rebuilt on every call — hoist it to module scope."
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
@override
|
|
187
|
+
def check(self, path: Path, source: str) -> list[Diagnostic]:
|
|
188
|
+
if is_test_path(path) or is_generated_source(source):
|
|
189
|
+
return []
|
|
190
|
+
tree = parse_or_none(path, source)
|
|
191
|
+
if tree is None:
|
|
192
|
+
return []
|
|
193
|
+
diags: list[Diagnostic] = []
|
|
194
|
+
for func in _iter_functions(tree):
|
|
195
|
+
for stmt, name, candidate in _hoistable_bindings(func):
|
|
196
|
+
diags.append(
|
|
197
|
+
Diagnostic(
|
|
198
|
+
path=path,
|
|
199
|
+
line=stmt.lineno,
|
|
200
|
+
col=stmt.col_offset + 1,
|
|
201
|
+
code=self.code,
|
|
202
|
+
message=_message(name, candidate),
|
|
203
|
+
)
|
|
204
|
+
)
|
|
205
|
+
diags.sort(key=lambda d: (d.line, d.col))
|
|
206
|
+
return diags
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
@dataclass(frozen=True, slots=True)
|
|
210
|
+
class _Candidate:
|
|
211
|
+
"""A classified initializer: what kind of value it is and how many entries it has."""
|
|
212
|
+
|
|
213
|
+
kind: str
|
|
214
|
+
size: int
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@dataclass(frozen=True, slots=True)
|
|
218
|
+
class _Scope:
|
|
219
|
+
"""One function body flattened: every descendant node, its parent, its nesting."""
|
|
220
|
+
|
|
221
|
+
nodes: list[ast.AST]
|
|
222
|
+
parents: dict[int, ast.AST]
|
|
223
|
+
nested: set[int]
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _iter_functions(tree: ast.Module) -> Iterator[_Function]:
|
|
227
|
+
"""Walk the module for every `def` / `async def`, nested ones included.
|
|
228
|
+
|
|
229
|
+
Yields:
|
|
230
|
+
Each function node in the module.
|
|
231
|
+
|
|
232
|
+
"""
|
|
233
|
+
for node in ast.walk(tree):
|
|
234
|
+
if isinstance(node, _FUNCTION_NODES):
|
|
235
|
+
yield node
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _hoistable_bindings(func: _Function) -> Iterator[tuple[ast.stmt, str, _Candidate]]:
|
|
239
|
+
"""Find the bindings in this function's own scope that are safe to hoist.
|
|
240
|
+
|
|
241
|
+
Yields:
|
|
242
|
+
The assignment statement, the bound name, and its classified value.
|
|
243
|
+
|
|
244
|
+
"""
|
|
245
|
+
scope = _scope_of(func)
|
|
246
|
+
for node in scope.nodes:
|
|
247
|
+
if id(node) in scope.nested or not isinstance(node, ast.stmt):
|
|
248
|
+
continue
|
|
249
|
+
binding = _candidate_binding(node)
|
|
250
|
+
if binding is None:
|
|
251
|
+
continue
|
|
252
|
+
target, value = binding
|
|
253
|
+
candidate = _classify(value)
|
|
254
|
+
if candidate is None or not _is_large_enough(candidate):
|
|
255
|
+
continue
|
|
256
|
+
if _is_safely_hoistable(scope, func, target, _safe_methods_for(candidate)):
|
|
257
|
+
yield node, target.id, candidate
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _scope_of(func: _Function) -> _Scope:
|
|
261
|
+
"""Flatten the function body into nodes, parent links, and nesting flags.
|
|
262
|
+
|
|
263
|
+
A node is "nested" when it lives inside an inner `def` / `lambda` / `class`,
|
|
264
|
+
i.e. in a scope that can outlive or re-enter the enclosing call.
|
|
265
|
+
|
|
266
|
+
Returns:
|
|
267
|
+
The flattened view of the function body.
|
|
268
|
+
|
|
269
|
+
"""
|
|
270
|
+
nodes: list[ast.AST] = []
|
|
271
|
+
parents: dict[int, ast.AST] = {}
|
|
272
|
+
nested: set[int] = set()
|
|
273
|
+
stack: list[tuple[ast.AST, ast.AST, bool]] = [(child, func, False) for child in func.body]
|
|
274
|
+
while stack:
|
|
275
|
+
node, parent, is_nested = stack.pop()
|
|
276
|
+
nodes.append(node)
|
|
277
|
+
parents[id(node)] = parent
|
|
278
|
+
if is_nested:
|
|
279
|
+
nested.add(id(node))
|
|
280
|
+
child_nested = is_nested or isinstance(node, _INNER_SCOPE_NODES)
|
|
281
|
+
stack.extend((child, node, child_nested) for child in ast.iter_child_nodes(node))
|
|
282
|
+
return _Scope(nodes=nodes, parents=parents, nested=nested)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _candidate_binding(node: ast.stmt) -> tuple[ast.Name, ast.expr] | None:
|
|
286
|
+
"""Match a single-target `x = <value>` / `x: T = <value>` statement.
|
|
287
|
+
|
|
288
|
+
Returns:
|
|
289
|
+
The bound name and its initializer, or None for any other statement.
|
|
290
|
+
|
|
291
|
+
"""
|
|
292
|
+
match node:
|
|
293
|
+
case ast.Assign(targets=[ast.Name() as target], value=value):
|
|
294
|
+
return target, value
|
|
295
|
+
case ast.AnnAssign(target=ast.Name() as target, value=ast.expr() as value):
|
|
296
|
+
return target, value
|
|
297
|
+
case _:
|
|
298
|
+
return None
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _classify(value: ast.expr) -> _Candidate | None:
|
|
302
|
+
"""Classify the initializer as a constant-only display, a `frozenset`, or a regex.
|
|
303
|
+
|
|
304
|
+
Returns:
|
|
305
|
+
The candidate kind and top-level size, or None when it does not qualify.
|
|
306
|
+
|
|
307
|
+
"""
|
|
308
|
+
match value:
|
|
309
|
+
case ast.List(elts=elts):
|
|
310
|
+
return _display_candidate("list", value, len(elts))
|
|
311
|
+
case ast.Set(elts=elts):
|
|
312
|
+
return _display_candidate("set", value, len(elts))
|
|
313
|
+
case ast.Tuple(elts=elts):
|
|
314
|
+
return _display_candidate("tuple", value, len(elts))
|
|
315
|
+
case ast.Dict(keys=keys):
|
|
316
|
+
return _display_candidate("dict", value, len(keys))
|
|
317
|
+
case ast.Call():
|
|
318
|
+
return _call_candidate(value)
|
|
319
|
+
case _:
|
|
320
|
+
return None
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _is_large_enough(candidate: _Candidate) -> bool:
|
|
324
|
+
"""Apply the `_MIN_ELEMENTS` floor, which a compiled regex is exempt from.
|
|
325
|
+
|
|
326
|
+
Returns:
|
|
327
|
+
True when the candidate is worth hoisting on size grounds.
|
|
328
|
+
|
|
329
|
+
"""
|
|
330
|
+
return candidate.kind == _REGEX_KIND or candidate.size >= _MIN_ELEMENTS
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _display_candidate(kind: str, node: ast.expr, size: int) -> _Candidate | None:
|
|
334
|
+
"""Accept a display only when every one of its leaves is a constant.
|
|
335
|
+
|
|
336
|
+
Returns:
|
|
337
|
+
The candidate, or None when any leaf is non-constant.
|
|
338
|
+
|
|
339
|
+
"""
|
|
340
|
+
return _Candidate(kind=kind, size=size) if _is_constant_only(node, 0) else None
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _call_candidate(call: ast.Call) -> _Candidate | None:
|
|
344
|
+
"""Classify `frozenset([...])` and `re.compile("...")` call initializers.
|
|
345
|
+
|
|
346
|
+
Returns:
|
|
347
|
+
The candidate, or None for any other call.
|
|
348
|
+
|
|
349
|
+
"""
|
|
350
|
+
if call.keywords:
|
|
351
|
+
return None
|
|
352
|
+
callee = _dotted_name(call.func)
|
|
353
|
+
if callee == _FROZENSET:
|
|
354
|
+
return _frozenset_candidate(call)
|
|
355
|
+
if callee == _RE_COMPILE and _is_constant_pattern(call):
|
|
356
|
+
return _Candidate(kind=_REGEX_KIND, size=1)
|
|
357
|
+
return None
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _frozenset_candidate(call: ast.Call) -> _Candidate | None:
|
|
361
|
+
"""Accept `frozenset(<constant-only display>)`.
|
|
362
|
+
|
|
363
|
+
Returns:
|
|
364
|
+
The candidate, or None when the sole argument is not a constant display.
|
|
365
|
+
|
|
366
|
+
"""
|
|
367
|
+
match call.args:
|
|
368
|
+
case [ast.List(elts=elts) | ast.Set(elts=elts) | ast.Tuple(elts=elts) as inner] if _is_constant_only(inner, 0):
|
|
369
|
+
return _Candidate(kind=_FROZENSET_KIND, size=len(elts))
|
|
370
|
+
case _:
|
|
371
|
+
return None
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _is_constant_pattern(call: ast.Call) -> bool:
|
|
375
|
+
"""Report whether a `re.compile(...)` call takes a literal pattern and constant flags.
|
|
376
|
+
|
|
377
|
+
Returns:
|
|
378
|
+
True when the compiled pattern is fully determined at import time.
|
|
379
|
+
|
|
380
|
+
"""
|
|
381
|
+
if not call.args or len(call.args) > _COMPILE_MAX_ARGS:
|
|
382
|
+
return False
|
|
383
|
+
match call.args[0]:
|
|
384
|
+
case ast.Constant(value=str() | bytes()):
|
|
385
|
+
pass
|
|
386
|
+
case _:
|
|
387
|
+
return False
|
|
388
|
+
return len(call.args) < _COMPILE_MAX_ARGS or _is_constant_flags(call.args[1])
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _is_constant_flags(node: ast.expr) -> bool:
|
|
392
|
+
"""Report whether a `re.compile` flags argument is an import-time constant.
|
|
393
|
+
|
|
394
|
+
Accepts an int literal, an `re.<FLAG>` attribute, and `|` combinations of
|
|
395
|
+
those — the only flag shapes real code uses.
|
|
396
|
+
|
|
397
|
+
Returns:
|
|
398
|
+
True when the flags expression is constant.
|
|
399
|
+
|
|
400
|
+
"""
|
|
401
|
+
match node:
|
|
402
|
+
case ast.Constant(value=int()):
|
|
403
|
+
return True
|
|
404
|
+
case ast.Attribute():
|
|
405
|
+
dotted = _dotted_name(node)
|
|
406
|
+
return dotted is not None and dotted.startswith(_RE_FLAG_PREFIX)
|
|
407
|
+
case ast.BinOp(op=ast.BitOr(), left=left, right=right):
|
|
408
|
+
return _is_constant_flags(left) and _is_constant_flags(right)
|
|
409
|
+
case _:
|
|
410
|
+
return False
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _is_constant_only(node: ast.expr, depth: int) -> bool:
|
|
414
|
+
"""Report whether the expression is built entirely out of constants.
|
|
415
|
+
|
|
416
|
+
No name, no attribute, no call, no comprehension, no f-string, no spread. A
|
|
417
|
+
constant-only value cannot capture a parameter, cannot observe call-time
|
|
418
|
+
state, and cannot have side effects, which is exactly what makes the hoist
|
|
419
|
+
provably safe.
|
|
420
|
+
|
|
421
|
+
Returns:
|
|
422
|
+
True when every leaf of the expression is a literal constant.
|
|
423
|
+
|
|
424
|
+
"""
|
|
425
|
+
if depth > _MAX_LITERAL_DEPTH:
|
|
426
|
+
return False
|
|
427
|
+
match node:
|
|
428
|
+
case ast.Constant():
|
|
429
|
+
return True
|
|
430
|
+
case ast.UnaryOp(op=ast.USub() | ast.UAdd(), operand=ast.Constant(value=int() | float() | complex())):
|
|
431
|
+
return True
|
|
432
|
+
case ast.List(elts=elts) | ast.Set(elts=elts) | ast.Tuple(elts=elts):
|
|
433
|
+
return all(_is_constant_only(element, depth + 1) for element in elts)
|
|
434
|
+
case ast.Dict(keys=keys, values=values):
|
|
435
|
+
entries = [*keys, *values]
|
|
436
|
+
return all(entry is not None and _is_constant_only(entry, depth + 1) for entry in entries)
|
|
437
|
+
case _:
|
|
438
|
+
return False
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _safe_methods_for(candidate: _Candidate) -> frozenset[str]:
|
|
442
|
+
"""Pick the non-mutating method list that applies to this kind of value.
|
|
443
|
+
|
|
444
|
+
Returns:
|
|
445
|
+
The methods callable on the binding without mutating it.
|
|
446
|
+
|
|
447
|
+
"""
|
|
448
|
+
return _SAFE_REGEX_METHODS if candidate.kind == _REGEX_KIND else _SAFE_METHODS
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _is_safely_hoistable(
|
|
452
|
+
scope: _Scope,
|
|
453
|
+
func: _Function,
|
|
454
|
+
target: ast.Name,
|
|
455
|
+
safe_methods: frozenset[str],
|
|
456
|
+
) -> bool:
|
|
457
|
+
"""Report whether every reference to the binding is a non-mutating, non-escaping read.
|
|
458
|
+
|
|
459
|
+
Default-deny: the binding must be bound exactly once and every other
|
|
460
|
+
reference must be recognised as safe, so an unfamiliar usage suppresses the
|
|
461
|
+
report rather than risking a hoist that shares mutable state across calls.
|
|
462
|
+
|
|
463
|
+
Returns:
|
|
464
|
+
True when the binding can be moved to module scope without changing behaviour.
|
|
465
|
+
|
|
466
|
+
"""
|
|
467
|
+
name = target.id
|
|
468
|
+
if name in _parameter_names(func):
|
|
469
|
+
return False
|
|
470
|
+
for node in scope.nodes:
|
|
471
|
+
if id(node) in scope.nested:
|
|
472
|
+
if _mentions_name(node, name):
|
|
473
|
+
return False
|
|
474
|
+
continue
|
|
475
|
+
if _rebinds_name(node, name):
|
|
476
|
+
return False
|
|
477
|
+
if not (isinstance(node, ast.Name) and node.id == name):
|
|
478
|
+
continue
|
|
479
|
+
if not isinstance(node.ctx, ast.Load):
|
|
480
|
+
if node is not target:
|
|
481
|
+
return False
|
|
482
|
+
elif not _is_safe_read(node, scope.parents, safe_methods):
|
|
483
|
+
return False
|
|
484
|
+
return True
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def _parameter_names(func: _Function) -> frozenset[str]:
|
|
488
|
+
"""Collect every parameter name of the function, variadics included.
|
|
489
|
+
|
|
490
|
+
Returns:
|
|
491
|
+
The parameter names, which the binding may not shadow.
|
|
492
|
+
|
|
493
|
+
"""
|
|
494
|
+
args = func.args
|
|
495
|
+
params = [*args.posonlyargs, *args.args, *args.kwonlyargs]
|
|
496
|
+
params.extend(arg for arg in (args.vararg, args.kwarg) if arg is not None)
|
|
497
|
+
return frozenset(param.arg for param in params)
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _mentions_name(node: ast.AST, name: str) -> bool:
|
|
501
|
+
"""Report whether this node inside an inner scope refers to the binding.
|
|
502
|
+
|
|
503
|
+
Returns:
|
|
504
|
+
True when an inner `def` / `lambda` / `class` captures or shadows the name.
|
|
505
|
+
|
|
506
|
+
"""
|
|
507
|
+
match node:
|
|
508
|
+
case ast.Name(id=ident):
|
|
509
|
+
return ident == name
|
|
510
|
+
case ast.arg(arg=ident):
|
|
511
|
+
return ident == name
|
|
512
|
+
case ast.Global(names=names) | ast.Nonlocal(names=names):
|
|
513
|
+
return name in names
|
|
514
|
+
case _:
|
|
515
|
+
return False
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _rebinds_name(node: ast.AST, name: str) -> bool:
|
|
519
|
+
"""Report whether this node binds the name through something other than a `Name` store.
|
|
520
|
+
|
|
521
|
+
Covers the binding forms that carry the name as a plain string:
|
|
522
|
+
`except ... as x`, `global` / `nonlocal`, a nested `def` / `class`, an
|
|
523
|
+
`import as`, and `match` captures.
|
|
524
|
+
|
|
525
|
+
Returns:
|
|
526
|
+
True when the node is a second binding of the name.
|
|
527
|
+
|
|
528
|
+
"""
|
|
529
|
+
match node:
|
|
530
|
+
case ast.Global(names=names) | ast.Nonlocal(names=names):
|
|
531
|
+
return name in names
|
|
532
|
+
case ast.ExceptHandler(name=bound):
|
|
533
|
+
return bound == name
|
|
534
|
+
case ast.FunctionDef(name=bound) | ast.AsyncFunctionDef(name=bound) | ast.ClassDef(name=bound):
|
|
535
|
+
return bound == name
|
|
536
|
+
case ast.alias(asname=None, name=module):
|
|
537
|
+
return module.split(".")[0] == name
|
|
538
|
+
case ast.alias(asname=str() as bound):
|
|
539
|
+
return bound == name
|
|
540
|
+
case ast.MatchAs(name=bound) | ast.MatchStar(name=bound):
|
|
541
|
+
return bound == name
|
|
542
|
+
case ast.MatchMapping(rest=rest):
|
|
543
|
+
return rest == name
|
|
544
|
+
case _:
|
|
545
|
+
return False
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def _is_safe_read(node: ast.Name, parents: dict[int, ast.AST], safe_methods: frozenset[str]) -> bool:
|
|
549
|
+
"""Report whether this single reference to the binding neither mutates nor escapes it.
|
|
550
|
+
|
|
551
|
+
Returns:
|
|
552
|
+
True when the reference is a recognised non-mutating, non-escaping read.
|
|
553
|
+
|
|
554
|
+
"""
|
|
555
|
+
parent = parents.get(id(node))
|
|
556
|
+
match parent:
|
|
557
|
+
case ast.Attribute():
|
|
558
|
+
return _is_safe_method_call(parent, parents, safe_methods)
|
|
559
|
+
case ast.Subscript(value=value, ctx=ctx):
|
|
560
|
+
# `x[k]` reads; `x[k] = v` / `del x[k]` mutate. `d[x]` uses the
|
|
561
|
+
# binding as a key, which is a plain read whatever `d` does.
|
|
562
|
+
return isinstance(ctx, ast.Load) if value is node else True
|
|
563
|
+
case ast.Call(args=args, func=callee):
|
|
564
|
+
return any(arg is node for arg in args) and _is_safe_callee(callee)
|
|
565
|
+
case ast.keyword(arg=str(), value=value) if value is node:
|
|
566
|
+
grandparent = parents.get(id(parent))
|
|
567
|
+
return isinstance(grandparent, ast.Call) and _is_safe_callee(grandparent.func)
|
|
568
|
+
case ast.Compare():
|
|
569
|
+
return True
|
|
570
|
+
case ast.For(iter=iterable) | ast.AsyncFor(iter=iterable) | ast.comprehension(iter=iterable):
|
|
571
|
+
return iterable is node
|
|
572
|
+
case ast.FormattedValue():
|
|
573
|
+
return True
|
|
574
|
+
case _:
|
|
575
|
+
return False
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def _is_safe_method_call(
|
|
579
|
+
attribute: ast.Attribute,
|
|
580
|
+
parents: dict[int, ast.AST],
|
|
581
|
+
safe_methods: frozenset[str],
|
|
582
|
+
) -> bool:
|
|
583
|
+
"""Report whether `x.<attr>` is an immediate call to a known non-mutating method.
|
|
584
|
+
|
|
585
|
+
A bare `x.attr` read that is not called hands the bound method out, and an
|
|
586
|
+
attribute store mutates, so both are unsafe.
|
|
587
|
+
|
|
588
|
+
Returns:
|
|
589
|
+
True when the attribute is a called method on the safe list.
|
|
590
|
+
|
|
591
|
+
"""
|
|
592
|
+
if not isinstance(attribute.ctx, ast.Load) or attribute.attr not in safe_methods:
|
|
593
|
+
return False
|
|
594
|
+
parent = parents.get(id(attribute))
|
|
595
|
+
return isinstance(parent, ast.Call) and parent.func is attribute
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def _is_safe_callee(func: ast.expr) -> bool:
|
|
599
|
+
"""Report whether a call that receives the binding is known not to retain or mutate it.
|
|
600
|
+
|
|
601
|
+
Returns:
|
|
602
|
+
True when the callee is on the safe list.
|
|
603
|
+
|
|
604
|
+
"""
|
|
605
|
+
return _dotted_name(func) in _SAFE_CALLEES
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def _dotted_name(node: ast.expr) -> str | None:
|
|
609
|
+
"""Render a `Name` / `Attribute` chain as a dotted string.
|
|
610
|
+
|
|
611
|
+
Returns:
|
|
612
|
+
The dotted name, or None when the expression is not a plain chain.
|
|
613
|
+
|
|
614
|
+
"""
|
|
615
|
+
match node:
|
|
616
|
+
case ast.Name(id=ident):
|
|
617
|
+
return ident
|
|
618
|
+
case ast.Attribute(value=value, attr=attr):
|
|
619
|
+
base = _dotted_name(value)
|
|
620
|
+
return None if base is None else f"{base}.{attr}"
|
|
621
|
+
case _:
|
|
622
|
+
return None
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def _message(name: str, candidate: _Candidate) -> str:
|
|
626
|
+
"""Render the diagnostic text for one hoistable binding.
|
|
627
|
+
|
|
628
|
+
Returns:
|
|
629
|
+
The message naming the binding and the hoist.
|
|
630
|
+
|
|
631
|
+
"""
|
|
632
|
+
if candidate.kind == _REGEX_KIND:
|
|
633
|
+
return (
|
|
634
|
+
f"`{name}` is a constant regex recompiled on every call — hoist it to module scope so it is compiled once."
|
|
635
|
+
)
|
|
636
|
+
return (
|
|
637
|
+
f"`{name}` is a constant-only {candidate.kind} rebuilt on every call — hoist it "
|
|
638
|
+
"to module scope so it is built once and can be imported, reused, and tested."
|
|
639
|
+
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.13.1 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/no_comment_cruft.py
RENAMED
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.13.1 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/no_fat_try_blocks.py
RENAMED
|
File without changes
|
{sarj_python_lint-0.13.1 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/no_fstring_in_log.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.13.1 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/no_secret_in_log.py
RENAMED
|
File without changes
|
{sarj_python_lint-0.13.1 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/no_select_star.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.13.1 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/prefer_class_row.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.13.1 → sarj_python_lint-0.14.0}/src/sarj_python_lint/rules/prefer_str_enum.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|