🛡

pydanticでdataclassを代替してもっと安全なPythonコードを楽に実現する

2022/12/06に公開4

この記事は、LAPRAS Advent Calendar 2022の6日目の記事です。
https://qiita.com/advent-calendar/2022/lapras

0. はじめに pydanticでもっと硬い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で「以上」の意味、逆にltは「未満」leは「以下」
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 ↩︎

GitHubで編集を提案

Discussion

りんりん

標準のdataclassにはdict/jsonへの備え付けの変換機能はありません

標準のdataclassでは asdict() で dict に変換でき、そこからpydanticと同様 json.dumps() に繋げればJSON文字列が得られますが、これでは不足でしょうか?

Kumamoto-HamachiKumamoto-Hamachi

標準のdataclassでは asdict() で dict に変換でき
完全に自分のリサーチ不足でした...!ありがとうございます。記事の方に近日中に修正・反映させるようにします...!m(_ _)m