Pydantic V2:把外部数据变成可信模型
目标:能够用 Pydantic V2 定义外部输入边界,并有意决定类型转换、严格字段、未知字段和序列化策略。
类型标注只提供静态契约;HTTP JSON、配置和消息队列数据在运行时仍是不可信值。Pydantic BaseModel 读取标注,执行解析与验证,成功后保证模型字段符合声明类型,并能生成 JSON Schema。
默认模式会进行有用的 coercion,例如把 "123" 转成整数 123。但转换是否安全是业务决策:配置文件中的端口字符串通常可以接受,机器 API 的身份 ID 或权限字段可能应该严格拒绝。
安装与完整实验
在项目中运行:
uv add "pydantic[email]"
建立 src/textlab/schemas.py:
from __future__ import annotations
from pydantic import BaseModel, ConfigDict, Field, ValidationError
class MessageInput(BaseModel):
model_config = ConfigDict(extra="forbid")
text: str = Field(min_length=1, max_length=5000)
priority: int = Field(default=0, ge=0, le=10, strict=True)
tags: list[str] = Field(default_factory=list)
class MessageOutput(BaseModel):
text: str
category: str
priority: int
def demo() -> None:
message = MessageInput.model_validate(
{"text": "hello", "priority": 2, "tags": ["demo"]}
)
print(message.model_dump())
try:
MessageInput.model_validate(
{"text": "hello", "priority": "2", "unknown": True}
)
except ValidationError as exc:
print([(error["loc"], error["type"]) for error in exc.errors()])
if __name__ == "__main__":
demo()
运行:
uv run python -m textlab.schemas
预期输出的稳定结构:
{'text': 'hello', 'priority': 2, 'tags': ['demo']}
[(('priority',), 'int_type'), (('unknown',), 'extra_forbidden')]
错误顺序在这里通常稳定,但测试时更适合按 loc/type 集合断言。priority 使用 strict,因此字符串不能悄悄转换;extra="forbid" 能抓住客户端把 priority 拼错一类问题。
Pydantic 与 dataclass 的边界
- Pydantic model:外部边界、验证、schema、JSON 序列化。
- dataclass:内部可信值对象、领域行为、较少框架耦合。
小项目可以直接在内部使用 Pydantic model;边界复杂或领域模型稳定时,可验证后转换为第 5 课的 frozen dataclass。不要为了“纯粹”而重复两套完全相同的模型。
Python 输入与 JSON 输入
model_validate() 接 Python 对象;model_validate_json() 直接接 JSON str/bytes。strict 模式下,两种来源对日期等类型可能有不同允许范围,因为 JSON 本身没有 date 类型。测试你的真实入口,不要只测构造器。
引导实验:观察默认 extra 行为
暂时删除 ConfigDict(extra="forbid"),传入 {"text": "hello", "pritorty": 9}。默认 extra 是 ignore,拼错字段会消失,priority 回到 0。恢复 forbid 后错误被及时发现。
这说明“更宽松”并不总是更友好;机器契约中,静默忽略常比明确失败更难排查。
独立练习
新增 nested Metadata 模型,包含 source: str 和 request_id: str | None;让 MessageInput 必须包含 metadata,并写测试覆盖缺失 source、额外字段和合法输入。
参考实现与测试
class Metadata(BaseModel):
model_config = ConfigDict(extra="forbid")
source: str = Field(min_length=1)
request_id: str | None = None
class MessageInput(BaseModel):
model_config = ConfigDict(extra="forbid")
text: str = Field(min_length=1, max_length=5000)
priority: int = Field(default=0, ge=0, le=10, strict=True)
tags: list[str] = Field(default_factory=list)
metadata: Metadata
from pydantic import ValidationError
try:
MessageInput.model_validate({"text": "x", "metadata": {}})
except ValidationError as exc:
assert exc.errors()[0]["loc"] == ("metadata", "source")
else:
raise AssertionError("source should be required")
排错路线
- 以为 Optional 自动代表可省略:字段是否 required 取决于是否有默认值;
str | None允许 null,不必然允许缺失。 - 错误被静默忽略:检查
extra配置。 - strict 开启后旧调用大量失败:审计调用来源并分阶段迁移,不要直接删除测试。