2023年9月29日金曜日

pydanticV2 で自作クラス側の field_validator に info.data が渡らない問題をどう解決するか

pydanticV2 で自作クラス側の field_validator に info.data が渡らない問題をどう解決するか

概要

以下にサンプルコードを掲載しますが pydanticV2 を使っていて自作クラスを型に指定しその自作クラス側で FieldValidationInfo を使ってバリデーションした場合に info.data が空になるという問題があります

解決する方法としてクラスをまとめるという方法がありますがそれだとクラスが膨大になってしまいます
今回はその場合の解決策を紹介します

環境

  • Python 3.11.3
  • fastapi 0.100.1
  • pydantic 2.1.1

サンプルコード (info.data が空になる)

以下の場合 name -> age という順番でバリデーションされます
pydantic V2 の場合、バリデーションを通過した値はすべて FieldValidationInfo の info.data というオブジェクトに格納されます

しかし以下のように独自クラス側に age を分けている場合に info.data の値が空になるためバリデーションに成功した name を参照することができません

これを解決する方法として Age クラスを使わずに User 側にバリデーションを寄せる方法がありますがそれだとクラスが大きくなり微妙です

from pydantic import BaseModel, Field, FieldValidationInfo, field_validator


class Age(BaseModel):
    age: int = Field()

    @field_validator("age")
    @classmethod
    def validate_age(cls, v: int, info: FieldValidationInfo):
        # print(info.data["name"]) ## KeyError になる
        if info.context is None:
            return v
        if "age_limit" not in info.context or not isinstance(
            info.context["age_limit"], int
        ):
            raise ValueError("info.context.age_limit が正しく設定されていません")
        print("validate_age_with_context")
        limit = int(info.context["age_limit"])
        if v > limit:
            raise ValueError(f"age が limit={limit} を超えています")
        return v


class User(BaseModel):
    name: str = Field()
    age: Age = Field()

    @field_validator("name")
    @classmethod
    def validate_name(cls, v: str, info: FieldValidationInfo):
        if info.context is None:
            return v
        if "allowed_name" not in info.context or not isinstance(
            info.context["allowed_name"], str
        ):
            raise ValueError("info.context.allowed_name が正しく設定されていません")
        print("validate_name")
        if v not in [info.context["allowed_name"]]:
            raise ValueError()
        return v


if __name__ == "__main__":
    user = User.model_validate(
        {"name": "hawksnowlog", "age": {"age": 0}}, context={"allowed_name": "hawksnowlog", "age_limit": 9}
    )

解決方法 (context を無理やり使う

自分が発見した方法は info.context を使う方法です
これをすることで info.data ではなく info.context を Age 側で参照することで検証済みの name を使うことができます

from pydantic import BaseModel, Field, FieldValidationInfo, field_validator


class Age(BaseModel):
    age: int = Field()

    @field_validator("age")
    @classmethod
    def validate_age(cls, v: int, info: FieldValidationInfo):
        # info.context を使うように変更
        if info.context is None:
            raise ValueError()
        print(info.context["validated_name"])
        if info.context is None:
            return v
        if "age_limit" not in info.context or not isinstance(
            info.context["age_limit"], int
        ):
            raise ValueError("info.context.age_limit が正しく設定されていません")
        print("validate_age_with_context")
        limit = int(info.context["age_limit"])
        if v > limit:
            raise ValueError(f"age が limit={limit} を超えています")
        return v


class User(BaseModel):
    name: str = Field()
    age: Age = Field()

    @field_validator("name")
    @classmethod
    def validate_name(cls, v: str, info: FieldValidationInfo):
        if info.context is None:
            return v
        if "allowed_name" not in info.context or not isinstance(
            info.context["allowed_name"], str
        ):
            raise ValueError("info.context.allowed_name が正しく設定されていません")
        print("validate_name")
        if v not in [info.context["allowed_name"]]:
            raise ValueError()
        return v

    # mode=before にすることで Field() に記載されている評価よりも前にこっちを実行させる
    @field_validator("age", mode="before")
    @classmethod
    def validate_age(cls, v: Age, info: FieldValidationInfo):
        if info.context is None:
            raise ValueError()
        # 同じクラス内では info.data が引き継がれるので検証済みの name が参照できる
        info.context["validated_name"] = info.data["name"]
        Age.model_validate(v, context=info.context)
        return v


if __name__ == "__main__":
    user = User.model_validate(
        {"name": "hawksnowlog", "age": {"age": 0}},
        context={"allowed_name": "hawksnowlog", "age_limit": 9},
    )

最後に

他に自作クラス側に info.data を渡す方法はあるのだろうか

info.data の説明を見ると同一クラス内で参照可能とあるので Age クラスと User が継承するようにすれば参照できるようになるはず

なので継承しない独自クラスを作る場合には別のモデルクラスとフィールドが相互参照するようなモデルの設計をしないほうが無難な気がする

0 件のコメント:

コメントを投稿