model_copy принял неверное значение: проверяем обновление Pydantic

Функция настройки лимитов получает словарь изменений и вызывает model_copy(update=changes). В исходной модели limit ограничен положительным числом, но копия спокойно содержит ноль или даже строку. Название метода создаёт впечатление, что новая модель проверится так же, как конструктор. На самом деле данные update считаются уже доверенными.

Копирование и проверка выполняют разные задачи

model_copy нужен для создания копии с заранее подготовленными значениями. Он не является валидатором пользовательского patch. Параметр deep управляет копированием вложенных объектов, а не проверкой типов и ограничений. Поэтому deep=True тоже не исправит неверный limit и не защитит от опечатки в имени поля обновления.

Для простой модели настроек можно выгрузить текущие значения, объединить их с изменениями и вызвать model_validate. Включим extra=forbid, чтобы опечатка не исчезла молча. В HTTP-приложении входной patch удобно дополнительно описать отдельной моделью: она определяет, какие поля вообще разрешено менять. Финальная модель проверяет целое новое состояние.

Неверная копия и проверенное обновление

Понадобится Pydantic v2. В примере воспроизводится обход ограничения, затем показывается функция обновления, отклоняющая ноль и неизвестное поле. Успешное изменение не меняет исходный объект. Это полезно для настроек, которые нужно заменить только после полной проверки.

from pydantic import BaseModel, ConfigDict, Field, ValidationError

class Limits(BaseModel):
    model_config = ConfigDict(extra="forbid")
    limit: int = Field(gt=0, le=100)
    enabled: bool

original = Limits(limit=20, enabled=True)
unsafe = original.model_copy(update={"limit": 0})
assert unsafe.limit == 0

def changed(current: Limits, patch: dict) -> Limits:
    values = current.model_dump()
    values.update(patch)
    return Limits.model_validate(values)

for patch in [{"limit": 0}, {"limti": 5}]:
    try:
        changed(original, patch)
    except ValidationError as exc:
        print({"rejected_at": exc.errors()[0]["loc"],
               "code": exc.errors()[0]["type"]})
    else:
        raise AssertionError("invalid patch accepted")

updated = changed(original, {"limit": 5, "enabled": False})
assert updated.limit == 5 and updated.enabled is False
assert original.limit == 20 and original.enabled is True
print({"original": original.model_dump(), "updated": updated.model_dump()})

Пропущенное поле и явный null

Если изменения приходят через модель patch, выгружайте только действительно переданные поля через exclude_unset=True. Иначе значения по умолчанию модели обновления затрут существующие настройки. При этом null остаётся явным значением, которое финальная модель должна принять или отклонить по своей аннотации. Исключение всех None меняет смысл команды и не всегда допустимо.

У сложной модели обычный dump может содержать вычисляемые поля или преобразованные представления специальных типов. Показанная функция рассчитана на простые поля Limits. Для крупной предметной модели лучше явная функция построения нового состояния с разрешёнными изменениями. Так доступные для обновления поля не зависят от всех деталей сериализации.

Не принимайте произвольный patch непосредственно в ORM-сущность через setattr. Даже корректно названное поле может быть запрещено клиенту: например, роль, владелец или серверное состояние расчёта. Входная модель команды определяет разрешённое изменение, а model_validate проверяет значения. Эти два решения нужны вместе, когда обновление приходит извне.

Если настройка хранится глобально в приложении, сначала создайте updated, затем замените ссылку на готовый объект. Пошаговое изменение работающего экземпляра способно показать другой задаче промежуточное состояние. Для хранения в базе дополнительно нужна согласованность конкурентной записи; копия модели сама по себе её не обеспечивает.

В разборе PATCH основная проблема — потеря false и нуля при выборе переданных полей. Здесь выбор уже сделан, а ошибка возникает на следующем шаге: выбранные значения копируются без повторной валидации.

Продолжить на практике

Сделайте отдельную команду изменения и проверьте её нормальные, граничные и ошибочные значения. В продвинутом Python есть pytest, dataclass и защита инвариантов: эти материалы помогут выбрать понятную модель обновления состояния. Откройте программу продвинутого Python.

Продолжить на практике

  • Python для продвинутых: ООП, типизация и тестирование ↗

    Практический курс-мост от базового Python к разработке поддерживаемых приложений. Вы разберётесь со структурой пакетов, объектной моделью, современной типизацией и автоматическими тестами, а затем соедините эти инструменты в небольшом приложении с хранением данных и логированием.