pythonfastapipydanticpydantic-v2

How to ignore a certain argument in a `Field` while serializing in pydantic?


What I am trying to achieve?

I want to filter books based on some criteria. Since the filtering logic is roughly equal to "compare value with column_in_db", I decided to create different types of filters for filtering values. I am then using these filters to create my Filter model. I will then use FastAPI to let users of my api populate the filter object and get filtered results.

What works?

# filtering.py

from pydantic import BaseModel, Field
from my_sqlalchemy_models import Book, User


class EqualityFilter(BaseModel):
    value: int
    _column: Any | None = PrivateAttr(default=None)

    def __init__(self, **data):
        super().__init__(**data)
        self._column = data.get("_column")

    @property
    def column(self):
        return self._column

class MinMaxFilter:
    ...

class ContainsFilter:
   ...

class Filter(BaseModel):
    author_id: EqualityFilter | None = Field(default=None, column=Book.author_id)
    owner_id: EqualityFilter | None = Field(default=None, column=User.id)
    owner_name: ContainsFilter | None = Field(default=None, column=User.name)
    price: MinMaxFilter | None = Field(default=None, column=Book.price)

    @model_validator(mode="before")
    @classmethod
    def add_columns(cls, data: Any):
        for field_name, value in cls.model_fields.items():
            schema_extra = value.json_schema_extra
            if field_name in data and schema_extra and schema_extra.get("column") is not None:
                data[field_name]["_column"] = value.json_schema_extra["column"]

        return data

The setup above works perfectly and I can create my filter object like:

>>> my_filter = Filter(author_id={"value": 1}, owner_name={"value": "John"}, price={"min": 10})
>>> my_filter.author_id.column
<sqlalchemy.orm.attributes.InstrumentedAttribute object at 0x105933420>

Now I can create a condition for .where statement in sqlalchemy query:

>>> my_filter.author_id.column == my_filter.author_id.value
<sqlalchemy.sql.elements.BinaryExpression object at 0x102bfc860>

What doesn't work?

The problem happens when I try to use the Filter in my FastAPI app. FastAPI tries to generate openapi.json for the /docs and it fails to serialize the column arguments in Field's.

  File "***/.venv/lib/python3.12/site-packages/pydantic/_internal/_generate_schema.py", line 2250, in json_schema_update_func
    add_json_schema_extra(json_schema, json_schema_extra)
  File "***/.venv/lib/python3.12/site-packages/pydantic/_internal/_generate_schema.py", line 2260, in add_json_schema_extra
    json_schema.update(to_jsonable_python(json_schema_extra))
                       ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
pydantic_core._pydantic_core.PydanticSerializationError: Unable to serialize unknown type: <class 'sqlalchemy.orm.attributes.InstrumentedAttribute'>

I don't need it to be serialized. I am using the column internally.

So I wonder if there is way to ignore serialization for certain arguments in a field. If what I am trying to do doesn't make much sense or it is not possible, is there another way to do it?

Complete Minimal Reproducible Example

Run the below code with python app.py and go to http://127.0.0.1:8000/docs and you will see the error. To keep things simple, I've replaced references to my sqlalchemy models to int which is also non-serializable. The error explanation looks slightly different but it's caused by the same thing.

from typing import Any

from fastapi import FastAPI
from pydantic import BaseModel, Field, PrivateAttr, model_validator


class EqualityFilter(BaseModel):
    value: int
    _column: Any | None = PrivateAttr(default=None)

    def __init__(self, **data):
        super().__init__(**data)
        self._column = data.get("_column")

    @property
    def column(self):
        return self._column


class Filter(BaseModel):
    author_id: EqualityFilter | None = Field(default=None, column=int)
    owner_id: EqualityFilter | None = Field(default=None, column=int)

    @model_validator(mode="before")
    @classmethod
    def add_columns(cls, data: Any):
        for field_name, value in cls.model_fields.items():
            schema_extra = value.json_schema_extra
            if field_name in data and schema_extra and schema_extra.get("column") is not None:
                data[field_name]["_column"] = value.json_schema_extra["column"]

        return data


app = FastAPI()


@app.post("/books")
async def get_books(filter: Filter):
    # do the filtering
    result = []
    return result


if __name__ == "__main__":
    import uvicorn

    uvicorn.run(app, host="127.0.0.1", port=8000)

Solution

  • This is mentioned in pydantic docs. You can attach any metadata to your field's with the following code:

    from typing import Annotated, Any
    
    from fastapi import FastAPI
    from pydantic import BaseModel, GetCoreSchemaHandler
    from pydantic_core import CoreSchema
    
    
    class Metadata(BaseModel):
        column: Any
    
        @classmethod
        def __get_pydantic_core_schema__(cls, source_type: type[BaseModel], handler: GetCoreSchemaHandler) -> CoreSchema:
            if cls is not source_type:
                return handler(source_type)
            return super().__get_pydantic_core_schema__(source_type, handler)
    
    
    class EqualityFilter(BaseModel):
        value: int
    
    
    class Filter(BaseModel):
        author_id: Annotated[EqualityFilter, Metadata(column=str)]
        owner_id: Annotated[EqualityFilter, Metadata(column=int)]
    
    
    app = FastAPI()
    
    
    @app.post("/books")
    async def get_books(filter: Filter):
        for field, val in filter.model_fields.items():
            print(f"{field=}, {getattr(filter, field)}, metadata={getattr(val, "metadata")}")
        result = []
        return result
    
    
    if __name__ == "__main__":
        import uvicorn
    
        uvicorn.run(app, host="127.0.0.1", port=8000)
    

    If you go to docs, you will not see any error. And when you perform a request, all the required information will be available:

    field='author_id', value=1, metadata=[Metadata(column=<class 'str'>)]
    field='owner_id', value=0, metadata=[Metadata(column=<class 'int'>)]