2025 年 10 月: データ検証ライブラリ 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

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

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 つ目も同様に loc , msg , input を確認することで詳細がわかります。

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

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

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

Field()関数の API リファレンス

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

  • 必須かどうか

  • デフォルト値の指定

  • 数値の範囲

  • 属性のタイトルを付記

引き続き 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 引数はデフォルト値です。 ...False, 0 を指定しています。 ... は、 [エリプシス] (https://docs.python.org/ja/3.14/library/stdtypes.html#bltin-ellipsis-object) という 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

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

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 となっている部分が時間の範囲でないことの検証ができています。

【コラム】 Annotated パターンとデコレータパターン

Pydantic には単一の属性を検証する方法が 2 つ提供されています。 Annotated パターンとデコレータ(@field_validator)パターンです。

デコレータパターンは、Pydantic のバージョン 1 で提供されていた @validator に似た方法です。 バージョン 1 からの移行コードであれば、デコレータパターンを利用するほうが良いでしょう。

新規に作る場合は、それぞれの特徴を理解したうえでどちらのパターンを使うかを決めることになります。

Annotated パターンは、型ヒントの機能を使っていることから IDE の支援を受けやすくなります。 さらに、Annotated の宣言を変数に入れることで、同じ検証を複数の Pydantic クラスで使い回すことも可能です。

デコレータパターンは、すべての属性にすべて同じ検証を入れることができたり、複数の属性にまとめて検証の設定をすることができます。

公式ドキュメント: どちらのパターンを使うか

【コラム】 時間の検証と変換にカスタムバリデータを使わない方法

今回は 30 分刻みの時刻になるように制限があったためカスタムバリデータを作りましたが、そのような複雑な条件がなければ、以下のように簡潔に条件を書くことができます。

カスタムバリデータを使わないコード例
class Sample(BaseModel):
     start_time: datetime.time = Field(
          ..., ge=datetime.time(8, 0), le=datetime.time(11, 0)
          )

これは、Pydantic 内部でデータ型の自動変換行われ、さらに範囲を指定した検証を行うことができるからです。 今回は、 datetime.time の例を示しましたが、 int の場合においても、変換可能な "10" のような文字列を int 型に自動変換をしてくれます。

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

ここからは最後の条件である、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())