🎃

dataclassを捨ててpydanticに乗り換える

2023/07/22に公開

Pydanticが今最高にCool

こんにちは、極論モンスターのYosematです。pydanticに替えてdataclassを使う理由は今ほとんどありません。pydanticがV2になったこのタイミングでpydanticに乗り換えましょう。この記事ではなぜdataclassよりもpydanticなのか理由を述べていきます。

※2024/02/26追記 OpenAIのクライアントもPydanticを採用しました


素敵なブログからの引用。ただし現在はdataclassもslotを導入している。slotを利用して通常より高速にフィールドアクセスしたい人はattrsやdataclassもアリ。

理由① より洗練されたインターフェース

pydanticをdataclassに代えて使うのはなんといってもかゆいところに手が届くインターフェースです。はっきりいってdataclassも素晴らしいライブラリでちょっとした工夫でたいていの差は埋められますが、何度も何度も使う基本的な機能だけにimport文1つの差でも大きなユーザビリティの差として感じられます。

Serialize (as dict or json)

Pydanticの場合

pydanticはSerialize用の機能がモデルに備わっていてメソッドとして使うdatetime型の自動変換などのかゆいところに手が届く

from datetime import datetime

from pydantic import BaseModel


class Product(BaseModel):
    name: str
    price: int


class Delivery(BaseModel):
    timestamp: datetime
    products: list[Product]


deliv = Delivery(
    timestamp=datetime.fromisoformat("2020-01-02T03:04:05Z"),
    products=[Product(name="sushi", price=1000)],
)
deliv_dict = deliv.model_dump()  # ◎
# >> {'timestamp': datetime.datetime(2020, 1, 2, 3, 4, 5, tzinfo=datetime.timezone.utc), 'products': [{'name': 'sushi', 'price': 1000}]}
deliv_json = deliv.model_dump_json()  # ◎
# >> {"timestamp":"2020-01-02T03:04:05Z","products":[{"name":"sushi","price":1000}]}

dataclassの場合

dataclassはSerialize用の機能がモジュールに備わっていてimportして使う。json化するときはdictを経由しjsonライブラリをimportする。

import json
from dataclasses import asdict, dataclass
from datetime import datetime


@dataclass
class Product:
    name: str
    price: int


@dataclass
class Delivery:
    timestamp: datetime
    products: list[Product]


deliv = Delivery(
    timestamp=datetime.fromisoformat("2020-01-02T03:04:05Z"),
    products=[Product(name="sushi", price=1000)],
)
deliv_dict = asdict(deliv)  # ◎
# >> {'timestamp': datetime.datetime(2020, 1, 2, 3, 4, 5, tzinfo=datetime.timezone.utc), 'products': [{'name': 'sushi', 'price': 1000}]}
deliv_json = json.dumps(asdict(deliv))  # ×
# >> Object of type datetime is not JSON serializable

Deserialize (from dict or json)

pydanticの場合

pydanticはネストされたdictやjson形式のstrからモデルインスタンスを生成できる。
変数を1つずつ渡す通常のモデル初期化に加えいちいち展開しなくても良いmodel_validateやjson形式のstrから直接生成できるmodel_validate_jsonをclassmethodとして備えている。
__init__を直接呼ばないclassmethodを使ったインスタンス生成はRustなどのモダンで硬い言語でも採用されているCoolな方法。

deliv_dict = {
    "timestamp": datetime(2020, 1, 2, 3, 4, 5, tzinfo=timezone.utc),
    "products": [{"name": "sushi", "price": 1000}],
}

deliv_json = (
    '{"timestamp":"2020-01-02T03:04:05Z","products":[{"name":"sushi","price":1000}]}'
)
deliv_from_dict = Delivery(**deliv_dict)
deliv_from_dict = Delivery.model_validate(deliv_dict)
deliv_from_json = Delivery.model_validate_json(deliv_json)

dataclassの場合

dataclassの場合ネストされたdictからインスタンスを一発で生成することはできないのでネストされた要素を1つ1つモデルに変換していく。jsonはdictを経由しなければならないしdatetimeは自前で変換しなければならない。

deliv_dict = {
    "timestamp": datetime(2020, 1, 2, 3, 4, 5, tzinfo=timezone.utc),
    "products": [{"name": "sushi", "price": 1000}],
}

