AWS上で犬と戯れたい→Pydanticを理解したい

# はじめに
皆さん、こんにちは。
今回は仕事で使いそうな[ケルベロス](https://docs.python-cerberus.org/)と戯れたいと思います。また、**「記事を書きながら実装をタイムアタック形式で行う」** というルールで進めたいと思います。

とりあえず現在時刻は23:20なので、40分間でなるべくアウトプットしたいと思います。

# Cerberusとはなにか
[Cerberusの公式サイト](https://docs.python-cerberus.org/)を見ると、以下の記述があります。軽量の入力チェックツールです。

> ケルベロスは、パワフルでありながらシンプルで軽量なデータバリデーション機能を提供し、簡単に拡張できるように設計されているため、カスタムバリデーションが可能です。 依存関係はなく、Python 2.7から3.8、PyPy、PyPy3まで徹底的にテストされています。


チェックロジックをスキーマとして定義できるため、フロントエンド、バックエンド、APIなどの入力チェックロジックを一元管理することができることがメリットです。
ただ、Python3.8という記述に一抹の不安を覚えます。私がCerberusを知ったのは3年くらい前なので、一応pip trendsしてみました。

https://piptrends.com/compare/cerberus-vs-pydantic-vs-marshmallow-vs-attrs

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/217144/82d728e5-46af-2819-5160-7e7f85315d52.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/217144/55685df8-bf95-f031-a9b1-045dd29449e1.png)

なるほど。[Pydantic](https://docs.pydantic.dev/latest/) にしましょうか。開始10分で早くも **検証するツールを変える** という事態になりましたが、あと30分で形にしてみせます。

:::note info
[世界一流エンジニアの思考法](https://www.amazon.co.jp/dp/4163917683) を最近読んで、私がいつもおこなっている **「取り敢えず触ってなんとなく理解する」** というのは、長期的には生産性が頭打ちになる恐れがあると気づきました。
ということで、めちゃくちゃ触りたいのですが、残り30分を使ってPydanticをしっかり理解していきます。戯れるのは理解したあとにします。
:::

# Pydanticとはなにか
PydanticとはPython + Pedantic（「些細な規則にこだわる」という意味を持つ英語）から来ているとのことです。[Why use Pydantic?](https://docs.pydantic.dev/latest/#why-use-pydantic) を見ると、隙のない優良パッケージに見えます。
学んで損はなさそうです。
- 型注釈による制御（型書いてないんですよね…）
- JSONスキーマで記述できる（Cerberusと同じ…！）
- カスタマイズ性が高い
- 多くの著名パッケージで使われている（FastAPI, Djangoなど）
- 月間7000万DLかつ巨大企業による利用実績がある

# Pytdanticの使い方
まずは[Example](https://docs.pydantic.dev/latest/#pydantic-examples)を見てみます。Userクラスが1つのバリデーション/型変換対象です。external_dataをUserクラスに放り込むことで、以下が実現できています。この例に色々凝縮されていそうなので、しっかり理解していきます。
- class定義の引数をBaseModelとし、Dict型を[アンパック代入](https://docs.python.org/ja/3.13/tutorial/controlflow.html#tut-unpacking-arguments)する
- 変数.属性名でのデータ取得（user.id）
- 固定値の設定（user.name。初期値かも）※要確認
- 文字列からdatetime型への暗黙変換
- str型とbyte型の暗黙変換（b'cheese' → 'cheese'）
- PositiveIntで文字列の数値から数値型への暗黙変換（'1' → 1) ※要確認
```python
from datetime import datetime

from pydantic import BaseModel, PositiveInt


class User(BaseModel):
    id: int  
    name: str = 'John Doe'  
    signup_ts: datetime | None  
    tastes: dict[str, PositiveInt]  


external_data = {
    'id': 123,
    'signup_ts': '2019-06-01 12:22',  
    'tastes': {
        'wine': 9,
        b'cheese': 7,  
        'cabbage': '1',  
    },
}

user = User(**external_data)  

print(user.id)  
#> 123
print(user.model_dump())  
"""
{
    'id': 123,
    'name': 'John Doe',
    'signup_ts': datetime.datetime(2019, 6, 1, 12, 22),
    'tastes': {'wine': 9, 'cheese': 7, 'cabbage': 1},
}
"""
```

まだ完璧に理解していないので、もう少し調べます。今まではすぐに動かして試してしまっていたので、しっかりドキュメントを読んでいきます。
- 上記の `str = 'John Doe'` は初期値なのか？
    - ドキュメントに説明が載っていました。「デフォルト値があるため、必須ではない」との記述から、初期値であると理解しました。
    ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/217144/17cb9d8e-5552-6423-84e3-3c411ec7c685.png)

- 上記の `PositiveInt` の解釈はあっているか？
    - PositiveInt型はAnnotated[int, annotated_types.Gt(0)]の省略形です。これは、int型でannotated_types.Gt(0): Greater than 0 という意味です。つまり「PositiveInt = 正の整数」であることがわかりました。
    ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/217144/946d66d9-7732-54b6-56af-401cd7f8f55d.png)

# バリデーションエラーの場合
今度は同じUserクラスに対し、違反となるデータを投入した場合の例を見てみます。
- Class定義は前述の例と同様
- User.idはint型で定義しているが、文字列型を入れている
- User.signup_tsの属性が無い
- User.tastesは空のdictが格納されている
- バリデーションを行いたい場合、Userクラスに対象データをアンパック代入したタイミングで、ValidationErrorが発生する
- エラーオブジェクトeに対し、e.errors()を行うとエラーの詳細が取得できる
- 今回は、①idの型違反、②signup_tsの属性値が無い、という2つのエラーが発生している
- User.nameは初期値があるから違反なし、User.tastesは空のdict型であれば必須違反にはならないということが分かる

```python: Validation Errorパターン
# continuing the above example...

from datetime import datetime
from pydantic import BaseModel, PositiveInt, ValidationError


class User(BaseModel):
    id: int
    name: str = 'John Doe'
    signup_ts: datetime | None
    tastes: dict[str, PositiveInt]


external_data = {'id': 'not an int', 'tastes': {}}  

try:
    User(**external_data)  
except ValidationError as e:
    print(e.errors())
    """
    [
        {
            'type': 'int_parsing',
            'loc': ('id',),
            'msg': 'Input should be a valid integer, unable to parse string as an integer',
            'input': 'not an int',
            'url': 'https://errors.pydantic.dev/2/v/int_parsing',
        },
        {
            'type': 'missing',
            'loc': ('signup_ts',),
            'msg': 'Field required',
            'input': {'id': 'not an int', 'tastes': {}},
            'url': 'https://errors.pydantic.dev/2/v/missing',
        },
    ]
    """
```

## 定義の動的生成
業務でのユースケースとして、エンティティ定義のような定義書を読み込んでモデルを作成したいケースがあると思います。その場合は、[dynamic-model-creation](https://docs.pydantic.dev/latest/concepts/models/#dynamic-model-creation)という仕組みを使うようです。

以下のDynamicFoobarModelと、StaticFoobarModelは同一の意味になります。

```python:dynamic-model-creation
from pydantic import BaseModel, create_model

DynamicFoobarModel = create_model(
    'DynamicFoobarModel', foo=(str, ...), bar=(int, 123)
)


class StaticFoobarModel(BaseModel):
    foo: str
    bar: int = 123
```

create_modelの引数には以下の要素を入れていきます。また、初期値を設定しない場合、[必須項目](https://docs.pydantic.dev/latest/concepts/models/#required-fields)となります。
- モデル名
- 型名 = tapple(type, default value)
    - `bar=(int, 123)`は、初期値123を持つint型の定義
    - `bar=(int, ...)`は、初期値を持たないint型の定義、つまり必須項目（Ellipsisを使用して初期値なしとする）
- 型名 = tapple(type, Field(...))
    - [Field](https://docs.pydantic.dev/latest/concepts/fields/)は様々なルールを付与するもの
    - `bar=(int, Field(default=123))`は、`bar=(int, 123)`と同様
    - `bar=(int, Field(default='twelve', validate_default=True))`は、（通常はチェック対象外である）デフォルト値の'twelve'をチェック対象にする
    - `bar=(int, Field(alias='var'))`で定義したフィールドは、`DynamicFoobarModel(var=123)`のように別名で代入やバリデートなどができる
    - `bar=(int, Field(gt=0))`は、greater than 0 のため **0より大きい** という意味
    - `bar=(int, Field(lt=0))`は、less than 0 のため **0より小さい** という意味
    - `bar=(int, Field(ge=0))`は、greater equal 0 のため **0以上** という意味
    - `bar=(int, Field(le=0))`は、less equal 0 のため **0以下** という意味
    - `bar=(int, Field(ge=1, lt=100))`は、1~99までの整数という意味
    - `bar=(str, Field(min_length=3))`は、最小文字数が3という意味
    - `bar=(int, Field(max_length=10))`は、最大文字数が10という意味
    - `bar=(int, Field(pattern=r'^\d*$'))`は、正規表現ですべて数字という意味
    - `bar=(Decimal, Field(max_digits=5, decimal_places=2))`は、123.45のように全体の長さと少数の長さを定義している
    - 
- 型名 = tapple(typing.Annotated[type, Field(...)])


また、継承のようにベースとなるモデルに独自の定義を加えることもできます。

```python:dynamic-model-creation
from pydantic import BaseModel, create_model


class FooModel(BaseModel):
    foo: str
    bar: int = 123


BarModel = create_model(
    'BarModel',
    apple=(str, 'russet'),
    banana=(str, 'yellow'),
    __base__=FooModel,
)
print(BarModel)
#> <class '__main__.BarModel'>
print(BarModel.model_fields.keys())
#> dict_keys(['foo', 'bar', 'apple', 'banana'])
```

今日はここまで。続きは以下。
https://docs.pydantic.dev/latest/concepts/json_schema/