見出し画像

Pydantic V2: Essentials: コンポジションと継承 (セクション11/13)

  • モデルコンポジション:モデルのフィールドとして別の Pydantic モデルを利用することで、ネストされたデータのシリアライズや逆シリアライズを自動化できる。

  • モデル継承:共通の設定(例:alias_generator や extra の設定)や共通フィールドをカスタムベースモデルに定義し、各モデルに再利用することでコードの重複を避ける。

  • これらを使い分けることで、複雑なデータ構造を整理し、保守性と再利用性の高い効率的なデータモデリングが実現できる。

Pydantic では、通常、`BaseModel` を継承するクラスとしてモデルを定義します。これらのモデルは多くのデータ構造に対してうまく機能しますが、スキーマがより複雑になるにつれて、コードの整理のためにモデルを コンポジション(構成)または 継承 を用いて組み合わせる必要が出てきます。本記事では、Pydantic V2 における2つの基本概念について検討します。

  1. モデルコンポジション:モデルのフィールド自体が Pydantic モデルである場合のこと。

  2. モデル継承:カスタムのベースクラス(または共通の親クラス)を作成し、他のモデルが設定やフィールドを拡張または再利用できるようにする方法。


1. コンポジション:モデルのネスト

コンポジションとは、モデルのフィールドとして、単なる Python の基本型や注釈付き型ではなく、別の Pydantic モデルを用いることを指します。これは、たとえば「円」モデルが「点」モデルを中心として持つ場合や、ある「連絡先」モデルが「電話番号」サブモデルを持つ場合に非常に有用です。

1.1 基本的な例

たとえば、まず `Point2D` モデルを定義します:

from pydantic import BaseModel, Field

class Point2D(BaseModel):
    x: float = 0
    y: float = 0

次に、`Circle2D` モデルを作成し、その `center` フィールドの型として `Point2D` を指定し、半径 `radius` を定義します:

class Circle2D(BaseModel):
    center: Point2D
    radius: float = Field(default=1, gt=0)

インスタンス生成の例:

circle = Circle2D(center=Point2D(x=1, y=1), radius=2)
print(circle)
# 出力例: Circle2D(center=Point2D(x=1.0, y=1.0), radius=2.0)

シリアライズの場合:

  • `circle.model_dump()` は以下のような辞書を生成します:

  {
    "center": {"x": 1.0, "y": 1.0},
    "radius": 2.0
  }
  • `circle.model_dump_json()` は次のような JSON 文字列となります:

  {"center":{"x":1.0,"y":1.0},"radius":2.0}

辞書や JSON からの 逆シリアライズ も同様に動作します。ネストされた辞書(または JSON オブジェクト)に `"center"` キーが存在すれば、それが `Point2D` として検証され、`Circle2D` インスタンスの `center` フィールドとなります。

1.2 実際のユースケース:必要なフィールドのみの選択

コンポジションは、入力データに大量の余分な情報が含まれている場合にも有効です。Pydantic では `extra="ignore"`(または、特定の設定によって未定義フィールドの入力を禁止する)を利用することで、必要なフィールドのみを抽出することができます。たとえば、大きな JSON ペイロードが次のように与えられたとします:

{
  "firstName": "David",
  "lastName": "Hilbert",
  "contactInfo": {
    "email": "d.hilbert@example.com",
    "homePhone": { /* … 大量のネストしたオブジェクト … */ }
  },
  "personalInfo": {
    "nationality": "German",
    "born": {
      "date": "1862-01-23",
      "place": {
        "city": "Konigsberg",
        "country": "Prussia"
      }
    },
    "died": { /* … この部分は無視 */ }
  },
  "awards": ["Lobachevsky Prize", "ForMemRS"],
  "notableStudents": ["von Neumann", "Weyl", "Courant", "Zermelo"]
}

ここで、たとえば `firstName`、`lastName`、`email`、`born.date` と `born.place`、および `notableStudents` のみが必要であるとします。複数のサブモデルを定義し、それぞれが不要なフィールドを無視するように設定することで、最終的な `Person` モデルで必要なフィールドのみを抽出できます。各 Pydantic モデルは独自の `model_config` を持ち、未定義フィールドを無視することにより、必要なフィールドだけを確実に取得できます。


2. 継承:共通設定および共有フィールド

