Skip to content

← The Type System as a Design Tool step 1 of 24

Easy Framework

Modernise legacy annotations

Write the codemod that drags a 2019 annotation into 2026.

from typing import List in a file that already runs on 3.12 is rarely a lone mistake. It is a marker: the author has also missed X | Y, PEP 695 generics and Self, and almost certainly learned Python typing from a tutorial written before any of them existed. Reviewers read it that way, so the cheapest thing you can do to a legacy module is make its annotations look their age.

PEP 585 (Python 3.9) made the builtin containers subscriptable: list[int], dict[str, int], tuple[str, ...], type[Exception], and the collections.abc originals — Sequence, Mapping, Callable, Iterable — instead of their typing shadows. PEP 604 (3.10) made X | Y a real union type at runtime, which retires both Union and Optional.

The honest nuance, because you will be asked in review: the typing aliases are deprecated, but removal is not currently planned (cpython#106745). Nothing will break if you leave them. The names with actual deadlines are different ones: ByteString (removal in 3.17), AnyStr (3.18), and no_type_check_decorator (3.15).

And this matters for how you gate it: mypy --strict does not flag typing.List — not even with --enable-error-code deprecated. Modernisation is entirely ruff’s job, rules UP006 and UP035. If you thought your type checker was catching this, it never was.

The task

def modernise(annotations: list[str]) -> tuple[list[str], list[str]]:

You receive a list of annotation expressions as strings. Return a 2-tuple:

  1. the same annotations rewritten in modern spelling, in the same order;
  2. the import lines the rewritten module now needs, one per module, sorted by module name, with the imported names sorted inside each line.

The rewrite rules, exactly:

Legacy Modern Import needed
List Dict Tuple Set FrozenSet Type list dict tuple set frozenset type none — they are builtins
Optional[X] X | None none
Union[A, B] A | B none
Callable Sequence Iterable Iterator Mapping MutableMapping MutableSequence Hashable Awaitable Coroutine Generator unchanged from collections.abc import ...
Any Final ClassVar Literal Never NoReturn Self unchanged from typing import ...
anything else (int, Exception, MyModel) unchanged none

Two module constants and a split_top helper are given to you — split_top splits on a separator only where bracket depth is zero, which is what makes the nesting tractable. The rest is a small recursive rewrite.

Details that the tests pin down:

  • Nesting is arbitrary. Dict[str, List[int]] becomes dict[str, list[int]]; the rewrite has to recurse into arguments.
  • Unions flatten and deduplicate. Union[int, Optional[str]] is int | str | None, not int | str | None | None and not int | (str | None). Union[int, int] collapses to int.
  • Callable‘s first argument is a bracketed list, not a type. Callable[[int, str], X] must keep the inner [int, str] intact — and Callable[..., None] must keep its literal ....
  • Any survives. There is no builtin equivalent, so it stays, and it still needs its typing import. Preserving Any is not an endorsement — see the Any versus object problem for why it is the most dangerous name in the module.
  • Two annotations that both need Sequence produce one import line.

Emit from collections.abc import Mapping, Sequence, not two lines and not import collections.abc. When nothing needs importing, the second element is an empty list.

Why this shape

You are writing the transformation ruff’s UP006/UP035/UP007 perform, on a deliberately narrowed grammar. Doing it once by hand is the difference between “the linter yelled and I accepted the fix” and knowing exactly which fixes are safe. It is not academic: UP007 on a module that calls get_type_hints() at runtime under Python 3.9 will break it, because int | str was a syntax-level change, not a library one. Knowing what the codemod does tells you when not to run it.

Your submission must pass mypy --strict. Note in particular that --disallow-any-generics rejects a bare dict or list annotation — you must parameterise the accumulator you use to collect imports.