※当サイトの記事には、広告・プロモーションが含まれます。

PythonのPydanticで共通的に利用する独自バリデーションのクラスを作りたいんだが

gigazine.net

しかし、研究チームは、FDA承認を受けたAI医療機器の約43%で臨床的検証データが公表されていない点に注目しています。

FDAが承認したAI医療機器のほぼ半数が実際の患者データに基づいてトレーニングされていないことが研究で明らかに - GIGAZINE

さらに、一部のデバイスは実際の患者データではなく、コンピューターで生成した架空の画像を使用しており、臨床的検証の要件を満たしていなかったケースもあったそうです。研究チームは、FDAの2023年9月に発表されたガイダンスにおいて、異なる種類の臨床的検証研究の区別が製造業者への推奨事項に明確に示されていないことも指摘しました。

FDAが承認したAI医療機器のほぼ半数が実際の患者データに基づいてトレーニングされていないことが研究で明らかに - GIGAZINE

⇧ 学習データが用意できないなら、「データ拡張(Data augmentation)」はアプローチとして間違っていないと思いますが、「臨床検証」の「要件」漏れがあったと。

レーニング時点で、一般的に既知であった情報が考慮されていなかったのであれば「要件」漏れと言えるかもしれませんが、未知の情報であったならば「要件」通りと言えると思われますが。

アメリカ食品医薬品局FDA:Food and Drug Administration)」が承認しているなら、推奨事項を正確にソフトウェア開発者に伝えられていたのかは気になりますな。

分類が重要ということだとは思いますが、ソフトウェア開発も「情報システム」の分類が明確ではないからなぁ...

ただ、

この結果を受けて、研究チームはFDAと機器製造業者に対し、臨床的検証研究の種類を明確に区別し、その結果を公開することを強く推奨しています。また、回帰的バリデーション、予測的バリデーション、ランダム化比較試験それぞれの定義を標準化し、業界全体で使用することを提案しました。

FDAが承認したAI医療機器のほぼ半数が実際の患者データに基づいてトレーニングされていないことが研究で明らかに - GIGAZINE

⇧ 状況を改善しようと、標準化を進める動きがあるのが羨ましいところですな。

PythonのPydanticで共通的に利用する独自バリデーションのクラスを作りたいんだが

どうしても、Javaに慣れ親しんできてたということもあり、何かとJavaで実現できること目線の発想になってしまうのですが、JavaSpring Frameworkを利用した開発とかしていると、

www.baeldung.com

⇧ 上記サイト様にありますように、

を用意して、Java標準のAPIで用意されていないような「バリデーション」の機能を独自に作ることあるあるだと思います。

と言うのも、Javaの標準のAPIである、jakarta.validation.constraintsパッケージで用意されているアノテーションによる「バリデーション」では、機能が圧倒的に不足しているが故。

で、Pythonでモデル用のクラスに対して「バリデーション」を実現したいとなると、「Pydantic」というライブラリを使うことがメジャーらしいのですが(Pythonの場合、そもそも標準のライブラリで「バリデーション」に関するものが用意されていなさそう...)、Javaでの「バリデーション」と同じ様なこと、つまり、共通的に利用する独自「バリデーション」のクラスを作って利用することを、Pythonでも実現したいですと。

しかしながら、

docs.pydantic.dev

⇧「Pydantic」の公式のドキュメントの実装例を見た感じでは、

  • モデル用のクラス
  • バリデーション処理

が「密結合」してしまっているという残念な実装例になってしまっておりますと...

