Проект растёт восемь лет, в коде триста тысяч строк, и очередное изменение типа поля в общей модели приносит ревью на сорок файлов, из которых автор тридцать не открывал. Инженер в этом месте обычно думает о статической типизации: включить mypy, получить проверку аннотаций до запуска и перестать находить ошибку несоответствия типов в продакшене. Вторая половина правды приходит позже: аннотации это код, их придётся писать, читать, обновлять и чинить, когда проверка жалуется на легаси, написанное до всяких PEP. Ниже разбор обеих сторон: что mypy реально ловит в живом коде, сколько стоит проверка на большой кодовой базе, как аннотации усложняют эволюцию API и где проходит точка безубыточности.

Постепенная типизация и уровни строгости от легаси до флага strict

Идея типов в Python строилась как необязательная надстройка. PEP 484, появившийся в 2014 году, ввёл модуль typing, и с Python 3.5 аннотации стали частью синтаксиса, не меняя исполнения: интерпретатор хранит их в __annotations__ и не проверяет. Проверкой занимается внешний инструмент, и mypy, начатый Юккой Лехтосало, стал де-факто стандартом этой роли. К январю 2023 года инструмент дошёл до версии 1.0, после чего развитие идёт размеренными минорными релизами, и это само по себе аргумент для консервативных команд. Ключевое слово подхода это постепенность: один проект живёт с полностью нетипизированными модулями и строгим new-кодом одновременно, и это штатный режим, а не компромисс.

Управление строгостью вынесено в конфигурацию, типично в pyproject.toml:

[tool.mypy]
python_version = "3.12"
strict = true

[[tool.mypy.overrides]]
module = "legacy.*"
ignore_errors = true

Флаг strict это пакет из десятка проверок: запрет неаннотированных определений, запрет неявных Any, требование аннотаций для всех аргументов и результатов. Освобождение легаси через overrides позволяет двигаться в строгий мир частями, включая его для новых модулей по мере готовности. Популярный порядок такой: сначала типизируют границы модулей, публичные сигнатуры, затем тела, и лишь в конце включают строгие проверки внутри. Проверить жёсткость на отдельном файле можно и без переезда конфига целиком:

mypy src/
mypy --strict src/new_module.py

Первая команда гоняет проект по действующим правилам, вторая просеивает один новый модуль через строгий фильтр ещё до того, как правило станет общим. Такой двойной режим снимает страх внедрения: строгий мир не наступает сразу, он протаптывается по одному файлу, и каждая строка отчёта остаётся объяснимой.

Попытка типизировать всё сразу на восьмилетнем проекте заканчивается одинаково: три тысячи ошибок, отмена задачи, недоверие к инструменту.

Реальные классы ошибок, которые mypy находит до запуска

Основная добыча mypy это не ошибки дизайна, а будничные несоответствия, которые раньше всплывали бы в рантайме на конкретной ветке. Типичные представители: обращение к атрибуту, который может отсутствовать, передача str туда, где ждут int, потерянная ветка с None после dict.get, перепутанный порядок аргументов одного типа. Проверка срабатывает на этапе сборки, до тестов и до выката:

def load(order_id: int | None) -> Order:
    if order_id is None:
        return default_order()
    return repo.get(order_id)          # mypy видит здесь уже int

Для ручной проверки выражений существует reveal_type, работающий и без аннотаций вокруг:

reveal_type(repo.get(order_id))       # note: Revealed type is "Order"

Отдельная ценность это каскадные изменения. Поле amount сменило тип с int на Decimal, и mypy перечисляет все точки, где это отразится, файл за файлом, включая места, о которых автор изменения не подозревал. Без проверки такой тур по кодовой базе занял бы дни чтения или годы ожидания багов.

Оговорка о пределах проверена. Текст ошибок сам говорит за себя, у каждой указан файл, строка и код вида:

error: Item "None" of "Optional[Order]" has no attribute "amount"  [union-attr]

