好久没学过 Python 基础的玩意儿了,最近要用 FastAPI 写点东西,然而 FastAPI 大量使用 Pydantic 用于数据定义和校验,而且之前学的 LangChain 也是大量依赖 Pydantic。这让学习 Pydantic 有实际意义。
所以,就去学。
下面基本把可能会用到的以及不大可能用到的概念都过了一遍,基本已经能够服务于实践并建立对 Pydantic 整体的感知了。给我热情都写没了。
Pydantic,其实就是一个类型定义、校验、序列化、反序列化库,如果要论相似品的话,就是 Typescript 的 zod 加上 Java 的 Jackson。区别在于,Pydantic 通过继承 BaseModel 实现功能 ,而 zod 更纯,而 Java 使用 POJO 和注解。
Pydantic 有如下值得一提的功能:
支持序列化、反序列化到 JSON,嵌套地
Strict 和 Lax 模式,前者严格,不进行类型转换,后者宽松,像 Jackson 的反序列化那样宽松
支持 Dataclass 和 TypedDict
支持自定义校验器和序列化器
hmmm,这个或许使用 learn x in y minutes 的形式更容易写和学一些。
一个整蛊的地方是,pydantic 把反序列化叫成 validation。
Hello, World 下面是 Pydantic 的 HelloWorld——展示了 schema 中各种字段的定义:
from typing import Literal , Annotatedfrom pydantic import BaseModelfrom annotated_types import Gtclass Fruit (BaseModel ): name: str color: Literal ['red' , 'green' ] weight: Annotated[float , Gt(0 )] bazam: dict [str , list [tuple [int , bool , float ]]] print (Fruit( name='apple' , color='red' , weight='100' , bazam={'foobar' : [(1 , True , 0.1 )]}, ))
注意到它某些地方莫名严格——name 是 str,传 int 不行,而某些地方又莫名宽松,weight 是 float,传 str 可以,这应该是故意设计为如此的,而其行为和我熟悉的 jackson 不合。实际上,这里有个 转换表 ,描述了所有类型在默认模式(lax)以及 strict 模式下可以的入参。
但我怀疑,JSON 反序列化时可能不走同样的机制 (因为这个表里入参都不是 JSON 类型)。
注意——只有初次创建实例时才会进行校验,在其后你可以自由地把值设置地违反约束。
这里也提一下——Pydantic 支持所谓的RootModel
——它表示一个数据类型本身是一个 list 或者 str 这样的单数据类型 ,而并非是一个对象,要比较的话,就是 Jackson 的 JsonValue 注解。RootModel 通常用于生成文档等,它看上去几乎是可以被 TypeAlias(pydantic 提供的我是说,它能提供文档的)代替的……只不过 TypeAlias 没有直接提供校验功能,需要让 TypeAdapater 协助:
from pydantic import RootModelclass SomeRequestBody (RootModel[list [str ]]): pass v = SomeRequestBody.model_validate_json(""" ["1", "2"] """ )print (v.root)
序列化到 dict 或 JSON 关于序列化,pydantic 支持三种方式——
序列化到 dict,字段包含 python 对象,如 datetime 等
序列化到 dict,所有字段类型和 json 兼容
序列化到 JSON
前两者使用model_dump
方法,后者使用model_dump_json
方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 from datetime import date, datetimefrom typing import Annotatedfrom pydantic import BaseModelfrom annotated_types import Gtclass Idol (BaseModel ): name: str age: Annotated[int , Gt(0 )] join_date: date idol = Idol(name='Chihaya' , age=16 , join_date=datetime.strptime('2004-01-01' , '%Y-%m-%d' ))print (idol.model_dump()) print (idol.model_dump(mode='json' )) print (idol.model_dump_json())
此外,也能使用dict
函数去直接把 model 转成 dict,但这样操作只能转换顶层为 dict ,这大多数时候恐怕都不是我们想要的结果。
此外,model 能通过model_json_schema
方法去生成 JSON Schema(似乎是兼容 OpenAPI 格式的,即 Swagger 那个),不过这里不表,因为我不会显式地用它。
从 dict 或 JSON 反序列化 从 dict 反序列化,有两种方式,使用构造函数或者model_validate
方法,这两种方式就使用上来说好像 tmd 没有任何差别 ,我还以为至少在别名等地方有差别呢……
from pydantic import BaseModelclass Clazz (BaseModel ): name: str class Idol (BaseModel ): name: str clazz: Clazzprint (Idol(name='chihaya' ,clazz={'name' : '765' }))print (Idol.model_validate(dict (name='chihaya' ,clazz={'name' : '765' })))
还有一个model_validate_strings
……它相较于model_validate
,似乎在类型转换上更加智能,允许所有字段全部是 string 。
然后是model_validate_json
,从 JSON 字符串或 bytes 到 model,这个不言自明。
此外,还有一个model_construct
方法直接构造 model,不进行校验,这个很坏。
Schema 的花式定义法 自定义类型和约束 Pydantic 既支持 Python 内置类型,也支持自定义类型的校验,同时它也提供了一些 Helper 类型,定制序列化反序列化的过程,如 StrictXXX,不表。
Pydantic 支持常见的标准库类型,大部分地方都是符合直觉的,所以标准库类型不表,只需要知道,datetime 支持我们习惯的 YYYY-MM-DD HH:MM:SS
的格式(非严格时支持YYYY-MM-DD
),date 则支持YYYY-MM-DD
,很方便,就这点来说,沿用 Jackson 的经验已足够。
重点是,Pydantic 允许添加自定义规则的类型,比如,我们想要大于 0 的整型(有没有想到 Dependent Type?哈哈),我们就这么写。注意下面的 TypeAdapter,它相当于是利用类型的元信息构造的一个序列化、反序列化器 。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 from typing import Annotatedfrom pydantic import BaseModel, Field, TypeAdapter, ValidationError PositiveInt = Annotated[int , Field(gt=0 )]class TestClz (BaseModel ): data: PositiveInttry : TestClz(data=-1 )except ValidationError as e: print ('成功地失败了' , e) ta = TypeAdapter(PositiveInt)try : v = ta.validate_python(-1 )except ValidationError as e: print ('成功地失败了' , e) NotEmptyPositiveList = Annotated[list [Annotated[int , Field(gt=0 )]], Field(min_length=1 )] ta= TypeAdapter(NotEmptyPositiveList)try : ta.validate_python(None )except : print ('successfully failed' )try : ta.validate_python([])except : print ('successfully failed' )try : ta.validate_python([-1 ])except : print ('successfully failed' ) ta.validate_python([1 ,2 ])print ('success' )
后面和 dataclass,TypedDict,NamedTuple 协同工作,同样要使用 TypeAdapter 。
不过这里算是跑题了,现在问题是,有哪些像 gt 这样的规则和约束,如何自定义约束 ?大部分情况下,使用上面示例中的 Field
函数,它类似标准库中 dataclass 的field
函数,但它放在 Annotated 注解中,这个注解用于添加元信息,Pydantic 大量使用这个模式。
Field 包含这些重要配置可能会常用:
title, description:JSON Schema 中的标题,描述等,这个在 LangChain 中要求结构化输出时有意义
default, default_factory: 字面意思,但 pydantic 似乎不会有共享可变对象的问题
alias:字段别名,可以分别序列化、反序列化时使用的别名
gt, ge, lt, le, multiple_of, allow_inf_nan:数字约束;疑惑为什么没有 eq?直接用 Literal
min_length, max_length, pattern:字符串约束,但 min_length 和 max_length 都支持所有可迭代类型
strict:严格校验模式
frozen:不可变
Field 以外的元信息 Field 这个究竟叫什么好像没找到专门的术语,但除了 Field 以外,Pydantic 还支持其它的元信息,它们也是通过 Annotated 去使用的。以及,FastAPI 等框架也会依赖 Annotated 提供功能。
这里主要说的是内置库中提供的更多约束,实际上这些约束均在annotated_type
包下,一些可能会用到且 Field 没有覆盖到的约束包括:
Interval:相当于是 gt,lt,ge,le 的组合
Len:相当于是 min_length 和 max_length 的组合
Timezone:限制时区
Unit:数字的单位,如克,米等,这个好像没屁用
Predicate:接受 Callable,自定义断言 !自定义断言在类型转换后才开始操作,所以可以安心!
Not:这是一个 dataclass,接受一个函数,行为为将函数的返回值取反
LowerCase,UpperCase,IsDigit:一堆使用 Annotated 和 Predicate 预定义的类型 (注意是类型!它作为元信息是没效果的!)
下面随意展示一下它的使用:
from pydantic import BaseModel, Fieldfrom typing import Annotatedfrom annotated_types import LowerCase, Predicate, IsDigitclass Idol (BaseModel ): name: Annotated[LowerCase, Field(pattern=r'\w+ \w+' )] clazz: IsDigit age: Annotated[int , Predicate(lambda age: age < 18 )] print (Idol(name='amami chihaya' , clazz='765' , age='16' ))
计算字段 这个只用来序列化 ,相当于是在 jackson 中给 getter 加 JsonProperty。注意顺序!!!computed_field 必须接受 property,这意味着它必须放在 property 外头 !
from typing import Annotatedfrom annotated_types import Gtfrom pydantic import BaseModel, Field, TypeAdapter, ValidationError, computed_fieldclass Idol (BaseModel ): first_name: str last_name: str @computed_field @property def full_name (self ) -> str : return self.first_name + ' ' + self.last_name print (Idol(first_name='Chihaya' , last_name='Amami' ).model_dump_json())
模式匹配(Discriminated Unions) 首先,Pydantic 支持 Union 类型,即str | int | None
,Pydantic 会选择最匹配的类型 。
就像 zod 一样,Pydantic 支持 Discriminated Union(就是 ts 的模式匹配那一套,根据特定字段的值去具体确定一个对象的类型),但 Pydantic 的相对更为复杂一些——Python 是强类型语言,在 js 里一切皆 object,但 Python 中要区分成不同的类,而且这些类互相之间是没有继承关系的。
但在此之前,我们先验证一下 Python 本身是否支持 ts 的模式匹配,使用下面的代码,检查 IDE 是否正确识别下面的 v 究竟是 Nothing 还是 Just。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 @dataclass class Nothing [T]: type : Literal ['NOTHING' ] = field(default='NOTHING' )@dataclass class Just [T]: value: T type : Literal ['JUST' ] = field(default='JUST' )type Maybe[T] = Nothing[T] | Just[T]def some_maybe (i: int ) -> Maybe[int ]: if i % 2 == 0 : return Just(i) return Nothing() v = some_maybe(111 )if v.type == 'JUST' : v.value else : v.type if v.type == 'NOTHING' : v.value else : v.value
但是,我为什么要学 ts?Python 的类本身就有类型信息,为什么要一个 type 字段?下面的实现更现代 ,更 Python:
@dataclass class Nothing [T]: pass @dataclass class Just [T]: value: Ttype Maybe[T] = Nothing[T] | Just[T] v = cast(Maybe[int ], Just(100 ))match v: case Just(x): print (f'just {x} ' ) case Nothing(): print ('nothing' )
直接展示代码,这代码会给人一点惊吓。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 from typing import Annotated, Literal , TypeAliasType, castfrom annotated_types import Gtfrom pydantic import BaseModel, Field, TypeAdapter, ValidationError, computed_fieldclass Nothing [T](BaseModel): type : Annotated[Literal ['NOTHING' ], Field(default='NOTHING' )]class Just [T](BaseModel): value: T type : Annotated[Literal ['JUST' ], Field(default='JUST' )]type Maybe[T] = Annotated[Nothing[T] | Just[T], Field(discriminator='type' )]class WTF_MAN [T](BaseModel): maybe: Maybe[T]print (WTF_MAN[int ].model_validate({'maybe' : {'type' : 'NOTHING' }}))print (WTF_MAN[int ].model_validate({'maybe' : {'type' : 'JUST' , 'value' : 100 }}))
函数参数校验 一个经典的问题是校验入参,Pydantic 提供了一个装饰器 validate_call
,支持对函数的参数进行校验。这个装饰器同时支持 async 函数,支持不定参,支持关键字参数,以及支持校验返回值(默认不开启)……总之支持 Python 函数的一切特性 ,只能说牛逼。
而且这个装饰器还带来一个好处——Pydantic 顺便还把隐式类型转换给做了,这意味着我们可以依赖它,比如直接传给 datetime 字段一个日期字符串 ,如果不想要这个,可以配置它。
from pydantic import ConfigDict, validate_call@validate_call def add (a: int , b: int ): return a + bprint (add(1 , '2' )) @validate_call(config=ConfigDict(strict=True ) ) def add (a: int , b: int ): return a + btry : print (add(1 , '2' ))except : print ('strict!' )
遗憾的是,这个装饰器不支持泛型 。
和 dataclass,TypedDict,NamedTuple 协同工作 首先,Pydantic 提供了一个自己的 dataclasses 模块,它和标准库的 dataclass 兼容,只需要使用 Pydantic 提供的 dataclass 便可以利用上 Pydantic 的校验机制,这在不想使用 BaseModel 而想使用 dataclass 那样的模式又想品尝到校验机制时可以选择。这里不表。
要和 dataclass,TypedDict,NamedTuple 一起工作,即和原始 Python 类型的类型标注协同工作,需要使用 TypeAdapter ,这里的 Adapter 的意思,显然就是将 Python 类型标注适配为 Pydantic 类型元信息。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 from typing import TypedDict, NamedTuplefrom dataclasses import dataclassfrom pydantic import TypeAdapterclass Idol1 (TypedDict ): name: str age: int class Idol2 (NamedTuple ): name: str age: int Idol3 = NamedTuple('Idol3' , [('name' , str ), ('age' , int )])@dataclass class Idol4 : name: str age: int data = {'name' : 'amami haruka' , 'age' : '17' } ta = TypeAdapter(Idol1)print (ta.validate_python(data)) ta = TypeAdapter(Idol2)print (ta.validate_python(data)) ta = TypeAdapter(Idol3)print (ta.validate_python(data)) ta = TypeAdapter(Idol4)print (ta.validate_python(data))
自定义校验(即反序列化) 就像 Hibernate Validator 和 Jackson 的 Serde,Pydantic 支持配置字段和模型级别的校验器 ,其中均允许配置前校验、后校验,环绕校验——对原始输入进行校验,对类型转换后的数据进行校验,对转换前后的数据进行校验 。
校验器是什么呢?可以认为,校验器就是接受输入,然后返回校验后的值的一个 Callable,这个值可以和原值不同(做修改,或者类型转换等)。
同时,这个 Callable 可以抛出异常,Pydantic 支持ValueError
,AssertionError
,PydanticCustomError
,Pydantic 会将这些异常包装成 ValidationError
。Assertion 看着很香,但注意它在生产环境可能是会被关闭的!
字段校验 有两种方式去自定义校验器——使用 Annotated 模式,或者使用 field_validator 装饰器,显然前者供复用,后者临时用。
最常用的是后校验 ——后校验在 Pydantic 内置校验之后发生,因此类型安全,容易实现 。下面同时展示 Annotated 模式和 Decorator 模式的用法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 from typing import Annotatedfrom pydantic import AfterValidator, field_validator, BaseModel, ValidationErrordef is_even (i: int ): assert i % 2 == 0 return iclass SomeModel (BaseModel ): field1: Annotated[int , AfterValidator(is_even)] field2: int @field_validator('field2' ) @classmethod def is_even (cls, i: int ) -> int : if i % 2 != 0 : raise ValueError(f'{i} is not an even number' ) return itry : SomeModel(field1=1 , field2=2 )except Exception as e: print (e)try : SomeModel(field1=2 , field2=1 )except Exception as e: print (e)
前面提到,后校验发生在 Pydantic 内部校验之后,后校验的输入是 Pydantic 内部校验的输出,输出是用户看到的值,而前校验,输入是原始输入,输出喂给 Pydantic 的内部校验 。
前校验器显然是更灵活的——它接受任意的输入并作处理然后再做后续流程。考虑一个经典问题:使用一个字符串传多个半角逗号分割的 ID 集合。下面同时以两种方式写:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 from typing import Annotatedfrom pydantic import BeforeValidator, field_validator, BaseModeldef collection_str (ids_str ): if isinstance (ids_str, str ): return ids_str.split(',' ) if isinstance (ids_str, list ): return ids_str return [str (ids_str)]class SomeModel (BaseModel ): field1: Annotated[list [str ], BeforeValidator(collection_str)] field2: list [str ] @field_validator('field2' , mode='before' ) @classmethod def collection_str (cls, i ): return collection_str(i)print (SomeModel(field1='1,2,3' , field2=['123' ,'234' ]))
注意——前校验器抛出异常不代表校验失败,比如 Union 类型,左边的类型校验失败后 Pydantic 会尝试右边的类型 ,这意味着前校验器不应当修改可变对象 。
然后有个所谓的 PlainValidator,这个……它行为是 BeforeValidator,然而终止后续流程,直接把结果返回给用户 。
最灵活的则是环绕校验 ,最灵活,它类似 Servlet 的 Filter,直接展示它的定义(注意它只有 Annotated 模式,Decorator 模式不支持它 ):
from pydantic import ValidatorFunctionWrapHandler, ValidationErrordef do_nothing (value, handler: ValidatorFunctionWrapHandler ): try : result = handler(value) except ValidationError: raise return result
模型校验 说句题外话,Pydantic 中没有像 Hibernate Validator 那样的校验组机制。
在 Hibernate Validator 中,我只使用过字段校验,然而实际上它也是支持模型校验的。Pydantic 同样支持模型校验,即对整个模型进行校验,因此可以看到字段之间的关系 。
模型校验支持后校验,前校验,和环绕校验。考虑到模型校验是无法重用的,Pydantic 只提供了 Decorator 模式去实现校验 。具体的说,使用model_validator
装饰器。
后校验使用实例方法(因此是实例化后执行的),前校验和环绕校验使用类方法 。这里只写后校验的示例,因为我已经有点麻了:
from pydantic import model_validator, BaseModelclass ResetPasswordDto (BaseModel ): username: str new_password: str new_password_repeat: str @model_validator(mode='after' ) def check_password_match (self ): if self.new_password != self.new_password_repeat: raise ValueError('Passwords do not match' ) return self ResetPasswordDto(username='abc' , new_password='pass' , new_password_repeat='pass' )
我不研究各个校验器的执行顺序——我又不是去面试!真遇到问题再说!
自定义序列化 配置序列化有四种方式,@field_serializer
,@model_serializer
,PlainSerializer
,WrapSerializer
。
@field_serializer
不能配置 mode,@model_serialzer
能配置 wrap 或者 plain,显然@field_serializer
在model_serializer
配置为 plain 时是不会起效的。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 from pydantic import field_serializer, model_serializer, BaseModelclass Hello (BaseModel ): hello: str @field_serializer('hello' , when_used='json' ) def ser_hello (x: str ) -> str : return x.upper() h = Hello(hello='world' )print (h.model_dump()) print (h.model_dump(mode='json' )) print (h.model_dump_json())
两个 Annotated 模式的 Serializer,显然是适用于字段的 ,使用方式和上面的一致,这里不表了。
关于日期的序列化、反序列化 接下来是一个究极经典的问题——对日期的序列化、反序列化,就像 Jackson 的 JsonFormat 注解。
首先,Pydantic 对 date,datetime,默认的序列化、反序列化逻辑是怎样的?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 from datetime import datetime, datefrom typing import Annotatedfrom pydantic import BeforeValidator, PlainSerializer, TypeAdapter date_ta = TypeAdapter(date) datetime_ta = TypeAdapter(datetime) now_datetime = datetime.now() date_ta.validate_python(date(year=2023 ,month=1 ,day=1 )) date_ta.validate_python(datetime(year=2023 ,month=1 ,day=1 )) date_ta.validate_python(now_datetime.strftime('%Y-%m-%d' )) date_ta.validate_python(now_datetime.strftime('%Y-%m-%d 00:00:00' )) datetime_ta.validate_python(date(year=2023 ,month=1 ,day=1 )) datetime_ta.validate_python(datetime(year=2023 ,month=1 ,day=1 )) datetime_ta.validate_python(now_datetime.strftime('%Y-%m-%d' )) datetime_ta.validate_python(now_datetime.strftime('%Y-%m-%d 00:00:00' ))
注意到:
date,datetime 均接受我们最熟悉的YYYY-MM-dd
和YYYY-MM-dd HH:mm:ss
格式
datetime 接受精确到时分秒的表示,但也能接受日期表示(后者在 strict 模式下不合法)
date 接受日期表示,但也能接受精确到时分秒的表示,只是时分秒必须为 0
我想到两种方式:
使用类似注解的形式——提供一个可配置的元信息去模拟 JsonFormat 注解
提供预先添加上面的元信息的类型
其实两种都是同样的对不对?这里操作一个:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 from datetime import datetime, datefrom typing import Annotatedfrom pydantic import BeforeValidator, PlainSerializer, TypeAdapterdef time_formatter (pattern: str ): datetime.now().strftime(pattern) def ser (x: datetime | date ) -> str : return x.strftime(pattern) def deser (x ) -> datetime: return datetime.strptime(str (x), pattern) return BeforeValidator(deser), PlainSerializer(ser) YYYYMMDD_DATE = Annotated[date, *time_formatter('%Y--%m--%d' )] ta = TypeAdapter(YYYYMMDD_DATE) deser_test = ta.validate_python('2025--12--31' )print (type (deser_test), deser_test) ser_test = ta.dump_json(deser_test)print (ser_test) YYYYMMDD_HHMMSS_DATETIME = Annotated[datetime, *time_formatter('%Y-%m-%d//%H:%M:%S' )] ta = TypeAdapter(YYYYMMDD_HHMMSS_DATETIME) d = ta.validate_python('2025-12-31//12:34:56' )print (type (d), d) print (ta.dump_json(d))
配置 Model Pydantic 支持配置 Model 的校验、序列化配置,这些配置能够影响其所有字段的行为(实际上就是处理 Pydantic 关于内部校验的行为)。该配置利用所谓的ConfigDict
类。配置似乎是不会递归影响的,只会影响本层。
无论是 BaseModel,Typeadapter,Pydantic 的 dataclass,还是 Python 自己的 dataclass,TypedDict 等,均允许进行配置。
对 BaseModel,直接设置类字段model_config
。
对 Pydantic 的 dataclass,设置装饰器中的 config 字段。
对 TypeAdapter,通过参数传递。
对 Python 的 dataclass,设置__pydantic_config__
字段或者使用with_config
装饰器。
对 TypedDict,只能使用with_config
装饰器。
ConfigDict 中具体能配置这些信息:
JSON Schema 相关的生成
字符串的默认处理(全大写,小写,strip,长度等)
对多余字段的处理,默认是忽略 ,可以配置为 allow,这时候多余字段会存储到__pydantic_extra__
字段下
是否 frozen,是否 strict……
关于枚举的配置
JSON 中日期的序列化方式,JSON 字节串的编解码方式
就这样吧。