Pythonや「Pydantic」における詳細設計の思想が分からないので何とも言えないのですが、一般的には、可能な限り「DRY(Don't Repeat Yourself)」を実現できるようにはしておくべきだとは思われますと。

要するに、同じ様な処理を、あちこちに「ハードコーディング」するんじゃありませんよ、修正が大変になるでしょ、ってことなのですが、誠に遺憾ではありますが、「Pydantic」の公式のドキュメントの実装例が正にその惨状に当てはまる感じになってしまっていますと...

Pythonの流儀が分からないので、「Pydantic」の公式のドキュメントの実装例を模範とすべきなのかが、Python初学者には判断が付かないので、不特定多数の人が参照できるドキュメントの見本が適当だと困るのよね...

個人的には、「Pydantic」の公式のドキュメントの実装例は、実運用的に厳しいのではないかという懸念もあり、やはり、ここはJavaっぽく、

を作成することで、「疎結合」にできないものか、試してみました。

Pythonプロジェクトの構成などは、

ts0818.hatenablog.com

⇧ 上記の記事の時のものを利用してます。

⇧ 今回、追加したもの。

■C:\Users\Toshinobu\Desktop\soft_work\python_work\fastapi\app\src\main\py\validator\phone\contact_phone_validator.py

class ContactPhoneValidator:
    @staticmethod
    def is_valid(contact_field: str) -> bool:
        is_not_empty: bool = contact_field is not None
        is_digit: bool = is_not_empty and str(contact_field).isdigit()
        is_phone: bool = is_not_empty and (8 < len(str(contact_field)) < 14)
        return is_digit and is_phone

■C:\Users\Toshinobu\Desktop\soft_work\python_work\fastapi\app\src\main\py\validator\phone\contact_phone_constraint.py

from functools import wraps
from app.src.main.py.validator.phone.contact_phone_validator import (
    ContactPhoneValidator,
)


def contact_phone_constraint(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        print("■■■contact_phone_constraint■■■")
        value = args[1]  # args[1] is the value being set
        if not ContactPhoneValidator.is_valid(value):
            raise ValueError("Invalid phone number")
        return func(*args, **kwargs)

    return wrapper

■C:\Users\Toshinobu\Desktop\soft_work\python_work\fastapi\app\src\main\py\dto\user_dto.py

# from pydantic import BaseModel, constr
from typing_extensions import Annotated, ClassVar

from pydantic import BaseModel, ConfigDict, StringConstraints, Field
from pydantic.functional_validators import WrapValidator
from datetime import date, datetime
from app.src.main.py.validator.phone.contact_phone_constraint import (
    contact_phone_constraint,
)


class UserDto(BaseModel):
    #    first_name: constr(max_length=255)
    #    last_name: constr(max_length=255)

    # class Config:
    #     orm_mode = True

    # model_config = ConfigDict(from_attributes=True)
    model_config = ConfigDict(arbitrary_types_allowed=True)

    @contact_phone_constraint
    def validate_phone(self, value: str):
        pass

    # first_name: ClassVar[Annotated[str, StringConstraints(max_length=255)]]
    # last_name: ClassVar[Annotated[str, StringConstraints(max_length=255)]]
    # first_name_kana: ClassVar[Annotated[str, StringConstraints(max_length=255)]]
    # last_name_kana: ClassVar[Annotated[str, StringConstraints(max_length=255)]]
    # email: ClassVar[Annotated[str, StringConstraints(max_length=255)]]
    # birth: ClassVar[date]
    # gender: ClassVar[int]
    # #    phone: Annotated[str, StringConstraints(max_length=15)]
    # phone: ClassVar[Annotated[str, WrapValidator(set_phone)]]

    first_name: Annotated[str, Field(max_length=255)]
    last_name: Annotated[str, Field(max_length=255)]
    first_name_kana: Annotated[str, Field(max_length=255)]
    last_name_kana: Annotated[str, Field(max_length=255)]
    email: Annotated[str, Field(max_length=255)]
    birth: date
    gender: int
    phone: Annotated[str, WrapValidator(validate_phone)]
    

動作確認用。

■C:\Users\Toshinobu\Desktop\soft_work\python_work\fastapi\app\src\test\py\validator\test_user_dto.py

import pytest
import asyncio

from datetime import date
from pprint import pprint

from app.src.main.py.code.gender import Gender

from app.src.main.py.dto.user_dto import UserDto


class TestUserDto:

    # @pytest
    def test_01(self):
        print("■■■[start][test_user_dto.py]test_01■■■")
        try:
            user: UserDto = UserDto(
                first_name="鈴木",
                last_name="一郎",
                first_name_kana="スズキ",
                last_name_kana="イチロウ",
                email="ichiro@hoge.com",
                birth=date(1983, 7, 20),
                gender=int(Gender.MAN),
                phone="000-8888-8888",
            )
            pprint(user)
        except ValueError as e:
            print(e)
        print("■■■[finish][test_user_dto.py]test_01■■■")
    

⇧ で保存。実行。

⇧ とりあえず、独自「バリデーション」が機能することは確認できたのだけど、Javaみたいにクラスのフィールドにアノテーションを付与するみたいなことは無理っぽい。

だけども、「バリデーション」の処理自体は、1箇所に集約できたので、「DRY(Don't Repeat Yourself)」の考え方に少し近付きましたと。

と思ったら、

phone: Annotated[str, WrapValidator(validate_phone)]    

⇧ の部分で、コンストラクタの引数が連携できなかったことを確認。

validate_phoneメソッドの実際の引数を確認してみたところ、

ValidatorCallable(Str(StrValidator { strict: false, coerce_numbers_to_str: false }))

⇧ のような値が入ってました、意味が分からない...

つまり、「バリデーション」処理の「疎結合」を実現できない...

と思ったら、「WrapValidator」以外を使えば挙動が変わった。

なので、「DTO(Data Transfer Object)」に該当するクラスを以下のようにしてみたのだけど、

■C:\Users\Toshinobu\Desktop\soft_work\python_work\fastapi\app\src\main\py\dto\user_dto.py

# from pydantic import BaseModel, constr
from typing_extensions import Annotated, ClassVar

from pydantic import BaseModel, ConfigDict, StringConstraints, Field
from pydantic.functional_validators import WrapValidator, AfterValidator
from datetime import date, datetime
from app.src.main.py.validator.phone.contact_phone_constraint import (
    contact_phone_constraint,
)


class UserDto(BaseModel):
    #    first_name: constr(max_length=255)
    #    last_name: constr(max_length=255)

    # class Config:
    #     orm_mode = True

    # model_config = ConfigDict(from_attributes=True)
    model_config = ConfigDict(arbitrary_types_allowed=True)

    @contact_phone_constraint
    def validate_phone(self, value: str):
        pass

    # first_name: ClassVar[Annotated[str, StringConstraints(max_length=255)]]
    # last_name: ClassVar[Annotated[str, StringConstraints(max_length=255)]]
    # first_name_kana: ClassVar[Annotated[str, StringConstraints(max_length=255)]]
    # last_name_kana: ClassVar[Annotated[str, StringConstraints(max_length=255)]]
    # email: ClassVar[Annotated[str, StringConstraints(max_length=255)]]
    # birth: ClassVar[date]
    # gender: ClassVar[int]
    # #    phone: Annotated[str, StringConstraints(max_length=15)]
    # phone: ClassVar[Annotated[str, WrapValidator(set_phone)]]

    first_name: Annotated[str, Field(max_length=255)]
    last_name: Annotated[str, Field(max_length=255)]
    first_name_kana: Annotated[str, Field(max_length=255)]
    last_name_kana: Annotated[str, Field(max_length=255)]
    email: Annotated[str, Field(max_length=255)]
    birth: date
    gender: int
    # phone: Annotated[str, WrapValidator(validate_phone)]
    phone: Annotated[str, AfterValidator(validate_phone)]
    

そうすると、validate_phoneメソッドの実際の引数は以下のようになりました。

ValidationInfo(config={'title': 'UserDto'}, context=None, data={'first_name': '鈴木', 'last_name': '一郎', 'first_name_kana': 'スズキ', 'last_name_kana': 'イチロウ', 'email': 'ichiro@hoge.com', 'birth': datetime.date(1983, 7, 20), 'gender': 0}, field_name='phone')

⇧ どちらにしろ、何故か、phoneの値だけ連携されない...

「バリデーション」できないやんけ...

何か、ものすごく微妙な形にはなるが、以下のような感じにすれば「バリエーション」してくれました。

■C:\Users\Toshinobu\Desktop\soft_work\python_work\fastapi\app\src\main\py\dto\user_dto.py

# from pydantic import BaseModel, constr
from typing_extensions import Annotated, ClassVar

from pydantic import BaseModel, ConfigDict, StringConstraints, Field, model_validator
from pydantic.functional_validators import (
    WrapValidator,
    AfterValidator,
    BeforeValidator,
    PlainValidator,
)
from datetime import date, datetime
from app.src.main.py.validator.phone.contact_phone_constraint import (
    contact_phone_constraint,
)


class UserDto(BaseModel):
    #    first_name: constr(max_length=255)
    #    last_name: constr(max_length=255)

    # class Config:
    #     orm_mode = True

    # model_config = ConfigDict(from_attributes=True)
    model_config = ConfigDict(arbitrary_types_allowed=True)

    # @model_validator(mode="after")
    @contact_phone_constraint
    def validate_phone(self, value: str):
        self.phone = value
        # pass

    # first_name: ClassVar[Annotated[str, StringConstraints(max_length=255)]]
    # last_name: ClassVar[Annotated[str, StringConstraints(max_length=255)]]
    # first_name_kana: ClassVar[Annotated[str, StringConstraints(max_length=255)]]
    # last_name_kana: ClassVar[Annotated[str, StringConstraints(max_length=255)]]
    # email: ClassVar[Annotated[str, StringConstraints(max_length=255)]]
    # birth: ClassVar[date]
    # gender: ClassVar[int]
    # #    phone: Annotated[str, StringConstraints(max_length=15)]
    # phone: ClassVar[Annotated[str, WrapValidator(set_phone)]]

    first_name: Annotated[str, Field(max_length=255)]
    last_name: Annotated[str, Field(max_length=255)]
    first_name_kana: Annotated[str, Field(max_length=255)]
    last_name_kana: Annotated[str, Field(max_length=255)]
    email: Annotated[str, Field(max_length=255)]
    birth: date
    gender: int
    # phone: Annotated[str, WrapValidator(validate_phone)]
    phone: str
    # def model_post_init(self):
    #     BeforeValidator(self.validate_phone)

    @model_validator(mode="after")
    def valid_after_valid(self):
        self.validate_phone(self.phone)
        # return values
    

⇧ ただ、何か、これじゃない感が漂ってるのよね...

う~む、Pythonのモデル用のクラスの「バリデーション」はどうするのが良いんですかね?

「DRY(Don't Repeat Yourself)」の考え方に近付けたいのが一般的ではないかなと思う故、「Pydantic」の公式のドキュメントの実装例のような各々のモデル用のクラスに「バリデーション」の処理を「ハードコーディング」するのは避けたいと考えるのが自然のような気はしますが...

つまり、モデル用のクラスからは、別のファイルで定義した「バリデーション」処理を呼び出す形にはしたいんよね。

だって、仮にモデル用のクラスが10個ほどあったとして、その全てに同じ「バリデーション」の処理をコーディングした場合、修正とか面倒になるのは確定じゃないですか。

Pythonの流儀と言うんですか、「Pythonic」や「Zen of Python」的な思想が分からないので、何とも言えませんが...

後、「SQLAlchemy」の「ORM(Object Relational Mapping)」の機能を利用している場合、まさかの

  • SQLAlchemyのモデル用のクラス
  • Pydanticのモデル用のクラス

の2つを作る必要があるらしいという...

「Pydantic」における「モデル」が何の役割を想定しているのかが、いまいち良く分からないんよね...

「モデル」がデータの入れ物という分類になるのであれば、Javaだと、

yyyank.blogspot.com

⇧ 上記サイト様にありますように、役割によって意味合いが変わってきますからな。

「Pydanticのモデル用のクラス」は、「DTO(Data Transfer Object)」的な立ち位置ってことになると考えておけば良いんかね?

Python、実用に適した標準的な実装が分からないので、学習するのに調査時間ばかりかかってしまうのが辛過ぎるぜよ...

学習時間を、推奨される実装を写経するだけに充てられれば良いのですが、見本となる実装がネット上に見当たらないんよな...

毎度モヤモヤ感が半端ない…

今回はこのへんで。