Learning Platform
Урок 06.04 · 22 мин
Начальный
mypyType checkingCIStatic analysispyright
mypy на своей машине: конфигурация strict и постепенное введение Production DE workflow: uv + ruff + mypy + pytest + caching

Зачем тратить время на проверку, которая ничего не запускает

В трёх прошлых уроках мы потратили много времени на аннотации: типы аргументов, dataclass-поля, Pydantic-схемы. Всё это подсказки — Python в runtime их игнорирует. Не означает ли это, что мы пишем «декорацию ради декорации»?

Не означает, если в проекте есть

type checker
. Это инструмент, который:

  1. Читает весь ваш код, не запуская его.
  2. Проверяет совместимость аннотаций: если def f(x: int), нельзя вызывать f("hello").
  3. Выводит типы там, где их не написали: x = 1 → mypy знает, что x: int.
  4. Сообщает обо всех нестыковках до того, как код доехал до production.

Без type checker’а type hints — действительно просто комментарии. С type checker’ом — это сетка из защитных тестов, которая ловит целый класс багов бесплатно, без написания unit-тестов.

Самый популярный инструмент —

mypy
. На нём и сосредоточимся.

Установка mypy

uv add --group dev mypy

--group dev означает: добавить в группу зависимостей для разработки. Они нужны только разработчикам, не в production-сборке (см. модуль 2 про uv).

После установки:

uv run mypy --version
# mypy 1.x.y (compiled: yes)

Первый запуск

Создадим простой файл src/etl.py:

def add_one(x: int) -> int:
    return x + 1

def process(items: list[str]) -> int:
    return sum(items)   # БАГ: sum от строк не работает

Запускаем mypy:

uv run mypy src/

Вывод:

src/etl.py:5: error: No overload variant of "sum" matches argument type "list[str]"  [call-overload]
Found 1 error in 1 file (checked 1 source file)

Mypy поймал, что мы суммируем список строк. Без запуска кода. Без тестов. Просто прочитав аннотации.

Этот пример простой, но реально в DE-коде mypy ловит:

  • Передачу None туда, где ждут не-None (после рефакторинга).
  • Доступ к атрибуту, которого нет (опечатки, переименования).
  • Возврат не того типа, что обещано в сигнатуре.
  • Неправильное использование generic-типов (например, dict.get без проверки на None).
  • Несовместимость dataclass / Pydantic-моделей с тем, как их используют.

Конфигурация в pyproject.toml

Все настройки mypy лежат в одной секции:

[tool.mypy]
python_version = "3.13"
strict = true
ignore_missing_imports = true
exclude = ["build/", "dist/"]

Разбираем по строкам:

  • python_version = "3.13" — какой синтаксис типов проверять. Без этого mypy может не понимать новый синтаксис вроде int | None.
  • strict = true — включить все проверки сразу. Главная опция, см. ниже.
  • ignore_missing_imports = true — не падать, если у сторонней библиотеки нет аннотаций (pandas, polars, etc.). Иначе будете тонуть в ошибках про чужой код.
  • exclude — папки, которые не проверяем.

strict mode

strict = true включает пачку проверок одним флагом. Самые важные:

  • disallow_untyped_defs — функции без аннотаций запрещены.
  • disallow_untyped_calls — нельзя вызывать функцию без аннотаций.
  • no_implicit_optionaldef f(x: int = None) запрещено (раньше Python считал это int | None).
  • warn_return_any — функция, возвращающая Any, выдаёт предупреждение.
  • warn_unused_ignores# type: ignore без необходимости — ошибка.
  • check_untyped_defs — даже неаннотированные функции анализируются на ошибки внутри.

Без strict mode mypy позволяет «постепенно» типизировать код. На greenfield-проекте это плохо: вы начнёте писать «и так и так», и через полгода окажется, что половина кода без типов. Поэтому правило для нового кода: сразу strict, без послаблений.

Чтение сообщений mypy

Mypy выдаёт два уровня:

  • error — что-то не так с типами, нужно чинить.
  • note — пояснение к error (где определена функция, какой тип ожидался).

Типичная ошибка:

src/parsers.py:42: error: Argument "user_id" to "fetch_repos" has incompatible type "str"; expected "int"  [arg-type]
src/parsers.py:38: note: "fetch_repos" defined here

