😽

【Python開発に欠かせない!】pydanticの使い方とメリット

2023/03/24に公開

はじめに

pythonで堅牢な開発をしたい時、pydanticが候補に上がってくると思います。 基本的な使い方、よく使いそうなことをまとめてみました。

想定読者

Pythonを最近触り始めて、型がある開発をしたいと思って、pydanticの存在を知った人 pydanticでできることをざっくり知りたい人

pydanticとは

pydanticはデータのバリデーションや型ヒントを提供します。 これにより、Pythonで安全な開発を行うことができます。

逆にPythonの書きやすさみたいなものは少し犠牲にするかもしれませんが、 仕事で使うなら、安全さを優先したいかなと思います。

なお、Pydanticは入力ではなく、出力モデルの型と制約を保証するものになります。 (intの定義のところにfloatを突っ込むとintに変換されてしまう)

dataclassじゃだめなのか

Pythonにデフォルトでdataclassがあります。 アノテーションはされるんですが、厳密ではありません。

intの定義のところにfloatを突っ込むとfloatが登録されてしまう

@dataclass
class User2:
    name: str
    age: int

user2 = User2(name=1.23, age="aa")
# User2(name=1.23, age='aa')
print(user2)

準備

Pythonのバージョンやpipenvは揃える必要はありません。 pydanticのみインストールしてください。

$pipenv --python 3.9.16
$pipenv shell
$pip install pydantic

モデル

https://docs.pydantic.dev/usage/models/

dictとparse_objについて

dictでモデルを辞書に変換することができます。 parse_objで辞書からクラスを作成することができます。

user = User(id=123, age=27)
user_dict = user.dict()
user_dict["test"] = "aaa"
user2 = User.parse_obj(user_dict)

バリデーションのエラーについて

バリデーションのエラーに引っかかった場合は、ValidationErrorがraiseされるので、 try,exceptで補足してあげることで管理することが可能です。

class User(BaseModel):
    id: int
    age: int

try:
    user = User(id="aaa", age=27)
except ValidationError as e:
    print(e.json())
    # [{"loc": ["id"],"msg": "value is not a valid integer","type": "type_error.integer"}]

自作のバリデーション作成

デフォルトの型だけでは不十分な場合、自分でバリデーションを作成することができます。 また、raiseするエラーも自由なので、エラーによって後の処理を管理することが可能です。

class User(BaseModel):
    id: int
    age: int

    @validator("age")
    def age_size(cls, v):
        if 0 <= v <= 100:
            return v
        else:
            raise ValueError("範囲外のageが入力されました。")

try:
    user = User(id=123, age=999)
except ValueError as e:
    print(e.json())
    # [{"loc": ["age"],"msg": "範囲外のageが入力されました。","type": "value_error"}]

Fieldを使った一般的なバリデーション

先ほどの自作バリデーション、自由度は高いのですが よくあるパターンに対しても書かないといけないのは大変ですよね。

Field関数を使えば、よくあるパターンに関しては対応することができます。 https://docs.pydantic.dev/usage/schema/#field-customization

class User(BaseModel):
    age: int = Field(..., ge=0, le=100)

try:
    User(age=101)
except ValidationError as e:
    print(e.json())
    # [{"loc": ["age"],"msg": "ensure this value is less than or equal to 100","type": "value_error.number.not_le","ctx": {"limit_value": 100}}]

自作のバリデーションを作る前にFieldで補えないか確認するのが良さそうですね。

必須と必須ではないフィールドを分ける

それぞれ分けたい時は以下のような記述をします。

class User(BaseModel):
    id: int  # 必須
    age: Optional[int]  # 必須ではない

user = User(id=123)
print(user)
# id=123 age=None

動的なデフォルト値を持つフィールドの作り方

日付やuuidなんかは動的に生成したい時があると思いますが、 Field関数とdefault_factoryを使うことで叶えられます。

class User(BaseModel):
    now: datetime = Field(default_factory=datetime.now)

user = User()
print(user)
# now=datetime.datetime(2023, 3, 23, 11, 48, 53, 594133)

暗黙的な型変換を避ける

以下の例ではidはintが正しいのに、モデルをインスタンス化する際にfloatの型を渡しています。 pydanticでは出力結果の正当性を保証するので、暗黙的にintに変換されてしまいます。

class User(BaseModel):
    id: int

user = User(id=1.23)
print(user)
# id=1

これを防ぐために、いくつか厳密な型定義が提供されています。 https://docs.pydantic.dev/usage/types/#strict-types

