Опирается на правила:
R-VLD-CC-1…R-VLD-CC-5иR-VLD-CC-X1…R-VLD-CC-X3из Validation Style Guide → раздел 3. Custom constraints.
Важно знать
- Custom constraint в Python — это
Annotated[T, AfterValidator(fn)]или reusable@field_validatorна базовом типе. Не inline-лямбда в DTO.- Пишем custom, только когда формат встречается на 3+ полях или несёт доменное имя (
RussianPhone,VatNumber,BicCode).- Функция-валидатор вызывается только для не-None значений. Для optional-поля:
RussianPhone | None = None. Null-логику в валидатор не добавляем — composability сломается.- Валидатор — stateless чистая функция от значения. Без глобального состояния, без singleton-зависимостей с mutable state.
- Расположение —
backend/validation/types.py. Не в файле DTO, неinlineопределение.- Имя по доменному термину:
RussianPhone,VatNumber. НеValidPhone,CheckVat,IsPhone.
Custom constraint — это способ выразить доменное правило на уровне типа. Когда формат «телефон +7XXXXXXXXXX» появляется на пятом поле в трёх разных DTO — не надо копировать regex: достаточно Annotated[str, AfterValidator(_russian_phone)] под именем RussianPhone. Тот же принцип, что Value Object: часто встречающееся правило превращается в именованную абстракцию. Раскрытие раздела 3 гайда.
Когда вводим
Триггер — формат встречается часто или имеет доменное имя:
- Один и тот же
Field(pattern=...)появляется в 3+ полях. - Формат имеет общепринятое имя: российский телефон, ИНН, BIC, номер карты Луна.
- Валидация сложнее regex: контрольная сумма ИНН, алгоритм Луна.
Когда не нужен:
- Формат уникальный для одного поля (
Field(pattern=r"^CTR-\d{4}-\d{8}$")остаётся на месте). - Стандартный тип Pydantic покрывает (
EmailStrвместоValidEmail). - Правило в одну строку и не несёт доменного смысла.
Структура: Annotated-тип с AfterValidator
R-VLD-CC-1: переиспользуемый тип — Annotated alias в общем модуле.
# backend/validation/types.py
import re
from typing import Annotated
from pydantic import AfterValidator
_RU_PHONE = re.compile(r"^\+7\d{10}$")
def _russian_phone(v: str) -> str:
if not _RU_PHONE.match(v):
raise ValueError("Номер должен быть в формате +7XXXXXXXXXX")
return v
RussianPhone = Annotated[str, AfterValidator(_russian_phone)]
Использование тривиально:
# backend/customer/api/request.py
from backend.validation.types import RussianPhone
from pydantic import BaseModel, EmailStr
class CreateCustomerRequest(BaseModel):
first_name: str
phone: RussianPhone
alt_phone: RussianPhone | None = None
email: EmailStr
RussianPhone — это str с дополнительным AfterValidator. Pydantic сначала проверит, что значение — строка, потом вызовет _russian_phone. alt_phone: RussianPhone | None = None — optional, при None валидатор не вызывается.
Более сложный пример: контрольная сумма ИНН
# backend/validation/types.py
def _vat_number(v: str) -> str:
if not v.isdigit():
raise ValueError("ИНН должен состоять из цифр")
if len(v) not in (10, 12):
raise ValueError("ИНН должен содержать 10 или 12 цифр")
if not _check_inn_checksum(v):
raise ValueError("Некорректный ИНН: неверная контрольная сумма")
return v
def _check_inn_checksum(inn: str) -> bool:
weights_10 = [2, 4, 10, 3, 5, 9, 4, 6, 8]
weights_12 = [7, 2, 4, 10, 3, 5, 9, 4, 6, 8]
if len(inn) == 10:
return int(inn[9]) == sum(w * int(d) for w, d in zip(weights_10, inn)) % 11 % 10
n11 = sum(w * int(d) for w, d in zip(weights_12, inn)) % 11 % 10
n12 = sum(w * int(d) for w, d in zip([3] + weights_12, inn)) % 11 % 10
return int(inn[10]) == n11 and int(inn[11]) == n12
VatNumber = Annotated[str, AfterValidator(_vat_number)]
class CreateOrganizationRequest(BaseModel):
name: str
inn: VatNumber
kpp: str | None = None
Валидатор на не-None значении
R-VLD-CC-4: AfterValidator вызывается Pydantic только после успешного парсинга типа. Для RussianPhone (Annotated[str, ...]) — только если значение является строкой, то есть не None. Логику if v is None: return v в валидатор добавлять не нужно — это усложняет код и вводит в заблуждение.
Для optional-поля весь null-путь обрабатывается через тип:
phone: RussianPhone | None = None
Pydantic не вызовет _russian_phone при phone=None.
Валидатор — stateless
R-VLD-CC-5: функция _russian_phone — pure function от значения. Никакого глобального mutable state, никаких HTTP-вызовов внутри.
# ХОРОШО
_RU_PHONE = re.compile(r"^\+7\d{10}$")
def _russian_phone(v: str) -> str:
if not _RU_PHONE.match(v):
raise ValueError("Номер должен быть в формате +7XXXXXXXXXX")
return v
# ПЛОХО — state и I/O в валидаторе
_blacklist_cache = []
def _russian_phone(v: str) -> str:
if v in _blacklist_cache:
raise ValueError("Номер заблокирован")
return v
Если правило требует обращения к внешней системе (проверка ИНН в реестре ФНС) — это бизнес-проверка, не валидация формата. Место — в Handler, не в Pydantic. См. python/where-to-validate.
Расположение и нэйминг
R-VLD-CC-2: все custom-типы — в backend/validation/types.py. Не в файле DTO, не в __init__.py пакета.
backend/
validation/
types.py # RussianPhone, VatNumber, BicCode, ...
__init__.py
customer/
api/
request.py # импортирует из validation/types
R-VLD-CC-3: имя типа — доменный существительное, без префиксов.
# ХОРОШО
RussianPhone = Annotated[str, AfterValidator(_russian_phone)]
VatNumber = Annotated[str, AfterValidator(_vat_number)]
BicCode = Annotated[str, AfterValidator(_bic_code)]
# ПЛОХО
ValidPhone = ... # что значит «valid»?
CheckVat = ... # глагол — это команда
IsPhoneValid = ... # предикат, не тип
Inline-валидатор в DTO — нет
R-VLD-CC-X2: соблазн определить AfterValidator прямо в DTO-файле.
# ПЛОХО — логика в файле DTO
class CreateCustomerRequest(BaseModel):
phone: Annotated[str, AfterValidator(
lambda v: v if re.match(r"^\+7\d{10}$", v) else (_ for _ in ()).throw(ValueError("..."))
)]
Что не так:
- Не переиспользуется.
CreateEmployeeRequestс тем же форматом — копипаст. grep RussianPhoneне найдёт — нет имени.- Нечитаемо. Lambda с хаком вместо именованной функции.
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
if v is None: return v внутри AfterValidator | R-VLD-CC-4 | Optional — через тип T \| None; валидатор работает только на не-None |
Inline AfterValidator(lambda ...) в файле DTO | R-VLD-CC-X2 | Annotated-тип в backend/validation/types.py |
@model_validator с невыносимой логикой вместо типа | R-VLD-CC-X3 | Переиспользуемый Annotated-тип |
| HTTP-запрос или mutable state в функции-валидаторе | R-VLD-CC-5 | Pure-функция; бизнес-проверки — в Handler |
Имя с префиксом Valid/Check/Is | R-VLD-CC-3 | Имя по домену: RussianPhone, VatNumber |
Куда дальше
- Validation → раздел 3. Custom constraints — нормативные формулировки
R-VLD-CC-*. - python/standard-constraints — что есть из коробки Pydantic, до custom.
- python/cross-field-validation —
@model_validatorдля правил между полями. - python/messages-and-i18n — тексты
ValueErrorна русском. - python/openapi-generated-dto — custom-тип в code-first контракте.