Опирается на правила: PY-8.1PY-8.5 из Python Style Guide → раздел 8. Современные фичи.

Важно знать

  • match/case — для разбора структур и sealed-подобных иерархий; заменяет isinstance-цепочки.
  • @dataclass(frozen=True, slots=True) — стандарт для иммутабельных команд и Value Objects.
  • StrEnum вместо строковых литералов для любого закрытого набора значений домена.
  • type Alias = ... (синтаксис 3.12) — для сложных составных типов; не TypeAlias из typing.
  • @override (3.12) — обязателен на каждом переопределённом методе; ловит рассинхронизацию с базовым классом.
  • Walrus (:=) — только когда устраняет буквальное дублирование и код становится яснее.
  • frozen=True без slots=True работает, но с slots=True экономит память и ускоряет атрибутный доступ.
  • PY-RUFF-4: семантику PY-8.* ловит ucp-py-style-review, не ruff/mypy.

Python 3.12 добавил синтаксис type, уточнил match/case (появившийся в 3.10), зафиксировал @override. Правила PY-8.* описывают, что из этого набора становится обязательным стилем, а не экзотикой.

match / case — структурный разбор

PY-8.1: match/case для разбора структур и sealed-подобных иерархий.

Длинная isinstance-цепочка сигналит об отсутствии match:

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


@dataclass(frozen=True, slots=True)
class PlaceOrder:
    order_id: UUID
    amount: Decimal


@dataclass(frozen=True, slots=True)
class CancelOrder:
    order_id: UUID
    reason: str


@dataclass(frozen=True, slots=True)
class RefundOrder:
    order_id: UUID
    amount: Decimal


type OrderCommand = PlaceOrder | CancelOrder | RefundOrder


def handle(command: OrderCommand) -> None:
    match command:
        case PlaceOrder(order_id=oid, amount=amount):
            _place(oid, amount)
        case CancelOrder(order_id=oid, reason=reason):
            _cancel(oid, reason)
        case RefundOrder(order_id=oid, amount=amount):
            _refund(oid, amount)

Деструктуризация в паттерне (order_id=oid) сразу извлекает поля — не нужны промежуточные .-обращения внутри ветки.

match читается как спецификация случаев: структура команды видна в заголовке ветки, а не зарыта в тело if.

Гвардии в паттерне

def apply_discount(order: PlaceOrder) -> Decimal:
    match order:
        case PlaceOrder(amount=amount) if amount > Decimal("10000"):
            return amount * Decimal("0.05")
        case PlaceOrder(amount=amount):
            return Decimal("0")

Guard (if amount > ...) — часть паттерна, не отдельный if внутри ветки.

@dataclass(frozen=True, slots=True) — иммутабельные носители

PY-8.2: @dataclass(frozen=True, slots=True) для команд, запросов, Value Objects.

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


@dataclass(frozen=True, slots=True)
class Money:
    amount: Decimal
    currency: str

    def add(self, other: "Money") -> "Money":
        if self.currency != other.currency:
            raise ValueError(f"currency mismatch: {self.currency} vs {other.currency}")
        return Money(amount=self.amount + other.amount, currency=self.currency)


@dataclass(frozen=True, slots=True)
class CreateOrder:
    order_id: UUID
    customer_id: UUID
    total: Money

frozen=True запрещает мутацию атрибутов после создания — объект становится hashable и безопасен для передачи между слоями.

slots=True генерирует __slots__, устраняет __dict__ на экземпляре. Для carrier-объектов, создаваемых тысячами (команды, события), это снижает потребление памяти и ускоряет доступ.

Без slots=True можно навесить произвольный атрибут через instance.__dict__frozen этого не запрещает, только блокирует присваивание существующих полей через обычный синтаксис. Совместно frozen + slots делает объект действительно неизменяемым.

StrEnum — закрытые наборы значений

PY-8.3: enum.StrEnum для любого закрытого домена вместо строковых констант.

