2410 字
约 8 分钟
20
S10-X01 · Python 类型注解与 Pydantic 校验

类型注解在 Python 里有个容易被误解的定位:它是写给工具看的,运行时不会拦你。def f(x: int) 传一个字符串进去照样跑,直到某处 .bit_length() 报 AttributeError 才炸。理解这一点,才能分清"注解负责静态契约"和"Pydantic 负责运行时校验"这两条不该混淆的职责线。


1. 注解为什么不等于运行时约束

def add(a: int, b: int) -> int:
    return a + b

print(add("1", "2"))     # 典型输出:'12'

解释器会保存注解(add.__annotations__),但从不检查。注解的消费方是 mypy / pyright 这类静态检查器、IDE 补全、typing.get_type_hints() 反射,以及 Pydantic 这样的运行库。

层 谁在执行 何时生效 能拦住什么
注解本身 无人 —— 什么都拦不住
静态检查(mypy/pyright) 开发机或 CI 提交/构建时 类型不匹配、None 访问
运行时校验(Pydantic) 解释器 每次调用 脏数据、字段缺失、格式错误
assert 解释器 每次执行 只在你写的地方;-O 下被移除

结论:注解让代码可被分析,Pydantic 让数据在边界上被拒收,两者互补。


2. 基础注解速查

from typing import Optional, Callable, Literal

def find(uid: int) -> Optional[User]: ...      # 旧写法
def find2(uid: int) -> User | None: ...        # 3.10+ 推荐
def call(fn: Callable[[int, str], bool]) -> None: ...
def mode() -> Literal["dev", "prod"]: ...
写法 含义 备注
list[str]、dict[str, int] 内置泛型 3.9+,不需要 List/Dict
X | None 可选 3.10+;Optional[X] 等价
Union[int, str] / int | str 多选一 两种写法等价
Literal["a","b"] 枚举字面量 静态检查器能查拼写
Callable[[int], str] 可调用对象 返回类型在最后
Iterable[T] / Sequence[T] 只读视角 参数宽、返回严

一条经验:参数类型尽量宽(Iterable、Mapping),返回类型尽量具体,宽进严出。


3. TypedDict、dataclass、BaseModel 三者对照

from typing import TypedDict
from dataclasses import dataclass

class UserDict(TypedDict):
    uid: int
    name: str
维度 TypedDict dataclass Pydantic BaseModel
运行时类型 普通 dict 类实例 类实例
运行时校验 无 仅 __post_init__ 手写 有,字段级
类型转换("1"→1) 无 无 有
序列化 json.dumps 直接可用 dataclasses.asdict model_dump()
嵌套校验 无 无 递归校验
典型用途 给检查器描述 dict 结构 内部领域对象、纯计算 外部数据入口/出口

选型规则:数据来自外部(HTTP 请求体、配置、第三方 API)用 BaseModel;数据在内部流通、字段自己保证用 dataclass;只给 dict 加标注、不想建类就用 TypedDict。


4. Pydantic v2 的 BaseModel 基础

from pydantic import BaseModel, Field, ConfigDict
from datetime import datetime

class Order(BaseModel):
    model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)

    oid: int
    amount: float = Field(gt=0, le=1_000_000)
    currency: str = "CNY"
    tags: list[str] = Field(default_factory=list)
    created: datetime = Field(default_factory=datetime.utcnow)

o = Order.model_validate({"oid": 1, "amount": "12.5"})
print(o.amount)          # 典型输出:12.5(float,已转换)
print(o.model_dump())    # 典型输出:{'oid': 1, 'amount': 12.5, ...}
API 方向 说明
model_validate(obj) 入 校验 dict/对象;失败抛 ValidationError
model_validate_json(s) 入 从 JSON 字符串校验
model_dump() / model_dump_json() 出 转 dict / 转 JSON 字符串
model_dump(exclude_unset=True) 出 只输出显式赋值字段,PATCH 接口常用
model_json_schema() 出 生成 JSON Schema,喂 OpenAPI

可变默认值用 default_factory:Pydantic 会自己复制默认值,但 dataclass 里 tags: list = [] 是明确的 ValueError,统一习惯最省心。

ValidationError 的结构化输出是它最值钱的部分:

from pydantic import ValidationError
try:
    Order.model_validate({"oid": "x", "amount": -1})
except ValidationError as e:
    for err in e.errors():
        print(err["loc"], err["type"])   # ('oid',) int_parsing / ('amount',) greater_than

5. model_config 常用项

配置 作用 何时开
extra="forbid" 出现未声明字段就报错 对外 API 入口,防拼写错误
extra="ignore" 静默丢弃未知字段 消费第三方 API,对方加字段不该炸
from_attributes=True 允许从对象属性构造(原 orm_mode) 从 ORM 行对象构造
str_strip_whitespace=True 自动去首尾空白 表单输入
frozen=True 实例不可变、可哈希 做 dict key 或缓存键
validate_assignment=True 赋值时也校验 会拖慢写入

from_attributes 是三层架构里最省胶水的一行配置:没有它,从数据库行对象转 DTO 就得手写 model_validate(record.__dict__),而 __dict__ 常带多余字段。


