🔦

Pydanticがわからないのでメモる

2024/05/01に公開

概要

pythonのバリデーションライブラリ pydantic の使い方を学ぶ。
pydanticの公式ドキュメントを見ると、英語であることを差し引いてもどう使えばいいのか迷子になる感覚があった。であれば別のライブラリを使えばいいとは思いつつも、オープンソース系のライブラリでチュートリアルが充実しているのも稀である気がしてきたので逃げたくなる気持ちを抑えつつチュートリアルもどきを自前で用意しようと考えた。
DDDについて。セキュア・バイ・デザインを読むまで名前だけ知ってたものの概要もわかっていなかった。良さそうな考えに感じたので興味が出てきているものの、この記事内では導入で触れるのみ。

経緯

セキュア・バイ・デザインを読んだ。ドメイン・プリミティブという言葉は目にしたことがあったものの今まで何のことだかわからなかった。これは長年感じていたもやもや、こうした方がいいような気がするけどコード量が多くなって開発メンバーからクレーム来そうだし導入は難しいだろうな、と感じていたので導入の入口、解決策、言語化のとっかかりになりそうな気がした。この構成部品として見た妥当性確認について、今までのやり方が強引な気がしていたのでより良い方法がないだろうかと考えた。

妥当性確認(Validation)は重要、だがしかし

以前から関数の入口で引数チェックは入れるようにしていたものの、忘れることやassertがテストの邪魔になることがあり削除することになったりと、実装に一貫性がなかった。ドメイン・プリミティブのパターンはこの解決策になりそう。

Pydantic

車輪の再開発は避けたいというか楽できる方法があるなら楽がしたい。妥当性確認のライブラリ?フレームワーク?何かあるのだろうか?最近はpython触ってるからpythonで。検索するとPydanticというライブラリが目に付いたので使ってみることに。

Pydanticのオブジェクトの作成-失敗例

BaseModelを基本クラスにしてフィールドを定義する。__init__()不要なのね。

from datetime import datetime
from pydantic import BaseModel, PositiveInt
class User(BaseModel):
    id: int  
    name: str = 'John Doe'  
    signup_ts: datetime | None  
    tastes: dict[str, PositiveInt]  

失敗1 位置引数のみ利用(のつもり)

if __name__=='__main__':
    u = User(1, 'ore', '2024/04/29', {
        'score1': 50, 'score2': 70
    })
    print(u)
例外が発生しました: TypeError
BaseModel.__init__() takes 1 positional argument but 5 were given
  File "/home/user1/ex1.py", line 10, in <module>
    u = User(1, 'ore', '2024/04/29', {'score1': 50, 'score2': 70})
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
TypeError: BaseModel.__init__() takes 1 positional argument but 5 were given

え、だめなの?? takes 1 positional argument??

失敗2 idだけ引数渡し(のつもりでid指定にはならない模様)

if __name__=='__main__':
    u = User(1)
    print(u)
例外が発生しました: TypeError
BaseModel.__init__() takes 1 positional argument but 2 were given
  File "/home/user1/ex1.py", line 10, in <module>
    u = User(1)
        ^^^^^^^
TypeError: BaseModel.__init__() takes 1 positional argument but 2 were given

これもだめ? 2 were given?? 見えない何かが・・(追加されて)いる?

失敗3 引数無し

if __name__=='__main__':
    u = User()
    print(u)
例外が発生しました: ValidationError
3 validation errors for User
id
  Field required [type=missing, input_value={}, input_type=dict]
    For further information visit https://errors.pydantic.dev/2.7/v/missing
signup_ts
  Field required [type=missing, input_value={}, input_type=dict]
    For further information visit https://errors.pydantic.dev/2.7/v/missing
tastes
  Field required [type=missing, input_value={}, input_type=dict]
    For further information visit https://errors.pydantic.dev/2.7/v/missing
  File "/home/user1/ex1.py", line 10, in <module>
    u = User()
        ^^^^^^

Oh... idはデフォルト値設定してないから必須だと。id=1すれば通るのか? json形式だとオブジェクト作成できる?

失敗4 辞書オブジェクト(json形式?)引数を使う

if __name__=='__main__':
    u = User({
        'id': 1,
        'name': 'ore',
        'signup_ts':'2024/04/29',
        'tastes': {
            'score1': 50,
            'score2': 70
            }
        })
    print(u)
