(typingと)pydanticで始める入出力のモデル定義とバリデーション

仕事

こんにちは、ものレボの橋本です。

ものレボでは現在バックエンドの実装に主にPythonを利用していますが、そのフレームワークであるFastAPIでは入出力のモデル定義をpydanticを使用して行います。
今回は、その部分を掘り下げて見ようと思います。

pydanticとは

まず、Pydanticとは何かという話をする必要がありますが、公式のドキュメントにはこう書かれています。

Data validation and settings management using python type annotations.

https://pydantic-docs.helpmanual.io/

意訳すると、「Pythonの型注釈を用いたデータ検証と設定管理」といったところになると思います。
pydanticは、上記の機能のみを提供するパッケージで使用する上で、何かフレームワークを使ったりする必要はなく、既存のスクリプトへの適用も簡単に行えます。

入力サンプルとバリデーションルール

さて、pydanticが何かを説明したところで、本題の「(typing)とpydanticで始める入出力のモデル定義とバリデーション」に入っていきたいと思います。
ちなみに、「(typingと)」としているのは、pydantic自体が型注釈(Pythonの言葉ではtype hints)を前提に書かれ使われているためです。

今回は、入力に住所と氏名、年齢、Eメールアドレス(複数)を受け取るようなモデルを定義してみたいと思います。
入力値のjsonは以下のようなものになります。

{
  "country": "JP",
  "zip_code": "604-8206",
  "pref": "京都府",
  "address1": "京都市中京区",
  "address2": "新町通三条上ル町頭町112",
  "address3": "菊三ビル3F",
  "fisrt_name": "太郎",
  "last_name": "ものレボ",
  "age": 20,
  "email_addresses": [
    "webmaster@example.com",
    "sample@example.com"
  ]
}

さて、ここで私達が行いたくなるバリデーションとしては以下の様なものが挙げられます。

  • 必須項目: country, pref, address1, address2, first_name, last_name, age
  • 入力値の制限:
    • country: ISO 3166-1 alpha-2準拠の国名コード
    • zip_code: 数字とハイフンのみで構成された文字列
    • pref, address1, address2, address3, first_name, last_name: 文字列で1文字以上であること
    • ※ 本来であればpref(都道府県)はより厳密にチェックすべきだが例示なので今回は省略
    • age: 正の整数かつ、18以上
    • email_addresses: RFC 5321準拠のEメールアドレスかつ、1つ以上

古の時代には、上記をひとつひとつif-でチェックしていたかもしれませんが、今回は現代的にpydanticを使えます。

モデル定義とバリデーションの実装

from pydantic import BaseModel


class UserInfo(BaseModel):
    country: str
    zip_code: str
    pref: str
    address1: str
    address2: str
    address3: str
    first_name: str
    last_name: str
    age: int
    email_addresses: list[str]

pydanticのBaseModelというクラスを継承し、型ヒント付きでメンバー変数を書くだけでOKです。
ただし、この状態ではほぼ意図したバリデーションが効いていませんので、順番に説明を加えながらバリデーションを追加していきたいと思います。

始めに必須項目のバリデーションを追加します。
また、概ねメンバー変数名から読み取れますが、わかりやすい説明がついていると嬉しいので合わせてメンバー変数が何を表しているのか説明も追加していきます。

from pydantic import BaseModel, Field


class UserInfo(BaseModel):
    # 第一引数はデフォルト値, 省略(...)時は必須になる
    country: str = Field(..., title="ISO 3166-1 alpha-2準拠の国名コード")
    zip_code: str = Field(..., title="郵便番号")
    pref: str = Field(..., title="都道府県名")
    address1: str = Field(..., title="市区町村")
    address2: str = Field(..., title="番地")
    address3: str = Field("", title="ビル・マンション名 部屋番号")
    first_name: str = Field(..., title="姓")
    last_name: str = Field(..., title="名")
    age: int = Field(..., title="年齢")
    email_addresses: list[str] = Field(..., title="Eメールアドレス")


user_info = UserInfo()
"""
pydantic.error_wrappers.ValidationError: 9 validation errors for UserInfo
country
  field required (type=value_error.missing)
zip_code
  field required (type=value_error.missing)
pref
  field required (type=value_error.missing)
address1
  field required (type=value_error.missing)
address2
  field required (type=value_error.missing)
first_name
  field required (type=value_error.missing)
last_name
  field required (type=value_error.missing)
age
  field required (type=value_error.missing)
email_addresses
  field required (type=value_error.missing)
"""

Fieldの第一引数にデフォルト値を設定することで、必須項目ではなくなります。また、未指定であったり、...で省略した場合には、必須になります。

次にcountryにISO 3166-1 alpha-2準拠の国名コードという制限をつけていきます。

from enum import auto, Enum
from pydantic import BaseModel, Field
from typing import Any


class StrEnum(Enum):
    @staticmethod
    def _generate_next_value_(name: str, start: int, count: int, last_values: list[Any]) -> str:
        # NOTE: Enum.autoによって定義される値を列挙型のメンバー名の大文字にする
        return name.upper()


