バリデーションとシリアライズができるdataclassとしてPydanticを使う
FastAPIなどのフレームワークと組み合わせて使われることが多い印象のPydanticですが、それ単体でdataclassの高機能版として使えますよ、という紹介です。
Pydanticとは
Pythonでおそらく最も使われているバリデーションのためのライブラリです。型アノテーションを用いてバリデーションやシリアライズを行なってくれます。
最初のモデル
まずは概観です。
BaseModelを継承させたクラスに、dataclassのように型アノテーション付きでフィールドを記述していけば、インスタンス化時に動的に型チェックを行なってくれるようになります。(invalid_userは、nameにstrではなくintの値をセットしようとして、バリデーションエラーとなってます。)
# https://docs.pydantic.dev/latest/より抜粋
from pydantic import BaseModel
class User(BaseModel):
id: int
name: str = "Jane Doe"
user = User(id="123")
assert isinstance(user.id, int)
assert user.name == "Jane Doe"
invalid_user = User(id=999, name=999)
# pydantic_core._pydantic_core.ValidationError: 1 validation error for User
# name
# Input should be a valid string [type=string_type, input_value=999, input_type=int]
# For further information visit https://errors.pydantic.dev/2.4/v/string_type
V2
現在 (2023/10) の最新メジャーバージョンは2で、2023/6にリリースされました。
ソフトウェアアーキテクチャもAPIも大きく変わっています。経緯はこちら↓の記事が参考になりますが、コアとなる実装を切り出してRustで再実装したようで、V1と比べて17倍高速になったそうです。
本投稿ではV2を使っていきます。
dataclassの代わりとして使うためのTips
以下の環境で手を動かしながら具体的にPydanticを触っていきます。
$ python --version
Python 3.12.0
$ pip install pydantic==2.4.2
$ pip freeze
annotated-types==0.6.0
pydantic==2.4.2
pydantic_core==2.10.1
typing_extensions==4.8.0
先ほどよりももう少し複雑なモデルを定義してみます。
import re
from datetime import datetime
from pydantic import BaseModel, field_validator
class Coupon(BaseModel, frozen=True):
code: str
issued_at: datetime
@field_validator("code")
def validate_code(cls, v):
pat = r"[a-z]{3}[0-9]{3}"
if not re.fullmatch(pat, v):
raise ValueError(f"`code` must be match with pattern: {pat}")
return v
class User(BaseModel, frozen=True):
id: int
name: str
coupons: list[Coupon] = []
入れ子の構造
フィールドに別のモデルクラスを型アノテーションすることで、User:Couponが1:Nの関係を表現できます。
user_1 = User(
id=1,
name="Taro Yamada",
coupons=[Coupon(code="aaa111", issued_at=datetime.now())],
)
# id=1 name='Taro Yamada' coupons=[Coupon(code='aaa111', issued_at=datetime.datetime(2023, 10, 14, 22, 13, 38, 881408))]
dictやJSON文字列との変換
**{...}
で渡すことでdictからもインスタンスを作れます。JSON文字列の場合は、model_validate_json()メソッドに渡せばインスタンス化してくれます。
datetime型への変換もよしなにやってくれますが、書式が合わなければエラーになります。
user_2 = User(
**{
"id": 2,
"name": "Jiro Suzuki",
"coupons": [{"code": "bbb222", "issued_at": "2023-10-01T11:22:33"}],
}
)
user_3 = User.model_validate_json(
(
'{"id": 3, "name": "Yoshio Tanaka", "coupons": [{"code":"ccc333", "issued_at":"2023-10-01T11:22:33"}]}'
)
)
最初の例と同様に、型が合わなければエラーになります。
invalid_user = User(**{"id": "X", "name": "Ichiro Kimura"})
# 1 validation error for User
# id
# Input should be a valid integer, unable to parse string as an integer [type=int_parsing, input_value='X', input_type=str]
今度は逆で、model_dump()メソッドでdictへ、model_dump_json()メソッドでJSON文字列へ、それぞれ変換できます。
user_2_dict = user_2.model_dump()
# {'id': 2, 'name': 'Jiro Suzuki', 'coupons': [{'code': 'bbb222', 'issued_at': datetime.datetime(2023, 10, 1, 11, 22, 33)}]}
user_3_json = user_3.model_dump_json()
# '{"id":3,"name":"Yoshio Tanaka","coupons":[{"code":"ccc333","issued_at":"2023-10-01T11:22:33"}]}'
カスタムバリデーション
フィールドに自前でバリデーションを作って設定することもできます。Couponでは、@field_validator("code")
にてcodeフィールドの書式を指定 (英小文字3桁 + 数字3桁) してますので、マッチしなければエラーになります。
invalid_coupon = Coupon(**{"code": "AAAAAA", "issued_at": "2023-10-01T11:22:33"})
# 1 validation error for Coupon
# code
# Value error, `code` must be match with pattern: [a-z]{3}[0-9]{3} [type=value_error, input_value='AAAAAA', input_type=str]
Userはfrozen=True
で定義されていますので、フィールドを直接書き換えるとエラーになります。
user_1.name = "X"
# 1 validation error for User
# id
# Instance is frozen [type=frozen_instance, input_value="X", input_type=str]
そのため、値を更新する場合は、新たにインスタンスを作成する必要があります。
model_copy()メソッドのupdateパラメータにて更新したいフィールドと値を渡せば、その他の値はコピーされたインスタンスを作ることができます。
deep=True
とすれば、dictやdataclassでは面倒だったディープコピーも簡単にできます。
updated_user_1 = user_1.model_copy(update={"name": "X"}, deep=True)
# id=1 name='X' coupons=[Coupon(code='aaa111', issued_at=datetime.datetime(2023, 10, 14, 22, 39, 49, 138396))]
フィールドは存在するか? Noneを許すか?
dictやJSON文字列などとやりとりするときに、しばしば厄介なのが、フィールド (キー) 自体が存在するかどうかと、フィールドのNoneを許容するかどうかの取り扱いです。
Pydanticでは以下のように記述することで区別できます。
class Point(BaseModel):
x: int # フィールドも値も必須
y: int | None # フィールド必須、値にはNone可
z: int | None = None # フィールド任意、値にはNone可
point_1 = Point(x=1, y=2)
# x=1 y=2 z=None
point_2 = Point(x=1, y=None)
# x=1 y=None z=None
invalid_point = Point(x=1)
# 1 validation error for Point
# y
# Field required [type=missing, input_value={'x': 1}, input_type=dict]
まとめ
肩肘張らずに "dataclassよりさらにええやつ" としてPydanticを使ってみよう、という記事でした。
Discussion