見出し画像

Pydantic V2: Essentials: カスタムシリアライザ (セクション4-2/13)

  • バリデーション専用のエイリアス(`validation_alias`)や複数候補を許容する`AliasChoices`など、エイリアスを細かく制御する方法を解説。

  • `@field_serializer`を使って日時などを独自フォーマットへ変換するカスタムシリアライザの実装例を紹介。

  • 自動生成エイリアスやカスタムシリアライズを組み合わせた最終プロジェクトを提示し、高度な用途への応用を示した。

こんにちは! ここでは Pydantic V2: Essentials の第4章後半(レッスン32~35)の内容を続けて解説し、シリアライズやデシリアライズ、フィールド・エイリアスの高度な設定方法を取り上げます。第4章の前半部分では、基本的なエイリアス設定、自動生成エイリアス、およびデシリアライズ時にフィールド名またはエイリアスのどちらも受け付けるようにする手法を学びました。ここではさらに:

  1. バリデーション・エイリアス(通常のエイリアスやシリアライゼーション・エイリアスとは別物)の使い方

  2. 複数のバリデーション・エイリアスを `AliasChoices` で定義する方法

  3. 特定の型、特に `datetime` のようなもののシリアライズを細かく制御するためのカスタム・シリアライザ

  4. 高度なエイリアスとカスタムシリアライザを組み合わせたセクション全体のプロジェクト


1. バリデーション・エイリアス: 単なるエイリアスを越えて

なぜ別のエイリアスが必要なのか?

これまでに2種類のエイリアスを見てきました:

  • 通常のエイリアス (`alias="someAlias"`): デシリアライズと(`by_alias=True` の場合に)シリアライズの両方に使われる。特にシリアライゼーション・エイリアスで上書きされなければ、そのままシリアライズにも使われる。

  • シリアライゼーション・エイリアス (`serialization_alias="someOutputName"`): シリアライズ時、かつ `by_alias=True` のときに特化して用いられる。

バリデーション・エイリアス は3つめの方法で、これはデシリアライズ(バリデーション)時にのみ用いられます。つまりデータ読み込み時に特別な名前を探させる一方で、通常のエイリアスやシリアライゼーション・エイリアスとは独立して運用できるのです。バリデーション・エイリアスと通常エイリアスの両方を定義した場合、デシリアライズではバリデーション・エイリアスの方が優先されます。

from pydantic import BaseModel, Field, ConfigDict

class Model(BaseModel):
    model_config = ConfigDict(populate_by_name=True)

    # デシリアライズ時に "FirstName" だけが認識される
    first_name: str = Field(validation_alias="FirstName")
  • デシリアライズ: 入力データで `"FirstName"` を探す。

  • シリアライズ: デフォルトでは `{'first_name': ...}` を出力(`by_alias=True` を使わなければ)。

バリデーション・エイリアスと他のエイリアスの組み合わせ

単一フィールドに3種類すべてを設定することも可能です:

class Model(BaseModel):
    model_config = ConfigDict(populate_by_name=True)

    first_name: str = Field(
        validation_alias="FirstName",
        alias="firstName",  # 「通常」エイリアス
        serialization_alias="givenName"
    )
  1. デシリアライズでは `"FirstName"` が使用される(`validation_alias` が通常のエイリアスを上書き)。

  2. シリアライズ(`by_alias=True`)では `"givenName"` を使う(`serialization_alias` が通常のエイリアスを上書き)。

注意: 3つすべて定義していると、通常の `alias="..."` は実質的にデシリアライズでもシリアライズでも使われなくなります。バリデーションとシリアライゼーションで上書きされるからです。


2. `AliasChoices` を使った複数バリデーション・エイリアス

場合によっては、同一フィールドに対して複数の候補名を受け付けたいことがあります。例えば、異なるデータソースが同じ概念に対して少し違うフィールド名を使っている場合:

from pydantic import BaseModel, AliasChoices, Field, ConfigDict

class Model(BaseModel):
    model_config = ConfigDict(populate_by_name=True)
    first_name: str = Field(
        validation_alias=AliasChoices("FirstName", "GivenName")
    )

これで Pydantic はデシリアライズ時に `"FirstName"` または `"GivenName"` のどちらを与えられても `first_name` を埋めます。

  • `{"FirstName": "Alice", "GivenName": "Alicia"}` のように両方含まれている場合でも、後に出てきた値が最終的に採用され、エラーにはなりません。

実際のユースケース

