🐍

Python 3.12~3.13の型ヒント革命:Pydantic v2.7~v2.10のアップデート情報

2024/12/20に公開

はじめに  📘

この記事は ラクス Advent Calendar 2024 の 20 日目の記事になります!!
ラクスパートナーズ Advent Calendar 2024 の 23 日目にも参加してますのでそちらもよろしくお願い致します 🥳

昨年のアドベントカレンダーでは Python3.7~3.11 の型ヒントやコード品質向上のテクニックについてまとめました。
今年は引き続き Python3.12~3.13 の型ヒントについてまとめるとともに、 Pydantic のバージョンアップ情報[1]についてもまとめていきたいと思います。
また、最後にバージョンアップによる Pydantic の高速化検証も行いたいと思います。

この記事の対象者  🎯

  • Python3.12~3.13 の型ヒントについて理解を深めたい方
  • Pydantic の最新情報(3.7~3.10)を知りたい方

第 1 部:Python 3.12~3.13 の型ヒントの進化 👆

Python3.12 で追加された型 ⤴️

type

type 文を用いて型エイリアスを定義できるようになりました。

以下の例では、静的型検査器は Vectorlist[float] を等しいものとして扱います

type Vector = list[float]


def scale(scalar: float, vector: Vector) -> Vector:
    return [scalar * num for num in vector]


new_vector = scale(2.0, [1.0, -4.2, 5.4])  # Passes type checker

another_vector = scale(2.0, ["1.0", "-4.2", "5.4"])  # Error in type checker
🚨Pylance
型 "list[str]" の引数を、関数 "scale" の型 "Vector" のパラメーター "vector" に割り当てることはできません
  "Literal['1.0']" は "float" に割り当てできません
  "Literal['-4.2']" は "float" に割り当てできません
  "Literal['5.4']" は "float" に割り当てできませんPylancereportArgumentType

型エイリアスを定義する方法は type 以外に2つあります。

Vector: TypeAlias = list[float]
Vector = list[float]

どのように使い分けるのが良いか煩雑なので、それぞれの記載方法の違いについて以下にまとめます。

特徴 type Vector = list[float] Vector: TypeAlias = list[float] Vector = list[float]
Python バージョン Python 3.12 以降で利用可能 Python 3.9 以降で利用可能(3.12 で非推奨) 全ての Python バージョンで利用可能
構文の簡潔さ 最も簡潔 やや冗長 簡潔だが意図が明確ではない
互換性 3.12 以前ではサポートされない 3.12 未満のコードでも使用可能 互換性の心配なし
型チェッカーの動作 型エイリアスとして認識 型エイリアスとして認識 型エイリアスとして認識されるが曖昧さあり
意図の明確さ 明確 明確 型エイリアスか通常の変数か不明確
推奨度 推奨される 非推奨(3.12 以降) 推奨されない(型エイリアスとしての意図が曖昧)
TypeAliasType

type を用いて作成された type alias を表現するタイプです。

type Vector = list[float]

print(type(Vector))
<class 'typing.TypeAliasType'>
type_check_only

@type_check_only の主な目的は、特定のクラスや関数に紐づくプライベートなクラスを隠蔽しつつ、型チェッカーを通じてそのクラスの構造を利用者に提供することです。

from typing import type_check_only


@type_check_only
class Response:  # 内部クラス
    code: int

    def get_header(self, name: str) -> str: ...


def fetch_response() -> Response:
    # 実際にはプライベートなクラスを使う
    class _PrivateResponse:
        def __init__(self, code: int):
            self.code = code

        def get_header(self, name: str) -> str:
            return f"Header-{name}"

    return _PrivateResponse(200)


response = fetch_response()
response.code  # 型チェッカーがコード補完で 'code: int' を認識
response.get_header("Content-Type")  # 'get_header' メソッドも認識

response = Response(200)  # Error in type checker

🚨Pylance
"Response" は@type_check_onlyとしてマークされており、型注釈でのみ使用できます

Python3.12 でアップデートされた型 🔄

TypeVar

Python 3.12 では、新しい構文でジェネリック型を直接定義できるようになりました。

新構文
def identity[T](value: T) -> T:
    return value
旧構文(Python 3.11以前)
from typing import TypeVar

T = TypeVar('T')

def identity(value: T) -> T:
    return value
TypeVar:`__infer_variance__`(分散推論の自動化)
  • 分散は型チェッカーによって自動で推論されるようになりました。
  • 明示的に指定したい場合は従来どおりcovariantcontravariantを使うことも可能です。

共変(Covariant):サブクラスの型がスーパークラスの型として扱える性質。
反変(Contravariant):スーパークラスの型がサブクラスの型として扱える性質。

3.12以降
T = TypeVar("T")  # 推論により適切な分散が決定される
3.11以前 共変(Covariant)
from typing import TypeVar, Generic

# 型変数を共変(covariant=True)として定義
T_co = TypeVar("T_co", covariant=True)

class Box(Generic[T_co]):
    def __init__(self, value: T_co):
        self.value = value

class Animal:
    pass

class Dog(Animal):
    pass

# Box[Dog]はBox[Animal]として扱える
def handle_animal_box(box: Box[Animal]):
    print("Handling a box of animals.")

# Dogを格納したBoxを作成
dog_box = Box(Dog())  # Box[Dog]
handle_animal_box(dog_box)  # 共変なので問題なく動作
3.11以前 反変(Contravariant)
from typing import Generic, TypeVar

# 型変数を反変(contravariant=True)として定義
T_contra = TypeVar("T_contra", contravariant=True)

class Handler(Generic[T_contra]):
    def handle(self, value: T_contra):
        print(f"Handling {value}")

class Animal:
    pass

class Dog(Animal):
    pass

# Handler[Animal]はHandler[Dog]として扱える
def register_handler(handler: Handler[Dog]):
    print("Registering a handler for dogs.")

animal_handler = Handler[Animal]()  # Handler[Animal]

# Handler[Animal]が反変なので、Handler[Dog]として扱える
register_handler(animal_handler)  # 反変なので問題なく動作
TypeVar:`__bound__`(制約の遅延評価)
  • bound はアクセス時に評価(遅延評価)されるようになりました。
  • 未定義の型や再帰的な型にも柔軟に対応可能です。
from typing import TypeVar

# 3.11以前: 定義時に SomeClass が存在していないとエラー
# 3.12以降: 遅延評価のため SomeClass が未定義でもOK
T = TypeVar("T", bound="SomeClass")

class SomeClass:
    pass
TypeVar:`__constraints__`(制約の遅延評価)
  • constraints はアクセス時に評価(遅延評価)されるようになりました。
  • 未定義の型を柔軟に扱うことが可能です。
from typing import TypeVar

# 3.11以前: 制約に未定義の型 SomeClass を指定した場合、即時評価のためエラー
# 3.12以降: 遅延評価のため、SomeClass が未定義でも問題なし
T = TypeVar("T", int, str, "SomeClass")

class SomeClass:
    pass
TypeVarTuple

型パラメータ構文を用いることで、タプルの型変数(TypeVarTuple)をより簡潔に定義できるようになリました。

TypeVarTupleは、可変長の型パラメータを扱うための機能で、Python 3.11 で導入されました。例えば、タプルの要素が異なる型を持つ場合や、複数の型引数を受け取るクラスや関数を定義したい場合に使います。

python3.11での書き方
from typing import TypeVarTuple, Generic

