Skip to main content

pylint-complex-struct

Python pylint License: MIT

A pylint plugin that flags over-nested type annotations and pushes them towards type aliases, NamedTuples and TypedDicts.

# flagged
def load_records() -> tuple[list[dict[str, Any]], dict[str, Any]]: ...

# fixed
type Record  = dict[str, Any]
type Summary = dict[str, Any]

class LoadResult(NamedTuple):
    records: list[Record]
    summary: Summary

def load_records() -> LoadResult: ...

Nothing in pylint 4 checks this — pylint.extensions.typing only covers redundant and deprecated typing constructs — and ruff has no equivalent rule.

Table of contents

Installation

Requires Python 3.10+ and pylint 4.0+.

pip install pylint-complex-struct

Or from a checkout:

git clone https://github.com/dgutson/pylint-complex-struct.git
cd pylint-complex-struct
pip install -e .

Quick start

Pylint has no entry-point autoloading, so the plugin must be named explicitly:

pylint --load-plugins=pylint_complex_struct yourpackage

To survey an existing codebase with only this plugin's output:

pylint --load-plugins=pylint_complex_struct --disable=all \
       --enable=complex-type-annotation,complex-type-alias,tuple-should-be-namedtuple \
       yourpackage

Or configure it once in pyproject.toml:

[tool.pylint.main]
load-plugins = ["pylint_complex_struct"]

[tool.pylint."complex-struct"]
max-annotation-complexity = 2

The config section is the checker's name, complex-struct, which needs quoting in TOML because of the hyphen. The equivalent in pylintrc is [complex-struct], and in setup.cfg or tox.ini it is [pylint.complex-struct].

pre-commit hook
repos:
  - repo: local
    hooks:
      - id: pylint-complex-struct
        name: complex type annotations
        entry: pylint --load-plugins=pylint_complex_struct
        language: python
        additional_dependencies: [pylint, pylint-complex-struct]
        types: [python]

The plugin has to be importable in the same environment as pylint, so both are named in additional_dependencies and pre-commit builds one venv holding the pair. Swap language: python for language: system and drop additional_dependencies to reuse the environment you already have.

CI
- run: pip install pylint-complex-struct
- run: pylint --load-plugins=pylint_complex_struct --disable=all
         --enable=complex-type-annotation,complex-type-alias,tuple-should-be-namedtuple
         yourpackage

All three messages are in the refactor category, so a clean run exits 0 and a run with findings sets bit 3 (exit status 8).

Messages

ID Symbol Fires on
R9501 complex-type-annotation an annotation deeper than max-annotation-complexity, or with more terms than max-annotation-terms
R9502 complex-type-alias the body of a type alias, over the laxer max-alias-complexity
R9503 tuple-should-be-namedtuple a return annotation that is a heterogeneous fixed-size tuple

At most one message is emitted per annotation site: the depth rule wins over the NamedTuple suggestion, and an alias body only ever produces R9502.

Silence an individual case as you would any pylint message:

def legacy() -> tuple[dict[str, Any], list[dict[str, str]]]:  # pylint: disable=complex-type-annotation
    ...

R9503 in detail

A returned fixed-size tuple whose elements differ forces every call site to unpack positionally and re-invent names for the fields:

def load() -> tuple[Config, int]: ...     # R9503
def coords() -> tuple[int, int]: ...      # silent: a fixed-size vector
def rows() -> tuple[str, ...]: ...        # silent: a homogeneous sequence
def items() -> list[tuple[str, int]]: ... # silent: the dict.items() shape

Returns only, by default. A parameter typed tuple[str, int] is usually pass-through, and the caller already has the values named.

Configuration

All options live under [tool.pylint."complex-struct"] and work as ordinary pylint options on the command line (--max-annotation-complexity=3).