Pydantic における 継承 は、通常の Python の継承と同様です。親クラスも `BaseModel` から派生しているため、親クラスで定義した設定やフィールドは子クラスに引き継がれます。これにより、以下のようなメリットがあります。

  • 共通設定の共有
    例えば、`extra` の設定や `alias_generator`(エイリアス生成関数)の設定をすべてのモデルに対して一括で行えます。これにより、同じ設定を各モデルで繰り返し記述する必要がなくなります。

  • 共通フィールドの提供
    すべてのモデルに共通のフィールド(例えば、リクエストごとの一意な ID やタイムスタンプなど)を親クラスで定義し、各モデルがそれを引き継ぐようにできます。

2.1 カスタムベースモデルの作成

一般的なパターンとして、まず「カスタムベースモデル」を定義します。これはフィールドは持たず、`model_config` のみを設定するものです。たとえば:

from pydantic import BaseModel, ConfigDict
from pydantic.alias_generators import to_camel

class CamelBaseModel(BaseModel):
    model_config = ConfigDict(
        extra="ignore",
        alias_generator=to_camel,
        populate_by_name=True
    )

これにより、`CamelBaseModel` を継承するすべてのモデルは以下の設定を自動的に引き継ぎます。

  • 未定義フィールドを無視する。

  • 出力時にフィールド名が camelCase に変換される。

  • エイリアスまたは名前のどちらからもフィールドに値を設定できる。

これは、各モデルごとに同じ `model_config` を記述するよりも遥かに効率的です。

2.2 フィールドの共有による継承

すべてのモデルに共通のフィールドを持たせたい場合も、継承が有効です。たとえば、すべての API レスポンスに対して、リクエスト ID や実行時間などの共通フィールドが必要な場合、以下のような親クラスを定義します。

from uuid import uuid4
from pydantic import Field

class ResponseBaseModel(CamelBaseModel):
    request_id: UUID = Field(default_factory=uuid4)
    elapsed_time: float = 0.0

その後、API の各レスポンスモデルは `ResponseBaseModel` を継承するだけで、これらの共通フィールドを自動的に持つことができます。

注意点:Pydantic では複数継承は基本的に避けるべきです。単一継承で共通の設定やフィールドを共有するのが一般的であり、複数継承は Python の MRO(メソッド解決順序)との相互作用で予期せぬ挙動を引き起こす可能性があるため、慎重に扱う必要があります。


3. コンポジションと継承の使い分け

コンポジションと継承はどちらもコードの再利用の手法ですが、目的が異なります。

  • コンポジション
    → 階層構造のデータ、つまりあるモデルがサブモデル(他の Pydantic モデル)をフィールドとして持つ場合に使用します。たとえば、ユーザーの住所や連絡先情報など、ネストされた構造を表現する場合に適しています。

  • 継承
    → 全体に共通する設定(例:`extra="ignore"` や `alias_generator`)や、すべてのモデルに含めたい共通フィールド(例:ID やタイムスタンプ)を定義するために使用します。

要点

  • 階層的なデータ構造には コンポジション を使い、

  • 全体に共通する設定やフィールドの共有には 継承 を使います。


4. 具体例:Automobile モデルのリファクタリング

たとえば、`Automobile` モデルが日付、複数の文字列フィールド、そして検証済みの「国名」フィールドを含んでいるとします。ここで以下の要件があると仮定します。

  1. すべてのモデルに対して「camelCase のエイリアス生成」と「未定義フィールドの禁止」の共通設定を適用したい。

  2. 「登録国(registration country)」のロジックは、サブモデルに切り出したほうが良い(重複を避けるため)。

4.1 カスタムベースモデルの定義

まず、共通設定をまとめた `CamelBaseModel` を定義します。

from pydantic import BaseModel, ConfigDict
from pydantic.alias_generators import to_camel

class CamelBaseModel(BaseModel):
    model_config = ConfigDict(
        extra="ignore",
        str_strip_whitespace=True,
        validate_default=True,
        validate_assignment=True,
        alias_generator=to_camel,
    )

4.2 登録国サブモデルの作成

次に、登録国に関するロジック(例えば、標準化された国名と3文字コードの算出)をサブモデルとして切り出します。

from functools import cached_property
from pydantic import computed_field, Field

