freezegunとPydanticを併用すると稀にエラーが出る問題

2025/03/05に公開

はじめに

エンジニアの藤岡です。

弊社のプロダクトのバックエンド開発において、Pythonのfreezegunというライブラリを利用しています。このライブラリは基本的に[1]datetime.now()メソッドの挙動を変えて任意の値を返させるためのものです。タイムスタンプ関連のコードなどをテストする際に実行時間の影響を受けなくなるので非常に便利です。

ただ、Pydanticと組み合わせた場合に変なエラーが出て開発で詰まってしまったので、その解決方法込みで紹介しようと思います。

注意点

ここではfreezegun v1.5.1、pydantic v2.10.6(2025/03/04現在の最新版)をベースに解説しています。特に、外にExposeされていない内部実装を利用している箇所があるのでバージョンが違うと紹介しているパッチ等が動かない可能性もあります。ご注意ください。

また、コードを一部freezegunやPydanticのレポジトリから引用していますが、その都度トピックに合わせて中略(...で表現)やインデントの削除などで関係ない情報を省いている場合があります。それぞれ対応するコードのパーマリンクを張っているので、元のコードを参照したい場合にはそちらをご覧ください。

問題

freezegunによる時間固定が有効になっているときにdatetime型のフィールドを持つPydantic ModelをBaseModel.model_rebuild()メソッドでリビルドするとPydanticSchemaGenerationError例外が出てしまうという問題です。

問題のコード(pytestを想定)

# foo.py
class FooEntity(BaseModel):
    timestamp: datetime


# test_foo.py
def test_schema_genenration_error():    
    with freeze_time('2012-01-14'):
        FooEntity.model_rebuild(force=True)  # ここでエラー

実行時エラー(一部抜粋)

pydantic.errors.PydanticSchemaGenerationError: Unable to generate pydantic-core schema for <class 'datetime.datetime'>. ...

おそらく読者の10割はこんなコード書くわけないだろうと思ったと思います。僕も書いたことありませんし、そもそもmodel_rebuild()メソッドなんて使ったこともないです。しかし、実はmodel_rebuild()が暗黙に呼ばれることがあるのです

例えば、以下のコードでは暗黙にmodel_rebuild()が呼ばれて全く同じエラーが発生します。

# foo.py
class FooEntity(BaseModel):
    internal: "FooInternal"
    timestamp: datetime


class FooInternal: ...


# test_foo.py
def test_error():
    with freeze_time('2012-01-14'):
        foo_entity = FooEntity(internal=FooInternal())  # ここでエラー

どうしてmodel_rebuild()が呼ばれているのか分かるでしょうか?

一つヒントを出すと、FooEntityとFooInternalの定義順を変えるとエラーが出ることはありません。

# foo.py
class FooInternal: ...


class FooEntity(BaseModel):
    internal: "FooInternal"
    timestamp: datetime


# test_foo.py
def test_ok():
    with freeze_time('2012-01-14'):
        foo_entity = FooEntity(internal=FooInternal())  # pass

正解は「ForwardRefされているから」です。

Pydanticはスキーマ生成にフィールドの型情報を使用しています。しかし、FooEntityのスキーマ生成時にはその後で定義されているFooInternalの情報が未知です。このような場合に、Pydanticはスキーマ生成を後で(おそらくインスタンス化を行う際に)行うのですが、その処理中でmodel_rebuildが呼ばれるのです。

とはいえ、ここで紹介した事例は自分が直面したパターンの一例で、他の場合は自分もわかりません。

ここで理解しておいてほしいことは「freezegunとPydanticを併用すると、なんか知らんけど急にPydanticSchemaGenerationErrorが出ることがある」という事実です。

解決方法

原因がどうあれ、自分の知る限りでは以下のfixtureをテストに当てることで解決できます。

import inspect
from typing import Any, Generator

import pytest
from freezegun.api import FakeDate, FakeDatetime, freeze_factories
from pydantic._internal._generate_schema import GenerateSchema
from pydantic_core import core_schema


