見出し画像

Pydantic V2: Essentials: プロパティと算出フィールド (セクション9/13)

  • Pydanticモデルは通常のPythonクラスとして振る舞い、@propertyや@cached_propertyを使って計算処理を実装できるが、デフォルトではシリアライズや表示に含まれない。

  • @computed_fieldデコレータを用いることで、プロパティを算出フィールドに変換し、型ヒントの指定やエイリアス、repr設定などで出力を制御できる。

  • 実例として、自動車モデルに登録国の国コードを算出フィールドとして組み込み、キャッシュ機能やfrozen_fieldエラーなどのエラーハンドリングも活用している。

Pydantic のモデルは `BaseModel` クラスから継承されますが、その本質は通常の Python クラスです。つまり、メソッドやプロパティ、さらには特定のプロトコルを実装するための特殊メソッドまで自由に追加できます。場合によっては、シリアライズ時に含めたいプロパティが必要なこともあれば、単に内部的な補助機能として利用するだけのプロパティが必要なこともあります。本稿では、Pydantic V2 におけるこれらの機能について、全ての重要な点を抜かりなく解説していきます。


1. 通常のプロパティの追加

Pydantic のクラスは通常の Python クラスと同様に振る舞うため、クラスにメソッドを定義したり、Python の `@property` デコレータを使ってプロパティを追加することができます。たとえば、以下のコードは円を表すモデルを定義しています。

from math import pi
from pydantic import BaseModel, Field

class Circle(BaseModel):
    center: tuple[int, int] = (0, 0)
    radius: int = Field(default=1, gt=0)

    @property
    def area(self):
        return pi * self.radius ** 2

上記の例では、`Circle` インスタンスを生成し、プロパティとして `area` にアクセスすることができます。

c = Circle(center=(1, 1), radius=2)
print(c.area)  # 12.566370614359172

しかし、このプロパティは以下の点で注意が必要です:

  • モデルの文字列表現(`repr(c)`)には表示されません。

  • シリアライズ時(`c.model_dump()` や `c.model_dump_json()`)にも含まれません。

  • 実際のフィールドの辞書(`c.model_fields`)にも現れません。

つまり、単なるプロパティは Pydantic における「フィールド」としては扱われず、あくまで通常の Python の属性として動作します。


2. `@cached_property` の使用(および Frozen フィールド)

計算が高価なプロパティ(例えば、データベースへの問い合わせや複雑な計算)を実装する場合、Python 標準ライブラリの `functools` モジュールにある `@cached_property` を使用して結果をキャッシュすることができます。キャッシュされたプロパティは、一度計算された後は再計算されず、その結果が再利用されます。しかし、キャッシュされたプロパティを正しく機能させるためには、その元となるフィールド(この例では `radius`)が変更されないように、immutable(不変)にする必要があります。これを実現するために、フィールドに `frozen=True` を指定します。

from functools import cached_property

class Circle(BaseModel):
    center: tuple[int, int] = (0, 0)
    radius: int = Field(default=1, gt=0, frozen=True)  # radius は変更不可

    @cached_property
    def area(self):
        print("calculating area...")
        return pi * self.radius ** 2

上記のコードでは、最初に `area` プロパティへアクセスした際に計算が行われ、その際に `"calculating area..."` が出力されます。以降はキャッシュされた値が返されるため、再びアクセスしても再計算は行われません。

c = Circle()
print(c.area)
# 出力:
# calculating area...
# 3.141592653589793

print(c.area)  # キャッシュされた結果が返るので "calculating area..." は表示されない
# 3.141592653589793

try:
    c.radius = 2
except Exception as ex:
    print(ex)
    # frozen=True のため、フィールドの変更はできずバリデーションエラー(例:"Field is frozen")が発生する

Pydantic V2 では、`frozen=True` が指定されたフィールドを変更しようとすると `frozen_field` というバリデーションエラーが発生します。このエラーは、キャッシュされたプロパティが不変の値に依存しているため、値の変更による不整合を防止するためのものです。


3. プロパティを「算出フィールド」に変換する

場合によっては、プロパティを単なる内部処理のための補助機能としてだけではなく、モデルのシリアライズや文字列表現に含めたいことがあります。そこで登場するのが Pydantic V2 の `@computed_field` デコレータです。

3.1 基本的な使い方

算出フィールドにするためには、まず通常のプロパティ(または `@cached_property` )として実装し、その後に `@computed_field` で装飾します。そして、Pydantic がそのフィールドの型を認識できるように、戻り値の型ヒントを必ず指定します。

from pydantic import BaseModel, Field, computed_field

class Circle(BaseModel):
    center: tuple[int, int] = (0, 0)
    radius: int = Field(default=1, gt=0, frozen=True)

    @computed_field
    @property
    def area(self) -> float:
        return pi * self.radius ** 2

このようにすることで、`area` は自動的に以下のように扱われます:

  • インスタンスの文字列表現(`repr(circle_instance)`)に含まれる

  • 辞書形式の出力(`circle_instance.model_dump()`)に含まれる

  • JSON 形式の出力(`circle_instance.model_dump_json()`)にも含まれる

もし戻り値の型ヒント(例:`-> float`)を省略すると、Pydantic は算出フィールドの型が不明であるとしてユーザーエラーを発生させます。これは、Pydantic が各フィールドの型を明示的に必要とするルールに沿った動作です。

3.2 エイリアス、読み取り専用、及び `repr=False` の指定

  • 読み取り専用
    算出フィールドは実質的に読み取り専用です。もし `c.area = 10` のように値を設定しようとすると、セッターが定義されていないため `AttributeError` が発生します。ほとんどの用途では、算出フィールドは読み取り専用で十分です。

3.3 `@cached_property` と算出フィールドの併用

計算コストの高い算出フィールドの場合、`@cached_property` と `@computed_field` をスタックして併用することも可能です。

from functools import cached_property

class Circle(BaseModel):
    center: tuple[int, int] = (0, 0)
    radius: int = Field(default=1, gt=0, frozen=True)

    @computed_field(alias="AREA")
    @cached_property
    def area(self) -> float:
        print("calculating area...")
        return pi * self.radius ** 2

この例では、初回アクセス時に「calculating area...」と表示され、その後はキャッシュされた結果が返されます。また、シリアライズ時にはエイリアス `"AREA"` で出力されます。


4. 実例:自動車の登録に関するモデル

より実践的な例として、複数のフィールドを持つ自動車モデル(`Automobile`)を考えてみます。ここでは以下のようなフィールドを扱います:

  • manufacturer(製造業者)series_name(シリーズ名)type_(車種)is_electric(電気自動車かどうか) など

  • registration_country(登録国):特定の国のみ有効な値かどうかを検証するため、辞書によるチェックを行います

  • registration_date(登録日):製造日より前の日付が指定されないようにするバリデーションロジックを持ちます

以下は、その一例です(コード内のコメントは説明のために記述されています)。

from datetime import date
from enum import Enum
from typing import Annotated, TypeVar
from uuid import uuid4, UUID
from functools import cached_property
from pydantic import (
    BaseModel, Field, ConfigDict, 
    field_serializer, field_validator, computed_field
)
from pydantic.alias_generators import to_camel

# 例としての国の辞書
countries = {
    "us": ("United States of America", "USA"),
    "uk": ("United Kingdom", "GBR"),
    # 他の国も追加可能
}

def lookup_country(name: str) -> tuple[str, str]:
    """
    国名が有効な場合に (国の正式名称, 国コード) を返し、無効な場合は ValueError を発生させる関数。
    実装は省略。
    """
    # 実際の実装では、入力の正規化やエラーチェックを行う
    ...

# 国コードを得るためのルックアップテーブル
country_code_lookup = {
    # 例: "United States of America": "USA"
    name: code for (name, code) in countries.values()
}

# 制約付きの文字列やリストの型ヒントの定義
T = TypeVar("T")
BoundedString = Annotated[str, Field(min_length=2, max_length=50)]
BoundedList = Annotated[list[T], Field(min_length=1, max_length=5)]

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

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

    id_: UUID = Field(alias="id", default_factory=uuid4)
    manufacturer: BoundedString
    series_name: BoundedString
    type_: AutomobileType = Field(alias="type")
    is_electric: bool = Field(default=False, repr=False)
    manufactured_date: date = Field(
        alias="manufacturedDate", ge=date(1980, 1, 1), repr=False
    )
    base_msrp_usd: float = Field(
        alias="baseMSRPUSD", repr=False
    )
    top_features: BoundedList[BoundedString] | None = Field(default=None, repr=False)
    vin: BoundedString = Field(repr=False)
    number_of_doors: int = Field(default=4, repr=False)
    registration_country: str | None = Field(default=None, repr=False)
    registration_date: date | None = Field(default=None, repr=False)
    license_plate: BoundedString | None = Field(default=None, repr=False)

    @field_serializer("manufactured_date", "registration_date", when_used="json-unless-none")
    def serialize_date(self, value: date) -> str:
        return value.strftime("%Y/%m/%d")

    @field_validator("registration_date")
    @classmethod
    def validate_registration_date(cls, value, values):
        # manufactured_date より前の登録日が指定されていないかを検証するロジック
        return value

    # ここで算出フィールドとして、登録国の 3 文字の国コードを返すプロパティを定義する
    @computed_field(alias="registrationCountryCode", repr=False)
    @cached_property
    def registration_country_code(self) -> str | None:
        """
        登録国に基づいて 3 文字の国コードを返す。登録国が指定されていない場合は None を返す。
        """
        if self.registration_country:
            return country_code_lookup.get(self.registration_country)
        return None

    def __repr__(self):
        # 表示の際に、id_、manufacturer、series_name、type_ のみを含むように文字列表現を整える
        return (
            f"{self.__class__.__name__}("
            f"id_={self.id_!r}, "
            f"manufacturer={self.manufacturer!r}, "
            f"series_name={self.series_name!r}, "
            f"type_={self.type_!r})"
        )

この例では:

  • 多くのフィールドについて、`Field(..., repr=False)` を用いて文字列表現から除外しています。

  • `@computed_field` と `@cached_property` を組み合わせることで、計算が高価な場合でも一度のみ算出し、その結果をキャッシュします。これにより、シリアライズ出力(エイリアス `"registrationCountryCode"` を使用)には必ず結果が含まれるようになります。

  • もし `auto.registration_country_code = "ABC"` のように値を設定しようとすると、セッターが定義されていないためエラーとなります。


5. エラーハンドリングの考察

プロパティや算出フィールドを扱っていると、以下のようなエラーに遭遇することがあります:

  • `frozen_field` エラー
    `frozen=True` と指定されたフィールドを変更しようとすると発生します。これは、キャッシュされたプロパティが不変の値に依存しているため、データの不整合を防ぐためのものです。

  • `model-field-missing-annotation` エラー
    フィールドや算出フィールドに型ヒントを指定せずに定義すると発生します。Pydantic は各フィールドの型情報を必要とするため、このエラーは明示的な型指定の重要性を示しています。

これらのエラーは、データモデルの整合性を保つための Pydantic の仕組みであり、開発者が問題の原因を迅速に特定できるように設計されています。


まとめ

  • 通常の `@property`
    内部ロジックや補助的な処理には有用ですが、シリアライズ結果や文字列表現には含まれません。

  • `@computed_field`
    読み取り専用のプロパティをモデルのフィールドとして扱い、シリアライズや文字列表現に含めるためのデコレータです。戻り値の型ヒントが必須です。

  • `@cached_property`
    高価な計算結果をキャッシュするために使用し、`frozen=True` を併用することで、キャッシュとモデルデータの整合性を保ちます。

これらの技法を用いることで、Pydantic のモデルは洗練されたデータ検証とシリアライズを実現しながら、Python の柔軟なクラス設計の恩恵も受けることができます。API のデータ整合性を維持しつつ、パフォーマンスを最適化するために、適切なプロパティと算出フィールドの設計が非常に重要となります。


「超温和なパイソン」へ

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