この記事はPythonその2 Advent Calendar 2020、16日目の記事です。
Python3.5でType Hintsが導入され、元々動的型付け言語であったPythonでもコードに型情報を記述することが現在では当たり前になってきました。
今回は、この型情報を最大限活用してより堅牢なPythonコードを書く大きな助けになるライブラリ、pydantic
を紹介します。
pydanticとは
最近話題のPython製WebフレームワークFastAPIでも使用されているので、存在自体は知っている方も多いのでは無いでしょうか。
実は私もFastAPIを初めて使ったときにこのpydantic
の存在を知りました。
pydantic
はずばり以下の機能を実現してくれるライブラリです。
- 実行時の型情報の提供
- 不正なデータにはユーザーフレンドリーなエラーを返す
これだけだとなんのこっちゃ、って人の方が多いですよね。
この後に例を用いて解説します。
公式リソース
GitHub: samuelcolvin/pydantic: Data parsing and validation using Python type hints
公式ドキュメント: pydantic
Example
pydantic
はpydantic.BaseModel
という基底クラスを継承したユーザー定義クラスにおいてその機能を発揮します。
まずはpydantic
を使用しないクラス定義を考えてみます。
dataclasses.dataclass
を使います。
from dataclasses import dataclass, field
from typing import Optional
@dataclass
class NonPydanticUser:
name: str
age: int
このNonPydanticUser
クラスのインスタンスを1つ作成してみます。
この例では、2つのフィールドname
はstr
型、age
はint
型です。
クラス定義の通りのデータ型を保持していますね。
Ichiro = NonPydanticUser(name="Ichiro", age=19)
print(Ichiro)
#> NonPydanticUser(name='Ichiro', age=19)
print(type(Ichiro.name))
#> <class 'str'>
print(type(Ichiro.age))
#> <class 'int'>
もう一つ別のインスタンスを作成してみます。
Samatoki = NonPydanticUser(name="Samatoki", age="25")
print(Samatoki)
#> NonPydanticUser(name='Samatoki', age='25')
print(type(Samatoki.name))
#> <class 'str'>
print(type(Samatoki.age))
#> <class 'str'>
この例では、name
はstr
型ですが、age
はstr
型になってしまいます。
TypeError
などの例外も送出されません。
あくまで型アノテーションによって与えられる型情報がコーディング時にのみ機能しているということが改めて分かりますね。
確かにmypy
やPylance
などを使えばこういった型の不整合はコーディング時に検出できますが、コード実行時に型の不整合や不正値で例外送出をしたい場合は自前で入力値チェックをする必要があります。
一方pydantic
を使用したクラス定義は以下の様になります。
from pydantic import BaseModel
class User(BaseModel):
name: str
age: int
一見するとdataclasses.dataclass
を使った場合と似ていますね。
ですが明確な違いがあります。
まずは正常なフィールド値を使ったインスタンスを作成してみます。
Ramuda = User(name="Ramuda", age=24)
print(Ramuda)
#> name='Ramuda' age=24
print(type(Ramuda.name))
#> <class 'str'>
print(type(Ramuda.age))
#> <class 'int'>
これだけならばあんまり違いが分かりませんね。
次にage
に"23"
、"45"
といったstr
型の数値を与えてみます。
Jakurai = User(name="Jakurai", age="35")
#> name='Jakurai' age=35
print(type(Jakurai.name))
#> <class 'str'>
print(type(Jakurai.age))
#> <class 'int'>
Jakurai.age
がint
型にキャストされています。
ちなみに、age
にhoge
、fuga
などのint
型にキャストできない値を与えるとどうなるのでしょうか。
Sasara = User(name="Sasara", age="ホンマか?")
#> ValidationError: 1 validation error for User
#> age
#> value is not a valid integer (type=type_error.integer)
ValidationError
という例外が送出されました。
特にバリデーションを実装していないのに、不正値を検出しています。
この様にpydantic
を使用すると、記述した型情報がコーディング時だけではなくコード実行時にも適用され、更に不正値に対しては分かりやすい例外を投げてくれる(後述)ので、動的型付け言語であるPythonで型に厳格なコードを書くことができます!
pydanticはこんな人にオススメ!!
- 簡単なバリデーションはできるだけ省略したい
- GoやTypeScript、Swiftなど型に厳格な言語からPythonに入ってきて、Pythonでも型を気にしたい人
- とにかく堅牢なコードが書きたい人
- とにかく型に縛られたい人
pydanticの基本
公式のExampleの以下のコードを使って基本的な解説をします。
from datetime import datetime
from typing import List, Optional
from pydantic import BaseModel
class User(BaseModel):
id: int
name = 'John Doe'
signup_ts: Optional[datetime] = None
friends: List[int] = []
external_data = {
'id': '123',
'signup_ts': '2019-06-01 12:22',
'friends': [1, 2, '3'],
}
user = User(**external_data)
print(user.id)
#> 123
print(repr(user.signup_ts))
#> datetime.datetime(2019, 6, 1, 12, 22)
print(user.friends)
#> [1, 2, 3]
print(user.dict())
"""
{
'id': 123,
'signup_ts': datetime.datetime(2019, 6, 1, 12, 22),
'friends': [1, 2, 3],
'name': 'John Doe',
}
"""
pydantic.BaseModel
という基底クラスを継承してユーザー独自のクラスを定義します。
このクラス定義の中ではid
、name
、signup_ts
、friends
という4つのフィールドが定義されています。
それぞれのフィールドはそれぞれ異なる記述がされています。ドキュメントによると以下の様な意味があります。
-
id
(int
) ... Type Hintsのみ宣言した場合、必須フィールドとなる。もしインスタンス生成時にstr
、bytes
、float
型の値が与えられた場合は強制的にint
に変換する。それ以外のデータ型(dict
,list
など)の値が与えられると例外を送出する。 -
name
(str
) ...John Doe
というデフォルト値からname
はstr
型と推論される。またデフォルト値が宣言されているので、name
は必須フィールドではない。 -
signup_ts
: (datetime
, optional) ...None
が許容されるdatetime
型。またデフォルト値が宣言されているので、sign_up
は必須フィールドではない。int
型のUNIX timestamp(e.g. 1608076800.0)や日付と時刻を表すstr
型文字列を引数に与えることができる。 -
friends
: (List[int]
) ... Pythonの組込みのtyping systemを利用している。またデフォルト値が宣言されているので、必須フィールドではない。id
と同様に、"123"
や"45"
などはint
型に変換される。
pydantic.BaseModel
を継承したクラスのインスタンス生成時に不正値を与えようとするとpydantic.ValidationError
という例外を送出することは触れました。
以下のコードを使用してValidationError
の中身を覗いてみましょう。
from pydantic import ValidationError
try:
User(signup_ts='broken', friends=[1, 2, 'not number'])
except ValidationError as e:
print(e.json())
このコードに対するValidationError
の中身は以下の様になります。
各フィールドにおいてそれぞれどの様な不整合が起こっているのかが分かります。
[
{
"loc": [
"id"
],
"msg": "field required",
"type": "value_error.missing"
},
{
"loc": [
"signup_ts"
],
"msg": "invalid datetime format",
"type": "value_error.datetime"
},
{
"loc": [
"friends",
2
],
"msg": "value is not a valid integer",
"type": "type_error.integer"
}
]
Tips
この記事だけではpydanticの全てを紹介することはできませんが、以降ではすぐに使えそうな要素をTips的に紹介していきたいと思います。
Field Types
pydantic
に対応しているデータ型は本当に多種多様です。
その一部を紹介します。
Standard Library Types
int
やstr
、list
やdict
などのプリミティブなデータ型はもちろん使用できます。
その他にtyping
やipaddress
、enum
、decimal
、pathlib
、uuid
などの組込みライブラリにも対応しています。
以下はipadress.IPv4Address
を使用した例です。
from pydantic import BaseModel
from ipaddress import IPv4Address
class IPNode(BaseModel):
address: IPv4Address
client = IPNode(address="192.168.0.12")
srv = IPNode(address="hoge")
#> ValidationError: 1 validation error for IPNode
#> address
#> value is not a valid IPv4 address (type=value_error.ipv4address)
URLs
pydantic
ではhttps://example.com
、ftp://hogehoge
といったURLにも対応しています。
from pydantic import BaseModel, HttpUrl, AnyUrl
class Backend(BaseModel):
url: HttpUrl
bd1 = Backend(url="https://example.com")
bd2 = Backend(url="file://hogehoge")
#> ValidationError: 1 validation error for Backend
#> url
#> URL scheme not permitted (type=value_error.url.scheme; allowed_schemes={'https', 'http'})
Secret Types
ログなどの出力に吐きたくない情報も取り扱うことができます。
例えばパスワードに対してはpydantic.SecretStr
が使用できます。
from pydantic import BaseModel, SecretStr
class Password(BaseModel):
value: SecretStr
p1 = Password(value="hogehogehoge")
print(p1.value)
#> **********
EmailStr
メールアドレスを扱える型です。
ただし、使用する際にはpydantic
とは別にemail-vaidatorというライブラリをインストールしておく必要があります。
このEmailStr
と前節のSecret Types
を使用してみます。
from pydantic import BaseModel, EmailStr, SecretStr, Field
class User(BaseModel):
email: EmailStr
password: SecretStr = Field(min_length=8, max_length=16)
# OK
Juto = User(email="juto@mtc.com", password="hogehogehoge")
print(Juto)
#> email='juto@mtc.com' password=SecretStr('**********')
# NG, emailがメールアドレスのフォーマットになっていない
Rio = User(email="rio", password="hogehogehogehoge")
#> ValidationError: 1 validation error for User
#> email
#> value is not a valid email address (type=value_error.email)
# NG, passwordの文字数が16文字を越えている
Gentaro = User(email="gentaro@fp.com", password="hogehogehogehogehoge")
#> ValidationError: 1 validation error for User
#> password
#> ensure this value has at most 16 characters (type=value_error.any_str.max_length; limit_value=16)
# NG, passwordの文字数が8文字未満である
Daisu = User(email="daisu@fp.com", password="hoge")
#> ValidationError: 1 validation error for User
#> password
#> ensure this value has at least 8 characters (type=value_error.any_str.min_length; limit_value=8)
Constrained Types(条件付き型)
from pydantic import BaseModel, HttpUrl, AnyUrl, SecretStr, conint
# 正の数だけ許容する様にしてみる
class PositiveNumber(BaseModel):
value: conint(gt=0)
# OK
n1 = PositiveNumber(value=334)
#NG, 負の数である
n2 = PositiveNumber(value=-100)
#> ValidationError: 1 validation error for PositiveNumber
#> value
#> ensure this value is greater than 0 (type=value_error.number.not_gt; limit_value=0)
Strict Types
記事冒頭の例で"23"
、"45"
といったstr
型の数値をint
型にキャストして受け入れる礼がありました。
このキャストすら認めないより厳格なフィールドも宣言できます。
from pydantic import BaseModel, conint, StrictInt
# キャストを認めないint
class StrictNumber(BaseModel):
value: StrictInt
# OK
n1 = StrictNumber(value=4)
# キャストしてint型になれるstr型であっても、int型ではないのでNG
n2 = StrictNumber(value="4")
#> ValidationError: 1 validation error for StrictNumber
#> value
#> value is not a valid integer (type=type_error.integer)
前節のConstrained Typesと組み合わせることもできます。
from pydantic import BaseModel conint
# 自然数だけ許容する
class NaturalNumber(BaseModel):
value: conint(strict=True, gt=0)
# OK
n1 = NaturalNumber(value=334)
# NG, 負の数である
n2 = NaturalNumber(value=-45)
#> ValidationError: 1 validation error for NaturalNumber
#> value
#> ensure this value is greater than 0 (type=value_error.number.not_gt; limit_value=0)
# キャストしてint型になれるstr型であっても、int型ではないのでNG
n3 = NaturalNumber(value="45")
#> ValidationError: 1 validation error for NaturalNumber
#> value
#> value is not a valid integer (type=type_error.integer)
# float型も許容されない
n4 = NaturalNumber(value=123.4)
#> ValidationError: 1 validation error for NaturalNumber
#> value
#> value is not a valid integer (type=type_error.integer)
validators
簡単なバリデーションはフィールド宣言時に記述することが可能ですが、ユーザー定義のバリデーションをpydantic.validator
を使用して作成することが可能です。
基本的なvalidator
簡単な例を考えます。
name
フィールドに半角スペースを含む場合のみ許容するvalidator
を定義します。
from pydantic import BaseModel, validator
# nameに半角スペースが含まれていない場合を許容しない
class User(BaseModel):
name: str
age: int
@validator("name")
def validate_name(cls, v):
if ' ' not in v:
raise ValueError("must contain a space")
return v
# OK
Jiro = User(name="山田 二郎", age=17)
# NG
Saburo = User(name="山田三郎", age=14)
#> ValidationError: 1 validation error for User
#> name
#> must contain a space (type=value_error)
複数のフィールドを使ったvalidatorを実装する
例えば、ある予定の開始時刻と終了時刻をそれぞれbegin
、end
として保持するEvent
クラスを考えます。
from datetime import datetime
from pydantic import BaseModel
class Event(BaseModel):
begin: datetime
end: datetime
event = Event(begin="2020-12-16T09:00:00+09:00", end="2020-12-16T12:00:00+09:00")
この時、end
フィールドに代入される時刻が、begin
フィールドに代入される時刻よりも後であることを保証したいです。
begin
とend
の時刻が一致している場合も不正値であることにします。
やり方はいくつかあると思います。私からは2つ紹介します。
1つめの方法はpydantic.validator
の代わりにpydantic.root_validator
を使う方法です。
from datetime import datetime
from pydantic import BaseModel, root_validator
class Event(BaseModel):
begin: datetime
end: datetime
@root_validator(pre=True)
def validate_event_schedule(cls, values):
_begin: datetime = values["begin"]
_end: datetime = values["end"]
if _begin >= _end:
raise ValueError("Invalid event.")
return values
# OK
event1 = Event(begin="2020-12-16T09:00:00+09:00", end="2020-12-16T12:00:00+09:00")
# NG
event2 = Event(begin="2020-12-16T12:00:00+09:00", end="2020-12-16T09:00:00+09:00")
#> ValidationError: 1 validation error for Event
#> __root__
#> Invalid event. (type=value_error)
# NG
event3 = Event(begin="2020-12-16T12:00:00+09:00", end="2020-12-16T12:00:00+09:00")
#> ValidationError: 1 validation error for Event
#> __root__
#> Invalid event. (type=value_error)
もう一つは、validator
の仕様を活用します。
先にコードを紹介します。
from datetime import datetime
from pydantic import BaseModel, root_validator, validator
class Event(BaseModel):
begin: datetime
end: datetime
@validator("begin", pre=True)
def validate_begin(cls, v):
return v
@validator("end")
def validate_end(cls, v, values):
if values["begin"] >= v:
raise ValueError("Invalid schedule.")
return v
このコードでは2つのvalidator
を定義しました。
このEvent
クラスのインスタンス生成時には、pre=True
という引数がセットされたvalidate_begin
が先に実行されます。validate_begin
ではインスタンス生成時に引数begin
に指定された値をそのままbegin
フィールドにセットしています。
次にvalidate_end
が処理されます。
ただし、validate_end
はvalidate_begin
とは異なり第3引数としてvalues
という引数が指定されています。
pydantic.validator
の仕様として、あるvalidator
の前に実行されたvalidator
で入力値チェックされたフィールドに第3引数values
を使用してアクセスすることができます。
このvalues
は_values
でもValues
でもダメです。一種の予約語だと思ってください。
つまりこのコードの場合、各フィールドの入力値チェックの順序は以下の様になります。
- 先に
validate_begin
によるbegin
の入力値チェックが実行される - その後
validate_end
によってend
の入力値チェックが実行される。この時validate_end
のスコープ内からbegin
フィールドにvalues["begin"]
で参照することができる。
以上2通りの方法を紹介しました。もっといい方法があれば教えてください。
List
、Dict
、Set
などに含まれるそれぞれの要素に対するvalidator
以下の仕様を満たすRepeatedExams
クラスを考えます。
- ちょうど10回の試験の点数(
int
型)を格納するList[int]
型フィールドscores
を持つ。 - それぞれの試験結果は50点以上でなければならない。
- 10回の試験結果の合計点は800点以上でなければならない。
コードにすると以下の様になります。
List
、Dict
、Set
などの型のフィールドの要素のそれぞれに対して、あるvalidator
による入力値チェックを行いたい場合はそのvalidator
にeach_item=True
を設定します。
下のコードでは、validate_each_score
というvalidator
に対してeach_item=True
を設定しています。
from pydantic import BaseModel
from typing import List
class RepeatedExams(BaseModel):
scores: List[int]
# 試験結果の回数がちょうど10回であるか検証
@validator("scores", pre=True)
def validate_num_of_exams(cls, v):
if len(v) != 10:
raise ValueError("The number of exams must be 10.")
return v
# 1回の試験結果が50点以上であるか検証
@validator("scores", each_item=True)
def validate_each_score(cls, v):
assert v >= 50, "Each score must be at least 50."
return v
# 試験結果の合計が800点以上であるか検証
@validator("scores")
def validate_sum_score(cls, v):
if sum(v) < 800:
raise ValueError("sum of numbers greater than 800")
return v
# OK
result1 = RepeatedExams(scores=[87, 88, 77, 100, 61, 59, 97, 75, 80, 85])
# NG, 9回しか試験を受けていない
result2 = RepeatedExams(scores=[87, 88, 77, 100, 61, 59, 97, 75, 80])
#> ValidationError: 1 validation error for RepeatedExams
#> scores
#> The number of exams must be 10. (type=value_error)
# NG, 50点未満の試験がある
result3 = RepeatedExams(scores=[87, 88, 77, 100, 32, 59, 97, 75, 80, 85])
#> ValidationError: 1 validation error for RepeatedExams
#> scores -> 4
#> Each score must be at least 50. (type=assertion_error)
# NG, 10回の試験の合計が800点未満である
result4 = RepeatedExams(scores=[87, 88, 77, 100, 51, 59, 97, 75, 80, 85])
#> ValidationError: 1 validation error for RepeatedExams
#> scores
#> sum of numbers greater than 800 (type=value_error)
Exporting models
pydantic.BaseModel
を継承したクラスのインスタンスは、辞書形式やJSON形式に変換したり、コピーを生成したりすることができます。
ただ変換・コピーできるだけではなく、対象となるフィールドを指定して特定のフィールドだけ出力することができます。
from pydantic import BaseModel, conint
class User(BaseModel):
name: str
age: conint(strict=True, ge=0)
height: conint(strict=True, ge=0)
weight: conint(strict=True, ge=0)
Kuko = User(name="Kuko", age=19, height=168, weight=58)
print(Kuko)
# 全フィールドを対象にdictに変換
Kuko_dict_1 = Kuko.dict()
print(Kuko_dict_1)
#> {'name': 'Kuko', 'age': 19, 'height': 168, 'weight': 58}
# nameだけを対象にdictに変換
Kuko_name = Kuko.dict(include={"name"})
print(Kuko_name)
#> {'name': 'Kuko'}
# 全フィールドを対象にコピー
print(Kuko.copy())
print(Kuko_2)
#> name='Kuko' age=19 height=168 weight=58
# ageだけ除外してコピー
Kuko_3 = Kuko.copy(exclude={"age"})
print(Kuko_3)
#> name='Kuko' height=168 weight=58
# 全フィールドを対象にJSONに
Kuko_json = Kuko.json()
print(Kuko_json)
#> {"name": "Kuko", "age": 19, "height": 168, "weight": 58}
print(type(Kuko_json))
#> <class 'str'>
終わりに
Model Config、Schemaをはじめとする他の要素は執筆時間があまり確保できず、断念しました。
今後追記できたらいいな...