sarj-python-lint 0.15.0__tar.gz → 0.16.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.15.0 → sarj_python_lint-0.16.0}/PKG-INFO +1 -1
- {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/pyproject.toml +1 -1
- {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/_secret_names.py +56 -5
- {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/kwonly_same_type_params.py +2 -6
- {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/no_comment_cruft.py +53 -6
- {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +1 -2
- {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/no_isinstance_union_chain.py +1 -3
- {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/no_offset_pagination.py +1 -3
- {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +1 -2
- {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/parametrize_case_needs_id.py +17 -7
- {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +11 -10
- {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/prefer_match_assert_never.py +6 -17
- {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/xfail_requires_strict.py +36 -1
- {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/zero_assertion_test.py +42 -5
- {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/.gitignore +0 -0
- {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/README.md +0 -0
- {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/__init__.py +0 -0
- {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/__main__.py +0 -0
- {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/_version.py +0 -0
- {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/py.typed +0 -0
- {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rule_base.py +0 -0
- {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/__init__.py +0 -0
- {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/_logging.py +0 -0
- {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/_paths.py +0 -0
- {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/_registry.py +0 -0
- {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/_sql.py +0 -0
- {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/fixture_returns_bare_tuple.py +0 -0
- {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/inefficient_string_concat_in_loop.py +0 -0
- {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/kwarg_heavy_construction_in_test.py +0 -0
- {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
- {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/no_aggregation_in_store_query.py +0 -0
- {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/no_fat_try_blocks.py +0 -0
- {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/no_file_level_suppression.py +0 -0
- {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/no_fstring_in_log.py +0 -0
- {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/no_query_with_many_joins.py +0 -0
- {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/no_raw_sql_in_tests.py +0 -0
- {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
- {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
- {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/no_select_star.py +0 -0
- {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/no_sequential_await.py +0 -0
- {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/no_sleep_in_test_body.py +0 -0
- {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/no_unreachable_after_terminal.py +0 -0
- {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
- {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
- {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
- {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
- {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
- {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/prefer_timedelta_for_durations.py +0 -0
- {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +0 -0
- {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/single_public_export.py +0 -0
- {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/sleep_with_computed_arg_in_test.py +0 -0
- {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/stepdown.py +0 -0
- {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
- {sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/test_loops_over_literal_cases.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.16.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
|
|
@@ -11,13 +11,15 @@ misfired on a large false-positive class observed in a real audit:
|
|
|
11
11
|
`tokenize`, `tokenizer`, `token_budget`.
|
|
12
12
|
- Row-id / handle names: `api_key_id`, `*_key_id` — the id of a key row, not the
|
|
13
13
|
key material.
|
|
14
|
-
- Boolean feature / presence / state flags:
|
|
15
|
-
`token_present`, `password_set`, `password_configured`
|
|
16
|
-
|
|
17
|
-
is
|
|
14
|
+
- Boolean feature / presence / state flags, in both word orders: trailing
|
|
15
|
+
(`password_enabled`, `token_present`, `password_set`, `password_configured`)
|
|
16
|
+
and leading (`has_secret`, `hasSecret`, `is_token`, `isToken`) — a boolean
|
|
17
|
+
answering "is it there / was it set", not the credential itself. A `type`
|
|
18
|
+
discriminator is the same: `token_type` is `"Bearer"`, `credential_type` is a
|
|
19
|
+
class name.
|
|
18
20
|
- Innocent words embedding a secret word: `secretary` (embeds `secret`).
|
|
19
21
|
|
|
20
|
-
We fix this with
|
|
22
|
+
We fix this with three changes:
|
|
21
23
|
|
|
22
24
|
1. Match a secret word only as a WHOLE token (after snake_case / camelCase
|
|
23
25
|
splitting), never a substring. This alone clears `tokenize`, `tokenizer`,
|
|
@@ -28,6 +30,10 @@ We fix this with two changes:
|
|
|
28
30
|
also present — this clears `token_count`, `api_key_id`, `password_enabled`,
|
|
29
31
|
while still catching a credential that merely leads with such a word
|
|
30
32
|
(`valid_token`, `present_token` are secrets, not flags).
|
|
33
|
+
3. Disqualify an identifier whose LEADING WORD is a boolean predicate (`is`,
|
|
34
|
+
`has`, `was`, ...) — the mirror image of (2), clearing `has_secret` /
|
|
35
|
+
`hasSecret` / `is_token` / `isToken`, which name a boolean answering "does a
|
|
36
|
+
secret exist?" and are neither a leak nor a timing surface.
|
|
31
37
|
"""
|
|
32
38
|
|
|
33
39
|
from __future__ import annotations
|
|
@@ -89,6 +95,28 @@ _INNOCUOUS_WORDS = frozenset(
|
|
|
89
95
|
}
|
|
90
96
|
)
|
|
91
97
|
|
|
98
|
+
# A LEADING boolean-predicate word marks a flag, not the credential itself:
|
|
99
|
+
# `has_secret`, `hasSecret`, `is_token`, `isToken`, `should_rotate_token`.
|
|
100
|
+
#
|
|
101
|
+
# WHY THE SHARED PREDICATE (both SARJ011 and SARJ012), not just the SARJ011-only
|
|
102
|
+
# auth narrowing the TS port uses: this is the exact mirror of the TRAILING
|
|
103
|
+
# `_INNOCUOUS_WORDS` check above, which already exempts both rules. `has_token`
|
|
104
|
+
# and `token_present` are the same boolean; word order must not decide whether a
|
|
105
|
+
# name counts as a credential. And the SARJ012 case stands on its own — a boolean
|
|
106
|
+
# answering "does a secret exist?" leaks nothing when logged, so the rule was
|
|
107
|
+
# reporting a pure false positive. (As with the trailing form, the exemption keys
|
|
108
|
+
# on the NAME: someone who writes `has_secret=the_actual_secret` still leaks, but
|
|
109
|
+
# that hole predates this and is inherent to a name-only rule.)
|
|
110
|
+
#
|
|
111
|
+
# WHY IT IS SAFE IN THE PERMISSIVE DIRECTION: every member is a copula/auxiliary
|
|
112
|
+
# verb that is never the head noun of a credential. Real secret names are noun
|
|
113
|
+
# phrases — `auth_token`, `api_key`, `signing_secret`, `INTERNAL_ADMIN_TOKEN` —
|
|
114
|
+
# and none begins with `is`/`has`/`was`/`are`/`can`/`should`. Matching is on the
|
|
115
|
+
# whole leading WORD, never a prefix of one, so names that merely start with
|
|
116
|
+
# those letters keep firing: `hash_secret` (`hash` != `has`), `issuer_token`
|
|
117
|
+
# (`issuer` != `is`), `canary_token` (`canary` != `can`).
|
|
118
|
+
_FLAG_PREFIXES = frozenset({"is", "has", "was", "are", "can", "should"})
|
|
119
|
+
|
|
92
120
|
# camelCase / PascalCase / ALLCAPS / digit run splitter, applied to each
|
|
93
121
|
# snake/kebab segment. `APIKey` -> ["API", "Key"], `authToken` -> ["auth", "Token"].
|
|
94
122
|
_CAMEL_RE = re.compile(r"[A-Z]+(?=[A-Z][a-z])|[A-Z]?[a-z]+|[A-Z]+|\d+")
|
|
@@ -115,6 +143,27 @@ def identifier_tokens(identifier: str) -> list[str]:
|
|
|
115
143
|
return tokens
|
|
116
144
|
|
|
117
145
|
|
|
146
|
+
def leading_word(identifier: str) -> str | None:
|
|
147
|
+
"""Return the first *word* of `identifier`, lowercased, or None if it has none.
|
|
148
|
+
|
|
149
|
+
`identifier_tokens` deliberately emits each whole snake/kebab segment before
|
|
150
|
+
its camel parts, so its first entry for the camelCase `hasSecret` is the
|
|
151
|
+
useless `hassecret` rather than `has`. Splitting the leading segment with the
|
|
152
|
+
same camel regex makes `has_secret` and `hasSecret` both yield `has`.
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
The lowercased leading word, or None when `identifier` has no word
|
|
156
|
+
characters at all.
|
|
157
|
+
|
|
158
|
+
"""
|
|
159
|
+
for segment in _SEGMENT_RE.split(identifier):
|
|
160
|
+
if not segment:
|
|
161
|
+
continue
|
|
162
|
+
parts = _CAMEL_RE.findall(segment)
|
|
163
|
+
return parts[0].lower() if parts else segment.lower()
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
|
|
118
167
|
def is_secret_name(identifier: str) -> bool:
|
|
119
168
|
"""Report whether `identifier` names raw secret material (a credential, not metadata).
|
|
120
169
|
|
|
@@ -125,6 +174,8 @@ def is_secret_name(identifier: str) -> bool:
|
|
|
125
174
|
tokens = identifier_tokens(identifier)
|
|
126
175
|
if tokens and tokens[-1] in _INNOCUOUS_WORDS:
|
|
127
176
|
return False
|
|
177
|
+
if leading_word(identifier) in _FLAG_PREFIXES:
|
|
178
|
+
return False
|
|
128
179
|
if any(tok in _SECRET_WORDS for tok in tokens):
|
|
129
180
|
return True
|
|
130
181
|
return _has_api_key(tokens)
|
|
@@ -82,9 +82,7 @@ _PRIMITIVES = frozenset({"str", "int", "float", "bool"})
|
|
|
82
82
|
|
|
83
83
|
_EXEMPT_DECORATORS = frozenset({"override", "overload", "abstractmethod"})
|
|
84
84
|
|
|
85
|
-
_HTTP_ROUTE_METHODS = frozenset(
|
|
86
|
-
{"get", "post", "put", "patch", "delete", "head", "options", "websocket"}
|
|
87
|
-
)
|
|
85
|
+
_HTTP_ROUTE_METHODS = frozenset({"get", "post", "put", "patch", "delete", "head", "options", "websocket"})
|
|
88
86
|
|
|
89
87
|
_EXEMPT_NAME_PREFIXES = ("visit_", "test_")
|
|
90
88
|
|
|
@@ -229,9 +227,7 @@ def _value_referenced_names(tree: ast.AST) -> frozenset[str]:
|
|
|
229
227
|
The set of names loaded outside call position.
|
|
230
228
|
|
|
231
229
|
"""
|
|
232
|
-
call_funcs = {
|
|
233
|
-
id(node.func) for node in ast.walk(tree) if isinstance(node, ast.Call)
|
|
234
|
-
}
|
|
230
|
+
call_funcs = {id(node.func) for node in ast.walk(tree) if isinstance(node, ast.Call)}
|
|
235
231
|
return frozenset(
|
|
236
232
|
node.id
|
|
237
233
|
for node in ast.walk(tree)
|
{sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/no_comment_cruft.py
RENAMED
|
@@ -53,6 +53,7 @@ from sarj_python_lint.rules._paths import is_generated_source
|
|
|
53
53
|
|
|
54
54
|
|
|
55
55
|
if TYPE_CHECKING:
|
|
56
|
+
from collections.abc import Sequence
|
|
56
57
|
from pathlib import Path
|
|
57
58
|
|
|
58
59
|
|
|
@@ -144,6 +145,55 @@ def _is_word_char(ch: str) -> bool:
|
|
|
144
145
|
return ch.isalnum() or ch == "_"
|
|
145
146
|
|
|
146
147
|
|
|
148
|
+
_CODING_COOKIE_RE = re.compile(r"coding[:=]\s*[-_.a-zA-Z0-9]+")
|
|
149
|
+
|
|
150
|
+
# Only `>>>` arms a doctest block. A bare `...` cannot: an ASCII banner of
|
|
151
|
+
# dots starts with it, and exempting on that alone silently disabled the
|
|
152
|
+
# banner check.
|
|
153
|
+
_DOCTEST_PROMPT = ">>>"
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _is_coding_cookie(body: str) -> bool:
|
|
157
|
+
"""Report whether the comment is a PEP 263 source-encoding declaration.
|
|
158
|
+
|
|
159
|
+
`# encoding=utf-8` / `# -*- coding: utf-8 -*-` are read by the interpreter,
|
|
160
|
+
not commentary, and `rich` carries them at the top of test modules.
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
True when the body declares a source encoding.
|
|
164
|
+
|
|
165
|
+
"""
|
|
166
|
+
return bool(_CODING_COOKIE_RE.search(body))
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _doctest_block_lines(standalone: Sequence[tuple[int, int, str]]) -> set[int]:
|
|
170
|
+
"""Collect every line of a contiguous comment run that contains a doctest prompt.
|
|
171
|
+
|
|
172
|
+
A commented doctest is documentation, not dead code, but its *expected
|
|
173
|
+
output* lines look exactly like commented-out code — `# URL('https://...')`
|
|
174
|
+
in httpx's `_client.py` is the canonical shape. Exempting only the `>>>`
|
|
175
|
+
lines would still flag the output beneath them, so the whole run goes.
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
The line numbers belonging to a doctest comment block.
|
|
179
|
+
|
|
180
|
+
"""
|
|
181
|
+
exempt: set[int] = set()
|
|
182
|
+
block: list[tuple[int, str]] = []
|
|
183
|
+
|
|
184
|
+
def flush() -> None:
|
|
185
|
+
if any(body.startswith(_DOCTEST_PROMPT) for _, body in block):
|
|
186
|
+
exempt.update(line for line, _ in block)
|
|
187
|
+
block.clear()
|
|
188
|
+
|
|
189
|
+
for line, _, body in sorted(standalone):
|
|
190
|
+
if block and line != block[-1][0] + 1:
|
|
191
|
+
flush()
|
|
192
|
+
block.append((line, body))
|
|
193
|
+
flush()
|
|
194
|
+
return exempt
|
|
195
|
+
|
|
196
|
+
|
|
147
197
|
def _is_directive(body: str) -> bool:
|
|
148
198
|
low = body.lower()
|
|
149
199
|
for prefix in _DIRECTIVE_PREFIXES:
|
|
@@ -186,11 +236,7 @@ def _is_heading_underline(body: str, prev_body: str | None) -> bool:
|
|
|
186
236
|
return False
|
|
187
237
|
if prev_body is None:
|
|
188
238
|
return False
|
|
189
|
-
return (
|
|
190
|
-
any(_is_word_char(ch) for ch in prev_body)
|
|
191
|
-
and not _is_banner(prev_body)
|
|
192
|
-
and not _looks_like_code(prev_body)
|
|
193
|
-
)
|
|
239
|
+
return any(_is_word_char(ch) for ch in prev_body) and not _is_banner(prev_body) and not _looks_like_code(prev_body)
|
|
194
240
|
|
|
195
241
|
|
|
196
242
|
def _is_banner(body: str) -> bool:
|
|
@@ -288,8 +334,9 @@ class NoCommentCruft(Rule):
|
|
|
288
334
|
return []
|
|
289
335
|
diags: dict[int, Diagnostic] = {}
|
|
290
336
|
by_line = {line: body for line, _, body in standalone}
|
|
337
|
+
doctest_lines = _doctest_block_lines(standalone)
|
|
291
338
|
for line, col, body in standalone:
|
|
292
|
-
if _is_directive(body):
|
|
339
|
+
if _is_directive(body) or _is_coding_cookie(body) or line in doctest_lines:
|
|
293
340
|
continue
|
|
294
341
|
prev_body = by_line.get(line - 1)
|
|
295
342
|
msg = self._classify(body, prev_body)
|
|
@@ -36,8 +36,7 @@ class NoCorsWildcardWithCredentials(Rule):
|
|
|
36
36
|
id: str = "no-cors-wildcard-with-credentials"
|
|
37
37
|
code: str = "SARJ028"
|
|
38
38
|
description: str = (
|
|
39
|
-
'CORS `allow_credentials=True` with `"*"` in `allow_origins` lets any '
|
|
40
|
-
"site read authenticated responses."
|
|
39
|
+
'CORS `allow_credentials=True` with `"*"` in `allow_origins` lets any site read authenticated responses.'
|
|
41
40
|
)
|
|
42
41
|
|
|
43
42
|
@override
|
|
@@ -126,9 +126,7 @@ class NoIsinstanceUnionChain(Rule):
|
|
|
126
126
|
tree = parse_or_none(path, source)
|
|
127
127
|
if tree is None:
|
|
128
128
|
return []
|
|
129
|
-
local_classes = frozenset(
|
|
130
|
-
node.name for node in ast.walk(tree) if isinstance(node, ast.ClassDef)
|
|
131
|
-
)
|
|
129
|
+
local_classes = frozenset(node.name for node in ast.walk(tree) if isinstance(node, ast.ClassDef))
|
|
132
130
|
elif_nodes: set[int] = set()
|
|
133
131
|
diags: list[Diagnostic] = []
|
|
134
132
|
for node in ast.walk(tree):
|
|
@@ -44,9 +44,7 @@ if TYPE_CHECKING:
|
|
|
44
44
|
# `OFFSET` followed by a value/param token — the real pagination construct. This
|
|
45
45
|
# excludes the English word ("no base offset"), `'offset'` dict keys, and BigQuery
|
|
46
46
|
# `UNNEST(...) WITH OFFSET AS col` (array indexing, no value token after OFFSET).
|
|
47
|
-
_OFFSET_PAGINATION = re.compile(
|
|
48
|
-
r"\bOFFSET\s+(?:%s|%\(\w+\)s|:\w+|@\w+|\$\d+|\d+)", re.IGNORECASE
|
|
49
|
-
)
|
|
47
|
+
_OFFSET_PAGINATION = re.compile(r"\bOFFSET\s+(?:%s|%\(\w+\)s|:\w+|@\w+|\$\d+|\d+)", re.IGNORECASE)
|
|
50
48
|
|
|
51
49
|
|
|
52
50
|
class NoOffsetPagination(Rule):
|
|
@@ -123,8 +123,7 @@ class NoSentinelReturnOnExcept(Rule):
|
|
|
123
123
|
col=handler.col_offset + 1,
|
|
124
124
|
code=self.code,
|
|
125
125
|
message=(
|
|
126
|
-
"Bare `except: pass` silently swallows the exception — "
|
|
127
|
-
"re-raise, log it, or handle it explicitly."
|
|
126
|
+
"Bare `except: pass` silently swallows the exception — re-raise, log it, or handle it explicitly."
|
|
128
127
|
),
|
|
129
128
|
)
|
|
130
129
|
return None
|
|
@@ -15,10 +15,18 @@ Fires when ALL of these hold:
|
|
|
15
15
|
bare `parametrize` imported from pytest),
|
|
16
16
|
* the decorator does **not** pass `ids=` — one `ids=` covers the whole table, so
|
|
17
17
|
its presence exempts every case,
|
|
18
|
-
* and
|
|
19
|
-
comprehension, or a constructor/factory `Call`.
|
|
20
|
-
|
|
21
|
-
|
|
18
|
+
* and **every** column of the case is opaque to pytest's id generation: a
|
|
19
|
+
`dict`, `set`, comprehension, or a constructor/factory `Call`.
|
|
20
|
+
|
|
21
|
+
Requiring *every* column, not any, is the difference between a useful rule and
|
|
22
|
+
a noisy one. pytest builds an id by joining the per-argument ids with `-`, so a
|
|
23
|
+
single nameable column still distinguishes the case: `("0.0", Decimal("0.0"))`
|
|
24
|
+
reports as `0.0-value1`, which a reader can find. A third-party sweep over
|
|
25
|
+
pydantic, flask, httpx, requests and rich flagged 372 tables under the
|
|
26
|
+
any-column reading; the overwhelming majority paired an opaque value with a
|
|
27
|
+
perfectly nameable string or number — `Decimal('0.0')`, `datetime(2012, 4, 9)`,
|
|
28
|
+
`UUID(...)`, `timedelta(hours=10)`, `Err('...')` — and were false positives.
|
|
29
|
+
Only a case whose columns are *all* opaque degenerates to `case0`, `case1`,
|
|
22
30
|
* and that specific case is not individually named by `pytest.param(..., id=...)`.
|
|
23
31
|
|
|
24
32
|
The `pytest.param` unwrap is the load-bearing false-positive guard. A first pass
|
|
@@ -142,12 +150,14 @@ def _is_unnameable(case: ast.expr) -> bool:
|
|
|
142
150
|
# An explicitly named case is fine however opaque its payload is.
|
|
143
151
|
if _has_keyword(case, "id"):
|
|
144
152
|
return False
|
|
145
|
-
return
|
|
153
|
+
return bool(case.args) and all(_is_opaque_value(arg) for arg in case.args)
|
|
146
154
|
return _is_opaque_value(case)
|
|
147
155
|
|
|
148
156
|
|
|
149
157
|
def _is_opaque_value(value: ast.expr) -> bool:
|
|
150
|
-
# A multi-column case is a tuple
|
|
158
|
+
# A multi-column case is a tuple. pytest joins the per-column ids with `-`,
|
|
159
|
+
# so one nameable column is enough to tell the case apart — only an
|
|
160
|
+
# all-opaque case degenerates to `case0`.
|
|
151
161
|
if isinstance(value, ast.Tuple):
|
|
152
|
-
return
|
|
162
|
+
return bool(value.elts) and all(_is_opaque_value(elt) for elt in value.elts)
|
|
153
163
|
return isinstance(value, _OPAQUE_NODES)
|
|
@@ -49,10 +49,6 @@ _DESCRIPTOR_WORDS = frozenset({"type", "types", "name", "names", "id", "ids", "k
|
|
|
49
49
|
# credential: `TOKEN_TYPE_SYSTEM`, `credential_type`, `grant_kind`.
|
|
50
50
|
_CATEGORY_WORDS = frozenset({"type", "types", "kind", "kinds"})
|
|
51
51
|
|
|
52
|
-
# A leading boolean-predicate token marks a flag, not the credential itself:
|
|
53
|
-
# `is_token`, `has_secret`, `is_token_strategy`.
|
|
54
|
-
_FLAG_PREFIXES = frozenset({"is", "has", "was", "are", "can", "should"})
|
|
55
|
-
|
|
56
52
|
# Words that make an identifier a secret *only* via an integrity/content hash
|
|
57
53
|
# (`content_hash`, `metadata_hash`, `row_hash`) rather than an authenticator.
|
|
58
54
|
# A name that ALSO carries one of these keeps firing (`password_hash`,
|
|
@@ -182,10 +178,17 @@ def _is_auth_secret_name(identifier: str, *, crypto_module: bool) -> bool:
|
|
|
182
178
|
"""Report whether `identifier` names an authenticator (an access-gating secret).
|
|
183
179
|
|
|
184
180
|
Narrows the shared `is_secret_name` for SARJ011: strips category/handle
|
|
185
|
-
descriptors, `type`/`kind` discriminators,
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
181
|
+
descriptors, `type`/`kind` discriminators, and integrity-only hashes, none
|
|
182
|
+
of which are a timing-attack surface. A name whose only auth token is the
|
|
183
|
+
polysemous `signature` needs the module to import crypto machinery —
|
|
184
|
+
otherwise it is a function signature, not a MAC.
|
|
185
|
+
|
|
186
|
+
Boolean flags (`is_token`, `hasSecret`) are NOT handled here: the shared
|
|
187
|
+
`is_secret_name` now rejects a leading flag word for both SARJ011 and
|
|
188
|
+
SARJ012, so the gate above has already returned. A duplicate local check
|
|
189
|
+
used to live here and was dead once that landed — worse, it read
|
|
190
|
+
`tokens[0]`, which is the whole snake segment (`"hassecret"`), so it never
|
|
191
|
+
matched a camelCase flag in the first place.
|
|
189
192
|
|
|
190
193
|
Returns:
|
|
191
194
|
True when comparing `identifier` in non-constant time leaks an auth secret.
|
|
@@ -194,8 +197,6 @@ def _is_auth_secret_name(identifier: str, *, crypto_module: bool) -> bool:
|
|
|
194
197
|
if not is_secret_name(identifier):
|
|
195
198
|
return False
|
|
196
199
|
tokens = identifier_tokens(identifier)
|
|
197
|
-
if tokens and tokens[0] in _FLAG_PREFIXES:
|
|
198
|
-
return False
|
|
199
200
|
if tokens and tokens[-1] in _DESCRIPTOR_WORDS:
|
|
200
201
|
return False
|
|
201
202
|
if any(tok in _CATEGORY_WORDS for tok in tokens):
|
|
@@ -244,9 +244,7 @@ def _module_scope_classdefs(tree: ast.Module) -> list[ast.ClassDef]:
|
|
|
244
244
|
return found
|
|
245
245
|
|
|
246
246
|
|
|
247
|
-
def _enum_member_names(
|
|
248
|
-
classdefs: list[ast.ClassDef], local_enums: frozenset[str]
|
|
249
|
-
) -> dict[str, frozenset[str]]:
|
|
247
|
+
def _enum_member_names(classdefs: list[ast.ClassDef], local_enums: frozenset[str]) -> dict[str, frozenset[str]]:
|
|
250
248
|
"""Map each module-scope enum's name to the member names it declares.
|
|
251
249
|
|
|
252
250
|
A member is a plain `NAME = <value>` assignment in the class body. Methods,
|
|
@@ -296,16 +294,13 @@ def _grown_dict_names(tree: ast.Module) -> frozenset[str]:
|
|
|
296
294
|
grown: set[str] = set()
|
|
297
295
|
for node in ast.walk(tree):
|
|
298
296
|
match node:
|
|
299
|
-
case ast.Call(
|
|
300
|
-
func=ast.Attribute(value=ast.Name(id=name), attr=attr)
|
|
301
|
-
) if attr in _DICT_GROWING_METHODS:
|
|
297
|
+
case ast.Call(func=ast.Attribute(value=ast.Name(id=name), attr=attr)) if attr in _DICT_GROWING_METHODS:
|
|
302
298
|
grown.add(name)
|
|
303
299
|
case ast.Assign(targets=targets):
|
|
304
300
|
grown.update(
|
|
305
301
|
subscript.value.id
|
|
306
302
|
for subscript in targets
|
|
307
|
-
if isinstance(subscript, ast.Subscript)
|
|
308
|
-
and isinstance(subscript.value, ast.Name)
|
|
303
|
+
if isinstance(subscript, ast.Subscript) and isinstance(subscript.value, ast.Name)
|
|
309
304
|
)
|
|
310
305
|
case _:
|
|
311
306
|
pass
|
|
@@ -347,9 +342,7 @@ def _incomplete_dispatch_map(
|
|
|
347
342
|
if owner is None or owner not in enum_members:
|
|
348
343
|
return None
|
|
349
344
|
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
|
-
}
|
|
345
|
+
covered = {key.attr for key in mapping.keys if isinstance(key, ast.Attribute) and key.attr in declared}
|
|
353
346
|
if len(covered) != len(mapping.keys) or not covered < declared:
|
|
354
347
|
return None
|
|
355
348
|
missing = ", ".join(f"{owner}.{name}" for name in sorted(declared - covered))
|
|
@@ -483,9 +476,7 @@ def _is_silent_body(body: list[ast.stmt]) -> bool:
|
|
|
483
476
|
return False
|
|
484
477
|
|
|
485
478
|
|
|
486
|
-
def _all_one_owner_member_arms(
|
|
487
|
-
cases: list[ast.match_case], member_owners: frozenset[str]
|
|
488
|
-
) -> bool:
|
|
479
|
+
def _all_one_owner_member_arms(cases: list[ast.match_case], member_owners: frozenset[str]) -> bool:
|
|
489
480
|
"""Report whether every arm matches enum-member values of one owner class.
|
|
490
481
|
|
|
491
482
|
The owner must be a name that can actually bind a class here: defined by a
|
|
@@ -535,9 +526,7 @@ def _is_local_class_pattern(pattern: ast.pattern, local_classes: frozenset[str])
|
|
|
535
526
|
return False
|
|
536
527
|
|
|
537
528
|
|
|
538
|
-
def _silent_enum_chain(
|
|
539
|
-
head: ast.If, local_enums: frozenset[str], consumed_elifs: set[int]
|
|
540
|
-
) -> str | None:
|
|
529
|
+
def _silent_enum_chain(head: ast.If, local_enums: frozenset[str], consumed_elifs: set[int]) -> str | None:
|
|
541
530
|
"""Parse `head` as an ==/in chain over one local enum with a silent `else`.
|
|
542
531
|
|
|
543
532
|
Each nested `elif` that genuinely continues the chain (same target, same
|
|
@@ -32,6 +32,14 @@ reason mentioning intermittence.
|
|
|
32
32
|
|
|
33
33
|
Deliberately NOT flagged:
|
|
34
34
|
|
|
35
|
+
* **property-based and fuzz tests.** A `@given(...)` (hypothesis) or
|
|
36
|
+
`<schema>.parametrize()` (schemathesis) decorator means one test function
|
|
37
|
+
expands into many generated cases, and a documented bug is typically tripped
|
|
38
|
+
by only a subset of them — the rest legitimately XPASS. `strict=True` there
|
|
39
|
+
turns every passing generated input into a failure, which is why these suites
|
|
40
|
+
set `strict=False` deliberately. Found against bulbul's
|
|
41
|
+
`test_calls_fuzz_known_bugs`, where the unroutable-id shapes trip the bug and
|
|
42
|
+
the other generated ids do not,
|
|
35
43
|
* `xfail` with no `reason=`, or a reason describing an environment gate rather
|
|
36
44
|
than a defect ("no GPU on CI") — those are not bug pins,
|
|
37
45
|
* `pytest.xfail(...)` called imperatively inside a body — that aborts the test
|
|
@@ -65,6 +73,14 @@ _NONDETERMINISM_RE = re.compile(r"intermittent|flak|sometimes|non-?deterministic
|
|
|
65
73
|
# Sibling markers that declare a nondeterministic dependency.
|
|
66
74
|
_NONDETERMINISTIC_MARKERS = frozenset({"real_llm", "flaky", "network", "integration"})
|
|
67
75
|
|
|
76
|
+
# Hypothesis' entry point. One `@given` expands into many generated inputs.
|
|
77
|
+
_PROPERTY_DECORATORS = frozenset({"given"})
|
|
78
|
+
|
|
79
|
+
# schemathesis binds `.parametrize()` on a schema object. `pytest.mark.parametrize`
|
|
80
|
+
# is a fixed table and is NOT this — it is excluded by checking the receiver.
|
|
81
|
+
_PARAMETRIZE_ATTR = "parametrize"
|
|
82
|
+
_PYTEST_MARK = "mark"
|
|
83
|
+
|
|
68
84
|
_FUNC_NODES = (ast.FunctionDef, ast.AsyncFunctionDef)
|
|
69
85
|
|
|
70
86
|
|
|
@@ -119,7 +135,26 @@ def _rotting_bug_pins(tree: ast.Module) -> list[ast.Call]:
|
|
|
119
135
|
|
|
120
136
|
|
|
121
137
|
def _has_nondeterministic_marker(decorators: list[ast.expr]) -> bool:
|
|
122
|
-
return any(_marker_name(dec) in _NONDETERMINISTIC_MARKERS for dec in decorators)
|
|
138
|
+
return any(_marker_name(dec) in _NONDETERMINISTIC_MARKERS or _is_property_based(dec) for dec in decorators)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _is_property_based(dec: ast.expr) -> bool:
|
|
142
|
+
"""Report whether `dec` expands the test into many generated inputs.
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
True for a hypothesis `@given(...)` or a schemathesis
|
|
146
|
+
`<schema>.parametrize()`, both of which make a partial XPASS normal.
|
|
147
|
+
|
|
148
|
+
"""
|
|
149
|
+
target = dec.func if isinstance(dec, ast.Call) else dec
|
|
150
|
+
if isinstance(target, ast.Name):
|
|
151
|
+
return target.id in _PROPERTY_DECORATORS
|
|
152
|
+
if not isinstance(target, ast.Attribute) or target.attr != _PARAMETRIZE_ATTR:
|
|
153
|
+
return False
|
|
154
|
+
# `pytest.mark.parametrize` is a fixed table, not a generator — the receiver
|
|
155
|
+
# is `mark`. A schemathesis schema object is anything else.
|
|
156
|
+
receiver = target.value
|
|
157
|
+
return not (isinstance(receiver, ast.Attribute) and receiver.attr == _PYTEST_MARK)
|
|
123
158
|
|
|
124
159
|
|
|
125
160
|
def _marker_name(dec: ast.expr) -> str | None:
|
|
@@ -39,7 +39,17 @@ Deliberately NOT flagged:
|
|
|
39
39
|
|
|
40
40
|
* a test marked `@pytest.mark.skip`/`skipif`/`xfail` — it is not expected to
|
|
41
41
|
verify anything right now,
|
|
42
|
-
* a
|
|
42
|
+
* **a `test_*` function nested inside another function.** pytest only collects
|
|
43
|
+
module-level functions and methods of `Test*` classes, so a nested one is not
|
|
44
|
+
a test at all — it is a callback that happens to be named for what it does.
|
|
45
|
+
Flask route handlers are the canonical case: `def test_index()` registered
|
|
46
|
+
with `@app.route("/", subdomain="test")` inside a test that asserts on the
|
|
47
|
+
response afterwards. A third-party sweep found 36 such hits, every one a false
|
|
48
|
+
positive. Only functions whose parent is the module or a class are considered.
|
|
49
|
+
* **a `@pytest.fixture`**, whatever it is named. `flask/tests/conftest.py`
|
|
50
|
+
defines `def test_apps(monkeypatch)` as a fixture; it sets up `sys.path` and
|
|
51
|
+
yields, and asserting nothing is exactly right for it,
|
|
52
|
+
* a helper, or any function not named `test_*`,
|
|
43
53
|
* an abstract or stub body (`...`, `pass`, docstring only) — an intentionally
|
|
44
54
|
empty placeholder is a different problem from a half-written test,
|
|
45
55
|
* anything under a `scripts/` directory. `digital-bank/banking-ai/chat/scripts/`
|
|
@@ -75,6 +85,8 @@ _FLUENT_ATTRS = frozenset({"expect"})
|
|
|
75
85
|
|
|
76
86
|
_SKIP_MARKERS = frozenset({"skip", "skipif", "xfail"})
|
|
77
87
|
|
|
88
|
+
_FIXTURE = "fixture"
|
|
89
|
+
|
|
78
90
|
_FUNC_NODES = (ast.FunctionDef, ast.AsyncFunctionDef)
|
|
79
91
|
|
|
80
92
|
# Manual CLI probes live here under test_*.py names but are never collected.
|
|
@@ -126,15 +138,40 @@ def _is_uncollected(path: Path) -> bool:
|
|
|
126
138
|
|
|
127
139
|
def _unverifying_tests(tree: ast.Module) -> list[ast.FunctionDef | ast.AsyncFunctionDef]:
|
|
128
140
|
hits: list[ast.FunctionDef | ast.AsyncFunctionDef] = []
|
|
129
|
-
for node in
|
|
130
|
-
if
|
|
131
|
-
continue
|
|
132
|
-
if _is_skipped(node) or _is_placeholder(node) or _verifies_something(node):
|
|
141
|
+
for node in _collectible_tests(tree):
|
|
142
|
+
if _is_skipped(node) or _is_fixture(node) or _is_placeholder(node) or _verifies_something(node):
|
|
133
143
|
continue
|
|
134
144
|
hits.append(node)
|
|
135
145
|
return hits
|
|
136
146
|
|
|
137
147
|
|
|
148
|
+
def _collectible_tests(tree: ast.Module) -> list[ast.FunctionDef | ast.AsyncFunctionDef]:
|
|
149
|
+
"""Collect the `test_*` functions pytest would actually run.
|
|
150
|
+
|
|
151
|
+
Only module-level functions and methods of a class qualify. A `test_*`
|
|
152
|
+
nested inside another function is a callback, not a test — pytest never
|
|
153
|
+
collects it — so descending into function bodies would invent findings.
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
The test functions in the order they appear.
|
|
157
|
+
|
|
158
|
+
"""
|
|
159
|
+
found: list[ast.FunctionDef | ast.AsyncFunctionDef] = []
|
|
160
|
+
containers: list[ast.Module | ast.ClassDef] = [tree]
|
|
161
|
+
while containers:
|
|
162
|
+
for stmt in containers.pop().body:
|
|
163
|
+
if isinstance(stmt, ast.ClassDef):
|
|
164
|
+
containers.append(stmt)
|
|
165
|
+
elif isinstance(stmt, _FUNC_NODES) and stmt.name.startswith("test_"):
|
|
166
|
+
found.append(stmt)
|
|
167
|
+
found.sort(key=lambda n: (n.lineno, n.col_offset))
|
|
168
|
+
return found
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _is_fixture(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
|
|
172
|
+
return any(_marker_name(dec) == _FIXTURE for dec in node.decorator_list)
|
|
173
|
+
|
|
174
|
+
|
|
138
175
|
def _is_skipped(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
|
|
139
176
|
return any(_marker_name(dec) in _SKIP_MARKERS for dec in node.decorator_list)
|
|
140
177
|
|
|
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.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/mock_without_spec.py
RENAMED
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/no_fat_try_blocks.py
RENAMED
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/no_fstring_in_log.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/no_secret_in_log.py
RENAMED
|
File without changes
|
{sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/no_select_star.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.15.0 → sarj_python_lint-0.16.0}/src/sarj_python_lint/rules/prefer_class_row.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.15.0 → sarj_python_lint-0.16.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
|
|
File without changes
|
|
File without changes
|