Современный Python: type hints, mypy, pydantic и pattern matching

Как типизация изменила Python: аннотации типов и статическая проверка (mypy/pyright), pydantic для валидации данных, dataclasses и structural pattern matching — дисциплина без потери гибкости

Главный сдвиг современного Python — не новая возможность языка, а культура типизации. Аннотации, mypy с pyright и pydantic превратили «динамический скриптовый язык» в среду, где большие кодовые базы поддаются рефакторингу.

Вопрос, который обычно решают на вкус: сколько этой дисциплины нужно и что она реально даёт. Я попробовал ответить счётом. Один модуль с правилом расчёта заказа, пять подсунутых дефектов и пять способов их искать: mypy, mypy в строгом режиме, pyright, валидация на границе через pydantic и просто выполнение кода. Результат — целые «поймал / не поймал», без оттенков и без секунд.

digital-cookbook/python/typing-and-data

Ретрофутуристичная схема-блюпринт: одна дорога проходит через четверо последовательных ворот, подписанных «анализатор», «строгий режим», «граница», «выполнение»; под каждыми воротами табличка с предметом проверки — «код», «код, который иначе не проверить», «данные», «правило предметной области». По дороге идут четыре повозки, и каждая остановлена опущенным шлагбаумом у своей заставы: «неверный тип» у первой, «функция без аннотаций» у второй, «строка вместо булева» у третьей, «итог ушёл в минус» у четвёртой. За четвёртыми воротами дорога пуста, там стоит только указатель «в продакшен». Внизу табличка «четыре рубежа, ни один не заменяет соседний»

В статье

Матрица: кто что ловит

состояние кода mypy --strict pyright pydantic выполнение
none — контроль зел. зел. зел. зел. зел.
wrong-return поймал поймал поймал нет поймал
untyped-body нет поймал поймал нет нет
optional-unchecked поймал поймал поймал нет нет
external-data нет нет нет поймал нет
runtime-only нет нет нет нет поймал

Строка none — не для симметрии. Без неё «поймал» ничего не значит: инструмент, который ругается всегда, ловит любой дефект и ни одного. Контроль зелёный у всех пяти, и это первое, что проверяет разбор стенда.

Читать таблицу стоит как карту слепых зон, а не как турнирную таблицу. Ни одно плечо не покрывает всё, и ни одно не лишнее — у каждого есть строка, где оно единственное или почти единственное.

Одна техническая деталь, без которой матрица была бы невозможна: дефект — это правка исходника, а не флаг в коде. Статические анализаторы читают файл, а не выполняют его, поэтому ветка if os.environ["DEFECT"] == ... не переключила бы состояние — mypy увидел бы сразу все варианты и пожаловался на все. Стенд применяет правку к копии исходника и сверяет хеш файлов до и после прогона: все пять инструментов обязаны видеть побайтово одно и то же.

Одинаковое «поймал» с разными причинами

Строка untyped-body: функция без аннотаций, внутри которой складывают строку с числом.

def describe(order):
    count = len(order.lines)
    return "строк: " + count

Обычный mypy молчит. Не потому, что не заметил, а потому, что тела нетипизированных функций он по умолчанию не проверяет вовсе. Ошибка внутри такой функции для него невидима.

Строгий режим ловит, и полный вывод показывает, что именно:

$ grep '"untyped-body"' fixtures/typing.txt | jq -r '.arms["mypy-strict"].output[]'
orders.py:47: error: Function is missing a type annotation  [no-untyped-def]
orders.py:54: error: Unsupported operand types for + ("str" and "int")  [operator]
Found 2 errors in 1 file (checked 1 source file)
$ grep '"untyped-body"' fixtures/typing.txt | jq -r '.arms["pyright"].output[]'
/work/orders.py
  /work/orders.py:54:12 - error: Operator "+" not supported for types "Literal['строк: ']" and "int" (reportOperatorIssue)
1 error, 0 warnings, 0 informations

--strict включает --check-untyped-defs, поэтому тело функции без аннотаций строгий mypy проверяет и находит ту же ошибку сложения, что и pyright. Сверх этого он сообщает, что у функции нет аннотации, и этого не допускает. pyright тела проверяет и без строгого режима, а об отсутствии аннотации молчит.

Практический вывод: обе ошибки строгий mypy выдаёт сразу, но первая, no-untyped-def, — требование к самому коду. Разница проявится в тот день, когда вы включите строгость на существующей кодовой базе: к каждой реальной ошибке строгий mypy добавит требование аннотировать каждую нетипизированную функцию, и настоящие находки утонут в этом потоке. pyright в умолчаниях покажет только ошибки в том, что уже написано.

Отсюда и практика внедрения: для первого прохода по чужому коду удобнее pyright или mypy с одним --check-untyped-defs, без остального --strict; строгий mypy — для удержания планки в своём.