# 可変長の型引数を定義
Ts = TypeVarTuple("Ts")

class MyClass(Generic[*Ts]):  # Ts を展開して複数の型引数を受け取る
    def __init__(self, *args: *Ts):
        self.values = args

# 使用例
instance = MyClass[int, str, float](42, "Hello", 3.14)
print(instance.values)  # 出力: (42, 'Hello', 3.14)

Python 3.12 では、以下のように型パラメータ構文で可変長型を直接記述できます:

新構文(Python 3.12以降)
class MyClass[*Ts]:  # 可変長型を直接記述
    def __init__(self, *args: *Ts):
        self.values = args

# 使用例
instance = MyClass[int, str, float](42, "Hello", 3.14)
print(instance.values)  # 出力: (42, 'Hello', 3.14)
ParamSpec

冗長なParamSpecの定義が不要になり、コードが簡潔になりました。

従来(Python 3.11以前)
from typing import ParamSpec, TypeVar, Callable

P = ParamSpec("P")
R = TypeVar("R")

def my_function(func: Callable[P, R]) -> Callable[P, R]:
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        print("Before the function call")
        result = func(*args, **kwargs)
        print("After the function call")
        return result
    return wrapper
新構文(Python 3.12以降)
def my_function[P, R](func: Callable[P, R]) -> Callable[P, R]:  # 型パラメータ構文
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        print("Before the function call")
        result = func(*args, **kwargs)
        print("After the function call")
        return result
protocol, runtime_checkable

isinstance()でプロトコルの属性を確認する際に、hasattr()を使用していましたが、inspect.getattr_static()を使用するようになりました。これにより属性が動的に生成される場合に対応できなくなりました。
(hasattr()はオブジェクトの属性を確認するために__getattr____getattribute__を呼び出すため、属性が動的に生成される場合でも対応できました。)

コードの補足[2]

from typing import Protocol, runtime_checkable

@runtime_checkable
class MyProtocol(Protocol):
    def some_method(self) -> None: ...

class DynamicClass:
    def __getattr__(self, name):
        if name == "some_method":
            return lambda: print("Dynamically generated method")
        raise AttributeError

obj = DynamicClass()

# Python 3.11以前: True(動的に生成されたメソッドが存在と見なされる)
# Python 3.12以降: False(静的に属性を確認するため、メソッドが見つからない)
print(isinstance(obj, MyProtocol))  # 3.11以前: True / 3.12以降: False
dataclass_transform

Python 3.12 から、@dataclass_transformfrozen_default オプションが追加され、デフォルトで属性を不変(frozen=True)として扱えるようになりました。

そもそも dataclass_transform とは dataclass のように振る舞うことを型チェッカーに知らせるためのものです。

@dataclass_transformを付与しない場合
class ImmutableBase:
    def __init__(self, **kwargs):
        for key, value in kwargs.items():
            object.__setattr__(self, key, value)

class Point(ImmutableBase):
    x: int
    y: int

point = Point(x="1", y="2") # 型チェッカーはエラーを報告しない
point = Point(x="1") # 型チェッカーはエラーを報告しない
@dataclass_transormのfrozen_defaultを使用する場合
from typing import dataclass_transform

@dataclass_transform(frozen_default=True)
class ImmutableBase:
    def __init__(self, **kwargs):
        for key, value in kwargs.items():
            object.__setattr__(self, key, value)

class Point(ImmutableBase):
    x: int
    y: int

point = Point(x=1, y=2)
print(point.x)  # 1

point.x = 3  # 型チェッカーがエラーを報告
🚨Pylance
クラス "Point" の属性 "x" に割り当てることはできません
  属性 "x" は読み取り専用です

Python3.12 で非推奨になった型 ⤵️

Python3.13 で追加された型 ⤴️

ReadOnly(TypedDict)

TypedDict のアイテムが読み取り専用であることを示します。

from typing import ReadOnly, TypedDict


class Movie(TypedDict):
    title: ReadOnly[str]
    year: int
    rate: float


print(Movie.__readonly_keys__)  # 出力: frozenset({'title'})
print(Movie.__mutable_keys__)  # 出力: frozenset({'year', 'rate'})


def mutate_movie(m: Movie) -> None:
    m["year"] = 1999  # allowed
    m["title"] = "The Matrix"  # typechecker error
🚨Pylance
TypedDict で項目を割り当てることができませんでした
  "title" は "Movie" の読み取り専用キーです
get_protocol_members

指定した Protocol 型に定義されているメンバー(メソッドや属性)の名前をセットとして返します。

from typing import Protocol, get_protocol_members


class P(Protocol):
    def a(self) -> str: ...

    b: int


print(get_protocol_members(P))  # 出力: frozenset({'a', 'b'})
is_protocol

指定した型が Protocol かどうかを判定します。

from typing import Protocol, is_protocol


class P(Protocol):
    def a(self) -> str: ...

    b: int


print(is_protocol(P))  # 出力: True
print(is_protocol(int))  # 出力: False
TypeIs

ユーザー定義の型述語関数(type predicate function) を記述するための特殊な型コンストラクトです。これは、型チェッカーがコードのフローに基づいて式の型を狭める(narrowing) ことをサポートします。

Case1
from typing import TypeIs

class Parent: pass
class Child(Parent): pass
class Unrelated: pass

def is_parent(val: object) -> TypeIs[Parent]:
    return isinstance(val, Parent)

def process(value: Child | Unrelated):
    if is_parent(value):
        # 型チェッカーはここで value を Child と推論
        print(f"{value} is a Child!")
    else:
        # 型チェッカーはここで value を Unrelated と推論
        print(f"{value} is Unrelated!")
Case2
from typing import TypeIs


def is_str_or_bytes(val: object) -> TypeIs[str | bytes]:
    return isinstance(val, (str, bytes))


def handle_data(data: int | str | bytes):
    if is_str_or_bytes(data):
        # data は str | bytes と推論
        result = data + 1 # Type check error
    else:
        # data は int と推論
        result = data + 1 # Pass type checker
🚨Pylance
演算子 "+" は型 "str | bytes | bytearray | memoryview[_I@memoryview]" と "Literal[1]" ではサポートされていません

TypeGuard との違いがわかりずらいと感じた為、テーブル形式でまとめてみます。

特性 TypeIs TypeGuard
適用範囲 入力型と出力型が同じ型階層または交差型である場合 入力型と出力型が異なる場合にも使用可能
型の交差(Intersection) サポートあり サポートなし
型の補集合(Negation) サポートあり サポートなし
主な用途 型階層内の型の絞り込み 入力型を別の型に変換する場合

TypeIs の適用例(クラス階層内での型の絞り込み)
from typing import TypeIs

class Parent:
    pass

class Child(Parent):
    pass

class Unrelated:
    pass

def is_parent(val: object) -> TypeIs[Parent]:
    # Parent 型かどうかを判定
    return isinstance(val, Parent)

def example_type_is(arg: Child | Unrelated):
    if is_parent(arg):
        # TypeIs により、arg の型が Parent に絞り込まれる
        print(f"{arg} is a Parent or subclass (e.g., Child)")
    else:
        print(f"{arg} is not a Parent (e.g., Unrelated)")
TypeGuard の適用例(コンテナ型の要素型変換)
from typing import TypeGuard

def is_list_of_ints(val: list[object]) -> TypeGuard[list[int]]:
    # リストの全要素が int 型であるかを判定
    return all(isinstance(item, int) for item in val)