例外が発生しました: TypeError
BaseModel.__init__() takes 1 positional argument but 2 were given
  File "/home/user1/ex1.py", line 10, in <module>
    u = User({'id': 1, 'name': 'ore', 'signup_ts':'2024/04/29', 'tastes': {'score1': 50, 'score2': 70}})
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
TypeError: BaseModel.__init__() takes 1 positional argument but 2 were given

あれ、これもだめなのか。こんな記述見かけた気がしたんだけど。(後述、**が足りなかった)

失敗5 引数名指定

if __name__=='__main__':
    u = User(
            id=1,
            name='ore',
            signup_ts='2024/04/29',
            tastes={
                'score1': 50,
                'score2': 70
                }
        )
    print(u)
例外が発生しました: ValidationError
1 validation error for User
signup_ts
  Input should be a valid datetime or date, invalid date separator, expected `-` [type=datetime_from_date_parsing, input_value='2024/04/29', input_type=str]
    For further information visit https://errors.pydantic.dev/2.7/v/datetime_from_date_parsing
  File "/home/user1/ex1.py", line 10, in <module>
    u = User(id=1, name='ore', signup_ts='2024/04/29', tastes={'score1': 50, 'score2': 70})
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
pydantic_core._pydantic_core.ValidationError: 1 validation error for User
signup_ts
  Input should be a valid datetime or date, invalid date separator, expected `-` [type=datetime_from_date_parsing, input_value='2024/04/29', input_type=str]
    For further information visit https://errors.pydantic.dev/2.7/v/datetime_from_date_parsing

あ、少し進んだ。年月日フォーマットNGのみか。まあ通らないかもとは思ってた。from_date_parsing?

Pydanticのオブジェクトの作成-成功例

成功1 引数名指定、datetimeフォーマット修正

if __name__=='__main__':
    u = User(
        id=1,
        name='ore',
        signup_ts='2024-04-29',
        tastes={
            'score1': 50,
            'score2': 70
            }
        )
    print(u)
id=1 name='ore' signup_ts=datetime.datetime(2024, 4, 29, 0, 0) tastes={'score1': 50, 'score2': 70}

オブジェクト作成はできたけど、json形式の引数渡して作るのが失敗したままなのはちょっと気持ち悪い。…json形式?辞書オブジェクト?理解が曖昧。

成功2 辞書オブジェクトを引数としたオブジェクトの作成

if __name__=='__main__':
    j = {
        'id':1,
        'name':'ore',
        'signup_ts':'2024-04-29',
        'tastes':{
            'score1': 50,
            'score2': 70
            }
        }
    u = User(**j)
    print(u)
id=1 name='ore' signup_ts=datetime.datetime(2024, 4, 29, 0, 0) tastes={'score1': 50, 'score2': 70}

**が必要らしい。

さてオブジェクトの作成はできたものの。これだけでは型のチェックくらいで値の範囲をどう設定するのかがわからない。
…型のチェックくらいはする、よね?ドキュメントに書いてあったような…。一応確認してみる。

デフォルトのバリデーションの動作確認

intフィールドに文字列を渡すと validation errorになる

if __name__=='__main__':
    j = {
        'id':'pandra',  # intフィールドに文字列を渡す
        'name':'ore',
        'signup_ts':'2024-04-29',
        'tastes':{
            'score1': 50,
            'score2': 70
            }
        }
    u = User(**j)
    print(u)
例外が発生しました: ValidationError
1 validation error for User
id
  Input should be a valid integer, unable to parse string as an integer [type=int_parsing, input_value='pandra', input_type=str]
    For further information visit https://errors.pydantic.dev/2.7/v/int_parsing
  File "/home/user1/ex1.py", line 29, in <module>
    u = User(**j)
        ^^^^^^^^^
pydantic_core._pydantic_core.ValidationError: 1 validation error for User
id
  Input should be a valid integer, unable to parse string as an integer [type=int_parsing, input_value='pandra', input_type=str]

文字列フィールドに整数値を渡すと validation errorになる

if __name__=='__main__':
    j = {
        'id':1,
        'name': 1,    # 文字列フィールドに整数値を渡す
        'signup_ts':'2024-04-29',
        'tastes':{
            'score1': 50,
            'score2': 70
            }
        }
    u = User(**j)
    print(u)
