🚠

バリデーションとシリアライズができるdataclassとしてPydanticを使う

2023/10/14に公開

FastAPIなどのフレームワークと組み合わせて使われることが多い印象のPydanticですが、それ単体でdataclassの高機能版として使えますよ、という紹介です。

Pydanticとは

Pythonでおそらく最も使われているバリデーションのためのライブラリです。型アノテーションを用いてバリデーションやシリアライズを行なってくれます。

https://docs.pydantic.dev/latest/

https://github.com/pydantic/pydantic

最初のモデル

まずは概観です。
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倍高速になったそうです。

https://qiita.com/ksato9700/items/053e06f795d8a9b5d706

本投稿では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