Python Monthly Topics

データ検証ライブラリPydanticの紹介

寺田 学@terapyonです。2025年10月の「Python Monthly Topics」は、データ検証ライブラリのPydanticを紹介します。

型安全とデータ構造

主題のPydanticの説明に入る前に、Pythonにおける型安全の考え方とデータ構造についておさらいしておきます。

型安全のための型ヒント

Pythonは動的型付け言語です。型を宣言せずにコーディングすることができますが、型ヒントを書くことで型安全にコーディングできます。最近のPythonコードには型ヒントが書かれていることが多くなっているかと思います。

本連載(Python Monthly Topics)でも過去に型ヒント関係のトピックを扱っていますので、参照してください。

私自身は型ヒントによって、より型を意識したコーディングスタイルに変化しています。旧来、関数やメソッドの引数や戻り値にタプルや辞書を使うことが多くありましたが、厳密に型を付けるために、TypedDictやdataclassを活用することが増えています。

型が厳密になっていることで、IDEなどでの補完が充実したり、エラーの早期発見につながることも多くあります。

データ構造

内部の状態を管理するには、リストと辞書を組み合わせることで多くのデータ構造を表すことが可能です。しかし、自由な構造で表現すると、前項で示した型安全なコーディングができません。さらに、関数の引数で受け取ったデータを毎回検証するという必要性が出てきます。

ここでは、以下のような支店名とスタッフを表すデータを考えていきます。

支店名とスタッフの辞書
branch = {
  "branch_name": "東京",
  "staff": [
      {"number": 1, "name": "佐藤", "is_leader": True, "leader_period": 3},
      {"number": 5, "name": "田中"},
      {"number": None, "name": "山本"},
  ],
}

注目すべきは、staffの中には各スタッフを表す辞書がリストの中に入っている点です。スタッフの辞書に、numberNoneが入っていたり、is_leaderキーやleader_periodキーがない場合があります。

このデータを受け取るときは、以下のように値を確認したり、キーが存在するかを確認したりする必要があります。

データの値やキーの存在を確認するコード例
def get_user_attr(branch):
    for staff in branch["staff"]:
        number = staff["number"]
        if number is None:  # Noneの場合の条件を追加
            continue
        is_leader = staff.get("is_leader", False)  # キーかあるかどうかを確認してデフォルト値を設定
    ...

このコードを型安全にするには、TypedDictを用いることができます。詳細は、先にも紹介した「2022年9月:Python最新バージョン対応!より良い型ヒントの書き方を参照してください。

しかし、TypedDictはデータの値やキーを保証していません。あくまでも、型ヒントのための宣言だからです。TypedDictで宣言していないキーを追加することや、違うデータ型を値として入れることもできます。

そこで、データ構造を明確にするための機能としてdataclassがあります。

先ほどのbranch辞書をdataclassで表現します。

dataclassで支店名とスタッフモデル化
from dataclasses import dataclass

@dataclass
class Staff:
    """スタッフ"""
    number: int | None  # 社員番号
    name: str  # 社員名
    is_leader: bool = False  # リーダー
    leader_period: int = 0  # リーダー経験

@dataclass
class Branch:
    """支店"""
    branch_name: str  # 支店名
    staff: list[Staff]  # 社員リスト

このBranchクラスをインスタンス化して使うことで、データを使う時に属性の存在を確認する必要がなくなります。ただ、データの値については確認していません。インスタンス化する時に確認するか、dataclass内にバリデーションのコードを追記する必要があります。

このようなデータの値を検証するといった課題に対して、Pydanticを使うと簡潔にデータ検証機能が組み込めます。

Pydanticとは

Pydanticは、データ検証用のパッケージです。

PydanticはFastAPIのデータ検証で利用されていることで注目を浴びるようになりました。また、Django NinjaというDjango用のRESTフレームワークでも使われています。Pydanticは、これらのフレームワークから独立したPythonのパッケージですので、他のフレームワークで利用することや、独自のスクリプトなどでも利用可能です。

