pydantic-discriminator


Namepydantic-discriminator JSON
Version 0.1.0 PyPI version JSON
download
home_page
SummaryPydantic discriminators for polymorphic models
upload_time2024-01-23 20:48:06
maintainer
docs_urlNone
author
requires_python>=3.9
licenseThis is free and unencumbered software released into the public domain. Anyone is free to copy, modify, publish, use, compile, sell, or distribute this software, either in source code form or as a compiled binary, for any purpose, commercial or non-commercial, and by any means. In jurisdictions that recognize copyright laws, the author or authors of this software dedicate any and all copyright interest in the software to the public domain. We make this dedication for the benefit of the public at large and to the detriment of our heirs and successors. We intend this dedication to be an overt act of relinquishment in perpetuity of all present and future rights to this software under copyright law. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. For more information, please refer to <https://unlicense.org>
keywords discriminator polymorphism pydantic
VCS
bugtrack_url
requirements No requirements were recorded.
Travis-CI No Travis.
coveralls test coverage
            # pydantic-discriminator

Welcome to pydantic-discriminator! This is a small utility library that adds support for
discriminator-based polymorphism to [pydantic](https://pydantic-docs.helpmanual.io/).

> [!CAUTION]
> This library is **cursed** 💀 and was **condemned 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 be **very hacky** expecially 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.12
> - Pydantic 1.10, 2.0, 2.1, 2.3, 2.4, 2.5
> 
> Please fill this repository with issues if you find any bugs or have any suggestions.

## 📦Installation

You can install `pydantic-discriminator` with pip:

```bash
pip install pydantic-discriminator
```

The only requirement is [pydantic](https://pydantic-docs.helpmanual.io/), 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:

```mermaid
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 --> Shape
```

Let's implement it with pydantic:

```python
class Shape(BaseModel):
    x: float
    y: float

class Circle(Shape):
    radius: float

class Hexagon(Shape):
    radius: float

class Rectangle(Shape):
    width: float
    height: float

class Container(BaseModel):
    shapes: list[Shape]
```

> [!CAUTION]
> The code above is **completely broken**. Nothing will work. Keep reading to find out why.


 
Now, let's write a program that uses this class hierarchy:

```python
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 a `Shape` can be either a `Circle`, an `Hexagon` or a `Rectangle`. We just tell him that it is a list of `Shape`, and that's it, we get a list of `Shape`.

> [!WARNING]
> A very bad smell is coming from the fact that `Circle` and `Hexagon` have 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" solution

How should we handle this situation? As far as I know, we must sacrifice the Object-Oriented approach and use `Union` types. 

Let's rewrite our class hierarchy, applying the following changes:
- All classes have a `type` field 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 the `Container` class, replace the `Shape` hint with a `Union` hint that contains all the possible shapes.

```python
class Shape(BaseModel):
    type: Literal["shape"] = "shape"
    x: float
    y: float

class Circle(Shape):
    type: Literal["circle"] = "circle"
    radius: float

class Hexagon(Shape):
    type: Literal["hexagon"] = "hexagon"
    radius: float

class Rectangle(Shape):
    type: Literal["rectangle"] = "rectangle"
    width: float
    height: float

class Container(BaseModel):
    shapes: list[Circle | Hexagon | Rectangle]
```

Let's also update the client program:

```python
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 class `Triangle` is added to the hierarchy? We must remember to add it to the `Union` type in `Container`. 

> [!CAUTION]
> What if we want to add the `Triangle` class to the hierarchy, but the `Container` class is defined in a different library? We can't, unless we do some radioactive monkey patching. ☢️ 

> [!WARNING]
> Moreover, the `Union` type is not very readable, and it will completely mess up every type hint in the `Container` class. The IDE will complain, the type checker will complain, and you will too. 😡

### The `pydantic-discriminator` solution

This library provides a solution to this problem by using a modified `BaseModel` class that can handle this situation. No more `Union` types, 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:
- The `Shape` class is now a `DiscriminatedBaseModel` class.
- All classes have a class keyword argument `discriminator` 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.

```python
from pydantic_discriminator import DiscriminatedBaseModel

class Shape(DiscriminatedBaseModel):
    x: float
    y: float

class Circle(Shape, discriminator="circle"):
    radius: float

class Hexagon(Shape, discriminator="hexagon"):
    radius: float

class Rectangle(Shape, discriminator="rectangle"):
    width: float
    height: float

class Container(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 the `discriminator` keyword 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 the `Container` class. The new class can also be located in different modules or libraries, as long as it is imported somewhere in the program and the `discriminator` keyword argument is set correctly.

> [!NOTE]
> The IDE and the type checker will be happy too. 😊

Under the hood, what happens is that the `DiscriminatedBaseModel` class will automatically add a `type_` (aliased to `type` to 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 whose `discriminator` keyword argument matches the value of the `type_` field. 

Classes are registered automatically when they are defined in a tree structure, so there is no need to do anything else.
            

Raw data

            {
    "_id": null,
    "home_page": "",
    "name": "pydantic-discriminator",
    "maintainer": "",
    "docs_url": null,
    "requires_python": ">=3.9",
    "maintainer_email": "Luca Bonfiglioli <luca.bonfiglioli@gmail.com>",
    "keywords": "discriminator,polymorphism,pydantic",
    "author": "",
    "author_email": "Luca Bonfiglioli <luca.bonfiglioli@gmail.com>",
    "download_url": "https://files.pythonhosted.org/packages/20/3b/76f3b9a386d3c21adff0a2dc7c24fb09315a997991fdebc4741df4124710/pydantic_discriminator-0.1.0.tar.gz",
    "platform": null,
    "description": "# pydantic-discriminator\n\nWelcome to pydantic-discriminator! This is a small utility library that adds support for\ndiscriminator-based polymorphism to [pydantic](https://pydantic-docs.helpmanual.io/).\n\n> [!CAUTION]\n> This library is **cursed** \ud83d\udc80 and was **condemned 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 be **very hacky** expecially after release 2. I warned you, proceed at your own risk.\n\n> [!NOTE]\n> Currently tested with 100% test coverage on every possible combination of:\n> - Python 3.9, 3.10, 3.11 and 3.12\n> - Pydantic 1.10, 2.0, 2.1, 2.3, 2.4, 2.5\n> \n> Please fill this repository with issues if you find any bugs or have any suggestions.\n\n## \ud83d\udce6Installation\n\nYou can install `pydantic-discriminator` with pip:\n\n```bash\npip install pydantic-discriminator\n```\n\nThe only requirement is [pydantic](https://pydantic-docs.helpmanual.io/), which is automatically installed with this library. No additional dependencies will be installed in your environment.\n\n## \ud83d\udca1What does it do?\n\n### \ud83d\ude21The problem\n\n> [!IMPORTANT]\n> 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).\n\nLet's say you have a class hierarchy that looks like this:\n\n```mermaid\nclassDiagram\n    class Shape {\n        + x: float\n        + y: float\n    }\n    class Circle {\n        + radius : float\n    }\n    class Hexagon {\n        + radius : float\n    }\n    class Rectangle {\n        + width : float\n        + height : float\n    }\n    Shape <|-- Circle\n    Shape <|-- Hexagon\n    Shape <|-- Rectangle\n    class Container {\n        + shapes : list[Shape]\n    }\n    Container --> Shape\n```\n\nLet's implement it with pydantic:\n\n```python\nclass Shape(BaseModel):\n    x: float\n    y: float\n\nclass Circle(Shape):\n    radius: float\n\nclass Hexagon(Shape):\n    radius: float\n\nclass Rectangle(Shape):\n    width: float\n    height: float\n\nclass Container(BaseModel):\n    shapes: list[Shape]\n```\n\n> [!CAUTION]\n> The code above is **completely broken**. Nothing will work. Keep reading to find out why.\n\n\n \nNow, let's write a program that uses this class hierarchy:\n\n```python\nmy_data = {\n    \"shapes\": [\n        {\"x\": 0, \"y\": 0, \"radius\": 1},  # This is a Circle\n        {\"x\": 1, \"y\": 2, \"radius\": 1},  # This is a Hexagon (because I said so)\n        {\"x\": 5, \"y\": 3, \"width\": 1, \"height\": 1},  # This is a Rectangle\n    ]\n}\n\ncont = Container.model_validate(my_data)\nprint(cont)\n```\n```\n>>> shapes=[Shape(x=0.0, y=0.0), Shape(x=0.0, y=0.0)]\n```\n\nDisappointing, isn't it? We lost all the information about the shapes \ud83d\ude29. This is actually expected behaviour, because pydantic doesn't know that a `Shape` can be either a `Circle`, an `Hexagon` or a `Rectangle`. We just tell him that it is a list of `Shape`, and that's it, we get a list of `Shape`.\n\n> [!WARNING]\n> A very bad smell is coming from the fact that `Circle` and `Hexagon` have 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.\n\n### \ud83d\ude15The \"Union\" solution\n\nHow should we handle this situation? As far as I know, we must sacrifice the Object-Oriented approach and use `Union` types. \n\nLet's rewrite our class hierarchy, applying the following changes:\n- All classes have a `type` field 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.\n- In the `Container` class, replace the `Shape` hint with a `Union` hint that contains all the possible shapes.\n\n```python\nclass Shape(BaseModel):\n    type: Literal[\"shape\"] = \"shape\"\n    x: float\n    y: float\n\nclass Circle(Shape):\n    type: Literal[\"circle\"] = \"circle\"\n    radius: float\n\nclass Hexagon(Shape):\n    type: Literal[\"hexagon\"] = \"hexagon\"\n    radius: float\n\nclass Rectangle(Shape):\n    type: Literal[\"rectangle\"] = \"rectangle\"\n    width: float\n    height: float\n\nclass Container(BaseModel):\n    shapes: list[Circle | Hexagon | Rectangle]\n```\n\nLet's also update the client program:\n\n```python\nmy_data = {\n    \"shapes\": [\n        {\"type\": \"circle\", \"x\": 0, \"y\": 0, \"radius\": 1},\n        {\"type\": \"hexagon\", \"x\": 1, \"y\": 2, \"radius\": 1},\n        {\"type\": \"rectangle\", \"x\": 5, \"y\": 3, \"width\": 1, \"height\": 1},\n    ]\n}\n\ncont = Container.model_validate(my_data)\nprint(cont)\n```\n```\n>>> 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)]\n```\n\nIt works! Yay! \ud83c\udf89\n\nBut... something is not right.\n\n> [!WARNING]\n> What if a new class `Triangle` is added to the hierarchy? We must remember to add it to the `Union` type in `Container`. \n\n> [!CAUTION]\n> What if we want to add the `Triangle` class to the hierarchy, but the `Container` class is defined in a different library? We can't, unless we do some radioactive monkey patching. \u2622\ufe0f \n\n> [!WARNING]\n> Moreover, the `Union` type is not very readable, and it will completely mess up every type hint in the `Container` class. The IDE will complain, the type checker will complain, and you will too. \ud83d\ude21\n\n### The `pydantic-discriminator` solution\n\nThis library provides a solution to this problem by using a modified `BaseModel` class that can handle this situation. No more `Union` types, no more monkey patching, no more type checker errors.\n\n> [!NOTE]\n> All the pydantic features should be preserved. The new base class just adds some additional functionality.\n\nLet's go back to the original class hierarchy, but applying the following changes:\n- The `Shape` class is now a `DiscriminatedBaseModel` class.\n- All classes have a class keyword argument `discriminator` 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.\n\n```python\nfrom pydantic_discriminator import DiscriminatedBaseModel\n\nclass Shape(DiscriminatedBaseModel):\n    x: float\n    y: float\n\nclass Circle(Shape, discriminator=\"circle\"):\n    radius: float\n\nclass Hexagon(Shape, discriminator=\"hexagon\"):\n    radius: float\n\nclass Rectangle(Shape, discriminator=\"rectangle\"):\n    width: float\n    height: float\n\nclass Container(BaseModel):\n    shapes: list[Shape]\n```\n```\n>>> 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)]\n```\n\nIt works too! Yay! \ud83c\udf89\n\n> [!NOTE]\n> The code is now much more clean and readable. It is basically the same as the original code, with the addition of the `discriminator` keyword argument.\n\n> [!NOTE]\n> Adding a new class to the hierarchy is now as easy as adding a new class to the hierarchy. No need to modify the `Container` class. The new class can also be located in different modules or libraries, as long as it is imported somewhere in the program and the `discriminator` keyword argument is set correctly.\n\n> [!NOTE]\n> The IDE and the type checker will be happy too. \ud83d\ude0a\n\nUnder the hood, what happens is that the `DiscriminatedBaseModel` class will automatically add a `type_` (aliased to `type` to 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 whose `discriminator` keyword argument matches the value of the `type_` field. \n\nClasses are registered automatically when they are defined in a tree structure, so there is no need to do anything else.",
    "bugtrack_url": null,
    "license": "This is free and unencumbered software released into the public domain.  Anyone is free to copy, modify, publish, use, compile, sell, or distribute this software, either in source code form or as a compiled binary, for any purpose, commercial or non-commercial, and by any means.  In jurisdictions that recognize copyright laws, the author or authors of this software dedicate any and all copyright interest in the software to the public domain. We make this dedication for the benefit of the public at large and to the detriment of our heirs and successors. We intend this dedication to be an overt act of relinquishment in perpetuity of all present and future rights to this software under copyright law.  THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.  For more information, please refer to <https://unlicense.org>",
    "summary": "Pydantic discriminators for polymorphic models",
    "version": "0.1.0",
    "project_urls": {
        "Repository": "https://github.com/lucabonfiglioli/pydantic-discriminator"
    },
    "split_keywords": [
        "discriminator",
        "polymorphism",
        "pydantic"
    ],
    "urls": [
        {
            "comment_text": "",
            "digests": {
                "blake2b_256": "1ab5d4b730b9df1344d4b4dd7cc068c700d9073ac051f7a053a89c5de53e18d5",
                "md5": "9028354c902b4ded8f1dfcbe2cb0cdd0",
                "sha256": "6cc065fea789ed7895121d43b8394b0010e74aeac71738e3f9a8f0078e17ef5c"
            },
            "downloads": -1,
            "filename": "pydantic_discriminator-0.1.0-py3-none-any.whl",
            "has_sig": false,
            "md5_digest": "9028354c902b4ded8f1dfcbe2cb0cdd0",
            "packagetype": "bdist_wheel",
            "python_version": "py3",
            "requires_python": ">=3.9",
            "size": 9103,
            "upload_time": "2024-01-23T20:48:04",
            "upload_time_iso_8601": "2024-01-23T20:48:04.032926Z",
            "url": "https://files.pythonhosted.org/packages/1a/b5/d4b730b9df1344d4b4dd7cc068c700d9073ac051f7a053a89c5de53e18d5/pydantic_discriminator-0.1.0-py3-none-any.whl",
            "yanked": false,
            "yanked_reason": null
        },
        {
            "comment_text": "",
            "digests": {
                "blake2b_256": "203b76f3b9a386d3c21adff0a2dc7c24fb09315a997991fdebc4741df4124710",
                "md5": "5150aef36eba3135f832927966b1087f",
                "sha256": "d2adac6bc37fab0e2977259669d61c9a61fce6685effaf7f7e986462f09e556f"
            },
            "downloads": -1,
            "filename": "pydantic_discriminator-0.1.0.tar.gz",
            "has_sig": false,
            "md5_digest": "5150aef36eba3135f832927966b1087f",
            "packagetype": "sdist",
            "python_version": "source",
            "requires_python": ">=3.9",
            "size": 11160,
            "upload_time": "2024-01-23T20:48:06",
            "upload_time_iso_8601": "2024-01-23T20:48:06.260490Z",
            "url": "https://files.pythonhosted.org/packages/20/3b/76f3b9a386d3c21adff0a2dc7c24fb09315a997991fdebc4741df4124710/pydantic_discriminator-0.1.0.tar.gz",
            "yanked": false,
            "yanked_reason": null
        }
    ],
    "upload_time": "2024-01-23 20:48:06",
    "github": true,
    "gitlab": false,
    "bitbucket": false,
    "codeberg": false,
    "github_user": "lucabonfiglioli",
    "github_project": "pydantic-discriminator",
    "travis_ci": false,
    "coveralls": true,
    "github_actions": false,
    "tox": true,
    "lcname": "pydantic-discriminator"
}
        
Elapsed time: 0.18665s