pylint-complex-struct 0.1.0__py3-none-any.whl
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.
- pylint_complex_struct/__init__.py +21 -0
- pylint_complex_struct/checker.py +380 -0
- pylint_complex_struct/depth.py +348 -0
- pylint_complex_struct/names.py +184 -0
- pylint_complex_struct/py.typed +0 -0
- pylint_complex_struct-0.1.0.dist-info/METADATA +343 -0
- pylint_complex_struct-0.1.0.dist-info/RECORD +10 -0
- pylint_complex_struct-0.1.0.dist-info/WHEEL +5 -0
- pylint_complex_struct-0.1.0.dist-info/licenses/LICENSE +21 -0
- pylint_complex_struct-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""A pylint checker for over-nested type annotations.
|
|
2
|
+
|
|
3
|
+
Load it with ``pylint --load-plugins=pylint_complex_struct``.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
from .checker import ComplexStructChecker
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from pylint.lint import PyLinter
|
|
14
|
+
|
|
15
|
+
__version__ = "0.1.0"
|
|
16
|
+
__all__ = ["ComplexStructChecker", "register"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def register(linter: PyLinter) -> None:
|
|
20
|
+
"""Entry point called by pylint when the plugin is loaded."""
|
|
21
|
+
linter.register_checker(ComplexStructChecker(linter))
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
"""The pylint checker: where annotations are collected and messages emitted."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Iterator, NamedTuple
|
|
6
|
+
|
|
7
|
+
from astroid import nodes
|
|
8
|
+
from pylint.checkers import BaseChecker
|
|
9
|
+
from pylint.interfaces import HIGH
|
|
10
|
+
|
|
11
|
+
from .depth import MAX_LEVEL, Policy, Verdict, flatten_union, measure, namedtuple_candidate
|
|
12
|
+
from .names import GENERIC_ORIGINS, ImportMap, is_type_alias_annotation, resolve_head
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from pylint.lint import PyLinter
|
|
16
|
+
|
|
17
|
+
VALID_SCOPES = frozenset({"returns", "params", "attributes", "locals", "aliases"})
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class MessageKey(NamedTuple):
|
|
21
|
+
"""Identity of an emitted message, for the belt-and-braces dedup set."""
|
|
22
|
+
|
|
23
|
+
symbol: str
|
|
24
|
+
lineno: int
|
|
25
|
+
col_offset: int
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AnnotationSite(NamedTuple):
|
|
29
|
+
"""One annotation found on a function signature."""
|
|
30
|
+
|
|
31
|
+
annotation: nodes.NodeNG
|
|
32
|
+
name: str
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _depth_reason(verdict: Verdict, budget: int) -> str:
|
|
36
|
+
"""Phrase the budget breach, without inventing a number for a truncated walk."""
|
|
37
|
+
if verdict.truncated:
|
|
38
|
+
return f"nesting depth over {MAX_LEVEL} > {budget}"
|
|
39
|
+
return f"nesting depth {verdict.depth} > {budget}"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ComplexStructChecker(BaseChecker):
|
|
43
|
+
"""Push nested inline annotations towards aliases, NamedTuples and TypedDicts."""
|
|
44
|
+
|
|
45
|
+
name = "complex-struct"
|
|
46
|
+
|
|
47
|
+
msgs = {
|
|
48
|
+
"R9501": (
|
|
49
|
+
"Type annotation of %s is too complex (%s); extract a type alias, "
|
|
50
|
+
"a NamedTuple or a TypedDict",
|
|
51
|
+
"complex-type-annotation",
|
|
52
|
+
"Emitted when a type annotation nests type constructors more deeply, or "
|
|
53
|
+
"spells out more type terms, than the configured budget. A reference to a "
|
|
54
|
+
"type alias counts as a single term, so extracting an alias always fixes "
|
|
55
|
+
"the message.",
|
|
56
|
+
),
|
|
57
|
+
"R9502": (
|
|
58
|
+
"Type alias %s is too complex (%s); compose it from smaller aliases",
|
|
59
|
+
"complex-type-alias",
|
|
60
|
+
"Emitted when the body of a type alias exceeds the (laxer) alias budget. "
|
|
61
|
+
"Composing aliases is encouraged; hiding one unreadable structure behind a "
|
|
62
|
+
"single name is not.",
|
|
63
|
+
),
|
|
64
|
+
"R9503": (
|
|
65
|
+
"%s is a heterogeneous %d-field tuple; return a NamedTuple (or a "
|
|
66
|
+
"dataclass) so the fields have names",
|
|
67
|
+
"tuple-should-be-namedtuple",
|
|
68
|
+
"Emitted when a function returns a fixed-size tuple of differing types, "
|
|
69
|
+
"forcing every call site to unpack it positionally.",
|
|
70
|
+
),
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
options = (
|
|
74
|
+
(
|
|
75
|
+
"max-annotation-complexity",
|
|
76
|
+
{
|
|
77
|
+
"default": 2,
|
|
78
|
+
"type": "int",
|
|
79
|
+
"metavar": "<int>",
|
|
80
|
+
"help": "Maximum nesting depth of a type annotation. 'int' is 1, "
|
|
81
|
+
"'list[int]' is 2, 'dict[str, list[int]]' is 3. A reference to a type "
|
|
82
|
+
"alias counts as 1, whatever the alias expands to.",
|
|
83
|
+
},
|
|
84
|
+
),
|
|
85
|
+
(
|
|
86
|
+
"max-alias-complexity",
|
|
87
|
+
{
|
|
88
|
+
"default": 3,
|
|
89
|
+
"type": "int",
|
|
90
|
+
"metavar": "<int>",
|
|
91
|
+
"help": "Maximum nesting depth allowed in the body of a type alias "
|
|
92
|
+
"('type X = ...', 'X: TypeAlias = ...'). Laxer than "
|
|
93
|
+
"max-annotation-complexity, because absorbing structure is what an "
|
|
94
|
+
"alias is for.",
|
|
95
|
+
},
|
|
96
|
+
),
|
|
97
|
+
(
|
|
98
|
+
"max-annotation-terms",
|
|
99
|
+
{
|
|
100
|
+
"default": 7,
|
|
101
|
+
"type": "int",
|
|
102
|
+
"metavar": "<int>",
|
|
103
|
+
"help": "Maximum number of type terms (leaves) in one annotation; a "
|
|
104
|
+
"Literal[...] counts as one term. Set to 0 to disable.",
|
|
105
|
+
},
|
|
106
|
+
),
|
|
107
|
+
(
|
|
108
|
+
"count-optional-as-nesting",
|
|
109
|
+
{
|
|
110
|
+
"default": False,
|
|
111
|
+
"type": "yn",
|
|
112
|
+
"metavar": "<y or n>",
|
|
113
|
+
"help": "Count 'Optional[X]' and 'X | None' as a level of nesting.",
|
|
114
|
+
},
|
|
115
|
+
),
|
|
116
|
+
(
|
|
117
|
+
"count-union-as-nesting",
|
|
118
|
+
{
|
|
119
|
+
"default": True,
|
|
120
|
+
"type": "yn",
|
|
121
|
+
"metavar": "<y or n>",
|
|
122
|
+
"help": "Count a union of two or more non-None members as a level of "
|
|
123
|
+
"nesting. Applies equally to 'A | B' and 'Union[A, B]'.",
|
|
124
|
+
},
|
|
125
|
+
),
|
|
126
|
+
(
|
|
127
|
+
"count-callable-params-as-nesting",
|
|
128
|
+
{
|
|
129
|
+
"default": False,
|
|
130
|
+
"type": "yn",
|
|
131
|
+
"metavar": "<y or n>",
|
|
132
|
+
"help": "Count the parameter-list bracket of 'Callable[[A, B], R]' as a "
|
|
133
|
+
"level of nesting. The parameter types themselves are always counted.",
|
|
134
|
+
},
|
|
135
|
+
),
|
|
136
|
+
(
|
|
137
|
+
"namedtuple-check-scope",
|
|
138
|
+
{
|
|
139
|
+
"default": ("returns",),
|
|
140
|
+
"type": "csv",
|
|
141
|
+
"metavar": "<scopes>",
|
|
142
|
+
"help": "Where to suggest a NamedTuple for heterogeneous fixed-size "
|
|
143
|
+
"tuples: any of returns, params, attributes, locals, aliases. Empty "
|
|
144
|
+
"disables the check.",
|
|
145
|
+
},
|
|
146
|
+
),
|
|
147
|
+
(
|
|
148
|
+
"min-namedtuple-fields",
|
|
149
|
+
{
|
|
150
|
+
"default": 2,
|
|
151
|
+
"type": "int",
|
|
152
|
+
"metavar": "<int>",
|
|
153
|
+
"help": "Minimum number of elements a heterogeneous tuple must have "
|
|
154
|
+
"before a NamedTuple is suggested.",
|
|
155
|
+
},
|
|
156
|
+
),
|
|
157
|
+
(
|
|
158
|
+
"check-implicit-type-aliases",
|
|
159
|
+
{
|
|
160
|
+
"default": False,
|
|
161
|
+
"type": "yn",
|
|
162
|
+
"metavar": "<y or n>",
|
|
163
|
+
"help": "Also treat unannotated module- or class-level assignments whose "
|
|
164
|
+
"value is a generic subscription ('Rows = dict[str, list[int]]') as type "
|
|
165
|
+
"aliases.",
|
|
166
|
+
},
|
|
167
|
+
),
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
def __init__(self, linter: PyLinter | None = None) -> None:
|
|
171
|
+
super().__init__(linter)
|
|
172
|
+
self._imports = ImportMap()
|
|
173
|
+
self._policy: Policy | None = None
|
|
174
|
+
self._reported: set[MessageKey] = set()
|
|
175
|
+
self._skip_module = False
|
|
176
|
+
|
|
177
|
+
# -- lifecycle ------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
def open(self) -> None:
|
|
180
|
+
"""Validate the options once, so a typo is not silently ignored."""
|
|
181
|
+
unknown = sorted(set(self.linter.config.namedtuple_check_scope) - VALID_SCOPES - {""})
|
|
182
|
+
if unknown:
|
|
183
|
+
raise ValueError(
|
|
184
|
+
f"{self.name}: unknown namedtuple-check-scope value(s) {unknown}; "
|
|
185
|
+
f"valid values are {sorted(VALID_SCOPES)}"
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
def visit_module(self, node: nodes.Module) -> None:
|
|
189
|
+
"""Reset per-module state and resolve the options."""
|
|
190
|
+
self._imports = ImportMap()
|
|
191
|
+
self._reported = set()
|
|
192
|
+
self._policy = self._build_policy()
|
|
193
|
+
# Stubs describe APIs the author often cannot refactor.
|
|
194
|
+
self._skip_module = bool(node.file and node.file.endswith(".pyi"))
|
|
195
|
+
|
|
196
|
+
def leave_module(self, _node: nodes.Module) -> None:
|
|
197
|
+
"""Drop per-module state."""
|
|
198
|
+
self._imports = ImportMap()
|
|
199
|
+
self._reported = set()
|
|
200
|
+
self._policy = None
|
|
201
|
+
self._skip_module = False
|
|
202
|
+
|
|
203
|
+
def visit_import(self, node: nodes.Import) -> None:
|
|
204
|
+
"""Record `import typing as t`."""
|
|
205
|
+
for name, alias in node.names:
|
|
206
|
+
self._imports.add_module(alias or name, name)
|
|
207
|
+
|
|
208
|
+
def visit_importfrom(self, node: nodes.ImportFrom) -> None:
|
|
209
|
+
"""Record `from typing import Optional as Opt`."""
|
|
210
|
+
for name, alias in node.names:
|
|
211
|
+
self._imports.add_from(alias or name, node.modname, name)
|
|
212
|
+
|
|
213
|
+
# -- annotation sites -----------------------------------------------------
|
|
214
|
+
|
|
215
|
+
def visit_functiondef(self, node: nodes.FunctionDef) -> None:
|
|
216
|
+
"""Check every parameter and the return annotation."""
|
|
217
|
+
if self._skip_module:
|
|
218
|
+
return
|
|
219
|
+
for annotation, arg_name in self._arg_annotations(node.args):
|
|
220
|
+
self._check(annotation, f"parameter '{arg_name}'", "params")
|
|
221
|
+
if node.returns is not None:
|
|
222
|
+
self._check(node.returns, f"the return value of '{node.name}'", "returns")
|
|
223
|
+
|
|
224
|
+
visit_asyncfunctiondef = visit_functiondef
|
|
225
|
+
|
|
226
|
+
def visit_annassign(self, node: nodes.AnnAssign) -> None:
|
|
227
|
+
"""Check an annotated assignment, or a PEP 613 alias body."""
|
|
228
|
+
if self._skip_module:
|
|
229
|
+
return
|
|
230
|
+
if is_type_alias_annotation(node.annotation, self._imports):
|
|
231
|
+
# PEP 613. The annotation is just the marker; the alias body is the value.
|
|
232
|
+
if node.value is not None:
|
|
233
|
+
self._check_alias(node.value, self._target_name(node.target))
|
|
234
|
+
return
|
|
235
|
+
subject, scope = self._annassign_subject(node)
|
|
236
|
+
self._check(node.annotation, subject, scope)
|
|
237
|
+
|
|
238
|
+
def visit_typealias(self, node: nodes.TypeAlias) -> None:
|
|
239
|
+
"""Check a PEP 695 `type X = ...` body."""
|
|
240
|
+
if self._skip_module:
|
|
241
|
+
return
|
|
242
|
+
self._check_alias(node.value, node.name.name)
|
|
243
|
+
|
|
244
|
+
def visit_assign(self, node: nodes.Assign) -> None:
|
|
245
|
+
"""Check an unannotated assignment that looks like a type alias."""
|
|
246
|
+
policy = self._current_policy()
|
|
247
|
+
if self._skip_module or not policy.check_implicit_type_aliases:
|
|
248
|
+
return
|
|
249
|
+
if len(node.targets) != 1:
|
|
250
|
+
return
|
|
251
|
+
target = node.targets[0]
|
|
252
|
+
if not isinstance(target, nodes.AssignName):
|
|
253
|
+
return
|
|
254
|
+
# SCREAMING_CASE is a constant (PEP 8), not a type alias: `PEELABLE = TRANSPARENT
|
|
255
|
+
# | ANNOTATED` is a frozenset union, not a union type. Found by running this
|
|
256
|
+
# checker over its own source.
|
|
257
|
+
if target.name.isupper():
|
|
258
|
+
return
|
|
259
|
+
if not isinstance(node.scope(), (nodes.Module, nodes.ClassDef)):
|
|
260
|
+
return
|
|
261
|
+
if not self._looks_like_alias(node.value):
|
|
262
|
+
return
|
|
263
|
+
self._check_alias(node.value, target.name)
|
|
264
|
+
|
|
265
|
+
# -- checks ---------------------------------------------------------------
|
|
266
|
+
|
|
267
|
+
def _check(self, annotation: nodes.NodeNG, subject: str, scope: str) -> None:
|
|
268
|
+
policy = self._current_policy()
|
|
269
|
+
verdict = measure(annotation, policy, self._imports)
|
|
270
|
+
if verdict.depth > policy.max_annotation_complexity:
|
|
271
|
+
reason = _depth_reason(verdict, policy.max_annotation_complexity)
|
|
272
|
+
self._report("complex-type-annotation", annotation, (subject, reason))
|
|
273
|
+
return
|
|
274
|
+
if policy.max_annotation_terms and verdict.terms > policy.max_annotation_terms:
|
|
275
|
+
reason = f"{verdict.terms} type terms > {policy.max_annotation_terms}"
|
|
276
|
+
self._report("complex-type-annotation", annotation, (subject, reason))
|
|
277
|
+
return
|
|
278
|
+
self._check_heuristics(annotation, subject, scope)
|
|
279
|
+
|
|
280
|
+
def _check_alias(self, body: nodes.NodeNG, name: str) -> None:
|
|
281
|
+
policy = self._current_policy()
|
|
282
|
+
verdict = measure(body, policy, self._imports)
|
|
283
|
+
if verdict.depth > policy.max_alias_complexity:
|
|
284
|
+
reason = _depth_reason(verdict, policy.max_alias_complexity)
|
|
285
|
+
self._report("complex-type-alias", body, (f"'{name}'", reason))
|
|
286
|
+
return
|
|
287
|
+
if policy.max_annotation_terms and verdict.terms > policy.max_annotation_terms:
|
|
288
|
+
reason = f"{verdict.terms} type terms > {policy.max_annotation_terms}"
|
|
289
|
+
self._report("complex-type-alias", body, (f"'{name}'", reason))
|
|
290
|
+
return
|
|
291
|
+
self._check_heuristics(body, f"type alias '{name}'", "aliases")
|
|
292
|
+
|
|
293
|
+
def _check_heuristics(self, annotation: nodes.NodeNG, subject: str, scope: str) -> None:
|
|
294
|
+
policy = self._current_policy()
|
|
295
|
+
if scope not in policy.namedtuple_scopes:
|
|
296
|
+
return
|
|
297
|
+
candidate = namedtuple_candidate(annotation, self._imports, policy.min_namedtuple_fields)
|
|
298
|
+
if candidate is None:
|
|
299
|
+
return
|
|
300
|
+
self._report(
|
|
301
|
+
"tuple-should-be-namedtuple",
|
|
302
|
+
candidate.anchor,
|
|
303
|
+
(subject[:1].upper() + subject[1:], candidate.fields),
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
def _report(self, symbol: str, node: nodes.NodeNG, args: tuple[object, ...]) -> None:
|
|
307
|
+
key = MessageKey(symbol, node.lineno or 0, node.col_offset or 0)
|
|
308
|
+
if key in self._reported:
|
|
309
|
+
return
|
|
310
|
+
self._reported.add(key)
|
|
311
|
+
self.add_message(symbol, node=node, args=args, confidence=HIGH)
|
|
312
|
+
|
|
313
|
+
# -- helpers --------------------------------------------------------------
|
|
314
|
+
|
|
315
|
+
def _current_policy(self) -> Policy:
|
|
316
|
+
# Normally built in visit_module; rebuilt lazily so that calling a single
|
|
317
|
+
# visitor in isolation (as the unit tests do) still works.
|
|
318
|
+
if self._policy is None:
|
|
319
|
+
self._policy = self._build_policy()
|
|
320
|
+
return self._policy
|
|
321
|
+
|
|
322
|
+
def _build_policy(self) -> Policy:
|
|
323
|
+
config = self.linter.config
|
|
324
|
+
scopes = frozenset(s for s in config.namedtuple_check_scope if s)
|
|
325
|
+
return Policy(
|
|
326
|
+
max_annotation_complexity=config.max_annotation_complexity,
|
|
327
|
+
max_alias_complexity=config.max_alias_complexity,
|
|
328
|
+
max_annotation_terms=config.max_annotation_terms,
|
|
329
|
+
count_optional=config.count_optional_as_nesting,
|
|
330
|
+
count_union=config.count_union_as_nesting,
|
|
331
|
+
count_callable_params=config.count_callable_params_as_nesting,
|
|
332
|
+
min_namedtuple_fields=config.min_namedtuple_fields,
|
|
333
|
+
namedtuple_scopes=scopes,
|
|
334
|
+
check_implicit_type_aliases=config.check_implicit_type_aliases,
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
@staticmethod
|
|
338
|
+
def _arg_annotations(args: nodes.Arguments) -> Iterator[AnnotationSite]:
|
|
339
|
+
groups = (
|
|
340
|
+
(args.posonlyargs, args.posonlyargs_annotations),
|
|
341
|
+
(args.args, args.annotations),
|
|
342
|
+
(args.kwonlyargs, args.kwonlyargs_annotations),
|
|
343
|
+
)
|
|
344
|
+
for arg_nodes, annotations in groups:
|
|
345
|
+
for arg, annotation in zip(arg_nodes or [], annotations or []):
|
|
346
|
+
if annotation is not None:
|
|
347
|
+
yield AnnotationSite(annotation, arg.name)
|
|
348
|
+
if args.varargannotation is not None:
|
|
349
|
+
yield AnnotationSite(args.varargannotation, args.vararg)
|
|
350
|
+
if args.kwargannotation is not None:
|
|
351
|
+
yield AnnotationSite(args.kwargannotation, args.kwarg)
|
|
352
|
+
|
|
353
|
+
@staticmethod
|
|
354
|
+
def _target_name(target: nodes.NodeNG) -> str:
|
|
355
|
+
return getattr(target, "name", None) or target.as_string()
|
|
356
|
+
|
|
357
|
+
def _annassign_subject(self, node: nodes.AnnAssign) -> tuple[str, str]:
|
|
358
|
+
target = node.target
|
|
359
|
+
if isinstance(target, nodes.AssignAttr):
|
|
360
|
+
return f"attribute '{target.as_string()}'", "attributes"
|
|
361
|
+
name = self._target_name(target)
|
|
362
|
+
scope = node.scope()
|
|
363
|
+
if isinstance(scope, nodes.ClassDef):
|
|
364
|
+
return f"attribute '{name}'", "attributes"
|
|
365
|
+
if isinstance(scope, nodes.Module):
|
|
366
|
+
return f"variable '{name}'", "locals"
|
|
367
|
+
return f"local variable '{name}'", "locals"
|
|
368
|
+
|
|
369
|
+
def _looks_like_alias(self, value: nodes.NodeNG | None) -> bool:
|
|
370
|
+
"""Tell `Rows = dict[str, int]` from `rows = cache["key"]`, syntactically."""
|
|
371
|
+
if isinstance(value, nodes.Subscript):
|
|
372
|
+
return resolve_head(value.value, self._imports) in GENERIC_ORIGINS
|
|
373
|
+
if isinstance(value, nodes.BinOp) and value.op == "|":
|
|
374
|
+
members = flatten_union(value)
|
|
375
|
+
return all(
|
|
376
|
+
isinstance(m, (nodes.Name, nodes.Attribute, nodes.Subscript))
|
|
377
|
+
or (isinstance(m, nodes.Const) and m.value is None)
|
|
378
|
+
for m in members
|
|
379
|
+
)
|
|
380
|
+
return False
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
"""The annotation complexity metric. Pure, and deliberately inference-free.
|
|
2
|
+
|
|
3
|
+
This module must not import pylint, so that the metric can be unit-tested with
|
|
4
|
+
nothing but ``astroid.extract_node``.
|
|
5
|
+
|
|
6
|
+
It must also never ask astroid what a name *refers to* -- no inference, no scope
|
|
7
|
+
lookups. A name is always a leaf, whatever it expands to. That is not a
|
|
8
|
+
shortcut, it is the whole point: it makes the rule actionable, because pulling a
|
|
9
|
+
subtree out into an alias mechanically brings the score back within budget and
|
|
10
|
+
re-linting the fixed file is clean. A metric that expanded aliases would score
|
|
11
|
+
the fixed code exactly like the original, and there would be no legal way to
|
|
12
|
+
write the type at all. ``tests/test_no_inference.py`` enforces this by grepping
|
|
13
|
+
the source of this module.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import functools
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from typing import NamedTuple
|
|
21
|
+
|
|
22
|
+
import astroid
|
|
23
|
+
from astroid import nodes
|
|
24
|
+
|
|
25
|
+
from .names import (
|
|
26
|
+
ANNOTATED,
|
|
27
|
+
CALLABLE,
|
|
28
|
+
COROUTINE,
|
|
29
|
+
LITERAL,
|
|
30
|
+
NONE_NAMES,
|
|
31
|
+
OPTIONAL,
|
|
32
|
+
PEELABLE,
|
|
33
|
+
TRANSPARENT,
|
|
34
|
+
TUPLE_HEADS,
|
|
35
|
+
UNION,
|
|
36
|
+
UNPACK,
|
|
37
|
+
ImportMap,
|
|
38
|
+
resolve_head,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
#: Nesting beyond this is over any sane budget, so we stop walking and say so
|
|
42
|
+
#: rather than risking a RecursionError inside pylint.
|
|
43
|
+
MAX_LEVEL = 32
|
|
44
|
+
#: Guard against machine-generated monsters.
|
|
45
|
+
MAX_NODES = 2000
|
|
46
|
+
|
|
47
|
+
_PARSEABLE = (
|
|
48
|
+
nodes.Name,
|
|
49
|
+
nodes.Attribute,
|
|
50
|
+
nodes.Subscript,
|
|
51
|
+
nodes.BinOp,
|
|
52
|
+
nodes.Const,
|
|
53
|
+
nodes.Tuple,
|
|
54
|
+
nodes.List,
|
|
55
|
+
nodes.Starred,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class Policy: # pylint: disable=too-many-instance-attributes # a bundle of options
|
|
61
|
+
"""Resolved option values, built once per module."""
|
|
62
|
+
|
|
63
|
+
max_annotation_complexity: int = 2
|
|
64
|
+
max_alias_complexity: int = 3
|
|
65
|
+
max_annotation_terms: int = 7
|
|
66
|
+
count_optional: bool = False
|
|
67
|
+
count_union: bool = True
|
|
68
|
+
count_callable_params: bool = False
|
|
69
|
+
min_namedtuple_fields: int = 2
|
|
70
|
+
namedtuple_scopes: frozenset[str] = frozenset({"returns"})
|
|
71
|
+
check_implicit_type_aliases: bool = False
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class Verdict:
|
|
76
|
+
"""What one annotation scored."""
|
|
77
|
+
|
|
78
|
+
depth: int
|
|
79
|
+
terms: int
|
|
80
|
+
#: True when the walk hit MAX_LEVEL or MAX_NODES, so `depth` is a floor.
|
|
81
|
+
truncated: bool = False
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass
|
|
85
|
+
class _Ctx:
|
|
86
|
+
"""Per-annotation walk state."""
|
|
87
|
+
|
|
88
|
+
policy: Policy
|
|
89
|
+
imports: ImportMap
|
|
90
|
+
terms: int = 0
|
|
91
|
+
seen: int = 0
|
|
92
|
+
truncated: bool = False
|
|
93
|
+
|
|
94
|
+
def budget_left(self) -> bool:
|
|
95
|
+
"""Count one node and report whether the walk may continue."""
|
|
96
|
+
self.seen += 1
|
|
97
|
+
return self.seen <= MAX_NODES
|
|
98
|
+
|
|
99
|
+
def leaf(self) -> int:
|
|
100
|
+
"""Record a type term and score it as a leaf."""
|
|
101
|
+
self.terms += 1
|
|
102
|
+
return 1
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@functools.lru_cache(maxsize=512)
|
|
106
|
+
def parse_forward_ref(text: str) -> nodes.NodeNG | None:
|
|
107
|
+
"""Parse a quoted annotation. Quoting must not be an escape hatch.
|
|
108
|
+
|
|
109
|
+
Failure is always silent and scores as a leaf: annotations like
|
|
110
|
+
``x: "the widget id"`` are prose, not types, and reporting on them would be
|
|
111
|
+
worse than missing them.
|
|
112
|
+
"""
|
|
113
|
+
stripped = text.strip()
|
|
114
|
+
if not stripped or len(stripped) > 512 or "\n" in stripped:
|
|
115
|
+
return None
|
|
116
|
+
try:
|
|
117
|
+
node = astroid.extract_node(stripped)
|
|
118
|
+
except (astroid.AstroidSyntaxError, SyntaxError, ValueError, RecursionError, AttributeError):
|
|
119
|
+
return None
|
|
120
|
+
return node if isinstance(node, _PARSEABLE) else None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def slice_elements(slice_node: nodes.NodeNG | None) -> list[nodes.NodeNG]:
|
|
124
|
+
"""The arguments of a subscript. ``dict[str, int]`` -> ``[str, int]``."""
|
|
125
|
+
if slice_node is None:
|
|
126
|
+
return []
|
|
127
|
+
if isinstance(slice_node, nodes.Tuple):
|
|
128
|
+
return list(slice_node.elts)
|
|
129
|
+
return [slice_node]
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def flatten_union(node: nodes.BinOp) -> list[nodes.NodeNG]:
|
|
133
|
+
"""Flatten ``a | b | c`` iteratively.
|
|
134
|
+
|
|
135
|
+
Not an optimisation: ``|`` parses left-deep, so a generated thousand-member
|
|
136
|
+
union would otherwise recurse a thousand frames and blow the stack inside
|
|
137
|
+
pylint.
|
|
138
|
+
"""
|
|
139
|
+
out: list[nodes.NodeNG] = []
|
|
140
|
+
stack: list[nodes.NodeNG] = [node]
|
|
141
|
+
while stack:
|
|
142
|
+
current = stack.pop()
|
|
143
|
+
if isinstance(current, nodes.BinOp) and current.op == "|":
|
|
144
|
+
stack.append(current.left)
|
|
145
|
+
stack.append(current.right)
|
|
146
|
+
else:
|
|
147
|
+
out.append(current)
|
|
148
|
+
return out
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def is_ellipsis(node: nodes.NodeNG | None) -> bool:
|
|
152
|
+
"""True for the `...` in `tuple[int, ...]` and `Callable[..., R]`."""
|
|
153
|
+
return isinstance(node, nodes.Const) and node.value is Ellipsis
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def is_none(node: nodes.NodeNG | None) -> bool:
|
|
157
|
+
"""True for every spelling of the None type."""
|
|
158
|
+
if isinstance(node, nodes.Const) and node.value is None:
|
|
159
|
+
return True
|
|
160
|
+
if isinstance(node, nodes.Name) and node.name in NONE_NAMES:
|
|
161
|
+
return True
|
|
162
|
+
return isinstance(node, nodes.Attribute) and node.attrname == "NoneType"
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _max0(values: list[int]) -> int:
|
|
166
|
+
return max(values) if values else 0
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _depth( # pylint: disable=too-many-return-statements # one return per node kind
|
|
170
|
+
node: nodes.NodeNG | None, ctx: _Ctx, level: int
|
|
171
|
+
) -> int:
|
|
172
|
+
if node is None:
|
|
173
|
+
return 0
|
|
174
|
+
if level >= MAX_LEVEL or not ctx.budget_left():
|
|
175
|
+
ctx.truncated = True
|
|
176
|
+
return MAX_LEVEL
|
|
177
|
+
|
|
178
|
+
if isinstance(node, (nodes.Name, nodes.Attribute)):
|
|
179
|
+
return ctx.leaf()
|
|
180
|
+
|
|
181
|
+
if isinstance(node, nodes.Const):
|
|
182
|
+
if node.value is Ellipsis:
|
|
183
|
+
return 0
|
|
184
|
+
if isinstance(node.value, str):
|
|
185
|
+
parsed = parse_forward_ref(node.value)
|
|
186
|
+
if parsed is None:
|
|
187
|
+
return ctx.leaf()
|
|
188
|
+
return _depth(parsed, ctx, level + 1)
|
|
189
|
+
return ctx.leaf()
|
|
190
|
+
|
|
191
|
+
if isinstance(node, nodes.Starred):
|
|
192
|
+
return _depth(node.value, ctx, level + 1)
|
|
193
|
+
|
|
194
|
+
if isinstance(node, (nodes.Tuple, nodes.List)):
|
|
195
|
+
# A bare bracket: a subscript's argument list, or Callable's parameter
|
|
196
|
+
# list. Syntax, not nesting, so no +1.
|
|
197
|
+
return _max0([_depth(elt, ctx, level + 1) for elt in node.elts])
|
|
198
|
+
|
|
199
|
+
if isinstance(node, nodes.BinOp):
|
|
200
|
+
if node.op == "|":
|
|
201
|
+
return _union_depth(flatten_union(node), ctx, level)
|
|
202
|
+
return ctx.leaf()
|
|
203
|
+
|
|
204
|
+
if isinstance(node, nodes.Subscript):
|
|
205
|
+
return _subscript_depth(node, ctx, level)
|
|
206
|
+
|
|
207
|
+
return ctx.leaf()
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _union_depth(members: list[nodes.NodeNG], ctx: _Ctx, level: int) -> int:
|
|
211
|
+
real = [m for m in members if not is_none(m)]
|
|
212
|
+
had_none = len(real) != len(members)
|
|
213
|
+
if not real:
|
|
214
|
+
return ctx.leaf()
|
|
215
|
+
inner = max(_depth(m, ctx, level + 1) for m in real)
|
|
216
|
+
if len(real) == 1:
|
|
217
|
+
# Optional-equivalent. Nullability is a bit on a shape, not a shape.
|
|
218
|
+
return inner + (1 if had_none and ctx.policy.count_optional else 0)
|
|
219
|
+
return inner + (1 if ctx.policy.count_union else 0)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _subscript_depth( # pylint: disable=too-many-return-statements # one per head kind
|
|
223
|
+
node: nodes.Subscript, ctx: _Ctx, level: int
|
|
224
|
+
) -> int:
|
|
225
|
+
head = resolve_head(node.value, ctx.imports)
|
|
226
|
+
args = slice_elements(node.slice)
|
|
227
|
+
|
|
228
|
+
if head in LITERAL:
|
|
229
|
+
# Members are values, not types. Recursing would also parse
|
|
230
|
+
# Literal["dict[str, int]"] as a forward reference.
|
|
231
|
+
return ctx.leaf()
|
|
232
|
+
|
|
233
|
+
if head in ANNOTATED:
|
|
234
|
+
# Metadata is arbitrary runtime objects; it has no type shape.
|
|
235
|
+
return _depth(args[0], ctx, level + 1) if args else ctx.leaf()
|
|
236
|
+
|
|
237
|
+
if head in TRANSPARENT:
|
|
238
|
+
inner = _depth(args[0], ctx, level + 1) if args else ctx.leaf()
|
|
239
|
+
if head in OPTIONAL and ctx.policy.count_optional:
|
|
240
|
+
return inner + 1
|
|
241
|
+
return inner
|
|
242
|
+
|
|
243
|
+
if head in UNION:
|
|
244
|
+
# Same code path as ``|`` so the two spellings can never disagree.
|
|
245
|
+
return _union_depth(args, ctx, level)
|
|
246
|
+
|
|
247
|
+
if head in CALLABLE:
|
|
248
|
+
if len(args) >= 2:
|
|
249
|
+
params, result = args[0], args[-1]
|
|
250
|
+
else:
|
|
251
|
+
params, result = None, (args[0] if args else None)
|
|
252
|
+
param_depth = 0 if is_ellipsis(params) else _depth(params, ctx, level + 1)
|
|
253
|
+
if ctx.policy.count_callable_params and param_depth:
|
|
254
|
+
param_depth += 1
|
|
255
|
+
return 1 + max(param_depth, _depth(result, ctx, level + 1), 1)
|
|
256
|
+
|
|
257
|
+
return 1 + _max0([_depth(arg, ctx, level + 1) for arg in args])
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def measure(node: nodes.NodeNG, policy: Policy, imports: ImportMap) -> Verdict:
|
|
261
|
+
"""Score one annotation."""
|
|
262
|
+
ctx = _Ctx(policy=policy, imports=imports)
|
|
263
|
+
value = _depth(node, ctx, 0)
|
|
264
|
+
# Unwinding adds a level per frame, so a truncated walk can report more than
|
|
265
|
+
# MAX_LEVEL. Clamp it and let the caller say "over" instead of a made-up number.
|
|
266
|
+
return Verdict(
|
|
267
|
+
depth=min(value, MAX_LEVEL) if ctx.truncated else value,
|
|
268
|
+
terms=ctx.terms,
|
|
269
|
+
truncated=ctx.truncated,
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
class Peeled(NamedTuple):
|
|
274
|
+
"""A stripped annotation and the node a message about it should point at."""
|
|
275
|
+
|
|
276
|
+
node: nodes.NodeNG | None
|
|
277
|
+
anchor: nodes.NodeNG
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
class TupleShape(NamedTuple):
|
|
281
|
+
"""A fixed-size heterogeneous tuple found in an annotation."""
|
|
282
|
+
|
|
283
|
+
fields: int
|
|
284
|
+
anchor: nodes.NodeNG
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def peel(node: nodes.NodeNG, imports: ImportMap) -> Peeled:
|
|
288
|
+
"""Strip wrappers that do not change "what shape is this really?".
|
|
289
|
+
|
|
290
|
+
The anchor stays on the original node once we descend into a parsed forward
|
|
291
|
+
reference, because those nodes carry positions from a synthetic module.
|
|
292
|
+
"""
|
|
293
|
+
anchor = node
|
|
294
|
+
synthetic = False
|
|
295
|
+
for _ in range(MAX_LEVEL):
|
|
296
|
+
if isinstance(node, nodes.Const) and isinstance(node.value, str):
|
|
297
|
+
parsed = parse_forward_ref(node.value)
|
|
298
|
+
if parsed is None:
|
|
299
|
+
return Peeled(None, anchor)
|
|
300
|
+
node, synthetic = parsed, True
|
|
301
|
+
continue
|
|
302
|
+
if isinstance(node, nodes.BinOp) and node.op == "|":
|
|
303
|
+
real = [m for m in flatten_union(node) if not is_none(m)]
|
|
304
|
+
if len(real) != 1:
|
|
305
|
+
break
|
|
306
|
+
node = real[0]
|
|
307
|
+
if not synthetic:
|
|
308
|
+
anchor = node
|
|
309
|
+
continue
|
|
310
|
+
if isinstance(node, nodes.Subscript):
|
|
311
|
+
head = resolve_head(node.value, imports)
|
|
312
|
+
args = slice_elements(node.slice)
|
|
313
|
+
target = None
|
|
314
|
+
if head in PEELABLE and args:
|
|
315
|
+
target = args[0]
|
|
316
|
+
elif head in COROUTINE and len(args) == 3:
|
|
317
|
+
# Coroutine[Any, Any, X] must behave like `async def ... -> X`.
|
|
318
|
+
target = args[2]
|
|
319
|
+
if target is None:
|
|
320
|
+
break
|
|
321
|
+
node = target
|
|
322
|
+
if not synthetic:
|
|
323
|
+
anchor = node
|
|
324
|
+
continue
|
|
325
|
+
break
|
|
326
|
+
return Peeled(node, anchor)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def namedtuple_candidate( # pylint: disable=too-many-return-statements # a filter chain
|
|
330
|
+
node: nodes.NodeNG, imports: ImportMap, min_fields: int
|
|
331
|
+
) -> TupleShape | None:
|
|
332
|
+
"""The tuple shape, if this annotation is a heterogeneous fixed-size tuple."""
|
|
333
|
+
peeled, anchor = peel(node, imports)
|
|
334
|
+
if not isinstance(peeled, nodes.Subscript):
|
|
335
|
+
return None
|
|
336
|
+
if resolve_head(peeled.value, imports) not in TUPLE_HEADS:
|
|
337
|
+
return None
|
|
338
|
+
elements = slice_elements(peeled.slice)
|
|
339
|
+
if len(elements) < max(min_fields, 1):
|
|
340
|
+
return None
|
|
341
|
+
for element in elements:
|
|
342
|
+
if is_ellipsis(element) or isinstance(element, nodes.Starred):
|
|
343
|
+
return None # tuple[int, ...] is a sequence; tuple[*Ts] is polymorphic
|
|
344
|
+
if isinstance(element, nodes.Subscript) and resolve_head(element.value, imports) in UNPACK:
|
|
345
|
+
return None
|
|
346
|
+
if len({element.as_string() for element in elements}) < 2:
|
|
347
|
+
return None # tuple[int, int] is a fixed-size vector, not a record
|
|
348
|
+
return TupleShape(len(elements), anchor)
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Syntactic resolution of typing construct names.
|
|
2
|
+
|
|
3
|
+
Everything here is deliberately syntactic: the head of a subscript is resolved
|
|
4
|
+
by looking at what was written and at the module's import statements, never by
|
|
5
|
+
asking astroid what a name refers to. See :mod:`pylint_complex_struct.depth`
|
|
6
|
+
for why that matters.
|
|
7
|
+
|
|
8
|
+
The one accepted cost is the bare-name fallback: a bare ``Optional`` is treated
|
|
9
|
+
as ``typing.Optional`` even with no visible import, because re-exports and
|
|
10
|
+
``if TYPE_CHECKING`` blocks are common. A user-defined class literally named
|
|
11
|
+
``Optional`` is therefore mis-classified. That is documented and tested.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from astroid import nodes
|
|
17
|
+
|
|
18
|
+
# --- canonical category sets -------------------------------------------------
|
|
19
|
+
|
|
20
|
+
LITERAL = frozenset({"typing.Literal"})
|
|
21
|
+
ANNOTATED = frozenset({"typing.Annotated"})
|
|
22
|
+
UNION = frozenset({"typing.Union"})
|
|
23
|
+
OPTIONAL = frozenset({"typing.Optional"})
|
|
24
|
+
CALLABLE = frozenset({"typing.Callable"})
|
|
25
|
+
UNPACK = frozenset({"typing.Unpack"})
|
|
26
|
+
AWAITABLE = frozenset({"typing.Awaitable"})
|
|
27
|
+
COROUTINE = frozenset({"typing.Coroutine"})
|
|
28
|
+
TUPLE_HEADS = frozenset({"builtins.tuple"})
|
|
29
|
+
TYPE_ALIAS_NAMES = frozenset({"typing.TypeAlias"})
|
|
30
|
+
|
|
31
|
+
#: Wrappers that describe how a name is *used* rather than what shape it holds.
|
|
32
|
+
#: ``depth(Wrapper[T]) == depth(T)``.
|
|
33
|
+
TRANSPARENT = frozenset(
|
|
34
|
+
{
|
|
35
|
+
"typing.Optional",
|
|
36
|
+
"typing.Final",
|
|
37
|
+
"typing.ClassVar",
|
|
38
|
+
"typing.Required",
|
|
39
|
+
"typing.NotRequired",
|
|
40
|
+
"typing.ReadOnly",
|
|
41
|
+
"typing.Unpack",
|
|
42
|
+
"typing.TypeGuard",
|
|
43
|
+
"typing.TypeIs",
|
|
44
|
+
"dataclasses.InitVar",
|
|
45
|
+
}
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
#: Wrappers peeled off before asking "is this really a record?".
|
|
49
|
+
#: ``Coroutine`` is handled separately because the payload is the third argument.
|
|
50
|
+
PEELABLE = TRANSPARENT | ANNOTATED | AWAITABLE
|
|
51
|
+
|
|
52
|
+
#: Heads that make an unannotated assignment look like a type alias.
|
|
53
|
+
GENERIC_ORIGINS = frozenset(
|
|
54
|
+
{
|
|
55
|
+
"builtins.dict",
|
|
56
|
+
"builtins.list",
|
|
57
|
+
"builtins.tuple",
|
|
58
|
+
"builtins.set",
|
|
59
|
+
"builtins.frozenset",
|
|
60
|
+
"builtins.type",
|
|
61
|
+
"collections.abc.Mapping",
|
|
62
|
+
"collections.abc.MutableMapping",
|
|
63
|
+
"collections.abc.Sequence",
|
|
64
|
+
"collections.abc.Iterable",
|
|
65
|
+
"collections.abc.Iterator",
|
|
66
|
+
"collections.defaultdict",
|
|
67
|
+
"collections.deque",
|
|
68
|
+
}
|
|
69
|
+
| TRANSPARENT
|
|
70
|
+
| ANNOTATED
|
|
71
|
+
| UNION
|
|
72
|
+
| CALLABLE
|
|
73
|
+
| LITERAL
|
|
74
|
+
| AWAITABLE
|
|
75
|
+
| COROUTINE
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
_CANONICAL = {
|
|
79
|
+
"typing.Dict": "builtins.dict",
|
|
80
|
+
"typing.List": "builtins.list",
|
|
81
|
+
"typing.Tuple": "builtins.tuple",
|
|
82
|
+
"typing.Set": "builtins.set",
|
|
83
|
+
"typing.FrozenSet": "builtins.frozenset",
|
|
84
|
+
"typing.Type": "builtins.type",
|
|
85
|
+
"typing.DefaultDict": "collections.defaultdict",
|
|
86
|
+
"typing.Deque": "collections.deque",
|
|
87
|
+
"typing.Mapping": "collections.abc.Mapping",
|
|
88
|
+
"typing.MutableMapping": "collections.abc.MutableMapping",
|
|
89
|
+
"typing.Sequence": "collections.abc.Sequence",
|
|
90
|
+
"typing.Iterable": "collections.abc.Iterable",
|
|
91
|
+
"typing.Iterator": "collections.abc.Iterator",
|
|
92
|
+
"collections.abc.Callable": "typing.Callable",
|
|
93
|
+
"collections.abc.Awaitable": "typing.Awaitable",
|
|
94
|
+
"collections.abc.Coroutine": "typing.Coroutine",
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
_TYPING_BARE = (
|
|
98
|
+
"Optional Union Literal Annotated Callable Final ClassVar Required NotRequired "
|
|
99
|
+
"ReadOnly Unpack TypeGuard TypeIs Awaitable Coroutine TypeAlias Dict List Tuple "
|
|
100
|
+
"Set FrozenSet Type DefaultDict Deque Mapping MutableMapping Sequence Iterable "
|
|
101
|
+
"Iterator"
|
|
102
|
+
).split()
|
|
103
|
+
|
|
104
|
+
_BARE_FALLBACK: dict[str, str] = {
|
|
105
|
+
name: _CANONICAL.get(f"typing.{name}", f"typing.{name}") for name in _TYPING_BARE
|
|
106
|
+
}
|
|
107
|
+
_BARE_FALLBACK.update(
|
|
108
|
+
{name: f"builtins.{name}" for name in ("dict", "list", "tuple", "set", "frozenset", "type")}
|
|
109
|
+
)
|
|
110
|
+
_BARE_FALLBACK["InitVar"] = "dataclasses.InitVar"
|
|
111
|
+
# collections.abc spellings that are commonly imported bare
|
|
112
|
+
_BARE_FALLBACK.update(
|
|
113
|
+
{
|
|
114
|
+
name: f"collections.abc.{name}"
|
|
115
|
+
for name in ("Mapping", "MutableMapping", "Sequence", "Iterable", "Iterator")
|
|
116
|
+
}
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
NONE_NAMES = frozenset({"None", "NoneType"})
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class ImportMap:
|
|
123
|
+
"""Per-module map from locally visible names to canonical dotted names."""
|
|
124
|
+
|
|
125
|
+
__slots__ = ("_modules", "_names")
|
|
126
|
+
|
|
127
|
+
def __init__(self) -> None:
|
|
128
|
+
self._modules: dict[str, str] = {}
|
|
129
|
+
self._names: dict[str, str] = {}
|
|
130
|
+
|
|
131
|
+
def add_module(self, local: str, dotted: str) -> None:
|
|
132
|
+
"""Record ``import typing as t`` as ``t -> typing``."""
|
|
133
|
+
self._modules[local] = dotted
|
|
134
|
+
|
|
135
|
+
def add_from(self, local: str, modname: str, name: str) -> None:
|
|
136
|
+
"""Record ``from typing import Optional as Opt`` as ``Opt -> typing.Optional``."""
|
|
137
|
+
self._names[local] = f"{modname}.{name}" if modname else name
|
|
138
|
+
|
|
139
|
+
def resolve_prefix(self, local: str) -> str | None:
|
|
140
|
+
"""The module a dotted head starts from, e.g. `t` in `t.Dict`."""
|
|
141
|
+
return self._modules.get(local) or self._names.get(local)
|
|
142
|
+
|
|
143
|
+
def resolve_name(self, local: str) -> str | None:
|
|
144
|
+
"""The canonical name a bare identifier was imported as."""
|
|
145
|
+
return self._names.get(local)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def dotted_name(node: nodes.NodeNG | None) -> str | None:
|
|
149
|
+
"""``t.Dict`` -> ``"t.Dict"``; anything that is not a dotted name -> ``None``."""
|
|
150
|
+
if isinstance(node, nodes.Name):
|
|
151
|
+
return node.name
|
|
152
|
+
if isinstance(node, nodes.Attribute):
|
|
153
|
+
prefix = dotted_name(node.expr)
|
|
154
|
+
return f"{prefix}.{node.attrname}" if prefix else None
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def canonical(dotted: str) -> str:
|
|
159
|
+
"""Fold spelling variants together: `typing_extensions.X` and `typing.Dict`."""
|
|
160
|
+
if dotted.startswith("typing_extensions."):
|
|
161
|
+
dotted = "typing." + dotted.split(".", 1)[1]
|
|
162
|
+
return _CANONICAL.get(dotted, dotted)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def resolve_head(node: nodes.NodeNG | None, imports: ImportMap) -> str | None:
|
|
166
|
+
"""Canonical dotted name for the head of a subscript, or ``None`` if unknown."""
|
|
167
|
+
dotted = dotted_name(node)
|
|
168
|
+
if dotted is None:
|
|
169
|
+
return None
|
|
170
|
+
parts = dotted.split(".")
|
|
171
|
+
if len(parts) == 1:
|
|
172
|
+
qualified = imports.resolve_name(parts[0])
|
|
173
|
+
if qualified is not None:
|
|
174
|
+
return canonical(qualified)
|
|
175
|
+
return _BARE_FALLBACK.get(parts[0])
|
|
176
|
+
prefix = imports.resolve_prefix(parts[0])
|
|
177
|
+
if prefix is not None:
|
|
178
|
+
parts = prefix.split(".") + parts[1:]
|
|
179
|
+
return canonical(".".join(parts))
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def is_type_alias_annotation(node: nodes.NodeNG | None, imports: ImportMap) -> bool:
|
|
183
|
+
"""True for the ``TypeAlias`` in ``Rows: TypeAlias = ...`` (PEP 613)."""
|
|
184
|
+
return resolve_head(node, imports) in TYPE_ALIAS_NAMES
|
|
File without changes
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pylint-complex-struct
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A pylint checker that flags over-nested type annotations and pushes them towards type aliases, NamedTuples and TypedDicts
|
|
5
|
+
Author: Daniel Gutson
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/dgutson/pylint-complex-struct
|
|
8
|
+
Project-URL: Repository, https://github.com/dgutson/pylint-complex-struct
|
|
9
|
+
Project-URL: Issues, https://github.com/dgutson/pylint-complex-struct/issues
|
|
10
|
+
Keywords: pylint,pylint-plugin,linter,typing,type-annotations,static-analysis
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
Requires-Dist: pylint>=4.0
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
28
|
+
Dynamic: license-file
|
|
29
|
+
|
|
30
|
+
# pylint-complex-struct
|
|
31
|
+
|
|
32
|
+
[](https://www.python.org/downloads/)
|
|
33
|
+
[](https://pylint.readthedocs.io/)
|
|
34
|
+
[](LICENSE)
|
|
35
|
+
|
|
36
|
+
A pylint plugin that flags over-nested type annotations and pushes them towards type
|
|
37
|
+
aliases, `NamedTuple`s and `TypedDict`s.
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
# flagged
|
|
41
|
+
def load_records() -> tuple[list[dict[str, Any]], dict[str, Any]]: ...
|
|
42
|
+
|
|
43
|
+
# fixed
|
|
44
|
+
type Record = dict[str, Any]
|
|
45
|
+
type Summary = dict[str, Any]
|
|
46
|
+
|
|
47
|
+
class LoadResult(NamedTuple):
|
|
48
|
+
records: list[Record]
|
|
49
|
+
summary: Summary
|
|
50
|
+
|
|
51
|
+
def load_records() -> LoadResult: ...
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Nothing in pylint 4 checks this — `pylint.extensions.typing` only covers redundant and
|
|
55
|
+
deprecated typing constructs — and ruff has no equivalent rule.
|
|
56
|
+
|
|
57
|
+
## Table of contents
|
|
58
|
+
|
|
59
|
+
- [Installation](#installation)
|
|
60
|
+
- [Quick start](#quick-start)
|
|
61
|
+
- [Messages](#messages)
|
|
62
|
+
- [Configuration](#configuration)
|
|
63
|
+
- [How the metric works](#how-the-metric-works)
|
|
64
|
+
- [Adopting it on an existing codebase](#adopting-it-on-an-existing-codebase)
|
|
65
|
+
- [Comparison with flake8 and ruff](#comparison-with-flake8-and-ruff)
|
|
66
|
+
- [Known limitations](#known-limitations)
|
|
67
|
+
- [Development](#development)
|
|
68
|
+
- [Contributing](#contributing)
|
|
69
|
+
- [License](#license)
|
|
70
|
+
|
|
71
|
+
## Installation
|
|
72
|
+
|
|
73
|
+
Requires Python 3.10+ and pylint 4.0+.
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
pip install pylint-complex-struct
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Or from a checkout:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
git clone https://github.com/dgutson/pylint-complex-struct.git
|
|
83
|
+
cd pylint-complex-struct
|
|
84
|
+
pip install -e .
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Quick start
|
|
88
|
+
|
|
89
|
+
Pylint has no entry-point autoloading, so the plugin must be named explicitly:
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
pylint --load-plugins=pylint_complex_struct yourpackage
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
To survey an existing codebase with only this plugin's output:
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
pylint --load-plugins=pylint_complex_struct --disable=all \
|
|
99
|
+
--enable=complex-type-annotation,complex-type-alias,tuple-should-be-namedtuple \
|
|
100
|
+
yourpackage
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Or configure it once in `pyproject.toml`:
|
|
104
|
+
|
|
105
|
+
```toml
|
|
106
|
+
[tool.pylint.main]
|
|
107
|
+
load-plugins = ["pylint_complex_struct"]
|
|
108
|
+
|
|
109
|
+
[tool.pylint."complex-struct"]
|
|
110
|
+
max-annotation-complexity = 2
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
The config section is the checker's name, `complex-struct`, which needs quoting in TOML
|
|
114
|
+
because of the hyphen. The equivalent in `pylintrc` is `[complex-struct]`, and in
|
|
115
|
+
`setup.cfg` or `tox.ini` it is `[pylint.complex-struct]`.
|
|
116
|
+
|
|
117
|
+
<details>
|
|
118
|
+
<summary>pre-commit hook</summary>
|
|
119
|
+
|
|
120
|
+
```yaml
|
|
121
|
+
repos:
|
|
122
|
+
- repo: local
|
|
123
|
+
hooks:
|
|
124
|
+
- id: pylint-complex-struct
|
|
125
|
+
name: complex type annotations
|
|
126
|
+
entry: pylint --load-plugins=pylint_complex_struct
|
|
127
|
+
language: python
|
|
128
|
+
additional_dependencies: [pylint, pylint-complex-struct]
|
|
129
|
+
types: [python]
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
The plugin has to be importable in the same environment as pylint, so both are named in
|
|
133
|
+
`additional_dependencies` and pre-commit builds one venv holding the pair. Swap
|
|
134
|
+
`language: python` for `language: system` and drop `additional_dependencies` to reuse the
|
|
135
|
+
environment you already have.
|
|
136
|
+
</details>
|
|
137
|
+
|
|
138
|
+
<details>
|
|
139
|
+
<summary>CI</summary>
|
|
140
|
+
|
|
141
|
+
```yaml
|
|
142
|
+
- run: pip install pylint-complex-struct
|
|
143
|
+
- run: pylint --load-plugins=pylint_complex_struct --disable=all
|
|
144
|
+
--enable=complex-type-annotation,complex-type-alias,tuple-should-be-namedtuple
|
|
145
|
+
yourpackage
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
All three messages are in the refactor category, so a clean run exits `0` and a run with
|
|
149
|
+
findings sets bit 3 (exit status `8`).
|
|
150
|
+
</details>
|
|
151
|
+
|
|
152
|
+
## Messages
|
|
153
|
+
|
|
154
|
+
| ID | Symbol | Fires on |
|
|
155
|
+
|---|---|---|
|
|
156
|
+
| `R9501` | `complex-type-annotation` | an annotation deeper than `max-annotation-complexity`, or with more terms than `max-annotation-terms` |
|
|
157
|
+
| `R9502` | `complex-type-alias` | the *body* of a type alias, over the laxer `max-alias-complexity` |
|
|
158
|
+
| `R9503` | `tuple-should-be-namedtuple` | a return annotation that is a heterogeneous fixed-size tuple |
|
|
159
|
+
|
|
160
|
+
At most one message is emitted per annotation site: the depth rule wins over the
|
|
161
|
+
`NamedTuple` suggestion, and an alias body only ever produces `R9502`.
|
|
162
|
+
|
|
163
|
+
Silence an individual case as you would any pylint message:
|
|
164
|
+
|
|
165
|
+
```python
|
|
166
|
+
def legacy() -> tuple[dict[str, Any], list[dict[str, str]]]: # pylint: disable=complex-type-annotation
|
|
167
|
+
...
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
### `R9503` in detail
|
|
171
|
+
|
|
172
|
+
A returned fixed-size tuple whose elements differ forces every call site to unpack
|
|
173
|
+
positionally and re-invent names for the fields:
|
|
174
|
+
|
|
175
|
+
```python
|
|
176
|
+
def load() -> tuple[Config, int]: ... # R9503
|
|
177
|
+
def coords() -> tuple[int, int]: ... # silent: a fixed-size vector
|
|
178
|
+
def rows() -> tuple[str, ...]: ... # silent: a homogeneous sequence
|
|
179
|
+
def items() -> list[tuple[str, int]]: ... # silent: the dict.items() shape
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
Returns only, by default. A parameter typed `tuple[str, int]` is usually pass-through, and
|
|
183
|
+
the caller already has the values named.
|
|
184
|
+
|
|
185
|
+
## Configuration
|
|
186
|
+
|
|
187
|
+
All options live under `[tool.pylint."complex-struct"]` and work as ordinary pylint options
|
|
188
|
+
on the command line (`--max-annotation-complexity=3`).
|
|
189
|
+
|
|
190
|
+
| Option | Type | Default | Meaning |
|
|
191
|
+
|---|---|---|---|
|
|
192
|
+
| `max-annotation-complexity` | int | `2` | Max nesting depth of an annotation. |
|
|
193
|
+
| `max-alias-complexity` | int | `3` | Max nesting depth of an alias body. |
|
|
194
|
+
| `max-annotation-terms` | int | `7` | Max number of type terms in one annotation; `0` disables. |
|
|
195
|
+
| `count-optional-as-nesting` | yn | `n` | Count `Optional[X]` / `X \| None` as a level. |
|
|
196
|
+
| `count-union-as-nesting` | yn | `y` | Count a 2+ member union as a level. |
|
|
197
|
+
| `count-callable-params-as-nesting` | yn | `n` | Count `Callable`'s parameter bracket. |
|
|
198
|
+
| `namedtuple-check-scope` | csv | `returns` | Any of `returns,params,attributes,locals,aliases`; empty disables `R9503`. |
|
|
199
|
+
| `min-namedtuple-fields` | int | `2` | Minimum elements before suggesting a NamedTuple. |
|
|
200
|
+
| `check-implicit-type-aliases` | yn | `n` | Treat `Rows = dict[str, int]` as an alias. |
|
|
201
|
+
|
|
202
|
+
The default budget of `2` is stricter than the flake8 equivalent's `3`. If the first run on
|
|
203
|
+
an existing codebase is too loud, set `max-annotation-complexity = 3`.
|
|
204
|
+
|
|
205
|
+
## How the metric works
|
|
206
|
+
|
|
207
|
+
Depth counts subscript levels, and **a name is always a leaf**:
|
|
208
|
+
|
|
209
|
+
```
|
|
210
|
+
int 1
|
|
211
|
+
dict[str, Any] 2 <- legal by default
|
|
212
|
+
list[dict[str, Any]] 3 <- flagged
|
|
213
|
+
type Row = dict[str, Any]
|
|
214
|
+
type Table = list[Row]
|
|
215
|
+
dict[str, Table] 2 <- legal: extracting the alias fixed it
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
That last line is the whole design. The metric is purely syntactic and never asks astroid
|
|
219
|
+
what a name refers to, so pulling a subtree out into an alias mechanically brings the score
|
|
220
|
+
back within budget. A metric that expanded aliases would score the fixed code exactly like
|
|
221
|
+
the original — the checker could never be satisfied, and there would be no legal way to
|
|
222
|
+
write the type at all. `tests/test_no_inference.py` enforces this by grepping the source.
|
|
223
|
+
|
|
224
|
+
It also means results do not depend on which third-party packages happen to be installed,
|
|
225
|
+
so CI and your laptop agree.
|
|
226
|
+
|
|
227
|
+
### What does not count as nesting
|
|
228
|
+
|
|
229
|
+
| Construct | Treatment | Why |
|
|
230
|
+
|---|---|---|
|
|
231
|
+
| `X \| None`, `Optional[X]` | transparent | Nullability is a bit on a shape, not a shape to decompose. Aliasing it away hides optionality at the call site. Configurable. |
|
|
232
|
+
| `A \| B`, `Union[A, B]` | one level | A real branch the reader must hold. Both spellings share one code path. |
|
|
233
|
+
| `Callable[[A, B], R]` | the param bracket is free; param *types* count | The bracket is mandatory syntax, not chosen nesting. Configurable. |
|
|
234
|
+
| `Literal["a", "b"]` | leaf, contents never walked | Members are values, not types; there is nothing to extract. |
|
|
235
|
+
| `Annotated[T, meta]` | transparent, metadata never walked | Metadata is arbitrary runtime objects. Every Pydantic/FastAPI codebase would otherwise light up. |
|
|
236
|
+
| `Final`, `ClassVar`, `Required`, `NotRequired`, `ReadOnly`, `Unpack`, `TypeGuard`, `TypeIs`, `InitVar` | transparent | They describe how a name is used, not what shape it holds. |
|
|
237
|
+
| `*tuple[int, str]` | transparent | Must score the same as `Unpack[tuple[int, str]]`. |
|
|
238
|
+
| `...` in `tuple[int, ...]` | contributes nothing | A marker, not a type. |
|
|
239
|
+
| class bases | never visited | An alias cannot cleanly replace a base. |
|
|
240
|
+
|
|
241
|
+
Quoting is **not** an escape hatch: `-> "dict[str, list[tuple[int, int]]]"` scores the same
|
|
242
|
+
as the unquoted form. A forward reference that will not parse (`x: "the widget id"`) scores
|
|
243
|
+
as a leaf and is silently ignored.
|
|
244
|
+
|
|
245
|
+
### Type aliases
|
|
246
|
+
|
|
247
|
+
Alias bodies get their own, laxer budget, because absorbing structure is what an alias is
|
|
248
|
+
for — but hiding one unreadable structure behind a name has only moved the problem:
|
|
249
|
+
|
|
250
|
+
```python
|
|
251
|
+
type Row = dict[str, Any] # fine
|
|
252
|
+
type Table = list[Row] # composing is free
|
|
253
|
+
type Blob = dict[str, list[dict[str, Any]]] # R9502: depth 4 > 3
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
`type X = ...` (PEP 695) and `X: TypeAlias = ...` (PEP 613) are both recognised.
|
|
257
|
+
Unannotated `Rows = dict[str, int]` is opt-in via `check-implicit-type-aliases`, because
|
|
258
|
+
`rows = cache["key"]` is also an assignment whose value is a subscript and there is no sound
|
|
259
|
+
syntactic way to tell them apart in general. A SCREAMING_CASE target is skipped: by PEP 8
|
|
260
|
+
that is a constant, so `PEELABLE = TRANSPARENT | ANNOTATED` is a frozenset union rather than
|
|
261
|
+
a union type.
|
|
262
|
+
|
|
263
|
+
## Adopting it on an existing codebase
|
|
264
|
+
|
|
265
|
+
1. Survey first with `--disable=all --enable=...` so the output is only this plugin.
|
|
266
|
+
2. If the count is large, start at `max-annotation-complexity = 3` and ratchet down to `2`
|
|
267
|
+
once the depth-4 cases are gone.
|
|
268
|
+
3. Fix repeated shapes before one-offs — a single alias usually clears several sites.
|
|
269
|
+
`--output-format=json2` piped through a counter tells you which shapes repeat.
|
|
270
|
+
4. Widen `namedtuple-check-scope` beyond `returns` only after the depth rule is quiet, since
|
|
271
|
+
the depth rule masks the NamedTuple suggestion on any site that is also too deep.
|
|
272
|
+
|
|
273
|
+
## Comparison with flake8 and ruff
|
|
274
|
+
|
|
275
|
+
**flake8** would be marginally simpler to bootstrap and worse to live with. A flake8 plugin
|
|
276
|
+
is a class taking `(tree, filename)` with a `run()` yielding `(line, col, "XXX001 text",
|
|
277
|
+
type(self))` — perhaps 30 lines less scaffolding. But roughly 70% of this project is the
|
|
278
|
+
depth function, which would be identical, and since the design deliberately avoids type
|
|
279
|
+
inference, astroid's main advantage over the stdlib `ast` goes unused. What pylint buys is
|
|
280
|
+
everything around the check: named message symbols (`# pylint:
|
|
281
|
+
disable=complex-type-annotation` rather than `# noqa: TAE001`), a message catalogue visible
|
|
282
|
+
to `--list-msgs`, typed options with config-file support, confidence levels, and
|
|
283
|
+
`pylint.testutils.CheckerTestCase` as a ready-made harness.
|
|
284
|
+
|
|
285
|
+
If you are already on flake8,
|
|
286
|
+
[flake8-annotations-complexity](https://github.com/best-doctor/flake8-annotations-complexity)
|
|
287
|
+
covers the nesting metric today (`TAE002`/`TAE003`) with zero code. Its gaps:
|
|
288
|
+
|
|
289
|
+
- no `ast.BinOp` case, so PEP 604 unions are invisible — `tuple[int, int] | None` scores
|
|
290
|
+
**1** there and **2** here (pinned by `tests/test_depth.py::test_pep604_union_is_not_free`);
|
|
291
|
+
- no concept of type aliases, so no laxer budget for alias bodies;
|
|
292
|
+
- no `NamedTuple` suggestion.
|
|
293
|
+
|
|
294
|
+
**ruff** cannot do this at all: it does not support third-party plugins. The meta issue
|
|
295
|
+
([astral-sh/ruff#283](https://github.com/astral-sh/ruff/issues/283)) has been open since
|
|
296
|
+
2022, and as of late 2025 the maintainers described the design as discussed but unstarted.
|
|
297
|
+
Ruff has reimplemented 50+ flake8 plugins natively, but the `TAE` rules are not among them.
|
|
298
|
+
|
|
299
|
+
## Known limitations
|
|
300
|
+
|
|
301
|
+
- The head of a construct is recognised syntactically, with a bare-name fallback
|
|
302
|
+
(`Optional` is assumed to mean `typing.Optional` even with no visible import, because
|
|
303
|
+
re-exports and `if TYPE_CHECKING` blocks are common). A user-defined class literally named
|
|
304
|
+
`Optional` or `Literal` is therefore mis-classified. Tested and accepted.
|
|
305
|
+
- `.pyi` stubs are skipped entirely.
|
|
306
|
+
- Not checked: `NewType("X", ...)`, `TypeVar(bound=...)`, `cast("...", x)`, and `# type:`
|
|
307
|
+
comments.
|
|
308
|
+
- Annotations nested deeper than 32 levels, or larger than 2000 nodes, stop being walked and
|
|
309
|
+
are reported as "over 32" rather than with an exact number.
|
|
310
|
+
|
|
311
|
+
## Development
|
|
312
|
+
|
|
313
|
+
```bash
|
|
314
|
+
python3 -m venv .venv
|
|
315
|
+
.venv/bin/pip install -e '.[dev]'
|
|
316
|
+
.venv/bin/pytest -q
|
|
317
|
+
.venv/bin/pylint --load-plugins=pylint_complex_struct pylint_complex_struct tests
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
The plugin is run against its own source as part of the test discipline, and is expected to
|
|
321
|
+
stay clean at 10.00/10.
|
|
322
|
+
|
|
323
|
+
**Layout.** `pylint_complex_struct/depth.py` holds the metric and `names.py` the syntactic
|
|
324
|
+
head resolution; neither imports pylint, so both stay unit-testable with bare astroid.
|
|
325
|
+
`checker.py` holds the pylint plumbing. See [CLAUDE.md](CLAUDE.md) for the architectural
|
|
326
|
+
invariants and [ROADMAP.md](ROADMAP.md) for what is planned.
|
|
327
|
+
|
|
328
|
+
## Contributing
|
|
329
|
+
|
|
330
|
+
Issues and pull requests are welcome. Two things to know before opening one:
|
|
331
|
+
|
|
332
|
+
- **The metric must never infer.** `depth.py` and `names.py` may not import pylint and may
|
|
333
|
+
not call `.infer()`, `.inferred()`, `safe_infer()`, `.lookup()`, `object_type()` or
|
|
334
|
+
`.getattr()`. `tests/test_no_inference.py` greps for exactly these. Expanding aliases
|
|
335
|
+
during scoring would make the rule unsatisfiable, so it will not be accepted.
|
|
336
|
+
- New messages use the `95xx` range; pylint reserves 51–99 as the first two digits for
|
|
337
|
+
third-party checkers.
|
|
338
|
+
|
|
339
|
+
Please make sure `pytest` and the self-lint above both pass.
|
|
340
|
+
|
|
341
|
+
## License
|
|
342
|
+
|
|
343
|
+
[MIT](LICENSE) © Daniel Gutson
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
pylint_complex_struct/__init__.py,sha256=E_vQxCIU9PxI_lIWMXeJrm47-kTfBA95YZP3OrBbr2s,526
|
|
2
|
+
pylint_complex_struct/checker.py,sha256=HViITkJKzB9g3ghYdh2r4Op-930h1YwatDg7m4mB7h4,15573
|
|
3
|
+
pylint_complex_struct/depth.py,sha256=YcQ9ami5E4wetw22B9lWrqWVeMKT4IcZgzJka5ZTPmg,11610
|
|
4
|
+
pylint_complex_struct/names.py,sha256=bKuD-MOXvgRidNMK6DF4VRBpGaDiLMWhCqgn0MXcbSA,6546
|
|
5
|
+
pylint_complex_struct/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
pylint_complex_struct-0.1.0.dist-info/licenses/LICENSE,sha256=ohr_7faNlGK2RD_TaT76Trup2MvlrdQ9CaNVxC0J4Zk,1070
|
|
7
|
+
pylint_complex_struct-0.1.0.dist-info/METADATA,sha256=3tWW4KSTy2SutczKbj4Ud61ucqxIQ36-u7y_DJYWhdA,14635
|
|
8
|
+
pylint_complex_struct-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
9
|
+
pylint_complex_struct-0.1.0.dist-info/top_level.txt,sha256=UMPMU0F48EK2_BqqI1dHnUfjT3bjR9KY-KHZAcasrNA,22
|
|
10
|
+
pylint_complex_struct-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Daniel Gutson
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pylint_complex_struct
|