PydanticのBaseModelを継承したクラスを書くことで、データの検証されたオブジェクトを作ることができます。dataclassのように型ヒント付きのクラス属性を定義することで、インスタンス化する際にデータ検証が行われます。検証に失敗すると、ValidationErrorという専用のエラーが送出され、エラーオブジェクトの中にエラーの詳細が格納されています。

Pydanticは、独自の検証スクリプトを通さずにオブジェクトが安全に作れることが一番うれしいところです。オブジェクトが作られれば、適切なデータ及びデータ型となっていることが保証されるため、データ受け渡し時の条件分岐などによるハンドリングが不要になります。また、検証内容が、Pythonのコードとしてわかりやすく表現されることも良い点だと思います。

Pydanticの基本的な使い方

Pydanticを使うにはpipコマンドでインストールします。

Pydanticのインストール
$ pip install pydantic

先ほどのBranchクラスをPydanticに置き換えてみます。

Pydanticで支店名とスタッフをモデル化
from pydantic import BaseModel

class Staff(BaseModel):
    """スタッフ"""
    number: int | None
    name: str
    is_leader: bool = False
    leader_period: int = 0

class Branch(BaseModel):
    """支店"""
    branch_name: str
    staff: list[Staff]

dataclassとほぼ同じコードになります。違いはデコレータでの宣言ではなく、BaseModelを継承する点です。

ここからは、JSONファイルを入力に使いデータの検証を行います。データ構造の項で利用した、支店名とスタッフを表す辞書をJSON形式にしたものを準備します。このJSONファイルを読み込んで確認をしてみましょう。

支店名とスタッフのJSON
{
  "branch_name": "東京",
  "staff": [
    { "number": 1, "name": "佐藤", "is_leader": true, "leader_period": 3 },
    { "number": 5, "name": "田中" },
    { "number": null, "name": "山本" }
  ]
}

JSONファイルを使ってBranchインスタンスを作り、生成されたオブジェクトをprint()関数で確認します。

JSONからPydanticインスタンス化するコード
import json
from model import Branch  # 先ほど定義した Branch クラスをインポート

with open("branch.json", "r", encoding="utf-8") as f:
    data = json.load(f)
    branch = Branch(**data)

print(branch)
# branch_name='東京' staff=[
#  Staff(number=1, name='佐藤', is_leader=True, leader_period=3),
#  Staff(number=5, name='田中', is_leader=False, leader_period=0),
#  Staff(number=None, name='山本', is_leader=False, leader_period=0)]

Branchクラスのインスタンスとしてbranchオブジェクトができたことがわかりました。

次に以下のように、想定しないデータ型や必要な属性がない場合の検証をしてみます。

支店名とスタッフで想定外のJSON
{
  "branch_name": 10,
  "staff": [
    { "number": 1, "name": "佐藤", "is_leader": null, "leader_period": 3 },
    { "number": 5, "name": "田中" },
    { "name": "山本" }
  ]
}

このJSONファイルには3つの間違いがあります。

  • branch_nameが数値になっている
  • is_leaderがnull(None)になっている
  • 3番目のstaffにnumberキーがない
想定外のJSONからPydanticインスタンス化しエラーを表示するコード
import json
from pydantic import ValidationError
from model import Branch  # 先ほど定義した Branch クラスをインポート

with open("branch-error.json", "r", encoding="utf-8") as f:
    data = json.load(f)
    try:
        branch = Branch(**data)
    except ValidationError as e:
        print(e.errors())

PydanticのモデルであるBranchをインスタンス化する時に、データ検証が動作します。データ検証エラーになるとValidationErrorが送出されます。このエラーオブジェクトに.errors()メソッドがあり、メソッドを実行すると以下のようなリストの辞書が出力されます。

.errors()メソッドの結果は以下のようになりました。

