Make every fields as optional with Pydantic

12,302

Solution 1

Solution with metaclasses

I've just come up with the following:


class AllOptional(pydantic.main.ModelMetaclass):
    def __new__(self, name, bases, namespaces, **kwargs):
        annotations = namespaces.get('__annotations__', {})
        for base in bases:
            annotations.update(base.__annotations__)
        for field in annotations:
            if not field.startswith('__'):
                annotations[field] = Optional[annotations[field]]
        namespaces['__annotations__'] = annotations
        return super().__new__(self, name, bases, namespaces, **kwargs)

Use it as:

class UpdatedItem(Item, metaclass=AllOptional):
    pass

So basically it replace all non optional fields with Optional

Any edits are welcome!

With your example:

from typing import Optional

from fastapi import FastAPI
from pydantic import BaseModel
import pydantic

app = FastAPI()

class Item(BaseModel):
    name: str
    description: str
    price: float
    tax: float


class AllOptional(pydantic.main.ModelMetaclass):
    def __new__(self, name, bases, namespaces, **kwargs):
        annotations = namespaces.get('__annotations__', {})
        for base in bases:
            annotations.update(base.__annotations__)
        for field in annotations:
            if not field.startswith('__'):
                annotations[field] = Optional[annotations[field]]
        namespaces['__annotations__'] = annotations
        return super().__new__(self, name, bases, namespaces, **kwargs)

class UpdatedItem(Item, metaclass=AllOptional):
    pass

# This continues to work correctly
@app.get("/items/{item_id}", response_model=Item)
async def get_item(item_id: int):
    return {
        'name': 'Uzbek Palov',
        'description': 'Palov is my traditional meal',
        'price': 15.0,
        'tax': 0.5,
    }

@app.patch("/items/{item_id}") # does using response_model=UpdatedItem makes mypy sad? idk, i did not check
async def update_item(item_id: str, item: UpdatedItem):
    return item

Solution 2

The problem is once FastAPI sees item: Item in your route definition, it will try to initialize an Item type from the request body, and you can't declare your model's fields to be optional sometimes depending on some conditional, such as depending on which route it is used.

I have 3 solutions:

Solution #1: Separate Models

I would say that having separate models for the POST and PATCH payloads seems to be the more logical and readable approach. It might lead to duplicated code, yes, but I think clearly defining which route has an all-required or an all-optional model balances out the maintainability cost.

The FastAPI docs has a section for partially updating models with PUT or PATCH that uses Optional fields, and there's a note at the end that says something similar:

Notice that the input model is still validated.

So, if you want to receive partial updates that can omit all the attributes, you need to have a model with all the attributes marked as optional (with default values or None).

So...

class NewItem(BaseModel):
    name: str
    description: str
    price: float
    tax: float

class UpdateItem(BaseModel):
    name: Optional[str] = None
    description: Optional[str] = None
    price: Optional[float] = None
    tax: Optional[float] = None

@app.post('/items', response_model=NewItem)
async def post_item(item: NewItem):
    return item

@app.patch('/items/{item_id}',
           response_model=UpdateItem,
           response_model_exclude_none=True)
async def update_item(item_id: str, item: UpdateItem):
    return item

Solution #2: Declare as All-Required, but Manually Validate for PATCH

You can define your model to have all-required fields, then define your payload as a regular Body parameter on the PATCH route, and then initialize the actual Item object "manually" depending on what's available in the payload.

from fastapi import Body
from typing import Dict

class Item(BaseModel):
    name: str
    description: str
    price: float
    tax: float

@app.post('/items', response_model=Item)
async def post_item(item: Item):
    return item

@app.patch('/items/{item_id}', response_model=Item)
async def update_item(item_id: str, payload: Dict = Body(...)):
    item = Item(
        name=payload.get('name', ''),
        description=payload.get('description', ''),
        price=payload.get('price', 0.0),
        tax=payload.get('tax', 0.0),
    )
    return item

Here, the Item object is initialized with whatever is in the payload, or some default if there isn't one. You'll have to manually validate if none of the expected fields are passed, ex.:

from fastapi import HTTPException

@app.patch('/items/{item_id}', response_model=Item)
async def update_item(item_id: str, payload: Dict = Body(...)):
    # Get intersection of keys/fields
    # Must have at least 1 common
    if not (set(payload.keys()) & set(Item.__fields__)):
        raise HTTPException(status_code=400, detail='No common fields')
    ...
$ cat test2.json
{
    "asda": "1923"
}
$ curl -i -H'Content-Type: application/json' --data @test2.json --request PATCH localhost:8000/items/1
HTTP/1.1 400 Bad Request
content-type: application/json

{"detail":"No common fields"}

The behavior for the POST route is as expected: all the fields must be passed.

Solution #3: Declare as All-Optional But Manually Validate for POST

Pydantic's BaseModel's dict method has exclude_defaults and exclude_none options for:

  • exclude_defaults: whether fields which are equal to their default values (whether set or otherwise) should be excluded from the returned dictionary; default False

  • exclude_none: whether fields which are equal to None should be excluded from the returned dictionary; default False

This means, for both POST and PATCH routes, you can use the same Item model, but now with all Optional[T] = None fields. The same item: Item parameter can also be used.

class Item(BaseModel):
    name: Optional[str] = None
    description: Optional[str] = None
    price: Optional[float] = None
    tax: Optional[float] = None

On the POST route, if not all the fields were set, then exclude_defaults and exclude_none will return an incomplete dict, so you can raise an error. Else, you can use the item as your new Item.

@app.post('/items', response_model=Item)
async def post_item(item: Item):
    new_item_values = item.dict(exclude_defaults=True, exclude_none=True)

    # Check if exactly same set of keys/fields
    if set(new_item_values.keys()) != set(Item.__fields__):
        raise HTTPException(status_code=400, detail='Missing some fields..')

    # Use `item` or `new_item_values`
    return item
$ cat test_empty.json
{
}
$ curl -i -H'Content-Type: application/json' --data @test_empty.json --request POST localhost:8000/items
HTTP/1.1 400 Bad Request
content-type: application/json

{"detail":"Missing some fields.."}

$ cat test_incomplete.json 
{
    "name": "test-name",
    "tax": 0.44
}
$ curl -i -H'Content-Type: application/json' --data @test_incomplete.json --request POST localhost:8000/items
HTTP/1.1 400 Bad Request
content-type: application/json

{"detail":"Missing some fields.."}

$ cat test_ok.json
{
    "name": "test-name",
    "description": "test-description",
    "price": 123.456,
    "tax": 0.44
}
$ curl -i -H'Content-Type: application/json' --data @test_ok.json --request POST localhost:8000/items
HTTP/1.1 200 OK
content-type: application/json

{"name":"test-name","description":"test-description","price":123.456,"tax":0.44}

On the PATCH route, if at least 1 value is not default/None, then that will be your update data. Use the same validation from Solution 2 to fail if none of the expected fields were passed in.

@app.patch('/items/{item_id}', response_model=Item)
async def update_item(item_id: str, item: Item):
    update_item_values = item.dict(exclude_defaults=True, exclude_none=True)

    # Get intersection of keys/fields
    # Must have at least 1 common
    if not (set(update_item_values.keys()) & set(Item.__fields__)):
        raise HTTPException(status_code=400, detail='No common fields')

    update_item = Item(**update_item_values)

    return update_item
$ cat test2.json
{
    "asda": "1923"
}
$ curl -i -s -H'Content-Type: application/json' --data @test2.json --request PATCH localhost:8000/items/1
HTTP/1.1 400 Bad Request
content-type: application/json

{"detail":"No common fields"}

$ cat test2.json
{
    "description": "test-description"
}
$ curl -i -s -H'Content-Type: application/json' --data @test2.json --request PATCH localhost:8000/items/1
HTTP/1.1 200 OK
content-type: application/json

{"name":null,"description":"test-description","price":null,"tax":null}

Solution 3

Modified @Drdilyor solution. Added checking for nesting of models.

from pydantic.main import ModelMetaclass, BaseModel
from typing import Any, Dict, Optional, Tuple

class _AllOptionalMeta(ModelMetaclass):
    def __new__(self, name: str, bases: Tuple[type], namespaces: Dict[str, Any], **kwargs):
        annotations: dict = namespaces.get('__annotations__', {})

        for base in bases:
            for base_ in base.__mro__:
                if base_ is BaseModel:
                    break

                annotations.update(base_.__annotations__)

        for field in annotations:
            if not field.startswith('__'):
                annotations[field] = Optional[annotations[field]]

        namespaces['__annotations__'] = annotations

        return super().__new__(mcs, name, bases, namespaces, **kwargs)
Share:
12,302
nolwww
Author by

nolwww

Updated on June 14, 2022

Comments

  • nolwww
    nolwww almost 2 years

    I'm making an API with FastAPI and Pydantic.

    I would like to have some PATCH endpoints, where 1 or N fields of a record could be edited at once. Moreover, I would like the client to only pass the necessary fields in the payload.

    Example:

    class Item(BaseModel):
        name: str
        description: str
        price: float
        tax: float
    
    
    @app.post("/items", response_model=Item)
    async def post_item(item: Item):
        ...
    
    @app.patch("/items/{item_id}", response_model=Item)
    async def update_item(item_id: str, item: Item):
        ...
    

    In this example, for the POST request, I want every field to be required. However, in the PATCH endpoint, I don't mind if the payload only contains, for example, the description field. That's why I wish to have all fields as optional.

    Naive approach:

    class UpdateItem(BaseModel):
        name: Optional[str] = None
        description: Optional[str] = None
        price: Optional[float] = None
        tax: Optional[float]
    

    But that would be terrible in terms of code repetition.

    Any better option?

  • nolwww
    nolwww almost 3 years
    Thanks ! Great explanations. So, it looks like solution 2 is better than 3 as the manual validation for PATCH has to be done in both, while POST validation only in 3. But I agree solution 1 is easier to read when you are not alone in a project ...
  • nolwww
    nolwww almost 3 years
    Put it as the accepted answer as it's the only solution that really fix the problem. It could honestly be an improvement of Pydantic !
  • princelySid
    princelySid over 2 years
    Is there a way to make this general so it works with any pydantic model, Rather than inheriting from PydanticModel?
  • princelySid
    princelySid over 2 years
    Never mind figured it out
  • Jimmy Obonyo Abor
    Jimmy Obonyo Abor about 2 years
    Great solution !
  • hi im Bacon
    hi im Bacon almost 2 years
    Hey - the solution doesn't appear to work for nested models, as in, if I have a model as an attribute of another and apply the metaclass to both of these objects, parse_obj will through validation errors. Any thoughts?
  • Drdilyor
    Drdilyor almost 2 years
    @hiimBacon does Maxim's solution work for that case?