def example_type_guard(data: list[object]):
    if is_list_of_ints(data):
        # TypeGuard により、data の型が list[int] に絞り込まれる
        print(f"{data} is a list of integers")
    else:
        # data は元の型 list[object] のまま
        print(f"{data} is not a list of integers")
NoDefault

TypeVar でデフォルト値を持たないことを示す為に使用されます。

from typing import NoDefault, TypeVar

T = TypeVar("T")
print(T.__default__ is NoDefault) # True

S = TypeVar("S", default=None)
print(S.__default__ is None) # True

Python3.13 でアップデートされた型 🔄

ClassVar && Final

ClassVarFinal を合わせて利用することができるようになりました。

ClassVarの使い方
from typing import ClassVar


class Example:
    shared_value: ClassVar[int] = 42  # クラス全体で共有される属性


a = Example()
a.shared_value = 100  # Error, setting class variable on instance
Example.shared_value = 100  # This is OK
🚨Pylance
クラス "Example" の属性 "shared_value" に割り当てることはできません
  属性 "shared_value" は ClassVar であるため、クラス インスタンスを介して割り当てることはできません
Finalの使い方
from typing import Final

class Example:
    MAX_ITEMS: Final[int] = 100  # 再代入禁止

Example.MAX_ITEMS = 10 # 型チェックエラー: Finalで再代入禁止
🚨Pylance
クラス "type[Example]" の属性 "MAX_ITEMS" に割り当てることはできません
  "MAX_ITEMS" は Final として宣言されており、再割り当てできません
ClassVar と Final を合わせて利用する場合
from typing import ClassVar, Final


class Example:
    DEFAULT_VALUE: ClassVar[Final[int]] = 42  # クラス属性で再代入不可


Example.DEFAULT_VALUE = 99  # 型チェックエラー: Finalで再代入禁止
🚨Pylance
クラス "type[Example]" の属性 "DEFAULT_VALUE" に割り当てることはできません
  "Literal[99]" は "Final" に割り当てできません
TypeVar: default

TypeVar にデフォルト値を指定できるようになりました。

from typing import TypeVar

T = TypeVar("T", default=int)  # デフォルト値として int を指定
U = TypeVar("U")               # デフォルト値なし

print(T.__default__)  # 出力: <class 'int'>
print(U.__default__)  # 出力: <typing.NoDefault object at ...>

うーん。ただ基本的に default を指定しなくても型推論してくれるし、そもそもこれってテンプレート関数を作る為なので最初から型を指定する意味とは?。。。 使用する際に明示的に型を指定してあげれば良いので default を使うタイミングがいまいちわからないな。。。

TypeVar: has_default()

デフォルト値が設定されているかを確認するメソッドも追加されました。

from typing import TypeVar

T = TypeVar("T", default=int)  # デフォルト値として int を指定
U = TypeVar("U")               # デフォルト値なし

print(T.has_default())  # 出力: True
print(U.has_default())  # 出力: False
TypeVarTuple: default/has_default()

Typevar と同様

ParamSpec: default/has_default()

Typevar と同様

NamedTuple
クラスベース構文/関数ベース構文
from typing import NamedTuple

# クラスベース構文
class Point(NamedTuple):
    x: int
    y: int

p = Point(1, 2)
print(p.x)  # 出力: 1
print(p.y)  # 出力: 2

# 関数ベース構文
Point = NamedTuple("Point", [("x", int), ("y", int)])
p = Point(1, 2)
print(p.x)  # 出力: 1
print(p.y)  # 出力: 2

以下は非推奨になった使い方です。

from typing import NamedTuple

# 非推奨(Python 3.13以降で警告)
Point = NamedTuple("Point", x=int, y=int)
# 非推奨(Python 3.13以降で警告)
Empty = NamedTuple("Empty")
EmptyNone = NamedTuple("Empty", None)

色々複雑と思われるかもしれませんが、NamedTuple については クラスベース構文を使用しておけば問題ないと考えて良さそう。

そもそも、NamedTuple じゃなくて pydantic(frozen=True) 使えば良くないか?って思ったので下記に比較表作ってみました。

特性 NamedTuple Pydantic モデル(frozen=True)
不変性(immutable) デフォルトで不変 frozen=Trueまたはallow_mutation=Falseで指定
型チェック 静的型チェック(型注釈が必須) 実行時の型チェックと値の検証が可能
デフォルト値 クラスベース構文でのみ可能 簡単に設定可能
シリアル化/デシリアル化 非対応 JSON や辞書への変換が簡単
カスタムバリデーション 不可能 豊富なバリデーション機能をサポート
メソッドの追加 基本的に不可能 メソッドの追加が可能
パフォーマンス 非常に軽量で高速 若干重い(通常は無視できる程度)
用途 軽量な固定データ構造に適している 不変性に加えて検証やシリアル化が必要な場合
ContextManager/AbstractContextManager

AbstractContextManager を使用することで __exit__ のデフォルト実装を継承できるようになりました。

AbstractContextManager には __exit__ が実装されている。
class AbstractContextManager(abc.ABC):
    ...
    @abc.abstractmethod
    def __exit__(self, exc_type, exc_value, traceback):
        """Raise any exception triggered within the runtime context."""
        return None
古い書き方
from typing import ContextManager


def get_context() -> ContextManager[str]:
    class MyContext:
        def __enter__(self) -> str:
            return "Hello"

        # NOTE: __exit__ の実装が必須
        def __exit__(self, exc_type, exc_value, traceback) -> None:
            pass

    return MyContext()

新しい書き方(推奨)
from contextlib import AbstractContextManager

def get_context() -> AbstractContextManager[str]:
    class MyContext(AbstractContextManager[str]):
        def __enter__(self) -> str:
            return "Hello"

    return MyContext() # NOTE: 型チェッカーによっては警告が出るみたいです。
AsyncGenerator/Generator(collections.abc)

デフォルト値が追加されたため、SendTypeReturnType を省略可能になりました。
これが地味に便利

非推奨のコード(古いコード)
from typing import Generator

def example() -> Generator[int, None, None]:
    yield 1
推奨されたコード(新しいコード)
from collections.abc import Generator

def example() -> Generator[int]:
    yield 1

Python3.13 で非推奨になった型 ⤵️

AnyStr

Python 3.18 で削除される予定となりました。代わりに [T: (str, bytes)] を使うことが推奨されてます。

from typing import AnyStr

def concat(a: AnyStr, b: AnyStr) -> AnyStr:
    return a + b

concat("hello", "world")  # OK
concat(b"hello", b"world")  # OK
concat("hello", b"world")  # 型エラー
🚨Pylance
型 "Literal[b"world"]" の引数を、関数 "concat" の型 "AnyStr@concat" のパラメーター "b" に割り当てることはできません
  "Literal[b"world"]" は "str" に割り当てできません
3.13以降の推奨
def concat[T: (str, bytes)](a: T, b: T) -> T:
    return a + b
TypedDict

フィールドをキーワード引数として指定する構文は非推奨。

# 非推奨(Python 3.13 以降で警告、3.15 で削除予定)
Config = TypedDict("Config", mutable_key=int, readonly_key=str)
@no_type_check_decorator

代わりに @no_type_check を使用することが推奨されます。

from typing import no_type_check


@no_type_check
def my_function(arg: int) -> None:
    # 型チェッカーはこの関数を無視
    print(arg + "This is invalid")  # 実行時エラーになるが、型チェッカーは検出しない