class CountryCode(StrEnum):
    # NOTE: JP = "JP" としても良いが、人類はミスをするのでStrEnumを作ることでそれを回避する
    JP = auto()
    US = auto()
    ...  # 例示なので他は省略する


class UserInfo(BaseModel):
    # 定義したCountryCodeを型ヒントに指定することで、その列挙型のメンバーの値のみ許可される
    country: CountryCode = Field(..., title="ISO 3166-1 alpha-2準拠の国名コード")
    zip_code: str = Field(..., title="郵便番号")
    pref: str = Field(..., title="都道府県名")
    address1: str = Field(..., title="市区町村")
    address2: str = Field(..., title="町名番地")
    address3: str = Field("", title="ビル・マンション名 部屋番号")
    first_name: str = Field(..., title="姓")
    last_name: str = Field(..., title="名")
    age: int = Field(..., title="年齢")
    email_addresses: list[str] = Field(..., title="Eメールアドレス")


user_info = UserInfo(
    country="",
    zip_code="",
    pref="",
    address1="",
    address2="",
    first_name="",
    last_name="",
    age=10,
    email_addresses=[],
)
"""
pydantic.error_wrappers.ValidationError: 1 validation error for UserInfo
country
  value is not a valid enumeration member; permitted: 'JP', 'US' (type=type_error.enum; enum_values=[<CountryCode.JP: 'JP'>, <CountryCode.US: 'US'>])
"""

急にコードの量が増えてしまいましたが、許可したい値の列挙型を作ってそれを型ヒントに指定することで、列挙型のメンバーの値しか取ることしかできないようになります。

3つ目は、zip_codeの数字とハイフンのみで構成された文字列です。
ここからは、すべてのコードを乗せると長くなってしまうので、該当部分のみ抜粋する形で解説していきます。

from pydantic import BaseModel, Field, validator
from typing import Any


class UserInfo(BaseModel):
    # 郵便番号は、5-7文字
    zip_code: str = Field(..., title="郵便番号", min_length=5, max_length=7)

    # プログラム的に不要なハイフンを取り除く
    @validator("zip_code", pre=True)
    def clean_zip_code(cls, v: Any) -> Any:
        if isinstance(v, str):
            return v.replace("-", "")
        return v


user_info = UserInfo(zip_code="")
"""
pydantic.error_wrappers.ValidationError: 1 validation error for UserInfo
zip_code
  ensure this value has at least 5 characters (type=value_error.any_str.min_length; limit_value=5)
"""

user_info = UserInfo(zip_code="604-8206-99999")
"""
pydantic.error_wrappers.ValidationError: 1 validation error for UserInfo
zip_code
  ensure this value has at most 7 characters (type=value_error.any_str.max_length; limit_value=7)
"""

user_info = UserInfo(zip_code="604-8206")
print(user_info.zip_code)
"""
6048206
"""

さて新しい要素の@validatorというデコレータが出てきました。これは、第一引数に指定したメンバー変数名のバリデーションを行う関数を定義して、インスタンスの生成時にバリデーションを行う際に使うものです。
今回はそれを使用し、プログラム的に不要な-(ハイフン)を取り除いた上で、文字数の制限をFieldで行うことで、日本の郵便番号を意図したバリデーションを行うようになります。(※ 私は日本の郵便番号について詳しくないため正しくない可能性があります)

4つ目は、pref, address1, address2, address3, first_name, last_nameが1文字以上であることです。

from pydantic import BaseModel, Field

class UserInfo(BaseModel):
    # 1文字以上
    pref: str = Field(..., title="都道府県名", min_length=1)
    address1: str = Field(..., title="市区町村", min_length=1)
    address2: str = Field(..., title="町名番地", min_length=1)
    # 文字数制限なしなので空文字OK
    address3: str = Field("", title="ビル・マンション名 部屋番号")
    first_name: str = Field(..., title="姓", min_length=1)
    last_name: str = Field(..., title="名", min_length=1)

user_info = UserInfo(
    pref="",
    address1="",
    address2="",
    first_name="",
    last_name="",
)
"""
pydantic.error_wrappers.ValidationError: 5 validation errors for UserInfo
pref
  ensure this value has at least 1 characters (type=value_error.any_str.min_length; limit_value=1)
address1
  ensure this value has at least 1 characters (type=value_error.any_str.min_length; limit_value=1)
address2
  ensure this value has at least 1 characters (type=value_error.any_str.min_length; limit_value=1)
first_name
  ensure this value has at least 1 characters (type=value_error.any_str.min_length; limit_value=1)
last_name
  ensure this value has at least 1 characters (type=value_error.any_str.min_length; limit_value=1)
"""

先程のzip_codeのバリデーションでも触れた文字数の制限を追加するだけでOKです。

5つ目は、ageが正の整数かつ、18歳以上であることです。

from pydantic import BaseModel, Field