Pydanticのエラー出力
[{'input': 10,
  'loc': ('branch_name',),
  'msg': 'Input should be a valid string',
  'type': 'string_type',
  'url': 'https://errors.pydantic.dev/2.12/v/string_type'},
 {'input': None,
  'loc': ('staff', 0, 'is_leader'),
  'msg': 'Input should be a valid boolean',
  'type': 'bool_type',
  'url': 'https://errors.pydantic.dev/2.12/v/bool_type'},
 {'input': {'name': '山本'},
  'loc': ('staff', 2, 'number'),
  'msg': 'Field required',
  'type': 'missing',
  'url': 'https://errors.pydantic.dev/2.12/v/missing'}]

3つのエラーが出ていることがわかりました。

1つ目は、'loc': ('branch_name',)とありますので、branch_nameに関するエラーで、input10となっていて、msgにあるように文字列型が必要となっています。

2つ目は、'loc': ('staff', 0, 'is_leader')となっていて、staff属性のインデックス0is_leaderに関するエラーで、inputNoneであることがわかります。

3つ目も同様にlocmsginputを確認することで詳細がわかります。

このようにPydanticは全データを検証し、検証結果をわかりやすく出力してくれます。

データ形式をより具体的にする

PydanticのField()関数を使って属性値をより具体的に宣言し、データ検証することができます。

たとえば以下のようなことが可能です。

  • 必須かどうか
  • デフォルト値の指定
  • 数値の範囲
  • 属性のタイトルを付記

引き続きBranchクラスを変更していきます。今回の変更に合わせて、branch_nameを列挙型で候補を宣言しデータ検証をあわせて行います。

PydanticのFieldでより具体的に検証可能にするコード
import enum

from pydantic import Field

class BRANCH_NAMES(str, enum.Enum):  # 列挙型(enum)を使って宣言
    """支店名の列挙型"""
    TOKYO = "東京"
    OSAKA = "大阪"
    FUKUOKA = "福岡"
    SAPPORO = "札幌"

class Staff(BaseModel):
    """スタッフ"""
    number: int | None = Field(..., title="社員番号")
    name: str = Field(..., title="社員名")
    is_leader: bool = Field(False, title="リーダー")
    leader_period: int = Field(0, title="リーダー経験", ge=0, lt=20)

class Branch(BaseModel):
    """支店"""
    branch_name: BRANCH_NAMES = Field(..., title="支店名")
    staff: list[Staff] = Field([], title="社員リスト")

変更したコードについて説明します。

  • Field()関数の第1引数はデフォルト値です。
  • ...False0を指定しています。
  • ...は、エリプシスというPythonの省略を表す定数を指定しています。PydanticのField()関数にエリプシスを渡すことで必須を示します。
  • titleキーワード引数は、属性を表すタイトルです。
  • ge、ltキーワード引数は、数値などの範囲を示すことができます。geは以上、ltは未満を示します。
  • branch_nameのデータ型は、strから列挙型で宣言したBRANCH_NAMESに変更しました。

続いて、以下のJSONファイルを検証してみます。

検証エラーになるJSON
{
  "branch_name": "広島",
  "staff": [
    { "number": 1, "name": "佐藤", "is_leader": null, "leader_period": 35 },
    { "number": 5, "name": "田中" },
    { "name": "山本" }
  ]
}
検証エラーになるJSONからPydanticインスタンス化しエラーを表示するコード
import json
from pydantic import ValidationError
from model import Branch  # 先ほど定義した Branch クラスをインポート

with open("branch-error2.json", "r", encoding="utf-8") as f:
    data = json.load(f)
    try:
        branch = Branch(**data)
    except ValidationError as e:
        print(e.errors())

実行の結果は次のとおりです。

検証エラーの出力
[{'ctx': {'expected': "'東京', '大阪', '福岡' or '札幌'"},
  'input': '広島',
  'loc': ('branch_name',),
  'msg': "Input should be '東京', '大阪', '福岡' or '札幌'",
  'type': 'enum',
  'url': 'https://errors.pydantic.dev/2.12/v/enum'},
 {'input': None,
  'loc': ('staff', 0, 'is_leader'),
  'msg': 'Input should be a valid boolean',
  'type': 'bool_type',
  'url': 'https://errors.pydantic.dev/2.12/v/bool_type'},
 {'ctx': {'lt': 20},
  'input': 35,
  'loc': ('staff', 0, 'leader_period'),
  'msg': 'Input should be less than 20',
  'type': 'less_than',
  'url': 'https://errors.pydantic.dev/2.12/v/less_than'},
 {'input': {'name': '山本'},
  'loc': ('staff', 2, 'number'),
  'msg': 'Field required',
  'type': 'missing',
  'url': 'https://errors.pydantic.dev/2.12/v/missing'}]