第 2 部:2024 年の Pydantic のバージョンアップ情報

全てのアップデート情報を網羅できているわけではありません。詳細は各自で公式ドキュメントを参照してください 🙏

v2.7の主な新機能 🎉

1. 部分的な JSON パースのサポート:

from_jsonメソッドを使用して、無効な構文に遭遇するまでの入力を読み取り、有効な部分を可能な限り JSON オブジェクトとして返すことができます。これは、LLM(大規模言語モデル)のストリーミング出力など、従来のパーサーではエラーとなる部分的な JSON オブジェクトの処理に特に有用です。

使用例1
from pydantic_core import from_json

partial_json_data = '["aa", "bb", "c'  # 不完全なJSONリスト

try:
    result = from_json(partial_json_data, allow_partial=False)
except ValueError as e:
    print(e)  # エラー: EOF while parsing a string at line 1 column 15

result = from_json(partial_json_data, allow_partial=True)
print(result)  # 出力: ['aa', 'bb']

私も LLM サービスを以前に構築した際にこの機能にはお世話になりました。
基本的には pydantic_core.from_jsonpydantic.BaseModel の組み合わせで使用するケースになると思います。

使用例2
 from pydantic_core import from_json

 from pydantic import BaseModel


 class Dog(BaseModel):
     breed: str
     name: str
     friends: list


 partial_dog_json = '{"breed": "lab", "name": "fluffy", "friends": ["buddy", "spot", "rufus"], "age'
 dog = Dog.model_validate(from_json(partial_dog_json, allow_partial=True))
 print(repr(dog))
 #> Dog(breed='lab', name='fluffy', friends=['buddy', 'spot', 'rufus'])

以下は、デフォルト値を使用する例になります。

default値を使用する例
 from typing import Any, Optional, Tuple

 import pydantic_core
 from typing_extensions import Annotated

 from pydantic import BaseModel, ValidationError, WrapValidator


 def default_on_error(v, handler) -> Any:
     """
     Raise a PydanticUseDefault exception if the value is missing.

     This is useful for avoiding errors from partial
     JSON preventing successful validation.
     """
     try:
         return handler(v)
     except ValidationError as exc:
         # there might be other types of errors resulting from partial JSON parsing
         # that you allow here, feel free to customize as needed
         if all(e['type'] == 'missing' for e in exc.errors()):
             raise pydantic_core.PydanticUseDefault()
         else:
             raise


 class NestedModel(BaseModel):
     x: int
     y: str


 class MyModel(BaseModel):
     foo: Optional[str] = None
     bar: Annotated[
         Optional[Tuple[str, int]], WrapValidator(default_on_error)
     ] = None
     nested: Annotated[
         Optional[NestedModel], WrapValidator(default_on_error)
     ] = None


 m = MyModel.model_validate(
     pydantic_core.from_json('{"foo": "x", "bar": ["world",', allow_partial=True)
 )
 print(repr(m))
 #> MyModel(foo='x', bar=None, nested=None)


 m = MyModel.model_validate(
     pydantic_core.from_json(
         '{"foo": "x", "bar": ["world", 1], "nested": {"x":', allow_partial=True
     )
 )
 print(repr(m))
 #> MyModel(foo='x', bar=('world', 1), nested=None)
2. 汎用的な`Secret`基本型の導入:

SecretStrSecretBytesに加えて、任意の型をラップするカスタムシークレット型を作成できる汎用的なSecret基本型が追加されました。

Secret の使用例
from pydantic import BaseModel, Secret

class SecretSalary(Secret[int]):
    def _display(self) -> str:
        return '$******'

class Employee(BaseModel):
    name: str
    salary: SecretSalary

employee = Employee(name='John Doe', salary=100_000)
print(repr(employee))  # 出力: Employee(name='John Doe', salary=SecretSalary('$******'))
print(employee.salary)  # 出力: $******
print(employee.salary.get_secret_value())  # 出力: 100000

print(employee.model_dump())
#> {'sensitive_int': Secret('**********')}

ちなみに、SecretStr, SecretBytes の使用例は下記のような感じです。

SecretStr の使用例
 from pydantic import BaseModel, SecretStr

 class User(BaseModel):
     username: str
     password: SecretStr

 # 使用例
 user = User(username='alice', password="supersecretpassword")
 print(user)  # User(username='alice', password=SecretStr('**********'))
 print(user.password)  # **********
 print(user.password.get_secret_value())  # supersecretpassword
SecretBytes の使用例
 from pydantic import BaseModel, SecretBytes

 class Config(BaseModel):
     api_key: SecretBytes

 # 使用例
 config = Config(api_key=b"secret_api_key")
 print(config)  # Config(api_key=SecretBytes('**********'))
 print(config.api_key)  # **********
 print(config.api_key.get_secret_value())  # b'secret_api_key'

Secret の使い所としては、機密情報を含む API レスポンスやログ出力に使用するケースでしょうか。
こうすることで機密情報を誤ってログに流したりレスポンスに含めたりすることを防げます。

Secret で機密情報を扱う例
 from typing_extensions import Annotated
 from pydantic import BaseModel, Field, Secret

 # Overwriting the representation
 class SecretSalary(Secret[float]):
     # NOTE: str 関数を呼び出した時の表示を制御
     def _display(self) -> str:
         return '$****.**'

 class Model(BaseModel):
     salary: SecretSalary

     # Configでjson_encodersを設定
     class Config:
         json_encoders = {
             Secret: lambda v: str(v)  # Secret型を安全にマスクした文字列へ変換
         }

 # モデルインスタンスの作成
 m = Model(salary=42)

 # JSONへの変換
 json_data = m.model_dump_json() # Response として返す Json データを作成
 print(json_data)  # {"sensitive_int": "**********"} ログに出力
3. フィールドの`deprecated`属性:

フィールドを非推奨としてマークする機能が追加されました。これにより、フィールドにアクセスするとランタイムで警告が発せられ、生成される JSON スキーマ内でdeprecatedパラメータがtrueに設定されます。

from pydantic import BaseModel, Field

class User(BaseModel):
    username: str
    old_field: str = Field(..., deprecated=True)

user = User(username='alice', old_field='deprecated_value')
print(user.old_field)  # アクセス時に非推奨警告が表示される
4. `serialize_as_any`

Duck-typing serialization is the behavior of serializing an object based on the fields present in the object itself, rather than the fields present in the schema of the object. This means that when an object is serialized, fields present in a subclass, but not in the original schema, will be included in the serialized output.

簡単に言うと、モデルのスキーマ(型定義)に基づくか、モデルが持つフィールド(実際の属性値)に基づくかを選択可能にしたと言うことみたいです。

from pydantic import BaseModel, TypeAdapter

class User(BaseModel):
    name: str

class UserLogin(User):
    password: str

user_login = UserLogin(name="John Doe", password="secret_password")

ta = TypeAdapter(User)

# モデルスキーマ(型定義)に基づくシリアライズ(通常のケース)
print(ta.dump_python(user_login, serialize_as_any=False))
# 出力: {'name': 'John Doe'}

# 実際のデータに基づく柔軟なシリアライズ
print(ta.dump_python(user_login, serialize_as_any=True))
# 出力: {'name': 'John Doe', 'password': 'secret_password'}

