2025, Nov 01 11:46

Как избежать «запекания» default_factory в JSON Schema Pydantic 2.11.7

Почему default_factory во вложенных моделях Pydantic «запекает» сид в JSON Schema, и как убрать его из default через BaseModel.construct. Без «шумных» дефолтов.

При генерации JSON Schema из вложенных моделей Pydantic в версии 2.11.7 можно заметить, что динамические значения по умолчанию неожиданно «запекаются» в схему. Это становится проблемой, когда во вложенной модели есть зависящее от времени поле с default_factory, но при этом вы хотите сохранить предсказуемое значение по умолчанию для другого поля в той же модели.

Минимальный пример

Рассмотрим вложенную конфигурацию, где одно поле — это сид, получаемый из времени, а другое имеет простой и стабильный булевый дефолт. Цель — чтобы схема включала булевый дефолт, но не фиксировала числовой сид.

import json
import time
from pydantic import BaseModel, Field

class RandomSeedConfig(BaseModel):
    rand: int = Field(default_factory=lambda _: int(time.time() * 1000))
    use_safe_default: bool = False
    
class AppConfig(BaseModel):
    options: RandomSeedConfig = RandomSeedConfig()

schema_map = AppConfig.model_json_schema()
print(json.dumps(schema_map, indent=2))

При запуске получится схема, где значение по умолчанию для вложенного поля — это полностью созданный объект, включающий конкретное целочисленное значение для сида на основе времени. Иными словами, дефолт для вложенной модели захватывается как литеральный экземпляр, поэтому в схеме оказываются и стабильный булевый флаг, и конкретное число для сида.

Почему сид попадает в схему

Значение по умолчанию для вложенного поля создаётся путём непосредственного инстанцирования вложенной модели. Это означает, что default_factory для зависящего от времени поля выполняется в момент определения этого дефолтного экземпляра. Когда генерируется схема, Pydantic может отразить только то значение по умолчанию, которое он видит — полностью сконструированный объект, — поэтому сид появляется как фиксированное целое число в JSON Schema. Это нежелательно, если вы хотите, чтобы схема содержала только стабильные значения по умолчанию, а динамические оставались неуказанными.

Решение

Чтобы управлять тем, что попадает в схему как значение по умолчанию, не выполняя динамические фабрики, инициализируйте вложенный дефолт через BaseModel.construct и передайте только те поля, которые нужно сериализовать в схеме. Такой подход позволяет сохранить стабильный дефолт и избежать попадания в раздел default сида, зависящего от времени.

from pydantic import BaseModel, Field
import time
import json

class RandomSeedConfig(BaseModel):
    rand: int = Field(default_factory=lambda: int(time.time() * 1000))
    use_safe_default: bool = False

class AppConfig(BaseModel):
    options: RandomSeedConfig = RandomSeedConfig.construct(use_safe_default=False)

schema_map = AppConfig.model_json_schema()
print(json.dumps(schema_map, indent=2))

При таком подходе значение по умолчанию для вложенного поля в схеме содержит только стабильный булевый флаг. Сид в схеме не материализуется — именно так и должно быть для значения, которое предполагается генерировать во время выполнения.

Полезно понимать и концептуально: установка параметров через BaseModel.construct даёт явный контроль над тем, что включается в сгенерированную JSON Schema для значений по умолчанию. Практическое следствие этого подхода в том, что construct не выполняет проверку во время выполнения: динамическая фабрика не запускается, но при этом вы обмениваете валидацию на управляемость схемой в данном месте.

Зачем это важно

JSON Schema часто используется для документации, генерации кода и внешней валидации. Встраивание недетерминированного значения, например сида на основе метки времени, может вводить в заблуждение, засорять последующие диффы и создавать впечатление, что конкретный числовой сид — канонический дефолт. Оставляя динамические значения вне раздела default и сохраняя стабильные, вы удерживаете контракт ясным, а схему — детерминированной.

Выводы

Если во вложенной модели сочетаются динамические и стабильные дефолты, инициализация дефолта родительского поля через BaseModel.construct позволяет экспортировать «чистую» JSON Schema: стабильные значения сохраняются, а предназначенные только для рантайма — не попадают. Используйте это осознанно там, где понятность схемы важнее, чем валидация дефолтного экземпляра при выполнении, а динамическое поле пусть создаётся при обычной инстанциации модели.

Материал основан на вопросе на StackOverflow от Tristan F.-R. и ответе Jone.