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.
@@ -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
+ [![Python](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/downloads/)
33
+ [![pylint](https://img.shields.io/badge/pylint-4.0%2B-green.svg)](https://pylint.readthedocs.io/)
34
+ [![License: MIT](https://img.shields.io/badge/license-MIT-yellow.svg)](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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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