Опирается на правила: PY-6.1PY-6.4, PY-6.X1, PY-6.X2, PY-RUFF-2, PY-RUFF-3 из Python Style Guide → раздел 6. Тайп-хинты и mypy.

Важно знать

  • Все публичные сигнатуры (функции, методы, атрибуты dataclass) аннотированы; mypy --strict зелёный в CI.
  • X | None вместо Optional[X]; list[X], dict[K, V] вместо List/Dict из typing (Python 3.10+).
  • Protocol — для структурной типизации портов/интерфейсов; ABC — когда нужна реализация или иерархия с общим поведением.
  • Деньги — Decimal, никогда float; дата-время — aware datetimetzinfo), не naive.
  • Any как заглушка для mypy — нарушение PY-6.X1; разрешён только с кодом правила и обоснованием.
  • # type: ignore — только с кодом (# type: ignore[assignment]) и комментарием WHY.
  • mypy --strict ослабляется исключительно через per-module override в pyproject.toml с обоснованием (PY-RUFF-2).
  • ruff check + ruff format --check + mypy — тройной прогон на каждом CI-прогоне, fail при нарушении.

Аннотации типов в Python — не опциональный «сахар», а контракт, который mypy проверяет статически. Без --strict аннотации становятся документацией, а не гарантией: mypy молчит о неаннотированных функциях, и первый же Any-«пробел» разрушает всю цепочку выводимых типов. Именно поэтому PY-6.1 требует полного покрытия публичных сигнатур, а PY-RUFF-2--strict в CI.

Аннотации публичных сигнатур

PY-6.1: функции, методы, атрибуты dataclass — все аннотированы.

from decimal import Decimal
from uuid import UUID
from dataclasses import dataclass


@dataclass(frozen=True, slots=True)
class CreateOrder:
    customer_id: UUID
    amount: Decimal


async def create_order(command: CreateOrder) -> UUID:
    ...


def find_by_id(order_id: UUID) -> "Order | None":
    ...

mypy --strict включает в себя --disallow-untyped-defs, --disallow-incomplete-defs, --warn-return-any и ещё восемь флагов. Написать «почти аннотированную» функцию с Any-параметром — то же самое, что не написать аннотацию вовсе: вывод типов прерывается.

Синтаксис 3.10+ — Union и встроенные дженерики

PY-6.2: новый синтаксис обязателен для кода, требующего Python 3.10+.

def find(order_id: UUID) -> Order | None: ...

def batch_update(order_ids: list[UUID], status: OrderStatus) -> dict[UUID, bool]: ...

def process(items: list[Order] | None = None) -> None:
    items = items or []
    ...

Импорты Optional, List, Dict, Tuple, Union из typing заменяются встроенными аналогами:

from typing import Optional, List, Dict

def find(order_id: UUID) -> Optional[Order]: ...
items: List[Order]
cache: Dict[str, Order]
def find(order_id: UUID) -> Order | None: ...
items: list[Order]
cache: dict[str, Order]

Если проект поддерживает Python 3.9, используют from __future__ import annotations — тогда X | None работает без рантайм-ошибок и до 3.10.

Protocol для портов и структурной типизации

PY-6.3: Protocol — для описания интерфейсов, которые не требуют наследования.

from typing import Protocol
from uuid import UUID
from decimal import Decimal


class OrderRepository(Protocol):
    async def save(self, order: "Order") -> None: ...
    async def find_by_id(self, order_id: UUID) -> "Order | None": ...


class PaymentGateway(Protocol):
    async def charge(self, order_id: UUID, amount: Decimal) -> str: ...
    async def refund(self, transaction_id: str) -> None: ...

Protocol реализует структурную типизацию (duck typing): класс удовлетворяет Protocol, если у него есть нужные методы с совместимыми сигнатурами — наследоваться не нужно. Это ключевое отличие от ABC.

ABC уместен когда:

  • порт предоставляет общую реализацию (template method);
  • иерархия намеренно закрытая, и isinstance-проверки нужны в рантайме.
from abc import ABC, abstractmethod


class BaseNotifier(ABC):
    def notify(self, order_id: UUID, message: str) -> None:
        self._log(order_id, message)
        self._send(order_id, message)

    @abstractmethod
    def _send(self, order_id: UUID, message: str) -> None: ...

    def _log(self, order_id: UUID, message: str) -> None:
        ...

В гексагональной архитектуре порты — всегда Protocol; ABC — только для базовых классов с реализацией внутри домена.

Деньги — Decimal, время — aware datetime

PY-6.4: две семантически нагруженные ошибки.

float для денег

price: float = 19.99
discount: float = 0.1
total = price - price * discount

