👨‍💻

PydanticV2へのアップデート。苦労したことなど

2024/04/07に公開

概要

pydanticV1 → V2にアップデートする作業を行ったので、自分が道中調べたところなどをメモしていきます。
どなたかの参考になれば幸いです。

作業内容

BaseModelインスタンスで利用する関数名の変更

シンプルな変更です。
公式のMigration Guideにあるので粛々やってしまいます。
(あくまで非推奨なので元のままでも動きますが、やることはっきりしていて時間かからないので、やったほうが良いと思います。)

公式からの抜粋


https://docs.pydantic.dev/latest/migration/#changes-to-pydanticbasemodel

エディタで検索して置換していきます。(目視なしで全部やると他も引っかかりそうなので。。。もっと良い方法ご存知の方コメントいただけると嬉しいです。)

str型への甘えの修正(そもそもやっちゃダメ!)

オニオンアーキテクチャを組んでいて、フロントエンドへはNoneとかをわかりやすい表現に変換したいので文字列として送りたいけど、バックエンドでは数値で扱っているデータとかやってしまっていると、痛い目合います。(アップグレード関係なく、そもそもやめましょう。)
V1ではstr型にfloatを入れてもエラーが出ませんでしたが、V2ではしっかり出ます。
上記のような時は、ちゃんとdomainモデルとフロント用の出力モデルを分けて、関数でパースするようにしておきましょう。

class DomainSampleModel:
    value:float
    
class FrontResponseSampleModel:
    value:str
    
    def from_entity(self, entity:DomainSampleModel):
        self.value = str(entity.value)
        return self

BaseConfig → ConfigDict

いくつか廃止、変更されたものがあります。
基本的には公式の「Changes to config」に記載の通りです。
https://docs.pydantic.dev/latest/migration/#changes-to-config

ただ、個人的には、これに関しては以下のapi仕様書の方がわかりやすかったです。
V1からの変更も書いてくれています。
https://docs.pydantic.dev/2.0/api/config/

allow_mutation → frozenのdefalutがTrueからFalseに変わったのできをつけましょう。

自分がわかりにくかったのが、copy_on_model_validationの扱いです。
以下URLのように、validateの時のコピー動作がそもそもなくなり、オプション指定が必要なくなったようです。(背景実はよくわかっていない。)
https://github.com/pydantic/bump-pydantic/issues/62

validator → field_validator

自分が使っていた範疇なら、ほぼ名前の変更だけでなんとかなりました。
each_itemsが不要になった(複数指定するときは自動で捌いてくれる)くらいでした。
公式のMigration Guideの「Changes to validators」に書いてありますね。
https://docs.pydantic.dev/latest/migration/#changes-to-validators

V1ではよくcls, valueを使っていましたが、ValidationInfoをこの際使っていくようにしました。
(V1でも使えたかわかっていません。。。)

https://docs.pydantic.dev/latest/concepts/validators/
公式のありがたいサンプルコードを以下に記載

from pydantic import BaseModel, ValidationInfo, field_validator


class Model(BaseModel):
    text: str

    @field_validator('text')
    @classmethod
    def remove_stopwords(cls, v: str, info: ValidationInfo):
        context = info.context
        if context:
            stopwords = context.get('stopwords', set())
            v = ' '.join(w for w in v.split() if w.lower() not in stopwords)
        return v


data = {'text': 'This is an example document'}
print(Model.model_validate(data))  # no context
#> text='This is an example document'
print(Model.model_validate(data, context={'stopwords': ['this', 'is', 'an']}))
#> text='example document'
print(Model.model_validate(data, context={'stopwords': ['document']}))
#> text='This is an example'

root_validator → model_validator

結構変更多いです。
概念から変わっている印象を自分は受けたので、しっかりmodel_validatorの動きを勉強して臨みましょう。
既存のコードを文法変更するというよりは、公式の説明を見て再設計したほうが早いかもしれません。
自分はこのサンプルコードが一番イメージ動作しやすかったです。

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

公式のありがたいサンプルコードを以下に記載

from typing import Any

from pydantic import BaseModel, ValidationError, model_validator


class UserModel(BaseModel):
    username: str
    password1: str
    password2: str

    @model_validator(mode='before')
    @classmethod
    def check_card_number_omitted(cls, data: Any) -> Any:
        if isinstance(data, dict):
            assert (
                'card_number' not in data
            ), 'card_number should not be included'
        return data

    @model_validator(mode='after')
    def check_passwords_match(self) -> 'UserModel':
        pw1 = self.password1
        pw2 = self.password2
        if pw1 is not None and pw2 is not None and pw1 != pw2:
            raise ValueError('passwords do not match')
        return self


print(UserModel(username='scolvin', password1='zxcvbn', password2='zxcvbn'))
#> username='scolvin' password1='zxcvbn' password2='zxcvbn'
try:
    UserModel(username='scolvin', password1='zxcvbn', password2='zxcvbn2')
except ValidationError as e:
    print(e)
    """
    1 validation error for UserModel
      Value error, passwords do not match [type=value_error, input_value={'username': 'scolvin', '... 'password2': 'zxcvbn2'}, input_type=dict]
    """

try:
    UserModel(
        username='scolvin',
        password1='zxcvbn',
        password2='zxcvbn',
        card_number='1234',
    )
except ValidationError as e:
    print(e)
    """
    1 validation error for UserModel
      Assertion failed, card_number should not be included
    assert 'card_number' not in {'card_number': '1234', 'password1': 'zxcvbn', 'password2': 'zxcvbn', 'username': 'scolvin'} [type=assertion_error, input_value={'username': 'scolvin', '..., 'card_number': '1234'}, input_type=dict]

感想

大きなアプリケーションだと動作確認が大変ですが、自分の場合は基本BaseModelに頼っているので、その周りを注目すれば良かったです。
オニオンアーキテクチャをしっかり設計できていると(model部分を分離できていると)、こういったバージョンアップがかなりしやすいことを体感しました。これからも気をつけたいと思います。

また、今までなんとなくで理解していたコードを見直す良い勉強の機会になりました。
V1, V2両方の操作を想像して、施したであろう改良を想像、体感するのも楽しかったです。
素晴らしいエンジニアたちがせっかく良いものとしてバージョンアップしてくれているので、今後もできるだけついていきたいですね。

Discussion