Зачем тратить время на проверку, которая ничего не запускает
В трёх прошлых уроках мы потратили много времени на аннотации: типы аргументов, dataclass-поля, Pydantic-схемы. Всё это подсказки — Python в runtime их игнорирует. Не означает ли это, что мы пишем «декорацию ради декорации»?
Не означает, если в проекте есть
- Читает весь ваш код, не запуская его.
- Проверяет совместимость аннотаций: если
def f(x: int), нельзя вызыватьf("hello"). - Выводит типы там, где их не написали:
x = 1→ mypy знает, чтоx: int. - Сообщает обо всех нестыковках до того, как код доехал до production.
Без type checker’а type hints — действительно просто комментарии. С type checker’ом — это сетка из защитных тестов, которая ловит целый класс багов бесплатно, без написания unit-тестов.
Самый популярный инструмент —
Установка 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_optional—def 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]
Не пишите голый # type: ignore — пишите с кодом: # type: ignore[arg-type]. Голый ignore подавляет вообще все ошибки на строке, включая будущие, и вы пропустите реальный баг.
Хороший принцип: каждый # type: ignore — это явное решение «я знаю, что делаю». На code review такие места читаются особенно внимательно.
Альтернативы mypy: pyright и ty
Mypy — не единственный type checker. На 2026 год есть три актуальных:
| Инструмент | Автор | Язык | Скорость | Особенности |
|---|---|---|---|---|
| mypy | Python core team | Python | базовая | Эталон, самая совместимая, медленнее всего |
| pyright | Microsoft | TypeScript | в 5-10x быстрее | Основа Pylance в VS Code, отличные сообщения |
| ty | Astral | Rust | в 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/
Что здесь происходит:
- Чекаут кода.
- Установка uv (через готовый action от Astral).
uv sync --group dev— установка всех зависимостей, включая mypy.- Запуск mypy на папке
src/. Если ошибки — exit code != 0, и GitHub помечает PR как failed.
Реальные проекты добавляют ещё ruff (линтер), pytest (тесты), может быть coverage. Но даже одного mypy job достаточно, чтобы предотвратить целый класс багов.
Pre-commit hook
CI ловит ошибки после того, как вы запушили — это слишком поздно. Лучше ловить до commit’а, локально. Для этого есть
В .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 без героических усилий.
Это
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 в идентификаторе и не вернёт строку вместо суммы.
Что должно остаться в голове
- Type hints без type checker’а — декорация. Тип checker превращает их в работающий контракт.
- mypy — стандарт.
uv add --group dev mypy,uv run mypy src/. strict = trueдля нового кода. Для legacy — gradual через per-module overrides.# type: ignore[code]— с кодом ошибки, не голый. Каждый ignore — явное решение.- Альтернативы: pyright (VS Code), ty (Astral, новый и быстрый). Можно выбрать любой.
- Type check на CI через GitHub Actions + pre-commit hook локально — стандартная связка.
В следующем модуле — I/O и форматы данных: как читать и писать CSV/JSON/Parquet, какие подводные камни с кодировками и эскейпингом. Там пригодится всё, что мы здесь типизировали — мы будем строить именно типизированные pipeline.