Сделайте поля Pydantic BaseModel необязательными, включая подмодели для PATCH

Как уже было задано в подобных вопросах, я хочу поддерживать операции PATCH для приложения FastApi, в котором вызывающая сторона может указать столько полей, сколько ему нужно, Pydantic BaseModel с подмоделями, чтобы можно было выполнять эффективные операции PATCH, без того, чтобы вызывающая сторона предоставляла всю допустимую модель только для того, чтобы обновить два или три поля.

Я обнаружил, что в Pydantic есть 2 шага PATCH из туториала, которые не поддерживают подмодели. Тем не менее, Pydantic слишком хорош, чтобы критиковать его за то, что, кажется, можно создать с помощью инструментов, предоставляемых Pydantic. Этот вопрос заключается в том, чтобы запросить реализацию этих двух вещей, а также поддерживать подмодели:

  1. создать новый DRY BaseModel со всеми необязательными полями
  2. внедрить глубокую копию с обновлением BaseModel

Эти проблемы уже признаны Pydantic.

  • Существует обсуждение решения на основе классов для необязательной модели.
  • А там два выпуска открываются на глубокую копию с обновлением

Подобный вопрос задавался один или два раза здесь, на SO, и есть несколько отличных ответов с различными подходами к созданию необязательной версии вложенного BaseModel для всех полей. После рассмотрения их всех этот конкретный ответ от Ziur Olpa показался мне лучшим, предоставляя функцию, которая берет существующую модель с необязательными и обязательными полями и возвращает новую модель со всеми необязательными полями: https://stackoverflow.com/a/72365032

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

Но реализация, представленная в предыдущем ответе, не предприняла шаги для работы с подобъектами в исправленном BaseModel.

Таким образом, этот вопрос требует улучшенной реализации необязательной для всех полей функции, которая также имеет дело с подобъектами, а также глубокой копии с обновлением.

У меня есть простой пример в качестве демонстрации этого варианта использования, который, хотя и призван быть простым для демонстрационных целей, также включает ряд полей, чтобы более точно отражать примеры из реального мира, которые мы видим. Надеемся, что этот пример предоставляет тестовый сценарий для реализации, экономя работу:

import logging
from datetime import datetime, date

from collections import defaultdict
from pydantic import BaseModel
from fastapi import FastAPI, HTTPException, status, Depends
from fastapi.encoders import jsonable_encoder

app = FastAPI(title = "PATCH demo")
logging.basicConfig(level=logging.DEBUG)


class Collection:
    collection = defaultdict(dict)

    def __init__(self, this, that):
        logging.debug("-".join((this, that)))
        self.this = this
        self.that = that

    def get_document(self):
        document = self.collection[self.this].get(self.that)
        if not document:
            raise HTTPException(
                status_code=status.HTTP_404_NOT_FOUND,
                detail = "Not Found",
            )
        logging.debug(document)
        return document

    def save_document(self, document):
        logging.debug(document)
        self.collection[self.this][self.that] = document
        return document


class SubOne(BaseModel):
    original: date
    verified: str = ""
    source: str = ""
    incurred: str = ""
    reason: str = ""
    attachments: list[str] = []


class SubTwo(BaseModel):
    this: str
    that: str
    amount: float
    plan_code: str = ""
    plan_name: str = ""
    plan_type: str = ""
    meta_a: str = ""
    meta_b: str = ""
    meta_c: str = ""


class Document(BaseModel):
    this: str
    that: str
    created: datetime
    updated: datetime

    sub_one: SubOne
    sub_two: SubTwo

    the_code: str = ""
    the_status: str = ""
    the_type: str = ""
    phase: str = ""
    process: str = ""
    option: str = ""


@app.get("/endpoint/{this}/{that}", response_model=Document)
async def get_submission(this: str, that: str) -> Document:

    collection = Collection(this=this, that=that)
    return collection.get_document()


@app.put("/endpoint/{this}/{that}", response_model=Document)
async def put_submission(this: str, that: str, document: Document) -> Document:

    collection = Collection(this=this, that=that)
    return collection.save_document(jsonable_encoder(document))