model_dump/model_dump_json でもシリアライズ化できるので、基本的にモデルスキーマに基づくシリアライズ化を行いたい場合は TypeAdapter(serialize_asy_any=False) を使う感じでしょうか。以下、モデルスキーマとモデルフィールドの違いをまとめておきます。

基準 モデルスキーマに基づく モデルフィールドに基づく
動作の基準 型定義(親クラスの定義など)に基づく 実際のインスタンスが持つフィールドに基づく
Pydantic v2.x デフォルト serialize_as_any=False (型スキーマ優先) model_dump()model_dump_json, serialize_as_any=True (フィールド優先)
柔軟性 スキーマに明示されたもののみをシリアライズ 実際のデータを柔軟に扱う
セキュリティ 型スキーマ外の予期しないデータを除外 サブクラスで追加されたフィールドも含まれる
5. `pass context to serialization`

シリアライズ時にもコンテキスト情報を渡せるようになりました。

元々、バリデーション時には以下のようにコンテキスト情報を渡すことができました。

validation で コンテキスト情報を使用
from pydantic import BaseModel, root_validator, ValidationError

class Measurement(BaseModel):
    distance: float  # in meters

    @root_validator(pre=True)
    def validate_distance(cls, values, context):
        distance = values.get("distance")
        if context and "unit" in context:
            if context["unit"] == "km" and distance > 100:
                raise ValueError("Distance cannot exceed 100 km")
        return values

# バリデーション実行
try:
    Measurement(distance=101, context={"unit": "km"})
except ValidationError as e:
    print(e)
    # Distance cannot exceed 100 km

これが以下のように、シリアライズ時に可能になりました。

シリアライズ で コンテキスト情報を使用
from pydantic import BaseModel, SerializationInfo, field_serializer

class Measurement(BaseModel):
    distance: float  # in meters

    @field_serializer('distance')
    def convert_units(self, v: float, info: SerializationInfo):
        context = info.context
        if context and 'unit' in context:
            if context['unit'] == 'km':
                v /= 1000 # convert to kilometers
            elif context['unit'] == 'cm':
                v *= 100  # convert to centimeters
        return v

measurement = Measurement(distance=500)

print(measurement.model_dump())  # no context
#> {'distance': 500.0}

print(measurement.model_dump(context={'unit': 'km'}))  # with context
#> {'distance': 0.5}

print(measurement.model_dump(context={'unit': 'cm'}))  # with context
#> {'distance': 50000.0}
  1. パフォーマンス向上
    詳細はこちら

v2.8の主な新機能 🎉

1. Fail-Fast Validation

list, tuple, setfrozenset のタイプにおいて要素のバリデーションにおいて失敗が生じた時点でバリデーションを終えられるようになりました。データの正確性を重視する場合や、パフォーマンスを重視する場合に役立ちます。

from typing import List

from typing_extensions import Annotated

from pydantic import BaseModel, FailFast, Field, ValidationError


class Model(BaseModel):
    x: Annotated[List[int], FailFast()]
    y: List[int] = Field(..., fail_fast=True)


# This will raise a single error for the first invalid value in each list
# At which point, validation for said field will stop
try:
    obj = Model(x=[1, 2, 'a', 4, 5, 'b', 7, 8, 9, 'c'], y=[1, 2, 'a', 4, 5, 'b', 7, 8, 9, 'c'])
except ValidationError as e:
    print(e)
    """
    2 validation errors for Model
    x.2
    Input should be a valid integer, unable to parse string as an integer [type=int_parsing, input_value='a', input_type=str]
        For further information visit https://errors.pydantic.dev/2.8/v/int_parsing
    y.2
    Input should be a valid integer, unable to parse string as an integer [type=int_parsing, input_value='a', input_type=str]
        For further information visit https://errors.pydantic.dev/2.8/v/int_parsing
    """
2. Model/field deprecation in JSON schema

JSON schema に deprecation 情報が格納されるようになりました。
これによって、FastAPI の自動生成する OpenAPI ドキュメントなどに deprecated 情報が表示されるようなるそうです。

from typing_extensions import deprecated

from pydantic import BaseModel, Field

@deprecated('DeprecatedModel is... sadly deprecated')
class DeprecatedModel(BaseModel):
    deprecated_field: str = Field(..., deprecated=True)

json_schema = DeprecatedModel.schema()
assert json_schema['deprecated'] is True
assert json_schema['properties']['deprecated_field']['deprecated'] is True
3. Programmatic Title Generation

model や fileds のタイトルをアトリビュート情報を使用して動的に生成できるようになりました。

import json

from pydantic import BaseModel, ConfigDict, Field


class MyModel(BaseModel):
    foo: str = Field(..., field_title_generator=lambda name, field_info: f'title-{name}-from-field')
    bar: str

    model_config = ConfigDict(
        field_title_generator=lambda name, field_info: f'title-{name}-from-config',
        model_title_generator=lambda cls: f'title-{cls.__name__}-from-config',
    )

print(json.dumps(MyModel.model_json_schema(), indent=2))
"""
{
"properties": {
    "foo": {
    "title": "title-foo-from-field",
    "type": "string"
    },
    "bar": {
    "title": "title-bar-from-config",
    "type": "string"
    },
},
"required": [
    "foo",
    "bar",
],
"title": "title-MyModel-from-config",
"type": "object"
}
"""
4. Serialization Context for TypeAdapter

TypeAdapter のシリアライゼーションにもコンテキスト情報を渡せるようになりました。

from typing_extensions import Annotated

from pydantic import SerializationInfo, TypeAdapter, PlainSerializer


def serialize_distance(v: float, info: SerializationInfo) -> float:
    """We assume a distance is provided in meters, but we can convert it to other units if a context is provided."""
    context = info.context
    if context and 'unit' in context:
        if context['unit'] == 'km':
            v /= 1000  # convert to kilometers
        elif context['unit'] == 'cm':
            v *= 100  # convert to centimeters
    return v


distance_adapter = TypeAdapter(Annotated[float, PlainSerializer(serialize_distance)])

print(distance_adapter.dump_python(500))  # no context, dumps in meters
# > 500.0

print(distance_adapter.dump_python(500, context={'unit': 'km'}))  # with context, dumps in kilometers
# > 0.5

print(distance_adapter.dump_python(500, context={'unit': 'cm'}))  # with context, dumps in centimeters
# > 50000

補足

そもそも、 TypeAdapter っていつ使うのか?
以下に、Pydantic モデルを使用する場合との比較表を記載しておきます。

特徴 TypeAdapter Pydantic モデル
適用範囲 単一型や複雑な型アノテーション モデルスキーマ全体の設計
モデル定義の必要性 不要 必要
柔軟性 型アノテーションに直接対応 モデル内のフィールドに依存
単一型のバリデーション 適切 対応可能だがモデル作成が必要
スキーマ生成 型アノテーションに基づいて直接生成可能 モデルに基づくスキーマ生成
複雑な型(Union、List 等) 直接対応 モデルに依存した構造で対応
5. Experimental Features

実験的機能が導入されました。詳細はこちら

  1. パフォーマンス向上
    詳細はこちら

v2.9の主な新機能 🎉

1. complex number support

Python の標準ライブラリが提供する 複素数(complex)型 がサポートされました。

TypeAdapter
from pydantic import TypeAdapter


ta = TypeAdapter(complex)

complex_number = ta.validate_python('1+2j')
assert complex_number == complex(1, 2)

assert ta.dump_json(complex_number) == b'"1+2j"'
BaseModel
from pydantic import BaseModel

