LoginSignup
98
54

More than 1 year has passed since last update.

PydanticがRustで爆速になるという話

Last updated at Posted at 2022-08-21

はじめに

最近ポッドキャスト聴く時間が少し減ってしまったんだけど、久しぶりに Talk Python to Me を聴いたらPydanticの話題でした(エピソードのリンクはこちら)。作者のSamuel Colvinさんが秋に予定しているメジャーバージョンアップの話をし始めたのですが、冒頭で「コアをRustで実装して17倍速くなる」と言っていて、リンク張られていたドキュメントを読みました。この記事はそこで語られていた内容を中心にPydantic v2についてご紹介します。

Pydanticとは

v2の話の前に、そもそもPydanticとは何かについて簡単に触れておきます。PydanticはPythonの型ヒント情報を使ってデータバリデーション(データの妥当性検証)を行うライブラリです。予めデータの構造を定義しておいて、入力されたデータがその構造に合っているかを調べてくれます。

例えば、id(整数)とname(文字列)の二つのフィールドを持つデータ構造を考えます。それを Pydanticのモデルで定義するとこうなります。

from pydantic import BaseModel

class User(BaseModel):
    id: int
    name: str = "John Doe"

BaseModelというクラスを継承して、クラスに型ヒント付きで定義するだけです。nameにはデフォルト値として "John Doe"が設定されています。これを使って幾つか試してみます。

>>> userdata = {
...     "id": 123,
...     "name": "Taro Yamada",
... }
>>> user = User(**userdata)
>>> user
User(id=123, name='Taro Yamada')
>>> user.id
123
>>> user.name
'Taro Yamada'

問題なく Userクラスのデータとして取り込めています。一方で、idが欠けたデータを使っています。

>>> userdata = {
...     "name": "Taro Yamada",
... }
>>> user = User(**userdata)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "pydantic/main.py", line 341, in pydantic.main.BaseModel.__init__
pydantic.error_wrappers.ValidationError: 1 validation error for User
id
  field required (type=value_error.missing)

「idというフィールドが必要だよ」というエラーになります。ちなみに、 nameが欠けたデータを使うとデフォルト値が使われるので成功します。つまり、デフォルト値の無いものは必須フィールドと扱われるようです。

今度は idはあるけれども、整数ではなく文字列なデータを使ってみます。

>>> userdata = {
...     "id": "abc",
...     "name": "Taro Yamada",
... }
>>> user = User(**userdata)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "pydantic/main.py", line 341, in pydantic.main.BaseModel.__init__
pydantic.error_wrappers.ValidationError: 1 validation error for User
id
  value is not a valid integer (type=type_error.integer)

「idは整数じゃ無いとダメだよ」というエラーになります。

なお "id": "123"とすると文字列だったとしてもバリデーションが通ります。勝手に(親切に)文字列から整数への変換を行なってからチェックしてくれているのですが、もし厳密に整数のみを受け付けたいということであれば、intの代わりにpydantic.StrictIntを使うとできます。

新バージョンV2

V2までの道のり

ようやくここから本題ですが、まずはスケジュール予定です。V2を出すまでにどういうステップを踏むのか、Roadmapという形で記載されています。なお、私が見ているドキュメントはは2022年7月10日にアップデートされたものなので、これが書かれた時点から既に進んでいる部分もあります。

  1. pydantic-core (後述) にもう少し機能追加して最初のバージョンをリリースする
  2. V1.10向けのマージ作業をする
  3. V1.10を出す
  4. V1.10に入らなかった古くなってしまったPRにごめんなさいする
  5. 良い機会なので、mastermainに名称変更する
  6. mainをV2向けとする
  7. PydanticをV2向けに変更し始め、既存のテストがどれくらい通るかを見る
  8. テストが通るようにして、また変更して、を繰り返す
  9. V2をリリースする!

これを、10月末までに、遅くとも年末までにはやり切る!と言ってます。マジか。チャレンジャーだなぁ。

Pydantic-core