@app.patch("/endpoint/{this}/{that}", response_model=Document)
async def patch_submission(
    document: Document,
    # document: optional(Document),  # <<< IMPLEMENT optional <<<
    this: str,
    that: str,
) -> Document:

    collection = Collection(this=this, that=that)
    existing = collection.get_document()
    existing = Document(**existing)
    update = document.dict(exclude_unset=True)
    updated = existing.copy(update=update, deep=True)  # <<< FIX THIS <<<
    updated = jsonable_encoder(updated)
    collection.save_document(updated)
    return updated

Этот пример представляет собой работающее приложение FastAPI, следуя руководству, и его можно запустить с помощью uvicorn example:app --reload. За исключением того, что это не работает, потому что нет модели со всеми необязательными полями, а глубокая копия Pydantic с обновлением фактически перезаписывает подмодели, а не обновляет их.

Чтобы проверить это, можно использовать следующий скрипт Bash для запуска запросов curl. Опять же, я предоставляю это только для того, чтобы облегчить начало работы с этим вопросом. Просто комментируйте другие команды каждый раз, когда вы запускаете его, чтобы использовалась нужная вам команда. Чтобы продемонстрировать это начальное состояние работы примерного приложения, вы должны запустить GET (ожидание 404), PUT (документ сохранен), GET (ожидание 200 и возврат того же документа), PATCH (ожидание 200), GET (ожидание 200 и возврат обновленного документа) .

host='http://127.0.0.1:8000'
path = "/endpoint/A123/B456"

method='PUT'
data='
{
"this":"A123",
"that":"B456",
"created":"2022-12-01T01:02:03.456",
"updated":"2023-01-01T01:02:03.456",
"sub_one":{"original":"2022-12-12","verified":"Y"},
"sub_two":{"this":"A123","that":"B456","amount":0.88,"plan_code":"HELLO"},
"the_code":"BYE"}
'

# method='PATCH'
# data='{"this":"A123","that":"B456","created":"2022-12-01T01:02:03.456","updated":"2023-01-02T03:04:05.678","sub_one":{"original":"2022-12-12","verified":"N"},"sub_two":{"this":"A123","that":"B456","amount":123.456}}' 

method='GET'
data=''

if [[ -n data ]]; then data = " --data '$data'"; fi
curl = "curl -K curlrc -X $method '$host$path' $data"
echo $curl >&2
eval $curl

Этот curlrc должен быть совмещен, чтобы обеспечить правильность заголовков типов контента:

--cookie "_cookies"
--cookie-jar "_cookies"
--header "Content-Type: application/json"
--header "Accept: application/json"
--header "Accept-Encoding: compress, gzip"
--header "Cache-Control: no-cache"

Итак, я ищу реализацию optional, закомментированную в коде, и исправление для existing.copy с параметром update, которое позволит использовать этот пример с вызовами PATCH, которые опускают обязательные поля. Реализация не обязательно должна точно соответствовать закомментированной строке, я просто предоставил ее на основе предыдущего ответа Зиура Олпы.

Подумайте о том, чтобы сделать это запросом функции в проекте pydantic: github.com/pydantic/pydantic Я согласен, эта проблема часто повторяется, должен быть какой-то библиотечный код, который поможет решить эту проблему за вас.

Yaakov Bressler 21.01.2023 20:55

Кроме того, этот вопрос содержит много информации, трудно понять суть того, о чем вы спрашиваете, с такой информационной перегрузкой. Можно ли упростить Q? Вы ищете просто рекурсивную функцию «сделать поля необязательными»?

Yaakov Bressler 21.01.2023 20:58

Спасибо, @YaakovBressler, я ценю обратную связь. На самом деле я работал с Zuir_Olpa с исходного вопроса, и я сформулировал вопрос, когда работал над ним, начиная с его ответа. Это слишком многословно, и у меня уже есть реализация, основанная на ответе Ziur_Olpa. Это достаточно просто, чтобы почти поместиться в комментарий, но по пути я обнаружил, что есть дополнительные проблемы с другими компонентами Pydantic, чтобы реализовать PATCH подмодели, который мне нужен. Я постараюсь улучшить вопрос.

NeilG 22.01.2023 22:56

