Как уже было задано в подобных вопросах, я хочу поддерживать операции PATCH для приложения FastApi, в котором вызывающая сторона может указать столько полей, сколько ему нужно, Pydantic BaseModel с подмоделями, чтобы можно было выполнять эффективные операции PATCH, без того, чтобы вызывающая сторона предоставляла всю допустимую модель только для того, чтобы обновить два или три поля.
Я обнаружил, что в Pydantic есть 2 шага PATCH из туториала, которые не поддерживают подмодели. Тем не менее, Pydantic слишком хорош, чтобы критиковать его за то, что, кажется, можно создать с помощью инструментов, предоставляемых Pydantic. Этот вопрос заключается в том, чтобы запросить реализацию этих двух вещей, а также поддерживать подмодели:
BaseModel со всеми необязательными полями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, которые опускают обязательные поля.
Реализация не обязательно должна точно соответствовать закомментированной строке, я просто предоставил ее на основе предыдущего ответа Зиура Олпы.
Кроме того, этот вопрос содержит много информации, трудно понять суть того, о чем вы спрашиваете, с такой информационной перегрузкой. Можно ли упростить Q? Вы ищете просто рекурсивную функцию «сделать поля необязательными»?
Спасибо, @YaakovBressler, я ценю обратную связь. На самом деле я работал с Zuir_Olpa с исходного вопроса, и я сформулировал вопрос, когда работал над ним, начиная с его ответа. Это слишком многословно, и у меня уже есть реализация, основанная на ответе Ziur_Olpa. Это достаточно просто, чтобы почти поместиться в комментарий, но по пути я обнаружил, что есть дополнительные проблемы с другими компонентами Pydantic, чтобы реализовать PATCH подмодели, который мне нужен. Я постараюсь улучшить вопрос.
Улучшен вопрос @YaakovBressler: удален дополнительный запутанный вопрос в конце, разъяснены проблемы Pydantic, которые необходимо решить. Конечно, код делает его длиннее, но я надеюсь, что код проясняет вариант использования и дает возможность начать работу над решением.






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