pydanticを1行の入力チェック関数として使う

# この記事について

入力ライブラリのPydanticで、（クラス定義を通さずに）関数で値をチェックする方法を紹介します

検証環境のバージョン

- Python 3.10
- pydantic 1.10.7

## pydanticって何？

入力をチェックするPythonのライブラリです

```python
# ユーザー情報クラスを定義する
class UserInfo(BaseModel):
    user_name: str
    password: str

# もし入力パラメータが{"user_name": "文字列", "password": "文字列"}の形式でなければ例外を投げる
UserInfo.parse_raw(input)
```

BaseModelを継承したクラスの型ヒントから、値を入力チェックすることができます。

# この記事で実現したいこと、実現すること

## 実現したいこと

JSONのような入力チェックなら標準の使い方そのままでいいのですが、プリミティブなデータに対して直接入力チェックを使いたいことがあります。

たとえば値によって処理を分岐させるケースです。

```python
# str型を検証する。値がNullか空文字かどうか
if is_null_or_empty(value):
    # Nullか空文字なら初期化関数を実行する
    value = initialize()
```

この時に検証したい引数は、BaseModel型ではなく、シンプルなstr型です。

せっかく高機能な入力チェックライブラリが入っているのだから、こういう簡単な入力チェックにもpydanticが使えたら便利です。

…が、わざわざこれをpydanticでチェックするためにclassを書きたくありません。**個別のクラスを書かずに1行で入力チェックだけ**を実現したい。

## 実現すること

先の入力チェックを、pydanticのフィールドで1行で書けるようにします

```python
# str型を検証する。値がNullか空文字かどうか
if not inline_validate(value, constr(min_length=1, strict=True)):
    # Nullか空文字なら初期化関数を実行する
    value = initialize()
```

# 実装方法: 動的にクラスを作る

実装のために使うpydanticの関数はこちらです。

- **create_pydantic_model_from_dataclass**  
pythonのDataModelクラスをpydanticで使えるクラスに変換してくれる関数です。

DataModelクラスは、普通のクラスと比べて、型ヒントの情報が格段に扱いやすいメリットがあります。動的にクラスを定義する際に、型ヒントのあるフィールドも動的に定義できます。pythonの標準機能です。

この関数をラップして、以下のようなinline_validate関数を実装します。

```python
from pydantic.dataclasses import create_pydantic_model_from_dataclass
from dataclasses import make_dataclass

def inline_validate(value, type):
    """
    1行でvalueを入力チェックする
    """
    try:
        # 動的にPydanticの対象クラスを作成する
        create_pydantic_model_from_dataclass(
            make_dataclass("ClassName", fields=[("value", type)])
        ).parse_obj({"value": value})
        # 入力チェックが例外を投げなければTrueを返す
        return True
    except Exception:
        # parse_objが例外を投げたのならFalseを返す
        return False
```

ソースの内容はシンプルです。Pydanticの検証をさせるクラスを動的に作って、検証処理を走らせています。

作った関数は`inline_validate(値, 型)`で実行できます。

## 使ってみる

inline_validateを使って、変数valueをいろいろな方法で検証します

- constrを使って、valueが【1文字以上の文字列】であることを確認します

```python
from pydantic import constr

# Nullや空文字ではないことを確認する型を定数化する
# valueが数値型だとf"{value}"として検証してしまうので、strict=Trueで文字列以外を検証除外する
IS_NOT_NULL_OR_EMPTY = constr(min_length=1, strict=True)

# Null空文字チェックを実施する
if not inline_validate(value, IS_NOT_NULL_OR_EMPTY):
    print("valueはNullまたは空文字です")
```

- conintを使って、valueが【1～999の数値】と【1000以上の数値】で処理を分岐する

```python
from pydantic import conint

if inline_validate(value, conint(ge=1, le=999)):
    print(f"{value}は999以下の自然数です")
elif inline_validate(value, conint(ge=1000)):
    print(f"{value}は1000以上の自然数です")
```

- IPv4AddressとHttpUrlを使って、valueが【IPv4形式】と【HTTPのURL】で処理を分岐する

```python
from ipaddress import IPv4Address
from pydantic import HttpUrl

if inline_validate(value, constr(strict=True)):
    # 整数もIPv4Addressに変換できるが、今回はvalueが文字列である場合に限定して検証する
    if inline_validate(value, IPv4Address):
        # 通信先をIPアドレスで直接入力されたときの処理を実行する
        print(f"DIRECT {value}")
    elif inline_validate(value, HttpUrl):
        # 通信先をHTTPのURLで入力されたときの処理を実行する
        print(f"HTTP {value}")
```

- UUID4を使って、valueが【UUID4形式】かどうかを検証する

```python
from pydantic import UUID4

if inline_validate(value, UUID4):
    print(f"{value}はUUID4形式です")
```

- condateを使って、valueが指定期間の日時かどうかを検証する
 