Улучшен вопрос @YaakovBressler: удален дополнительный запутанный вопрос в конце, разъяснены проблемы Pydantic, которые необходимо решить. Конечно, код делает его длиннее, но я надеюсь, что код проясняет вариант использования и дает возможность начать работу над решением.

NeilG 23.01.2023 01:38
Почему в Python есть оператор "pass"?
Почему в Python есть оператор "pass"?
Оператор pass в Python - это простая концепция, которую могут быстро освоить даже новички без опыта программирования.
Некоторые методы, о которых вы не знали, что они существуют в Python
Некоторые методы, о которых вы не знали, что они существуют в Python
Python - самый известный и самый простой в изучении язык в наши дни. Имея широкий спектр применения в области машинного обучения, Data Science,...
Основы Python Часть I
Основы Python Часть I
Вы когда-нибудь задумывались, почему в программах на Python вы видите приведенный ниже код?
LeetCode - 1579. Удаление максимального числа ребер для сохранения полной проходимости графа
LeetCode - 1579. Удаление максимального числа ребер для сохранения полной проходимости графа
Алиса и Боб имеют неориентированный граф из n узлов и трех типов ребер:
Оптимизация кода с помощью тернарного оператора Python
Оптимизация кода с помощью тернарного оператора Python
И последнее, что мы хотели бы показать вам, прежде чем двигаться дальше, это
Советы по эффективной веб-разработке с помощью Python
Советы по эффективной веб-разработке с помощью Python
Как веб-разработчик, Python может стать мощным инструментом для создания эффективных и масштабируемых веб-приложений.
3
4
117
1
Перейти к ответу Данный вопрос помечен как решенный

Ответы 1

Ответ принят как подходящий

Когда я впервые задал этот вопрос, я думал, что единственная проблема заключается в том, как превратить все поля Optional во вложенные BaseModel, но на самом деле это было несложно исправить.

Реальная проблема с частичными обновлениями при реализации вызова PATCH заключается в том, что метод Pydantic BaseModel.copy не пытается поддерживать вложенные модели при применении своего параметра update. Это довольно сложная задача для общего случая, учитывая, что у вас могут быть поля, которые являются dicts, lists или sets другого BaseModel, например. Вместо этого он просто распаковывает dict с помощью **: https://github.com/pydantic/pydantic/blob/main/pydantic/main.py#L353

У меня нет надлежащей реализации этого для Pydantic, но, поскольку у меня есть рабочий пример PATCH путем обмана, я собираюсь опубликовать это как ответ и посмотреть, может ли кто-нибудь обвинить его или предоставить лучше, возможно, даже с реализация BaseModel.copy, которая поддерживает обновления для вложенных моделей.

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

Два дополнения: partial и merge. partial — это то, что обозначается как optional в коде вопроса.

partial: Это функция, которая принимает любой BaseModel и возвращает новый BaseModel со всеми полями Optional, включая поля подобъектов. Этого достаточно, чтобы Pydantic разрешил любой подмножество полей, не выдавая ошибку для «отсутствующих полей». Это рекурсивно - не очень популярно - но, учитывая, что это вложенные модели данных, ожидается, что глубина не превысит однозначных цифр.

merge: Метод обновления при копировании BaseModel работает с экземпляром BaseModel, но поддержка всех возможных вариаций типов при спуске по вложенной модели является сложной задачей, а данные базы данных и входящие обновления легко доступны в виде простых Python dict; так что это чит: merge вместо этого является реализацией вложенного dict обновления, и, поскольку данные dict уже были проверены в тот или иной момент, все должно быть в порядке.

Вот полный пример решения:

import logging
from typing import Optional, Type
from datetime import datetime, date
from functools import lru_cache

from pydantic import BaseModel, create_model

from collections import defaultdict
from pydantic import BaseModel
from fastapi import FastAPI, HTTPException, status, Depends, Body
from fastapi.encoders import jsonable_encoder

app = FastAPI(title = "Nested model PATCH demo")
logging.basicConfig(level=logging.DEBUG)


class Collection:
    collection = defaultdict(dict)

    def __init__(self, this, that):
        logging.debug("-".join((this, that)))
        self.this = this
        self.that = that

    def get_document(self):
        document = self.collection[self.this].get(self.that)
        if not document:
            raise HTTPException(
                status_code=status.HTTP_404_NOT_FOUND,
                detail = "Not Found",
            )
        logging.debug(document)
        return document

    def save_document(self, document):
        logging.debug(document)
        self.collection[self.this][self.that] = document
        return document