例外が発生しました: ValidationError
1 validation error for User
name
  Input should be a valid string [type=string_type, input_value=1, input_type=int]
    For further information visit https://errors.pydantic.dev/2.7/v/string_type
  File "/home/user1/ex1.py", line 29, in <module>
    u = User(**j)
        ^^^^^^^^^
pydantic_core._pydantic_core.ValidationError: 1 validation error for User
name
  Input should be a valid string [type=string_type, input_value=1, input_type=int]
    For further information visit https://errors.pydantic.dev/2.7/v/string_type

datetimeフィールドに整数値(整数解釈可能文字列)渡すと validation errorに ならない が誤った書式文字列を渡すとvalidation errorになる

if __name__=='__main__':
    j = {
        'id':1,
        'name': 'ore',
        'signup_ts':20240429,    # datetimeフィールドに整数値を渡す
        'tastes':{
            'score1': 50,
            'score2': 70
            }
        }
    u = User(**j)
    print(u)
id=1 name='ore' signup_ts=datetime.datetime(1970, 8, 23, 6, 20, 29, tzinfo=TzInfo(UTC)) tastes={'score1': 50, 'score2': 70}

あれ、通っちゃったよ…。しかも1970/8/23 6:20:29?それっぽい日付が入ってる。実体は整数値だったはずだから仕方がないのか・・?いやいや妥当性確認にならないでしょどうするのこれ。公式のdatetime validationの説明を見ると
int or float; assumed as Unix time, i.e. seconds (if >= -2e10 and <= 2e10) or milliseconds (if < -2e10or > 2e10) since 1 January 1970と記述されている。
-20000000000.0から20000000000.0の範囲の値は妥当だと判断されるらしい。Humm...

if __name__=='__main__':
    j = {
        'id':1,
        'name': 'ore',
        'signup_ts':'20240429',    # datetimeフィールドにyyyymmdd形式の文字列を渡す
        'tastes':{
            'score1': 50,
            'score2': 70
            }
        }
    u = User(**j)
    print(u)
id=1 name='ore' signup_ts=datetime.datetime(1970, 8, 23, 6, 20, 29, tzinfo=TzInfo(UTC)) tastes={'score1': 50, 'score2': 70}

あ、これも通るんだ。区切り記号なくてどうやって解析してるのかと実行結果を見たら 1970/8/23 6:20:29 と解釈されている。これも公式の説明に
str; the following formats are accepted: YYYY-MM-DD[T]HH:MM[:SS[.ffffff]][Z or [±]HH[:]MM] int or float as a string (assumed as Unix time)
と記述されていて、20240429というint値、unix時間に解釈された結果らしい。
何を弾いてくれるのだろうか・・・?
datetimeは追加の妥当性確認の実装が必要なのだろうという結論にしておく。

if __name__=='__main__':
    j = {
        'id':1,
        'name': 'ore',
        'signup_ts':'',    # datetimeフィールドに空文字列を渡す
        'tastes':{
            'score1': 50,
            'score2': 70
            }
        }
    u = User(**j)
    print(u)
例外が発生しました: ValidationError
1 validation error for User
signup_ts
  Input should be a valid datetime or date, input is too short [type=datetime_from_date_parsing, input_value='', input_type=str]
    For further information visit https://errors.pydantic.dev/2.7/v/datetime_from_date_parsing
  File "/home/user1/ex1.py", line 29, in <module>
    u = User(**j)
        ^^^^^^^^^
pydantic_core._pydantic_core.ValidationError: 1 validation error for User
signup_ts
  Input should be a valid datetime or date, input is too short [type=datetime_from_date_parsing, input_value='', input_type=str]
    For further information visit https://errors.pydantic.dev/2.7/v/datetime_from_date_parsing

さすがに空文字列渡すとオブジェクトの作成を防いでくれた。

dictフィールドにtuple値を渡すと validation error になる

if __name__=='__main__':
    j = {
        'id':1,
        'name': 'ore',
        'signup_ts':'2024-04-29',
        'tastes':('score1', 20)    # dictフィールドにtuple値を渡す
        }
    u = User(**j)
    print(u)
例外が発生しました: ValidationError
1 validation error for User
tastes
  Input should be a valid dictionary [type=dict_type, input_value=('score1', 20), input_type=tuple]
    For further information visit https://errors.pydantic.dev/2.7/v/dict_type
  File "/home/user1/ex1.py", line 26, in <module>
    u = User(**j)
        ^^^^^^^^^
