この記事は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をはじめとする他の要素は執筆時間があまり確保できず、断念しました。
今後追記できたらいいな...