データ型検証ライブラリ Pydantic v2の紹介

みなさん、こんにちは。 ニカチョーのバックエンドエンジニア武じい(@pouhiroshi) です。
ニカチョーというのは、開発部二課の課長のことです。

弊社の開発部(プロダクトチーム)はマルチプロダクトを推進すべく、最近3チームに分かれるという変化がありました。
そのうちのDev2チームのリーダーをやることになりました。部は課からできてますよね。だからDev2は二課かな。そのリーダーなので課長かな?つまりニカチョー?
これまで役職がついたことがないので、ニカチョー頑張っていきたい所存です。(特に何か変わるわけではないのですが、気分的なアレです)

さて、今回はPythonのライブラリPydanticのバージョン2についての紹介です。

Pydanticとは?

Pydanticとは、Pythonで利用できるデータ型の検証ライブラリで、Webシステムの入力チェック(バリデーション)を簡単、シンプルに実装することができます。
FastAPIというWebシステムフレームワークで公式にサポートされており、弊社の請求書サービスでもFastAPI x Pydantic(ただしversion1)の組み合わせで利用しています。

xmart.co.jp


FastAPIだけではなく、他のFW(Djangoなど)や普通のPythonプログラムを使うというだけでも利用可能です。
FastAPIを使っていなくても、Pydanticは便利です – Attsun Blog

「FastAPIを使っていなくても、Pydanticは便利です」

ごちゃりがちなバリデーション関係をすっきり実装できる便利なライブラリですので、興味のある方はぜひ触っていただけるとPydanticの良さをわかっていただけると思います。

また、Pydanticはスキーマ駆動開発に相性の良いライブラリだと日頃から感じています。この辺の話はまた別の機会にしたいと思います。
(↓とてもわかりやすくまとめていただいている記事がありました。)
スキーマ駆動開発ってなに?便利なの?って方へ。

Pydantic version2がリリース

さて、本題です。
最近、Pydantic version2(以下v2)がリリースされ、FastAPIも正式にv2対応されたとのアナウンスがありました。
Pydantic v2を使うメリットは第一には速度。
v1に比べて22.5倍の速度が出たそうです。スピードこそパワー!! これだけでもv2を使うメリットがありますね!

パフォーマンスアップ

https://slides.com/samuelcolvin/how-pydantic-v2-leverages-rust-s-superpowers#/13/0/0

あとは v1からv2へのマイグレーション(変換)ツール bump-pydanticが用意されていること。
https://docs.pydantic.dev/latest/migration/#code-transformation-tool

マイグレーションツール

メジャーバージョンアップの対応が困難すぎて、バージョンアップを諦める、というようなことはよくあることですが、
Pydanticはその点、安心・親切ですね!

あとは、諸々の便利な新機能が登場しています。紹介しきれない機能がたくさんあるのですが、今回はPydantic作者の Samuel Colvinさん2023 PyCON USで発表された Pydantic v2の新機能に沿って、ご紹介していきたいと思います。
(そして、請求書サービスのPydanticバージョンアップの予備知識としても活用していきたいと思います!)
slides.com

PydanticはFastAPIのおかげで成長したとおっしゃっていて、仲の良さが伺えますね。

ありがとうセバスチャン(FastAPI作者)


それでは、紹介する新機能一覧です。たくさんありますが、どうぞお付き合いください。

新機能1 ストリクトモード 別名ペダントモード

https://slides.com/samuelcolvin/how-pydantic-v2-leverages-rust-s-superpowers#/14

ストリクト(厳正)モード

以前は整数の定義をしたフィールドに文字列の整数値(例えば"42" Samuelさん、42歳なのですかね笑)を与えても、バリデーションエラーにならずに自動でintに変換してくれていました
ただ、明示的にエラーにしたい場合もあるので、その場合は型をpydantic.StrictIntを使うという手間がありました。

v2ではその代わりに、model_configにstrict=True をすることで、厳密なチェックが行われるようになりました。
厳しい型チェックを望むエンジニア諸氏にはありがたい機能になりそうです。

新機能2 組み込みのJSON解析

https://slides.com/samuelcolvin/how-pydantic-v2-leverages-rust-s-superpowers#/15

以前は、json文字列をModelインスタンスへ変換する場合、BaseModel.parse_rawやjson.loadsでdictに変換したのち、Modelをインスタンス化するなどの必要がありました。