```python
from pydantic import condate, FutureDate, PastDate
from datetime import date

if inline_validate(
    value, # valueは"2021-02-29"のような文字列です
    condate(
        ge=date(year=2021, month=1, day=1),
        le=date(year=2021, month=12, day=31),
    ),
):
    print(f"{value}は2021年の日付です")

# 未来の日付かどうかをチェックするだけなら、こう書くこともできます
inline_validate(value, FutureDate)
# 過去の日付かどうかのチェックなら、こう書くこともできます
inline_validate(value, PastDate)

# date型の標準チェックのユーティリティは色々あるのですが、
# datetime型へのユーティリティはpydanticにはありません
```

このように、pydanticでデフォルトで定義されている型チェックが使えるようになります。

一覧はこちらにあります
https://docs.pydantic.dev/latest/usage/types/#pydantic-types

## 型のキャストにpydanticを使う

同じような方法で、coerce（型のキャスト）に使うこともできます

```python
from pydantic.dataclasses import create_pydantic_model_from_dataclass
from dataclasses import make_dataclass
from typing import Generic, TypeVar

# ジェネリクスを宣言する
T = TypeVar("T")


class Coerce(Generic[T]):
    """ 型不明な値をキャストするクラス """
    @staticmethod
    def to(value, type: T) -> T | None:
        """ valueをTで宣言した型にキャスト、できないのならNoneを返す """
        try:
            return (
                create_pydantic_model_from_dataclass(
                    make_dataclass("ClassName", fields=[("value", type)])
                )
                .parse_obj({"value": value})
                .value
            )
        except Exception:
            return None
```

以下のように使うことで、型不明なvalueを安全にキャストできます。

```python
from datetime import datetime
# valueを日時型にキャストする。キャストに失敗したらNoneを返す
dt = Coerce[datetime].to(value, datetime)
if dt is not None:
    # キャストできる（存在する日時で、dtがdatetime型である）のなら処理をする
    print(dt)
```

特定の範囲の数値だけを処理したいときも便利です

```python
from pydantic import conint

# 機器から上がってきた1バイトデータの最上位ビットが立っているかを検証する
v = Coerce[int].to(value, conint(ge=0x80, le=0xFF))
if v is not None:
    # 最上位ビットが立っているのなら処理を続行する
    print(v)
```

オプションを使うことで、【4文字以上の文字列なら大文字にする】といった変換もできます。

```python
upper = Coerce[str].to("select text", constr(min_length=4, to_upper=True))
if upper is not None:
    # valueが4文字以上なら実行する
    print(upper) # "SELECT TEXT"が出力される
```

インポートできるかわからないライブラリがあるときに、存在すればインポート、みたいなこともできます

```python
from pydantic import PyObject

cos = Coerce[PyObject].to("math.cos", PyObject)
if cos is not None:
    # Cos関数がインポートできているのなら実行する
    print(cos(1.0))
```

# おまけ: 別の実装方法

蛇足ですが、以下のように**必須ではないフィールドをぶら下げる**方法もあります

```python
from pydantic import BaseModel, constr, conint
from typing import Optional

class SharedValidator(BaseModel):
    # 空文字チェック
    is_empty_str: Optional[constr(max_length=0)] = None
    # 数値が0～9のチェック
    is_value_check: Optional[conint(ge=0, le=9)] = None

#　普通に使ってみる
try:
    SharedValidator(is_empty_str=value)
    print("valueは空文字です")
except Exception:
    print("valueは空文字ではありません")
```

仕組み的な都合で、以下のような問題があります。

- Null検証できない
- tryの中に入れないといけない

少しだけ手を加えて、インラインで実行できるようにします。

```python
from dataclasses import dataclass
from pydantic import BaseModel, constr, conint
from typing import Optional

@dataclass
class Condition:
    # 空文字かどうかをチェックする
    is_not_empty_str: str = None
    # 1桁の自然数かどうかをチェックする
    is_one_digit_natural_number: str = None
    # 以下に他のバリデータを追加...


class _ExecuteValidateConditions(BaseModel):
    # 空文字かどうかをチェックする
    is_not_empty_str: Optional[constr(min_length=1, strict=True)] = None
    # 1桁の自然数かどうかをチェックする
    is_one_digit_natural_number: Optional[conint(ge=1, le=9)] = None
    # 以下に他のバリデータを追加...

    class Config:
        orm_mode = True


def inline_validator(model: Condition):
    """ データを検証する """
    try:
        # BaseModel型に変換する
        m = _ExecuteValidateConditions.from_orm(model)
        # 有効値の数を検証する
        if len(m.dict(exclude_none=True, exclude_unset=True).keys()) >= 1:
            # 1つ以上の数値チェックを満たしたのならTrueを返す
            return True
        else:
            # キーがない（全ての引数がNullだった）のならFalseを返す
            return False
    except Exception:
        return False
```

最初のinline_validatorに比べて冗長ですが、この形でもinline_validatorを実装できます。

```python
# 1桁の自然数かどうかを検証する
if inline_validator(Condition(is_one_digit_natural_number=value)):
    print(f"{value}は1桁の自然数です")
```

from_orm（※オブジェクトから検証対象に変換する）を使って、初期化と検証のタイミングをずらすことで、True/Falseでパラメータを返せるようにしています。

# まとめ

pydanticに少し手を加えるだけで、処理の分岐でも強い力を発揮することができます