【Python】Pydanticのvalidatorが非推奨だからfield_validatorを使って2段階バリデーションを実装する

Code

はじまり

リサちゃん
リサちゃん

くそう、こんがらがってきやがった・・・

135ml
135ml

クロージャを使ってバリデーションチェックですね。

PydanticのBaseModelのバリデーションをする

Pythonには、Pydanticという強力な型アノテーションおよび値のバリデーションを実装することが出来るライブラリがあります。Python標準パッケージの中に「dataclasses」という似たような型ヒント用のライブラリもありますが、Pydanticの方がもっと強力に例外を発生することが出来るので、堅牢なコードを書くことが出来ます。僕個人的には、Pydanticを導入したらコーディング中に型の不整合によってハマることは無くなりましたし、テストコードも使う以前よりも書きやすくなりました。

Welcome to Pydantic - Pydantic
Data validation using Python type hints

そして今回は、そのPydanticの機能を利用する上で最も使うであろうクラスの「BaseModel」クラスに対する型アノテーションと値のバリデーションチェックを実装するまでの流れを紹介していきます。

ちなみに今回作ったコードは、以下のNotionのデータベースから取得したデータをAPIを介して渡したりリクエストするために作っていたものです。なので「NotionTasks」とかいうクラス名になっているわけです。

それでは行ってみましょう。

「クラス」メソッドと「インスタンス」メソッドの分別を付ける。

まあ、まずはChatGPTに質問してみるわけです。「Pydanticでバリデーションチェックをするコードを書いて。」と。

from pydantic import BaseModel, validator, ValidationError

class MyModel(BaseModel):
    name: str = "default_name"  # 任意フィールド

    @validator("name", pre=True, always=True)
    def check_not_none(cls, v):
        if v is None:
            raise ValueError("name cannot be None")
        return v

# OK
model = MyModel(name="John")
print(model)

# Error: name cannot be None
try:
    model = MyModel(name=None)
except ValidationError as e:
    print(e)

そして疑問に思うわけです。

このprealwaysって・・・何だ?

そこでChatGPTに聞いてみるわけです。「『@validator(“name”, pre=True, always=True)』の処理の内容を教えて。」と。そして、返ってきた回答を意訳するとこんな感じでした。

pre=Trueは、バリデーションが「前処理の段階」で実行されることを意味します。

always=Trueは、フィールドに値が提供されていなくても、常にバリデーションを実行します。

Pydanticでは、バリデーションには 前処理(pre-processing)後処理(post-processing) があります。pre=True を設定すると、フィールドの型が適用される「前」にバリデーションが実行されます。また、always=Trueを設定すると、「入力がない場合にデフォルト値を使うが、そのデフォルト値もバリデーションを通したい」といったケースをカバーできます。

なるほど、クラスのフィールドに入る前と入る後の2種類のタイミングでバリデーションを評価することが出来るわけだ。すごいなPydantic。