class ComplexModel(BaseModel):
    value: complex

# バリデーション
model = ComplexModel(value="1+2j")
assert model.value == complex(1, 2)

# JSON ダンプ
json_data = model.model_dump_json()
assert json_data == b'{"value":"1+2j"}'
2. Explicit ZoneInfo support

Python の標準ライブラリが提供する タイムゾーン情報(Zoneinfo型) がサポートされました。

from pydantic import TypeAdapter
from zoneinfo import ZoneInfo

ta = TypeAdapter(ZoneInfo)

tz = ta.validate_python('America/Los_Angeles')
assert tz == ZoneInfo('America/Los_Angeles')

assert ta.dump_json(tz) == b'"America/Los_Angeles"'
3. val_json_bytes setting

JSON からバイトデータをデコードする際に使用するエンコーディングを指定できるようになりました。
Pydantic v2.7 以前では、バイトデータのデコード時にデフォルトで UTF-8 を使用していました。このため、JSON に格納されたバイトデータが他のエンコーディング(例: Base64)を使用している場合、ラウンドトリップ(エンコード -> デコードの往復)が失敗することがありました。

from pydantic import TypeAdapter, ConfigDict

ta = TypeAdapter(bytes, config=ConfigDict(ser_json_bytes='base64', val_json_bytes='base64'))

some_bytes = b'hello'
validated_bytes = ta.validate_python(some_bytes)

encoded_bytes = b'"aGVsbG8="'
assert ta.dump_json(validated_bytes) == encoded_bytes

# verifying round trip
# before we added support for val_json_bytes, the default encoding was 'utf-8' for validation, so this would fail
assert ta.validate_json(encoded_bytes) == validated_bytes
4. Support for JSON schema with custom validators

Pydantic では、フィールドのカスタムバリデーション(例: BeforeValidatorPlainValidator)を使用する場合、JSON Schema の定義がバリデーションロジックに対応していませんでした。例えば、数値と文字列の両方を受け付けて内部で整数に変換するカスタムバリデーションがあった場合でも、生成される JSON Schema には整数型しか記載されない、という問題がありました。この新機能により、カスタムバリデータを使用したフィールドでも、json_schema_input_type を指定することで、入力形式に応じた JSON Schema を正しく生成できるようになりました。

TypeAdapter
from typing import Any, Union
from pydantic_core import PydanticKnownError
from typing_extensions import Annotated
from pydantic import PlainValidator, TypeAdapter

# カスタムバリデータ: int または str を受け付けて int に変換
def validate_maybe_int(v: Any) -> int:
    if isinstance(v, int):
        return v
    elif isinstance(v, str):
        try:
            return int(v)
        except ValueError:
            ...

    # Pydantic の既知のエラーをスロー
    raise PydanticKnownError('int_parsing')

# TypeAdapter にカスタムバリデータと JSON Schema 情報を指定
ta = TypeAdapter(
    Annotated[
        int,
        PlainValidator(validate_maybe_int, json_schema_input_type=Union[int, str])
    ]
)

# JSON Schema の生成(mode='validation')
print(ta.json_schema(mode='validation'))
# {
# "anyOf": [
#     {"type": "integer"},
#     {"type": "string"}
# ]
# }
BaseModel
from typing import Any, Union
from pydantic import BaseModel, field_validator, Field
from typing_extensions import Annotated

# カスタムバリデータ: int または str を受け付けて int に変換
def validate_maybe_int(v: Any) -> int:
    if isinstance(v, int):
        return v
    elif isinstance(v, str):
        try:
            return int(v)
        except ValueError:
            ...
    raise ValueError("Invalid value: must be int or string representing an int.")

# モデル定義
class MyModel(BaseModel):
    value: Annotated[
        int,
        Field(json_schema_input_type=Union[int, str])  # JSON Schema 情報の指定
    ]

    @field_validator("value", mode="before")
    def validate_value(cls, v):
        return validate_maybe_int(v)


# JSON Schema の生成
print(MyModel.model_json_schema(mode='validation'))
# {
#   "properties": {
#     "value": {
#       "anyOf": [
#         {"type": "integer"},
#         {"type": "string"}
#       ]
#     }
#   },
#   "type": "object",
#   "required": ["value"]
# }

# バリデーション例
model = MyModel(value="123")
assert model.value == 123

model = MyModel(value=456)
assert model.value == 456

try:
    MyModel(value="invalid")
except ValueError as e:
    print(e)  # 出力: Invalid value: must be int or string representing an int.

補足

そもそも、mode はどう言う意味を持つのか?

Pydantic では、JSON Schema の生成時に mode パラメータを指定することで、どのタイミングに基づいたスキーマを生成するかを制御できます。

  • mode='validation':

    • 「入力時の検証」用のスキーマを生成します。
    • バリデーションで「受け入れられる形式」に基づいて JSON Schema を定義します。
    • 用途
      • 主に 入力時のデータ検証 を意図してスキーマを定義する場合。
      • :
      • API リクエストやフロントエンドでのフォーム入力時に、どの形式のデータを受け入れるかを明示する。
  • mode='after':

    • 「検証後(バリデーション後)の状態」に基づくスキーマを生成します。
    • 実際にモデル内で保持されるデータ型や構造を反映したスキーマを定義します。
    • 用途
      • 主に モデル内部の状態や出力形式 を意図してスキーマを定義する場合
      • API レスポンスで、モデル内部に格納されたデータ構造を正確に反映する。

比較表

mode 説明 主な用途
validation バリデーション時に受け入れる入力形式を基にスキーマを生成。 入力データの検証。ユーザーからのリクエストやデータ入力に適用。
after バリデーション後の内部状態(変換後の型や値)を基にスキーマを生成。 出力データの検証。API レスポンスやデータ保存に適用。
  1. パフォーマンス向上
    詳細はこちら

v2.10の主な新機能 🎉

1. experimental_allow_partial

不完全な JSON 文字列や、Python Objects のバリデーションが可能となりました。LLM の出力を処理する際により便利になりました。

現在は、TypeAdapter インスタンスのみサポートしてます。今後 BaseModel, Pydantic dataclass でも対応予定とのことです。

この機能もっと早く欲しかったなー

from pydantic import TypeAdapter, BaseModel
from typing import Literal


class UserRecord(BaseModel):
    id: int
    name: str
    role: Literal['admin', 'user']


ta = TypeAdapter(list[UserRecord])
# allow_partial if the input is a python object
d = ta.validate_python(
    [
        {'id': '1', 'name': 'Alice', 'role': 'user'},
        {'id': '1', 'name': 'Ben', 'role': 'user'},
        {'id': '1', 'name': 'Char'},
    ],
    experimental_allow_partial=True,
)
print(d)
#> [UserRecord(id=1, name='Alice', role='user'), UserRecord(id=1, name='Ben', role='user')]

# allow_partial if the input is a json string
d = ta.validate_json(
    '[{"id":"1","name":"Alice","role":"user"},{"id":"1","name":"Ben","role":"user"},{"id":"1","name":"Char"}]',
    experimental_allow_partial=True,
)
print(d)
#> [UserRecord(id=1, name='Alice', role='user'), UserRecord(id=1, name='Ben', role='user')]
2. Support default factories taking validated data as an argument

default_factory ですでにバリデーション済みのデータを使用することが可能となりました。

Pydantic のフィールドバリデーションでは、モデル定義ないのフィールドが上から順に処理されるのが基本です。