@pytest.fixture
def pydantic_freezegun_conflict_resolver(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
    class CustomGenerateSchema(GenerateSchema):
        def match_type(self, obj: Any) -> core_schema.CoreSchema:
            if len(freeze_factories) > 0:  # freezegunが有効でない場合は分岐しない
                if inspect.isclass(obj) and issubclass(obj, FakeDatetime):
                    return core_schema.datetime_schema()
                if inspect.isclass(obj) and issubclass(obj, FakeDate):
                    return core_schema.date_schema()
            return super().match_type(obj)

    with monkeypatch.context() as m:
        m.setattr("pydantic._internal._generate_schema.GenerateSchema", CustomGenerateSchema)
        yield

類似した(というより同一?)の問題を扱っているこのDiscussionの中のコメントに解決方法が書かれていたので、これを参考にしつつpytest向けに筆者が整えたものです。

これをconftest.pyにでも定義しておいて、必要な場合にpytest.mark.usefixtures()デコレーターを通じて利用できます。

@pytest.mark.usefixtures('pydantic_freezegun_conflict_resolver')
def test_foo():
    ...

このfixtureの利用に際してはいくつか注意点があります。

まず、functionスコープより広いスコープで適用する場合にはmonkeypatch fixtureでpatchしている部分をpytest-mockプラグインやunittestライブラリを使って書き換える必要があります。ただし、他のスコープでもうまく動くかは未検証なので、慎重に検証してから導入することを推奨します。

次に、fixtureの内部ではこのパッチは有効にならない場合があるのでご注意ください。例えば、下記の例では無効になります。

@pytest.fixture(scope='class')
def foo_instance():    
    with freeze_time('2012-01-14'):
        FooEntity.model_rebuild(force=True)  # ここでエラー


@pytest.mark.usefixtures("pydantic_freezegun_conflict_resolver")
def test_schema_genenration_error(foo_instance: FooEntity):
    ...    

これにはfixtureの実行順序が絡んでいるので、気になる方は調べてみてください。

以降では、どうしてこの修正が有効なのかをfreezegunの実装を参照しながら説明します。

(つまり、余談です)

FakeDatetime

freezegunはdatetime.now()メソッドの戻り値を変更するためにFakeDatetimeというクラスを使用しています[2]

このクラスはあたかも標準ライブラリのdatetimeクラスと同じように振る舞うのですが、例外的にnow()メソッドだけが指定された固定値を返すように改変されています。freezegunでは、このFakeDatetimeをdatetimeに対してpatchすることでdatetime.now()メソッドの挙動を変えています。

標準ライブラリのクラスを置き換えてしまう大胆な実装ですが、このクラスのインスタンスメソッドをただ呼び出したりする程度であれば特にテストと元実装とでその挙動が変わることがないので、テスト対象に時間固定以外の影響を与えることはありません。

しかし、Pydanticはスキーマ生成時にdatetimeクラスに対してそのメソッドを呼ぶ以上のことをしているため、datetimeクラスとFakeDatetimeクラスの違いに起因するエラーが発生してしまうのです。

下記のコードはPydantic ModelのFieldとSchemaクラスとを対応づけるGenerateSchema.match_type()メソッドの一部です。

pydantic/pydantic/_internal/_generate_schema.py::GenerateSchema::match_type

def match_type(self, obj: Any) -> core_schema.CoreSchema:  # noqa: C901
    ...
    elif obj is datetime.date:
        return core_schema.date_schema()
    elif obj is datetime.datetime:
        return core_schema.datetime_schema()
    ...

ちなみに、objにはFieldの型情報がクラスや型オブジェクト(e.g. Union[str, int])の形式で格納されています。

match_type()メソッドではobjdatetimeクラスが等価かどうか判定しているので、厳密に元のdatetimeとクラスが一致しなければdatetimeだと判定されません[3]

よって、FakeDatetimeクラスはdatetimeクラスと等価ではないため、未知の型だと判定されて例外が送出されてしまいます。

上記のパッチでは、match_type()メソッドをオーバーライドしてFakeDatetimeクラスのインスタンスもdatetime型であると判定するための処理をこの処理の前に追加することで、エラーの発生を回避しているのです。

freeze_factories

前述のパッチではfreeze_factoriesというオブジェクトを使ってfreezegunが適用中なのかを判定していますが、こちらのオブジェクトの正体も軽く触れておこうと思います。

if len(freeze_factories) > 0:  # freezegunが有効でない場合は分岐しない
    ...

freezegunはfreeze_time()関数が時間固定のエントリーポイントですが、この関数は内部で_freeze_timeクラスをインスタンス化して返しています。

このクラスがcontext managerやdecoratorなどのfreezegunの多様なユースケースをサポートしているのですが、同時に時間固定処理を開始/終了するためのstart()stop()メソッドも定義しています。

前の章で説明したdatetimeクラスに対するパッチはこの行から始まるstart()メソッドの一連の処理で行われています。

それが分かりやすく表れているのが以下の部分です。

freezegun/freezegun/api.py::_freeze_time::start

datetime.datetime = FakeDatetime  # type: ignore[misc]
datetime.date = FakeDate  # type: ignore[misc]

time.time = fake_time
time.monotonic = fake_monotonic
time.perf_counter = fake_perf_counter
time.localtime = fake_localtime  # type: ignore
time.gmtime = fake_gmtime  # type: ignore
time.strftime = fake_strftime  # type: ignore

ここでdatetime.datetimedatetime.date、その他諸々をすべてFakeクラスに置き換えています[4]

これらのFakeクラスの内部では当然固定する時間を取得しているのですが、この情報は単なる日時情報では無くFreezeFactoryという特殊なクラスとして保持されています。

例えば、普通のdatetime[5]に固定する場合であれば、FrozenDateTimeFactoryクラスのインスタンスを固定したい時間で初期化し、freezegun/freezegun/api.pyのグローバル領域に保持しています。

ところで、freezegunは以下のような多重の時間変更を実現しています。

with freeze_time('2000/01/01'):
    ... # 2000/01/01
    with freeze_time('2001/01/01'):
        ... # 2001/01/01
    ... # 2000/01/01

単一のFreezeFactoryを保持しているだけではこの機能を実現できません。どうにかして、今適用されているすべての固定時間の情報(上記の例では'2000/01/01'と'2001/01/01'の二つ)を保持して、時間固定処理の際に適切な値を取りだす必要があります。

この目的で複数のFreezeFactoryを保持するために使われるストアがfreeze_factoriesオブジェクトです。

これはFreezeFactoryを格納するスタックでlist型のSingleton[6]として定義されています。

FreezeFactoryのインスタンス化とfreeze_factoriesへのpushは_freeze_time.start()の内部で行われます。場所的には、先ほどのFakeDatetimeへの置き換え直前の部分です。

freezegun/freezegun/api.py::_freeze_time::start

if self.auto_tick_seconds:
    ...
else:
    freeze_factory = FrozenDateTimeFactory(self.time_to_freeze)

...
freeze_factories.append(freeze_factory)

前述した固定先の時間の取得には以下の関数を利用しており、スタックの末尾の値を参照しています。

freezegun/freezegun/api.py::get_current_time

def get_current_time() -> datetime.datetime:
    return freeze_factories[-1]()

_freeze_time.stop()が呼び出された場合にこのスタックのpop処理が行われます。

さて、最初に紹介したスニペットでは、freeze_factoriesが値を1つ以上持つ=時間が固定されているという前提で条件分岐していましたが、この論理が成り立つのはfreeze_factoriesが固定する時間のストアだからです。

freezegunの内部でも同様の判定を行なっている部分もあるので、これが一番いいやり方なのかなと思っています(それでも、内部実装にガッツリ依存しているのでテストでやるくらいがギリギリな方法かなと思います)。

おわりに

この記事(の特に後半が)役に立つかと言われれば微妙ですが、patchしたりmockしたりの周りって個人的にバグりやすくデバッグしにくい領域だと思っています。その内部実装を軽く知っておくと問題箇所のあたりを付けやすくなるので、そういう面で役に立ったり立たなかったりするのかなと。

あと、Singletonパターンの実例として個人的に参考になったので、あえて実装にも触れました。業務でつかうことはなさそうですが......。

もし誰かの学びやエラーへの対処法として本記事が役に立てれば幸いです。

Reference

https://github.com/spulec/freezegun
https://github.com/pydantic/pydantic

脚注
  1. 固定するだけでなく、そこからtickを進めるなど色々な機能があるのですが、その辺りは本記事では無いものとしています。使ったことないですし ↩︎

  2. FakeDateというdate用の同等のクラスがありますが、FakeDatetimeとほぼ同じなのでこれ以降は省略します。 ↩︎

  3. ただ、この判定部分の内部で使われているdatetimeがなぜFakeDatetimeに置き換えられていないのか、については時間切れで調査できていないです。どなたかご存じであれば教えていただけると幸いです ↩︎

  4. 正確にはすでにimportされているdatetime等々はこの処理だけで置き換えることができません。この後の処理にその辺りをよしなに対処するコードがあるのですが、かなり複雑なので省略しています ↩︎

  5. 普通じゃないdatetimeとは、Tickを進めるためのdatetimeなどfreezegunの機能に対応したdatetimeのことです。個別にFreezeFactoryが定義されています ↩︎

  6. 正確にはSingletonではないですが、別ファイルにこの配列をlistのNewTypeとしてprivateに定義してそのインスタンス化を行い、それをimportすれば正確なSingletonになります。なので、まぁ妥協したSingletonと言ってもいいかなと ↩︎

Globe-ingテックブログ

Discussion