Pydantic: اعتبارسنجی و طرحوارهها
دیگر به کلاینت اعتماد نمیکنیم. هر دادهای که وارد API میشود یا از آن خارج میشود را با یک مدل Pydantic توصیف میکنیم: چه فیلدهایی هست، کدام اجباری است، چه مقدارهایی مجاز است و کلاینت اجازه دیدن چه چیزی را دارد. تفاوت دادهای که ذخیره میکنیم، دادهای که میپذیریم و دادهای که برمیگردانیم را یاد میگیریم: الگوی Create و Update و Read که بقیه دوره روی آن بنا میشود.
- BaseModel: dataclassی که بررسی میکند
- Field و Annotated: قاعده برای هر فیلد
- اجباری، پیشفرض، اختیاری
- مدلهای تودرتو
- اعتبارسنج سفارشی
- سریالسازی و from_attributes
- پیشبینی کنید: چرا قبول شد، چرا رد شد؟
- مدل پاسخ: کنترل آنچه از API خارج میشود
- یک منبع، سه طرحواره
- پروژه: API آموزشگاه، نسخه دو
- خلاصه فصل
۱. BaseModel: dataclassی که بررسی میکند
در فصل اول dataclassها به هر چیزی که میگرفتند اعتماد میکردند. دادهای که از اینترنت میرسد قابل اعتماد نیست. یک مدل Pydantic:
- وقتی امن است تبدیل میکند:
"42"به42و"yes"بهTrue. - وقتی امن نیست رد میکند:
"abc"برای یک عدد صحیح یعنیValidationError. - همه مشکلها را یکجا گزارش میکند، با نام فیلد (
loc).
from pydantic import BaseModel, ValidationError
class Course(BaseModel):
id: int
title: str
price: int
is_active: bool = True
def main() -> None:
course = Course(id='7', title='FastAPI', price=4_800_000, is_active='yes')
print(course) # '7' became 7, 'yes' became True
print(course.id + 1)
data_from_json = {'id': 8, 'title': 'SQL', 'price': 2_900_000}
print(Course.model_validate(data_from_json))
print(Course.model_validate_json('{"id": 9, "title": "Git", "price": 3000000}'))
try:
Course(id='abc', title=None, price=1.5)
except ValidationError as error:
print(f'{error.error_count()} errors:')
for item in error.errors():
print(' ', item['loc'], '->', item['msg'])
if __name__ == '__main__':
main()
تبدیل و خطا: نمونه 01_basemodel/app.py
id=7 title='FastAPI' price=4800000 is_active=True
3 errors:
('id',) -> Input should be a valid integer, unable to parse string as an integer
('title',) -> Input should be a valid string
('price',) -> Input should be a valid integer, got a number with a fractional part
همین فهرست خطاها، وقتی درون FastAPI رخ دهد، به شکل پاسخ ۴۲۲ به کلاینت میرسد.
۲. Field و Annotated: قاعده برای هر فیلد
from typing import Annotated
from pydantic import BaseModel, Field, ValidationError
Price = Annotated[int, Field(ge=0, le=1_000_000_000, description='Price in Toman')]
Title = Annotated[str, Field(min_length=3, max_length=200)]
class Course(BaseModel):
title: Title
code: str = Field(pattern=r'^[A-Z]{3}-\d{3}$', examples=['FST-101'])
price: Price
capacity: int = Field(gt=0, le=500)
def main() -> None:
print(Course(title='FastAPI', code='FST-101', price=4_800_000, capacity=12))
bad_courses = [
{'title': 'AI', 'code': 'FST-101', 'price': 1, 'capacity': 1},
{'title': 'FastAPI', 'code': 'fst101', 'price': 1, 'capacity': 1},
{'title': 'FastAPI', 'code': 'FST-101', 'price': -5, 'capacity': 0},
]
for data in bad_courses:
try:
Course(**data)
except ValidationError as error:
print([f'{e["loc"][0]}: {e["msg"]}' for e in error.errors()])
if __name__ == '__main__':
main()
قاعدههای فیلد و نوعهای قابل استفاده دوباره: نمونه 02_field_constraints/app.py
| قاعده | معنی |
|---|---|
min_length، max_length | طول رشته |
ge، gt | بزرگتر یا مساوی، بزرگتر |
le، lt | کوچکتر یا مساوی، کوچکتر |
pattern | عبارت باقاعده |
description، examples | فقط برای مستند /docs |
با Annotated که در فصل اول دیدیم، قاعده یک بار تعریف و همهجا استفاده میشود: Price = Annotated[int, Field(ge=0)].
۳. اجباری، پیشفرض، اختیاری
from pydantic import BaseModel, ValidationError
class CourseForm(BaseModel):
title: str
level: str = 'beginner'
description: str | None = None
teacher_note: str | None # required, but may be null!
def main() -> None:
form = CourseForm(title='FastAPI', teacher_note=None)
print(form)
print('sent by the client:', form.model_fields_set)
try:
CourseForm(title='FastAPI')
except ValidationError as error:
print(error.errors()[0]['loc'], error.errors()[0]['msg'])
patch = CourseForm(title='FastAPI', teacher_note=None, description=None)
print(patch.model_dump(exclude_unset=True)) # only what was sent
if __name__ == '__main__':
main()
سه حالت متفاوت: نمونه 03_optional_and_defaults/app.py
str | None بدون مقدار پیشفرض هنوز اجباری است: کلاینت باید کلید را بفرستد، حتی اگر مقدارش null باشد. برای اختیاری شدن، = None لازم است.
model_fields_set میگوید کلاینت واقعاً کدام فیلدها را فرستاده است. PATCH به همین طریق «فرستاده نشده» را از «با null فرستاده شده» جدا میکند.
۴. مدلهای تودرتو
JSON درخت است و مدلهای Pydantic هم میتوانند درخت باشند. هر سطح بررسی میشود و محل خطا کل مسیر را نشان میدهد:
class Instructor(BaseModel):
id: int
full_name: str
class ClassSession(BaseModel):
number: int = Field(ge=1, le=16)
topic: str
class Course(BaseModel):
title: str
instructor: Instructor
tags: list[str] = []
sessions: list[ClassSession] = []
مدل درون مدل: نمونه 04_nested_models/app.py
('instructor', 'full_name') Field required
('sessions', 1, 'number') Input should be less than or equal to 16
۵. اعتبارسنج سفارشی
قاعدههایی که Field نمیتواند بیان کند در یک تابع میآیند:
from typing import Self
from pydantic import BaseModel, EmailStr, ValidationError, field_validator, model_validator
class UserCreate(BaseModel):
email: EmailStr
full_name: str
password: str
@field_validator('full_name')
@classmethod
def clean_name(cls, value: str) -> str:
value = ' '.join(value.split()) # ' sara ahmadi ' -> 'sara ahmadi'
if len(value) < 2:
raise ValueError('name is too short')
return value.title()
@field_validator('password')
@classmethod
def strong_password(cls, value: str) -> str:
if len(value) < 8 or value.isalpha() or value.isdigit():
raise ValueError('at least 8 characters, with letters AND digits')
return value
class PriceRange(BaseModel):
min_price: int = 0
max_price: int = 1_000_000_000
@model_validator(mode='after')
def check_order(self) -> Self:
if self.min_price > self.max_price:
raise ValueError('min_price cannot be greater than max_price')
return self
def main() -> None:
print(UserCreate(email='Sara@Example.com', full_name=' sara ahmadi ', password='secret123'))
for data in [
{'email': 'not-an-email', 'full_name': 'S', 'password': 'short'},
{'email': 'a@b.com', 'full_name': 'Ali', 'password': 'onlyletters'},
]:
try:
UserCreate(**data)
except ValidationError as error:
print([f'{e["loc"][0]}: {e["msg"]}' for e in error.errors()])
try:
PriceRange(min_price=500, max_price=100)
except ValidationError as error:
print(error.errors()[0]['msg'])
if __name__ == '__main__':
main()
field_validator و model_validator: نمونه 05_custom_validators/app.py
@field_validator('full_name')یک فیلد را تمیز یا رد میکند. مقدار (شاید تغییرکرده) را برگردانید یاValueErrorبدهید.@model_validator(mode='after')چند فیلد را با هم بررسی میکند، مثل «حداقل قیمت از حداکثر بیشتر نباشد».EmailStrایمیل را بررسی میکند و بهpydantic[email]نیاز دارد.
۶. سریالسازی و from_attributes
from pydantic import BaseModel, ConfigDict, computed_field
class CourseRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
title: str
price: int
capacity: int
enrolled: int
created_at: datetime
@computed_field
@property
def seats_left(self) -> int:
return self.capacity - self.enrolled
@dataclass
class CourseRow:
"""Pretend this came from a database."""
id: int
title: str
price: int
capacity: int
enrolled: int
created_at: datetime
internal_note: str
def main() -> None:
row = CourseRow(1, 'FastAPI', 4_800_000, 12, 10, datetime(2026, 9, 24, 18, 0), 'secret')
course = CourseRead.model_validate(row) # reads attributes, ignores internal_note
print(course.model_dump())
print(course.model_dump(include={'title', 'seats_left'}))
print(course.model_dump_json(indent=2))
if __name__ == '__main__':
main()
از مدل به دیکشنری و JSON: نمونه 06_serialization/app.py
model_dump(include=..., exclude=...)فقط بخشی از فیلدها را میدهد.@computed_fieldمقداری محاسبهشده، مثل صندلیهای خالی، به خروجی اضافه میکند.from_attributes=Trueیعنی مدل میتواند از هر شیئی با صفت خوانده شود، نه فقط دیکشنری. فصل پنجم برای خواندن ردیفهای SQLAlchemy دقیقاً به همین نیاز دارد.
۷. پیشبینی کنید: چرا قبول شد، چرا رد شد؟
این بخش تمرین کلاسی است. فایل 07_diagnose_validation/app.py دوازده ورودی دارد. پیش از اجرا برای هر کدام روی کاغذ بنویسید قبول میشود یا رد، و اگر رد میشود به خاطر کدام فیلد. بعد اجرا کنید و مقایسه کنید.
class Enrollment(BaseModel):
student_id: int
course_id: int = Field(gt=0)
paid: bool = False
amount: int = Field(default=0, ge=0)
note: str | None = None
strict_code: int | None = Field(default=None, strict=True)
CASES = [
{'student_id': 1, 'course_id': 2},
{'student_id': '1', 'course_id': '2'},
{'student_id': 1.0, 'course_id': 2},
{'student_id': 1.5, 'course_id': 2},
{'student_id': 1, 'course_id': 0},
{'student_id': 1, 'course_id': 2, 'paid': 'yes'},
{'student_id': 1, 'course_id': 2, 'paid': 'maybe'},
{'student_id': 1, 'course_id': 2, 'amount': '-1'},
{'student_id': 1, 'course_id': 2, 'note': 42},
{'student_id': 1, 'course_id': 2, 'strict_code': '7'},
{'student_id': 1, 'course_id': 2, 'unknown_field': 'x'},
{'course_id': 2},
]
دوازده ورودی برای پیشبینی: نمونه 07_diagnose_validation/app.py
مورد ۳ (1.0) قبول میشود، چون عدد اعشاری بدون کسر بیخطر به عدد صحیح تبدیل میشود؛ مورد ۴ (1.5) رد میشود. مورد ۱۰ ('7') رد میشود چون آن فیلد strict=True است و تبدیل را خاموش کرده است. مورد ۱۱ با فیلد ناشناخته قبول میشود، چون Pydantic بهطور پیشفرض فیلدهای اضافه را نادیده میگیرد؛ با extra='forbid' رد میشود.
۸. مدل پاسخ: کنترل آنچه از API خارج میشود
کاربر ذخیرهشده یک درهمساز رمز دارد که هرگز نباید برگردد. نوع بازگشتی endpoint یا response_model مدلی بدون آن فیلد است:
USERS = {
1: {'id': 1, 'email': 'sara@example.com', 'full_name': 'Sara', 'password_hash': 'x9$2...'},
}
class UserRead(BaseModel):
id: int
email: str
full_name: str
@app.get('/users/{user_id}', response_model=UserRead)
def get_user(user_id: int) -> dict:
if user_id not in USERS:
raise HTTPException(404, detail='User not found')
return USERS[user_id] # the hash is in here, but it will be filtered out
@app.get('/users', response_model=list[UserRead])
def list_users() -> list[dict]:
return list(USERS.values())
پنهان کردن درهمساز رمز: نمونه 08_response_model/app.py
FastAPI داده خروجی را با UserRead بررسی میکند و هر فیلدی را که آنجا تعریف نشده حذف میکند. مستند /docs هم دقیقاً همین شکل را نشان میدهد.
۹. یک منبع، سه طرحواره
داده ورودی و داده خروجی یک شکل ندارند:
| طرحواره | پاسخ به این سؤال |
|---|---|
CourseCreate | کلاینت هنگام ساختن چه چیزی را میتواند تعیین کند؟ (بدون id، بدون تاریخ) |
CourseUpdate | کلاینت چه چیزی را میتواند تغییر دهد؟ (همه اختیاری) |
CourseRead | کلاینت چه چیزی را میتواند ببیند؟ (با id و تاریخ ساخت) |
| مدل پایگاه داده (فصل ۵) | داده چگونه ذخیره میشود؟ |
class CourseBase(BaseModel):
title: str = Field(min_length=3, max_length=200)
price: int = Field(ge=0)
capacity: int = Field(gt=0, le=500)
class CourseCreate(CourseBase):
pass
class CourseUpdate(BaseModel):
title: str | None = Field(default=None, min_length=3, max_length=200)
price: int | None = Field(default=None, ge=0)
capacity: int | None = Field(default=None, gt=0, le=500)
class CourseRead(CourseBase):
id: int
created_at: datetime
COURSES: dict[int, CourseRead] = {}
Create و Update و Read: نمونه 09_create_update_read/app.py
چهار کلاس برای یک «دوره» ممکن است زیاد به نظر برسد، اما مخلوط کردن آنها همان راهی است که APIها از طریقش رمز لو میدهند یا به دانشجو اجازه میدهند role: admin بفرستد.
۱۰. پروژه: API آموزشگاه، نسخه دو
همه طرحوارههای پروژه در یک فایل schemas.py جمع میشوند و هیچ endpointی دیگر دیکشنری خام نمیپذیرد یا برنمیگرداند.
Level = Literal['beginner', 'intermediate', 'advanced']
Role = Literal['admin', 'instructor', 'student']
Title = Annotated[str, Field(min_length=3, max_length=200)]
Price = Annotated[int, Field(ge=0, le=1_000_000_000, description='Price in Toman')]
Capacity = Annotated[int, Field(gt=0, le=500)]
# ---------- courses ----------
class CourseBase(BaseModel):
title: Title
description: str = Field(default='', max_length=2000)
price: Price
capacity: Capacity
level: Level = 'beginner'
@field_validator('title')
@classmethod
def clean_title(cls, value: str) -> str:
return ' '.join(value.split())
class CourseCreate(CourseBase):
instructor_id: int
class CourseUpdate(BaseModel):
title: Title | None = None
description: str | None = Field(default=None, max_length=2000)
price: Price | None = None
capacity: Capacity | None = None
level: Level | None = None
@model_validator(mode='after')
def not_empty(self) -> Self:
if not self.model_fields_set:
raise ValueError('send at least one field to change')
return self
class CourseRead(CourseBase):
id: int
instructor_id: int
created_at: datetime
طرحوارههای دوره: 10_project/schemas.py
LevelیکLiteralاست؛?level=expertخودکار ۴۲۲ میگیرد.clean_titleفاصلههای اضافه عنوان را حذف میکند.CourseUpdateبدنه خالی را رد میکند.- اولین endpoint ثبتنام هم اضافه شده است: دوره پر و ثبتنام دوباره ۴۰۹ میگیرند.
در Bruno پوشه 10_project/bruno را اجرا کنید. درخواست «Create course - every field wrong» چهار خطا را در یک پاسخ نشان میدهد.
پوشه S03-pydantic-validation ده پوشه دارد. نمونههای ۰۱ تا ۰۷ اسکریپتاند و فقط python app.py لازم دارند. نمونههای ۰۸ و ۰۹ سرورند. پروژه با python main.py در 10_project اجرا میشود.
۱۱. خلاصه فصل
| ابزار | وظیفه آن |
|---|---|
BaseModel | تبدیل و بررسی داده |
model_validate، model_validate_json | ساختن مدل از دیکشنری یا متن |
Field(ge=..., max_length=...) | قاعده برای یک فیلد |
Annotated[int, Field(...)] | قاعده قابل استفاده دوباره |
= None | فیلد اختیاری |
model_fields_set، exclude_unset | تشخیص فیلدهای فرستادهشده |
@field_validator، @model_validator | قاعده سفارشی |
EmailStr | بررسی ایمیل |
from_attributes=True | خواندن از شیء، نه فقط دیکشنری |
@computed_field | مقدار محاسبهشده در خروجی |
نوع بازگشتی یا response_model | کنترل آنچه خارج میشود |
| Create / Update / Read | سه طرحواره برای یک منبع |
تمرینهای این فصل
- در
01_basemodelیکCourseاز{"id": "7.0", "title": 12, "price": "4,800,000"}بسازید. پیش از اجرا نتیجه هر فیلد را پیشبینی کنید. - به
Courseمقدارmodel_config = ConfigDict(extra='forbid')بدهید. برای فیلد ناشناخته چه تغییری میکند؟ - نوع
Phoneرا برای شماره همراه ایرانی (09و نه رقم) باpatternتعریف کنید. - به
UserCreateفیلدpassword_repeatاضافه کنید و باmodel_validatorبرابری دو رمز را بررسی کنید. - برای هر پیشبینی نادرستتان در
07_diagnose_validationیک جمله توضیح بنویسید. - در پروژه
CourseDetailبسازید که مدرس را به شکل یکUserReadتودرتو برگرداند. @computed_fieldبرای صندلیهای خالی دوره در پروژه اضافه کنید.PUT /courses/{course_id}را با طرحوارهCourseReplaceبنویسید. چراinstructor_idحالت خاصی دارد؟- تکلیف: برای API کتابخانه
BookCreate،BookUpdate،BookReadوMemberCreate،MemberReadبنویسید: شابک ۱۰ یا ۱۳ رقمی، سال بین ۱۴۵۰ و امسال، ایمیل معتبر و یکmodel_validatorکه کتابی با تعداد صفحه کمتر از تعداد فصل را رد کند.