# 例:country_code_lookup は「United States of America」で "USA" を返す辞書など
class RegistrationCountry(CamelBaseModel):
    name: str | None = Field(default=None)
    
    @computed_field
    @cached_property
    def code3(self) -> str | None:
        if self.name:
            return country_code_lookup[self.name]  # 例:"USA"
        return None

4.3 Automobile モデルのリファクタリング

最後に、`Automobile` モデルは `CamelBaseModel` を継承し、従来の「registration_country」フィールドの代わりに、サブモデル `RegistrationCountry` をフィールドとして持ちます。

class Automobile(CamelBaseModel):
    id_: UUID4 = Field(alias="id", default_factory=uuid4)
    manufacturer: str
    # ... 他のフィールド …
    
    # 登録国フィールドを文字列ではなくサブモデルとして定義
    registration_country: RegistrationCountry | None = Field(default=None)
    # その他のフィールドは従来通り

このように分離することで、共通のロジック(エイリアス生成や検証)は一箇所にまとめられ、各モデルはより整理された状態となります。


5. まとめてみると

以下は、これまでの内容をまとめた実例です。

from uuid import UUID, uuid4
from datetime import date
from pydantic import BaseModel, ConfigDict, Field, computed_field, field_validator, PlainSerializer, UUID4, ValidationInfo
from pydantic.alias_generators import to_camel
from functools import cached_property

# 国名とコードの対応辞書(例)
countries = {
    "australia": ("Australia", "AUS"),
    "canada": ("Canada", "CAN"),
    "us": ("United States of America", "USA"),
    # 他の国も同様…
}
country_code_lookup = {name: code for name, code in countries.values()}

# CamelBaseModel:共通設定を持つカスタムベースモデル
class CamelBaseModel(BaseModel):
    model_config = ConfigDict(
        extra="ignore",
        str_strip_whitespace=True,
        validate_default=True,
        validate_assignment=True,
        alias_generator=to_camel,
    )

# 登録国サブモデル
class RegistrationCountry(CamelBaseModel):
    name: str | None = Field(default=None)
    
    @computed_field
    @cached_property
    def code3(self) -> str | None:
        if self.name:
            return country_code_lookup[self.name]
        return None

# Automobile モデル(共通設定とサブモデルを利用)
class Automobile(CamelBaseModel):
    id_: UUID4 = Field(alias="id", default_factory=uuid4)
    manufacturer: str
    # ... 他のフィールド …
    
    # 登録国フィールドは RegistrationCountry サブモデル
    registration_country: RegistrationCountry | None = Field(default=None)
    # 例として、製造日や登録日などの他のフィールドも含む

実際のデータを用いた逆シリアライズの例:

data = {
    "id": "c4e60f4a-3c7f-4da5-9b3f-07aee50b23e7",
    "manufacturer": "BMW",
    "seriesName": "M4 Competition xDrive",
    "type": "Convertible",
    "isElectric": False,
    "completionDate": "2023-01-01",
    "msrpUSD": 93300,
    "doors": 2,
    "registrationCountry": {"name": "us"},
    "registrationDate": "2023-06-01",
    "licensePlate": "AAA-BBB"
}

auto = Automobile.model_validate(data)
print(auto)
# Automobile(id_=UUID(...), manufacturer='BMW', ...)
print(auto.registration_country)
# RegistrationCountry(name='United States of America', code3='USA')

シリアライズ時は、`registrationCountry` がネストされた辞書として出力され、内部に `"name"` と `"code3"` が含まれます。これがコンポジション(サブモデルによるネスト)と継承(共通設定の共有)の本質です。


結論

Pydantic における複雑なデータ要件では、ネスト構造や共通設定の重複が生じがちです。

  • コンポジション:あるモデルが別の Pydantic モデルをフィールドとして持つことで、ネストされた構造のシリアライズ・逆シリアライズを自動で行います。

  • 継承:共通設定やフィールドを親クラスに定義することで、各モデルで同じロジックを繰り返す必要がなくなります。

つまり、階層的なデータにはコンポジションを、全体に共通する設定やフィールドの共有には継承を使うことで、コードの再利用性、保守性が大幅に向上します。これにより、同じロジックや設定の繰り返しを避け、Python のベストプラクティスに則った整理されたデータモデリングが可能となります。

「超温和なパイソン」へ

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