跳至主要内容

[note] Pydantic

資料來源

[TOC]

Pydantic 是一個方便我們做以下事情的工具:

  • Deserializing data:將資料從 dictionary 或 JSON 轉成 model 的過程
  • Validation:確認資料符合定義的 model,包含欄位和型別。
  • Serializing model instance:將 model instance 轉成 dictionary 或 JSON 的過程

基本使用

定義 Pydantic Model

class Person(BaseModel):
first_name: str
last_name: str
age: int

Optional Fields:有給預設值

提示

欄位有給預設值就會是 Optional Field。

如果希望某些欄位是 Optional 的,只需要提供該欄位預設值:

class Circle(BaseModel):
center: tuple[int, int] = (0, 0) # center 會是 optional,沒給值的話預設是 (0, 0)
radius: int

c = Circle(radius=1) # Circle(radius=1)

要特別留意的是,Pydantic 不會驗證填入的預設值是否符合該欄位的型別,也就是說:

class Person(BaseModel):
age: int = "30" # Pydantic 不會對預設值進行型別驗證

class Model(BaseModel):
Field: int = None

都是可以的,但卻會建出預設就不符合型別定義的 instance。

危險

Pydantic 預設不會驗證填入的預設值是否符合該欄位的型別!

Nullable Fields:型別定義接受 None

提示

欄位的型別有 | None 就是 nullable。

Nullable fields 和 Optional Fields 不太一樣,「optional field 表示 deserialized 的時候可以沒有這個 key,而當這個 key 不存在時,會直接用 default value」;「nullable fields 則表示的是,這個欄位能不能是 None(即,JSON 中的 null)。

Nullable vs Optional Fields

Nullable Fields 比較像要給東西,但這個東西可以是 None / null;Optional Fields 則是可以接受不給東西,但會套用預設值。

class Model(BaseModel):
field: int | None # field 可以是 int 或 None

這樣是可以的:

Model(field=None)

但它並不是 Optional 的意思,所以這樣是不行的:

Model()

如果我們希望某個欄位同時是 Optional 且 Nullable 的,則要這樣寫:

class Model(BaseModel):
field: int | None = None

如此:

Model()  # 預設沒給 field 值的時候,該欄位值就會是 None

在 Python 3.10 之前,不能使用 | 來表示型別的「或」,因此要寫成:

from typing import Union, Optional

# 這三種寫法的意思是一樣的
class Model(BaseModel):
field_1: int | None
field_2: Union[int, None]
field_3: Optional[int] # 不建議使用 "Optional",因為這裡並不符合 Pydantic 的 Optional 的意思

Model.model_fields
"""
{
'field_1': FieldInfo(annotation=Union[int, NoneType], required=True),
'field_2': FieldInfo(annotation=Union[int, NoneType], required=True),
'field_3': FieldInfo(annotation=Union[int, NoneType], required=True)
}
"""
不建議使用 typing.Optional

不建議使用 typing.Optional 這種寫法來表示 Nullable,因為在 Pydantic 中,Optional 和 Nullable 是不一樣的。

定義欄位時請想清楚

這個欄位要的是 Optional?還是 Nullable?還是兩個都要?還是兩個都不要?

class Model(BaseModel):
field_1: int # 這個欄位是 required 且不能是 null(non-nullable)
field_2: int | None # 這個欄位是 required 且可以是 null(nullable)
field_3: int | None = None # 這個欄位是 optional 且可以是 null(nullable)
field_4: int = 3 # 這個欄位是 optional 且不能是 null(non-nullable)

Inspecting Fields

keywords: model_fields, model_fields_set
官方文件
from pydantic import BaseModel

class Circle(BaseModel):
center_x: int = 0 # Optional
center_y: int = 0 # Optional
radius: int = 1 # Optional
name: str | None = None # Optional & Nullable

取得 model 所有欄位的資訊

使用 Model.model_fields

Circle.model_fields

"""
{
'center_x': FieldInfo(annotation=int, required=False, default=0),
'center_y': FieldInfo(annotation=int, required=False, default=0),
'radius': FieldInfo(annotation=int, required=False, default=1),
'name': FieldInfo(annotation=Union[str, NoneType], required=False, default=None)
}
"""

instance 也可以使用 model_fields

c1 = Circle()
c1 # Circle(center_x=0, center_y=0, radius=1, name=None)
c1.model_fields
"""
{
'center_x': FieldInfo(annotation=int, required=False, default=0),
'center_y': FieldInfo(annotation=int, required=False, default=0),
'radius': FieldInfo(annotation=int, required=False, default=1),
'name': FieldInfo(annotation=Union[str, NoneType], required=False, default=None)
}
"""

取得那些欄位是使用者自己填入的欄位