この2つのモードでバリデーションをすれば、BaseModelに代入する前にはデータベース(PostgreSQLなど)の中でnot nullで定義したカラムが何らかの理由でNoneになった場合に例外を出すことが出来ます。(pre=True

そして、BaseModelに代入した後にはデフォルト値を定義しておくことで、必ず代入しなければならないフィールドに代入されていないことを検知できて、値自体の評価をすることも可能です。(always=True

よし、実装してみよう。

そして、渡されたコードを少しいじって動かしてみるわけです。ーーーしかし動かないのです。えっ・・・?

from pydantic import BaseModel, validator, ValidationError

class MyModel(BaseModel):
    name: str = "default_name"  # 任意フィールド

    @validator("name", pre=True, always=True)
    def check_not_none(self, v):
        if v is None:
            raise ValueError("name cannot be None")
        return v

# OK
model = MyModel(name="John")
print(model)

# Error: name cannot be None
try:
    model = MyModel(name=None)
except ValidationError as e:
    print(e)

エラーメッセージの抜粋。

pydantic.errors.PydanticUserError: @field_validator cannot be applied to instance methods

Positive prompt: Anime style, high quality, rough, (from above, upper body), 1 young woman with brown hairr, white eyes, Stinking eyes, wearing sports wear, sadness, under the blue sky

negative prompt: violence, sexual, derogatory, toxic

(generated by Imagen 3)

あれっ・・・、なんかそれっぽいイイ感じの回答が返ってきてたのに・・・。

いいえ、違います。これは僕がいじった内容に問題がありました。

これは、@classmethodを入れて、メソッドの第一引数にclsを指定したら直ります。なぜなら、Pythonで定義するクラスでは、そのクラスのメソッドの第一引数にclsを指定するとクラスメソッド、selfを指定するとインスタンスメソッドになるんですよねそう言えば。

なので、インスタンスメソッドではなくクラスメソッドとしてバリデーション用のメソッドを実行すれば問題なく通るわけです。(この例外はそれなりにPythonを使ってないと気付かなそう。)

    # ↓ class method
    @validator("name", pre=True, always=True)
    def check_not_none(cls, v):

    # ↓ instance method
    @validator("name", pre=True, always=True)
    def check_not_none(self, v):

@validatorデコレータは非推奨。

それじゃあ、clsselfで指定して、そんでもって@validatorを使った実装でハイッ解決。  とはなりませんでした。

色々調べていくと、判明してしまったのです。(いや、して良かった。)

なんと、@validatorデコレータは非推奨、deprecatedだったのです。(@root_validatorも非推奨です。)

Migration Guide - Pydantic
Migrating from Pydantic V1.

その公式ページには、@validatorは非推奨となり、さまざまな新機能と改善を提供する@field_validator に置き換える必要があるとかなんやら記載されています。なので、@field_validator でバリデーションの処理を書き換えました。それから、selfを渡していたメソッドにclsを渡してクラスメソッドにします。

from pydantic import BaseModel, Field, field_validator

class NotionTask(BaseModel):
    number: int = -1
    con_id: str = Field("default_value", example="TK000012345")
    page_id: str = Field("default_value", description="Notion page UUID")
    page_icon: str = Field(..., description="Notion page icon")
    is_page_archived: bool = Field(False, description="if page is archived")

    @field_validator("number")
    def validate_number(cls, v):
        FIELD_NAME = "con_id"
        if v is None:
            raise ValueError(f"'{FIELD_NAME}' cannot be None")
        if v <= -1:
            raise ValueError(f"'{FIELD_NAME}' must be over -1")
        return v

    @field_validator("con_id")
    def validate_con_id(cls, v):
        FIELD_NAME = "con_id"
        LENGTH_OF_CHARACTERS = 11
        if v is None:
            raise ValueError(f"'{FIELD_NAME}' cannot be None")
        if v == "default_value":
            raise ValueError(f"'{FIELD_NAME}' must not be 'default_value'")
        if len(v) != LENGTH_OF_CHARACTERS:
            raise ValueError(f"'{FIELD_NAME}' must have {LENGTH_OF_CHARACTERS} characters")
        return v

    @field_validator("page_id")
    def validate_page_id(cls, v):
        FIELD_NAME = "page_id"
        LENGTH_OF_CHARACTERS = 36
        if v is None:
            raise ValueError(f"'{FIELD_NAME}' cannot be None")
        if v == "default_value":
            raise ValueError(f"'{FIELD_NAME}' must have and not 'default_value'")
        if len(v) != LENGTH_OF_CHARACTERS:
            raise ValueError(f"'{FIELD_NAME}' must have {LENGTH_OF_CHARACTERS} characters")
        return v

そしてさらに、クラスメソッドに@classmethodのデコーレーターも付けて実行します。まあ、このコードは大丈夫だった気がします。

from pydantic import BaseModel, Field, field_validator

class NotionTask(BaseModel):
    number: int = -1
    con_id: str = Field("default_value", example="TK000012345")
    page_id: str = Field("default_value", description="Notion page UUID")
    page_icon: str = Field(..., description="Notion page icon")
    is_page_archived: bool = Field(False, description="if page is archived")

    @field_validator("number")
    @classmethod
    def validate_number(cls, v):
        FIELD_NAME = "con_id"
        if v is None:
            raise ValueError(f"'{FIELD_NAME}' cannot be None")
        if v <= -1:
            raise ValueError(f"'{FIELD_NAME}' must be over -1")
        return v

    @field_validator("con_id")
    @classmethod
    def validate_con_id(cls, v):
        FIELD_NAME = "con_id"
        LENGTH_OF_CHARACTERS = 11
        if v is None:
            raise ValueError(f"'{FIELD_NAME}' cannot be None")
        if v == "default_value":
            raise ValueError(f"'{FIELD_NAME}' must not be 'default_value'")
        if len(v) != LENGTH_OF_CHARACTERS:
            raise ValueError(f"'{FIELD_NAME}' must have {LENGTH_OF_CHARACTERS} characters")
        return v

    @field_validator("page_id")
    @classmethod
    def validate_page_id(cls, v):
        FIELD_NAME = "page_id"
        LENGTH_OF_CHARACTERS = 36
        if v is None:
            raise ValueError(f"'{FIELD_NAME}' cannot be None")
        if v == "default_value":
            raise ValueError(f"'{FIELD_NAME}' must have and not 'default_value'")
        if len(v) != LENGTH_OF_CHARACTERS:
            raise ValueError(f"'{FIELD_NAME}' must have {LENGTH_OF_CHARACTERS} characters")
        return v

しかし・・・、@field_validatorでは、先程の@validatorでは設定できた、pre=True とか、always=Trueを設定することが出来ません。(preとかを設定するとそんな感じのエラーが表示されるんですよね。)なので、フィールドの中に入れる前か入れる後でアノテーションやらバリデーションやらが漏れてしまうんですよね・・・。

他の方法は無いのか。

field_validatorのダブルバリデーションチェック

Pydanticの公式リファレンスのこのページにその方法が掲載されていました。

Validators - Pydantic
Data validation using Python type hints

基本的にはそのページの通りに実装しました。そして実装の方法が2種類あるようです。上記の公式ページでは2種類とも使用して実行順序を示してくれていますが、実際に使う時には片方で事足りると思います。

1つ目は、from pydantic.functional_validators import field_validatorでインポートしたfield_validatorを使用する方法です。まずは、この方法で実装してみます。

元々@field_validatorとして実装していたものを、別のValidatorを使ってクロージャ(Callableを返す関数)として作り直すのが煩雑で大変でした。なので、こういう場合は、ゼロから適当なクラスを作って、それをベースに実装し直すと楽かも。

そして、一旦ここまでコードが出来上がりました。これを実行してみます。

from typing import Any, Callable, ClassVar, List, cast
from pydantic import BaseModel, Field, field_validator, ValidationInfo

# Validator makers definition
def make_validator_it_is_not_none(validation_target: str) -> Callable[[Any, ValidationInfo], Any]:
    def validator(v: Any, info: ValidationInfo) -> Any:
        if v is None:
            raise ValueError(f"'{validation_target}' cannot be None")
        return v
    return validator
def validate_con_id_value(validation_target: str) -> Callable[[Any, ValidationInfo], Any]:
    def validator(v: Any, info: ValidationInfo) -> Any:
        LENGTH_OF_CHARACTERS = 11
        if v == "default_value":
            raise ValueError(f"'{validation_target}' must not be 'default_value'")
        if len(v) != LENGTH_OF_CHARACTERS:
            raise ValueError(f"'{validation_target}' must have {LENGTH_OF_CHARACTERS} characters")
        return v
    return validator
def make_validator_page_id_value_is_valid(validation_target: str) -> Callable[[Any, ValidationInfo], Any]:
    def validator(v: Any, info: ValidationInfo) -> Any:
        LENGTH_OF_CHARACTERS = 36
        if v == "default_value":
            raise ValueError(f"'{validation_target}' must have and not 'default_value'")
        if len(v) != LENGTH_OF_CHARACTERS:
            raise ValueError(f"'{validation_target}' must have {LENGTH_OF_CHARACTERS} characters")
        return v
    return validator

# Schema definition
class NotionTask(BaseModel):
    number: Annotated[int, -1]
    con_id: Annotated[str, Field("default_value", example="TK000012345")]
    page_id: Annotated[str, Field("default_value", description="Notion page UUID")]
    page_icon: Annotated[str, Field(..., description="Notion page icon")]
    is_page_archived: Annotated[bool, Field(False, description="if page is archived")]
    val_target = ""

    val_target = "number"
    val_number_before = field_validator(val_target, mode="before")(
        make_validator_it_is_not_none(val_target)
    )
    val_number_after = field_validator(val_target, mode="after")(
        make_validator_number_is_not_negative(val_target)
    )
    val_target = "con_id"
    val_con_id_before = field_validator(val_target, mode="before")(
        make_validator_it_is_not_none(val_target)
    )
    val_con_id_after = field_validator(val_target, mode="after")(
        validate_con_id_value(val_target)
    )
    val_target = "page_id"
    val_page_id_before = field_validator(val_target, mode="before")(
        make_validator_it_is_not_none(val_target)
    )
    val_page_id_after = field_validator(val_target, mode="after")(
        make_validator_page_id_value_is_valid(val_target)
    )
    val_target = "is_page_archived"
    # Validate nothing

@app.get("/task")
def return_all_tasks():
    result = {"number": 1, "con_id": "TK000012345", "page_id": "TK000012345TK000012345TK000012345789", "page_title": "test test title no test", "page_icon": "🍞", "is_page_archived": False}
    NotionTask.model_validate(result, context=context)
    return [NotionTask(**result)]

エラーメッセージです。

raise PydanticUserError( pydantic.errors.PydanticUserError: A non-annotated attribute was detected: `val_target = ‘con_id’`. All model fields require a type annotation; if `val_target` is not meant to be a field, you may be able to resolve this error by annotating it as a `ClassVar` or updating `model_config[‘ignored_types’]`.

Positive prompt: Anime style, high quality, rough, (from above, upper body), 1 young woman with brown hair, white eyes, Stinking eyes, wearing sports wear, sadness, under the blue sky

negative prompt: violence, sexual, derogatory, toxic

(generated by Imagen 3)

やっぱりシード値が変わると印象変わるなあ。

まあエラーメッセージに色々書いてありますが、結局は、ClassVarというアノテーションを付けるか、model_config['ignored_types']を設定するかの2種類の選択肢があるようですね。今回は、ClassVarを付けてクラス変数として定義するのが良さそうです。(先程も、インスタンスメソッドからクラスメソッドに直しましたね。)

    # ↓ インスタンス変数として宣言してしまっている。
    val_target = ""

    # ↓ クラス変数にする。
    from typing import ClassVar
    val_target: ClassVar[str] = ""

デフォルト値を設定し忘れると、代入不足が検知できない。

さあ、クラス変数にしたから直ったぞ。と思って実行するのですが・・・、

・・・ここで、例外が発生しない不具合が発生してしまいます。なんてこった・・・。

先程のソースでは、わざとフィールドが不足するようにインスタンスを作成しようとしていました。なので、例外が発生しなければおかしいわけです。しかし発生しなかった・・・。

@app.get("/task")
def return_all_tasks():
    result = {"number": 1, "con_id": "TK000012345", "page_id": "TK000012345TK000012345TK000012345789", "page_title": "test test title no test", "page_icon": "🍞", "is_page_archived": False}
    NotionTask.model_validate(result, context=context)
    return [NotionTask(**result)]

この原因としては2つありました。1つ目は、クラス変数の宣言時に、デフォルト値が設定されていないことが原因でした。そして2つ目は、Fieldの中でvalidate_default=Trueという設定も必要でした。元々Annotatedの中のFieldで設定した"default_value"は何のフィルターとしても機能していなかったみたいです。

from typing import Any, Callable, ClassVar, List

# ↓ デフォルト値の設定をグローバルインスタンスに一元化した。
class Context(TypedDict):
    logs: List[str]
    default_value: str
context = Context(logs=[], default_value="default_value")

# ↓ 修正前:デフォルト値が設定されていない。
# Schema definition
class NotionTask(BaseModel):
    number: Annotated[int, -1]
    con_id: Annotated[str, Field("default_value", example="TK000012345")]

# ↓ 修正後:デフォルト値を設定した。
# Schema definition
class NotionTask(BaseModel):
    number: Annotated[int, -1]
    con_id: Annotated[str, Field(validate_default=True, example="TK000012345")] = context["default_value"]

修正したコードが下記のものです。これで、1つ目のバリデーション実装である、from pydantic.functional_validators import field_validatorでインポートしたfield_validatorを使用する方法が出来上がりました。(ちなみに、チラホラコメントアウトされている行は、2つ目の方法でバリデーションチェックを実装した部分です。)

from typing import Any, Callable, ClassVar, List, cast
from typing_extensions import Annotated, TypedDict
from pydantic import BaseModel, Field, field_validator, AfterValidator, BeforeValidator, ValidationInfo, HttpUrl

class NotionTask(BaseModel):
    number: Annotated[int, -1]
    con_id: Annotated[str, Field(validate_default=True, example="TK000012345")] = context["default_value"]
    page_id: Annotated[str, Field(validate_default=True, description="Notion page UUID")] = context["default_value"]
    page_title: Annotated[str, Field(...)]
    status: Annotated[str, Field(validate_default=True, description="Notion page UUID")] = context["default_value"]
    page_icon: Annotated[str, Field(..., description="Notion page icon")]
    is_page_archived: Annotated[bool, Field(False, description="if page is archived")]
    val_target: ClassVar[str] = ""

    val_target = "number"
    # number: Annotated[int, -1, BeforeValidator(make_validator_it_is_not_none(val_target)), AfterValidator(make_validator_number_is_not_negative(val_target))]
    val_number_before = field_validator(val_target, mode="before")(
        make_validator_it_is_not_none(val_target)
    )
    val_number_after = field_validator(val_target, mode="after")(
        make_validator_number_is_not_negative(val_target)
    )
    val_target = "con_id"
    # con_id: Annotated[str, Field(validate_default=True, example="TK000012345"), BeforeValidator(make_validator_it_is_not_none(val_target)), AfterValidator(make_validator_values_length_is_equal(val_target, 11))] = context["default_value"]
    val_con_id_before = field_validator(val_target, mode="before")(
        make_validator_it_is_not_none(val_target)
    )
    val_con_id_after = field_validator(val_target, mode="after")(
        make_validator_values_length_is_equal(val_target, 11)
    )
    val_target = "page_id"
    # page_id: Annotated[str, Field(validate_default=True, description="Notion page UUID"), BeforeValidator(make_validator_it_is_not_none(val_target)), AfterValidator(make_validator_values_length_is_equal(val_target, 36))] = context["default_value"]
    val_page_id_before = field_validator(val_target, mode="before")(
        make_validator_it_is_not_none(val_target)
    )
    val_page_id_after = field_validator(val_target, mode="after")(
        make_validator_values_length_is_equal(val_target, 36)
    )
    val_target = "page_title"
    # page_title: Annotated[str, Field(validate_default=True), BeforeValidator(make_validator_it_is_not_none(val_target)), AfterValidator(make_validator_value_is_less(val_target, 120))]
    val_page_title_before = field_validator(val_target, mode="before")(
        make_validator_it_is_not_none(val_target)
    )
    val_page_title_after = field_validator(val_target, mode="after")(
        make_validator_value_is_less(val_target, 120)
    )
    val_target = "status"
    # status: Annotated[str, Field(validate_default=True, description="Notion page UUID"), BeforeValidator(make_validator_it_is_not_none(val_target)), AfterValidator(make_validator_values_length_is_equal(val_target, 36))] = context["default_value"]
    val_status_before = field_validator(val_target, mode="before")(
        make_validator_it_is_not_none(val_target)
    )
    val_status_after = field_validator(val_target, mode="after")(
        make_validator_values_length_is_equal(val_target, 36)
    )
    val_target = "page_icon"
    # page_icon: Annotated[str, Field(validate_default=True, description="Notion page icon"), BeforeValidator(make_validator_it_is_not_none(val_target)), AfterValidator(make_validator_value_is_less(val_target, 2))]
    val_page_icon_before = field_validator(val_target, mode="before")(
        make_validator_it_is_not_none(val_target)
    )
    val_page_icon_after = field_validator(val_target, mode="after")(
        make_validator_value_is_less(val_target, 2)
    )
    val_target = "is_page_archived"
    # is_page_archived: Annotated[bool, False, BeforeValidator(make_validator_it_is_not_none(val_target))]
    val_is_page_archived_before = field_validator(val_target, mode="before")(
        make_validator_it_is_not_none(val_target)
    )
    # Validates nothing

例えばそのコードをFastAPI上で実行すると、フィールドの代入漏れがあると例外を発生するようになりました。これで、意図しないデータを誤ってレスポンスのボディに載せる挙動を無くすことが出来ます。

from fastapi import FastAPI

app = FastAPI(
    title="NotionSynchronizer API",
    summary="Retrieves Notion pages and manages with PostgreSQL",
    description="You will be able to:\n- Retrieve Notion pages.\n- Store info of Notion pages into PostgreSQL.",
    version="0.0.1",
    openapi_url="/api/v0/openapi.json",
    separate_input_output_schemas=True
)

@app.get("/task")
def return_all_tasks():
    result = {"number": 1, "con_id": "TK000012345", "page_id": "TK000012345TK000012345TK000012345789", "page_title": "test test title no test", "page_icon": "🍞", "is_page_archived": False}
    NotionTask.model_validate(result, context=context)
    return [NotionTask(**result)]
# pydantic_core._pydantic_core.ValidationError: 1 validation error for NotionTask
# status
#  Value error, 'status' must not be 'default_value' [type=value_error, input_value='default_value', input_type=str]

Annotatedの中でダブルバリデーションチェック

次に、2つ目の実装方法である、BeforeValidatorAfterValidatorAnnotatedの中に入れる実装方法です。

先程のコード内で、from pydantic.functional_validators import field_validatorでインポートしたfield_validatorを使用する実装部分をコメントアウトして、BeforeValidatorAfterValidatorAnnotatedの中に入れる方法で実装した部分のコメントアウトを外します。

class NotionTask(BaseModel):
    # con_id: Annotated[str, Field(context["default_value"], example="TK000012345")]
    # page_id: Annotated[str, Field(context["default_value"], description="Notion page UUID")]
    # page_title: Annotated[str, Field(...)]
    # status: Annotated[str, Field(context["default_value"], description="Notion page UUID")]
    # page_icon: Annotated[str, Field(..., description="Notion page icon")]
    # is_page_archived: Annotated[bool, Field(False, description="if page is archived")]
    val_target: ClassVar[str] = ""

    val_target = "number"
    number: Annotated[int, -1, BeforeValidator(make_validator_it_is_not_none(val_target)), AfterValidator(make_validator_number_is_not_negative(val_target))]
    # val_number_before = field_validator(val_target, mode="before")(
    #     make_validator_it_is_not_none(val_target)
    # )
    # val_number_after = field_validator(val_target, mode="after")(
    #     make_validator_number_is_not_negative(val_target)
    # )
    val_target = "con_id"
    con_id: Annotated[str, Field(validate_default=True, example="TK000012345"), BeforeValidator(make_validator_it_is_not_none(val_target)), AfterValidator(make_validator_values_length_is_equal(val_target, 11))] = context["default_value"]
    # val_con_id_before = field_validator(val_target, mode="before")(
    #     make_validator_it_is_not_none(val_target)
    # )
    # val_con_id_after = field_validator(val_target, mode="after")(
    #     make_validator_values_length_is_equal(val_target, 11)
    # )
    val_target = "page_id"
    page_id: Annotated[str, Field(validate_default=True, description="Notion page UUID"), BeforeValidator(make_validator_it_is_not_none(val_target)), AfterValidator(make_validator_values_length_is_equal(val_target, 36))] = context["default_value"]
    # val_page_id_before = field_validator(val_target, mode="before")(
    #     make_validator_it_is_not_none(val_target)
    # )
    # val_page_id_after = field_validator(val_target, mode="after")(
    #     make_validator_values_length_is_equal(val_target, 36)
    # )
    val_target = "page_title"
    page_title: Annotated[str, Field(validate_default=True), BeforeValidator(make_validator_it_is_not_none(val_target)), AfterValidator(make_validator_value_is_less(val_target, 120))]
    # val_page_title_before = field_validator(val_target, mode="before")(
    #     make_validator_it_is_not_none(val_target)
    # )
    # val_page_title_after = field_validator(val_target, mode="after")(
    #     make_validator_value_is_less(val_target, 120)
    # )
    val_target = "status"
    status: Annotated[str, Field(validate_default=True, description="Notion page UUID"), BeforeValidator(make_validator_it_is_not_none(val_target)), AfterValidator(make_validator_values_length_is_equal(val_target, 36))] = context["default_value"]
    val_status_before = field_validator(val_target, mode="before")(
        make_validator_it_is_not_none(val_target)
    )
    val_status_after = field_validator(val_target, mode="after")(
        make_validator_values_length_is_equal(val_target, 36)
    )
    val_target = "page_icon"
    page_icon: Annotated[str, Field(validate_default=True, description="Notion page icon"), BeforeValidator(make_validator_it_is_not_none(val_target)), AfterValidator(make_validator_value_is_less(val_target, 2))]
    # val_page_icon_before = field_validator(val_target, mode="before")(
    #     make_validator_it_is_not_none(val_target)
    # )
    # val_page_icon_after = field_validator(val_target, mode="after")(
    #     make_validator_value_is_less(val_target, 2)
    # )
    val_target = "is_page_archived"
    is_page_archived: Annotated[bool, False, BeforeValidator(make_validator_it_is_not_none(val_target))]
    # val_is_page_archived_before = field_validator(val_target, mode="before")(
    #     make_validator_it_is_not_none(val_target)
    # )
    # Validates nothing

このコードもFastAPI上で実行すると、先程のコードと同様にフィールドの代入漏れがあると例外を発生させるようになりました。(これが代入前のチェック。)そして、フィールドの値自体が文字数の超過や不足が発生している場合も例外が発生するようになりました。(これが代入後のチェック。実は、先程のコードも同様のバリデーションチェックが可能です。)

# statusフィールドが存在しない場合のチェック
@app.get("/task")
def return_all_tasks():
    result = {"number": 1, "con_id": "TK000012345", "page_id": "TK000012345TK000012345TK000012345789", "page_title": "test test title no test", "page_icon": "🍞", "is_page_archived": False}
    NotionTask.model_validate(result, context=context)
    return [NotionTask(**result)]
# pydantic_core._pydantic_core.ValidationError: 1 validation error for NotionTask
# status
#   Value error, 'status' must not be 'default_value' [type=value_error, input_value='default_value', input_type=str]

# statusフィールドの文字列が短すぎる場合のチェック
@app.get("/task")
def return_all_tasks():
    result = {"number": 1, "con_id": "TK000012345", "page_id": "TK000012345TK000012345TK000012345789", "page_title": "test test title no test", "status": "TK000012345TK000012345TK00001234578", "page_icon": "🍞", "is_page_archived": False}
    NotionTask.model_validate(result, context=context)
    return [NotionTask(**result)]
# pydantic_core._pydantic_core.ValidationError: 1 validation error for NotionTask
# status
#   Value error, 'status' must have 36 characters [type=value_error, input_value='TK000012345TK000012345TK00001234578', input_type=str]

# statusフィールドの文字列が長すぎる場合のチェック
@app.get("/task")
def return_all_tasks():
    result = {"number": 1, "con_id": "TK000012345", "page_id": "TK000012345TK000012345TK000012345789", "page_title": "test test title no test", "status": "TK000012345TK000012345TK0000123457890", "page_icon": "🍞", "is_page_archived": False}
    NotionTask.model_validate(result, context=context)
    return [NotionTask(**result)]
# pydantic_core._pydantic_core.ValidationError: 1 validation error for NotionTask
# status
#   Value error, 'status' must have 36 characters [type=value_error, input_value='TK000012345TK000012345TK0000123457890', input_type=str]

このような流れで、Pydanticを用いてオブジェクトのフィールドの型安全を担保したコードを実装することが出来ます。これで自分のコードがより堅牢になると思います。

まとめ

今回の記事は、クラスのフィールドのバリデーションチェックを@validatorで書いてみたが、@validatorはdeprecatedになってて、代わりに@field_validatorを使ってみる。しかし、@field_validatorにはpreとかalwaysフラグが無いから、from pydantic.functional_validators import field_validatorでインポートしたfield_validatorを使用する方法と、BeforeValidatorAfterValidatorAnnotateの中に入れる実装方法で実装してみた。という流れの記事でした。

例えば、データベースにデータを格納する時や、APIとしてレスポンスを返す時などに、Pydanticはより堅牢で明確な機能性を担保してくれるでしょう。

おしまい

リサちゃん
リサちゃん

よし、形になったぞ・・・

135ml
135ml

こういうのって最初が一番たいへんですよね。

以上になります!

コメント

タイトルとURLをコピーしました