6. 校验器:field_validator 与 model_validator

from pydantic import BaseModel, field_validator, model_validator

class Range(BaseModel):
    start: int
    end: int

    @field_validator("start", "end")
    @classmethod
    def non_negative(cls, v: int) -> int:
        if v < 0:
            raise ValueError("不能为负")
        return v

    @model_validator(mode="after")
    def check_order(self) -> "Range":
        if self.end < self.start:
            raise ValueError("end 必须 >= start")
        return self
装饰器 时机 拿到什么 典型用途
field_validator(mode="after") 类型转换之后 已转换的值 业务规则、范围检查
field_validator(mode="before") 类型转换之前 原始输入 清洗(""→None)、兼容旧格式
model_validator(mode="after") 全部字段校验完 self 跨字段一致性
model_validator(mode="before") 构造前 原始 dict 整体结构改写、版本迁移

两个必记细节:field_validator 必须加 @classmethod(漏了会在导入时报错);mode="after" 的 model_validator 必须返回 self,否则模型实例变成 None。


7. 类型检查器在 CI 里的位置

pip install mypy pyright
mypy --strict src/                          # 新项目
mypy src/legacy/ --ignore-missing-imports   # 老代码过渡
pyright src/
工具 实现 强项 注意
mypy Python 生态老、插件多(Django/SQLAlchemy) 默认不检查未注解函数体
pyright Node/TS 推断更强、速度快 默认更严格,老项目噪音大

CI 里落地的顺序:先只对新增文件开 --strict;把 disallow_untyped_defs、check_untyped_defs 逐项打开,一次只开一个;配置写进 pyproject.toml 的 [tool.mypy] 而非命令行;# type: ignore[code] 必须带具体错误码。对一个五万行老仓库一次开到 --strict,结果通常是团队集体加 ignore,最后留个假绿灯。


8. 排错:六个高频注解错误

现象 根因 处理
Optional[X] 字段没给默认值就报必填 Optional 只表示"可为 None",不表示"可省略" 写成 x: X | None = None
from __future__ import annotations 后 Pydantic 解析失败 注解变字符串,运行时反射要解析 确保类型在模块命名空间可解析
循环引用模型报 schema 生成失败 两个模型互相 import 用字符串前向引用 + model_rebuild()
field_validator 没生效 漏了 @classmethod,或字段名写错 补装饰器;核对拼写
别名生效导致构造失败 默认只接受 alias,不接受字段名 开 populate_by_name=True
mypy 报第三方库无 stub 库没带类型信息 装 types-xxx 或局部 ignore

关于 from __future__ import annotations:它把注解变成字符串(PEP 563),静态检查器反而更好用,但依赖运行时反射的库会多一步解析。Pydantic v2 能处理,前提是类型名在模块作用域可解析——if TYPE_CHECKING: 里 import 的类型运行时不存在就会翻车。


9. 常见反模式

反模式 问题 替代
到处传 dict[str, Any] 无契约,字段名靠记忆 定义模型或 TypedDict
Any 满天飞 / 用注解当校验 检查器失效、传错类型深层才炸 具体类型 + 边界 Pydantic
一个巨型 BaseModel 覆盖所有接口 处处必填,PATCH 无法表达 按场景分 In/Out/Patch
用 assert 做输入校验 -O 下被整行剥离 if ... raise 或模型校验

assert user_id is not None 在 python -O 下会整行消失,校验随之蒸发;assert 只该表达"我确信这里不可能发生"的内部不变量。


10. 边界:注解、校验、契约各管一段

需求 该用什么 不该用什么
函数参数是 int 注解 + mypy 运行时 isinstance 断言
HTTP 请求体字段校验 Pydantic BaseModel 手写 if "field" not in body
第三方 API 响应 Pydantic(extra="ignore") 直接 resp.json()["data"]["items"]
数据库行转 DTO from_attributes=True 手工逐字段赋值
内部纯计算对象 dataclass Pydantic(白白付校验开销)
跨服务契约 model_json_schema() 生成 OpenAPI 靠文档人工同步

第三方 API 必须用 Pydantic 兜住。直接链式取值的问题是:对方少一个字段、把 int 改成 float、把 null 换成 "",你的代码就在半夜抛 KeyError,堆栈深处看不出哪一步坏的。用模型接住后,错误会在边界上以结构化 ValidationError 暴露,loc 直接指到字段。


11. 自测

  1. def f(x: int) 传 "1" 会怎样?注解在运行时由谁消费?
  2. Optional[int] 和 int | None = None 在"是否可省略"这一点上有什么差别?
  3. TypedDict、dataclass、BaseModel 各自最适合描述哪一类数据?
  4. extra="forbid" 与 extra="ignore" 分别该用在对内入口和消费第三方 API 的哪个位置?
  5. @field_validator 为什么必须配 @classmethod?mode="before" 与 mode="after" 拿到的值有什么不同?
  6. 第三方 API 响应直接链式取值,出问题时症状是什么?换成 Pydantic 后错误信息多了什么?
S10-X01 · Python 类型注解与 Pydantic 校验
https://www.clxhxhhr.top/posts/5743/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。