from pydantic import BaseModel


class Model(BaseModel):
    a: int = 1
    b: int = Field(default_factory=lambda data: data['a'] * 2)

model = Model()
assert model.b == 2
3. Support for typing.Unpack with @validate_call

Pydantic は、@validate_call デコレーターが付与された関数で、可変長のキーワード引数を指定するために typing.Unpack をサポートするようになりました。

「TypedDict をベースにして関数型チェックを行いたい」という目的がある場合に使う?っぽいのですが、そう言うケースってあまりな気がするが。。。そもそも型チェックをしたいのであれば初めから Pydantic を使えば良いって話なので。おそらく、 TypedDict の型をすでに利用しているプロジェクトで、バリデーションを導入したいケースとかを想定しているのかな。

from typing import Required, TypedDict, Unpack

from pydantic import ValidationError, validate_call, with_config


@with_config({'strict': True})
class TD(TypedDict, total=False):
    a: int
    b: Required[str]


@validate_call
def foo(**kwargs: Unpack[TD]):
    pass


foo(a=1, b='test')
foo(b='test')

try:
    foo(a='1')
except ValidationError as e:
    print(e)
    """
    2 validation errors for foo
    a
    Input should be a valid integer [type=int_type, input_value='1', input_type=str]
        For further information visit https://errors.pydantic.dev/2.10/v/int_type
    b
    Field required [type=missing, input_value={'a': '1'}, input_type=dict]
        For further information visit https://errors.pydantic.dev/2.10/v/missing
    """
4. Support compiled patterns in protected_namespaces

re.compile を使った正規表現で保護する名前空間を定義できるようになりました。

import re
import warnings

from pydantic import BaseModel, ConfigDict

with warnings.catch_warnings(record=True) as caught_warnings:
    warnings.simplefilter('always')  # 全ての警告をキャッチ

    class Model(BaseModel):
        safe_field: str  # 保護対象外
        also_protect_field: str  # 保護対象に該当
        protect_this: str  # 正規表現により保護対象に該当

        # カスタムの protected_namespaces を設定
        model_config = ConfigDict(
            protected_namespaces=(
                'protect_me_',  # 接頭辞に基づく保護
                'also_protect_',  # 別の接頭辞
                re.compile('^protect_this$'),  # 正規表現で特定のフィールド名を保護
            )
        )

# キャッチした警告を出力
for warning in caught_warnings:
    print(f'{warning.message}')

# Field "also_protect_field" in Model has conflict with protected namespace "also_protect_".
# You may be able to resolve this warning by setting `model_config['protected_namespaces'] = ('protect_me_', re.compile('^protect_this$'))`.

# Field "protect_this" in Model has conflict with protected namespace "re.compile('^protect_this$')".
# You may be able to resolve this warning by setting `model_config['protected_namespaces'] = ('protect_me_', 'also_protect_')`.

protected_namespaces の役割

Pydantic の protected_namespaces は、Pydantic モデル内のフィールド名が特定の規則(接頭辞や正規表現)に該当しないように制約を加える仕組みです。

protected_namespaces を利用すると、特定の名前(または名前パターン)を 予約語として保護 できます。
これにより、特定の名前空間を避けるべきフィールド名として警告を発することができます。

5. Support defer_build for TypeAdapter and Pydantic dataclasses

defer_build の設定が Pydantic dataclasses, TypeAdapter にも適用されるようになりました。
defer 設定はスキーマ構築、バリデーター、シリアライザーの構築を最初に実行されるタイミングまで遅延させることでアプリケーションの起動時間を短縮できます。大規模アプリケーションでは起動時間の大幅な改善が見込まれるとのことです。

TypeAdapter
from pydantic import ConfigDict, TypeAdapter

# TypeAdapter インスタンスを作成
# スキーマ構築を遅延させる設定を適用
ta = TypeAdapter('MyInt', config=ConfigDict(defer_build=True))

# 後で前方参照(forward reference)を定義
MyInt = int

# スキーマ構築を手動でトリガー
ta.rebuild()

# バリデーションが正しく機能することを確認
assert ta.validate_python(1) == 1
dataclass
from pydantic import ConfigDict
from pydantic.dataclasses import dataclass

@dataclass(config=ConfigDict(defer_build=True))
class MyDataClass:
    x: int
    y: str

# データクラスのインスタンスを作成
# スキーマ構築は遅延
data = MyDataClass(x=1, y="test")
BaseModel
from pydantic import BaseModel, ConfigDict

class MyModel(BaseModel, config=ConfigDict(defer_build=True)):
    x: int
    y: str

# インスタンス化時にはスキーマは構築されていない

model = MyModel(x=1, y="test")

# 初回利用時にスキーマ構築がトリガーされる

print(model)  # MyModel(x=1, y='test')
6. Support for fractions.Fraction

Python 標準ライブラリの fractions.Fraction 型をネイティブにサポートするようになりました。

from pydantic import TypeAdapter
from fractions import Fraction
from decimal import Decimal

# TypeAdapter を使った Fraction サポート
fraction_adapter = TypeAdapter(Fraction)

# バリデーション例
assert fraction_adapter.validate_python('3/2') == Fraction(3, 2)  # 文字列形式
assert fraction_adapter.validate_python(Fraction(3, 2)) == Fraction(3, 2)  # Fraction インスタンス
assert fraction_adapter.validate_python(Decimal('1.5')) == Fraction(3, 2)  # Decimal 型
assert fraction_adapter.validate_python(1.5) == Fraction(3, 2)  # 浮動小数点数

# シリアライゼーション例
assert fraction_adapter.dump_python(Fraction(3, 2)) == '3/2'
7. Incompatibility warnings for mixing v1 and v2 models

v1, v2 のモデルを混ぜて使用すると warging が出力されるようになりました。

from pydantic import BaseModel as BaseModelV2
from pydantic.v1 import BaseModel as BaseModelV1

class V1Model(BaseModelV1):
    ...

class V2Model(BaseModelV2):
    inner: V1Model

"""
UserWarning: Nesting V1 models inside V2 models is not supported. Please upgrade V1Model to V2.
"""
8. Expose public sort method for JSON schema generation

GenerateJsonSchema クラスに sort メソッドが追加され利用可能となりました。GenerateJsonSchema を継承して、sort メソッドをオーバーライドすることで、キーの順序を自由にカスタマイズできます。

key を sort したくないケース
import json
from typing import Optional

from pydantic import BaseModel, Field
from pydantic.json_schema import GenerateJsonSchema, JsonSchemaValue


class MyGenerateJsonSchema(GenerateJsonSchema):
    def sort(
        self, value: JsonSchemaValue, parent_key: Optional[str] = None
    ) -> JsonSchemaValue:
        """No-op, we don't want to sort schema values at all."""
        return value


class Bar(BaseModel):
    c: str
    b: str
    a: str = Field(json_schema_extra={'c': 'hi', 'b': 'hello', 'a': 'world'})


json_schema = Bar.model_json_schema(schema_generator=MyGenerateJsonSchema)
print(json.dumps(json_schema, indent=2))
"""
{
"type": "object",
"properties": {
    "c": {
    "type": "string",
    "title": "C"
    },
    "b": {
    "type": "string",
    "title": "B"
    },
    "a": {
    "type": "string",
    "c": "hi",
    "b": "hello",
    "a": "world",
    "title": "A"
    }
},
"required": [
    "c",
    "b",
    "a"
],
"title": "Bar"
}
"""
アルファベット順にソートしたいケース
import json
from typing import Optional