設定ファイルなどで「接続文字列」のキーがシステムによって少し異なるケースが典型例です:

from pydantic import BaseModel, AliasChoices

class Database(BaseModel):
    name: str
    connection: str = Field(validation_alias=AliasChoices("redis_conn", "pgsql_conn", "mongo_conn"))

Redis用なら `{"redis_conn": "...", "name": "Local Redis"}`、Postgresなら `{"pgsql_conn": "...", "name": "Local Postgres"}` のように、異なるキーが1つの `connection` フィールドにマッピングされるわけです。


3. カスタム・シリアライザ

なぜカスタム・シリアライズ?

Pydantic のデフォルトは多くの Python 型を適切に扱います(例: `datetime -> ISO8601`, `Enum -> str`)。しかし実際には以下のようにもっと細かく制御したい場合もあります:

  • `datetime` のフォーマットを `"YYYY/MM/DD"` や末尾に `"Z"` を付けたい

  • float を小数第2位に丸めたい

  • シリアライズ時だけに適用したいデータ変換がある

こういったニーズには `@field_serializer` デコレータが使えます。これを使うと、あるフィールドのシリアライゼーションを「横取り」して任意の処理ができます。

from pydantic import BaseModel, field_serializer

class Example(BaseModel):
    value: float

    @field_serializer("value")
    def round_to_two(self, v):
        return round(v, 2)

これで `Example(value=3.14159).model_dump_json()` は `{"value":3.14}` を出力します。

シリアライザをいつ使うかをコントロールする: `when_used`

`when_used` パラメータでは、カスタム・シリアライザが適用される場面を制限できます:

  • `always`: デフォルト。dict・JSON の両方に対して常に実行

  • `unless-none`: 値が `None` の場合にシリアライザをスキップ

  • `json`: JSON シリアライズ時のみ実行

  • `json-unless-none`: JSON シリアライズ時のみ実行、かつ `None` のときはスキップ

例: dict と JSON の振る舞いを分けたい

from datetime import datetime
from pydantic import BaseModel, field_serializer

class Event(BaseModel):
    dt: datetime | None

    @field_serializer("dt", when_used="json-unless-none")
    def custom_dt(self, value):
        # JSON 出力のときだけ処理。None のときはスキップ
        return value.strftime("%Y/%m/%d %H:%M:%S")
  • dict (`.model_dump()`) → `'dt': datetime.datetime(...)`

  • JSON (`.model_dump_json()`) → `'{"dt":"2023/08/17 14:05:00"}'`

dict と JSON を区別したい場合: `FieldSerializationInfo`

dict・JSON両方に同じシリアライザを使いたいが、処理を少し変えたい場合、シリアライザの第2引数に `info: FieldSerializationInfo` を受け取れます:

from pydantic import FieldSerializationInfo

@field_serializer("dt")
def dt_serializer(self, dt, info: FieldSerializationInfo):
    if info.mode_is_json():
        # JSON の場合
        return dt.strftime("...")
    else:
        # dict の場合
        return dt

こうすると、シリアライズ処理の途中で「これは JSON 向け? dict 向け?」を判別し、それぞれに応じた処理を分岐できます。


4. セクション・プロジェクト: すべてを組み合わせる

以下の最終プロジェクトでは、エイリアスの高度な設定、自動生成、カスタムシリアライザなどを合わせて利用します。例として、`Automobile` モデル:

  • 自動 CamelCase エイリアス(`alias_generator`)

  • `type_` フィールドを入力も出力も `"type"` にする

  • `manufactured_date` の JSON だけ特別なフォーマット(`"YYYY/MM/DD"`)

プロジェクト要件

  1. すべてのフィールドで キャメルケース エイリアスを自動生成

  2. `type_` フィールドは:

    • 読み込み・書き込み両方で `"type"` を使う

  3. フィールド名変換:

    • `number_of_doors` → 入力データは `"doors"`, シリアライズ時はキャメルケースの `"numberOfDoors"`

    • `manufactured_date` → 入力は `"completionDate"`, シリアライズは `"manufacturedDate"`

    • `base_msrp_usd` → 入力は `"msrpUSD"`, シリアライズは `"baseMSRPUSD"`

  4. 日付の JSON シリアライズ:

    • `"YYYY/MM/DD"` 形式

    • Python dict の場合は `date` オブジェクトのまま

  5. Enum (`type_` が `AutomobileType`) 正しく扱う

完成コード

from datetime import date
from enum import Enum
from pydantic import BaseModel, ConfigDict, Field, field_serializer
from pydantic.alias_generators import to_camel