Что видит только статический анализ

Строка optional-unchecked. Скидка объявлена как «может отсутствовать», и код вычитает её без проверки:

@dataclass(frozen=True)
class Order:
    ...
    discount: Decimal | None


def total(order: Order) -> Decimal:
    value = subtotal(order)
    value -= order.discount        # а если None?
    return max(ZERO, value)

Оба анализатора говорят прямо:

$ python scripts/analyze-typing.py
# (выдержка для optional-unchecked: пути и номера строк перед «error:» убраны)
mypy     error: Unsupported operand types for - ("Decimal" and "None")  [operator]
pyright  error: Operator "-=" not supported for types "Decimal" and "Decimal | None"

А выполнение — молчит. В данных стенда скидка задана у всех заказов, поэтому путь с None просто не проходится. Тип допускает, данные пока не содержат.

Это и есть главное, что даёт статическая проверка и чего не даст ни один тест: она смотрит на все пути, допустимые заявленными типами, а не на те, по которым прошли данные. Тест ловит то, что случилось; анализатор — то, что может случиться, если объявления верны. Оговорка в этом «если»: там, где тип Any, где импорт игнорируется как нетипизированный, где объект собирается динамически, анализатору нечего сопоставлять — и путь для него исчезает так же надёжно, как для непройденного теста.

Тот же эффект в строке untyped-body с другой стороны: describe в сценарии выполнения не вызывается вовсе. Функция для журнала, до неё «руки не дошли» — а анализатор видит её так же внимательно, как остальной код.

Что не видит статический анализ вообще

Строка external-data. В коде не меняется ни строчки — портятся входные данные:

{"id": 1, "express": "false", "discount": "10.00", "lines": [...]}

Поле объявлено bool, аннотация верна, ошибки в коде нет. Все три анализатора зелёные, и они правы: они проверяют код, а не данные.

Выполнение тоже молчит, и это опаснее молчания анализаторов. Непустая строка "false" истинна, надбавка за срочность применяется там, где не должна, итог получается больше правильного — но положительный и правдоподобный.

Сценарий стенда проверяет инварианты: итог не отрицателен, купон крупнее корзины гасит её в ноль. Такие проверки нарушенное правило ловят, а вот правдоподобно неверное число — нет. Это стоит держать в голове, когда кажется, что «у нас же есть assert’ы».

Граница как отдельный рубеж

Единственное, что ловит external-data, — разбор входных данных через pydantic:

$ python -c 'import json, boundary; [boundary.OrderIn.model_validate(i) for i in json.load(open("data.json"))]' \
    2>&1 | grep ValidationError
pydantic_core._pydantic_core.ValidationError: 1 validation error for OrderIn

Тот же разбор стоит на входе у FastAPI — модель запроса и есть граница, — так что в web-сервисе этот рубеж обычно уже включён; подробнее в статье про фреймворки.

Здесь важна одна настройка, и она же — единственное, что я хочу сказать про pydantic содержательно.

class OrderIn(BaseModel):
    model_config = ConfigDict(strict=True)

    id: int
    customer: str
    lines: list[LineIn]
    express: bool
    discount: Decimal | None = Field(default=None, strict=False)

По умолчанию pydantic приводит типы. Строку "false" он с удовольствием превратит в False, и дефект прошёл бы мимо. Приведение уместно, когда данные пришли из HTML-формы, где всё строка. На границе между службами оно означает другое: отправитель может прислать что угодно похожее, а получатель молча догадается.

Строгость при этом не сплошная, и это не компромисс ради удобства:

  • строго — int, str, bool: у JSON есть ровно эти типы, и подмена одного другим означает, что отправитель прислал не то;
  • нестрого — Decimal: десятичного типа в JSON нет. Деньги передают строкой именно потому, что число с плавающей точкой их портит.

Первая версия стенда включала строгость сплошь и отвергала "240.00" — то есть браковала корректные данные. Контроль поймал это сразу: на исправном наборе плечо обязано быть зелёным, а оно краснело. Хороший пример того, зачем в любом сравнении нужна строка «всё в порядке».

Про производительность pydantic v2 я здесь ничего не говорю. Ядро у него на Rust, это факт из документации; насколько это важно в вашей задаче — вопрос замера, которого на этом стенде нет.

Чего не видит никто

Строка runtime-only. Купон больше корзины, итог уходит в минус:

def total(order: Order) -> Decimal:
    value = subtotal(order)
    if order.express:
        value += EXPRESS_SURCHARGE
    if order.discount is not None:
        value -= order.discount
    return value          # было: max(ZERO, value)

Типы безупречны: Decimal минус Decimal даёт Decimal. Данные безупречны: скидка — корректное десятичное число. Оба анализатора и граница молчат.

