《Python编程入门》9.3 运行时校验与 Pydantic 入门

静态类型检查器只能看代码,看不到运行时流入的 JSON、环境变量与用户输入。本节先划清静态类型的边界,再入门 Pydantic V2:用 BaseModel 定义模型、Field 加约束、model_validate / model_dump 序列化、field_validator / model_validator 写校验,并用 TypeAdapter 处理裸类型。

本节目标:理解静态类型与运行时数据的边界,掌握 Pydantic V2 的模型定义、字段约束、校验器、序列化与配置读取。
适用版本:Python 3.12+(实测 3.14.6)

9.3 运行时校验与 Pydantic 入门

9.1 与 9.2 两节我们一直在给「代码本身」加类型。但程序还要面对一个静态检查器完全够不着的世界:来自 HTTP 请求的 JSON、来自操作系统的环境变量、来自用户填写的表单。这些数据的类型对不对,只有在运行时才知道。本节就补上这一环。

9.3.1 静态类型管不到运行时数据

回忆 9.1 节的核心结论:类型注解在运行时不做任何检查。看这段代码:

import json
def load_user(raw: str) -> dict[str, int]:
    return json.loads(raw)   # 检查器无法知道 raw 里到底是什么

json.loads 的返回类型是 Any,会一路关闭检查(9.1 节讲过)。哪怕签名里写 dict[str, int],实际返回的也可能是 {"age": "abc"}。静态类型描述的是「你希望数据是什么样」,而校验解决的是「数据实际是不是那样」——这两件事必须分开做。

9.3.2 dataclass 的类型注解不校验

很多初学者以为 @dataclass 会检查类型,其实不会——它只是帮你生成 __init__ 和 __repr__:

from dataclasses import dataclass

@dataclass
class User:
    name: str
    age: int

u = User(name="Alice", age="not a number")   # 完全不报错
print(u, "|", repr(u.age))
User(name='Alice', age='not a number') | 'not a number'

age 明明是 int 注解,却塞进了字符串,运行时毫无反应。dataclass 是「数据容器」,不是「校验器」。要校验,得请 Pydantic 出场。

9.3.3 Pydantic 快速上手:BaseModel

Pydantic 是 Python 生态里最流行的数据校验库,V2 版本用 Rust 重写了核心,速度快了一个数量级。安装(版本号来自 PyPI 实测):

pip install "pydantic==2.13.5"

定义模型就是继承 BaseModel,字段用注解声明:

from pydantic import BaseModel, ValidationError
class User(BaseModel):
    name: str
    age: int

print(User(name="Alice", age=30))
u2 = User(name="Bob", age="42")      # 字符串 "42" 会自动转成 int
print(u2, "| age type:", type(u2.age).__name__)
name='Alice' age=30
name='Bob' age=42 | age type: int

Pydantic 会做「宽松转换」:"42" 能变成 42,这是它和静态检查器最大的不同——愿意帮你转,转不了才报错:

try:
    User(name="Carol", age="not-a-number")
except ValidationError as e:
    print(str(e))
1 validation error for User
age
  Input should be a valid integer, unable to parse string as an integer [type=int_parsing, input_value='not-a-number', input_type=str]
    For further information visit https://errors.pydantic.dev/2.14/v/int_parsing

错误信息会精确告诉你:哪个字段、什么错误类型、原始输入是什么。这比手写 if not isinstance(...) 强太多。

9.3.4 字段约束:Field

光有类型还不够,业务上常常要求「价格必须大于 0」「名字不能为空」。用 Field 加约束:

from pydantic import BaseModel, Field

class Product(BaseModel):
    name: str = Field(min_length=1, max_length=50)
    price: float = Field(gt=0)
    quantity: int = Field(default=1, ge=1, le=999)

print(Product(name="Keyboard", price=199.0))
name='Keyboard' price=199.0 quantity=1

故意违反约束,Product(name="", price=-5, quantity=0) 会一次性报出全部问题:

3 validation errors for Product
name
  String should have at least 1 character [type=string_too_short, input_value='', input_type=str]
    For further information visit https://errors.pydantic.dev/2.14/v/string_too_short
price
  Input should be greater than 0 [type=greater_than, input_value=-5, input_type=int]
    For further information visit https://errors.pydantic.dev/2.14/v/greater_than
quantity
  Input should be greater than or equal to 1 [type=greater_than_equal, input_value=0, input_type=int]
    For further information visit https://errors.pydantic.dev/2.14/v/greater_than_equal

常用约束一览:

参数适用类型含义
gt / ge数值大于 / 大于等于
lt / le数值小于 / 小于等于
min_length / max_length字符串、列表长度下限 / 上限
pattern字符串正则匹配
default任意默认值

9.3.5 嵌套模型与 model_validate / model_dump

真实数据往往是嵌套的,Pydantic 模型可以直接嵌套:

from pydantic import BaseModel

class Address(BaseModel):
    city: str
    zipcode: str

class Person(BaseModel):
    name: str
    age: int
    address: Address

data = {
    "name": "Dana",
    "age": "28",
    "address": {"city": "Shanghai", "zipcode": "200000"},
}
p = Person.model_validate(data)
print(p)
print(p.model_dump())
print(p.model_dump_json())
name='Dana' age=28 address=Address(city='Shanghai', zipcode='200000')
{'name': 'Dana', 'age': 28, 'address': {'city': 'Shanghai', 'zipcode': '200000'}}
{"name":"Dana","age":28,"address":{"city":"Shanghai","zipcode":"200000"}}

三个关键方法:

  • model_validate(data):从 dict 校验并构造模型(边界数据的入口)。
  • model_dump():导出为 dict(可 mode="json" 得到纯 JSON 兼容类型)。
  • model_dump_json():直接导出 JSON 字符串。

age 从字符串 "28" 被转成整数 28,嵌套的 address 也被递归校验成 Address 实例。

9.3.6 自定义校验器:field_validator / model_validator

内置约束不够用时,写自己的校验逻辑。字段级用 field_validator,整模型级用 model_validator:

from pydantic import BaseModel, field_validator, model_validator
from typing import Self

class Signup(BaseModel):
    username: str
    password: str
    confirm: str

    @field_validator("username")
    @classmethod
    def username_lower(cls, v: str) -> str:
        return v.strip().lower()

    @model_validator(mode="after")
    def check_passwords(self) -> Self:
        if self.password != self.confirm:
            raise ValueError("两次密码不一致")
        return self

print(Signup(username="  Alice ", password="a1", confirm="a1"))
username='alice' password='a1' confirm='a1'

username 被自动去空格并转小写。当两次密码不一致时,会抛 value_error:

1 validation error for Signup
  Value error, 两次密码不一致 [type=value_error, input_value={'username': 'bob', 'pass...: 'a1', 'confirm': 'a2'}, input_type=dict]

model_validator(mode="after") 在字段都校验完之后运行,能访问整个模型,适合做「跨字段」检查(如密码确认、起始日期早于结束日期)。返回值用 Self(9.2 节讲过)能保持类型正确。

9.3.7 TypeAdapter:校验裸类型

有时你只想校验一个列表、一个联合类型,不想为它建模型。用 TypeAdapter:

from pydantic import TypeAdapter, ValidationError
from typing import Optional

IntList = TypeAdapter(list[int])
print(IntList.validate_python([1, 2, 3]))
print(IntList.validate_json("[4, 5, 6]"))

try:
    IntList.validate_python([1, "x", 3])
except ValidationError as e:
    print(str(e).splitlines()[2])

MaybeInt = TypeAdapter(Optional[int])
print(MaybeInt.validate_python(None), MaybeInt.validate_python("7"))
[1, 2, 3]
[4, 5, 6]
  Input should be a valid integer, unable to parse string as an integer [type=int_parsing, input_value='x', input_type=str]
None 7

TypeAdapter 把「类型」本身包装成一个可复用的校验器,validate_json 还能直接吃 JSON 字符串,解析「一个数组接口」时非常顺手。

9.3.8 Pydantic 与静态类型检查器的配合

Pydantic 模型同时也是普通类,检查器完全认识它:给 u.age 赋一个字符串,pyright 会报 Cannot assign to attribute "age",mypy 也会报赋值类型不兼容。默认情况下 Pydantic 允许在实例上赋值,但不会在赋值时重新校验;若希望「赋值也校验」,加上 ConfigDict:

from pydantic import BaseModel, ConfigDict

class StrictUser(BaseModel):
    model_config = ConfigDict(validate_assignment=True)
    age: int

这样 u.age = "abc" 会抛 ValidationError。ConfigDict 里还有 extra="forbid"(禁止多余字段)、frozen=True(不可变)等常用开关。静态检查 + 运行时校验双管齐下,才是 Pydantic 的正确用法。

9.3.9 用 pydantic-settings 读环境变量

配置通常来自环境变量,它们全都是字符串。pydantic-settings 把环境变量映射成带类型的配置对象:

import os
from pydantic_settings import BaseSettings, SettingsConfigDict

class AppSettings(BaseSettings):
    model_config = SettingsConfigDict(env_prefix="APP_")
    debug: bool = False
    db_url: str = "sqlite:///local.db"
    pool_size: int = 5

os.environ["APP_DEBUG"] = "true"
os.environ["APP_POOL_SIZE"] = "10"

print(AppSettings())
debug=True db_url='sqlite:///local.db' pool_size=10

"true" 被转成 True,"10" 被转成 10,env_prefix="APP_" 自动加前缀。安装用 pip install "pydantic-settings==2.15.0"。

这是 FastAPI 项目读取配置的标准做法。更完整的用法(.env 文件、密钥管理、多环境切换)会在第 16 章 Web 开发与实战书中展开,本节点到为止。

9.3.10 JSON 边界数据的处理流程

把本节串起来,处理一份外部 JSON 的推荐流程是:

  1. 拿到原始字符串(来自网络、文件、消息队列)。
  2. 用 model_validate_json() 或 model_validate() 解析——校验发生在这一步。
  3. 捕获 ValidationError,把错误转成用户可读的提示或日志。
  4. 在程序内部只传递已校验的模型实例,不再传裸 dict。
  5. 出口处用 model_dump_json() 序列化回 JSON。
from pydantic import BaseModel, ValidationError

class Order(BaseModel):
    order_id: str
    amount: float

raw = '{"order_id": "A-100", "amount": 9.9}'
try:
    order = Order.model_validate_json(raw)
    print("OK:", order.model_dump())
except ValidationError as e:
    print("校验失败:", e.error_count(), "处")
OK: {'order_id': 'A-100', 'amount': 9.9}

核心思想:让「已校验的模型」成为程序内部的统一货币。边界处严格校验,内部就再也不用担心类型问题了。

小结

  • 静态类型管不到运行时数据:json.loads 返回 Any,@dataclass 也不校验类型。
  • Pydantic V2(pydantic==2.13.5)用 BaseModel 定义模型,会做宽松类型转换,失败时抛信息丰富的 ValidationError;Field 加约束,field_validator / model_validator 写自定义校验。
  • model_validate / model_dump / model_dump_json 负责边界进出,TypeAdapter 校验裸类型;ConfigDict(validate_assignment=True) 让赋值也校验。
  • pydantic-settings 把环境变量映射成带类型的配置,是 FastAPI 项目的标准配置方案。

到这里,第 9 章「类型注解」就完整了:9.1 讲语法与工具,9.2 讲复杂类型结构,9.3 讲运行时校验。下一章我们回到字符串与文件,学习字符串处理与正则——这是文本类任务的必备技能。

阅读导航:上一节:泛型、Protocol、TypedDict 与 PEP 695 · 下一节:字符串处理与正则 re 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「python」更多文章

  1. 《Python高级编程》目录
  2. 《Python高级编程》11.3 PEP 流程与版本迁移策略
  3. 《Python高级编程》11.2 嵌入式与自由线程运行时