Search code examples
pythoninheritancepydantic

Is it possible to use inheritance on the `schema_extra` config setting of a Pydantic model?


For example I have the following toy example of a Parent Model:

from pydantic import BaseModel, Extra


class Parent(BaseModel):
    class Config:
        extra = Extra.ignore
        validate_assignment = True
        schema_extra = {
            "version": "00.00.00",
            "info": "Parent description",
            "name": "Parent Name",
        }

The goal here is that all child models inherit the same schema and maybe add additional stuff to that schema.

class Child(Parent):
    class Config:
        extra = Extra.ignore
        validate_assignment = True

        @staticmethod
        def schema_extra(
            schema: dict[str, object], model: type["Child"]
        ) -> None:
            schema['info'] = 'Child Description'
            schema['name'] = 'Child Name'
            schema['additional stuff'] = 'Something else'

The code above does not work, since the Config in the child class completely overwrites the inherited Config from the parent, therefore in the child's schema we are missing the version for example.

As mentioned, I want for all inherited classes to have the same basic schema layout with some meta information and possibly change values or add to it. Is this even possible?

Edit The solution from @Daniil-Fajnberg works well with one caveat. Having for example:

class Child(Parent):
    class Config:
        @staticmethod
        def schema_extra(schema: dict[str, object]) -> None:
            schema["info"] = "Child Description"
            schema["additional stuff"] = "Something else"
            schema["name"] = f"{schema.get('title')}v{schema.get('version')}"

the resulting entry for name in the schema will be for example:

'name': "Nonev00.00.00"

FYI: In my setup I use schema_extra as static method on the parent as well


