Skip to content

Conversation

LeandroDeJesus-S
Copy link
Contributor

@LeandroDeJesus-S LeandroDeJesus-S commented Jun 10, 2025

Fixed the error that occurs when using PatchDict with a schema that inherits from other schemas #1324

Problematic Scenario

Consider the following schemas:

class SomeSchema(Schema):
    some_field: str

class OtherSchema(SomeSchema):
    other_field: str

When using these in a route, the method ninja.patch_dict.create_patch_schema relies solely on the __annotations__ attribute of the schema class. This means it only considers the annotations declared in the current schema, ignoring inherited fields. This leads to the following error:

@router.patch('/{uuid}', ...)
def endpoint(request, uuid: str, payload: PatchDict[OtherSchema]):
    ...
    
# Error:
# File ".../ninja/patch_dict.py", line 45, in __getitem__
#   new_cls = create_patch_schema(schema_cls)
#             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
# File ".../ninja/patch_dict.py", line 29, in create_patch_schema
#   t = schema_cls.__annotations__[f]
#       ~~~~~~~~~~~~~~~~~~~~~~~~~~^^^
# KeyError: 'some_field'

Proposed Solution

To address this issue, I created a helper function that recursively collects annotations from all parent classes. It traverses the class hierarchy using the __base__ attribute until it reaches a base schema class (Schema, ModelSchema, or BaseModel), then merges all the collected __annotations__ in reverse order to preserve field overrides from the child class.

def get_schema_annotations(schema_cls: Type[Any]) -> Dict[str, Any]:
    schema = schema_cls
    schemas = []
    excluded_bases = (
        "<class 'ninja.schema.Schema'>",
        "<class 'ninja.orm.ModelSchema'>",
        "<class 'pydantic.main.BaseModel>",
    )
    while True:
        schemas.append(schema)
        schema = schema.__base__
        if str(schema) in excluded_bases:
            break

    annotations = {}
    for schema in reversed(schemas):
        annotations.update(schema.__annotations__)

    return annotations

Comment on lines 36 to 39
excluded_bases = (
"<class 'ninja.schema.Schema'>",
"<class 'ninja.orm.ModelSchema'>",
"<class 'pydantic.main.BaseModel'>",
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you can use actual classes here instead of their repr (which may break in some potential new version)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are right, I have updated to use the classes instead of their repr.



def get_schema_annotations(schema_cls: Type[Any]) -> Dict[str, Any]:
schema = schema_cls
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what you do here with while True you can do with class.__mro__
some thing like this:

def get_schema_annotations(schema_cls: Type[Any]) -> Dict[str, Any]:
    excluded_bases = {Schema, ModelSchema, BaseModel}
    annotations = {}

    for base in reversed(schema_cls.__mro__):
        if base in excluded_bases:
            break
        annotations.update(getattr(base, '__annotations__', {}))

    return annotations

Copy link
Contributor Author

@LeandroDeJesus-S LeandroDeJesus-S Jun 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This approach doesn’t work because when calling reversed(schema_cls.__mro__), it will always return an empty annotations dictionary. That’s because it will hit one of the excluded classes first:

class SomeSchema(Schema):
    name: str
    age: int
    category: Optional[str] = None

# reversed(SomeSchema.__mro__)
# [<class 'object'>, <class 'pydantic.main.BaseModel'>, <class 'ninja.schema.Schema'>, <class 'test_patch_dict.SomeSchema'>]

I updated to this solution:

def get_schema_annotations(schema_cls: Type[Any]) -> Dict[str, Any]:
    annotations: Dict[str, Any] = {}
    excluded_bases = {Schema, ModelSchema, BaseModel}
    bases = schema_cls.mro()[:-1]
    final_bases = reversed([b for b in bases if b not in excluded_bases])

    for base in final_bases:
        annotations.update(getattr(base, '__annotations__', {}))

    return annotations

@vitalik vitalik merged commit ce38a08 into vitalik:master Aug 9, 2025
37 checks passed
@vitalik
Copy link
Owner

vitalik commented Aug 9, 2025

Thank you @LeandroDeJesus-S

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants