LoginSignup
96
66

More than 3 years have passed since last update.

pydanticを使って実行時にも型情報が適用されるPythonコードを書く

Last updated at Posted at 2020-12-16

この記事は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

pydanticpydantic.BaseModelという基底クラスを継承したユーザー定義クラスにおいてその機能を発揮します。

まずはpydanticを使用しないクラス定義を考えてみます。
dataclasses.dataclassを使います。

from dataclasses import dataclass, field
from typing import Optional

@dataclass
class NonPydanticUser:
    name: str
    age: int

このNonPydanticUserクラスのインスタンスを1つ作成してみます。
この例では、2つのフィールドnamestr型、ageint型です。
クラス定義の通りのデータ型を保持していますね。

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'>

この例では、namestr型ですが、agestr型になってしまいます。
TypeErrorなどの例外も送出されません。

あくまで型アノテーションによって与えられる型情報がコーディング時にのみ機能しているということが改めて分かりますね。
確かにmypyPylanceなどを使えばこういった型の不整合はコーディング時に検出できますが、コード実行時に型の不整合や不正値で例外送出をしたい場合は自前で入力値チェックをする必要があります。

一方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.ageint型にキャストされています。

ちなみに、agehogefugaなどの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という基底クラスを継承してユーザー独自のクラスを定義します。
このクラス定義の中ではidnamesignup_tsfriendsという4つのフィールドが定義されています。
それぞれのフィールドはそれぞれ異なる記述がされています。ドキュメントによると以下の様な意味があります。

  • id (int) ... Type Hintsのみ宣言した場合、必須フィールドとなる。もしインスタンス生成時にstrbytesfloat型の値が与えられた場合は強制的にintに変換する。それ以外のデータ型(dict, listなど)の値が与えられると例外を送出する。
  • name (str) ... John Doeというデフォルト値からnamestr型と推論される。またデフォルト値が宣言されているので、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

intstrlistdictなどのプリミティブなデータ型はもちろん使用できます。
その他にtypingipaddressenumdecimalpathlibuuidなどの組込みライブラリにも対応しています。

以下は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.comftp://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を実装する

例えば、ある予定の開始時刻と終了時刻をそれぞれbeginendとして保持する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フィールドに代入される時刻よりも後であることを保証したいです。
beginendの時刻が一致している場合も不正値であることにします。

やり方はいくつかあると思います。私からは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_endvalidate_beginとは異なり第3引数としてvaluesという引数が指定されています。
pydantic.validatorの仕様として、あるvalidatorの前に実行されたvalidatorで入力値チェックされたフィールドに第3引数valuesを使用してアクセスすることができます。
このvalues_valuesでもValuesでもダメです。一種の予約語だと思ってください。

つまりこのコードの場合、各フィールドの入力値チェックの順序は以下の様になります。

  1. 先にvalidate_beginによるbeginの入力値チェックが実行される
  2. その後validate_endによってendの入力値チェックが実行される。この時validate_endのスコープ内からbeginフィールドにvalues["begin"]で参照することができる。

以上2通りの方法を紹介しました。もっといい方法があれば教えてください。

ListDictSetなどに含まれるそれぞれの要素に対するvalidator

以下の仕様を満たすRepeatedExamsクラスを考えます。

  • ちょうど10回の試験の点数(int型)を格納するList[int]型フィールドscoresを持つ。
  • それぞれの試験結果は50点以上でなければならない。
  • 10回の試験結果の合計点は800点以上でなければならない。

コードにすると以下の様になります。
ListDictSetなどの型のフィールドの要素のそれぞれに対して、あるvalidatorによる入力値チェックを行いたい場合はそのvalidatoreach_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 ConfigSchemaをはじめとする他の要素は執筆時間があまり確保できず、断念しました。
今後追記できたらいいな...

96
66
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
96
66