deliv_json = (
    '{"timestamp":"2020-01-02T03:04:05Z","products":[{"name":"sushi","price":1000}]}'
)

deliv_from_dict = Delivery(
    timestamp=deliv_dict["timestamp"],
    products=[
        Product(name=p["name"], price=p["price"]) for p in deliv_dict["products"]
    ],
)

deliv_dict_from_json = json.loads(deliv_json)
deliv_from_json = Delivery(
    timestamp=datetime.fromisoformat(deliv_dict_from_json["timestamp"]),
    products=[
        Product(name=p["name"], price=p["price"])
        for p in deliv_dict_from_json["products"]
    ],
)

その他のPydanticの魅力的なインターフェースたち

dictからだけじゃない、スゴいインスタンス生成

pydanticはmodel_configという特殊なモデルの設定変数がある。これにfrom_attributes引数を1つ通してやるだけで、どんな型のオブジェクトからでもPydanticモデルに変換することができる。使うのはいつもどおりのmodel_validate

from dataclasses import dataclass

from pydantic import BaseModel, ConfigDict


class Product(BaseModel):
    model_config = ConfigDict(from_attributes=True)

    name: str
    price: int


@dataclass
class ProductDC:
    name: str
    price: int


product_dc = ProductDC(name="sushi", price=1000)
product_pd = Product.model_validate(product_dc)

Shallow/Deep Copy

copyモジュールからimportしなくてもメソッドとしてmodel_copyが生えている。

from pydantic import BaseModel


class Product(BaseModel):
    name: str
    price: int


product = Product(name="sushi", price=1000)
product_deepcopy = product.model_copy(deep=True)

理由② Run-time Validation

そしてもちろんRun-time Validationが効くのもdataclassにはない魅力です。一般的にはこちらがメインで紹介されるpydanticの魅力。

pydanticの場合

int型の変数にstrを入れたりするとエラーになります。

from pydantic import BaseModel

class Product(BaseModel):
    name: str
    price: int


def create_product(prod_duct: dict) -> Product:
    return Product(**prod_duct)

prod_deliv = {"name": "sushi", "price": "invalid_value"}
prod = create_product(prod_deliv)  # Raises ValidationError

dataclassの場合

dataclassを使ってしまうとint型にstr型の値を代入してもエラーが出ません。
静的解析は強力ですが完璧ではありません。下の例では静的解析をすり抜けて不適切な値がpriceに代入されてしまっています。

from dataclasses import dataclass

@dataclass
class Product:
    name: str
    price: int


def create_product(prod_duct: dict) -> Product:
    return Product(**prod_duct)

prod_deliv = {"name": "sushi", "price": "invalid_value"}
prod = create_product(prod_deliv)  # Runs without error

さらに発展的なバリデーション

pydanticの動的解析では更に発展的なバリデーションも行えます。
field_validatorなどの素敵な機能もあるのですが、ここでは私のおすすめのAnnotatedを使ったバリデーションをご紹介。

Annotated[T, AfterValidator(valid_func)]

以下の例では値が整数の二乗であるかをcheck_squares関数によってチェックします。このように関数をAnnotatedAfterValidatorを使って登録することで型レベルだけでなく値レベルでカスタムロジックでバリデーションできるようになるのはdataclassにはない最高の機能だと思います。

from typing import Annotated

from pydantic import BaseModel
from pydantic.functional_validators import AfterValidator

def check_squares(v: int) -> int:
    assert v**0.5 % 1 == 0, f"{v} is not a square number"
    return v


SquaredNumber = Annotated[int, AfterValidator(check_squares)]

class DemoModel(BaseModel):
    square_numbers: list[SquaredNumber] = []

valid_model = DemoModel(square_numbers=[1, 4, 9])
invalid_model = DemoModel(square_numbers=[1, 4, 13])  # Raises ValidationError

その他の詳しい機能について詳しくはvalidatorを参照!

まとめ

以上踏まえるとdataclassを使う理由はありません(極論)!
バージョンがV2になり、FastAPIにも導入されたこのタイミングでpydanticに乗り換えましょう。Yosematでした。

Discussion