Skip to content

← Precise Type Hints with Literals and Generics step 23 of 26

Easy Framework

Rewrite Old Typing Spellings Such as List and Optional

You inherit a codebase written for Python 3.8. Its hints look like Dict[str, List[int]] and Optional[str], and every file starts with a long from typing import ... line. Since Python 3.9 you can write list[int], and since 3.10 you can write str | None. Tools such as Ruff's UP rules make this change for you. In this exercise you build a small version of one. It rewrites annotation text and works out which imports are still needed.

assert modernise(["Dict[str, List[int]]", "Sequence[Any]"]) == (
    ["dict[str, list[int]]", "Sequence[Any]"],
    ["from collections.abc import Sequence", "from typing import Any"],
)

What to write

modernise(annotations: list[str]) -> tuple[list[str], list[str]] returns the rewritten annotations in input order, and the import lines they need.

Old name or form New form Import from
List, Dict, Tuple, Set, FrozenSet, Type list, dict, tuple, set, frozenset, type nothing
Optional[X] X | None nothing
Union[A, B] A | B nothing
Callable, Sequence, Iterable, Iterator, Mapping, MutableMapping, MutableSequence, Hashable, Awaitable, Coroutine, Generator the same name collections.abc
Any, Final, ClassVar, Literal, Never, NoReturn, Self the same name typing
any other name, such as int or MyModel the same name nothing

The supplied BUILTIN, ABC_NAMES and TYPING_NAMES constants hold these names. Other names are already imported, so add no import for them.

  • Recurse. Rewrite the arguments inside brackets too.
  • Flatten unions. Nested Union, Optional and | become one flat union. Then drop repeated members, keeping the first:
    • Union[int, Optional[str]] becomes int | str | None.
    • Optional[Optional[str]] becomes str | None.
    • Union[int, int] becomes int.
    • List[int] | Optional[str] becomes list[int] | str | None.
  • Keep Callable's parameter list. In Callable[[int, str], X], keep the inner brackets and rewrite what is inside them.
  • Keep ..., as in Callable[..., None] or Tuple[int, ...].
  • Copy Literal values exactly. In Literal["a,b", "x|y", "List[int]"], the quoted strings are data. Do not change them, and do not add imports for words inside them.
  • Format separators as , and |, and ignore spaces around each expression and argument.
  • Imports. Write one line per module, from <module> import <names>. Sort the lines by module name, and sort the names inside each line. Each name appears once across the whole input. With no imports, return [].
  • An empty input returns ([], []).

The input

Every input is a well-formed annotation built from plain names, square brackets, Callable parameter lists, ..., unions and Literal values. Strings use single or double quotes and may contain backslash escapes. You do not need to handle parentheses, comments, dotted names such as typing.List, string prefixes, triple quotes or quoted forward references.

Use the supplied split_top(text, separator) to split on commas and pipes. It ignores separators inside brackets and quoted strings. Do not eval the text.

Annotate your helpers fully. For example, an accumulator for imports is a dict[str, set[str]], not a bare dict.

Going further

mypy accepts the old spellings, so this rewrite is a style change, not a type fix. Optional[X] means a value may be None. It does not make an argument optional. An argument is optional only when its parameter has a default. A real tool also has to handle aliased imports, shadowed names and libraries that read annotations at runtime. Ruff's UP006 and UP045 rules do this on whole files.