class AutomobileType(Enum):
    sedan = "Sedan"
    coupe = "Coupe"
    convertible = "Convertible"
    suv = "SUV"
    truck = "Truck"

def to_camel(s: str) -> str:
    # 'series_name' -> 'seriesName' 等
    parts = s.split('_')
    return parts[0] + ''.join(word.capitalize() for word in parts[1:])

class Automobile(BaseModel):
    model_config = ConfigDict(
        extra="forbid",
        str_strip_whitespace=True,
        validate_default=True,
        validate_assignment=True,
        alias_generator=to_camel,
        populate_by_name=True
    )

    manufacturer: str
    series_name: str
    type_: AutomobileType = Field(alias="type")
    is_electric: bool = False
    manufactured_date: date = Field(alias="completionDate")
    base_msrp_usd: float = Field(alias="msrpUSD", serialization_alias="baseMSRPUSD")
    vin: str
    number_of_doors: int = Field(alias="doors", default=4)
    registration_country: str | None = None
    license_plate: str | None = None

    @field_serializer("manufactured_date")
    def serialize_manufactured_date(self, value: date, info):
        # dict と JSON を区別したい場合は info.mode_is_json() などをチェック可能
        if info.mode == "json":
            return value.strftime("%Y/%m/%d")
        return value

テスト例

import json

data_json = '''
{
  "manufacturer": "BMW",
  "seriesName": "M4",
  "type": "Convertible",
  "isElectric": false,
  "completionDate": "2023-01-01",
  "msrpUSD": 93300,
  "vin": "1234567890",
  "doors": 2,
  "registrationCountry": "France",
  "licensePlate": "AAA-BBB"
}
'''

data_dict = json.loads(data_json)
auto = Automobile(**data_dict)

print("Serialized to Python dict:")
print(auto.model_dump())

print("\nSerialized to dict by alias:")
print(auto.model_dump(by_alias=True))

print("\nSerialized to JSON by alias:")
print(auto.model_dump_json(by_alias=True))

出力:

Serialized to Python dict:
{'manufacturer': 'BMW', 'series_name': 'M4', 'type_': <AutomobileType.convertible: 'Convertible'>,
 'is_electric': False, 'manufactured_date': datetime.date(2023, 1, 1),
 'base_msrp_usd': 93300.0, 'vin': '1234567890', 'number_of_doors': 2,
 'registration_country': 'France', 'license_plate': 'AAA-BBB'}

Serialized to dict by alias:
{'manufacturer': 'BMW', 'seriesName': 'M4', 'type': <AutomobileType.convertible: 'Convertible'>,
 'isElectric': False, 'manufacturedDate': datetime.date(2023, 1, 1),
 'baseMSRPUSD': 93300.0, 'vin': '1234567890', 'numberOfDoors': 2,
 'registrationCountry': 'France', 'licensePlate': 'AAA-BBB'}

Serialized to JSON by alias:
{"manufacturer":"BMW","seriesName":"M4","type":"Convertible","isElectric":false,
 "manufacturedDate":"2023/01/01","baseMSRPUSD":93300.0,
 "vin":"1234567890","numberOfDoors":2,"registrationCountry":"France",
 "licensePlate":"AAA-BBB"}
  • dict シリアライズでは `date` オブジェクトが保持され、 `type_` は `AutomobileType` の値が保持される

  • alias シリアライズではキーがキャメルケースや個別設定に変わる

  • JSON 出力では `manufactured_date` が `"YYYY/MM/DD"` に変更される


まとめ

以上でレッスン32~35、つまり第4章の後半でカバーした内容は下記のとおりです:

  • バリデーション・エイリアス: 入力データで特別な別名を受け付けたいときに使う。通常のエイリアスではなく、バリデーション専用の名称を上書き。

  • `AliasChoices`: 同一フィールドに複数の入力候補を与えたい場合に有効。重複キーがあっても最後の値を採用しエラーにしない。

  • カスタム・シリアライザ (`@field_serializer`): データを思い通りに整形する。dict 用と JSON 用を分ける、値が `None` のときはスキップ、など自由に定義できる。

これらを組み合わせれば、外部の不揃いなデータソースを扱って内部的には整合性のあるモデルを維持しつつ、出力時もフォーマットを厳密に制御することが可能です。これで第4章後半、フィールド・エイリアスやシリアライズ/デシリアライズの高度なトピックを網羅しました。

「超温和なパイソン」へ

いいなと思ったら応援しよう!