Расшифровка:

  • Файл и строка: src/parsers.py:42.
  • Что не так: «аргумент user_id имеет тип str, а ожидался int».
  • Код ошибки: [arg-type] — категория проблемы.
  • Подсказка-note: где функция объявлена.

Главное — читать ошибки буквально. Mypy редко ошибается. Если он говорит «ожидался int, получен str» — значит вы где-то прокидываете str в int. Часто это где-то выше по стеку, и нужно проследить откуда пришло значение.

Что чинить, а что игнорить

Стратегия — 90% ошибок чините правильно, ~10% действительно нужно подавить.

Чинить правильно — добавлять корректные аннотации:

# было:
def parse(s):              # mypy: missing annotations
    return s.upper()

# стало:
def parse(s: str) -> str:
    return s.upper()
# было:
result = config.get("port")        # type: str | None
port: int = result                  # mypy: incompatible

# стало:
result = config.get("port")
port: int = int(result) if result else 5432

Игнорить намеренно — через # type: ignore[code]:

# 1. Стороннняя библиотека без типов
import some_legacy_lib   # type: ignore[import-untyped]

# 2. Динамическое присваивание, которое mypy не сможет понять
setattr(obj, "x", value)   # type: ignore[attr-defined]

# 3. Frozen dataclass + object.__setattr__ (мы видели в уроке 02)
object.__setattr__(self, "computed", ...)   # type: ignore[misc]
WARNING

Не пишите голый # type: ignore — пишите с кодом: # type: ignore[arg-type]. Голый ignore подавляет вообще все ошибки на строке, включая будущие, и вы пропустите реальный баг.

Хороший принцип: каждый # type: ignore — это явное решение «я знаю, что делаю». На code review такие места читаются особенно внимательно.

Альтернативы mypy: pyright и ty

Mypy — не единственный type checker. На 2026 год есть три актуальных:

ИнструментАвторЯзыкСкоростьОсобенности
mypyPython core teamPythonбазоваяЭталон, самая совместимая, медленнее всего
pyrightMicrosoftTypeScriptв 5-10x быстрееОснова Pylance в VS Code, отличные сообщения
tyAstralRustв 50-100x быстрееНовый, ещё созревает (preview в 2025)

pyright — то, что встроено в VS Code через расширение Pylance. Если вы пишете в VS Code и видите подчёркивания типов прямо в редакторе — это pyright. По возможностям он ~эквивалентен mypy, иногда лучше.

ty — новый инструмент от тех же ребят, что сделали uv и ruff. Написан на Rust, очень быстрый. В 2025 был в alpha, в 2026 потихоньку выходит. Скорее всего вытеснит mypy в новых проектах через пару лет — как ruff вытеснил flake8/pylint.

Для junior’а правило: выберите один и придерживайтесь. Запускать одновременно mypy и pyright обычно не нужно — они дублируют работу. На большинстве проектов в 2026 году вы увидите либо mypy в CI + pyright в IDE, либо pyright везде.

Интеграция в CI

Type checking на CI — это то, что отделяет «у нас красивые типы» от «у нас типы реально работают». На каждый Pull Request запускается mypy, и PR с типовыми ошибками не мёрджится. Это даёт типам силу контракта.

Минимальный GitHub Actions job:

# .github/workflows/typecheck.yml
name: typecheck

on:
  pull_request:
  push:
    branches: [main]

jobs:
  mypy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: astral-sh/setup-uv@v6
        with:
          python-version: "3.13"
      - run: uv sync --group dev
      - run: uv run mypy src/

Что здесь происходит:

  1. Чекаут кода.
  2. Установка uv (через готовый action от Astral).
  3. uv sync --group dev — установка всех зависимостей, включая mypy.
  4. Запуск mypy на папке src/. Если ошибки — exit code != 0, и GitHub помечает PR как failed.

Реальные проекты добавляют ещё ruff (линтер), pytest (тесты), может быть coverage. Но даже одного mypy job достаточно, чтобы предотвратить целый класс багов.

Pre-commit hook

CI ловит ошибки после того, как вы запушили — это слишком поздно. Лучше ловить до commit’а, локально. Для этого есть

pre-commit
(мы про него говорили в уроке 03 модуля 2).