多くの開発者の貢献もあってPydanticは様々なところで利用されるようになりましたが、中心のロジックは最初のリリースからほぼ変わっていません。そこで、V2は作り直しの良い機会と考えて、コア部分を pydantic-core として切り出すことにしました。Pydantic-coreはここ数ヶ月をかけて作ってきたもので Rustで書かれています。それを PyO3というライブラリを使ってPythonから呼べるようにしています。Rustを使った理由は以下の3つです。

  1. 性能
  2. コードの可視性と拡張性
    • 関数呼び出しが速いので、コアのロジックを小さなバリデーターを互いに呼び出す形で実装できる
  3. 安全性
    • pydantic-coreは複雑なコードで多くの種類のエラーを区別する必要がある。Rustはそういうの得意なのでバグ少なく実装できる(と思う)

V2で大きく変わるところ

性能

ここにベンチマーク結果がありますが、V2はRustで実装し直したことによってV1と比べて4倍から50倍速くなっていて、通常、約17倍速くなっているとのこと。

Strict Mode

上の例で、文字列の"123"が整数の123に自動で変換されてバリデーションを通ってしまうことを説明しました。それを避けるためには特別な型を指定しなければならなかったのですが、V2では "strict mode"(厳密モード)が追加されて、モデルやフィールドごとに指定出来るようになります。

データ変換ルールの明確化

厳密モードではない時にはデータは自動変換されますが、その時の挙動も明確化されます。例えば、データが欠落する場合はエラーになります。例えば、123.1intで受けたり、2020-01-01T12:00:00date型で受けようとした場合。

JSONサポートを内包

V2ではJSONをパースしてそのままモデルや対象の型に変換していきます。(これまでは一旦JSONのパースをしてdict型のデータを作ってそこからモデルや対象の型に変換して行ったのかな)

モデルなしのバリデーション

V1ではバリデーションを行うためにはモデルが必要で、そのために無駄にモデル作ったり性能面でもペナルティがありました。V2では、単独の文字列、Datetime型、TypedDict型、データクラス型、URLなどを直接バリデーションできるようになります。

「必須」と「Null可」のクリーンアップ

V1ではフィールドの値が必須であることとNullを入れられることが混同されがちでした。V2ではそこを明確にします。Null可であることは、型定義で | Noneを追加することで表し、必須であるかどうかはデフォルト値を設定するかどうかで表します。

from pydantic import BaseModel

class Foo(BaseModel):
    f1: str  # 必須でNullにすることはできない
    f2: str | None  # 必須だけどNullを指定することは可能
    f3: str | None = None  # 必須ではなく、Nullを指定することも可能
    f4: str = 'Foobar'  # 必須ではないが、Nullには出来ない

バリデーター関数の改善

V1ではバリデーター関数は順番に適用される形でしたが、V2では入れ子構造で呼べるようになります。これまでは、pre=Trueなどを駆使して順序のコントロールをしていましたが、V2からは条件によっては不要なバリデータの呼び出しをスキップできたりするのでより効率的にバリデーションを行えます。また、入れ子のバリデータがバリデーションエラーになった時にそれをキャッチしてデフォルト値を返すということも出来るようになります。

よりパワフルなエイリアス

(こういう例が提示されているのですが、まだ実行環境がなくてよく理解できていません。おそらく「bazのindex=2の要素のquxの値をbarの値にする」ということが出来るのだろうと想像しています)

from pydantic import BaseModel, Field


class Foo(BaseModel):
    bar: str = Field(aliases=[['baz', 2, 'qux']])


data = {
    'baz': [
        {'qux': 'a'},
        {'qux': 'b'},
        {'qux': 'c'},
        {'qux': 'd'},
    ]
}

foo = Foo(**data)
assert foo.bar == 'c'

ダンプ、シリアライズ、エクスポートの改善

V2からはmodel.dict()でJSON準拠の型のみを使うエキスポートが出来るようになります。V1では例えば datetime 型はそのまま書き出されていて、json.dumps()するとエラーになっちゃいます(直接model.json()すれば大丈夫 )。

バリデーションのコンテキスト

model_validatemodel_validate_jsonでコンテキスト情報を引数で渡せるようになります。コンテキストは辞書型のデータで任意のキーで情報を引き渡しできます。

以下の例では、サポートしている国コードの情報をDBから取得してそれを渡しています。そしてバリデータ側でそれを使って home_countryフィールドの国コードがその中に含まれているかどうかのチェックをしています。

from pydantic import BaseModel, EmailStr, validator

