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


この記事は[Pythonその2 Advent Calendar 2020](https://qiita.com/advent-calendar/2020/python2)、16日目の記事です。

Python3.5でType Hintsが導入され、元々動的型付け言語であったPythonでもコードに型情報を記述することが現在では当たり前になってきました。

今回は、この型情報を最大限活用してより堅牢なPythonコードを書く大きな助けになるライブラリ、`pydantic`を紹介します。


## pydanticとは

最近話題のPython製Webフレームワーク[FastAPI](https://fastapi.tiangolo.com/)でも使用されているので、存在自体は知っている方も多いのでは無いでしょうか。
実は私もFastAPIを初めて使ったときにこの`pydantic`の存在を知りました。

`pydantic`はずばり以下の機能を実現してくれるライブラリです。

* **実行時**の型情報の提供
* 不正なデータにはユーザーフレンドリーなエラーを返す

これだけだとなんのこっちゃ、って人の方が多いですよね。
この後に例を用いて解説します。

### 公式リソース

GitHub: [samuelcolvin/pydantic: Data parsing and validation using Python type hints](https://github.com/samuelcolvin/pydantic/)
公式ドキュメント: [pydantic](https://pydantic-docs.helpmanual.io/)

### Example

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

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

```python
from dataclasses import dataclass, field
from typing import Optional

@dataclass
class NonPydanticUser:
    name: str
    age: int
```

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

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

もう一つ別のインスタンスを作成してみます。

```python
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`を使用したクラス定義は以下の様になります。

```python
from pydantic import BaseModel

class User(BaseModel):
    name: str
    age: int
```

一見すると`dataclasses.dataclass`を使った場合と似ていますね。
ですが明確な違いがあります。

まずは正常なフィールド値を使ったインスタンスを作成してみます。

```python
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`型の数値を与えてみます。

```python
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`型にキャストできない値を与えるとどうなるのでしょうか。

```python
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](https://pydantic-docs.helpmanual.io/#example)の以下のコードを使って基本的な解説をします。

```python
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`の中身を覗いてみましょう。

```python
from pydantic import ValidationError

try:
    User(signup_ts='broken', friends=[1, 2, 'not number'])
except ValidationError as e:
    print(e.json())
```

このコードに対する`ValidationError`の中身は以下の様になります。
各フィールドにおいてそれぞれどの様な不整合が起こっているのかが分かります。

```json
[
  {
    "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](https://pydantic-docs.helpmanual.io/usage/types/)

`pydantic`に対応しているデータ型は本当に多種多様です。
その一部を紹介します。

#### [Standard Library Types](https://pydantic-docs.helpmanual.io/usage/types/#standard-library-types)
`int`や`str`、`list`や`dict`などのプリミティブなデータ型はもちろん使用できます。
その他に`typing`や`ipaddress`、`enum`、`decimal`、`pathlib`、`uuid`などの組込みライブラリにも対応しています。

以下は`ipadress.IPv4Address`を使用した例です。

```python
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](https://pydantic-docs.helpmanual.io/usage/types/#urls)

`pydantic`では`https://example.com`、`ftp://hogehoge`といったURLにも対応しています。

```python
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](https://pydantic-docs.helpmanual.io/usage/types/#secret-types)

ログなどの出力に吐きたくない情報も取り扱うことができます。
例えばパスワードに対しては`pydantic.SecretStr`が使用できます。

```python
from pydantic import BaseModel, SecretStr

class Password(BaseModel):
    value: SecretStr

p1 = Password(value="hogehogehoge")
print(p1.value)
#> **********
```

#### EmailStr

メールアドレスを扱える型です。
ただし、使用する際には`pydantic`とは別に[email-vaidator](https://github.com/JoshData/python-email-validator)というライブラリをインストールしておく必要があります。

この`EmailStr`と前節の`Secret Types`を使用してみます。

```python
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（条件付き型）](https://pydantic-docs.helpmanual.io/usage/types/#constrained-types)

```python
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](https://pydantic-docs.helpmanual.io/usage/types/#strict-types)

記事冒頭の例で`"23"`、`"45"`といった`str`型の数値を`int`型にキャストして受け入れる礼がありました。
このキャストすら認めないより厳格なフィールドも宣言できます。

```python
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と組み合わせることもできます。

```python
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](https://pydantic-docs.helpmanual.io/usage/validators/)

簡単なバリデーションはフィールド宣言時に記述することが可能ですが、ユーザー定義のバリデーションを`pydantic.validator`を使用して作成することが可能です。

#### 基本的なvalidator

簡単な例を考えます。
`name`フィールドに半角スペースを含む場合のみ許容する`validator`を定義します。

```python
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`クラスを考えます。

```python
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`を使う方法です。

```python
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`の仕様を活用します。
先にコードを紹介します。

```python
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`でもダメです。一種の予約語だと思ってください。

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

1. 先に`validate_begin`による`begin`の入力値チェックが実行される
2. その後`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`を設定しています。

```python
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](https://pydantic-docs.helpmanual.io/usage/exporting_models/)

`pydantic.BaseModel`を継承したクラスのインスタンスは、辞書形式やJSON形式に変換したり、コピーを生成したりすることができます。
ただ変換・コピーできるだけではなく、対象となるフィールドを指定して特定のフィールドだけ出力することができます。

```python
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](https://pydantic-docs.helpmanual.io/usage/model_config/)、[Schema](https://pydantic-docs.helpmanual.io/usage/schema/)をはじめとする他の要素は執筆時間があまり確保できず、断念しました。
今後追記できたらいいな...