class UserInfo(BaseModel):
    # 18以上
    age: int = Field(..., title="年齢", ge=18)


user_info = UserInfo(age=0)
"""
pydantic.error_wrappers.ValidationError: 1 validation error for UserInfo
age
  ensure this value is greater than or equal to 18 (type=value_error.number.not_ge; limit_value=18)
"""

文字数の制限と同様、Fieldで数値の条件を追加するだけでOKです。
また、pydanticには、PositiveIntという型も用意されており、これは、Field(..., ge=0)と等価で、プログラミングでよく使う0以上の整数を簡単に定義できるようになっています。

最後に、email_addressesがRFC 5321準拠のEメールアドレスかつ、1つ以上です。

from pydantic import BaseModel, EmailStr, Field

class UserInfo(BaseModel):
    # 要素が1つ以上
    email_addresses: list[EmailStr] = Field(..., title="Eメールアドレス", min_items=1)

user_info = UserInfo(email_addresses=[])
"""
pydantic.error_wrappers.ValidationError: 1 validation error for UserInfo
email_addresses
  ensure this value has at least 1 items (type=value_error.list.min_items; limit_value=1)
"""

user_info = UserInfo(email_addresses=[""])
pydantic.error_wrappers.ValidationError: 1 validation error for UserInfo
email_addresses -> 0
  value is not a valid email address (type=value_error.email)
"""

pydanticには、EmailStrという便利な型が用意されているので、型ヒントにそれを指定した上で、Fieldで配列の長さの条件を追加します。
EmailStrの使用時には、email-validatorというパッケージが必要になるので注意が必要です。

これで、モデルの定義とバリデーションの実装が完了しました。最後にソースコードの全体を見てみます。

import json
from enum import Enum, auto
from typing import Any

from pydantic import BaseModel, EmailStr, Field, validator

input_json = """
    {
        "country": "JP",
        "zip_code": "604-8206",
        "pref": "京都府",
        "address1": "京都市中京区",
        "address2": "新町通三条上ル町頭町112",
        "first_name": "太郎",
        "last_name": "ものレボ",
        "age": 20,
        "email_addresses": [
            "webmaster@example.com",
            "sample@example.com"
        ]
    }
"""
input_dict = json.loads(input_json)


class StrEnum(Enum):
    @staticmethod
    def _generate_next_value_(
        name: str, start: int, count: int, last_values: list[Any]
    ) -> str:
        # NOTE: Enum.autoによって定義される値を列挙型のメンバー名の大文字にする
        return name.upper()


class CountryCode(StrEnum):
    # NOTE: JP = "JP" としても良いが、人類はミスをするのでStrEnumを作ることでそれを回避する
    JP = auto()
    US = auto()
    ...  # 例示なので、他は省略する


class UserInfo(BaseModel):
    country: CountryCode = Field(..., title="ISO 3166-1 alpha-2準拠の国名コード")
    zip_code: str = Field(..., title="郵便番号", min_length=5, max_length=7)
    pref: str = Field(..., title="都道府県名", min_length=1)
    address1: str = Field(..., title="市区町村", min_length=1)
    address2: str = Field(..., title="町名番地", min_length=1)
    address3: str = Field("", title="ビル・マンション名 部屋番号")
    first_name: str = Field(..., title="姓", min_length=1)
    last_name: str = Field(..., title="名", min_length=1)
    age: int = Field(..., title="年齢", ge=18)
    email_addresses: list[EmailStr] = Field(..., title="Eメールアドレス", min_items=1)

    @validator("zip_code", pre=True)
    def clean_zip_code(cls, v: Any) -> Any:
        if isinstance(v, str):
            return v.replace("-", "")
        return v


user_info = UserInfo(**input_dict)
print(user_info.json(indent=2, ensure_ascii=False))
"""
{
    "country": "JP",
    "zip_code": "6048206",
    "pref": "京都府",
    "address1": "京都市中京区",
    "address2": "新町通三条上ル町頭町112",
    "address3": "",
    "first_name": "太郎",
    "last_name": "ものレボ",
    "age": 20,
    "email_addresses": [
        "webmaster@example.com",
        "sample@example.com"
    ]
}
"""

分かりやすいように入力値と、出力例が追加してあります。
少しのモデル定義とバリデーションの実装で入力値を安全に扱い、定義されたモデルでの出力が簡単に行えるようになったことが分かります。

pydanticには、今回触れた以上の機能もありますので、興味を持った方はドキュメントを眺めてみてはいかがでしょうか。

最後に

前回の記事、今回の記事に興味をもっていただきここまで読んでくださりありがとうございます。

ものレボでは、バックエンド・フロントエンド問わず、サービスを一緒に開発していくメンバーを募集しています!
興味があるかたは、このブログ記事のヘッダーにある「採用情報」から会社のことを知っていただければと思います。
Facebookなどで告知をしていますが、定期的にmeetupイベントも行っていますので、そちらも是非ご参加ください。

仕事pydantic,Python,typing

Posted by Kazuki Hashimoto