class User(BaseModel):
    email: EmailStr
    home_country: str

    @validator('home_country')
    def check_home_country(cls, v, context):
        if v not in context['countries']:
            raise ValueError('invalid country choice')
        return v

async def add_user(post_data: bytes):
    countries = set(await db_connection.fetch_all('select code from country'))
    user = User.model_validate_json(post_data, context={'countries': countries})
    ...

モデル名前空間のクリーンアップ

Pydanticで作るモデルは pydantic.BaseModelを継承しています。そのため、ここで定義されているメソッド名と同じフィールド名を持つモデルは作れませんでした。V2ではこれを model_で始まるメソッド名に統一して解りやすくします。具体的には以下のようになります

  • .model_validate() (元 .parse_obj()
  • .model_validate_json() (元 .parse_raw(..., content_type='application/json')
  • .model_is_instance()(新設)
  • .model_is_instance_json()(新設)
  • .model_dump() (元 .dict()
  • .model_dump_json() (元 .json()
  • .model_json_schema() (元.schema()
  • .model_update_forward_refs() (元 .update_forward_refs()
  • .model_construct() (元 .construct()
  • .model_customize_schema() (新設)
  • ModelConfig (元 Config

そして、以下は削除されます

  • .parse_file() - Pydanticに入れたの間違いでした
  • .parse_raw() - Jsonを読むのは .model_validate_json()へ。それ以外は間違いでした
  • .from_orm() - configに移動
  • .schema_json() - json.dumps(m.model_json_schema())で置き換え
  • .copy() - __copy__を実装してcopyモジュールをつかってもらうことにします。

Strict APIとAPIドキュメンテーション

V2では公開APIと内部関数やクラスを明確に切り分けます。内部のオブジェクトは _internalサブパッケージに入れて利用しにくくしています。公開APIにはドキュメントが作られます。mkdocstringsを使う予定です。

エラー記述

V2ではより洗練されたエラー表示がされるようになります。その中にはそのタイプのエラーの詳細を記述したWebページへのURLも含まれていてどんなエラーが起きているのかを理解しやすくなっています。

Pure Python

pydantic-coreはRustで書かれるのでバイナリパッケージがインストールできる特定の環境のみで動作します。サポート予定の環境は以下の通りです。

  • Linux: x86_64, aarch64, i686, armv7l, musl-x86_64 , musl-aarch64
  • MacOS: x86_64, arm64 (python 3.7を除く)
  • Windows: amd64, win32
  • Web Assembly: wasm32 (wasm上のPythonは問題あるみたいだけど、pydantic-coreはwasm32でコンパイルしてユニットテスト通します)

一方、PydanticそのものはPure Pythonになります。V1では性能改善のためにcythonを使って利用環境向けにコンパイルしていましたが、V2ではそのようなパートは pydantic-coreに寄せられています。これによってpydanticのコードサイズは小さくなり、ユニットテストも速くなりました。

is_instance的なチェック

.model_is_instance()メソッドによって、例外処理を使わずにそのデータが対象のモデルに変換可能かを True/Falseで知ることが出来るようになります。

Parseって使うのをやめるよ

V1では"parse"という言葉と"validate"という言葉をメソッド名などで混在して使っていました。Pydanticはバリデーションだけのライブラリではないですが、ほとんどの人は "validate"を使うのでそちらで統一することにしました。

カスタムフィールド型の変更

バリデーターの呼び出し方を変更したので、V1の __get_validators__を使ったカスタムフィールド型の定義方法は使えなくなってしまいました。代わりに、__pydantic_validation_schema__という属性を見るようにして、そこに pydantic-core互換のスキーマを書けばそれを適用する形にしました。

その他変更

  1. 巡回参照による再帰モデルを定義できます。V1でも出来ましたが、Pythonの再帰呼び出し上限(1000)に引っかかりでエラーが出てしまうことがありました。V2ではRustで実装されていて正しく処理できます。
  2. pydantic-coreをwasmでコンパイルして動かすことにこだわっているのは、Pydantic V2のコード例をブラウザの上で編集して実行出来るようにするためです。
  3. total=Falseの場合も含めて TypedDictをサポートしました。
  4. from_ormfrom_attributesとなり、スキーマ生成時に定義されることになります(モデルのConfigかフィールドのConfigにて)
  5. input_valueがValidationErrorに追加されてなぜエラーが起きたのかがわかりやすくなります。
  6. スキーマでon_errorロジックを定義できて、エラーの時にデフォルト値をセットするのか、その値を無いことにするのかを選べるようになりました。
  7. datetime, date, time, timedeltaのバリデーションが改善しました。このために speedateというRustのライブラリを新たに作りました。
  8. 入れ子になったスキーマの設定をマージあるいは上書きするために「優先度」の仕組みができました。
  9. annotated-typesのサポートを行うので、Annotated[set[int], Len(0, 10)](整数のセット型、ただし要素数は0から9まで)とか、Annotated[str, Len(1, 1024)](文字列、ただし文字数は1から1023まで)という型をバリデーションできます
  10. 一種類のvalidateというデコレータを色々な場所で使えます。
    • 関数 (validate_argumentsの置き換え)
    • データクラス。pydantic.dataclasses.dataclassはこれのエイリアスになる
    • TypedDict
    • サポートしている全ての型。例えば、 Union[...], Dict[str, Thing]など
    • カスタムフィールド型。__pydantic_schema__ 属性を持つすべてのもの
  11. バリデーションエラーを簡単に作成する方法を提供します。特にモデルの外で出来る方法が欲しい(どうするかはまだ決まっていない?)
  12. モデルの __eq__の性能改善
  13. 計算されたフィールド。アイディアはずっとあって、ちゃんとしたものを作るべき
  14. サブクラスのインスタンスがデータを漏らさずにモデルをバリデーションする方法を提供します(よくわからない??)
  15. バージョンはsemvarのルールに従います
  16. ジェネリックス型で M(GenericModel, Generic[T])と書かなければならなかったのを、GenericModelは廃止して M(BaseModel, Generic[T])と書けるようにします。

削除される機能と制限

  1. モデルなしでバリデーションできるようになるので__root__カスタムルートモデルは不要になります。
  2. .parse_file() は削除されます。 .parse_raw()の一部機能(jsonバリデーション)は .model_validate_json()に引き継がれ、それ以外は削除されます。
  3. .schema_json().copy()
  4. TypeErrorはバリデーションエラーとして扱われなくなりました。関数バリデーターで引数名のエラーをキャッチするのに内部で使われます。
  5. 組み込み型の str, bytes, intのサブクラスはなくなりました。これらのバリデーションをRustで書いた pydantic-coreでやるためですが、必要であれば wrap バリデーターかカスタムタイプバリデーターを使うことができます
  6. 整数はRustのi64で表現されることになります。これを超える数値を扱いたければ wrapバリデーターを書くことで実現できます。
  7. 設定管理。機能をなくすつもりはないけど、Pydanticとは別のパッケージにするか、Pydanticのextraにして、pip install pydantic[settings]でインストールできるようにするとか(まだ決まっていない模様)
  8. 以下のConfigのプロパティは削除されます
    • fields - Fieldが出来る前からあったもので、削除可能です。
    • allow_mutation - frozen で代用
    • error_msg_templates - ちゃんとドキュメント化されておらず、エラーメッセージは外部ロジックでカスタマイズ可能
    • getter_dict - pydantic-core はハードコードされた from_attributes ロジックを持っている
    • json_loads - これもpydantic-coreでハードコードされている
    • json_dumps - 多分消す
    • json_encoders - 上のモードの議論を参照してください
    • underscore_attrs_are_private - デフォルト値で PrivateAttrつければ良いだけ
    • smart_union - 全てのunionsは "smart"になりました

まとめ

Pydantic V2についてまとめてみました。非互換の変更が加わるのでこれまでのコードの書き換えが一定必要になりそうですが、これだけ速度向上があるとそれも前向きにできそうです。そして、速度だけでなく色々とクリーンナップされているので全体的にスッキリ書けるようになるのではないかと期待しています。

なお、これまでPythonでパフォーマンスを上げるための手段として SWIG, Pyrex, Cythonなどが提案されてきましたが、Pydantic V2ではRustを使って速度向上するという新しい手段を取っていて、それを可能にしている PyO3というライブラリにもちょっと興味があるので、時間を見つけてそちらも見てみたいと思います。

98
54
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
98
54