pydantic_core._pydantic_core.ValidationError: 1 validation error for User
tastes
  Input should be a valid dictionary [type=dict_type, input_value=('score1', 20), input_type=tuple]
    For further information visit https://errors.pydantic.dev/2.7/v/dict_type

Pydantic.BaseModel派生クラスにバリデーションの追加設定を行う

とりあえず

  • idは1から100
  • nameはa-zA-Z開始のa-zA-Z0-9かつ1〜10文字
  • signup_tsは2024-04-29から2034-03-31かつ'YYYY-MM-DD'形式の文字列
  • tastesのkeyはa-zA-Z0-9で文字数は2文字から8文字の範囲、valueは0から100、Noneを許容

のようなルール設定をしてみる。

idは1から100

バリデーションの設定を追加しない場合だと

class User(BaseModel):
    # before
    id: int

追加すると

class User2(BaseModel):
    # after
    id: Annotated[int, Gt(0), Le(100)]

Annotatedは"注釈付き"と翻訳される。リスト形式で定義するようだ。[型, ルール1, ルール2,...]の形式っぽい。
Gtは Greater than の略称で"〜より大きい"なので0より大きい、つまり1以上、Ge(1)と等しくなる。
Geは Greater than or equal to の略称で"〜以上"。
Leは Less than or equal to の略称で"〜以下"。Le(100)は100以下という表現。
Ltは Less than or equal to の略称で"〜より小さい"。

動作は下記パターンを確認する。

  • 0 ※境界外
  • 1 ※許容最小値
  • 100 ※許容最大値
  • 101 ※境界外
  • 0.5 ※float
  • 'No.1' ※str

nameはa-zA-Z開始のa-zA-Z0-9かつ1〜10文字

アルファベットと数字のみで名前を構成したい。数字は2世とか名前で利用されてたりするかと思ったので追加。先頭に数字が来てはいけない。空白は簡単にしたいので拒否。
正規表現でルール設定できる。そして2種類の正規表現エンジンが用意されている。デフォルトのrust-regexは高速だがすべての正規表現をカバーできるわけではなく、python-reは正規表現をすべてサポートしているものの低速。デフォルトを使う。

class User(BaseModel):
    # before
    name: str = 'John Doe'
from pydantic import ConfigDict, Field
class User2(BaseModel):
    # after
    model_config = ConfigDict(regex_engine='rust-regex')
    name: str = Field(pattern=r'^[a-zA-Z]+[a-zA-Z0-9]*$', min_length=1, max_length=10)

model_configが何なのかはわかっていないものの、公式ドキュメントでは正規表現エンジンの説明で旧版のエンジンを指定するために登場していた。デフォルトの'rust-regex'を使う分には不要なコードなのかもしれない。
idでは型指定の部分をAnnotated[]に置き換えていたがこちらはstr = Field()となっている。気にはなるものの既に予想していたメモ時間を大幅に超えてしまっているため疑問を放置。正規表現パターンに文字数を含めることもできるのだろうが可能な限り簡単にしたいのでmin_lengthとmax_lengthで文字数の範囲を指定。
動作は下記パターンを確認する。

  • 'Tom'
  • 'Smith2' ※数字を含む
  • 'X' ※最小文字数
  • 'abcdefghij' ※最大文字数
  • '' ※空文字 ※過小文字数
  • 'abcdefghijk' ※文字数オーバー
  • '0Tom' ※先頭に数字
  • '(>_<)' ※許容範囲外文字
  • ' ' ※空白、許容範囲外文字
  • 0 ※数値、型エラー
  • 'SmithⅡ' ※全角含む文字列 ※※ChatGPT曰くこれでスミスⅡ世を表現するらしい

signup_tsは2024-04-29から2034-03-31かつ'YYYY-MM-DD'形式の文字列

class User(BaseModel):
    # before
    signup_ts: datetime | None