エラーメッセージの確認方法は先ほどとほぼ同様です。今回はデータ検証でより詳細に確認している項目で、ctx(追加情報:コンテキスト)が加わっています。

  • 'loc': ('branch_name',)は、列挙型で4つの文字列以外を受け付けないようにしたので、エラーになっていることがわかります。
  • 'loc': ('staff', 0, 'leader_period')は、0以上20未満と指定しているので、エラーとなっています。

詳細な検証とデータ変換

データの検証には、単一の属性に対してデータ型や値が条件にあっているのかをチェックするものと、複数の属性にまたがって条件をチェックするものがあります。

ここでは、開始時間(start_time)と終了時間(end_time)を追加したJSONを用い、最初に単一属性に対する検証を解説し、続いて複数の属性にまたがる検証を説明します。

開始時間と終了時間を追加したJSON
{
  "branch_name": "東京",
  "staff": [
    {
      "number": 1,
      "name": "佐藤花子",
      "is_leader": true,
      "leader_period": 3,
      "start_time": "9:00",
      "end_time": "17:15"
    },
    {
      "number": 5,
      "name": "田中太郎",
      "start_time": "12:00",
      "end_time": "18:00"
    },
    {
      "number": 8,
      "name": "山本次郎",
      "start_time": "8:00",
      "end_time": "19:00"
    }
  ]
}

検証の条件は以下のように設定します。

  • 開始時間(start_time)と終了時間(end_time)のフォーマットはH:MM:区切りの数値であること
  • それぞれの時間は30分単位とする
  • 開始時間は8:00から11:00の間であること
  • 終了時間は15:00から20:00の間であること
  • 開始時間と終了時間の間隔は9時間以内であること

さらに、データ検証が通過した場合は、2つの時間をdatetime.timeオブジェクトに変換します。

これらの項目のうち、⁠開始時間と終了時間は最大9時間」という条件は、開始時間と終了時間の2つの属性にまたがる検証になります。その他は単一の属性での検証になります。

単一の属性の検証とデータ変換

検証の関数を宣言
import datetime
from typing import Any

def _valid_half_time(v: Any) -> datetime.time:
    """30分単位の時間を検証する共通関数"""
    if isinstance(v, datetime.time):  # datetime.time型の場合
        time_ = v  # 変換せずにそのまま使う
    elif isinstance(v, str):  # 文字列型の場合
        try:
            h, m = v.split(":")  # ':'で分割
        except ValueError:
            raise ValueError(f"{v}は ':' が1つで区切られていません")
        try:
            time_ = datetime.time(int(h), int(m))  # 24時間制の時間に変換
        except ValueError:
            raise ValueError(f"{v}を24時間制の時間に変換できません")
    else:
        raise ValueError(f"{v}のデータ型不正です: {type(v)}")
    # 30分単位でない場合はエラー
    if time_.minute not in (0, 30) or time_.second or time_.microsecond:
        raise ValueError(f"{v}は30分単位の時間ではありません")
    return time_

def valid_start_time(v: Any) -> datetime.time:
    """開始時間の範囲を検証する関数"""
    time_ = _valid_half_time(v)
    # 開始時間の範囲を検証
    if not datetime.time(8, 0) <= time_ <= datetime.time(11, 0):
        raise ValueError(f"{v}は指定された開始時間の範囲ではありません")
    return time_

def valid_end_time(v: Any) -> datetime.time:
    """終了時間の範囲を検証する関数"""
    time_ = _valid_half_time(v)
    # 終了時間の範囲を検証
    if not datetime.time(15, 0) <= time_ <= datetime.time(20, 0):
        raise ValueError(f"{v}は指定された終了時間の範囲ではありません")
    return time_

