Валидаторы, сериализация, сложные типы данных
Pydantic — это не только валидация типов. В этой теме вы изучите кастомные валидаторы, сериализацию, сложные типы данных и лучшие практики работы с Pydantic v2.
Валидаторы позволяют проверять и преобразовывать значения полей:
from pydantic import BaseModel, field_validator, EmailStr
from datetime import datetime
class UserCreate(BaseModel):
username: str
email: EmailStr
password: str
birth_date: datetime | None = None
@field_validator('username')
@classmethod
def validate_username(cls, v: str) -> str:
if not v.isalnum():
raise ValueError('Username must be alphanumeric')
if len(v) < 3:
raise ValueError('Username must be at least 3 characters')
return v.lower() # Преобразуем к нижнему регистру
@field_validator('password')
@classmethod
def validate_password(cls, v: str) -> str:
if len(v) < 8:
raise ValueError('Password must be at least 8 characters')
if not any(c.isupper() for c in v):
raise ValueError('Password must contain uppercase letter')
if not any(c.isdigit() for c in v):
raise ValueError('Password must contain digit')
return vclass UserCreate(BaseModel):
password: str
password_confirm: str
@field_validator('password_confirm')
@classmethod
def passwords_match(cls, v: str, info) -> str:
if 'password' in info.data and v != info.data['password']:
raise ValueError('Passwords do not match')
return vinfo.data содержит уже валидированные поля.
Для валидации, затрагивающей несколько полей:
from pydantic import BaseModel, model_validator, Field
class Event(BaseModel):
title: str
start_date: datetime
end_date: datetime
@model_validator(mode='after')
def check_dates(self) -> 'Event':
if self.end_date < self.start_date:
raise ValueError('end_date must be after start_date')
return selfmode='after' — валидация после создания модели.
from pydantic import BaseModel, computed_field
class Product(BaseModel):
name: str
price: float
quantity: int
@computed_field
@property
def total_value(self) -> float:
return self.price * self.quantity
@computed_field
@property
def in_stock(self) -> bool:
return self.quantity > 0Пример ответа:
{
"name": "Laptop",
"price": 999.99,
"quantity": 5,
"total_value": 4999.95,
"in_stock": true
}class User(BaseModel):
id: int
username: str
password: str
created_at: datetime
user = User(id=1, username='john', password='secret', created_at=datetime.now())
# В словарь
data = user.model_dump()
# В JSON-строку
json_str = user.model_dump_json()
# Исключить поля
data = user.model_dump(exclude={'password'})
# Исключить None
data = user.model_dump(exclude_none=True)
# Только определённые поля
data = user.model_dump(include={'id', 'username'})from pydantic import BaseModel, ConfigDict
from datetime import datetime
class Event(BaseModel):
model_config = ConfigDict(
json_schema_extra={
'example': {
'title': 'Conference',
'start_date': '2024-01-01T09:00:00'
}
}
)
title: str
start_date: datetimefrom typing import Union
class Response(BaseModel):
status: str
data: Union[str, int, dict] # Может быть строкой, числом или словарёмВ Python 3.10+:
class Response(BaseModel):
status: str
data: str | int | dictfrom typing import Literal
class Post(BaseModel):
title: str
status: Literal['draft', 'published', 'archived']
priority: Literal[1, 2, 3, 4, 5] = 3from typing import Generic, TypeVar
from pydantic import BaseModel
T = TypeVar('T')
class APIResponse(BaseModel, Generic[T]):
success: bool
data: T
message: str | None = None
# Использование
class User(BaseModel):
id: int
username: str
response = APIResponse[User](
success=True,
data={'id': 1, 'username': 'john'},
message='User retrieved'
)class PaginatedResponse(BaseModel, Generic[T]):
items: list[T]
total: int
page: int
page_size: int
# Использование
response = PaginatedResponse[User](
items=[{'id': 1, 'username': 'john'}],
total=100,
page=1,
page_size=10
)В Pydantic v2 кастомные типы создаются через Annotated и AfterValidator:
from pydantic import BaseModel, AfterValidator
from typing import Annotated
from pydantic_core import PydanticCustomError
import re
def validate_phone(v: str) -> str:
pattern = r'^\+?1?\d{9,15}$'
if not re.match(pattern, v):
raise PydanticCustomError(
'phone_format',
'Invalid phone number format'
)
return v
PhoneNumber = Annotated[str, AfterValidator(validate_phone)]
class User(BaseModel):
phone: PhoneNumber
# Примеры
User(phone='+1234567890') # ✓ Пройдёт
User(phone='invalid') # ✗ Ошибка: Invalid phone number formatПочему не через класс? В Pydantic v1 можно было определить класс с методом
validate(), но в v2 этот подход не работает — методvalidateпросто не вызывается. ИспользуйтеAnnotated+AfterValidator.
from pydantic import BaseModel, EmailStr, HttpUrl, AnyUrl, IPvAnyAddress
class Network(BaseModel):
email: EmailStr # name@example.com
website: HttpUrl # https://example.com
link: AnyUrl # Любая URL
ip: IPvAnyAddress # 192.168.1.1 или ::1from pydantic import BaseModel, field_validator, ValidationInfo
class User(BaseModel):
username: str
role: str
@field_validator('role')
@classmethod
def validate_role(cls, v: str, info: ValidationInfo) -> str:
# Доступ к другим полям
username = info.data.get('username')
if username == 'admin' and v != 'admin':
raise ValueError('Admin user must have admin role')
return vfrom pydantic import (
BaseModel, Field, field_validator, model_validator,
computed_field, ConfigDict, EmailStr, HttpUrl
)
from datetime import datetime
from typing import Literal
import re
class UserProfile(BaseModel):
model_config = ConfigDict(
json_schema_extra={
'example': {
'username': 'john_doe',
'email': 'john@example.com',
'bio': 'Software developer',
'website': 'https://johndoe.com'
}
}
)
username: str = Field(
...,
min_length=3,
max_length=50,
pattern=r'^[a-zA-Z0-9_]+$',
description="Unique username"
)
email: EmailStr
bio: str | None = Field(None, max_length=500)
website: HttpUrl | None = None
age: int = Field(..., ge=18, le=120)
role: Literal['user', 'moderator', 'admin'] = 'user'
@field_validator('username')
@classmethod
def username_not_reserved(cls, v: str) -> str:
reserved = ['admin', 'moderator', 'system', 'root']
if v.lower() in reserved:
raise ValueError('This username is reserved')
return v
@field_validator('bio')
@classmethod
def validate_bio(cls, v: str | None) -> str | None:
if v and len(v.strip()) < 10:
raise ValueError('Bio must be at least 10 characters')
return v
@model_validator(mode='after')
def check_admin_age(self) -> 'UserProfile':
if self.role == 'admin' and self.age < 21:
raise ValueError('Admin must be at least 21 years old')
return self
@computed_field
@property
def is_adult(self) -> bool:
return self.age >= 18
@computed_field
@property
def profile_url(self) -> str:
return f'/users/{self.username}'| Pydantic v1 | Pydantic v2 |
|---|---|
validator | field_validator |
root_validator | model_validator |
@validator('field') | @field_validator('field') @classmethod |
.dict() | .model_dump() |
.json() | .model_dump_json() |
.parse_obj() | .model_validate() |
class Config | model_config = ConfigDict() |
orm_mode = True | from_attributes = True |
v1:
class User(BaseModel):
username: str
class Config:
orm_mode = True
@validator('username')
def validate_username(cls, v):
return v.lower()v2:
from pydantic import BaseModel, field_validator, ConfigDict
class User(BaseModel):
model_config = ConfigDict(from_attributes=True)
username: str
@field_validator('username')
@classmethod
def validate_username(cls, v: str) -> str:
return v.lower()class User(BaseModel):
@field_validator('username')
def validate_username(cls, v): # Ошибка: нет @classmethod
return vРешение:
@field_validator('username')
@classmethod
def validate_username(cls, v):
return v@field_validator('password')
@classmethod
def hash_password(cls, v: str) -> str:
return hash(v) # Ошибка: хешируем при валидации!Проблема: Пароль хешируется при каждой валидации, а не только при сохранении.
Решение: Хешируйте в CRUD-слое, не в модели.
@model_validator(mode='before')
def check_all(cls, values: dict) -> dict:
# values — это сырой словарь ДО валидации
# Поля ещё НЕ приведены к нужным типам!
print(values['start_date']) # Может быть строка, а не datetime
return valuesПроблема: mode='before' получает сырые данные до валидации. Типы могут быть неверны, строки не преобразованы в datetime/int и т.д.
Решение: Для кросс-полевой валидации используйте mode='after' — к этому моменту все поля уже валидированы и приведены к нужным типам:
@model_validator(mode='after')
def check_dates(self) -> 'Event':
# self.start_date и self.end_date — уже datetime
if self.end_date < self.start_date:
raise ValueError('end_date must be after start_date')
return self@field_validator('role')
@classmethod
def validate_role(cls, v: str) -> str:
# Ошибка: нет параметра info, нельзя получить другие поля
return vРешение: Добавьте info: ValidationInfo, если нужны другие поля:
@field_validator('role')
@classmethod
def validate_role(cls, v: str, info: ValidationInfo) -> str:
username = info.data.get('username')
if username == 'admin' and v != 'admin':
raise ValueError('Admin user must have admin role')
return vmode='before' vs mode='after')model_dump(), model_dump_json()Annotated + AfterValidatorConfigDict, json_schema_extraВ следующей теме вы изучите тестирование FastAPI — unit-тесты, интеграционные тесты, мокирование зависимостей.
Вопросы ещё не добавлены
Вопросы для этой подтемы ещё не добавлены.