from pydantic import field_validator
class User2(BaseModel):
    # after
    signup_ts: datetime | None

    @field_validator("signup_ts", mode='after')
    @classmethod
    def ensure_date_range(cls, v):
        if not (datetime(year=2024,month=4,day=29) <= v <= datetime(year=2034,month=3,day=31)):
            raise ValueError('signup_ts must be in range.')
        return v
    @field_validator("signup_ts", mode='before')
    @classmethod
    def ensure_date_type(cls, v):
        if not isinstance(v, str):
            raise TypeError('signup_ts must be str.')
        if v.count('-') != 2:
            raise ValueError('signup_ts must contains \'-\'')
        return v

今回行うdatetimeのバリデーションでは確認処理用のメソッドを用意。この方法以外にもあるのだろうか?シンプルな方が好ましいもののまあ目的が達成できれば最善でなくともよろしいこととする。
field_validatorの最初に確認対象のフィールドの名前を指定し、modeに確認タイミングを表す文字列を指定する。Literal['before'|'after'|'plain']の選択肢が用意されているが'plain'とは一体どのタイミングで動作するmodeなのか?'before'はフィールドに値が設定される前に呼ばれ、vに渡された値が設定される。'after'はフィールドに設定後に呼ばれる。デフォルトの挙動から文字列以外、intやfloatが渡されたときはエラーにするふるまいは'before'に実装し、年月日の範囲設定は'after'でフィールド更新後?に確認する。これらmodeのふるまいは動かしてみた経験からの説明であり公式ドキュメント上では厳密には違うことが記述されているのかもしれない。
動作確認パターンは

  • '2024-04-29' ※範囲内、許容限界古
  • '2028-09-30'
  • '2034-03-31' ※範囲内、許容限界新
  • '2024-04-28' ※範囲外、古い
  • '2034-04-01' ※範囲外、新しい
  • '20340301' ※フォーマットエラー
  • 20340301 ※型エラー

tastesのkeyはa-zA-Z0-9で文字数は2文字から8文字の範囲、valueは0から100、Noneを許容

class User(BaseModel):
    # before
    tastes: dict[str, PositiveInt]
from pydantic import ConfigDict, Field, PositiveInt
from annotated_types import Ge, Le
class User2(BaseModel):
    # after
    model_config = ConfigDict(regex_engine='rust-regex')
    tastes: Dict[
        Annotated[str, Field(pattern=r'^[a-zA-Z]+[a-zA-Z0-9]*$', min_length=2, max_length=8)],
        Annotated[int, Ge(0), Le(100)]] | None

dictをDictにする必要性に疑問。dictのままだとまずいのだろうか?
PositiveIntは An integer that must be greater than zero. らしく、0を許容しないので値型の型をintに変更する。
Dict[key type, value type]なのでkeyのルール設定、valueのルール設定をここに含める。これが分かれば後は前述のid, nameで利用したルールのコピペ。
動作確認パターンは

  • {'score1': 50}
  • {'score1': 50, 'score2': 60} ※kvペア2要素
  • {'s1': 50} ※key2文字 最小文字数
  • {'score100': 50} ※key8文字 最大文字数
  • {'score1': 0} ※値の最小許容範囲
  • {'score1': 100} ※値の最大許容範囲
  • None ※None許容
  • {'s': 50} ※key1文字 最小許容範囲外
  • {'score1000': 50} ※key9文字 最大許容範囲外
  • {'score1': -1} ※値の最小許容範囲外
  • {'score1': 101} ※値の最大許容範囲外
  • {'0score1': 50} ※key名称の先頭が数字
  • {'scre-': 50} ※key名称に許容範囲外文字が含まれる

バリデーションの追加設定を行ったクラスの最終形と動作確認コード

class User2(BaseModel):
    model_config = ConfigDict(regex_engine='rust-regex')
    id: Annotated[int, Gt(0), Le(100)]
    name: str = Field(pattern=r'^[a-zA-Z]+[a-zA-Z0-9]*$', min_length=1, max_length=10)
    signup_ts: datetime | None
    tastes: Dict[
        Annotated[str, Field(pattern=r'^[a-zA-Z]+[a-zA-Z0-9]*$', min_length=2, max_length=8)],
        Annotated[int, Ge(0), Le(100)]] | None

    @field_validator("signup_ts", mode='after')
    @classmethod
    def ensure_date_range(cls, v):
        if not (datetime(year=2024,month=4,day=29) <= v <= datetime(year=2034,month=3,day=31)):
            raise ValueError('signup_ts must be in range.')
        return v
    @field_validator("signup_ts", mode='before')
    @classmethod
    def ensure_date_type(cls, v):
        if not isinstance(v, str):
            raise TypeError('signup_ts must be str.')
        if v.count('-') != 2:
            raise ValueError('signup_ts must contains \'-\'')
        return v