_valid_half_time()関数は、30分単位の時間であることを確認するための関数です。開始時間と終了時間が同じ条件で検証するために、共通で使う関数として宣言しています。

データを検証する際にどのようなデータ型が渡されてくるかわからないので、Anyとしてどのようなデータ型も受け入れるようにしています。JSONからデータが来る場合には文字列型を想定していますが、Python内部で利用する際にdatetime.time型で渡ってくる場合も想定して、isinstanceでデータ型を確認して内部で挙動を変えています。また、strまたはdatetime.time以外の場合にはValueErrorとしています。

valid_start_time()関数とvalid_end_time()関数は、許可されている時間の範囲が違うので個別に関数を宣言しています。

これらの関数を検証に使うために、start_timeend_timeにAnnotatedで注釈を付けています。

検証の関数をPydanticに組み込むコード
from typing import Annotated
from pydantic import BeforeValidator

class Staff(BaseModel):
    """スタッフ"""
    number: int | None = Field(..., title="社員番号")
    name: str = Field(..., title="社員名")
    is_leader: bool = Field(False, title="リーダー")
    leader_period: int = Field(0, title="リーダー経験", ge=0, lt=20)
    start_time: Annotated[datetime.time, BeforeValidator(valid_start_time)]
    end_time: Annotated[datetime.time, BeforeValidator(valid_end_time)]

ここでは、Annotatedで、データ型と検証の方法を指定しました。

BeforeValidatorは、データ生成前に検証することを示しています。

単一属性に対する動作の確認のために、JSONファイルを検証します。

データ検証のコード
import json
from pydantic import ValidationError
from model import Branch  # 先ほど定義した Branch クラスをインポート

with open("branch2.json", "r", encoding="utf-8") as f:
    data = json.load(f)
    try:
        branch = Branch(**data)
    except ValidationError as e:
        print(e.errors())
検証エラーの出力
[{'ctx': {'error': ValueError('17:15は30分単位の時間ではありません')},
  'input': '17:15',
  'loc': ('staff', 0, 'end_time'),
  'msg': 'Value error, 17:15は30分単位の時間ではありません',
  'type': 'value_error',
  'url': 'https://errors.pydantic.dev/2.12/v/value_error'},
 {'ctx': {'error': ValueError('12:00は指定された開始時間の範囲ではありません')},
  'input': '12:00',
  'loc': ('staff', 1, 'start_time'),
  'msg': 'Value error, 12:00は指定された開始時間の範囲ではありません',
  'type': 'value_error',
  'url': 'https://errors.pydantic.dev/2.12/v/value_error'}]

17:15となっている部分が30分単位でないこと、start_timeが12:00となっている部分が時間の範囲でないことの検証ができています。

複数の属性にまたがる検証

ここからは、最後の条件である、start_timeとend_timeが9時間を超えていないことを検証します。

複数属性の検証コード
from typing import Self

from pydantic import model_validator

class Staff(BaseModel):
    """スタッフ"""
    number: int | None = Field(..., title="社員番号")
    name: str = Field(..., title="社員名")
    is_leader: bool = Field(False, title="リーダー")
    leader_period: int = Field(0, title="リーダー経験", ge=0, lt=20)
    start_time: Annotated[datetime.time, BeforeValidator(valid_start_time)]
    end_time: Annotated[datetime.time, BeforeValidator(valid_end_time)]

    @model_validator(mode="after")
    def check_duration(self) -> Self:
        """時間間隔を検証"""
        today = datetime.datetime.today().date()
        # 開始時間と終了時間を比較するために datetime.datetime型に変換
        start_dt = datetime.datetime.combine(today, self.start_time)
        end_dt = datetime.datetime.combine(today, self.end_time)
        # 9時間を超えていないかを確認
        if (end_dt - start_dt) > datetime.timedelta(hours=9):
            raise ValueError("開始時間と終了時間が9時間を超えています")
        return self  # 検証に通過した場合は、インスタンスを返す

