こんにちは。会員システムグループの鈴木(雅)です。
2018年5月に中途でニフティに入社し、以来 @niftyメール の開発、運用を担当しております。具体的には、メールデータを保存するオンプレの分散オブジェクトストレージシステムの運用から、Webアプリケーションの開発〜運用まで幅広い業務に従事しています。
よく鍛えられた Mr.Children のファンでもあり、2022年5月10日はお休みをいただいて彼らのデビュー30周年をお祝いしに 30th Anivversary Tour が催される東京ドームへ赴いておりました。
(しかしながら厳選なる抽選の結果、チケットはご用意していただけず…。)
さて、今回は直近の @niftyメール関連のサービス開発で利用した技術スタックの一部である、pydantic ついてお話したいと思います。
pydanticとは
Python 製ライブラリで、Python の型アノテーションを用いてデータのバリデーションを簡単に行えるようになります。クリーンアーキテクチャのエンティティの実装などで重宝します。
公式ページは以下です。
https://pydantic-docs.helpmanual.io/
FastAPI や Django Ninja などで利用されているため、これらのフレームワークを使っている方はご存知かと思います。
私自身も個人的に FastAPI を触っている際に、その使い勝手の良さに心を奪われました。
今回開発したシステムでは Flask を採用しましたが、pydantic は単独で利用しても十分にそのポテンシャルを発揮してくれています。
利用にあたっては、前提として 型ヒント に関する知識は必要になります。
なお、型安全な開発をしたいのであれば静的型付け言語を使えばいいのではないかというご意見もあるかもしれませんが、本記事のスコープではないため割愛いたします。
基本的な使い方
インストール
pip でインストールが可能です。
1 |
pip install pydantic |
モデル作成(基本形)
BaseModel を継承したクラスを記述します。
書き方は dataclass と似てますね。
1 2 3 4 5 6 7 8 9 10 |
from datetime import datetime from typing import List, Optional from pydantic import BaseModel class User(BaseModel): id: int name = 'Shinjuku Taro' signup_ts: Optional[datetime] = None friends: List[int] = [] |
この例では、以下のフィールドを持つ User クラスを定義しています。
変数名 | 型 |
---|---|
id | int |
name | str |
signup_ts | datetime |
friends | List (valueは int) |
上述のクラスは以下の通りに初期化し、”.”で繋いでフィールドにアクセスできます。
1 2 3 4 5 6 7 8 9 10 11 12 |
# 初期化 user = User( id=123, signup_ts='2022-05-19 12:22', friends=[1, 5] ) # フィールドにアクセス print(user.id) # 123 print(user.name) # Shinjuku Taro print(user.signup_ts) # 2022-05-19 12:22:00 print(user.friends) # [1, 5] |
name はクラス定義で初期値 (“Shinjuku Taro”) を定義しているため、インスタンス化の際に値を渡していなくても初期値が出力されています。
モデル作成(辞書型データの利用)
辞書型の変数を渡して初期化することも可能です。
1 2 3 4 5 6 7 8 9 10 11 12 |
external_data = { 'id': '123', 'signup_ts': '2022-05-19 12:22', 'friends': [1, '5'], } user = User(**external_data) print(user.id) # 123 print(user.name) # Shinjuku Taro print(user.signup_ts) # 2022-05-19 12:22:00 print(user.friends) # [1, 5] |
User クラスの
id は int型で定義されていますが、
external_data の
id の値は str型です。
pydantic では str、bytes、float型を int型の変数に代入する際はできる限り int型にキャストするようになっています。
(変換できない場合は例外が投げられます。)
公式ドキュメントでは このあたり で触れられていますが、Strict Types を使うと入力値も厳密に型チェックができるようになります。
型が異なる場合
各フィールドの型定義と異なる型の値を代入すると実行時に例外が送出されます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
from pydantic import ValidationError try: user = User( id='not a number', signup_ts='dummy', friends=[1, 'example'] ) except ValidationError as e: print(e) # 3 validation errors for User # id # value is not a valid integer (type=type_error.integer) # signup_ts # invalid datetime format (type=value_error.datetime) # friends -> 1 # value is not a valid integer (type=type_error.integer) |
例外のメッセージは json() メソッドにより JSON 形式でも出力可能です。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
try: user = User( id='not a number', signup_ts='dummy', friends=[1, 'example'] ) except ValidationError as e: print(e.json()) # [ # { # "loc": [ # "id" # ], # "msg": "value is not a valid integer", # "type": "type_error.integer" # }, # { # "loc": [ # "signup_ts" # ], # "msg": "invalid datetime format", # "type": "value_error.datetime" # }, # { # "loc": [ # "friends", # 1 # ], # "msg": "value is not a valid integer", # "type": "type_error.integer" # } # ] |
Python 自体は動的型付け言語であり、v3.5 から型ヒントが導入されたとはいえランタイムは型の違いを検知してエラーを出力してはくれないので、通常は mypy などの型チェッカーを用いて型の不整合を検知する必要があります。
ただ、pydantic を使えば型の不整合は実行時に検出されるため、型チェッカーの実行漏れなどですり抜けてしまってもまだセーフティネットが残っているという心理的な安心感は生まれます。
型の種類
Python の組み込み型だけでなく、pydantic オリジナルの便利な型も利用できます。
以下は一例です。
- EmailStr:Eメール用の型。
- HttpUrl:http or https で始まる URL に準拠した型。
- SecretStr:センシティブな情報を扱う型。フィールドにアクセスすると '**********'と表示される。
詳細は Field Types を参照ください。
カスタムバリデーション
pydantic の @validator デコレータを使って簡単にフィールド値のバリデーションを行うことができます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
from pydantic import BaseModel, ValidationError, validator class UserModel(BaseModel): name: str username: str password1: str password2: str @validator('name') def name_must_contain_space(cls, v): if ' ' not in v: raise ValueError('must contain a space') return v.title() @validator('password2') def passwords_match(cls, v, values, **kwargs): if 'password1' in values and v != values['password1']: raise ValueError('passwords do not match') return v @validator('username') def username_alphanumeric(cls, v): assert v.isalnum(), 'must be alphanumeric' return v |
バリデーションメソッドの書き方の基本
@validator
にはバリデーションを行うフィールド名を渡します。
@validator
が適用されたメソッドの第二引数にはデコレータに渡したフィールドの値が格納されています。
引数の名称は任意の値で構いません(ここでは
v としています)。
あとはこの
v を用いて任意のバリデーションを記述します。
本メソッドは最終的にフィールド値を return するか、バリデーションエラーであれば
ValueError 、
TypeError 、
AssertionError 例外を raise します。
では、サンプルコードの3つのバリデーションメソッドについて見ていきましょう。
name
値に半角スペースが含まれていなければ
ValueError を返します。
シンプルですね。
password2
入力フォームでパスワードを 2回入力してもらい、両者が一致しているかどうかを確認するようなケースを想定しています。
バリデーションメソッドの第三引数 (上述の
values ) には他のフィールド値が辞書型で保存されているため、 そこから
password1 にアクセスしています。
なお、任意のフィールド値のバリデーションで他のフィールド値を利用する際には以下の2点に注意する必要があります。
- バリデーションに失敗したフィールドは values に格納されない。
- バリデーションは基本的にフィールドを定義した順番に行われる。
password1 の存在確認をしているのは 1つ目の理由のためです。
password1 はカスタムバリデーションを定義していないため、str 型であるかどうかのみしかチェックされませんが、その検証に失敗した場合は
values されません。
また、
password1 は
password2 の前に定義されているため、2つ目の理由により
password1 のバリデーションメソッド内で
password2 にアクセスすることはできません。
(
password1 のバリデーション時は
password2 がバリデーション前だからです。)
1 2 3 4 5 6 |
# この場合、values に password2 は存在しない。 @validator('password1') def passwords_match(cls, v, values, **kwargs): if 'password2' in values and v != values['password2']: raise ValueError('passwords do not match') return v |
username
assert 文を用いて英数字であるかどうかを確認しています。
条件に当てはまらない場合は
AssertionError が返されます。
このように、フィールド値に対するちょっと複雑なバリデーションも簡潔に実装することができます。
サンプルコードのようにデータとそのバリデーションを同一クラス内に実装することを半ば強制できるので、ロジックが分離するといったような凝集度の低いコードが生まれる可能性を未然に防ぐことができます。
さいごに
pydantic は今回ご紹介した機能以外にも様々な特徴を備えています。
- イミュータブルなオブジェクトの生成
- ORM モードの利用で ORM オブジェクトへのマッピング
- BaseSettings の利用によるアプリケーション設定の柔軟な定義
- モデルから JSON スキーマの自動生成
- etc…
これらの機能を有効活用することにより、品質の高いソフトウェアを短時間で開発することができます。
本記事が皆さんの安全な Python 開発ライフに少しでもお役に立てましたら幸いです。