以下は先ほどの意図しない変換を防ぐ例になります。

class User(BaseModel):
    id: StrictInt

try:
    user = User(id=1.23)
    print(user)
except ValidationError as e:
    print(e.json())
    #[{"loc": ["id"],"msg": "value is not a valid integer","type": "type_error.integer"}]

フィールドの種類について

リテラル値のみを受けるようなフィールドを定義する

決まった値しか入力されたくないフィールドってあると思います。 Booleanで解決できればいいですが、 もう少し種類が多い時、Literal型を使えば解決できます。

class User(BaseModel):
    kind: Literal[1, 2, 3]

try:
    User(kind=1)
    User(kind=3)
    User(kind=5)
except ValidationError as e:
    print(e.json())
    # [{"loc": ["kind"],"msg": "unexpected value; permitted: 1, 2, 3","type": "value_error.const","ctx": {"given": 5,"permitted": [1,2,3]}}]

カスタムバリデータを宣言する必要が無いので、便利です。

よくある条件に一致するフィールドを宣言する

何文字以上、何文字以下にしたいだったり、 配列に格納する個数に制限を持たせたい時があると思います。

便利な型が用意されています。 https://docs.pydantic.dev/usage/types/#constrained-types

以下は2文字以上、10文字以下しか受け付けないようなフィールドを宣言しています。

class User(BaseModel):
    id: constr(min_length=2, max_length=10)

try:
    User(id="1")
except ValidationError as e:
    print(e.json())
    # [{"loc": ["id"],"msg": "ensure this value has at least 2 characters","type": "value_error.any_str.min_length","ctx": {"limit_value": 2}}]

バリデーター

https://docs.pydantic.dev/usage/validators/

独自のバリデーターを作成したい時

constrなどで基本の形のバリデーションはできますが、 全てに対応することは難しく、独自のバリデーターを作成したい時があると思います。 基本の形は以下になります。

class User(BaseModel):
    id: str

    @validator("id")
    def int_is_prefix_a(cls, v):
        if not v[0] == "a":
            raise ValidationError
        return v

try:
    User(id="1")
except ValidationError as e:
    print(e.json())
    # [{"loc": ["id"],"msg": "__init__() takes exactly 3 positional arguments (1 given)","type": "type_error"}]

バリデーターを再利用したい時

名前に一定の入力規則があり、複数のクラスにそれが存在する時、 それぞれのクラスでバリデーターを書くのは大変だと思います。

そんな時はバリデーターを再利用することができます。

def id_is_prefix_a(id: str):
    if not id[0] == "a":
        raise ValidationError
    return id

class User(BaseModel):
    id: str

    _id_is_prefix_a = validator("id", allow_reuse=True)(id_is_prefix_a)

try:
    User(id="1")
except ValidationError as e:
    print(e.json())
    # [{"loc": ["id"],"msg": "__init__() takes exactly 3 positional arguments (1 given)","type": "type_error"}]

モデルの構成(Config)

バリデーションエラーメッセージを変えたい時

フォームなどでユーザーが入力した時にその値がバリデーションエラーにかかった場合、 適切なメッセージを出したいと思いますが、デフォルトでは英語で返却されます。 以下のような形で定義すると、自由に変えることができます。

class User(BaseModel):
    id: str

    class Config:
        max_anystr_length = 5
        error_msg_templates = {
            "value_error.any_str.max_length": "max_length:{limit_value}",
        }

try:
    User(id="123456")
except ValidationError as e:
    print(e.json())
    # [{"loc": ["id"],"msg": "max_length:10","type": "value_error.any_str.max_length","ctx": {"limit_value": 10}}]

モデルのエクスポート

辞書形式でエクスポートしたい時

モデルを別で利用する時に、辞書形式の方が扱いやすいことがあると思います。 そんな時は、dict関数を使用することで叶えることができます。

class User(BaseModel):
    id: str
    name: str

try:
    hanako = User(id="123456", name="hanako")
    print(hanako.dict())
    # {'id': '123456', 'name': 'hanako'}

その他

イミュータブルにしたい

frozenをTrueにしてあげると、イミュータブルになる。 ただし、ミュータブルなフィールド(リストなど)がイミュータブルになるわけではないので注意

class User(BaseModel):
    id: int
    age: int
    name = "taro"

    class Config:
        frozen = True

おわりに

途中参加した案件でpydanticを使っていたのですが、コピペで使っていたのを 改めて学習すると改善できるところも見つかったのでよかったです。

GitHubで編集を提案

Discussion