from enum import StrEnum


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


class ProductCategory(StrEnum):
    ELECTRONICS = "ELECTRONICS"
    CLOTHING = "CLOTHING"
    FOOD = "FOOD"

StrEnum — подкласс str, значение сравнивается как строка напрямую (order.status == "PENDING" работает), но mypy и IDE знают тип. При сериализации Pydantic и FastAPI отдают строку без дополнительных value-вызовов.

Enum (не StrEnum) подходит, если значения не строки: числа, объекты, кортежи.

Строковые литералы в коде — сигнал отсутствия enum:

if order.status == "PENDING":
    ...

Такой код не проверяется mypy на опечатки, не поддерживает автодополнение, рассыпается при переименовании значения.

type Alias — именованные составные типы

PY-8.4: оператор type (синтаксис 3.12) для сложных аннотаций.

from uuid import UUID


type CustomerId = UUID
type OrderId = UUID
type ProductId = UUID

type OrderIndex = dict[OrderId, list[ProductId]]
type CustomerOrders = dict[CustomerId, list[OrderId]]

До 3.12 нужно было TypeAlias из typing с присваиванием. Теперь type Name = ... — отдельный оператор, mypy и pyright понимают его нативно.

Правило применять, когда тип встречается в нескольких сигнатурах или сам по себе достаточно сложен (dict[str, list[tuple[int, Decimal]]]), чтобы его чтение замедляло понимание сигнатуры.

@override — явное переопределение

PY-8.4: @override (3.12, модуль typing) на каждом методе, переопределяющем базовый.

from typing import override
from app.ports.order_port import OrderPort
from app.domain.order import Order
from uuid import UUID


class PostgresOrderRepository(OrderPort):
    @override
    async def save(self, order: Order) -> None:
        ...

    @override
    async def find_by_id(self, order_id: UUID) -> Order | None:
        ...

@override — контракт: mypy проверяет, что метод с таким именем и совместимой сигнатурой существует в базовом классе. Если базовый класс переименовал метод — mypy укажет на все переопределения, которые стали «висящими».

Без @override переопределение выглядит как новый метод: лишняя нагрузка при ревью, риск молча создать метод-дублёр вместо переопределения.

Walrus-оператор := — осторожно

PY-8.5: walrus только когда устраняет дублирование и читаемость не страдает.

import re

LOG_PATTERN = re.compile(r"ERROR (?P<code>\d+): (?P<msg>.+)")


def extract_error(line: str) -> str | None:
    if m := LOG_PATTERN.match(line):
        return f"[{m.group('code')}] {m.group('msg')}"
    return None

Здесь walrus оправдан: без него нужно либо два вызова match, либо промежуточная переменная m = ...; if m:.

Walrus не применяется ради краткости там, где промежуточная переменная яснее:

result := compute_heavy(order)

Если нет дублирования — обычное присваивание и явная проверка читаемее.

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

АнтипаттернПравилоЧто взамен
isinstance-цепочка вместо match/casePY-8.1match command: case PlaceOrder(...):
@dataclass без frozen=True для VO/командыPY-8.2@dataclass(frozen=True, slots=True)
Строковые константы вместо StrEnumPY-8.3class OrderStatus(StrEnum)
TypeAlias из typing вместо typePY-8.4type Alias = ... (3.12)
Переопределение метода без @overridePY-8.4добавить @override перед методом
Walrus ради краткости без устранения дублированияPY-8.5явное присваивание + отдельная проверка
@dataclass(frozen=True) без slots=True для carrier'овPY-8.2добавить slots=True

Куда дальше

  • python/type-hints.md — Protocol, X | None, Decimal и datetime aware.
  • python/expressions.md — guard clause, comprehension, EAFP/LBYL.
  • python/naming.md — StrEnum-значения и именование доменных типов.
  • /standards/backend/error-handling/ — обработка ошибок с match по типу исключения.