Оговорка о пределах: mypy не исполняет код. Динамические словари из JSON, значения с макетом Any, сборка атрибутов в цикле остаются за бортом его понимания. Проверка ловит несоответствия там, где есть честные аннотации, и молчит там, где их нет, что честно и одновременно коварно.

Скорость проверки на триста тысяч строк и постоянный процесс dmypy

Обратная сторона пользы это время прогона. mypy это интерпретируемая программа, полная проверка большой кодовой базы холодным запуском идёт минутами, и на CI такое съедает бюджет конвейера ежедневно. Стандартное решение это режим dmypy: постоянный процесс, держащий состояние проверки в памяти. По документации инструмента повторные прогоны через постоянный процесс могут быть более чем в десять раз быстрее командной строки, особенно при правке нескольких файлов, что и составляет типичный цикл разработки. Команды управления просты:

dmypy run -- src/
dmypy stop

Первый запуск такого процесса обрабатывает весь код и занимает то самое время полного прогона, последующие пересматривают только изменённое и зависимое. Ещё один ускоритель это mypyc, компилятор, собирающий сам mypy в машинный код, и скомпилированная версия работает в разы быстрее интерпретируемой. Для команд, которым нужна реакция мгновением, существует и pyright, проверка от Microsoft на TypeScript, быстрый и параллельный, но с собственным поведением на отдельных конструкциях, так что проекты нередко держат mypy в CI и pyright в редакторе.

Кэш это половина успеха на CI. Каталог .mypy_cache при повторных прогонах превращает минуты в секунды, а удалённое кэширование позволяет не греть его заново на каждом воркере. Зрелая конфигурация без кэша на большой монорепе это штраф, который платит каждый пайплайн:

CODE6
Удалённое кэширование выносит этот каталог в общее хранилище, и воркеры CI перестают греть одну и ту же печку с нуля.

Цена аннотаций при эволюции API и каскады рефакторинга сигнатур

Аннотации не бесплатны в поддержке. Строго типизированный код требует выражать в типах то, что раньше жило в голове: словари с фиксированным набором ключей получают TypedDict из PEP 589, структурная совместимость без наследования описывается Protocol из PEP 544, оба вошли в Python 3.8:

from typing import Protocol, TypedDict

class Event(TypedDict):
    id: int
    amount: Decimal

class Saver(Protocol):
    def save(self, event: Event) -> None: ...

Это выразительно, но каждая новая форма знания требует времени. Дженерики с TypeVar, перегрузки overload, рекурсивные типы читаются тяжело даже опытным авторам:

from typing import TypeVar

T = TypeVar("T")

def first(items: list[T]) -> T:
    return items[0]

Запись компактна, но за ней стоит соглашение о выводе типов, которое автор обязан держать в голове на каждом вызове: подставится строка или список строк, решится по аргументу, и ошибка в таком месте читается хуже простого несоответствия. Когда команда из двадцати человек меняет публичный API, аннотации приходится переписывать вместе с кодом, и для сигнатур, живущих в трёх слоях абстракции, стоимость изменения растёт. Особенно заметно на плагинах фреймворков: mypy ничего не знает о том, что Django-модель или декларативный SQLAlchemy-столбец реально имеет атрибут, и без плагина честно требует type ignore на каждой строке. Плагины эти отношения лечат, но сами требуют обновления под версии фреймворка.

Второй налог это дисциплина брешей. Значение без аннотации в нестрогом режиме это тихое Any, и Any распространяется по выражениям как вода по трюмам: функция, принявшая Any, отдаёт Any дальше. Строгие флаги отключают такой сток, но платой становится обязанность типизировать честно.

Any, исключения type ignore и гигиена подавленных предупреждений

Клапан для трудных мест известен: Any и подавление конкретной строки. Проблема не в их существовании, а в гниении без надзора:

from third_party import legacy_call

legacy_call(x)  # type: ignore[arg-type]