Option Type Default Meaning
max-annotation-complexity int 2 Max nesting depth of an annotation.
max-alias-complexity int 3 Max nesting depth of an alias body.
max-annotation-terms int 7 Max number of type terms in one annotation; 0 disables.
count-optional-as-nesting yn n Count Optional[X] / X | None as a level.
count-union-as-nesting yn y Count a 2+ member union as a level.
count-callable-params-as-nesting yn n Count Callable's parameter bracket.
namedtuple-check-scope csv returns Any of returns,params,attributes,locals,aliases; empty disables R9503.
min-namedtuple-fields int 2 Minimum elements before suggesting a NamedTuple.
check-implicit-type-aliases yn n Treat Rows = dict[str, int] as an alias.

The default budget of 2 is stricter than the flake8 equivalent's 3. If the first run on an existing codebase is too loud, set max-annotation-complexity = 3.

How the metric works

Depth counts subscript levels, and a name is always a leaf:

int                          1
dict[str, Any]               2      <- legal by default
list[dict[str, Any]]         3      <- flagged
type Row = dict[str, Any]
type Table = list[Row]
dict[str, Table]             2      <- legal: extracting the alias fixed it

That last line is the whole design. The metric is purely syntactic and never asks astroid what a name refers to, so pulling a subtree out into an alias mechanically brings the score back within budget. A metric that expanded aliases would score the fixed code exactly like the original — the checker could never be satisfied, and there would be no legal way to write the type at all. tests/test_no_inference.py enforces this by grepping the source.

It also means results do not depend on which third-party packages happen to be installed, so CI and your laptop agree.

What does not count as nesting

Construct Treatment Why
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.
A | B, Union[A, B] one level A real branch the reader must hold. Both spellings share one code path.
Callable[[A, B], R] the param bracket is free; param types count The bracket is mandatory syntax, not chosen nesting. Configurable.
Literal["a", "b"] leaf, contents never walked Members are values, not types; there is nothing to extract.
Annotated[T, meta] transparent, metadata never walked Metadata is arbitrary runtime objects. Every Pydantic/FastAPI codebase would otherwise light up.
Final, ClassVar, Required, NotRequired, ReadOnly, Unpack, TypeGuard, TypeIs, InitVar transparent They describe how a name is used, not what shape it holds.
*tuple[int, str] transparent Must score the same as Unpack[tuple[int, str]].
... in tuple[int, ...] contributes nothing A marker, not a type.
class bases never visited An alias cannot cleanly replace a base.

Quoting is not an escape hatch: -> "dict[str, list[tuple[int, int]]]" scores the same as the unquoted form. A forward reference that will not parse (x: "the widget id") scores as a leaf and is silently ignored.

Type aliases

Alias bodies get their own, laxer budget, because absorbing structure is what an alias is for — but hiding one unreadable structure behind a name has only moved the problem:

type Row   = dict[str, Any]                       # fine
type Table = list[Row]                            # composing is free
type Blob  = dict[str, list[dict[str, Any]]]      # R9502: depth 4 > 3

type X = ... (PEP 695) and X: TypeAlias = ... (PEP 613) are both recognised. Unannotated Rows = dict[str, int] is opt-in via check-implicit-type-aliases, because rows = cache["key"] is also an assignment whose value is a subscript and there is no sound syntactic way to tell them apart in general. A SCREAMING_CASE target is skipped: by PEP 8 that is a constant, so PEELABLE = TRANSPARENT | ANNOTATED is a frozenset union rather than a union type.

Adopting it on an existing codebase

  1. Survey first with --disable=all --enable=... so the output is only this plugin.
  2. If the count is large, start at max-annotation-complexity = 3 and ratchet down to 2 once the depth-4 cases are gone.
  3. Fix repeated shapes before one-offs — a single alias usually clears several sites. --output-format=json2 piped through a counter tells you which shapes repeat.
  4. Widen namedtuple-check-scope beyond returns only after the depth rule is quiet, since the depth rule masks the NamedTuple suggestion on any site that is also too deep.

Comparison with flake8 and ruff

flake8 would be marginally simpler to bootstrap and worse to live with. A flake8 plugin is a class taking (tree, filename) with a run() yielding (line, col, "XXX001 text", type(self)) — perhaps 30 lines less scaffolding. But roughly 70% of this project is the depth function, which would be identical, and since the design deliberately avoids type inference, astroid's main advantage over the stdlib ast goes unused. What pylint buys is everything around the check: named message symbols (# pylint: disable=complex-type-annotation rather than # noqa: TAE001), a message catalogue visible to --list-msgs, typed options with config-file support, confidence levels, and pylint.testutils.CheckerTestCase as a ready-made harness.