ValueErrorの中のメッセージの英文はやっつけなので文法精度は不明。

動作確認コード

import unittest
class TestUser2Validation(unittest.TestCase):
    def make_args(self, id=1, name='ore', signup_ts='2024-04-29', tastes={'score1': 50, 'score2':70}):
        return {
            'id':id,
            'name': name,
            'signup_ts': signup_ts,
            'tastes': tastes
            }
    ''' id.正常値テスト'''
    def test_id_1(self):
        j = self.make_args(id=1)
        u = User2(**j)
        self.assertEqual(j['id'], u.id)
    def test_id_100(self):
        j = self.make_args(id=100)
        u = User2(**j)
        self.assertEqual(j['id'], u.id)
    ''' id.境界値テスト'''
    def test_id_0(self):
        j = self.make_args(id=0)
        with self.assertRaises(ValueError):
            u = User2(**j)
    def test_id_101(self):
        j = self.make_args(id=101)
        with self.assertRaises(ValueError):
            u = User2(**j)
    ''' id.異常値テスト'''
    def test_id_float(self):
        j = self.make_args(id=0.5)
        with self.assertRaises(ValueError):
            u = User2(**j)
    def test_id_str(self):
        j = self.make_args(id='No.1')
        with self.assertRaises(ValueError):
            u = User2(**j)
    ''' name.正常値テスト'''
    def test_name_Tom(self):
        j = self.make_args(name='Tom')
        u = User2(**j)
        self.assertEqual(j['name'], u.name)
    def test_name_Smith_2(self):
        j = self.make_args(name='Smith2')
        u = User2(**j)
        self.assertEqual(j['name'], u.name)
    def test_name_shortest(self):
        j = self.make_args(name='X')
        u = User2(**j)
        self.assertEqual(j['name'], u.name)
    def test_name_longest(self):
        j = self.make_args(name='abcdefghij')
        u = User2(**j)
        self.assertEqual(j['name'], u.name)
    ''' name.境界値テスト'''
    def test_name_too_short(self):
        j = self.make_args(name='')
        with self.assertRaises(ValueError):
            u = User2(**j)
    def test_name_too_long(self):
        j = self.make_args(name='abcdefghijk')
        with self.assertRaises(ValueError):
            u = User2(**j)
    ''' name.異常値テスト'''
    def test_name_0Tom(self):
        j = self.make_args(name='0Tom')
        with self.assertRaises(ValueError):
            u = User2(**j)
    def test_name_some_mark(self):
        j = self.make_args(name='(>_<)')
        with self.assertRaises(ValueError):
            u = User2(**j)
    def test_name_space(self):
        j = self.make_args(name=' ')
        with self.assertRaises(ValueError):
            u = User2(**j)
    def test_name_num(self):
        j = self.make_args(name=0)
        with self.assertRaises(ValueError):
            u = User2(**j)
    def test_name_empty(self):
        j = self.make_args(name='')
        with self.assertRaises(ValueError):
            u = User2(**j)
    def test_name_Smith_Ⅱ(self):
        j = self.make_args(name='SmithⅡ')
        with self.assertRaises(ValueError):
            u = User2(**j)
    ''' signup_ts.正常値テスト'''
    def test_signup_ts_s20240429(self):
        j = self.make_args(signup_ts='2024-04-29')
        u = User2(**j)
        self.assertEqual(j['signup_ts'], u.signup_ts.strftime('%Y-%m-%d'))
    def test_signup_ts_s20280930(self):
        j = self.make_args(signup_ts='2028-09-30')
        u = User2(**j)
        self.assertEqual(j['signup_ts'], u.signup_ts.strftime('%Y-%m-%d'))
    def test_signup_ts_s20340331(self):
        j = self.make_args(signup_ts='2034-03-31')
        u = User2(**j)
        self.assertEqual(j['signup_ts'], u.signup_ts.strftime('%Y-%m-%d'))
    ''' signup_ts.境界値テスト'''
    def test_signup_ts_s20240428_range_too_old_ng(self):
        j = self.make_args(signup_ts='2024-04-28')
        with self.assertRaises(ValueError):
            u = User2(**j)
    def test_signup_ts_s20340401_range_too_new_ng(self):
        j = self.make_args(signup_ts='2034-04-01')
        with self.assertRaises(ValueError):
            u = User2(**j)
    ''' signup_ts.異常値テスト'''
    def test_signup_ts_invalid_format_ng(self):
        j = self.make_args(signup_ts='20340301')
        with self.assertRaises(ValueError):
            u = User2(**j)
    def test_signup_ts_int_ng(self):
        j = self.make_args(signup_ts=20340301)
        with self.assertRaises(TypeError):
            u = User2(**j)
    ''' tastes.正常値テスト'''
    def test_tastes_n1(self):
        j = self.make_args(tastes={'score1': 50})
        u = User2(**j)
        self.assertEqual(j['tastes'], u.tastes)
    def test_tastes_n2(self):
        j = self.make_args(tastes={'score1': 50, 'score2': 60})
        u = User2(**j)
        self.assertEqual(j['tastes'], u.tastes)
    def test_tastes_len2(self):
        j = self.make_args(tastes={'s1': 50})
        u = User2(**j)
        self.assertEqual(j['tastes'], u.tastes)
    def test_tastes_len8(self):
        j = self.make_args(tastes={'score100': 50})
        u = User2(**j)
        self.assertEqual(j['tastes'], u.tastes)
    def test_tastes_0(self):
        j = self.make_args(tastes={'score1': 0})
        u = User2(**j)
        self.assertEqual(j['tastes'], u.tastes)
    def test_tastes_100(self):
        j = self.make_args(tastes={'score1': 100})
        u = User2(**j)
        self.assertEqual(j['tastes'], u.tastes)
    def test_tastes_None(self):
        j = self.make_args(tastes=None)
        u = User2(**j)
        self.assertEqual(j['tastes'], u.tastes)
    ''' tastes.境界値テスト'''
    def test_tastes_len1_too_short(self):
        j = self.make_args(tastes={'s': 50})
        with self.assertRaises(ValueError):
            u = User2(**j)
    def test_tastes_len9_too_long(self):
        j = self.make_args(tastes={'score1000': 50})
        with self.assertRaises(ValueError):
            u = User2(**j)
    def test_tastes_val_too_small_ng(self):
        j = self.make_args(tastes={'score1': -1})
        with self.assertRaises(ValueError):
            u = User2(**j)
    def test_tastes_val_too_big_ng(self):
        j = self.make_args(tastes={'score1': 101})
        with self.assertRaises(ValueError):
            u = User2(**j)
    ''' tastes.異常値テスト'''
    def test_tastes_keyname_ng(self):
        j = self.make_args(tastes={'0score1': 50})
        with self.assertRaises(ValueError):
            u = User2(**j)
    def test_tastes_keyname_ng2(self):
        j = self.make_args(tastes={'scre-': 50})
        with self.assertRaises(ValueError):
            u = User2(**j)