user = User.__pydantic_model__.parse_raw('{"id": 123, "name": "James"}')
print(user)
# id=123 name='James'
Otherwise, if you want to keep the dataclass:

json_raw = '{"id": 123, "name": "James"}'
user_dict = json.loads(json_raw)
user = User(**user_dict)

これが、v2ではmodel_validate_jsonで、jsonからmodelへの変換が一発で可能になりました。

user = User.model_validate_json('{"id": 123, "name": "James"}')
print(user)
# id=123 name='James' 

また、カスタムエラーを実装することも可能になりました。
https://docs.pydantic.dev/latest/errors/errors/#custom-errors

PydanticCustomErrorにメッセージなどを自分で設定し、raiseすれば自分好みのエラーを発生させることができます。

from pydantic_core import PydanticCustomError
from pydantic import BaseModel, ValidationError, field_validator

class Model(BaseModel):
    foo: str
    @field_validator('foo')
    def value_must_equal_bar(cls, v):
        if v != 'bar':
            raise PydanticCustomError(
                'not_a_bar',
                'value is not "bar", got "{wrong_value}"',
                dict(wrong_value=v),
            )
        return v

try:
    Model(foo='ber')
except ValidationError as e:
    print(e)
    """
    1 validation error for Model
    foo
      value is not "bar", got "ber" [type=not_a_bar, input_value='ber', input_type=str]
    """

また、カスタムエラーメッセージを作ることも可能となりました。
https://docs.pydantic.dev/latest/errors/errors/#customize-error-messages


エラーの箇所を行番号で知ることもできる便利機能も予定しているようです。

新機能3 ラップバリデータ 別名:たまねぎ

Pydanticのwrap validatorは、既存のバリデーターを別のバリデーターでラップするものです。これにより、ロジックをバリデーションの前や後に入れたり、新しいエラーを発生させたりデフォルト値を返したり
自由にバリデーターの振る舞いを変えることができます。mode="wrap"でバリデータを定義し、引数でValidatorHandlerを受け取るようにします。
これにより、通常のバリデーションを通過させつつ、別のロジックを組み込むことが可能です。

https://docs.pydantic.dev/latest/api/functional_validators/#pydantic.functional_validators.WrapValidator

from pydantic import BaseModel, field_validator
 
class Model(BaseModel):
    x: int
 
    @field_validator('x', mode='wrap')
    def validate_x(cls, v, handler):
        if v == 'one':
            return 1
        try:
            return handler(v)
        except ValueError:
            return -999
 
print(Model(x='one'))
#> x=1
print(Model(x=2))
#> x=2
print(Model(x='three'))
#> x=-999

新機能4 RecursiveModelの検証

Pydantic v1では、RecursiveModelを継承したモデルのフィールドにRecursiveModelを指定すると、無限ループが発生していました。
Pydantic v2では、このような無限ループを検知し、バリデーションエラーを発生させるようになりました。

from __future__ import annotations
from pydantic import BaseModel, Field, ValidationError

class Branch(BaseModel):
    length: float
    branches: list[Branch] = Field(default_factory=list)

print(Branch(length=1, branches=[{'length': 2}]))
#> length=1.0 branches=[Branch(length=2.0, branches=[])]

b = {'length': 1, 'branches': []}
b['branches'].append(b)

try:
    Branch.model_validate(b)
except ValidationError as e:
    print(e)
    """
    1 validation error for Branch
    branches.0
      Recursion error - cyclic reference detected 
        [type=recursion_loop, 
         input_value={'length': 1, 'branches': [{...}]}, 
         input_type=dict]
    """

新機能5 エイリアスパス

validation_aliasPydantic は、 AliasPathを使用する際の利便性のために AliasPathそしてAliasChoicesという2 つの特別な型を提供します。
AliasPathエイリアスを使用してフィールドへのパスを指定するために使用されます。例えば

from pydantic import BaseModel, Field, AliasPath

class User(BaseModel):
    first_name: str = Field(validation_alias=AliasPath('names', 0))
    last_name: str = Field(validation_alias=AliasPath('names', 1))

user = User.model_validate({'names': ['John', 'Doe']})  
print(user)
#> first_name='John' last_name='Doe'

この'first_name'フィールドでは、エイリアス'names'とインデックスを使用して0(名)へのパスを指定しています。この'last_name'フィールドでは、エイリアス'names'とインデックスを使用して1(姓)へのパスを指定しています。
AliasChoicesエイリアスの選択を指定するために使用されます。例えば:

from pydantic import BaseModel, Field, AliasChoices

class User(BaseModel):
    first_name: str = Field(validation_alias=AliasChoices('first_name', 'fname'))
    last_name: str = Field(validation_alias=AliasChoices('last_name', 'lname'))

user = User.model_validate({'fname': 'John', 'lname': 'Doe'})  
print(user)
#> first_name='John' last_name='Doe'
user = User.model_validate({'first_name': 'John', 'lname': 'Doe'})  
print(user)
#> first_name='John' last_name='Doe'

新機能6 ジェネリック

内部で型変数を使用してAnnotated、型に再利用可能な変更を加えることができます。
Modelの型定義に別の型定義を入れて、再利用できるようにする、、、?
(ちょっと理解できなかったので、サンプルコードだけ載せておきます)

from typing import Generic, TypeVar
from pydantic import BaseModel

DataT = TypeVar('DataT')

class Response(BaseModel, Generic[DataT]):
    error: int | None = None
    data: DataT | None = None

class Profile(BaseModel):
    name: str
    email: str

def my_profile_view(id: int) -> Response[Profile]:
    if id == 42:
        return Response[Profile](data={'name': 'John', 'email': 'john@example.com'})
    else:
        return Response[Profile](error=404)

print(my_profile_view(42))
#> error=None data=Profile(name='John', email='john@example.com')
Favorite = tuple[int, str]

def my_favorites_view() -> Response[list[Favorite]]:
    return Response[list[Favorite]](data=[(1, 'a'), (2, 'b')])

新機能7 シリアライゼーション

Pydantic では、「シリアライズ」と「ダンプ」という用語を同じ意味で使用します。どちらも、モデルを辞書または JSON エンコード文字列に変換するプロセスを指します。
以下のように、Modelが入れ子になったデータも model.dump()を使って、型を気にすることなくdict型へシリアライズできます。

from pydantic import BaseModel

class User(BaseModel):
    name: str
    age: int

class Profile(BaseModel):
    account_id: int
    user: User

user = User(name='Alice', age=1)
print(Profile(account_id=1, user=user).model_dump())
#> {'account_id': 1, 'user': {'name': 'Alice', 'age': 1}}

class AuthUser(User):
    password: str

auth_user = AuthUser(name='Bob', age=2, password='very secret')
print(Profile(account_id=2, user=auth_user).model_dump())
#> {'account_id': 2, 'user': {'name': 'Bob', 'age': 2}}

model_dump_json()や、カスタムシリアライザーなども用意され、自由なシリアライズが可能になっています。
https://docs.pydantic.dev/2.3/usage/serialization/#custom-serializers

色々なシリアライザーが増えました

新機能8(というか変更)BaseModelは必須ではなくなりました(AnalyzedTypeを使えば。)

基本的にpydanticで定義するモデルはBaseModelを継承するのが必須でしたが、必須ではなくなりました。
BaseModelを継承しなくても、代わりにAnalyzedTypeを利用することで検証が可能になりました。
Pydanticは使いたいけど、BaseModelを継承してまで使うかというとちょっと・・というケースにも対応できるようになりました。

from dataclasses import dataclass
from pydantic import AnalyzedType

@dataclass
class Foo:
    a: int
    b: int

@dataclass
class Bar:
    c: int
    d: int

x = AnalyzedType(Foo | Bar)
d = x.validate_json('{"a": 1, "b": 2}')

print(d)
#> Foo(a=1, b=2)

print(x.dump_json(d))
#> b'{"a":1,"b":2}'

終わりに

Pydantic v2の新機能をご紹介しましたが、いかがだったでしょうか。
Pydanticはすでにversion2.3まで進んでおり、今回紹介した以外にも良い改善がたくさん反映されています。
詳しくはブログや、Pydantic公式ドキュメントを参照されると良いと思います。

https://docs.pydantic.dev/latest/blog/pydantic-v2-final/
https://docs.pydantic.dev/latest/

弊社サービスも、このような便利で性能の良いライブラリをアンテナ高くキャッチアップし、サービスの質を向上させていきたいと考えています。

これからもプロダクトチームは定期的にテックブログ記事を執筆していきます。気になる方はぜひ購読ボタンを押していってください!


また、クロスマートではPM、エンジニア、BizDevなど広く一緒に働く仲間を募集中です。
ご興味がある方は、是非こちらを御覧ください!

xorder.notion.site


お読みいただきありがとうございました!