インスタンスメソッドcheck_duration()を宣言します。複数の属性にまたがるデータ検証を行いたい場合は@model_validatorデコレータを使います。インスタンスができた後にデータ検証が実行されるように、mode="after"と指定しています。

検証の結果は以下のようになります。

複数属性の検証の結果
[{'ctx': {'error': ValueError('17:15は30分単位の時間ではありません')},
  'input': '17:15',
  'loc': ('staff', 0, 'end_time'),
  'msg': 'Value error, 17:15は30分単位の時間ではありません',
  'type': 'value_error',
  'url': 'https://errors.pydantic.dev/2.12/v/value_error'},
 {'ctx': {'error': ValueError('12:00は指定された開始時間の範囲ではありません')},
  'input': '12:00',
  'loc': ('staff', 1, 'start_time'),
  'msg': 'Value error, 12:00は指定された開始時間の範囲ではありません',
  'type': 'value_error',
  'url': 'https://errors.pydantic.dev/2.12/v/value_error'},
 {'ctx': {'error': ValueError('開始時間と終了時間が9時間を超えています')},
  'input': {'end_time': '19:00',
            'name': '山本次郎',
            'number': 8,
            'start_time': '8:00'},
  'loc': ('staff', 2),
  'msg': 'Value error, 開始時間と終了時間が9時間を超えています',
  'type': 'value_error',
  'url': 'https://errors.pydantic.dev/2.12/v/value_error'}]

9時間を超えるパターンの検証ができました。

単一属性に関する検証や複数の属性にまたがる検証ともに、Pythonのコードで書いていますので、アイデア次第では複雑な検証や外部の検証ツールを使うことも可能になります。

補足情報

JSONSchemaからの変換

既存の実装で、JSONSchemaを用いた検証を実施している場合があると思います。その際にPydanticでのデータ検証に置き換えたい場合に、サードパーティ製ライブラリでJSONSchemaをPydanticのコードに変換することができます。

datamodel-code-generatorを使うと、さまざまな入力に対してPydanticなどのモデルファイルを生成できます。このツールの作者は、PyCon JP 2025でPython3.14の新機能の紹介の招待講演を行った青野高大さんです。

入力フォーマットは、OpenAPI 3、JSON Schema、JSON/YAML/CSV Data、Pythonの辞書、GraphQL schemaと、さまざまなものに対応しています。

出力フォーマットは、pydantic.BaseModel、pydantic_v2.BaseModel、dataclasses.dataclass、typing.TypedDict、msgspec.Structに対応し、さらに、jinja2 templateで宣言するカスタムタイプにも対応しています。

すでに存在するJSONSchemaなどの仕様や検証ツールを、Pythonのコードに置き換えることができます。それにより、Pythonで仕様と実装の一元管理ができ、Pythonでコーディングする際にIDEでの補完が充実し、型ヒントの恩恵が得られるという大きなメリットが得られます。詳細は公式ドキュメントを確認してください。

TypedDict/dataclassとの使い分け

今回はPydanticを紹介しましたが、データ構造やデータモデルを作るときに、TypedDictやdataclassを用いる方法もあります。

TypedDictは、辞書を使ったデータ構造に型ヒントを追加するというアプローチになります。また、dataclassは型ヒントを使ったデータモデルを作るものです。

Pythonの標準に取り込まれている機能になりますが、どちらの方法もデータ検証をサポートしていません。また、TypedDictはあくまで型ヒントを付与しているだけなので、キーの存在が保証されているものではありません。dataclassは属性が決まっているので、TypedDictに比べると構造が明確になっています。

ここでは、筆者の使い分けの基準を示します。

  • 辞書でデータ構造が存在している場合は、TypedDictで型ヒントを付与
  • 改造やデータ構造が複雑になる場合は、dataclassへの置き換えを検討
  • 新規にデータ構造を示すのであれば、dataclassでデータモデル化
  • Pythonコードの内部で利用する場合は、dataclassを利用
  • 外部(Web APIなど)からのデータを使う場合は、データ検証を重視しPydanticを採用

まとめ

