pydanticで任意のキー名を持つJSONを検証する
この記事は Money Forward Kansai Advent Calendar 2024 12月5日の記事です。
前置き
こんにちは。Money ForwardでMLエンジニアをやっているMarisaka Mozzです。最近の楽しみは大阪の駅前ビルでランチの店を開拓することです。今のところ ネスパ がお気に入りです。
pydanticはJSONのスキーマを定義して、その定義に沿っているかどうかを検証できる便利なPythonのライブラリです。一般的な使用用途としては、APIの出力フォーマットなどをpydanticで定義することがよくあります。こうすることで、クライアントにJSONを返す前に、ちゃんと定義されたスキーマに則ったJSONが作成できているかを検証できるわけですね。
通常は下記のように定義すると思います。
from pydantic import BaseModel
class MyModel(BaseModel):
key1: str
key2: int
my_model = MyModel.model_validate({"key1": "foo", "key2": 123})
my_model.model_dump()
# {'key1': 'foo', 'key2': 123}
この場合、キー(上記の例であれば「key1」や「key2」)はあらかじめ決めておかなければなりません。
しかし、世の中にはあらかじめキーの名前を決めておきたくない、あるいは、決めることが難しい場合があります。例えば、
- キーの名前はなんでもいいんだけど、その中に入っている値は文字列だけを許したい
- 入れ子構造を2段階までにしたい
というようなケースです。そのような場合にはどのようにすれば良いでしょうか?pydanticのRootModelを使ってそれを実現することができます。
ここから本題
RootModelを使えば、キーと値のペアではなく、値だけを定義することができます。例えば、文字列の値を定義する場合は以下のように実装します。
from pydantic import RootModel
StringValue = RootModel[str]
StringValue.model_validate("foo") # OK
StringValue.model_validate(123) # ValidationError
キーや値に文字列だけを許可するJSONであれば、以下のように定義できます。この時、事前にキー名を想定していない、つまり、任意のキー名を許容することに注目してください。
StringModel = RootModel[dict[str, str]]
StringModel.model_validate({"foo": "bar"}) # OK
StringModel.model_validate({"foo": 123}) # ValidationError
値は何でもいいんだけど入れ子構造を2段階までにしたい場合は、以下のように定義できます。
from typing import Union
LeafValue = RootModel[Union[str, int, float, bool, None]]
LeafModel = RootModel[dict[str, LeafValue]]
NestedModel = RootModel[dict[str, Union[LeafValue, LeafModel]]]
NestedModel.model_validate({"foo": 123}) # OK
NestedModel.model_validate({"foo": 123, "bar": {"hoge": "fuga"}}) # OK
NestedModel.model_validate({"key1": {"key2": {}}}) # ValidationError
発展
さらに発展として、キーや値に文字列だけを許可したいが、もしもintやfloatの場合はValidationErrorとせず、文字列に変換して設定したいという場合は、上記の最初の例で作成したStringValueを継承したクラスを作って以下のように実現できます。
from pydantic import model_validator
class MyStringValue(StringValue):
@model_validator(mode="before")
def convert_to_str(cls, v):
if isinstance(v, (int, float)):
return str(v)
return v
MyStringModel = RootModel[dict[str, MyStringValue]]
my_model = MyStringModel.model_validate({"foo": 123}) # OK
my_model.model_dump() # {'foo': '123'}
@model_validator
のパラメータに mode="before"
とすることで、文字列かどうかの検証の前にintやfloatを文字列に変換していることがわかります。
まとめ
というわけで、RootModelを使えば柔軟にJSONのスキーマを定義できることがわかりました。うまく使えば様々な場面に応用できそうな気がします。
pydanticはv2になってからかなり仕様が変わってしまった関係で、ChatGPTやGitHub Copilotに聞いてもv1の古い実装が提案されることが多く、なかなか正しい情報に辿り着くことが難しかったりします。今回の情報がお役に立てれば幸いです。
Discussion