透過 model_fields_set 則可以看那些欄位是 developer 自己設定,而不是用預設值的:

# 使用者主動添了 "name" 和 "radius" 這兩個欄位
c2 = Circle(name="Circle 2", radius=5)
c2.model_fields_set # {'name', 'radius'}

透過 model_fields_set,可以只 serialize 使用者自己填、而非套用預設值的內容:

# 只 serialize 使用者填入的欄位
c2.model_dump(include=c2.model_fields_set) # {'radius': 5, 'name': 'Circle 2'}

取得那些欄位是使用了 default value

c2.model_fields.keys() - c2.model_fields_set  # {'center_x', 'center_y'}

Deserialization

keywords: model_validate(), model_validate_json()

在定義好 model 後,把資料變成 Pydantic Model instance 的過程就稱作 deserialization。下面這些方式都可以建立 instance:

直接帶入 Arguments

# 使用 function arguments 來建立 instance
person = Person(first_name='John', last_name='Doe', age=30)

將 dict 轉成 instance

使用 model_validate()

data = {
"first_name": "John",
"last_name": "Doe",
"age": 30
}

# 不建議直接用 unpacking 的方式
person = Person(**data)

# 建議使用 model_validate() 這個方法
person = Person.model_validate(data)

將 JSON 轉成 instance

使用 model_validate_json()

data_json = """
{
"first_name": "John",
"last_name": "Doe",
"age": 30
}
"""

person = Person.model_validate_json(data_json)
person

Serialization

keywords: model_dump(), model_dump_json()

把 Pydantic Model instance 轉成資料(dictionary 或 JSON)的方式,稱作 serialization

from pydantic import BaseModel, ValidationError

class Person(BaseModel):
first_name: str
last_name: str
age: int

data = {
'first_name': 'John',
'last_name': 'Doe',
'age': 30
}

john = Person.model_validate(data)
# 將 instance 轉成 Python 的 dict
john.model_dump() # {'first_name': 'John', 'last_name': 'Doe', 'age': 30}

# 將 instance 轉成 JSON
john.model_dump_json() # '{"first_name":"John","last_name":"Doe","age":30}'

# 底層是使用 json module 的 dump 方法,所以可以帶入 dump 能使用的參數
john.model_dump_json(indent=2)

移除或只保留某些欄位

keywords: exclude, include

如果希望在 serialization 的時候:

  • 移除某些欄位,可以使用 exclude
john.model_dump(exclude={'age'})  # {'first_name': 'John', 'last_name': 'Doe'}
  • 只要保留特定欄位,可以使用 include
john.model_dump(include={'age'})  # {'age': 30}

Field Configuration: Serialize, Deserialize, and Alias

keywords: alias, serialization_alias, validation_alias, populate_by_name, by_alias, alias_generator, @field_serializer

在 Pydantic 中,針對欄位除了可以直接透過型別和預設值來設定之外,如果需要更高的客製化是,我們可以使用 Field 來建立 field,並以 default value 的方式指定給該欄位。

# TL;DR

from pydantic.alias_generators import to_camel
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field, field_serializer


class Model(BaseModel):
# alias_generator
model_config = ConfigDict(alias_generator=to_camel)

# alias
type_: str = Field(alias="type", default="")

# validation_alias(input), serialization_alias(output)
base_usd: float = Field(validation_alias="USD", serialization_alias="baseUSD", default=10)

# default
number_of_doors: int = Field(validation_alias="doors", default=4)

manufactured_date: date = Field(validation_alias="completionDate", default=datetime.now())

# field_serializer
@field_serializer("manufactured_date", when_used="json")
def serialize_manufactured_date(self, v: date) -> str:
return v.strftime("%Y/%m/%d")


m = Model()
# serialize by alias (or serialization_alias)
m.model_dump(by_alias=True)
m.model_dump_json(by_alias=True)

在 Pydantic 中,alias 分成三種:

  • alias:能被作用在 serialize 和 deserialize,但可以被另外兩個覆蓋
  • serialization_alias:serialize 時使用,定義之後會覆蓋掉 alias
  • validation_alias:deserialize 時使用,定義的話會覆蓋掉 alias
提示

如果對同一個欄位同時用了 aliasserialization_aliasvalidation_alias,那 alias 等於沒有作用。

alias 會自動作用在 deserialize 時,但不會自動作用在 serialize 時

alias 預設「只會」作用在 「deserialize」 時,因此:

  • Deserialize JSON 或 dict data 時需要用 alias
  • 建立 instance 時也要用 alias
from pydantic import BaseModel, Field, ValidationError

