pydanticでdataclassを代替してもっと安全なPythonコードを楽に実現する
この記事は、LAPRAS Advent Calendar 2022の6日目の記事です。
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.
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が入っています。
※いずれも引数の変数名も自由に変えることが出来ます。
またバリデーションを複数ヶ所で再利用するコードもvalidator
のallow_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
-
今回の記事では既にFast APIやDjango Ninja等のFWでpydanticを使っている方向けではない記事にしたので敢えてdataclassの代用としての側面に焦点を当てた構成としていますm(_ _)m ↩︎
Discussion
pydanticのstrict types使えば、より厳密にできますよー
なんと...!完全にStrict Types認知出来てませんでした...!m(_ _)m
ありがとうございます。近日中に記事に反映させるようにします...!
標準のdataclassでは asdict() で dict に変換でき、そこからpydanticと同様 json.dumps() に繋げればJSON文字列が得られますが、これでは不足でしょうか?