from pydantic import BaseModel, Field
from pydantic.json_schema import GenerateJsonSchema, JsonSchemaValue


class MyCustomSortJsonSchema(GenerateJsonSchema):
    def sort(
        self, value: JsonSchemaValue, parent_key: Optional[str] = None
    ) -> JsonSchemaValue:
        """カスタムソート: キーをアルファベット順にソート"""
        if isinstance(value, dict):
            return {key: self.sort(val) for key, val in sorted(value.items())}
        elif isinstance(value, list):
            return [self.sort(item) for item in value]
        return value


class ExampleModel(BaseModel):
    z_field: str
    a_field: int
    b_field: float = Field(json_schema_extra={'example': 'value'})


# カスタムソートを適用して JSON Schema を生成
json_schema = ExampleModel.model_json_schema(schema_generator=MyCustomSortJsonSchema)
print(json.dumps(json_schema, indent=2))

"""
{
"properties": {
    "a_field": {
    "title": "A Field",
    "type": "integer"
    },
    "b_field": {
    "example": "value",
    "title": "B Field",
    "type": "number"
    },
    "z_field": {
    "title": "Z Field",
    "type": "string"
    }
},
"required": [
    "a_field",
    "z_field"
],
"title": "ExampleModel",
"type": "object"
}
"""
9. Allow for subclassing of ValidationError and PydanticCustomError

Pydantic の ValidationErrorPydanticCustomError をサブクラス化(継承)して、独自のエラーメッセージや振る舞いを持つカスタム例外を作成できるようになりました。

この辺り、ちゃんと調べてませんが、以前は ValidationErrorPydanticCustomError の内部動作(例えばエラーメッセージの生成や構造化されたデータの処理)は、固定的で拡張性が低かったみたいですね。

ValidationError
from pydantic import BaseModel, ValidationError


class CustomValidationError(ValidationError):
    """カスタム ValidationError"""
    def __init__(self, message: str, field_name: str):
        self.message = message
        self.field_name = field_name
        super().__init__(errors=[{'msg': message, 'loc': (field_name,)}])

    def __str__(self):
        return f"Validation Error in field '{self.field_name}': {self.message}"


class MyModel(BaseModel):
    x: int

    @classmethod
    def validate_x(cls, value):
        if value < 0:
            raise CustomValidationError("Value must be positive", field_name="x")
        return value


try:
    MyModel(x=-1)
except CustomValidationError as e:
    print(e)
    # 出力: Validation Error in field 'x': Value must be positive
PydanticCustomError
from pydantic_core import PydanticCustomError
from pydantic import TypeAdapter


class CustomError(PydanticCustomError):
    """カスタム PydanticCustomError"""
    def __init__(self, error_type: str, message: str):
        super().__init__(type=error_type, message=message)


def validate_positive(value):
    if value < 0:
        raise CustomError('negative_value', 'Value must be positive')
    return value


adapter = TypeAdapter(int)
try:
    adapter.validate_python(-1, validators=[validate_positive])
except CustomError as e:
    print(f"Error Type: {e.type}, Message: {e.message}")
    # 出力: Error Type: negative_value, Message: Value must be positive
  1. パフォーマンス向上
    詳細はこちら

第3部: Pydantic の高速化検証 ⏫

前回の記事でも行ったパフォーマンス比較を行いたいと思います。

実行環境

  • M1 Mac mini 16GB
  • python 3.11.5
検証コード
検証コード
import random
import time
from datetime import datetime

from pydantic import BaseModel

random.seed(314)  # seed値を固定


class Prediction(BaseModel):
    score: float
    version: str
    predicted_at: datetime


def measure_performance():
    # 固定の日時を用意
    fixed_datetime = datetime(2023, 1, 1, 12, 0, 0)

    # テストデータを事前生成
    test_data = [
        {
            "score": random.random(),
            "version": "v1",
            "predicted_at": fixed_datetime,
        }
        for _ in range(1_000_000)
    ]

    start = time.time()

    for data in test_data:
        prediction_result = Prediction.model_validate(data)

    end = time.time()
    return end - start


# 10回繰り返し計測
iterations = 10
results = [measure_performance() for _ in range(iterations)]
average_time = sum(results) / iterations

print(f"Average time over {iterations} iterations: {average_time:.2f} seconds")
ネストが深いケースの検証コード
import random
import time
from datetime import datetime

from pydantic import BaseModel

random.seed(314)  # seed値を固定


class Whatever(BaseModel):
    hoge: str
    hoge2: str
    hoge3: str


class User(BaseModel):
    name: str
    age: int
    whatever: Whatever


class Prediction(BaseModel):
    score: float
    version: str
    predicted_at: datetime
    user: User


def measure_performance():
    # 固定日時を使用
    fixed_datetime = datetime(2023, 1, 1, 12, 0, 0)

    # テストデータを事前生成
    test_data = [
        {
            "score": random.random(),
            "version": "v1",
            "predicted_at": fixed_datetime,
            "user": {
                "name": "test",
                "age": random.randint(0, 150),
                "whatever": {"hoge": "hoge", "hoge2": "hoge2", "hoge3": "hoge3"},
            },
        }
        for _ in range(1_000_000)
    ]

    start = time.time()

    # モデル検証をループ
    for data in test_data:
        prediction_result = Prediction.model_validate(data)

    end = time.time()
    return end - start


# 10回繰り返して計測
iterations = 10
results = [measure_performance() for _ in range(iterations)]
average_time = sum(results) / iterations

print(f"Average time over {iterations} iterations: {average_time:.2f} seconds")

結果は下記の表のようになりました。[3]

ライブラリ ネストなし処理時間(s) ネストあり処理時間(s)
pydantic 2.5.1 0.712 1.675
pydantic 2.6.4 0.677 1.714
pydantic 2.7.4 0.629 1.576
pydantic 2.8.2 0.621 1.595
pydantic 2.9.2 0.611 1.612
pydantic 2.10.2 0.618 1.580

Pydantic のバージョンが上がるにつれて、パフォーマンスがわずかに向上している?? と言えなくもなさそうです。
リリース情報を見る限り、主に バリデーションの最適化Rust ベースの処理最適化 がパフォーマンス向上に寄与していると考えられそうです。

まとめ

今年も引き続き Python の型ヒントの進化についてまとめながらキャッチアップを行いました。色々便利になりましたが、覚えることも多くなりキャッチアップが大変ですね。。。

Pydantic は特に LLM に特化した機能リリースが個人的に大きかったのではないかと感じています。次の v2.11 のリリースでは パフォーマンス向上にフォーカスするとの記載 [4]もあり今後のアップデートが楽しみです。

参考文献

https://docs.pydantic.dev/latest/

https://docs.python.org/ja/3/library/typing.html

脚注
  1. 3.14 についてはまだ dev 状態のため今回は取り上げません。 ↩︎

  2. 通常の Protocol は、静的型チェック専用であり、isinstance()や issubclass()では使用できません。@runtime_checkable を追加すると、isinstance()を使って実行時に型がプロトコルを満たしているかを確認できるようになります。 ↩︎

  3. 厳密な調査ではありません。参考の数値としてご覧ください。 ↩︎

  4. For this release, we focused on adding a variety of new features and bug fixes. We'll be pivoting to a focus on performance improvements in the next release, v2.11. ↩︎

Discussion