В .pre-commit-config.yaml:

repos:
  - repo: https://github.com/pre-commit/mirrors-mypy
    rev: v1.10.0
    hooks:
      - id: mypy
        additional_dependencies:
          - pydantic
          - types-requests

Теперь при git commit сначала прогоняется mypy. Если ошибки — commit не пройдёт, пока не починим. Это спасает от «закоммитил, отправил PR, увидел CI red, чинить, force-push» — лучше починить локально за 5 секунд.

additional_dependencies нужны, потому что pre-commit запускает mypy в своём изолированном окружении и не видит ваших проектных зависимостей. Поэтому туда явно добавляют типы для библиотек, которыми пользуется ваш код (pydantic, types-requests для запросов, и т.д.).

Прагматика: как внедрять mypy на существующий проект

Greenfield (новый проект с нуля) — сразу strict = true, никаких послаблений.

Brownfield (есть legacy без типов) — постепенно, иначе утонете в тысячах ошибок и забросите. Стратегия:

[tool.mypy]
python_version = "3.13"
ignore_missing_imports = true
# strict не включаем!

# но для новых модулей — strict per-module
[[tool.mypy.overrides]]
module = ["myproject.new_feature.*"]
disallow_untyped_defs = true
strict_equality = true

Идея: на уровне всего проекта — lax (всё пропускается), но новые модули должны быть типизированы. Постепенно конвертируем старые модули — переносим из «непокрытых» в strict-секцию. Через год весь проект становится strict без героических усилий.

Это

gradual typing
в действии — основной paradigm Python-типов с самого PEP 484.

DE-кейс: типизация ETL pipeline

Поглядим, как mypy ловит типичные DE-ошибки. Файл etl_step.py:

from dataclasses import dataclass

@dataclass(frozen=True, slots=True)
class Record:
    id: int
    name: str
    amount: int


def parse_amount(raw: str) -> int:
    return int(raw)


def transform(rows: list[dict[str, str]]) -> list[Record]:
    result = []
    for row in rows:
        # БАГ 1: ожидали int, но get() даёт str | None
        record = Record(
            id=row.get("id"),
            name=row["name"],
            amount=parse_amount(row["amount"]),
        )
        result.append(record)
    return result


def total(records: list[Record]) -> int:
    return sum(r.name for r in records)   # БАГ 2: суммируем строки

Запуск mypy:

etl_step.py:18: error: Argument "id" to "Record" has incompatible type "str | None"; expected "int"  [arg-type]
etl_step.py:25: error: No overload variant of "sum" matches argument type "Generator[str, None, None]"  [call-overload]
Found 2 errors in 1 file

Без тестов, без запуска кода, без отладки в production. Эти ошибки нашлись за секунду. Первая — типичный баг с dict.get (возвращает Optional, забыли проверить). Вторая — опечатка r.name вместо r.amount.

Исправляем:

def transform(rows: list[dict[str, str]]) -> list[Record]:
    result = []
    for row in rows:
        id_str = row.get("id")
        if id_str is None:
            continue
        record = Record(
            id=int(id_str),
            name=row["name"],
            amount=parse_amount(row["amount"]),
        )
        result.append(record)
    return result


def total(records: list[Record]) -> int:
    return sum(r.amount for r in records)

Mypy теперь молчит. И мы знаем, что ETL не упадёт на None в идентификаторе и не вернёт строку вместо суммы.

Что должно остаться в голове

  1. Type hints без type checker’а — декорация. Тип checker превращает их в работающий контракт.
  2. mypy — стандарт. uv add --group dev mypy, uv run mypy src/.
  3. strict = true для нового кода. Для legacy — gradual через per-module overrides.
  4. # type: ignore[code] — с кодом ошибки, не голый. Каждый ignore — явное решение.
  5. Альтернативы: pyright (VS Code), ty (Astral, новый и быстрый). Можно выбрать любой.
  6. Type check на CI через GitHub Actions + pre-commit hook локально — стандартная связка.

В следующем модуле — I/O и форматы данных: как читать и писать CSV/JSON/Parquet, какие подводные камни с кодировками и эскейпингом. Там пригодится всё, что мы здесь типизировали — мы будем строить именно типизированные pipeline.

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 4