[英]Make Pydantic BaseModel fields optional including sub-models for PATCH
正如在類似問題中已經問到的那樣,我想支持 FastApi 應用程序的PATCH
操作,在該應用程序中,調用者可以根據需要指定 Pydantic BaseModel
的任意多個字段sub-models ,以便可以執行高效的PATCH
操作,調用者無需提供完整有效的 model 即可更新兩個或三個字段。
我發現教程中的 Pydantic PATCH
中有2 個步驟不支持子模型。 然而,Pydantic 太好了,我無法批評它似乎可以使用 Pydantic 提供的工具構建的東西。 這個問題是要求實現這兩件事同時也支持子模型:
BaseModel
,所有字段都是可選的BaseModel
實現深度復制Pydantic 已經認識到這些問題。
類似的問題已經在 SO 上被問過一兩次,並且有一些很好的答案,使用不同的方法來生成嵌套BaseModel
的全字段可選版本。 在考慮了所有這些之后, Ziur Olpa的這個特殊答案在我看來是最好的,它提供了一個 function,它采用現有的 model 和可選字段,並返回一個新的 model,所有字段都是可選的: https://stackoverflow.com/ a/72365032
這種方法的美妙之處在於,您可以將(實際上非常緊湊的)小 function 隱藏在庫中,並將其用作依賴項,以便它以內聯方式出現在路徑操作 function 中,並且沒有其他代碼或樣板。
但是上一個答案中提供的實現並沒有采取處理正在修補的BaseModel
中的子對象的步驟。
因此,這個問題要求改進所有字段可選的 function 的實現,它也處理子對象,以及帶有更新的深層副本。
我有一個簡單的例子作為這個用例的演示,雖然出於演示目的而旨在簡單,但也包括一些字段以更接近地反映我們看到的真實世界的例子。 希望這個例子為實現提供了一個測試場景,節省了工作:
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
運行。 但它不起作用,因為沒有全可選字段 model,而 Pydantic 的帶更新的深層副本實際上會覆蓋子模型而不是更新它們。
為了對其進行測試,可以使用以下 Bash 腳本來運行curl
請求。 我再次提供這個只是為了希望更容易開始這個問題。 只需在每次運行時注釋掉其他命令,以便使用您想要的命令。 要演示示例應用程序的初始 state 工作,您將運行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
的實現,以及對帶有update
參數的existing.copy
的修復,這將使這個示例能夠與省略其他強制字段的PATCH
調用一起使用。 實施不必完全符合注釋掉的行,我只是根據Ziur Olpa之前的回答提供了這一點。
當我第一次提出這個問題時,我認為唯一的問題是如何在嵌套的BaseModel
所有字段變為Optional
,但實際上這並不難解決。
實現PATCH
調用時部分更新的真正問題是 Pydantic BaseModel.copy
方法在應用它的update
參數時不會嘗試支持嵌套模型。 對於一般情況,這是一項相當復雜的任務,例如,考慮到您的字段可能是另一個BaseModel
的dict
s、 list
s 或set
s。 相反,它只是使用**
解壓dict
: https://github.com/pydantic/pydantic/blob/main/pydantic/main.py#L353
我還沒有為 Pydantic 正確實現它,但由於我有一個工作示例PATCH
通過作弊,我將把它作為答案發布,看看是否有人可以錯誤它或提供更好的,甚至可能支持更新嵌套模型的BaseModel.copy
的實現。
我不會單獨發布實現,而是更新問題中給出的示例,以便它有一個有效的PATCH
並且是PATCH
的完整演示,希望這會幫助其他人更多。
這兩個添加是partial
和merge
。 partial
在問題代碼中稱為optional
。
partial
:這是一個 function,它采用任何BaseModel
並返回一個包含所有字段Optional
的新BaseModel
,包括子對象字段。 這足以讓 Pydantic 允許通過任何字段子集而不拋出“缺失字段”的錯誤。 它是遞歸的——不是很流行——但考慮到這些是嵌套數據模型,深度預計不會超過個位數。
merge
:復制方法上的BaseModel
更新在BaseModel
的實例上運行 - 但在通過嵌套 model 下降時支持所有可能的類型變化是困難的部分 - 數據庫數據和傳入更新很容易作為普通 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
聲明:本站的技術帖子網頁,遵循CC BY-SA 4.0協議,如果您需要轉載,請注明本站網址或者原文地址。任何問題請咨詢:yoyou2525@163.com.