class Model(BaseModel):
id_: int = Field(alias="id") # 在 Pydantic model 中是 "id_",但 serialize 後會是 "id"
last_name: str = Field(alias="lastName") # 在 Pydantic model 中是 "last_name",但 serialize 後會是 "lastName"
json_data = """
{
"id": 123,
"lastName": "Doe"
}
"""
m = Model.model_validate_json(json_data)
m # Model(id_=123, last_name='Doe')
dict_data = {
"id": 123,
"lastName": "Doe"
}
m = Model.model_validate(data)
m # Model(id_=123, last_name='Doe')

即使是直接帶入參數,也要用 alias(否則會噴錯):

# 🟢 要這樣才能建立 model instance
Model(id=123, lastName='Doe')

# ❌ 這樣會噴錯,過不了 validation
Model(id_=123, last_name='Doe')
危險

預設的情況下,使用了 alias,在建立 model instance 的時候,就要用 alias 定義的欄位名稱,否則會噴錯(除非有另外設定 populate_by_name=True)。

populate_by_name:deserialize 時可以不套用 alias,而是用原本的 field name

預設的情況下,alias 會自動套用在 deserialize,並且一定要用定義的 alias 才能建立 instance。但如果我們希望 deserialize 時「不一定要」套用 alias 的欄位名稱,而是可以「用原本的 field name 或 alias 都可以的話」,可以在 model_config 中使用 populate_by_name=True 這樣設定:

from pydantic import BaseModel, ConfigDict, Field

class Model(BaseModel):
# 使用 populate_by_name,可以在 deserialize 的使用,不一定要用 alias
model_config = ConfigDict(populate_by_name=True)

id_: int = Field(alias='id')
first_name: str = Field(alias='firstName')
data = {
"id_": 10,
"first_name": "John",
}
Model.model_validate(data)
# Model(id_=10, first_name='John')

同時混合原本的 field name 和 alias 也是可以的:

data_json = """
{
"id": 10,
"first_name": "John"
}
"""
m = Model.model_validate_json(data_json) # Model(id_=10, first_name='John')

m.model_dump() # {'id_': 10, 'first_name': 'John'}
m.model_dump(by_alias=True) # {'id': 10, 'firstName': 'John'}

by_alias:serialize 時也希望用 alias

提示

預設的情況下,alias 只作用在 deserialize 的時候,serialize 的時候不會轉成所定義的 alias,除非在 serialize 時有用 by_alias=True

alias 預設只作用在 deserialize 的時候:

m.model_dump()  # {'id_': 123, 'last_name': 'Doe'}
m.model_dump_json() # '{"id_":123,"last_name":"Doe"}'

如果希望在 serialize 的時候,也能用 alias 的欄位名稱來輸出,要加上 by_alias=True 的參數:

m.model_dump(by_alias=True)  # {'id': 123, 'lastName': 'Doe'}
m.model_dump_json(by_alias=True) # '{"id":123,"lastName":"Doe"}'

serialization_alias:在 serialize 的時候使用特定的 alias

提示

一旦對某個欄位定義了 serialization_alias,則在 serialize 時,原本定義的 alias 就沒作用了。

在某些情況下,可能會希望 deserialize 時用的 alias 和 serialize 時用的 alias 是不同欄位名稱,這時候就可以用到 serialization_alias

from pydantic import BaseModel, Field
response_json = """
{
"ID": 1,
"FirstName": "John",
"lastName": "Doe"
}
"""
class Person(BaseModel):
# 使用 serialization_alias
id_: int = Field(alias="ID", serialization_alias="id")
first_name: str = Field(alias="FirstName", serialization_alias="firstName")
last_name: str = Field(alias="lastName", serialization_alias="lastName")
# deserialize 時用的是 "alias"
p = Person.model_validate_json(response_json)

# serialize 時用的是 "serialization_alias"
p.model_dump(by_alias=True) # {'id': 1, 'firstName': 'John', 'lastName': 'Doe'}

alias_generator:針對所有欄位套用轉換成 alias 的 function

使用 alias_generator 可以一次針對所有的 fields 套用 alias。只需要 alias_generator 的參數中帶入轉換的 function:

from pydantic import BaseModel, ConfigDict, Field, ValidationError


def make_upper(in_str: str) -> str:
return in_str.upper()


class Person(BaseModel):
model_config = ConfigDict(alias_generator=make_upper)

id_: int
first_name: str | None = None # Optional & Nullable
last_name: str # Required
age: int | None = None # Optional & Nullable
Person.model_fields
"""
{
'id_': FieldInfo(annotation=int, required=True, alias='ID_', alias_priority=1),
'first_name': FieldInfo(annotation=Union[str, NoneType], required=False, default=None, alias='FIRST_NAME', alias_priority=1),
'last_name': FieldInfo(annotation=str, required=True, alias='LAST_NAME', alias_priority=1),
'age': FieldInfo(annotation=Union[int, NoneType], required=False, default=None, alias='AGE', alias_priority=1)
}
"""