if __name__=='__main__':
    unittest.main()

実行すると

.......................................
----------------------------------------------------------------------
Ran 38 tests in 0.004s

OK

境界値テストの定義が合っているのかは不安が残る。ものの、誰が正解を決めているのか?とも思った。

まとめ

自分で利用する分には今回取り上げた範囲で問題が無いと思う。というか何日間も検索しては放り投げるを繰り返していたというのに、公開することを意識して調べれば理解が進むということが予想外の収穫。つまみ食い調査は効率悪いようだ。だがしかし本来やりたいことのついででやるには必要な作業量が多過ぎる気もする。ライブラリやフレームワークなんておまけなんだからもっとチュートリアルでどんなことができるのかぱっと見でわかるようになっていてほしいんだよな…。ってOSSカテゴリにそれは要求が重すぎるか…。
ところでドメイン・プリミティブの妥当性確認を楽にするための学習として作ったこのクラス、どう見てもドメイン・プリミティブからはかけ離れている。適用するとしたらフィールドごとにクラス作るべきだったろうか。いやさすがに時間がかかり過ぎるのでそこまでは凝らない。ライブラリの使い方を学ぶことが設定したゴールだしよくわかってないものは取り込まないでおこう、そうしよう。

github

https://github.com/rifumi/pydantic_my_tutorial/blob/main/first_of_all.py

Discussion