Solution

  • Correction

    First of all, this statement is not entirely correct:

    the Config in the child class completely overwrites the inherited Config from the parent

    The Config itself is inherited. But individual Config attributes are overridden.

    Example:

    from pydantic import BaseModel, Extra
    
    
    class Parent(BaseModel):
        class Config:
            extra = Extra.allow
            validate_assignment = True
    
    
    class Child(Parent):
        class Config:
            extra = Extra.forbid
    
    
    print(Child.__config__.extra)                # Extra.forbid
    print(Child.__config__.validate_assignment)  # True
    

    That being said, the problem you are facing is that you are overriding the schema_extra attribute in your Child.Config. In other words Child.Config.schema_extra does replace Parent.Config.schema_extra.

    If we wish to make this specific attribute of the configuration extensible by child models rather than have them override it, we have to get a bit creative.


    Update for Pydantic 2.x

    With newer Pydantic versions this has become quite easier. In the baseclass instead of setting schema extra, one can simply use __get_pydantic_json_schema__ to get the current schema and modify it.

    Example Usage:

    from pydantic import BaseModel, GetJsonSchemaHandler, Field
    from pydantic.json_schema import JsonSchemaValue
    from pydantic_core import core_schema
    
    def schema_extra(schema: dict[str, Any], model: type["Parent"]) -> None:
        schema["version"] = "1"
        schema["info"] = "Parent description"
        
    class Parent(BaseModel, json_schema_extra=schema_extra):
        some_parent_property: str 
    
    class Child(Parent):
        @classmethod
        def __get_pydantic_json_schema__(
            cls, core_schema: core_schema.CoreSchema, handler: GetJsonSchemaHandler
        ) -> JsonSchemaValue:
            json_schema = handler(core_schema)
            json_schema = handler.resolve_ref_schema(json_schema)
            json_schema ["info"] = "Child Description"
            json_schema ["additional stuff"] = "Something else"
        return json_schema
    
    
    print(Child.schema_json(indent=4))
    

    Updated Solution

    I propose defining a custom BaseModel to use throughout the application, hooking into its __init_subclass__ method and applying a custom function to "inherit" the schema_extra config attribute from all ancestor models (in reverse MRO) iteratively.

    To ensure that any schema_extra function calls are deferred until the schema dictionary is actually constructed, we will need to ensure that every subclass will have its schema_extra config value as defined by the user saved in a separate config attribute. This is different from my initial solution (see below), which was sub-optimal because it called the functions immediately during class creation and thus would not have access to some schema attributes/keys that are only created later.

    This seems to work:

    from collections.abc import Iterable
    from functools import partial
    from inspect import signature
    from typing import Any, ClassVar, Union
    
    from pydantic import BaseModel as PydanticBaseModel
    from pydantic.config import BaseConfig as PydanticBaseConfig, SchemaExtraCallable
    
    
    class BaseConfig(PydanticBaseConfig):
        own_schema_extra: Union[dict[str, Any], SchemaExtraCallable] = {}
    
    
    class BaseModel(PydanticBaseModel):
        __config__: ClassVar[type[BaseConfig]] = BaseConfig
    
        @classmethod
        def __init_subclass__(cls, **kwargs: object) -> None:
            cls.__config__.own_schema_extra = cls.__config__.schema_extra
            cls.__config__.schema_extra = partial(
                inherited_schema_extra,
                base_classes=(
                    base for base in reversed(cls.__mro__)
                    if issubclass(base, BaseModel)
                ),
            )
    
    
    def inherited_schema_extra(
        schema: dict[str, Any],
        model: type[BaseModel],
        *,
        base_classes: Iterable[type[BaseModel]],
    ) -> None:
        for base in base_classes:
            base_schema_extra = base.__config__.own_schema_extra
            if callable(base_schema_extra):
                if len(signature(base_schema_extra).parameters) == 1:
                    base_schema_extra(schema)
                else:
                    base_schema_extra(schema, model)
            else:
                schema.update(base_schema_extra)
    

    Note that all this business with the BaseConfig annotation is mostly just to improve type safety and define our new own_schema_extra config attribute properly before using it. It is not strictly necessary to do this, but it makes this a cleaner solution.

    Usage:

    class Parent1(BaseModel):
        class Config:
            schema_extra = {
                "version": "1",
                "info": "Parent1 description",
            }
    
    
    class Parent2(BaseModel):
        class Config:
            schema_extra = {
                "version": "2",
                "info": "Parent2 description",
            }
    
    
    class Child(Parent2, Parent1):
        class Config:
            @staticmethod
            def schema_extra(schema: dict[str, Any]) -> None:
                schema["info"] = "Child Description"
                schema["additional stuff"] = "Something else"
                schema["title+version"] = f'{schema["title"]}v{schema["version"]}'
    
    
    print(Child.schema_json(indent=4))
    

    Output:

    {
        "title": "Child",
        "type": "object",
        "properties": {},
        "version": "2",
        "info": "Child Description",
        "additional stuff": "Something else",
        "title+version": "Childv2"
    }
    

    As you can see here, multiple inheritance for the schema is supported properly (because we follow the MRO to construct it) and the deferred call to the schema_extra functions allowed us to access the title key of the schema in one of them to construct the title+version value.


    Initial Solution (see OP/comments for caveat)

    I propose hooking into __init_subclass__ and applying a custom function to "inherit" the schema_extra config attribute from all parent models (in reverse MRO) iteratively:

    from __future__ import annotations
    from inspect import signature
    
    from pydantic import BaseModel as PydanticBaseModel
    
    
    def inherit_model_schema_extra(model: type[BaseModel]) -> None:
        schema_extra: dict[str, object] = {}
        for parent in reversed(model.__mro__):
            if not issubclass(parent, BaseModel):
                continue
            parent_schema_extra = parent.__config__.schema_extra
            if callable(parent_schema_extra):
                if len(signature(parent_schema_extra).parameters) == 1:
                    parent_schema_extra(schema_extra)
                else:
                    parent_schema_extra(schema_extra, model)
            else:
                schema_extra.update(parent_schema_extra)
        model.__config__.schema_extra = schema_extra
    
    
    class BaseModel(PydanticBaseModel):
        @classmethod
        def __init_subclass__(cls, **kwargs: object) -> None:
            inherit_model_schema_extra(cls)
    

    The way schema_extra is handled here (i.e. as a callable or as a dictionary) is essentially copied from how the model_process_schema function in the current stable version does it. (see line 591 and following)

    Usage:

    class Parent(BaseModel):
        class Config:
            schema_extra = {
                "version": "00.00.00",
                "info": "Parent description",
            }
    
    
    class Child(Parent):
        class Config:
            @staticmethod
            def schema_extra(schema: dict[str, object]) -> None:
                schema["info"] = "Child Description"
                schema["additional stuff"] = "Something else"
    
    
    print(Child.schema_json(indent=4))
    

    Output:

    {
        "title": "Child",
        "type": "object",
        "properties": {},
        "version": "00.00.00",
        "info": "Child Description",
        "additional stuff": "Something else"
    }
    

    As you can see, the Child schema has both the Parent extras as well as its own, whereby its own extras take precedence.