If you are already on flake8, flake8-annotations-complexity covers the nesting metric today (TAE002/TAE003) with zero code. Its gaps:

  • no ast.BinOp case, so PEP 604 unions are invisible — tuple[int, int] | None scores 1 there and 2 here (pinned by tests/test_depth.py::test_pep604_union_is_not_free);
  • no concept of type aliases, so no laxer budget for alias bodies;
  • no NamedTuple suggestion.

ruff cannot do this at all: it does not support third-party plugins. The meta issue (astral-sh/ruff#283) has been open since 2022, and as of late 2025 the maintainers described the design as discussed but unstarted. Ruff has reimplemented 50+ flake8 plugins natively, but the TAE rules are not among them.

Known limitations

  • The head of a construct is recognised syntactically, with a bare-name fallback (Optional is assumed to mean typing.Optional even with no visible import, because re-exports and if TYPE_CHECKING blocks are common). A user-defined class literally named Optional or Literal is therefore mis-classified. Tested and accepted.
  • .pyi stubs are skipped entirely.
  • Not checked: NewType("X", ...), TypeVar(bound=...), cast("...", x), and # type: comments.
  • Annotations nested deeper than 32 levels, or larger than 2000 nodes, stop being walked and are reported as "over 32" rather than with an exact number.

Development

python3 -m venv .venv
.venv/bin/pip install -e '.[dev]'
.venv/bin/pytest -q
.venv/bin/pylint --load-plugins=pylint_complex_struct pylint_complex_struct tests

The plugin is run against its own source as part of the test discipline, and is expected to stay clean at 10.00/10.

Layout. pylint_complex_struct/depth.py holds the metric and names.py the syntactic head resolution; neither imports pylint, so both stay unit-testable with bare astroid. checker.py holds the pylint plumbing. See CLAUDE.md for the architectural invariants and ROADMAP.md for what is planned.

Contributing

Issues and pull requests are welcome. Two things to know before opening one:

  • The metric must never infer. depth.py and names.py may not import pylint and may not call .infer(), .inferred(), safe_infer(), .lookup(), object_type() or .getattr(). tests/test_no_inference.py greps for exactly these. Expanding aliases during scoring would make the rule unsatisfiable, so it will not be accepted.
  • New messages use the 95xx range; pylint reserves 51–99 as the first two digits for third-party checkers.

Please make sure pytest and the self-lint above both pass.

License

MIT © Daniel Gutson

Release files for pylint-complex-struct 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for pylint-complex-struct 0.1.0
File Size Uploaded
pylint_complex_struct-0.1.0.tar.gz 29.0 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for pylint-complex-struct 0.1.0
File Interpreter ABI Platform
pylint_complex_struct-0.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 48.4 kB

Release files / pylint_complex_struct-0.1.0.tar.gz

Download URL pylint_complex_struct-0.1.0.tar.gz
Size 29.0 kB
Tags Source
SHA-256 checksum
How to use checksums
f30a7949e480fd725d1e1b48e6628015788e387679f0fc0b3b9164b5e54ad9e3
BLAKE2b-256 checksum
How to use checksums
6103a4df8dfe0733f96104fdc87c6d4b77bb193b8490e91e50d142c7b376bb12
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 21, 2026.

Transparency log

Release files / pylint_complex_struct-0.1.0-py3-none-any.whl

Download URL pylint_complex_struct-0.1.0-py3-none-any.whl
Size 19.4 kB
Tags Python 3
SHA-256 checksum
How to use checksums
1db72657f82939842350cb2149225e3d9745f874427ae8c5124b186b9db51df5
BLAKE2b-256 checksum
How to use checksums
8bb40c05ff7ae379c40893c5b50e15ff412e381358669c4a62be2e486296ab3d
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 21, 2026.

Transparency log

Release history Release notifications | RSS feed

0.2.0

2 release files

This release

0.1.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page