pylint-complex-struct 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,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,314 @@
1
+ # pylint-complex-struct
2
+
3
+ [![Python](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/downloads/)
4
+ [![pylint](https://img.shields.io/badge/pylint-4.0%2B-green.svg)](https://pylint.readthedocs.io/)
5
+ [![License: MIT](https://img.shields.io/badge/license-MIT-yellow.svg)](LICENSE)
6
+
7
+ A pylint plugin that flags over-nested type annotations and pushes them towards type
8
+ aliases, `NamedTuple`s and `TypedDict`s.
9
+
10
+ ```python
11
+ # flagged
12
+ def load_records() -> tuple[list[dict[str, Any]], dict[str, Any]]: ...
13
+
14
+ # fixed
15
+ type Record = dict[str, Any]
16
+ type Summary = dict[str, Any]
17
+
18
+ class LoadResult(NamedTuple):
19
+ records: list[Record]
20
+ summary: Summary
21
+
22
+ def load_records() -> LoadResult: ...
23
+ ```
24
+
25
+ Nothing in pylint 4 checks this — `pylint.extensions.typing` only covers redundant and
26
+ deprecated typing constructs — and ruff has no equivalent rule.
27
+
28
+ ## Table of contents
29
+
30
+ - [Installation](#installation)
31
+ - [Quick start](#quick-start)
32
+ - [Messages](#messages)
33
+ - [Configuration](#configuration)
34
+ - [How the metric works](#how-the-metric-works)
35
+ - [Adopting it on an existing codebase](#adopting-it-on-an-existing-codebase)
36
+ - [Comparison with flake8 and ruff](#comparison-with-flake8-and-ruff)
37
+ - [Known limitations](#known-limitations)
38
+ - [Development](#development)
39
+ - [Contributing](#contributing)
40
+ - [License](#license)
41
+
42
+ ## Installation
43
+
44
+ Requires Python 3.10+ and pylint 4.0+.
45
+
46
+ ```bash
47
+ pip install pylint-complex-struct
48
+ ```
49
+
50
+ Or from a checkout:
51
+
52
+ ```bash
53
+ git clone https://github.com/dgutson/pylint-complex-struct.git
54
+ cd pylint-complex-struct
55
+ pip install -e .
56
+ ```
57
+
58
+ ## Quick start
59
+
60
+ Pylint has no entry-point autoloading, so the plugin must be named explicitly:
61
+
62
+ ```bash
63
+ pylint --load-plugins=pylint_complex_struct yourpackage
64
+ ```
65
+
66
+ To survey an existing codebase with only this plugin's output:
67
+
68
+ ```bash
69
+ pylint --load-plugins=pylint_complex_struct --disable=all \
70
+ --enable=complex-type-annotation,complex-type-alias,tuple-should-be-namedtuple \
71
+ yourpackage
72
+ ```
73
+
74
+ Or configure it once in `pyproject.toml`:
75
+
76
+ ```toml
77
+ [tool.pylint.main]
78
+ load-plugins = ["pylint_complex_struct"]
79
+
80
+ [tool.pylint."complex-struct"]
81
+ max-annotation-complexity = 2
82
+ ```
83
+
84
+ The config section is the checker's name, `complex-struct`, which needs quoting in TOML
85
+ because of the hyphen. The equivalent in `pylintrc` is `[complex-struct]`, and in
86
+ `setup.cfg` or `tox.ini` it is `[pylint.complex-struct]`.
87
+
88
+ <details>
89
+ <summary>pre-commit hook</summary>
90
+
91
+ ```yaml
92
+ repos:
93
+ - repo: local
94
+ hooks:
95
+ - id: pylint-complex-struct
96
+ name: complex type annotations
97
+ entry: pylint --load-plugins=pylint_complex_struct
98
+ language: python
99
+ additional_dependencies: [pylint, pylint-complex-struct]
100
+ types: [python]
101
+ ```
102
+
103
+ The plugin has to be importable in the same environment as pylint, so both are named in
104
+ `additional_dependencies` and pre-commit builds one venv holding the pair. Swap
105
+ `language: python` for `language: system` and drop `additional_dependencies` to reuse the
106
+ environment you already have.
107
+ </details>
108
+
109
+ <details>
110
+ <summary>CI</summary>
111
+
112
+ ```yaml
113
+ - run: pip install pylint-complex-struct
114
+ - run: pylint --load-plugins=pylint_complex_struct --disable=all
115
+ --enable=complex-type-annotation,complex-type-alias,tuple-should-be-namedtuple
116
+ yourpackage
117
+ ```
118
+
119
+ All three messages are in the refactor category, so a clean run exits `0` and a run with
120
+ findings sets bit 3 (exit status `8`).
121
+ </details>
122
+
123
+ ## Messages
124
+
125
+ | ID | Symbol | Fires on |
126
+ |---|---|---|
127
+ | `R9501` | `complex-type-annotation` | an annotation deeper than `max-annotation-complexity`, or with more terms than `max-annotation-terms` |
128
+ | `R9502` | `complex-type-alias` | the *body* of a type alias, over the laxer `max-alias-complexity` |
129
+ | `R9503` | `tuple-should-be-namedtuple` | a return annotation that is a heterogeneous fixed-size tuple |
130
+
131
+ At most one message is emitted per annotation site: the depth rule wins over the
132
+ `NamedTuple` suggestion, and an alias body only ever produces `R9502`.
133
+
134
+ Silence an individual case as you would any pylint message:
135
+
136
+ ```python
137
+ def legacy() -> tuple[dict[str, Any], list[dict[str, str]]]: # pylint: disable=complex-type-annotation
138
+ ...
139
+ ```
140
+
141
+ ### `R9503` in detail
142
+
143
+ A returned fixed-size tuple whose elements differ forces every call site to unpack
144
+ positionally and re-invent names for the fields:
145
+
146
+ ```python
147
+ def load() -> tuple[Config, int]: ... # R9503
148
+ def coords() -> tuple[int, int]: ... # silent: a fixed-size vector
149
+ def rows() -> tuple[str, ...]: ... # silent: a homogeneous sequence
150
+ def items() -> list[tuple[str, int]]: ... # silent: the dict.items() shape
151
+ ```
152
+
153
+ Returns only, by default. A parameter typed `tuple[str, int]` is usually pass-through, and
154
+ the caller already has the values named.
155
+
156
+ ## Configuration
157
+
158
+ All options live under `[tool.pylint."complex-struct"]` and work as ordinary pylint options
159
+ on the command line (`--max-annotation-complexity=3`).
160
+
161
+ | Option | Type | Default | Meaning |
162
+ |---|---|---|---|
163
+ | `max-annotation-complexity` | int | `2` | Max nesting depth of an annotation. |
164
+ | `max-alias-complexity` | int | `3` | Max nesting depth of an alias body. |
165
+ | `max-annotation-terms` | int | `7` | Max number of type terms in one annotation; `0` disables. |
166
+ | `count-optional-as-nesting` | yn | `n` | Count `Optional[X]` / `X \| None` as a level. |
167
+ | `count-union-as-nesting` | yn | `y` | Count a 2+ member union as a level. |
168
+ | `count-callable-params-as-nesting` | yn | `n` | Count `Callable`'s parameter bracket. |
169
+ | `namedtuple-check-scope` | csv | `returns` | Any of `returns,params,attributes,locals,aliases`; empty disables `R9503`. |
170
+ | `min-namedtuple-fields` | int | `2` | Minimum elements before suggesting a NamedTuple. |
171
+ | `check-implicit-type-aliases` | yn | `n` | Treat `Rows = dict[str, int]` as an alias. |
172
+
173
+ The default budget of `2` is stricter than the flake8 equivalent's `3`. If the first run on
174
+ an existing codebase is too loud, set `max-annotation-complexity = 3`.
175
+
176
+ ## How the metric works
177
+
178
+ Depth counts subscript levels, and **a name is always a leaf**:
179
+
180
+ ```
181
+ int 1
182
+ dict[str, Any] 2 <- legal by default
183
+ list[dict[str, Any]] 3 <- flagged
184
+ type Row = dict[str, Any]
185
+ type Table = list[Row]
186
+ dict[str, Table] 2 <- legal: extracting the alias fixed it
187
+ ```
188
+
189
+ That last line is the whole design. The metric is purely syntactic and never asks astroid
190
+ what a name refers to, so pulling a subtree out into an alias mechanically brings the score
191
+ back within budget. A metric that expanded aliases would score the fixed code exactly like
192
+ the original — the checker could never be satisfied, and there would be no legal way to
193
+ write the type at all. `tests/test_no_inference.py` enforces this by grepping the source.
194
+
195
+ It also means results do not depend on which third-party packages happen to be installed,
196
+ so CI and your laptop agree.
197
+
198
+ ### What does not count as nesting
199
+
200
+ | Construct | Treatment | Why |
201
+ |---|---|---|
202
+ | `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. |
203
+ | `A \| B`, `Union[A, B]` | one level | A real branch the reader must hold. Both spellings share one code path. |
204
+ | `Callable[[A, B], R]` | the param bracket is free; param *types* count | The bracket is mandatory syntax, not chosen nesting. Configurable. |
205
+ | `Literal["a", "b"]` | leaf, contents never walked | Members are values, not types; there is nothing to extract. |
206
+ | `Annotated[T, meta]` | transparent, metadata never walked | Metadata is arbitrary runtime objects. Every Pydantic/FastAPI codebase would otherwise light up. |
207
+ | `Final`, `ClassVar`, `Required`, `NotRequired`, `ReadOnly`, `Unpack`, `TypeGuard`, `TypeIs`, `InitVar` | transparent | They describe how a name is used, not what shape it holds. |
208
+ | `*tuple[int, str]` | transparent | Must score the same as `Unpack[tuple[int, str]]`. |
209
+ | `...` in `tuple[int, ...]` | contributes nothing | A marker, not a type. |
210
+ | class bases | never visited | An alias cannot cleanly replace a base. |
211
+
212
+ Quoting is **not** an escape hatch: `-> "dict[str, list[tuple[int, int]]]"` scores the same
213
+ as the unquoted form. A forward reference that will not parse (`x: "the widget id"`) scores
214
+ as a leaf and is silently ignored.
215
+
216
+ ### Type aliases
217
+
218
+ Alias bodies get their own, laxer budget, because absorbing structure is what an alias is
219
+ for — but hiding one unreadable structure behind a name has only moved the problem:
220
+
221
+ ```python
222
+ type Row = dict[str, Any] # fine
223
+ type Table = list[Row] # composing is free
224
+ type Blob = dict[str, list[dict[str, Any]]] # R9502: depth 4 > 3
225
+ ```
226
+
227
+ `type X = ...` (PEP 695) and `X: TypeAlias = ...` (PEP 613) are both recognised.
228
+ Unannotated `Rows = dict[str, int]` is opt-in via `check-implicit-type-aliases`, because
229
+ `rows = cache["key"]` is also an assignment whose value is a subscript and there is no sound
230
+ syntactic way to tell them apart in general. A SCREAMING_CASE target is skipped: by PEP 8
231
+ that is a constant, so `PEELABLE = TRANSPARENT | ANNOTATED` is a frozenset union rather than
232
+ a union type.
233
+
234
+ ## Adopting it on an existing codebase
235
+
236
+ 1. Survey first with `--disable=all --enable=...` so the output is only this plugin.
237
+ 2. If the count is large, start at `max-annotation-complexity = 3` and ratchet down to `2`
238
+ once the depth-4 cases are gone.
239
+ 3. Fix repeated shapes before one-offs — a single alias usually clears several sites.
240
+ `--output-format=json2` piped through a counter tells you which shapes repeat.
241
+ 4. Widen `namedtuple-check-scope` beyond `returns` only after the depth rule is quiet, since
242
+ the depth rule masks the NamedTuple suggestion on any site that is also too deep.
243
+
244
+ ## Comparison with flake8 and ruff
245
+
246
+ **flake8** would be marginally simpler to bootstrap and worse to live with. A flake8 plugin
247
+ is a class taking `(tree, filename)` with a `run()` yielding `(line, col, "XXX001 text",
248
+ type(self))` — perhaps 30 lines less scaffolding. But roughly 70% of this project is the
249
+ depth function, which would be identical, and since the design deliberately avoids type
250
+ inference, astroid's main advantage over the stdlib `ast` goes unused. What pylint buys is
251
+ everything around the check: named message symbols (`# pylint:
252
+ disable=complex-type-annotation` rather than `# noqa: TAE001`), a message catalogue visible
253
+ to `--list-msgs`, typed options with config-file support, confidence levels, and
254
+ `pylint.testutils.CheckerTestCase` as a ready-made harness.
255
+
256
+ If you are already on flake8,
257
+ [flake8-annotations-complexity](https://github.com/best-doctor/flake8-annotations-complexity)
258
+ covers the nesting metric today (`TAE002`/`TAE003`) with zero code. Its gaps:
259
+
260
+ - no `ast.BinOp` case, so PEP 604 unions are invisible — `tuple[int, int] | None` scores
261
+ **1** there and **2** here (pinned by `tests/test_depth.py::test_pep604_union_is_not_free`);
262
+ - no concept of type aliases, so no laxer budget for alias bodies;
263
+ - no `NamedTuple` suggestion.
264
+
265
+ **ruff** cannot do this at all: it does not support third-party plugins. The meta issue
266
+ ([astral-sh/ruff#283](https://github.com/astral-sh/ruff/issues/283)) has been open since
267
+ 2022, and as of late 2025 the maintainers described the design as discussed but unstarted.
268
+ Ruff has reimplemented 50+ flake8 plugins natively, but the `TAE` rules are not among them.
269
+
270
+ ## Known limitations
271
+
272
+ - The head of a construct is recognised syntactically, with a bare-name fallback
273
+ (`Optional` is assumed to mean `typing.Optional` even with no visible import, because
274
+ re-exports and `if TYPE_CHECKING` blocks are common). A user-defined class literally named
275
+ `Optional` or `Literal` is therefore mis-classified. Tested and accepted.
276
+ - `.pyi` stubs are skipped entirely.
277
+ - Not checked: `NewType("X", ...)`, `TypeVar(bound=...)`, `cast("...", x)`, and `# type:`
278
+ comments.
279
+ - Annotations nested deeper than 32 levels, or larger than 2000 nodes, stop being walked and
280
+ are reported as "over 32" rather than with an exact number.
281
+
282
+ ## Development
283
+
284
+ ```bash
285
+ python3 -m venv .venv
286
+ .venv/bin/pip install -e '.[dev]'
287
+ .venv/bin/pytest -q
288
+ .venv/bin/pylint --load-plugins=pylint_complex_struct pylint_complex_struct tests
289
+ ```
290
+
291
+ The plugin is run against its own source as part of the test discipline, and is expected to
292
+ stay clean at 10.00/10.
293
+
294
+ **Layout.** `pylint_complex_struct/depth.py` holds the metric and `names.py` the syntactic
295
+ head resolution; neither imports pylint, so both stay unit-testable with bare astroid.
296
+ `checker.py` holds the pylint plumbing. See [CLAUDE.md](CLAUDE.md) for the architectural
297
+ invariants and [ROADMAP.md](ROADMAP.md) for what is planned.
298
+
299
+ ## Contributing
300
+
301
+ Issues and pull requests are welcome. Two things to know before opening one:
302
+
303
+ - **The metric must never infer.** `depth.py` and `names.py` may not import pylint and may
304
+ not call `.infer()`, `.inferred()`, `safe_infer()`, `.lookup()`, `object_type()` or
305
+ `.getattr()`. `tests/test_no_inference.py` greps for exactly these. Expanding aliases
306
+ during scoring would make the rule unsatisfiable, so it will not be accepted.
307
+ - New messages use the `95xx` range; pylint reserves 51–99 as the first two digits for
308
+ third-party checkers.
309
+
310
+ Please make sure `pytest` and the self-lint above both pass.
311
+
312
+ ## License
313
+
314
+ [MIT](LICENSE) © Daniel Gutson