(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イベントも行っていますので、そちらも是非ご参加ください。