pydantic で効率的かつモダンなデータシリアライズ・バリデーション

6 min read読了の目安(約5400字 2

はじめに

この記事は、筆者がただただハマっている pydantic の良いところを紹介し続ける記事になっています。基本的には、「pydantic はいいものだ」という立場でお話をしています。他のライブラリのファンの人は、あまりいい気分がしないと思いますので、みないことをお勧めします。

pydantic

Python で書かれた Python 用のデータシリアライズ・バリデーションライブラリです。要するに、データをいい感じにオブジェクトにしてくれて、データに対しては型ヒントに基づく検証を実施してくれるツールです。

公式には以下のような記載があります。

Data validation and settings management using python type annotations. pydantic enforces type hints at runtime, and provides user friendly errors when data is invalid.

要するに、pydantic は以下の二点を満たしているところが良い点らしい。

  • 実行時に型情報を提供していること
  • データが不正な時に、ユーザーが理解しやすいエラーを出してくれること

pydantic の Pros&Cons

Pros

  • 実行時に意図しない型のデータが入力されてきた時にしっかりエラーを出してくれる
  • Swagger, JsonSchema, YAML などから自動でモデルを生成するライブラリがある
  • 類似ライブラリに比べて実行速度が早い
  • 実装済みのフィールドタイプが豊富かつ、mypy のプラグインがあり静的解析の導入が用意
  • FastAPIで使用されており、アプリケーション開発時に知見をそのまま流用できる

Cons

個人的には、ほとんどデメリットを感じていませんが強いていうなら...

  • 型をゆるふわに書いて、動的言語の良さを最大限生かしたい人には向かない。(Any タイプをたくさん使いたい人とか)
  • mypy や Typing に慣れていないと辛い

他の類似ツール

データのバリデーション・シリアライズでいうといくつか似たようなライブラリがあります。

他にも、 Protocol Buffers のスタブファイルなども実質的には似たような体験を提供してくれます。

APIフレームワークとの関係性

FastAPI では、モデルを定義する際に pydantic が使用されています。ちなみに、 ASGI サーバーは starlette

FastAPI は、Pythonの標準である型ヒントに基づいてPython 3.6 以降でAPI を構築するための、モダンで、高速(高パフォーマンス)な、Web フレームワークです。(公式より)

pydantic でモデルを定義して、APIサーバーを起動すると SwaggerUI が自動生成される便利な機能がついています。FastAPI についての説明は本記事では行いませんが、気になる方はこちらをどうぞ。個人的には、Flask よりも記述が簡単で、Flask よりも早いため、Python で小さいAPIサーバーを作るときは、まず第一選択肢にFastAPIを考えます。

コードジェネレーター

Swagger でいうところの swagger-py-codegen や、 Protocol Buffers でいうところの protoc コマンドのようなツールが pydantic にもあります。これがまた便利です。

datamodel-code-generator というツールになるんですが、 Json Schema や Swagger, YAML, JSON などのフォーマットを読み込んで、 pydantic ベースのモデルを自動生成してくれるツールになっています。

データ分析等の文脈で言えば、Json Schema などデータの構造が既知のデータを抽象的に扱いたい時に活躍します。例えば、自社の別のサービスを利用する際に、Swaggerなどがすでに公開されていれば、そこからモデルを自動作成して使用する、などです。

使い方は、 datamodel-codegen を pip 等でインストールし、以下のように使うだけです。

$ datamodel-codegen --input api.yaml --output model.py

このようなファイルが生成されます。詳しくは、こちら

# generated by datamodel-codegen:
#   filename:  api.yaml
#   timestamp: 2020-06-02T05:28:24+00:00

from __future__ import annotations

from typing import List, Optional

from pydantic import AnyUrl, BaseModel, Field


class Pet(BaseModel):
    id: int
    name: str
    tag: Optional[str] = None


class Pets(BaseModel):
    __root__: List[Pet]


class Error(BaseModel):
    code: int
    message: str


class Api(BaseModel):
    apiKey: Optional[str] = Field(
        None, description='To be used as a dataset parameter value'
    )
    apiVersionNumber: Optional[str] = Field(
        None, description='To be used as a version parameter value'
    )
    apiUrl: Optional[AnyUrl] = Field(
        None, description="The URL describing the dataset's fields"
    )
    apiDocumentationUrl: Optional[AnyUrl] = Field(
        None, description='A URL to the API console for each API'
    )


class Apis(BaseModel):
    __root__: List[Api]

pydantic を使ったコードの書き方

Settings managementより。シークレット情報を扱う際の機能も実装されています。こう見ると、 .proto ファイルにスキーマ情報を記述するのと、近い感じを受けませんか?

from typing import Set

from pydantic import (
    BaseModel,
    BaseSettings,
    PyObject,
    RedisDsn,
    PostgresDsn,
    Field,
)


class SubModel(BaseModel):
    foo = 'bar'
    apple = 1


class Settings(BaseSettings):
    auth_key: str
    api_key: str = Field(..., env='my_api_key')

    redis_dsn: RedisDsn = 'redis://user:pass@localhost:6379/1'
    pg_dsn: PostgresDsn = 'postgres://user:pass@localhost:5432/foobar'

    special_function: PyObject = 'math.cos'

    # to override domains:
    # export my_prefix_domains='["foo.com", "bar.com"]'
    domains: Set[str] = set()

    # to override more_settings:
    # export my_prefix_more_settings='{"foo": "x", "apple": 1}'
    more_settings: SubModel = SubModel()

    class Config:
        env_prefix = 'my_prefix_'  # defaults to no prefix, i.e. ""
        fields = {
            'auth_key': {
                'env': 'my_auth_key',
            },
            'redis_dsn': {
                'env': ['service_redis_dsn', 'redis_url']
            }
        }

実行時は、

>>> print(Settings().dict())
{
    'auth_key': 'xxx',
    'api_key': 'xxx',
    'redis_dsn': RedisDsn('redis://user:pass@localhost:6379/1',
scheme='redis', user='user', password='pass', host='localhost',
host_type='int_domain', port='6379', path='/1'),
    'pg_dsn': PostgresDsn('postgres://user:pass@localhost:5432/foobar',
scheme='postgres', user='user', password='pass', host='localhost',
host_type='int_domain', port='5432', path='/foobar'),
    'special_function': <built-in function cos>,
    'domains': set(),
    'more_settings': {'foo': 'bar', 'apple': 1},
}

参考