p = Person(ID_=1, LAST_NAME='Doe')
p.model_dump() # {'id_': 1, 'first_name': None, 'last_name': 'Doe', 'age': None}
p.model_dump(by_alias=True) # {'ID_': 1, 'FIRST_NAME': None, 'LAST_NAME': 'Doe', 'AGE': None}

除了自己定義 transform function 外,也可以用 Pydantic 提供的 to_camelto_snaketo_pascal

from pydantic import BaseModel, ConfigDict, Field, ValidationError
from pydantic.alias_generators import to_camel, to_pascal, to_snake


class Person(BaseModel):
model_config = ConfigDict(alias_generator=to_camel)

# fields...

validation_alias:只作用在 deserialization

Validation Alias 可以想成是 Deserialization Alias 的意思,一旦定義了 validation alias,原本定的 alias 就沒用了。

validation_alias 除了是在 deserialize 作為 field name 使用外,可以搭配 AliasChoices 使用,可以把多個 deserialize 的欄位名稱 mapping 到同意 model 中的 field name:

class Person(BaseModel):
name: str
gender: str = Field(validation_alias=AliasChoices("Gender", "sex", "型別"))

當資料的欄位名稱是 Gendersex型別 時,都會被放進 gender 這個欄位中:

# 資料的屬性名稱是 "Gender",會被放到 "gender" 這個 field name 中
data = {"name": "John", "Gender": "Male"}
Person.model_validate(data) # Person(name='John', gender='Male')
# 資料的屬性名稱是「性別」,會被放到 "gender" 這個 field name 中
data_json="""
{
"name": "John",
"性別": "Male"
}
"""
Person.model_validate_json(data_json) # Person(name='John', gender='Male')

@field_serializer:客製化 serialize 的方式

@field_serializer(<field_name>, when_used="always")
def serialize_field(self, value):
# ...
return value

其中 when_used 表示的是要在什麼時候套用這個 serializer,它可以是:

  • always:預設值,不論是 serialize 成 dict(.model_dump())或 JSON(.model_dump_json())都會呼叫這個 serializer
  • unless-none:當 value 是 None 時不執行 serialized
  • json:只有在 serialize 成 JSON 時會使用
  • json-unless-non:只有在 serialize 成 JSON 時會使用,但當 value 時 None 則不會

@field_serializer 這個方法非常適合用在轉換 Python 的 datetime 物件,因為 Python Datetime 預設並沒有時區資訊,但在傳給前端時,一般會需要有時區的資訊(例如,Z+00:00)。這時候就可以透過 @field_serializer 來在 serialize 時把時間資料做轉換。

先定義兩個用來轉換 datetime 的 methods:

from datetime import datetime, timezone


def make_utc(dt: datetime) -> datetime:
"""
將 datetime 轉成 UTC timezone
如果傳入的 datetime 沒有時區資訊,則預設它應該是 UTC
如果傳入的 datetime 已經有時區資訊,則把它轉成 UTC
"""
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
else:
return dt.astimezone(timezone.utc)


def make_isoformat(dt: datetime) -> str:
"""
將傳入的 datetime 轉成 UTC 後,在轉成 ISO 8601
"""
dt_utc = make_utc(dt)
# return dt_utc.isoformat()
return dt_utc.strftime("%Y-%m-%dT%H:%M:%SZ")

接著使用 @field_serializer

  • 使用第三個參數 info 可以用來判斷這個 serializer 在執行的時候,是要轉成 dict(.model_dump())或轉成 JSON(.model_dump_json()
from pydantic import BaseModel, field_serializer, FieldSerializationInfo


class Model(BaseModel):
start_time: datetime | None = datetime.now() # optional, nullable

@field_serializer("start_time", when_used="unless-none") # 除了 value 是 None 不執行外,其他都要執行這個 serializer
def serialize_start_time(self, value: datetime | None, info: FieldSerializationInfo):
# 如果是 model_dump_json()
if info.mode_is_json():
return make_isoformat(value)
# 如果是 model_dump()
return make_utc(value)

使用這個 serializer:

m = Model()  # 原本的 start_time 是沒有時區資訊
m.model_dump() # {'start_time': datetime.datetime(2024, 8, 13, 23, 8, 20, 313152, tzinfo=datetime.timezone.utc)}
m.model_dump_json() # '{"start_time":"2024-08-13T23:08:20Z"}'

Default

class Model(BaseModel):
id_: int = Field(alias="id")
last_name: str = Field(alias="lastName", default="Doe") # 建立 default value

YouTube Channel

https://www.youtube.com/@mathbyteacademy

Provides additional Python content, including the following you might find useful: