簡體   English   中英

使 Pydantic BaseModel 字段可選,包括 PATCH 的子模型

[英]Make Pydantic BaseModel fields optional including sub-models for PATCH

正如在類似問題中已經問到的那樣,我想支持 FastApi 應用程序的PATCH操作,在該應用程序中,調用者可以根據需要指定 Pydantic BaseModel的任意多個字段sub-models ,以便可以執行高效的PATCH操作,調用者無需提供完整有效的 model 即可更新兩個或三個字段。

我發現教程中的 Pydantic PATCH中有2 個步驟不支持子模型 然而,Pydantic 太好了,我無法批評它似乎可以使用 Pydantic 提供的工具構建的東西。 這個問題是要求實現這兩件事同時也支持子模型

  1. 生成一個新的 DRY BaseModel ,所有字段都是可選的
  2. 通過更新BaseModel實現深度復制

Pydantic 已經認識到這些問題。

  • 討論了可選的 model 的基於 class 的解決方案
  • 在更新的深層副本上有兩個問題

類似問題已經在 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參數時不會嘗試支持嵌套模型。 對於一般情況,這是一項相當復雜的任務,例如,考慮到您的字段可能是另一個BaseModeldict s、 list s 或set s。 相反,它只是使用**解壓dicthttps://github.com/pydantic/pydantic/blob/main/pydantic/main.py#L353

我還沒有為 Pydantic 正確實現它,但由於我有一個工作示例PATCH通過作弊,我將把它作為答案發布,看看是否有人可以錯誤它或提供更好的,甚至可能支持更新嵌套模型的BaseModel.copy的實現。

我不會單獨發布實現,而是更新問題中給出的示例,以便它有一個有效的PATCH並且是PATCH的完整演示,希望這會幫助其他人更多。

這兩個添加是partialmerge 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.

 
粵ICP備18138465號  © 2020-2024 STACKOOM.COM