Подавление с кодом ошибки честнее безликого: оно сработает только на заявленную причину. Пары строк у каждой третьей функции через полгода превращают проверку в декорацию. Против загнивания в mypy есть warn_unused_ignores: устаревшее подавление само становится ошибкой, потому что библиотека пофиксилась, а type ignore остался. На проектах с историей этот флаг стоит включать рано, боль дешёвее в начале.

Второй фронт гигиены это сторонние пакеты. Пакет, не помеченный файлом py.typed по PEP 561, отдаёт mypy пустоту, и все его функции приносят Any. Решение исторически такое: отдельные пакеты заглушек вроде types-requests, собираемые сообществом, и команда --install-types для их подбора. За годы экосистема закрыла заглушками почти всё популярное, но нишевые библиотеки по-прежнему требуют либо собственных заглушек, либо явного Any на границе.

На границе с внешним потоком значений обязан стоять явный маркер, а не молчаливое распространение неизвестности:

from typing import Any, cast
import json

payload: Any = json.loads(raw)
event = cast(Event, payload)

Функция cast ничего не проверяет в рантайме, это честная расписка проверке: значение пришло извне, тип обещает человек. Такие расписки ищутся поиском и разбираются на ревью, в отличие от неявных Any, которые не видны вовсе.

Чек-лист гигиены прост: Any только на границе с внешним миром, type ignore только с кодом ошибки, warn_unused_ignores включён, количество подавлений на модуль видно в отчёте и обсуждается на ревью как любой другой долг.

Чек-лист внедрения mypy в проект с двенадцатью годами истории

Внедрение в живой код с историей проходит по проверенной схеме:

  1. сначала mypy включается в нестрогом режиме с check_untyped_defs, чтобы он видел ошибки в телах нетипизированных функций, не требуя аннотаций;
  2. новые модули сразу пишутся в строгом режиме через overrides, строгий режим для нового кода это норма, а не цель;
  3. легаси типизируется границами, публичные сигнатуры и точки вызова, до того как кто-либо берётся за тела функций;
  4. легаси-исключения со ignore_errors получают срок и ответственного, список модулей сокращается каждый квартал;
  5. режим dmypy ставится в локальную разработку, полный прогон с кэшем остаётся в CI как ворота;
  6. количество type ignore и Any по модулям выносится в отчёт, подавления обсуждаются на ревью, warn_unused_ignores включается после первого квартала;
  7. плагины для фреймворков подключаются сразу, до того как команда начнёт глушить их ложные срабатывания.

Итог квартала удобно снимать числами: mypy печатает сводку по видам найденных ошибок, и график их снижения работает на команду лучше уговоров.

Каждый пункт закрывает конкретный риск, проверенный чужим опытом: внедрение без плана разваливается на третьем месяце из-за шума ошибок, внедрение без постоянного процесса разваливается из-за медленных прогонов, внедрение без надзора над подавлениями тихо вырождается в декорацию.

Точка безубыточности и честный итог для большой команды

Стоимость статической проверки платится дважды: единовременно, в типизации существующего кода, и постоянно, в поддержке аннотаций при каждом изменении. Польза приходит тремя потоками: ошибки ловятся на сборке, рефакторинги перестают быть археологией, а границы модулей получают исполняемую документацию. Для проекта на триста тысяч строк и двадцать инженеров mypy безубыточен уже через пару кварталов, просто потому что каждый пойманный на сборке None экономит часы продакшен-разбора, а каскадный список точек после смены типа экономит недели.

Маленький проект живёт иначе: скрипт на две тысячи строк, пишущийся за вечер и умирающий через месяц, не окупит ни одной аннотации. Инструмент создан для кода с долгой жизнью и большим числом участников, где цена ошибки и цена чтения чужого кода выше цены писанины. Честная стратегия не в лозунгах, а в числах: включить, замерить скорость прогонов, посчитать пойманные ошибки за квартал и спросить команду, стал ли код читаемее. Mypy работает тогда, когда его воспринимают как часть процесса, а не как проверку в конце пути, и это самое честное ограничение из всех.