Closed23

pydantic入門

Kumamoto-HamachiKumamoto-Hamachi

Pydanticとはなにか

https://pydantic-docs.helpmanual.io/

Data validation and settings management using Python type annotations.
pydantic enforces type hints at runtime, and provides user friendly errors when data is invalid.
Define how data should be in pure, canonical Python; validate it with pydantic.

データのバリデーションや型注釈の設定に使われるモジュール。

なにがメリットなの?

ランタイムでの型ヒントを強制してくれること。
dataclassでは強制をしてくれないので事故が起きうる(型安全でない)

Kumamoto-HamachiKumamoto-Hamachi

Models:公式

BaseModelを継承させて使う

パースであってバリデーションじゃない...?

pydantic is primarily a parsing library, not a validation library. In other words, pydantic guarantees the types and constraints of the output model, not the input data

パースのためのライブラリであってバリデーションが目的のライブラリじゃないんです...?
出力データは保証するけど入力データはしないよ...?違いが難解(esoteric distinction)すぎる...と思う...が
=> Models - pydantic

from pydantic import BaseModel

class User(BaseModel):
    id: int
    name = 'Jane Doe'


if __name__ == "__main__":
    user = User(id='123')

    # pydantic.error_wrappers.ValidationError: 1 validation error for User id value is not a valid integer (type=type_error.integer)
    user_x = User(id='123.45')

Data Conversion

pydantic may cast input data to force it to conform to model field types, and in some cases this may result in a loss of information.

Modelのプロパティ

  • dict
    modelのフィールドと値をdictで返す

  • json
    modelのフィールドと値をjsonで返す

  • copy
    modelのコピー(デフォルトだとshallow copy)を返す

  • parse_obj() TODO
    任意のオブジェクトをモデルにロードする

  • parse_raw() TODO
    様々な形式の文字列を読み込む

  • parse_file()
    like parse_raw() but for file paths

  • from_orm()
    loads data into a model from an arbitrary class

  • schema()
    returns a dictionary representing the model as JSON Schema

  • construct()

  • __fields_set__

  • __fields__

  • __config__

Kumamoto-HamachiKumamoto-Hamachi

Recursive Models

More complex hierarchical data structures can be defined using models themselves as types in annotations.

モデル自体をアノテーションの型として使える

from typing import List, Optional
from pydantic import BaseModel


class Foo(BaseModel):
    count: int
    size: Optional[float] = None


class Bar(BaseModel):
    apple = 'x'
    banana = 'y'


class Spam(BaseModel):
    foo: Foo
    bars: List[Bar]
Kumamoto-HamachiKumamoto-Hamachi

ORM Mode(任意のクラスインスタンス?)

Pydantic models can be created from arbitrary class instances to support models that map to ORM objects.

  • コンフィグのorm_modeTrue
  • モデルインスタンスを作るためにfrom_ormというコンストラクタを使用すること
from sqlalchemy import Column, Integer, String
from sqlalchemy.dialects.postgresql import ARRAY
from sqlalchemy.ext.declarative import declarative_base
from pydantic import BaseModel, constr

Base = declarative_base()


class CompanyOrm(Base):
    __tablename__ = 'companies'
    id = Column(Integer, primary_key=True, nullable=False)
    public_key = Column(String(20), index=True, nullable=False, unique=True)
    name = Column(String(63), unique=True)
    domains = Column(ARRAY(String(255)))


class CompanyModel(BaseModel):
    id: int
    public_key: constr(max_length=20)
    name: constr(max_length=63)
    domains: list[constr(max_length=255)]

    class Config:
        orm_mode = True


co_orm = CompanyOrm(
    id=123,
    public_key='foobar',
    name='Testing',
    domains=['example.com', 'foobar.com'],
)
print(co_orm)
#> <models_orm_mode_3_9.CompanyOrm object at 0x7fcc98a0f760>
co_model = CompanyModel.from_orm(co_orm)
print(co_model)
#> id=123 public_key='foobar' name='Testing' domains=['example.com',
#> 'foobar.com']

Kumamoto-HamachiKumamoto-Hamachi
from pydantic import BaseModel
from dataclasses import dataclass

@dataclass
class InventoryItem:
    name: str
    quantity_on_hand: int = 0



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

class UserF(BaseModel):
    f_id: float
    name = "Kumamot"


if __name__ == "__main__":

    user_a = User(id=123)
    user_b = User(id="123")
    user_c = User(id=123.45)
    user_d = User(id=123, name="Kagoshima")
    print("user_d.dict()", user_d.json())  # debug
    # pydantic.error_wrappers.ValidationError: 1 validation error for User id value is not a valid integer (type=type_error.integer)
    # user_x = User(id='123.45') #これはアウト

    # データのコンバージョンが行われていることに注目!!!
    assert user_a.id == 123
    assert user_b.id == 123
    assert user_c.id == 123
    # デフォルト値は__fields_set__に現れない
    assert user_a.__fields_set__ == {"id"}
    assert user_d.__fields_set__ == {"id", "name"}
    # ----------------------------
    user_1 = UserF(f_id=1.2)
    user_2 = UserF(f_id=1)
    user_3 = UserF(f_id="3.1")
    user_3 = UserF(f_id="3")
    user_3.f_id


Kumamoto-HamachiKumamoto-Hamachi

Reserved Name これは飛ばす

SQLAlchemy で予約されたフィールドの名前のカラムの名前にしたい場合

Kumamoto-HamachiKumamoto-Hamachi

Helper Functions

Pydantic provides three classmethod helper functions on models for parsing data:

  • parse_obj
    キーワード引数でなくdictで受け取るというところを除けば概ね__init__みたいな働きをするってよ。
    dict以外を受け取るとValidationError!!って怒る。

  • parse_raw
    strやbytesを受け取ってそれをjsonにパースする。さらにその結果をparse_objに渡してくれる。めっちゃ気が利くやつだなぁ...

content_type引数を適切に設定しておけばpickleデータもサポートしてくれるらしい。やべぇな。

  • parse_file
    ファイルパスを受け取り=>parse_rawに。

content_type is omitted, it is inferred from the file's extension.

Kumamoto-HamachiKumamoto-Hamachi

Creating models without validation

construct()を与えるとvalidationなしのモデルを作ることが出来る。
インプットのデータが十分にvalidateされている保証がある時に用いる

original_user = User(id=123, age=32)
fields_set = original_user.__fields_set__

// validationなし、ちょっと早い
new_user = User.construct(_fields_set=fields_set, **user_data)
Kumamoto-HamachiKumamoto-Hamachi

Generic Models TODO:あとでもっと読む

Pydantic supports the creation of generic models to make it easier to reuse a common model structure.

In order to declare a generic model, you perform the following steps:

  • モデルのパラメーター化のために1つ以上のtyping.TypeVarインスタンスを宣言する

  • pydantic.generics.GenericModeltyping.Genericを継承したpydanticモデルを宣言する。その際にtyping.GenericにはパラメーターとしてTypeVarインスタンスを渡す

  • 入れ替えたいタイプやpydanticモデルの代わりにTypeVarインスタンスをアノテーションとして用いる

from typing import Generic, TypeVar
from pydantic.generics import GenericModel
DataT = TypeVar('DataT')

class Response(GenericModel, Generic[DataT]):
    data: DataT | None
    error: Error | None

To inherit from a GenericModel without replacing the TypeVar instance, a class must also inherit from typing.Generic

from typing import TypeVar, Generic
from pydantic.generics import GenericModel

TypeX = TypeVar('TypeX')


class BaseClass(GenericModel, Generic[TypeX]):
    X: TypeX


class ChildClass(BaseClass[TypeX], Generic[TypeX]):
    # Inherit from Generic[TypeX]
    pass


# Replace TypeX by int
print(ChildClass[int](X=1))
#> X=1

下記のように一部だけ書き換えも可能

class BaseClass(GenericModel, Generic[TypeX, TypeY]):
    x: TypeX
    y: TypeY


class ChildClass(BaseClass[int, TypeY], Generic[TypeY, TypeZ]):
    z: TypeZ


# Replace TypeY by str
print(ChildClass[str, int](x=1, y='y', z=3))
#> x=1 y='y' z=3
Kumamoto-HamachiKumamoto-Hamachi

Dynamic model creation

モデルの形状が実行時までわからない場合に備えたメソッドであるcreate_model

Kumamoto-HamachiKumamoto-Hamachi

Dataclass

標準的な pydantic のフィールド型はすべて使用でき、 結果のデータクラスは標準ライブラリ dataclass デコレータで作成したものと同じに。
基礎となるモデルとそのスキーマは__pydantic_model__ でアクセスできる。

pydantic.dataclasses.dataclass's arguments are the same as the standard decorator, except one extra keyword argument config

config って引数だけdataclassより増えてる。これはBaseModelで使えるとやつと一緒。

from pydantic import ConfigDict
from pydantic.dataclasses import dataclass


# Option 1 - use directly a dict
# Note: `mypy` will still raise typo error
@dataclass(config=dict(validate_assignment=True))
class MyDataclass1:
    a: int


# Option 2 - use `ConfigDict`
# (same as before at runtime since it's a `TypedDict` but with intellisense)
@dataclass(config=ConfigDict(validate_assignment=True))
class MyDataclass2:
    a: int


# Option 3 - use a `Config` class like for a `BaseModel`
class Config:
    validate_assignment = True


@dataclass(config=Config)
class MyDataclass3:
    a: int

Kumamoto-HamachiKumamoto-Hamachi

Convert stdlib dataclasses into pydantic dataclasses

__post_init_post_parse__の助けを借りて、バリデーション後にコードを実行することが可能である。これは、検証前にコードを実行する __post_init__ と同じではありません。

If you use a stdlib dataclass, you may only have post_init available and wish the validation to be done before. In this case you can set Config.post_init_call = 'after_validation'

Difference with stdlib dataclasses

dataclasses.dataclass を pydantic.dataclasses.dataclass で代用する場合、 post_init メソッドで実行されるコードを post_init_post_parse メソッドに移動し、検証前に実行する必要があるコードの一部のみを 残しておくことが推奨されます。

Kumamoto-HamachiKumamoto-Hamachi

JSON Dumping

Pydantic dataclasses do not feature a .json() function. To dump them as JSON, you will need to make use of the pydantic_encoder as follows:

Pydantic データクラスは .json() 関数を備えていません。それらをJSONとしてダンプするには、以下のようにpydantic_encoderを利用する必要があります。

Kumamoto-HamachiKumamoto-Hamachi

pydanticのdataclass利用でもっと安全なPythonコード実現する

Lightweight LanguageであるPythonの良さの1つに「気楽に書ける」ということが挙げられると思いますが、そのカジュアルな書き味を多少捨ててでもtypeヒントdataclass等を用いたより"硬い"コードの方が読む際の認知負荷が減って良いなぁ、と最近は思うようになってきました。

この記事では既にそのような"硬い"Pythonコードを書くためにdataclassを用いている人に向けて代わりに pydantic を使うという選択もアリだヨ!というお話をしていきたいと思います。[1]

pydanticは実行時に型ヒントを強制し、データのvalidation結果が無効な場合は分かりやすいエラーを提供してくれるPythonライブラリです。pipで簡単にインストール出来ます。

$ pip install pydantic

この記事を読むことで「dataclassのツラミがどこにありpydanticはどうそれを解決してくれるのか」について知ることが出来ます。またdataclassのツラミの解消だけでなく「pydanticの提供してくれるオリジナルの型がいかに便利か」や「pydanticで実装するカスタムバリデーションがいかに痒いところに手が届く仕様になっているのか」について学びを得られるように説明していきます。

※もし「ここの説明は違くないか?」等あればコメント等で指摘いただけると幸いです。

1. dataclassの辛みを癒やすpydantic 🐶

以前、Zenn記事:PEP557から読み解くPythonのdataclassの嬉しさと他手段との比較でも書きましたが、dataclassを使っていて若干のツラミが2つあるなと感じています。

1-1. 型安全性

dataclassの一番のツラミは型注釈を全く無視したコードがランタイムでエラーにならないことだと思います。

dataclassではmypyでエラーになったコードもランタイムではシレッと実行されてしまう😓
from dataclasses import dataclass


@dataclass
class Company:
    corporate_id: int
    name: str
    country_id: int


good_company = Company(corporate_id=1, name="熊本株式会社", country_id=81)
print(good_company.corporate_id, good_company.name)  # debug


# mypyでエラーになるのでランタイムでもエラーになって欲しいが,シレっと実行できてしまう
bad_company1 = Company(corporate_id="not int", name="鹿児島株式会社", country_id=81)
print(bad_company1.corporate_id, bad_company1.name, bad_company1.country_id)  # debug

pydanticを用いれば

pydanticならランタイムでエラーにちゃんとなってくれる😆
# from dataclasses import dataclass
from pydantic.dataclasses import dataclass


@dataclass
class Company:
    corporate_id: int
    name: str
    country_id: int


good_company = Company(corporate_id=1, name="熊本株式会社", country_id=81)
print(good_company.corporate_id, good_company.name, good_company.country_id)  # debug


# pydantic.error_wrappers.ValidationError: 1 validation error for Company corporate_id value is not a valid integer (type=type_error.integer)
bad_company1 = Company(corporate_id="not int", name="鹿児島株式会社", country_id=81)

ただしpydanticには若干癖があり注意が必要なポイントがあります。

pydantic is primarily a parsing library, not a validation library. Validation is a means to an end: building a model which conforms to the types and constraints provided.
In other words, pydantic guarantees the types and constraints of the output model, not the input data.

Models - pydanticより

pydanticは 入力データのvalidationのライブラリではなく、出力データの型の保証をするparseのライブラリである ということです。

入力データがcast出来るならエラーにならない。出力データの保証に力点がある😳
from pydantic.dataclasses import dataclass


@dataclass
class Company:
    corporate_id: int
    name: str
    country_id: int


# 勝手にcast出来るものはcastされる
bad_company2 = Company(corporate_id=2.1, name=3.14, country_id="2")
print(bad_company2.corporate_id, type(bad_company2.corporate_id)) # 2 <class 'int'>
print(bad_company2.name, type(bad_company2.name)) # 3.14 <class 'str'>
print(bad_company2.country_id, type(bad_company2.country_id)) # 2 <class 'int'>

1-2. JSON化

dictやjson形式でのデータしか受け入れてくれないプログラムにデータを送らなくてはいけない機会は多々ありますが、 標準のdataclassにはdict/jsonへの備え付けの変換機能はありません。

dataclasses-jsonという別ライブラリを用いるのも手ですが、 pydanticならpydantic.json.pydantic_encoderと標準ライブラリのjsonと併用でJSON化出来ます。

標準ライブラリと併用で簡単にJSON化出来る😆
import dataclasses
import json

from pydantic.dataclasses import dataclass
from pydantic.json import pydantic_encoder


@dataclass
class User:
    id: int
    name: str = "John Doe"
    friends: list[int] = dataclasses.field(default_factory=lambda: [0])


user = User(id="42")
# JSON化
user_json = json.dumps(user, indent=4, default=pydantic_encoder)

(これはpydanticと直接は関係ない話ですが)当然JSON化出来ればdictにも変換は容易です。

# JSONからdictへは当然変換可能😆
user_dict = json.loads(user_json)

2. オリジナルの便利な型を提供してくれるpydantic 🤖

pydantic提供のオリジナル型は非常に多彩で便利なものです。

ここではその一部を紹介します。
他のオリジナル型についてはField Types - pydanticを参考にしてみてください。

制限 補足
FutureDate 未来の日付 -
PastDate 過去の日付 -
EmailStr emailアドレス準拠の文字列 追加でemail-validatorライブラリが別途必要
HttpUrl httpかhttpsで始まるURL -
PositiveInt 正の整数 -
conint int型の範囲を指定 gtで「より大きい」geで「以上」の意味。
confloat float型の範囲を指定 -
SecretStr 機密にしたい文字列、表示の際隠し文字になる -
pydantic提供のオリジナル型を使ってみる(`email-validator`ライブラリが別途必要)😆
from datetime import datetime, timedelta

from pydantic import (
    EmailStr,
    FutureDate,
    HttpUrl,
    PastDate,
    PositiveFloat,
    PositiveInt,
    confloat,
    conint,
    SecretStr,
)
from pydantic.dataclasses import dataclass


@dataclass
class User:
    user_id: int
    auth_date: PastDate
    expire_date: FutureDate
    email_addres: EmailStr
    user_page: HttpUrl
    age: PositiveInt
    height: PositiveFloat
    money: conint(gt=1000, lt=1024)
    score: confloat(ge=3.5, lt=5)
    password: SecretStr


user = User(
    user_id=1,
    auth_date=datetime.now() - timedelta(days=1),
    expire_date=datetime.now() + timedelta(days=1),
    email_addres="hoge@example.com",
    user_page="https://zenn.dev/",
    age=12,
    height=1.72,
    money=1001,
    score=3.5,
    password="hogehoge",
)

3. 痒い所に手が届くカスタムバリデーションが簡単に実装できるpydantic 🚨

pydanticの@validatorデコレータを使えばフィールドに対するカスタムしたバリデーションを簡単に実装することが出来ます。

デコレータで簡単にバリデーションを付けられる😆
from pydantic import validator
from pydantic.dataclasses import dataclass


@dataclass
class User:
    name: str
    nick_name: str
    password1: str
    password2: str

    @validator("name")
    def name_must_contain_space(cls, v):
        if " " not in v:
            raise ValueError("must contain a space")
        return v.title()

    @validator("password2")
    def passwords_match(cls, v, values, **kwargs):
        # vとvaluesにはそれぞれフィールド値と該当のフィールド以外のデータが入ったdictがある
        print("v", v)  # hogehoge
        print("values", values)  # {'name': 'くまもと はまち', 'nick_name': 'kagoshima', 'password1': 'hogehoge'}
        if "password1" in values and v != values["password1"]:
            raise ValueError("passwords do not match")
        return v

    @validator("nick_name")
    def username_alphanumeric(cls, v):
        assert v.isalnum(), "must be alphanumeric"
        return v


user = User(
    name="くまもと はまち",
    nick_name="kagoshima",
    password1="hogehoge",
    password2="hogehoge"
)

上記のコード内のバリデーションメソッドの第二引数のvはフィールドに与えられた値で、第三引数のvaluesは該当のフィールド以外のフィールドのフィールド名と値のdictが入っています。
※いずれも引数の変数名も自由に変えることが出来ます。

またバリデーションを複数ヶ所で再利用するコードもvalidatorallow_reuse引数にTrueを渡すだけで簡単実装で済みます。

バリデーションの再利用が容易😆
from pydantic import validator
from pydantic.dataclasses import dataclass


def name_must_contain_space(cls, v):
    if " " not in v:
        raise ValueError("must contain a space")
    return v.title()


@dataclass
class Person1:
    name: str

    # validators
    _normalize_name = validator("name", allow_reuse=True)(name_must_contain_space)


@dataclass
class Person2:
    name: str

    # validators
    _normalize_name = validator("name", allow_reuse=True)(name_must_contain_space)


kagoshima = Person1(name="kagoshima gyoko")
print("kagoshima.name", kagoshima.name)  # debug
kumamoto = Person2(name="kumamoto hamachi")
print("kumamoto.name", kumamoto.name)  # debug

その他、validatorの引数をpre=Trueとすることで他のValidator より優先的にバリデーションを行ったり、always=Trueとすることでパフォーマンス問題でデフォルト値のあるフィールドに値が与えられなかった際にもバリデーションを行うなど細やかな設定をすることが出来ます。

まとめと参考 📗

pydanticを用いるとより堅牢なコードを容易に作ることが出来る、ということを上手く本記事が伝えることができていれば幸いです。

pydanticには他にも様々な機能があり今回紹介したdataclassの代替として用いるよりもむしろModelを使ったやり方の方が主要なものとなります。自分自身pydanticを利用したFast API、Django Ninja等のFWについて学びも深めてより踏み込んだpydanticの使い方について次回以降アウトプット出来るように精進していこうと思います!

以下、今回参考にさせていただいた参考資料ですm(_ _)m

https://pydantic-docs.helpmanual.io/
https://docs.python.org/ja/3/library/typing.html
https://docs.python.org/ja/3/library/dataclasses.html
https://engineering.nifty.co.jp/blog/5995

脚注
  1. 今回の記事では既にFast APIやDjango Ninja等のFWでpydanticを使っている方向けではない記事にしたので敢えてdataclassの代用としての側面に焦点を当てた構成としていますm(_ _)m ↩︎

このスクラップは2022/12/06にクローズされました