Ловит только выполнение, и ловит потому, что кто-то заранее сформулировал правило:

$ python scenario.py
НАРУШЕН ИНВАРИАНТ: заказ 3: итог -490.00 отрицателен —
купон крупнее корзины обязан гасить её в ноль

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

Никакая система типов этого не поймает: «итог не бывает отрицательным» — утверждение о предметной области, а не о типах. Его можно было бы частично поднять в типы — завести NonNegativeMoney с проверкой в конструкторе, — и тогда ошибка всплыла бы раньше. Но проверка всё равно осталась бы вашей: типы её переносят, а не заменяют.

Аннотации как контракт

Всё вышесказанное держится на одном допущении: аннотации верны. Анализатор не проверяет, соответствует ли -> Decimal замыслу, — он проверяет, соответствует ли ему код. Неверная аннотация превращает проверку в подтверждение ошибки.

Отсюда три практики, которые окупаются больше остальных.

Аннотируйте границы модулей, а не каждую переменную. Сигнатуры публичных функций и структуры данных — там, где ошибка стоит дороже всего. Локальные переменные анализатор выведет сам.

X | None — это обязательство, а не украшение. Строка optional-unchecked в матрице появляется ровно потому, что тип честно говорит о возможном отсутствии. Если бы скидка была объявлена просто Decimal, анализаторы промолчали бы — и ошибка ждала бы первого заказа без купона.

Отдельный случай — данные, прочитанные из файла. Типы, выведенные по конкретной выгрузке, завтра окажутся другими, и объявленная схема защищает ровно там, где выведенная подводит; про это — в статье про обработку данныхготовится, с 24 октября.

Структурная типизация вместо наследования. Protocol описывает, что объект умеет, не требуя от него родословной, — это ближе к тому, как код на Python пишут на самом деле:

class SupportsTotal(Protocol):
    def total(self) -> Decimal: ...

Отдельно про match/case: разбор структур с проверкой типов работает и сужает типы в ветвях, но переусложнять им не стоит. Там, где хватает if, match добавляет строк, а не ясности.

Что из этого следует

Четыре рубежа, и они не заменяют друг друга. Аннотации с анализатором ловят несоответствие кода объявлениям. Строгий режим дополнительно требует, чтобы проверяемым был весь код. Валидация на границе ловит данные. Проверки инвариантов ловят нарушенное правило предметной области. В матрице у каждого своя строка, где он единственный.

Обычный mypy пропускает тела нетипизированных функций. Если вы вводите типизацию постепенно, значительная часть кода не проверяется вовсе, а отчёт при этом зелёный. Включайте хотя бы --check-untyped-defs — или возьмите pyright: в умолчаниях он тела нетипизированных функций проверяет, и в матрице это его отличие от mypy. Именно в умолчаниях: строгость pyright настраивается и режимом, и правилами по отдельности, так что конфигурация проекта способна это отличие снять.

Статический анализ смотрит на все пути, тесты — на пройденные. Дефект optional-unchecked виден анализаторам и невидим выполнению просто потому, что в данных не нашлось заказа без скидки. Это не аргумент против тестов, это описание разделения труда.

Строгость границы выбирается по контракту, а не по привычке. Приведение типов по умолчанию превращает проверку в вежливую догадку — и для данных из недоверенного источника это значит, что рубежа нет. Но правило не одно на всю модель: pydantic держит строгость и на модели, и на отдельном поле, и в стенде это понадобилось буквально — строго там, где тип в JSON однозначен, послабление для Decimal, которого в JSON нет вовсе. Решает не режим, а то, чему вы готовы верить на входе.

Инварианты предметной области придётся писать руками. «Итог не отрицателен» не следует ни из типов, ни из схемы данных. Ни один инструмент этой таблицы его не выведет.

Границы всего сказанного. Дефектов пять, и подобраны они мной: набор не претендует на полноту. Версии зафиксированы — Python 3.14.7, mypy 2.3.1, pyright 1.1.413, pydantic 2.13.4, — а умолчания анализаторов меняются от релиза к релизу, и часть строк матрицы держится именно на умолчаниях. Про скорость проверок и производительность pydantic здесь нет ничего: этот стенд не меряет время вовсе, и все его величины — целые «да/нет».

Как это устроено в других языках

Современные возможности C#Скоро — противоположный полюс: типы обязательны, и вопрос стоит не «внедрять ли», а «насколько строго»; nullable reference types там решают ровно ту задачу, что здесь. Интерфейсы в Go — структурная типизация, встроенная в язык: то, чем стал для Python, у Go было с самого начала. Обработка ошибок в разных языках — про то, как система типов переносит часть проверок из выполнения в компиляцию.

Документация и первоисточники

Обсуждение в Telegram

Присоединиться →

Комментарии