class SubOne(BaseModel):
    original: date
    verified: str = ""
    source: str = ""
    incurred: str = ""
    reason: str = ""
    attachments: list[str] = []


class SubTwo(BaseModel):
    this: str
    that: str
    amount: float
    plan_code: str = ""
    plan_name: str = ""
    plan_type: str = ""
    meta_a: str = ""
    meta_b: str = ""
    meta_c: str = ""

class SubThree(BaseModel):
    one: str = ""
    two: str = ""


class Document(BaseModel):
    this: str
    that: str
    created: datetime
    updated: datetime

    sub_one: SubOne
    sub_two: SubTwo
    # sub_three: dict[str, SubThree] = {}  # Hah hah not really

    the_code: str = ""
    the_status: str = ""
    the_type: str = ""
    phase: str = ""
    process: str = ""
    option: str = ""


@lru_cache
def partial(baseclass: Type[BaseModel]) -> Type[BaseModel]:
    """Make all fields in supplied Pydantic BaseModel Optional, for use in PATCH calls.

    Iterate over fields of baseclass, descend into sub-classes, convert fields to Optional and return new model.
    Cache newly created model with lru_cache to ensure it's only created once.
    Use with Body to generate the partial model on the fly, in the PATCH path operation function.

    - https://stackoverflow.com/questions/75167317/make-pydantic-basemodel-fields-optional-including-sub-models-for-patch
    - https://stackoverflow.com/questions/67699451/make-every-fields-as-optional-with-pydantic
    - https://github.com/pydantic/pydantic/discussions/3089
    - https://fastapi.tiangolo.com/tutorial/body-updates/#partial-updates-with-patch
    """
    fields = {}
    for name, field in baseclass.__fields__.items():
        type_ = field.type_
        if type_.__base__ is BaseModel:
            fields[name] = (Optional[partial(type_)], {})
        else:
            fields[name] = (Optional[type_], None) if field.required else (type_, field.default)
    # https://docs.pydantic.dev/usage/models/#dynamic-model-creation
    validators = {"__validators__": baseclass.__validators__}
    return create_model(baseclass.__name__ + "Partial", **fields, __validators__=validators)


def merge(original, update):
    """Update original nested dict with values from update retaining original values that are missing in update.

    - https://github.com/pydantic/pydantic/issues/3785
    - https://github.com/pydantic/pydantic/issues/4177
    - https://docs.pydantic.dev/usage/exporting_models/#modelcopy
    - https://github.com/pydantic/pydantic/blob/main/pydantic/main.py#L353
    """
    for key in update:
        if key in original:
            if isinstance(original[key], dict) and isinstance(update[key], dict):
                merge(original[key], update[key])
            elif isinstance(original[key], list) and isinstance(update[key], list):
                original[key].extend(update[key])
            else:
                original[key] = update[key]
        else:
            original[key] = update[key]
    return original


@app.get("/endpoint/{this}/{that}", response_model=Document)
async def get_submission(this: str, that: str) -> Document:

    collection = Collection(this=this, that=that)
    return collection.get_document()


@app.put("/endpoint/{this}/{that}", response_model=Document)
async def put_submission(this: str, that: str, document: Document) -> Document:

    collection = Collection(this=this, that=that)
    return collection.save_document(jsonable_encoder(document))


@app.patch("/endpoint/{this}/{that}", response_model=Document)
async def patch_submission(
    this: str,
    that: str,
    document: partial(Document),  # <<< IMPLEMENTED partial TO MAKE ALL FIELDS Optional <<<
) -> Document:

    collection = Collection(this=this, that=that)
    existing_document = collection.get_document()
    incoming_document = document.dict(exclude_unset=True)
    # VVV IMPLEMENTED merge INSTEAD OF USING BROKEN PYDANTIC copy WITH update VVV
    updated_document = jsonable_encoder(merge(existing_document, incoming_document))
    collection.save_document(updated_document)
    return updated_document

Другие вопросы по теме