LoginSignup
16
8

More than 1 year has passed since last update.

pydanticを用いて@dataclassの型を堅牢にする

Last updated at Posted at 2022-07-19

概要

@dataclassを用いると型アノテーションが強制化されますが、この型自体に強制力はありません1
例えば、下記のように違う型を入れた場合でも動作してしまいます。

from dataclasses import dataclass

@dataclass
class User:
    name: str
    age: int

def main():
    user_info = User(20, "Hoge")
    print(user_info)                # User(name=20, age='Hoge')
    print(type(user_info.name))     # <class 'int'>
    print(type(user_info.age))      # <class 'str'>

if __name__ == "__main__":
    main()

なぜ強制力がないかというと、この型アノテーションは型のヒントでしかなく、言うのであればコメントと同様です2
仕様だとしても、せっかく定義した型が違う型に書き換わるのは解せない…
そういう方におすすめなのがpydanticです!

型アノテーションの補足

強制力がないならばあまり書く意味がないと思われますが、主に下記のメリットが存在します。

  • 他の開発者や未来の自分に、この変数の型は何が入るべきなのかを示すことが出来る
    • コードの読み解きが楽になる
  • mypyPylanceと合わせると型チェックを行ってくれて、開発しながら問題のある部分が明白になる

注意点として、Python3.5以上でなければ使用できない点です。
なので、Python3.5以上であればバンバン使っていくことをおすすめします。
(関数の返り値の型まで書くことが出来ます!)

pydanticとは

ドキュメントには下記のように記載されています。

  • pydanticは実行時に型ヒントを強制し、データが無効な場合はユーザーフレンドリーなエラーを提供します
  • 純粋で標準的なpythonでデータがどのようにあるべきかを定義し、pydanticでそれを検証します

と書いてありますが、この記事内では型を強制させる代物という理解で問題ありません。

インストール

外部モジュールなので、下記コマンドを実行しインストールする必要があります。
pip install pydantic

使い方

from dataclassesからfrom pydantic.dataclassesに変更するだけで使用できます!

from pydantic.dataclasses import dataclass

@dataclass
class User:
    name: str
    age: int

def main():
    user_info = User("Hoge", 20)
    print(user_info)            # User(name='Hoge', age=20)
    print(type(user_info.name)) # <class 'str'>
    print(type(user_info.age))  # <class 'int'>

if __name__ == "__main__":
    main()

今まで使用してきた@dataclassと同じですね!

エラー送出機能と型変換

ここからがpydanticの本領発揮です。
下記のように定義した型と異なる型を受け取った際には、エラーを出力してくれます。

from pydantic.dataclasses import dataclass

@dataclass
class User:
    age: int

def main():
    user_info = User("Hoge")
    print(user_info)
    print(type(user_info.age))

if __name__ == "__main__":
    main()

実行すると下記のようなエラーが送出されます。

Traceback (most recent call last):
  File "", line 17, in <module>
    main()
  File "", line 10, in main
    user_info = User("Hoge")
  File "<string>", line 4, in __init__
  File "pydantic\dataclasses.py", line 100, in pydantic.dataclasses._generate_pydantic_post_init._pydantic_post_init
    # +-------+-------+-------+
pydantic.error_wrappers.ValidationError: 1 validation error for User
age
  value is not a valid integer (type=type_error.integer)

このようにpydanticを用いることで、定義した型と異なる型が与えられた時にはエラーを出力してくれます。
……しかし、このようにエラーを出してくれるのはキャストが出来ないときに限られます。

試しにキャストが出来る文字列型の"20"をageに格納してみましょう。

from pydantic.dataclasses import dataclass

@dataclass
class User:
    age: int

def main():
    user_info = User("20")
    print(user_info)            # User(age=20)
    print(type(user_info.age))  # <class 'int'>

if __name__ == "__main__":
    main()

このようにstr型の文字列が、定義したint型にキャストされて受け取っています。
文字列だけではなく、float型であれ、bool型であれ、キャスト出来るならキャストをしてしまいます
これはpydanticの仕様です(バグではありません)3

キャストで型変換出来る場合でも、キャストをせずにエラーを出して欲しい。
そんな要望にもpydanticは応えてくれます!4

Strict Types

この型で定義すると、キャストでの変換を防いでエラーを出してくれます。
種類としてはStrictStr, StrictBytes, StrictInt, StrictFloat, StrictBoolがあります。
使い方は、from pydanticから使用する型をimportして、型アノテーションで定義するだけです。

from pydantic.dataclasses import dataclass
from pydantic import StrictInt

@dataclass
class User:
    age: StrictInt

def main():
    # ValidationError
    # user_info = User(3.14)
    # user_info = User("3")
    # user_info = User(True)

    # OK
    user_info = User(3)

    print(user_info)            # User(name=3)
    print(type(user_info.age))  # <class 'int'>

if __name__ == "__main__":
    main()

キャスト出来ていたものが軒並みValidation Errorになり、"真のint型"のみ受け取るように強制させることが出来ました!
他のStrictStrStrictFloatなども同様の動作などで省きます。5

ちなみに型を自分で作ることが出来るので、datetime.datetime型しか受け取らない型を作るなんてことも可能です!

終わりに

今回は、pydanticを使って@dataclassの型を堅牢にすることに絞ってまとめてみました。
ここで使用した型は一部分で、pydanticは様々な型をサポートしています(参照)
また思った以上にpydanticは奥深く、issueやドキュメントを読んでいるだけでも面白かったのでおすすめです6

おまけ

キャストのタイミングについて

引数で値を受け取ったと同時にキャストしているのか、と思っていたがどうやら__post_init__の終了後にキャストしてるっぽい。

from pydantic.dataclasses import dataclass


@dataclass
class User:
    age: int

    def __post_init__(self):
        print(f"{self.age=}")
        print(f"Type = {type(self.age)}")

    def OutputType(self):
        print(f"OutputType : {type(self.age)}")


def main():
    user_info = User("20")
    user_info.OutputType()


if __name__ == "__main__":
    main()

出力は下記の通りです。

self.age='20'
Type = <class 'str'>
OutputType : <class 'int'>

ちょっとびっくりした小話でした。

参考文献

  1. 「フィールドがPEP526で定義されたクラス変数かどうかの判定」と「フィールドが初期化限定変数かどうかの判定」では検査される(参照)

  2. Type Hints

  3. Data Conversionについて

  4. mypyなどの型チェッカーを導入すれば解決するが、pydanticだけで行いたかった

  5. Strict Typesについて

  6. 特に型変換の議論は読みごたえがあった(参照)

16
8
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
16
8