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.
- pylint_complex_struct-0.1.0/LICENSE +21 -0
- pylint_complex_struct-0.1.0/PKG-INFO +343 -0
- pylint_complex_struct-0.1.0/README.md +314 -0
- pylint_complex_struct-0.1.0/pylint_complex_struct/__init__.py +21 -0
- pylint_complex_struct-0.1.0/pylint_complex_struct/checker.py +380 -0
- pylint_complex_struct-0.1.0/pylint_complex_struct/depth.py +348 -0
- pylint_complex_struct-0.1.0/pylint_complex_struct/names.py +184 -0
- pylint_complex_struct-0.1.0/pylint_complex_struct/py.typed +0 -0
- pylint_complex_struct-0.1.0/pylint_complex_struct.egg-info/PKG-INFO +343 -0
- pylint_complex_struct-0.1.0/pylint_complex_struct.egg-info/SOURCES.txt +17 -0
- pylint_complex_struct-0.1.0/pylint_complex_struct.egg-info/dependency_links.txt +1 -0
- pylint_complex_struct-0.1.0/pylint_complex_struct.egg-info/requires.txt +4 -0
- pylint_complex_struct-0.1.0/pylint_complex_struct.egg-info/top_level.txt +1 -0
- pylint_complex_struct-0.1.0/pyproject.toml +54 -0
- pylint_complex_struct-0.1.0/setup.cfg +4 -0
- pylint_complex_struct-0.1.0/tests/test_checker.py +360 -0
- pylint_complex_struct-0.1.0/tests/test_depth.py +252 -0
- pylint_complex_struct-0.1.0/tests/test_functional.py +55 -0
- pylint_complex_struct-0.1.0/tests/test_no_inference.py +39 -0
|
@@ -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
|
+
[](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,314 @@
|
|
|
1
|
+
# pylint-complex-struct
|
|
2
|
+
|
|
3
|
+
[](https://www.python.org/downloads/)
|
|
4
|
+
[](https://pylint.readthedocs.io/)
|
|
5
|
+
[](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
|