Package: pydantic-discriminator
pydantic-discriminatorWelcome to pydantic-discriminator! This is a small utility library that adds support for
discriminator-based polymorphism topydantic.[!CAUTION]
This library iscursed💀 and wascondemned by the old ones. I am trying to make it as safe as possible, but integrating this functionality into pydantic as an external library can bevery hackyexpecially after release 2. I warned you, proceed at your own risk.[!NOTE]
Currently tested with 100% test coverage on every possible combination of:Python 3.9, 3.10, 3.11 and 3.12Pydantic 1.10, 2.0, 2.1, 2.3, 2.4, 2.5Please fill this repository with issues if you find any bugs or have any suggestions.📦InstallationYou can installpydantic-discriminatorwith pip:pipinstallpydantic-discriminatorThe only requirement ispydantic, which is automatically installed with this library. No additional dependencies will be installed in your environment.💡What does it do?😡The problem[!IMPORTANT]
The following example can be pretty long to read, but to some extent it is necessary to understand the problem that this library solves (or at least tries to solve).Let's say you have a class hierarchy that looks like this:classDiagram
    class Shape {
        + x: float
        + y: float
    }
    class Circle {
        + radius : float
    }
    class Hexagon {
        + radius : float
    }
    class Rectangle {
        + width : float
        + height : float
    }
    Shape <|-- Circle
    Shape <|-- Hexagon
    Shape <|-- Rectangle
    class Container {
        + shapes : list[Shape]
    }
    Container --> ShapeLet's implement it with pydantic:classShape(BaseModel):x:floaty:floatclassCircle(Shape):radius:floatclassHexagon(Shape):radius:floatclassRectangle(Shape):width:floatheight:floatclassContainer(BaseModel):shapes:list[Shape][!CAUTION]
The code above iscompletely broken. Nothing will work. Keep reading to find out why.Now, let's write a program that uses this class hierarchy:my_data={"shapes":[{"x":0,"y":0,"radius":1},# This is a Circle{"x":1,"y":2,"radius":1},# This is a Hexagon (because I said so){"x":5,"y":3,"width":1,"height":1},# This is a Rectangle]}cont=Container.model_validate(my_data)print(cont)>>> shapes=[Shape(x=0.0, y=0.0), Shape(x=0.0, y=0.0)]Disappointing, isn't it? We lost all the information about the shapes 😩. This is actually expected behaviour, because pydantic doesn't know that aShapecan be either aCircle, anHexagonor aRectangle. We just tell him that it is a list ofShape, and that's it, we get a list ofShape.[!WARNING]
A very bad smell is coming from the fact thatCircleandHexagonhave the same fields. Pydantic will never be able to tell them apart. This won't normally be a problem for any type system, like python's, but it is a problem for pydantic, because their serialization is ambiguous.😕The "Union" solutionHow should we handle this situation? As far as I know, we must sacrifice the Object-Oriented approach and useUniontypes.Let's rewrite our class hierarchy, applying the following changes:All classes have atypefield that is used as a discriminator, and must be set to a hardcoded value, in the form of a string literal. They must all be different.In theContainerclass, replace theShapehint with aUnionhint that contains all the possible shapes.classShape(BaseModel):type:Literal["shape"]="shape"x:floaty:floatclassCircle(Shape):type:Literal["circle"]="circle"radius:floatclassHexagon(Shape):type:Literal["hexagon"]="hexagon"radius:floatclassRectangle(Shape):type:Literal["rectangle"]="rectangle"width:floatheight:floatclassContainer(BaseModel):shapes:list[Circle|Hexagon|Rectangle]Let's also update the client program:my_data={"shapes":[{"type":"circle","x":0,"y":0,"radius":1},{"type":"hexagon","x":1,"y":2,"radius":1},{"type":"rectangle","x":5,"y":3,"width":1,"height":1},]}cont=Container.model_validate(my_data)print(cont)>>> shapes=[Circle(type='circle', x=0.0, y=0.0, radius=1.0), Hexagon(type='hexagon', x=1.0, y=2.0, radius=1.0), Rectangle(type='rectangle', x=5.0, y=3.0, width=1.0, height=1.0)]It works! Yay! 🎉But... something is not right.[!WARNING]
What if a new classTriangleis added to the hierarchy? We must remember to add it to theUniontype inContainer.[!CAUTION]
What if we want to add theTriangleclass to the hierarchy, but theContainerclass is defined in a different library? We can't, unless we do some radioactive monkey patching. ☢️[!WARNING]
Moreover, theUniontype is not very readable, and it will completely mess up every type hint in theContainerclass. The IDE will complain, the type checker will complain, and you will too. 😡Thepydantic-discriminatorsolutionThis library provides a solution to this problem by using a modifiedBaseModelclass that can handle this situation. No moreUniontypes, no more monkey patching, no more type checker errors.[!NOTE]
All the pydantic features should be preserved. The new base class just adds some additional functionality.Let's go back to the original class hierarchy, but applying the following changes:TheShapeclass is now aDiscriminatedBaseModelclass.All classes have a class keyword argumentdiscriminatorthat is used as a discriminator, and must be set to a hardcoded value, in the form of a string literal. They must all be different.frompydantic_discriminatorimportDiscriminatedBaseModelclassShape(DiscriminatedBaseModel):x:floaty:floatclassCircle(Shape,discriminator="circle"):radius:floatclassHexagon(Shape,discriminator="hexagon"):radius:floatclassRectangle(Shape,discriminator="rectangle"):width:floatheight:floatclassContainer(BaseModel):shapes:list[Shape]>>> shapes=[Circle(type_='circle', x=0.0, y=0.0, radius=1.0), Hexagon(type_='hexagon', x=1.0, y=2.0, radius=1.0), Rectangle(type_='rectangle', x=5.0, y=3.0, width=1.0, height=1.0)]It works too! Yay! 🎉[!NOTE]
The code is now much more clean and readable. It is basically the same as the original code, with the addition of thediscriminatorkeyword argument.[!NOTE]
Adding a new class to the hierarchy is now as easy as adding a new class to the hierarchy. No need to modify theContainerclass. The new class can also be located in different modules or libraries, as long as it is imported somewhere in the program and thediscriminatorkeyword argument is set correctly.[!NOTE]
The IDE and the type checker will be happy too. 😊Under the hood, what happens is that theDiscriminatedBaseModelclass will automatically add atype_(aliased totypeto avoid potential conflicts with python keywords) field to the model, and whenever a model of the hierarchy is created, it will look for the correct class to instantiate among the registered subclasses, which is the one whosediscriminatorkeyword argument matches the value of thetype_field.Classes are registered automatically when they are defined in a tree structure, so there is no need to do anything else.