sarj-python-lint 0.7.0__tar.gz → 0.8.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {sarj_python_lint-0.7.0 → sarj_python_lint-0.8.0}/PKG-INFO +1 -2
- {sarj_python_lint-0.7.0 → sarj_python_lint-0.8.0}/README.md +0 -1
- {sarj_python_lint-0.7.0 → sarj_python_lint-0.8.0}/pyproject.toml +1 -1
- {sarj_python_lint-0.7.0 → sarj_python_lint-0.8.0}/src/sarj_python_lint/rules/_registry.py +0 -2
- sarj_python_lint-0.7.0/src/sarj_python_lint/rules/prefer_discriminated_union.py +0 -391
- {sarj_python_lint-0.7.0 → sarj_python_lint-0.8.0}/.gitignore +0 -0
- {sarj_python_lint-0.7.0 → sarj_python_lint-0.8.0}/src/sarj_python_lint/__init__.py +0 -0
- {sarj_python_lint-0.7.0 → sarj_python_lint-0.8.0}/src/sarj_python_lint/__main__.py +0 -0
- {sarj_python_lint-0.7.0 → sarj_python_lint-0.8.0}/src/sarj_python_lint/py.typed +0 -0
- {sarj_python_lint-0.7.0 → sarj_python_lint-0.8.0}/src/sarj_python_lint/rule_base.py +0 -0
- {sarj_python_lint-0.7.0 → sarj_python_lint-0.8.0}/src/sarj_python_lint/rules/__init__.py +0 -0
- {sarj_python_lint-0.7.0 → sarj_python_lint-0.8.0}/src/sarj_python_lint/rules/_logging.py +0 -0
- {sarj_python_lint-0.7.0 → sarj_python_lint-0.8.0}/src/sarj_python_lint/rules/inefficient_string_concat_in_loop.py +0 -0
- {sarj_python_lint-0.7.0 → sarj_python_lint-0.8.0}/src/sarj_python_lint/rules/no_aggregation_in_store_query.py +0 -0
- {sarj_python_lint-0.7.0 → sarj_python_lint-0.8.0}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
- {sarj_python_lint-0.7.0 → sarj_python_lint-0.8.0}/src/sarj_python_lint/rules/no_fat_try_blocks.py +0 -0
- {sarj_python_lint-0.7.0 → sarj_python_lint-0.8.0}/src/sarj_python_lint/rules/no_fstring_in_log.py +0 -0
- {sarj_python_lint-0.7.0 → sarj_python_lint-0.8.0}/src/sarj_python_lint/rules/no_isinstance_union_chain.py +0 -0
- {sarj_python_lint-0.7.0 → sarj_python_lint-0.8.0}/src/sarj_python_lint/rules/no_query_with_many_joins.py +0 -0
- {sarj_python_lint-0.7.0 → sarj_python_lint-0.8.0}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
- {sarj_python_lint-0.7.0 → sarj_python_lint-0.8.0}/src/sarj_python_lint/rules/no_select_star.py +0 -0
- {sarj_python_lint-0.7.0 → sarj_python_lint-0.8.0}/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +0 -0
- {sarj_python_lint-0.7.0 → sarj_python_lint-0.8.0}/src/sarj_python_lint/rules/no_sequential_await.py +0 -0
- {sarj_python_lint-0.7.0 → sarj_python_lint-0.8.0}/src/sarj_python_lint/rules/no_unreachable_after_terminal.py +0 -0
- {sarj_python_lint-0.7.0 → sarj_python_lint-0.8.0}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
- {sarj_python_lint-0.7.0 → sarj_python_lint-0.8.0}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
- {sarj_python_lint-0.7.0 → sarj_python_lint-0.8.0}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
- {sarj_python_lint-0.7.0 → sarj_python_lint-0.8.0}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
- {sarj_python_lint-0.7.0 → sarj_python_lint-0.8.0}/src/sarj_python_lint/rules/prefer_timedelta_for_durations.py +0 -0
- {sarj_python_lint-0.7.0 → sarj_python_lint-0.8.0}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +0 -0
- {sarj_python_lint-0.7.0 → sarj_python_lint-0.8.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.8.0
|
|
4
4
|
Summary: Custom Python lint rules — AST-based, pre-commit-friendly, hypermodern defaults
|
|
5
5
|
Project-URL: Homepage, https://github.com/sarj-ai/standards/tree/main/packages/python
|
|
6
6
|
Project-URL: Repository, https://github.com/sarj-ai/standards
|
|
@@ -32,7 +32,6 @@ uv tool install sarj-python-lint
|
|
|
32
32
|
hooks:
|
|
33
33
|
- id: sarj-no-sequential-await
|
|
34
34
|
- id: sarj-inefficient-string-concat-in-loop
|
|
35
|
-
- id: sarj-prefer-discriminated-union
|
|
36
35
|
- id: sarj-prefer-str-enum
|
|
37
36
|
- id: sarj-no-fat-try-blocks
|
|
38
37
|
- id: sarj-pydantic-at-boundaries
|
|
@@ -14,7 +14,6 @@ uv tool install sarj-python-lint
|
|
|
14
14
|
hooks:
|
|
15
15
|
- id: sarj-no-sequential-await
|
|
16
16
|
- id: sarj-inefficient-string-concat-in-loop
|
|
17
|
-
- id: sarj-prefer-discriminated-union
|
|
18
17
|
- id: sarj-prefer-str-enum
|
|
19
18
|
- id: sarj-no-fat-try-blocks
|
|
20
19
|
- id: sarj-pydantic-at-boundaries
|
|
@@ -24,7 +24,6 @@ from sarj_python_lint.rules.prefer_class_row import PreferClassRow
|
|
|
24
24
|
from sarj_python_lint.rules.prefer_constant_time_secret_compare import (
|
|
25
25
|
PreferConstantTimeSecretCompare,
|
|
26
26
|
)
|
|
27
|
-
from sarj_python_lint.rules.prefer_discriminated_union import PreferDiscriminatedUnion
|
|
28
27
|
from sarj_python_lint.rules.prefer_str_enum import PreferStrEnum
|
|
29
28
|
from sarj_python_lint.rules.prefer_struct_over_namedtuple import (
|
|
30
29
|
PreferStructOverNamedtuple,
|
|
@@ -45,7 +44,6 @@ if TYPE_CHECKING:
|
|
|
45
44
|
REGISTRY: dict[str, type[Rule]] = {
|
|
46
45
|
NoSequentialAwait.id: NoSequentialAwait,
|
|
47
46
|
InefficientStringConcatInLoop.id: InefficientStringConcatInLoop,
|
|
48
|
-
PreferDiscriminatedUnion.id: PreferDiscriminatedUnion,
|
|
49
47
|
PreferClassRow.id: PreferClassRow,
|
|
50
48
|
PreferStrEnum.id: PreferStrEnum,
|
|
51
49
|
NoFatTryBlocks.id: NoFatTryBlocks,
|
|
@@ -1,391 +0,0 @@
|
|
|
1
|
-
"""SARJ005: flag poor-man's-result shapes — prefer a discriminated union.
|
|
2
|
-
|
|
3
|
-
Three triggers:
|
|
4
|
-
|
|
5
|
-
1. **success-bool model** — a pydantic BaseModel with a bool status field plus
|
|
6
|
-
Optional siblings:
|
|
7
|
-
|
|
8
|
-
class Result(BaseModel):
|
|
9
|
-
success: bool
|
|
10
|
-
data: Optional[Data] = None
|
|
11
|
-
error: Optional[str] = None
|
|
12
|
-
|
|
13
|
-
allows illegal states (success=True with data=None, or success=False with
|
|
14
|
-
data set). Use a discriminated union:
|
|
15
|
-
|
|
16
|
-
class Success(BaseModel): data: Data
|
|
17
|
-
class Failure(BaseModel): error: str
|
|
18
|
-
Result = Union[Success, Failure]
|
|
19
|
-
|
|
20
|
-
2. **bool-tuple result** — a function whose return annotation is a two-element
|
|
21
|
-
`tuple[bool, X]` / `tuple[X, bool]` (also `Tuple[...]` and `X | None`
|
|
22
|
-
payloads): the classic `(ok, value)` poor-man's-result. Model
|
|
23
|
-
success/failure as a discriminated union (e.g. `Ok[T] | Err`) instead of a
|
|
24
|
-
bool-tuple — the bool and the payload can disagree.
|
|
25
|
-
|
|
26
|
-
3. **nullable cluster with a discriminator** — a pydantic BaseModel or
|
|
27
|
-
dataclass with 3+ `X | None` / `Optional[X]` fields AND a str / StrEnum /
|
|
28
|
-
Literal field named like a discriminator (`status`, `state`, `type`,
|
|
29
|
-
`kind`, `result`, `outcome`):
|
|
30
|
-
|
|
31
|
-
class Call(BaseModel):
|
|
32
|
-
status: str
|
|
33
|
-
started_at: datetime | None = None
|
|
34
|
-
ended_at: datetime | None = None
|
|
35
|
-
error: str | None = None
|
|
36
|
-
|
|
37
|
-
Split into per-state models in a discriminated union (the CallState
|
|
38
|
-
pattern: `PendingCall | ActiveCall | CompletedCall | FailedCall`) so each
|
|
39
|
-
state carries exactly the fields that are valid for it.
|
|
40
|
-
|
|
41
|
-
Query/filter inputs and PATCH-style partial-update DTOs legitimately hold
|
|
42
|
-
many optional fields, so class names matching those conventions
|
|
43
|
-
(`*Input` / `*Params` / `*Filter` / `*Query` / `Update*` / `Patch*` /
|
|
44
|
-
`Upsert*`) are excluded from this trigger.
|
|
45
|
-
|
|
46
|
-
A single-value `Literal` tag (e.g. `type: Literal["complete"]`) marks a model
|
|
47
|
-
that is already an arm of a discriminated union, so it is excluded too — a
|
|
48
|
-
multi-value `Literal[...]` is still treated as a poor-man's discriminator.
|
|
49
|
-
|
|
50
|
-
References:
|
|
51
|
-
- https://docs.pydantic.dev/latest/concepts/unions/#discriminated-unions
|
|
52
|
-
- https://en.wikipedia.org/wiki/Tagged_union
|
|
53
|
-
"""
|
|
54
|
-
|
|
55
|
-
from __future__ import annotations
|
|
56
|
-
|
|
57
|
-
import ast
|
|
58
|
-
from typing import TYPE_CHECKING, override
|
|
59
|
-
|
|
60
|
-
from sarj_python_lint.rule_base import Diagnostic, Rule
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
if TYPE_CHECKING:
|
|
64
|
-
from pathlib import Path
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
STATUS_FIELDS = {"success", "ok", "is_success", "succeeded", "successful", "failed", "failure"}
|
|
68
|
-
IGNORED_OPTIONAL_FIELDS = {
|
|
69
|
-
"metadata",
|
|
70
|
-
"meta",
|
|
71
|
-
"debug",
|
|
72
|
-
"debug_logs",
|
|
73
|
-
"extra",
|
|
74
|
-
"log",
|
|
75
|
-
"logs",
|
|
76
|
-
"traceback",
|
|
77
|
-
"request_id",
|
|
78
|
-
"trace_id",
|
|
79
|
-
}
|
|
80
|
-
DISCRIMINATOR_FIELD_NAMES = {"status", "state", "type", "kind", "result", "outcome"}
|
|
81
|
-
NULLABLE_CLUSTER_THRESHOLD = 3
|
|
82
|
-
# A bool status field plus this many Optional siblings trips the original trigger.
|
|
83
|
-
OPTIONAL_SIBLINGS_THRESHOLD = 2
|
|
84
|
-
# An (ok, value) bool-tuple has exactly two elements.
|
|
85
|
-
_BOOL_TUPLE_LEN = 2
|
|
86
|
-
# Query/filter inputs and partial-update DTOs are all-optional by design.
|
|
87
|
-
DTO_CLASS_NAME_SUFFIXES = ("Input", "Params", "Filter", "Query")
|
|
88
|
-
DTO_CLASS_NAME_PREFIXES = ("Update", "Patch", "Upsert")
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
class PreferDiscriminatedUnion(Rule):
|
|
92
|
-
"""Bool-status models, bool-tuple results, status+Optionals — prefer a discriminated union."""
|
|
93
|
-
|
|
94
|
-
id: str = "prefer-discriminated-union"
|
|
95
|
-
code: str = "SARJ005"
|
|
96
|
-
description: str = (
|
|
97
|
-
"success:bool + Optionals, tuple[bool, X] results, or status + nullable "
|
|
98
|
-
"cluster — use a discriminated union."
|
|
99
|
-
)
|
|
100
|
-
|
|
101
|
-
@override
|
|
102
|
-
def check(self, path: Path, source: str) -> list[Diagnostic]:
|
|
103
|
-
try:
|
|
104
|
-
tree = ast.parse(source, filename=str(path))
|
|
105
|
-
except SyntaxError:
|
|
106
|
-
return []
|
|
107
|
-
diags: list[Diagnostic] = []
|
|
108
|
-
str_enum_names = _collect_str_enum_names(tree)
|
|
109
|
-
for node in ast.walk(tree):
|
|
110
|
-
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
111
|
-
diag = self._check_bool_tuple_return(path, node)
|
|
112
|
-
if diag is not None:
|
|
113
|
-
diags.append(diag)
|
|
114
|
-
continue
|
|
115
|
-
if not isinstance(node, ast.ClassDef):
|
|
116
|
-
continue
|
|
117
|
-
diag = self._check_class(path, node, str_enum_names)
|
|
118
|
-
if diag is not None:
|
|
119
|
-
diags.append(diag)
|
|
120
|
-
return diags
|
|
121
|
-
|
|
122
|
-
def _check_bool_tuple_return(
|
|
123
|
-
self, path: Path, node: ast.FunctionDef | ast.AsyncFunctionDef
|
|
124
|
-
) -> Diagnostic | None:
|
|
125
|
-
if not _is_bool_tuple(node.returns):
|
|
126
|
-
return None
|
|
127
|
-
returns_text = ast.unparse(node.returns) if node.returns else ""
|
|
128
|
-
return Diagnostic(
|
|
129
|
-
path=path,
|
|
130
|
-
line=node.lineno,
|
|
131
|
-
col=node.col_offset + 1,
|
|
132
|
-
code=self.code,
|
|
133
|
-
message=(
|
|
134
|
-
f"`{node.name}` returns `{returns_text}` — a (ok, value) bool-tuple. "
|
|
135
|
-
"Model success/failure as a discriminated union "
|
|
136
|
-
"(e.g. `Ok[T] | Err`), not a bool-tuple."
|
|
137
|
-
),
|
|
138
|
-
)
|
|
139
|
-
|
|
140
|
-
def _check_class(
|
|
141
|
-
self, path: Path, node: ast.ClassDef, str_enum_names: set[str]
|
|
142
|
-
) -> Diagnostic | None:
|
|
143
|
-
is_model = _inherits_basemodel(node)
|
|
144
|
-
is_dc = _is_dataclass(node)
|
|
145
|
-
if not (is_model or is_dc):
|
|
146
|
-
return None
|
|
147
|
-
has_status_bool = False
|
|
148
|
-
has_literal_tag = False
|
|
149
|
-
optional_fields: list[str] = []
|
|
150
|
-
discriminator_fields: list[str] = []
|
|
151
|
-
for stmt in node.body:
|
|
152
|
-
if not isinstance(stmt, ast.AnnAssign):
|
|
153
|
-
continue
|
|
154
|
-
if not isinstance(stmt.target, ast.Name):
|
|
155
|
-
continue
|
|
156
|
-
name = stmt.target.id
|
|
157
|
-
if name in STATUS_FIELDS and _is_bool_annotation(stmt.annotation):
|
|
158
|
-
has_status_bool = True
|
|
159
|
-
if name in DISCRIMINATOR_FIELD_NAMES and _is_discriminator_type(
|
|
160
|
-
stmt.annotation, str_enum_names
|
|
161
|
-
):
|
|
162
|
-
discriminator_fields.append(name)
|
|
163
|
-
if _is_single_value_literal(stmt.annotation):
|
|
164
|
-
has_literal_tag = True
|
|
165
|
-
if _is_optional(stmt.annotation) and name not in IGNORED_OPTIONAL_FIELDS:
|
|
166
|
-
optional_fields.append(name)
|
|
167
|
-
# Original trigger: bool status field + Optional siblings (BaseModel only).
|
|
168
|
-
if is_model and has_status_bool and len(optional_fields) >= OPTIONAL_SIBLINGS_THRESHOLD:
|
|
169
|
-
return Diagnostic(
|
|
170
|
-
path=path,
|
|
171
|
-
line=node.lineno,
|
|
172
|
-
col=node.col_offset + 1,
|
|
173
|
-
code=self.code,
|
|
174
|
-
message=(
|
|
175
|
-
f"`{node.name}` has a bool status field plus "
|
|
176
|
-
f"Optional fields ({', '.join(optional_fields)}). "
|
|
177
|
-
"Model as `Union[Success, Failure]` to make illegal "
|
|
178
|
-
"states unrepresentable."
|
|
179
|
-
),
|
|
180
|
-
)
|
|
181
|
-
# Nullable-cluster trigger: discriminator-ish field + 3 or more nullables.
|
|
182
|
-
# A single-value `Literal` tag (e.g. `type: Literal["complete"]`) marks a
|
|
183
|
-
# model that is already a discriminated-union arm, not a poor-man's result.
|
|
184
|
-
if (
|
|
185
|
-
discriminator_fields
|
|
186
|
-
and len(optional_fields) >= NULLABLE_CLUSTER_THRESHOLD
|
|
187
|
-
and not _is_dto_class_name(node.name)
|
|
188
|
-
and not has_literal_tag
|
|
189
|
-
):
|
|
190
|
-
return Diagnostic(
|
|
191
|
-
path=path,
|
|
192
|
-
line=node.lineno,
|
|
193
|
-
col=node.col_offset + 1,
|
|
194
|
-
code=self.code,
|
|
195
|
-
message=(
|
|
196
|
-
f"`{node.name}` has a discriminator-ish field "
|
|
197
|
-
f"(`{discriminator_fields[0]}`) plus {len(optional_fields)} nullable "
|
|
198
|
-
f"fields ({', '.join(optional_fields)}). Split into per-state models "
|
|
199
|
-
"in a discriminated union (the CallState pattern: "
|
|
200
|
-
"`PendingCall | ActiveCall | CompletedCall | FailedCall`)."
|
|
201
|
-
),
|
|
202
|
-
)
|
|
203
|
-
return None
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
def _is_dto_class_name(name: str) -> bool:
|
|
207
|
-
"""Query/filter input and partial-update DTO names are all-optional by design."""
|
|
208
|
-
return name.endswith(DTO_CLASS_NAME_SUFFIXES) or name.startswith(DTO_CLASS_NAME_PREFIXES)
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
def _is_single_value_literal(node: ast.AST | None) -> bool:
|
|
212
|
-
"""Detect a single-constant `Literal[X]` annotation.
|
|
213
|
-
|
|
214
|
-
A one-element `Literal` (e.g. `type: Literal["complete"]`) is the canonical
|
|
215
|
-
tag of a discriminated-union arm, so a model carrying one is already modelled
|
|
216
|
-
correctly. A multi-value `Literal[...]` is still a poor-man's discriminator.
|
|
217
|
-
"""
|
|
218
|
-
if node is None:
|
|
219
|
-
return False
|
|
220
|
-
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
|
221
|
-
try:
|
|
222
|
-
parsed = ast.parse(node.value, mode="eval")
|
|
223
|
-
except SyntaxError:
|
|
224
|
-
return False
|
|
225
|
-
return _is_single_value_literal(parsed.body)
|
|
226
|
-
if not isinstance(node, ast.Subscript):
|
|
227
|
-
return False
|
|
228
|
-
if _get_name_flat(node.value).rsplit(".", 1)[-1] != "Literal":
|
|
229
|
-
return False
|
|
230
|
-
slice_node = node.slice
|
|
231
|
-
if isinstance(slice_node, ast.Tuple):
|
|
232
|
-
return len(slice_node.elts) == 1
|
|
233
|
-
return True
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
def _inherits_basemodel(node: ast.ClassDef) -> bool:
|
|
237
|
-
for base in node.bases:
|
|
238
|
-
if isinstance(base, ast.Name) and base.id == "BaseModel":
|
|
239
|
-
return True
|
|
240
|
-
if isinstance(base, ast.Attribute) and base.attr == "BaseModel":
|
|
241
|
-
return True
|
|
242
|
-
return False
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
def _is_dataclass(node: ast.ClassDef) -> bool:
|
|
246
|
-
"""Detect `@dataclass`, `@dataclasses.dataclass`, and called forms."""
|
|
247
|
-
for deco in node.decorator_list:
|
|
248
|
-
target = deco.func if isinstance(deco, ast.Call) else deco
|
|
249
|
-
name = _get_name_flat(target)
|
|
250
|
-
if name == "dataclass" or name.endswith(".dataclass"):
|
|
251
|
-
return True
|
|
252
|
-
return False
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
def _collect_str_enum_names(tree: ast.Module) -> set[str]:
|
|
256
|
-
"""Names of classes in this module that look like string enums.
|
|
257
|
-
|
|
258
|
-
Matches `class X(StrEnum)`, `class X(enum.StrEnum)`, and the
|
|
259
|
-
pre-3.11 `class X(str, Enum)` spelling.
|
|
260
|
-
"""
|
|
261
|
-
names: set[str] = set()
|
|
262
|
-
for node in ast.walk(tree):
|
|
263
|
-
if not isinstance(node, ast.ClassDef):
|
|
264
|
-
continue
|
|
265
|
-
base_names = {_get_name_flat(base).rsplit(".", 1)[-1] for base in node.bases}
|
|
266
|
-
if "StrEnum" in base_names or {"str", "Enum"} <= base_names:
|
|
267
|
-
names.add(node.name)
|
|
268
|
-
return names
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
def _is_bool_tuple(node: ast.AST | None) -> bool:
|
|
272
|
-
"""Detect a two-element `tuple[bool, X]` / `tuple[X, bool]` annotation."""
|
|
273
|
-
if node is None:
|
|
274
|
-
return False
|
|
275
|
-
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
|
276
|
-
try:
|
|
277
|
-
parsed = ast.parse(node.value, mode="eval")
|
|
278
|
-
except SyntaxError:
|
|
279
|
-
return False
|
|
280
|
-
return _is_bool_tuple(parsed.body)
|
|
281
|
-
if not isinstance(node, ast.Subscript):
|
|
282
|
-
return False
|
|
283
|
-
name = _get_name_flat(node.value).rsplit(".", 1)[-1]
|
|
284
|
-
if name not in {"tuple", "Tuple"}:
|
|
285
|
-
return False
|
|
286
|
-
slice_node = node.slice
|
|
287
|
-
if not isinstance(slice_node, ast.Tuple) or len(slice_node.elts) != _BOOL_TUPLE_LEN:
|
|
288
|
-
return False
|
|
289
|
-
elts = slice_node.elts
|
|
290
|
-
# `tuple[bool, ...]` is a homogeneous variadic tuple, not an (ok, value) pair.
|
|
291
|
-
if any(isinstance(e, ast.Constant) and e.value is Ellipsis for e in elts):
|
|
292
|
-
return False
|
|
293
|
-
return any(_is_bool(e) for e in elts)
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
def _is_bool(node: ast.AST) -> bool:
|
|
297
|
-
if isinstance(node, ast.Name):
|
|
298
|
-
return node.id == "bool"
|
|
299
|
-
if isinstance(node, ast.Attribute):
|
|
300
|
-
return node.attr == "bool"
|
|
301
|
-
return False
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
def _is_bool_annotation(node: ast.AST | None) -> bool:
|
|
305
|
-
"""True if the annotation is `bool` (optionally unioned, e.g. `bool | None`).
|
|
306
|
-
|
|
307
|
-
A parsed-node check, so `success: BoolishFlag` no longer trips the substring
|
|
308
|
-
`"bool" in ast.unparse(...)` heuristic.
|
|
309
|
-
"""
|
|
310
|
-
if node is None:
|
|
311
|
-
return False
|
|
312
|
-
if _is_bool(node):
|
|
313
|
-
return True
|
|
314
|
-
if isinstance(node, ast.BinOp) and isinstance(node.op, ast.BitOr):
|
|
315
|
-
return _is_bool_annotation(node.left) or _is_bool_annotation(node.right)
|
|
316
|
-
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
|
317
|
-
try:
|
|
318
|
-
parsed = ast.parse(node.value, mode="eval")
|
|
319
|
-
except SyntaxError:
|
|
320
|
-
return False
|
|
321
|
-
return _is_bool_annotation(parsed.body)
|
|
322
|
-
return False
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
def _is_discriminator_type(node: ast.AST | None, str_enum_names: set[str]) -> bool:
|
|
326
|
-
"""Detect a str / StrEnum / Literal annotation (optionally unioned with None)."""
|
|
327
|
-
if node is None:
|
|
328
|
-
return False
|
|
329
|
-
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
|
330
|
-
try:
|
|
331
|
-
parsed = ast.parse(node.value, mode="eval")
|
|
332
|
-
except SyntaxError:
|
|
333
|
-
return False
|
|
334
|
-
return _is_discriminator_type(parsed.body, str_enum_names)
|
|
335
|
-
if isinstance(node, ast.Name):
|
|
336
|
-
return node.id == "str" or node.id in str_enum_names
|
|
337
|
-
if isinstance(node, ast.Attribute):
|
|
338
|
-
return node.attr in str_enum_names
|
|
339
|
-
if isinstance(node, ast.BinOp) and isinstance(node.op, ast.BitOr):
|
|
340
|
-
return _is_discriminator_type(node.left, str_enum_names) or _is_discriminator_type(
|
|
341
|
-
node.right, str_enum_names
|
|
342
|
-
)
|
|
343
|
-
if isinstance(node, ast.Subscript):
|
|
344
|
-
name = _get_name_flat(node.value).rsplit(".", 1)[-1]
|
|
345
|
-
if name == "Literal":
|
|
346
|
-
return True
|
|
347
|
-
if name == "Optional":
|
|
348
|
-
return _is_discriminator_type(node.slice, str_enum_names)
|
|
349
|
-
return False
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
def _get_name_flat(node: ast.AST) -> str:
|
|
353
|
-
if isinstance(node, ast.Name):
|
|
354
|
-
return node.id
|
|
355
|
-
if isinstance(node, ast.Attribute):
|
|
356
|
-
val = _get_name_flat(node.value)
|
|
357
|
-
if val:
|
|
358
|
-
return f"{val}.{node.attr}"
|
|
359
|
-
return ""
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
def _is_optional(node: ast.AST | None) -> bool:
|
|
363
|
-
"""Detect if an annotation represents an Optional type or Union with None."""
|
|
364
|
-
if node is None:
|
|
365
|
-
return False
|
|
366
|
-
|
|
367
|
-
# If it's a string literal (forward ref), parse it and check the inner AST
|
|
368
|
-
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
|
369
|
-
try:
|
|
370
|
-
parsed = ast.parse(node.value, mode="eval")
|
|
371
|
-
return _is_optional(parsed.body)
|
|
372
|
-
except SyntaxError:
|
|
373
|
-
pass
|
|
374
|
-
|
|
375
|
-
if isinstance(node, ast.BinOp) and isinstance(node.op, ast.BitOr):
|
|
376
|
-
return _is_optional(node.left) or _is_optional(node.right)
|
|
377
|
-
|
|
378
|
-
if isinstance(node, ast.Subscript):
|
|
379
|
-
name = _get_name_flat(node.value)
|
|
380
|
-
if name == "Optional" or name.endswith(".Optional"):
|
|
381
|
-
return True
|
|
382
|
-
if name == "Union" or name.endswith(".Union"):
|
|
383
|
-
slice_node = node.slice
|
|
384
|
-
if isinstance(slice_node, ast.Tuple):
|
|
385
|
-
return any(_is_optional(elt) for elt in slice_node.elts)
|
|
386
|
-
return _is_optional(slice_node)
|
|
387
|
-
|
|
388
|
-
if isinstance(node, ast.Constant) and node.value is None:
|
|
389
|
-
return True
|
|
390
|
-
|
|
391
|
-
return isinstance(node, ast.Name) and node.id == "None"
|
|
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.7.0 → sarj_python_lint-0.8.0}/src/sarj_python_lint/rules/no_comment_cruft.py
RENAMED
|
File without changes
|
{sarj_python_lint-0.7.0 → sarj_python_lint-0.8.0}/src/sarj_python_lint/rules/no_fat_try_blocks.py
RENAMED
|
File without changes
|
{sarj_python_lint-0.7.0 → sarj_python_lint-0.8.0}/src/sarj_python_lint/rules/no_fstring_in_log.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.7.0 → sarj_python_lint-0.8.0}/src/sarj_python_lint/rules/no_secret_in_log.py
RENAMED
|
File without changes
|
{sarj_python_lint-0.7.0 → sarj_python_lint-0.8.0}/src/sarj_python_lint/rules/no_select_star.py
RENAMED
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.7.0 → sarj_python_lint-0.8.0}/src/sarj_python_lint/rules/no_sequential_await.py
RENAMED
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.7.0 → sarj_python_lint-0.8.0}/src/sarj_python_lint/rules/prefer_class_row.py
RENAMED
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.7.0 → sarj_python_lint-0.8.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
|