今回は、Pydanticを紹介しました。Pydanticはデータ検証を担うサードパーティ製ライブラリです。型ヒントを使い、明確なデータ構造を示すことができます。内部で自動的にデータ変換が行われたりと便利な機能が備わっています。

また、データの検証だけはなく、データの構造を明確にすることができることから、Pythonコードをスッキリとスマートに書くことができるライブラリであると考えています。

みなさんもデータ検証やデータ構造の見直しにPydanticを使ってみてください。

最後に、今回のコードすべてを掲載しておきます。

実装コードの全体 branch_model.py
import datetime
import enum
from typing import Annotated, Any, Self

from pydantic import BaseModel, Field, BeforeValidator, model_validator


def _valid_half_time(v: Any) -> datetime.time:
    """30分単位の時間を検証する共通関数"""
    if isinstance(v, datetime.time):  # datetime.time型の場合
        time_ = v  # 変換せずにそのまま使う
    elif isinstance(v, str):  # 文字列型の場合
        try:
            h, m = v.split(":")  # ':'で分割
        except ValueError:
            raise ValueError(f"{v}は ':' が1つで区切られていません")
        try:
            time_ = datetime.time(int(h), int(m))  # 24時間制の時間に変換
        except ValueError:
            raise ValueError(f"{v}を24時間制の時間に変換できません")
    else:
        raise ValueError(f"{v}のデータ型不正です: {type(v)}")
    # 30分単位でない場合はエラー
    if time_.minute not in (0, 30) or time_.second or time_.microsecond:
        raise ValueError(f"{v}は30分単位の時間ではありません")
    return time_


def valid_start_time(v: Any) -> datetime.time:
    """開始時間の範囲を検証する関数"""
    time_ = _valid_half_time(v)
    # 開始時間の範囲を検証
    if not datetime.time(8, 0) <= time_ <= datetime.time(11, 0):
        raise ValueError(f"{v}は指定された開始時間の範囲ではありません")
    return time_


def valid_end_time(v: Any) -> datetime.time:
    """終了時間の範囲を検証する関数"""
    time_ = _valid_half_time(v)
    # 終了時間の範囲を検証
    if not datetime.time(15, 0) <= time_ <= datetime.time(20, 0):
        raise ValueError(f"{v}は指定された終了時間の範囲ではありません")
    return time_


class BRANCH_NAMES(str, enum.Enum):
    """支店名の列挙型"""

    TOKYO = "東京"
    OSAKA = "大阪"
    FUKUOKA = "福岡"
    SAPPORO = "札幌"


class Staff(BaseModel):
    """スタッフ"""

    number: int | None = Field(..., title="社員番号")
    name: str = Field(..., title="社員名")
    is_leader: bool = Field(False, title="リーダー")
    leader_period: int = Field(0, title="リーダー経験", ge=0, lt=20)
    start_time: Annotated[datetime.time, BeforeValidator(valid_start_time)]
    end_time: Annotated[datetime.time, BeforeValidator(valid_end_time)]

    @model_validator(mode="after")
    def check_duration(self) -> Self:
        """時間間隔を検証"""
        today = datetime.datetime.today().date()
        # 開始時間と終了時間を比較するために datetime.datetime型に変換
        start_dt = datetime.datetime.combine(today, self.start_time)
        end_dt = datetime.datetime.combine(today, self.end_time)
        # 9時間を超えていないかを確認
        if (end_dt - start_dt) > datetime.timedelta(hours=9):
            raise ValueError("開始時間と終了時間が9時間を超えています")
        return self  # 検証に通過した場合は、インスタンスを返す


class Branch(BaseModel):
    """支店"""

    branch_name: BRANCH_NAMES = Field(..., title="支店名")
    staff: list[Staff] = Field([], title="社員リスト")


if __name__ == "__main__":
    import json
    from pprint import pprint
    from pydantic import ValidationError

    with open("branch2.json", "r", encoding="utf-8") as f:
        data = json.load(f)
        try:
            branch = Branch(**data)
        except ValidationError as e:
            pprint(e.errors())

おすすめ記事

記事・ニュース一覧