float — бинарная дробь. 19.99 * 0.1 в Python даёт 1.9999999999999998, не 2.00. Для финансовых расчётов это неприемлемо.

from decimal import Decimal

price: Decimal = Decimal("19.99")
discount: Decimal = Decimal("0.1")
total: Decimal = price - price * discount

Инициализация через строку (Decimal("19.99")), не через float (Decimal(19.99) вносит ошибку ещё до Decimal).

naive datetime

from datetime import datetime

created_at: datetime = datetime.now()

datetime.now() возвращает naive datetime — без информации о часовом поясе. При сравнении, сериализации или передаче между сервисами naive datetime приводит к молчаливым ошибкам — особенно при переходе на летнее время.

from datetime import datetime, timezone

created_at: datetime = datetime.now(tz=timezone.utc)
expires_at: datetime = datetime.now(tz=timezone.utc)

mypy с --strict не различает naive и aware datetime — это семантическая ошибка вне статического анализа. PY-6.4 закрывает этот пробел конвенцией.

Any и type: ignore — только с обоснованием

PY-6.X1: Any как способ заглушить mypy — запрещён без явного обоснования.

from typing import Any

def process(data: Any) -> Any:
    return data["result"]

Такая функция «выключает» mypy: всё, что через неё проходит, теряет тип. Каждый Any — дыра в типовой безопасности.

Допустимый случай — граница с нетипизированной библиотекой, где stubs отсутствуют:

from typing import Any

def parse_response(raw: Any) -> OrderResponse:
    return OrderResponse.model_validate(raw)

# type: ignore — аналогично: только с кодом правила и комментарием (PY-RUFF-3):

result = legacy_client.fetch()  # type: ignore[no-untyped-call]  # legacy_client не имеет stubs

Без кода и обоснования # type: ignore нарушает PY-RUFF-3 и блокируется ruff check.

Полный пример — сигнатуры агрегата и порта

from __future__ import annotations

from dataclasses import dataclass, field
from datetime import datetime, timezone
from decimal import Decimal
from enum import StrEnum
from typing import Protocol
from uuid import UUID


class OrderStatus(StrEnum):
    PENDING = "PENDING"
    CONFIRMED = "CONFIRMED"
    CANCELLED = "CANCELLED"


@dataclass(frozen=True, slots=True)
class OrderItem:
    product_id: UUID
    quantity: int
    unit_price: Decimal


@dataclass(slots=True)
class Order:
    id: UUID
    customer_id: UUID
    status: OrderStatus
    items: list[OrderItem]
    created_at: datetime = field(default_factory=lambda: datetime.now(tz=timezone.utc))
    total_amount: Decimal = field(init=False)

    def __post_init__(self) -> None:
        object.__setattr__(
            self,
            "total_amount",
            sum(i.unit_price * i.quantity for i in self.items),
        )

    def confirm(self) -> Order:
        ...

    def cancel(self, reason: str) -> Order:
        ...


class OrderRepository(Protocol):
    async def save(self, order: Order) -> None: ...
    async def find_by_id(self, order_id: UUID) -> Order | None: ...
    async def find_by_customer(
        self,
        customer_id: UUID,
        status: OrderStatus | None = None,
    ) -> list[Order]: ...

Что запрещено

АнтипаттернПравилоЧто взамен
def find(order_id) -> Optional[Order]PY-6.1, PY-6.2def find(order_id: UUID) -> Order \| None
from typing import List, Dict, OptionalPY-6.2встроенные list, dict, X \| None
price: float = 19.99 для денегPY-6.4price: Decimal = Decimal("19.99")
datetime.now() без tzPY-6.4datetime.now(tz=timezone.utc)
class OrderRepo(ABC): ... для порта без реализацииPY-6.3class OrderRepo(Protocol): ...
def process(data: Any) -> Any без обоснованияPY-6.X1конкретный тип или TypeVar
# type: ignore без кода правилаPY-6.X1, PY-RUFF-3# type: ignore[code] # justify: ...
Аннотация расходится с реальным типомPY-6.X2аннотация отражает реальный тип

Куда дальше

  • Именование — PascalCase-классы, snake_case-функции, Protocol без I-префикса.
  • Импорты — абсолютные импорты, группировка stdlib/third-party/local, запрет wildcard.
  • Современный Python — @dataclass(frozen=True, slots=True), StrEnum, type Alias = (3.12), @override.
  • Enforcement ruff + mypy — конфиг pyproject.toml, per-module overrides, # noqa с justify.
  • Hexagonal — PythonProtocol как порт в гексагональной архитектуре, структурная типизация адаптеров.