Defining Extensible models with pydantic-modelable

The general usage of pydantic is to use the pydantic.BaseModel to represent some structured data. One common use within this category could be the representation of a Configuration file's internal structure and fields, thus expressing the expected data. This can be used for this, though this documents take a more general approach in what it will describe.

The following shows how pydantic-modelable allows defining a model that can be extended by third-party code, in order to build, dynamically a complete model that includes late additions.

Defining an extensible Model in your core project

As an example, we'll define an animal "shelter" project, defining the "architecture" of the shelter, represented through an extensible pydantic Model, using the pydantic-modelable module.

As such, we'll define a base model Shelter, which defines the basic facilities available in the shelter: - An administrative office to welcome visitors - A Sheltered submodel, describing the various available wings, dedicated to specific animals

from pydantic import BaseModel
from pydantic_modelable import Modelable


class GeneralSection(BaseModel):
    species: str
    capacity: int
    occupants: int


class Sections(Modelable):
    dogs: GeneralSection


class AdministrativeOffice(BaseModel):
    ...

class Shelter(Modelable):
    admin_office: AdministrativeOffice = AdministrativeOffice()
    sections: Sections
:memo: Nothing prevents us from defining multiple extensible models, based on what we want to allow extensions for

Using the extensible type in another module or a third party

As we've defined two extensible models, we'll be defining two extensions, one for each "feature" addition.

Adding facilities to the Shelter

For this first extension, we want to add some new facility to the Shelter: a Welcome desk to offer a pleasant entry point for visitors willing to adopt one of the sheltered animals.

The first step is to import the shelter's initial model, and make use of the Modelable's as_attribute decorator to decorate your extension's model:

from pydantic import BaseModel
from shelter import Shelter

class Worker(BaseModel):
    name: str

@Shelter.as_attribute('welcome_desk')
class WelcomeDesk(BaseModel):
    worker: Worker
    office_hours: list[tuple[int,int]]

By decorating our WelcomeDesk as the example shows, it will be added into the Shelter model under the attrbute name welcome_desk, thus extending the model, along with all the related pydantic facilities (schemas, validators, etc).

:memo: The as_attribute decorator offers multiple parameters to tune the behavior of the registered attribute

Adding a new Section to the shelter

As a second extension, we will be adding a new animal section to the shelter: A cats section.

Very similarly to the previous extension, we'll import the Sections model, and use it to decorate our new one. That being said, the shelter module already offers some basic common general definition of a section, so we might want to reuse it:

from pydantic import BaseModel
from shelter import GeneralSection, Sections


@Sections.as_attribute(
    'cats',
    default_factory=lambda: CatsSection(species='cats', capacity=40, occupants=5, caretaker='James'),
)
class CatsSection(GeneralSection):
    caretaker: str

Here, we've defined a default_factory to ensure that by default, the new CatsSection will be instanciated with specific values, as any default value would do by declaring them in front of the attribute declaration.

Ensuring container models including your Modelable are consistent

Pydantic relies on an internal representation of the Model, the core schema, which is built upon completing the declaration of the Model itself. As this schema is used for serialization and deserialization, it is of utmost importance to ensure it is properly rebuilt each time a Modelable is modified by one of its extensions.

To solve this issue, the Modelable model offers a class decorator that triggers the rebuild of the decorated model, every time the Modelable subclass's internal representation is rebuilt. Thus, you will be able to ensure a containing model will be kept up-to-date with the actual form of your Modelable subclass.

In a similar manner, all classes in the model hierarchy that are in the higher layers than your Modelable subclass should be decorated the same way, ensuring they're properly rebuilt whenever your model is updated.

In our case, it would take the following form:

@Sections.rebuilds_model
class Shelter(Modelable):
    admin_office: AdministrativeOffice = AdministrativeOffice()
    sections: Sections

Actual form of the modules (once extensions are loaded)

With the shelter module loaded, along with both extensions, the original Shelter and Sections models would be altered to look as if we'd written the following, in a single module:

from pydantic import BaseModel
from pydantic_modelable import Modelable


class GeneralSection(BaseModel):
    species: str
    capacity: int
    occupants: int


class CatsSection(GeneralSection):
    caretaker: str


class Sections(Modelable):
    dogs: GeneralSection
    cats: CatsSection = CatsSection(species='cats', capacity=40, occupants=5, caretaker='James')


class AdministrativeOffice(BaseModel):
    ...


class Worker(BaseModel):
    name: str


class WelcomeDesk(BaseModel):
    worker: Worker
    office_hours: list[tuple[int,int]]


class Shelter(Modelable):
    admin_office: AdministrativeOffice = AdministrativeOffice()
    sections: Sections
    welcome_desk: WelcomeDesk