dynapydantic 0.1.0__py3-none-any.whl → 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
dynapydantic/__init__.py CHANGED
@@ -6,6 +6,7 @@ from .exceptions import (
6
6
  Error,
7
7
  RegistrationError,
8
8
  )
9
+ from .polymorphic import Polymorphic
9
10
  from .subclass_tracking_model import SubclassTrackingModel
10
11
  from .tracking_group import TrackingGroup
11
12
 
@@ -13,6 +14,7 @@ __all__ = [
13
14
  "AmbiguousDiscriminatorValueError",
14
15
  "ConfigurationError",
15
16
  "Error",
17
+ "Polymorphic",
16
18
  "RegistrationError",
17
19
  "SubclassTrackingModel",
18
20
  "TrackingGroup",
@@ -0,0 +1,29 @@
1
+ """Definition for Polymorphic"""
2
+
3
+ import typing as ty
4
+
5
+ from .subclass_tracking_model import SubclassTrackingModel
6
+
7
+ ModelT = ty.TypeVar("ModelT", bound=SubclassTrackingModel)
8
+
9
+ if ty.TYPE_CHECKING: # pragma: no cover
10
+ Polymorphic = ty.Annotated[ModelT, ...]
11
+ else:
12
+
13
+ class Polymorphic:
14
+ """Annotation used to mark a type as having duck-typing behavior
15
+
16
+ This annotation is only valid for SubclassTrackingModel's.
17
+
18
+ Similar to SerializeAsAny, a field annotated with this shall serialize as
19
+ according to its actual type, not the field annotation type. In addition,
20
+ parsing will function as if the field annotation type were the union of
21
+ all tracked subclasses.
22
+ """
23
+
24
+ def __class_getitem__(cls, item: ModelT) -> ty.Any: # noqa: ANN401
25
+ """Get the annotation for the pydantic field"""
26
+ if not isinstance(item, type):
27
+ msg = f"dynapydantic.Polymorphic must be given a type, not {item}"
28
+ raise TypeError(msg)
29
+ return ty.Annotated[item, SubclassTrackingModel.PydanticAdaptor]
@@ -1,8 +1,12 @@
1
1
  """Base class for dynamic pydantic models"""
2
2
 
3
+ import inspect
3
4
  import typing as ty
4
5
 
5
6
  import pydantic
7
+ from pydantic import GetCoreSchemaHandler
8
+ from pydantic.errors import PydanticSchemaGenerationError
9
+ from pydantic_core import core_schema
6
10
 
7
11
  from .exceptions import ConfigurationError
8
12
  from .tracking_group import TrackingGroup
@@ -26,19 +30,37 @@ def direct_children_of_base_in_mro(derived: type, base: type) -> list[type]:
26
30
 
27
31
 
28
32
  class SubclassTrackingModel(pydantic.BaseModel):
29
- """Subclass-tracking BaseModel"""
33
+ """Subclass-tracking BaseModel
34
+
35
+ This will inject a TrackingGroup into your class and automate the
36
+ registration of subclasses.
37
+
38
+ Inheriting from this class will augment your class with the following
39
+ members functions:
40
+ 1. registered_subclasses() -> dict[str, type[cls]]:
41
+ This will return a mapping of discriminator value to the corresponding
42
+ sublcass. See TrackingGroup.models for details.
43
+ 2. union() -> typing.GenericAlias:
44
+ This will return an (optionally) annotated subclass union. See
45
+ TrackingGroup.union() for details.
46
+ 3. load_plugins() -> None:
47
+ If plugin_entry_point was specified, then this method will load plugin
48
+ packages to discover additional subclasses. See
49
+ TrackingGroup.load_plugins for more details.
50
+ """
30
51
 
31
- def __init_subclass__(
32
- cls,
33
- *args,
34
- exclude_from_union: bool | None = None,
35
- **kwargs,
36
- ) -> None:
52
+ def __init_subclass__(cls, *args, **kwargs) -> None:
37
53
  """Subclass hook"""
38
- # Intercept any kwargs that are intended for TrackingGroup
39
- super().__pydantic_init_subclass__(
54
+ # Intercept any kwargs that are intended for TrackingGroup or
55
+ # __pydantic_init_subclass__
56
+ sig = inspect.signature(SubclassTrackingModel.__pydantic_init_subclass__)
57
+ super().__init_subclass__(
40
58
  *args,
41
- **{k: v for k, v in kwargs.items() if k not in TrackingGroup.model_fields},
59
+ **{
60
+ k: v
61
+ for k, v in kwargs.items()
62
+ if k not in TrackingGroup.model_fields and k not in sig.parameters
63
+ },
42
64
  )
43
65
 
44
66
  @classmethod
@@ -60,8 +82,8 @@ class SubclassTrackingModel(pydantic.BaseModel):
60
82
  },
61
83
  )
62
84
 
63
- if isinstance(getattr(cls, "tracking_config", None), TrackingGroup):
64
- cls.__DYNAPYDANTIC__ = cls.tracking_config
85
+ if isinstance((tc := getattr(cls, "tracking_config", None)), TrackingGroup):
86
+ cls.__DYNAPYDANTIC__ = tc
65
87
  else:
66
88
  try:
67
89
  cls.__DYNAPYDANTIC__: TrackingGroup = TrackingGroup.model_validate(
@@ -101,7 +123,7 @@ class SubclassTrackingModel(pydantic.BaseModel):
101
123
 
102
124
  cls.union = staticmethod(_union)
103
125
 
104
- def _subclasses() -> dict[str, type[cls]]:
126
+ def _subclasses() -> dict[str, type[pydantic.BaseModel]]:
105
127
  """Return a mapping of discriminator values to registered model"""
106
128
  return cls.__DYNAPYDANTIC__.models
107
129
 
@@ -117,3 +139,23 @@ class SubclassTrackingModel(pydantic.BaseModel):
117
139
  supers = direct_children_of_base_in_mro(cls, SubclassTrackingModel)
118
140
  for base in supers:
119
141
  base.__DYNAPYDANTIC__.register_model(cls)
142
+
143
+ class PydanticAdaptor:
144
+ """Pydantic type adaptor for SubclassTrackingModel"""
145
+
146
+ @staticmethod
147
+ def __get_pydantic_core_schema__(
148
+ source_type: ty.Any, # noqa: ANN401
149
+ handler: GetCoreSchemaHandler,
150
+ ) -> core_schema.CoreSchema:
151
+ """Get the pydantic schema for this type"""
152
+ if not isinstance(source_type, type) or not issubclass(
153
+ source_type,
154
+ SubclassTrackingModel,
155
+ ):
156
+ msg = (
157
+ f"{source_type} was not a SubclassTrackingModel, "
158
+ "so it is incompatible with dynapydantic.Polymorphic"
159
+ )
160
+ raise PydanticSchemaGenerationError(msg)
161
+ return handler(source_type.union())
@@ -1,5 +1,6 @@
1
1
  """Base class for dynamic pydantic models"""
2
2
 
3
+ import contextlib
3
4
  import typing as ty
4
5
 
5
6
  import pydantic
@@ -27,10 +28,11 @@ def _inject_discriminator_field(
27
28
  """
28
29
  cls.model_fields[disc_field] = pydantic.fields.FieldInfo(
29
30
  default=value,
30
- annotation=ty.Literal[value],
31
+ annotation=ty.Literal[value], # type: ignore[not-a-type]
31
32
  frozen=True,
32
33
  )
33
- cls.model_rebuild(force=True)
34
+ with contextlib.suppress(pydantic.errors.PydanticUndefinedAnnotation):
35
+ cls.model_rebuild(force=True)
34
36
  return cls.model_fields[disc_field]
35
37
 
36
38
 
@@ -95,7 +97,7 @@ class TrackingGroup(pydantic.BaseModel):
95
97
  discriminator field must be declared by hand.
96
98
  """
97
99
 
98
- def _wrapper(cls: type[pydantic.BaseModel]) -> None:
100
+ def _wrapper(cls: type[pydantic.BaseModel]) -> type[pydantic.BaseModel]:
99
101
  disc = self.discriminator_field
100
102
  field = cls.model_fields.get(self.discriminator_field)
101
103
  if field is None:
@@ -169,8 +171,7 @@ class TrackingGroup(pydantic.BaseModel):
169
171
  unique default value in the group.
170
172
  """
171
173
  disc = self.discriminator_field
172
- field = cls.model_fields.get(disc)
173
- value = field.default
174
+ value = cls.model_fields[disc].default
174
175
  if value == pydantic_core.PydanticUndefined:
175
176
  msg = (
176
177
  f"{cls.__name__}.{disc} had no default value, it must "
@@ -187,17 +188,20 @@ class TrackingGroup(pydantic.BaseModel):
187
188
 
188
189
  self.models[value] = cls
189
190
 
190
- def union(self, *, annotated: bool = True) -> ty.GenericAlias:
191
+ def union(self, *, annotated: bool = True) -> ty.Any: # noqa: ANN401
191
192
  """Return the union of all registered models"""
192
193
  return (
193
194
  ty.Annotated[
194
195
  ty.Union[ # noqa: UP007
195
- tuple(
196
+ # This is fundamentally incompatible with static type
197
+ # checking, as this is resolved at runtime.
198
+ tuple( # type: ignore[not-a-type]
196
199
  ty.Annotated[x, pydantic.Tag(v)] for v, x in self.models.items()
197
200
  )
198
201
  ],
199
202
  pydantic.Field(discriminator=self.discriminator_field),
200
203
  ]
201
204
  if annotated
205
+ # type: ignore[not-a-type]
202
206
  else ty.Union[tuple(self.models.values())] # noqa: UP007
203
207
  )
@@ -0,0 +1,222 @@
1
+ Metadata-Version: 2.4
2
+ Name: dynapydantic
3
+ Version: 0.2.0
4
+ Summary: Dyanmic pydantic models
5
+ Author-email: Philip Salvaggio <salvaggio.philip@gmail.com>
6
+ License-File: LICENSE
7
+ Requires-Python: >=3.10
8
+ Requires-Dist: pydantic>=2.0
9
+ Description-Content-Type: text/markdown
10
+
11
+ # dynapydantic
12
+
13
+ [![CI](https://github.com/psalvaggio/dynapydantic/actions/workflows/ci.yml/badge.svg)](https://github.com/psalvaggio/dynapydantic/actions/workflows/ci.yml)
14
+ [![Pre-commit](https://github.com/psalvaggio/dynapydantic/actions/workflows/pre-commit.yml/badge.svg)](https://github.com/psalvaggio/dynapydantic/actions/workflows/pre-commit.yml)
15
+ [![Docs](https://img.shields.io/badge/docs-Docs-blue?style=flat-square&logo=github&logoColor=white&link=https://psalvaggio.github.io/dynapydantic/dev/)](https://psalvaggio.github.io/dynapydantic/dev/)
16
+ [![PyPI - Version](https://img.shields.io/pypi/v/dynapydantic)](https://pypi.org/project/dynapydantic/)
17
+ [![Coverage Status](https://coveralls.io/repos/github/psalvaggio/dynapydantic/badge.svg?branch=main)](https://coveralls.io/github/psalvaggio/dynapydantic?branch=main)
18
+ [![Conda Version](https://img.shields.io/conda/v/conda-forge/dynapydantic)](https://anaconda.org/conda-forge/dynapydantic)
19
+
20
+
21
+ `dynapydantic` is an extension to the [pydantic](https://pydantic.dev) Python
22
+ package that allow for dynamic tracking of `pydantic.BaseModel` subclasses.
23
+
24
+ ## Installation
25
+ This project can be installed via PyPI:
26
+ ```
27
+ pip install dynapydantic
28
+ ```
29
+ or with `conda` via the `conda-forge` channel:
30
+ ```
31
+ conda install dynapydantic
32
+ ```
33
+
34
+
35
+ ## Motiviation
36
+ Consider the following simple class setup:
37
+ ```python
38
+ import pydantic
39
+
40
+ class Base(pydantic.BaseModel):
41
+ pass
42
+
43
+ class A(Base):
44
+ field: int
45
+
46
+ class B(Base):
47
+ field: str
48
+
49
+ class Model(pydantic.BaseModel):
50
+ val: Base
51
+ ```
52
+ As expected, we can use `A`'s and `B`'s for `Model.val`:
53
+ ```python
54
+ >>> m = Model(val=A(field=1))
55
+ >>> m
56
+ Model(val=A(field=1))
57
+ ```
58
+ However, we quickly run into trouble when serializing and validating:
59
+ ```python
60
+ >>> m.model_dump()
61
+ {'base': {}}
62
+ >>> m.model_dump(serialize_as_any=True)
63
+ {'val': {'field': 1}}
64
+ >>> Model.model_validate(m.model_dump(serialize_as_any=True))
65
+ Model(val=Base())
66
+ ```
67
+
68
+ Pydantic provides a solution for serialization via `serialize_as_any` (and
69
+ its corresponding field annotation `SerializeAsAny`), but offers no native
70
+ solution for the validation half. Currently, the canonical way of doing this
71
+ is to annotate the field as a discriminated union of all subclasses. Often, a
72
+ single field in the model is chosen as the "discriminator". This library,
73
+ `dynapydantic`, automates this process.
74
+
75
+ Let's reframe the above problem with `dynapydantic`:
76
+ ```python
77
+ import dynapydantic
78
+ import pydantic
79
+
80
+ class Base(
81
+ dynapydantic.SubclassTrackingModel,
82
+ discriminator_field="name",
83
+ discriminator_value_generator=lambda t: t.__name__,
84
+ ):
85
+ pass
86
+
87
+ class A(Base):
88
+ field: int
89
+
90
+ class B(Base):
91
+ field: str
92
+
93
+ class Model(pydantic.BaseModel):
94
+ val: dynapydantic.Polymorphic[Base]
95
+ ```
96
+ Now, the same set of operations works as intended:
97
+ ```python
98
+ >>> m = Model(val=A(field=1))
99
+ >>> m
100
+ Model(val=A(field=1, name='A'))
101
+ >>> m.model_dump()
102
+ {'val': {'field': 1, 'name': 'A'}}
103
+ >>> Model.model_validate(m.model_dump())
104
+ Model(val=A(field=1, name='A')
105
+ ```
106
+
107
+
108
+ ## How it works
109
+
110
+ ### `TrackingGroup`
111
+ The core entity in this library is the `dynapydantic.TrackingGroup`:
112
+ ```python
113
+ import typing as ty
114
+
115
+ import dynapydantic
116
+ import pydantic
117
+
118
+ mygroup = dynapydantic.TrackingGroup(
119
+ name="mygroup",
120
+ discriminator_field="name"
121
+ )
122
+
123
+ @mygroup.register("A")
124
+ class A(pydantic.BaseModel):
125
+ """A class to be tracked, will be tracked as "A"."""
126
+ a: int
127
+
128
+ @mygroup.register()
129
+ class B(pydantic.BaseModel):
130
+ """Another class, will be tracked as "B"."""
131
+ name: ty.Literal["B"] = "B"
132
+ a: int
133
+
134
+ class Model(pydantic.BaseModel):
135
+ """A model that can have A or B"""
136
+ field: mygroup.union() # call after all subclasses have been registered
137
+
138
+ print(Model(field={"name": "A", "a": 4})) # field=A(a=4, name='A')
139
+ print(Model(field={"name": "B", "a": 5})) # field=B(name='B', a=5)
140
+ ```
141
+
142
+ The `union()` method produces a [discriminated union](https://docs.pydantic.dev/latest/concepts/unions/#discriminated-unions)
143
+ of all registered `pydantic.BaseModel` subclasses. It also accepts an
144
+ `annotated=False` keyword argument to produce a plain `typing.Union` for use
145
+ in type annotations, but since this is a runtime-computed union, this will not
146
+ work with static type checkers. This union is based on a discriminator field,
147
+ which was configured by the `discriminator_field` argument to `TrackingGroup`.
148
+ The field can be created by hand, as was shown with `B`, or `dynapydantic`
149
+ will inject it for you, as was shown with `A`.
150
+
151
+ `TrackingGroup` has a few opt-in features to make it more powerful and easier to use:
152
+ 1. `discriminator_value_generator`: This parameter is a optional callback
153
+ function that is called with each class that gets registered and produces a
154
+ default value for the discriminator field. This allows the user to call
155
+ `register()` without a value for the discriminator. For example, passing:
156
+ `lambda cls: cls.__name__` would use the name of the class as the
157
+ discriminator value.
158
+ 2. `plugin_entry_point`: This parameter indicates to `dynapydantic` that there
159
+ might be models to be discovered in other packages. Packages are discovered
160
+ by the Python entrypoint mechanism. See the `tests/example` directory for an
161
+ example of how this works.
162
+
163
+ ### `SubclassTrackingModel`
164
+ The most common use case of this pattern is to automatically register subclasses
165
+ of a given `pydantic.BaseModel`. This is supported via the use of
166
+ `dynapydantic.SubclassTrackingModel`. For example:
167
+ ```python
168
+ import typing as ty
169
+
170
+ import dynapydantic
171
+ import pydantic
172
+
173
+ class Base(
174
+ dynapydantic.SubclassTrackingModel,
175
+ discriminator_field="name",
176
+ discriminator_value_generator=lambda cls: cls.__name__,
177
+ ):
178
+ """Base model, will track its subclasses"""
179
+
180
+ # The TrackingGroup can be specified here like model_config, or passed in
181
+ # kwargs of the class declaration, just like how model_config works with
182
+ # pydantic.BaseModel. If you do it like this, you have to give the tracking
183
+ # group a name, whereas using kwargs will generate the name for you.
184
+ # tracking_config: ty.ClassVar[dynapydantic.TrackingGroup] = dynapydantic.TrackingGroup(
185
+ # name="BaseSubclasses",
186
+ # discriminator_field="name",
187
+ # discriminator_value_generator=lambda cls: cls.__name__,
188
+ # )
189
+
190
+
191
+ class Intermediate(Base, exclude_from_union=True):
192
+ """Subclasses can opt out of being tracked"""
193
+
194
+ class Derived1(Intermediate):
195
+ """Non-direct descendants are registered"""
196
+ a: int
197
+
198
+ class Derived2(Intermediate):
199
+ """You can override the value generator if desired"""
200
+ name: ty.Literal["Custom"] = "Custom"
201
+ a: int
202
+
203
+ print(Base.registered_subclasses())
204
+ # {'Derived1': <class '__main__.Derived1'>, 'Custom': <class '__main__.Derived2'>}
205
+
206
+ # if plugin_entry_point was specificed, load plugin packages
207
+ # Base.load_plugins()
208
+
209
+ class Model(pydantic.BaseModel):
210
+ """A model that can have any registered Base subclass"""
211
+ field: dynapydantic.Polymorphic[Base]
212
+
213
+ print(Model(field={"name": "Derived1", "a": 4}))
214
+ # field=Derived1(a=4, name='Derived1')
215
+ print(Model(field={"name": "Custom", "a": 5}))
216
+ # field=Derived2(name='Custom', a=5)
217
+ ```
218
+ It is important to note that the subclasses that are supported are those that
219
+ were defined *prior* to defining the model that uses `dynapydantic.Polymorphic`
220
+ (`Model` in the above example). If you declare additional subclasses afterwards,
221
+ you must call `.model_rebuild(force=True)` on the model that uses the subclass
222
+ union.
@@ -0,0 +1,10 @@
1
+ dynapydantic/__init__.py,sha256=5i1hbdkJO6fUwO1Sal9CfMv-htg_9MibQemwJH0gFGY,508
2
+ dynapydantic/exceptions.py,sha256=R1wJj-FmKv2JdYSG5HVMkZ0zLyFRKJRuUsBxXGnEjsQ,394
3
+ dynapydantic/polymorphic.py,sha256=qt7m2LBZH5jJzV0Uv8ipO-LUvWDXXPk7rRbAyJ7ghcg,1097
4
+ dynapydantic/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ dynapydantic/subclass_tracking_model.py,sha256=-dVA7eAOlzPDLg75S2PNZ5nuVpZmgbwA3drxclKRNyc,5980
6
+ dynapydantic/tracking_group.py,sha256=EdY9REjM_bhW7MAbFuKtm52ZZvQhBDVb_sght12KxAo,7673
7
+ dynapydantic-0.2.0.dist-info/METADATA,sha256=BHEgxpPBCak2VsCIDgYfkij1OJWYxgtEKaMX47mHqUc,7653
8
+ dynapydantic-0.2.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
9
+ dynapydantic-0.2.0.dist-info/licenses/LICENSE,sha256=I6pwCRw86q30bFjJohgVzXYgCLNCWN3A4jNGJX2iVM4,1073
10
+ dynapydantic-0.2.0.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.27.0
2
+ Generator: hatchling 1.28.0
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
@@ -1,21 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: dynapydantic
3
- Version: 0.1.0
4
- Summary: Dyanmic pydantic models
5
- Author-email: Philip Salvaggio <salvaggio.philip@gmail.com>
6
- License-File: LICENSE
7
- Requires-Python: >=3.10
8
- Requires-Dist: pydantic>=2.0
9
- Description-Content-Type: text/markdown
10
-
11
- # dynapydantic
12
-
13
- [![CI](https://github.com/psalvaggio/dynapydantic/actions/workflows/ci.yml/badge.svg)](https://github.com/psalvaggio/dynapydantic/actions/workflows/ci.yml)
14
- [![Pre-commit](https://github.com/psalvaggio/dynapydantic/actions/workflows/pre-commit.yml/badge.svg)](https://github.com/psalvaggio/dynapydantic/actions/workflows/pre-commit.yml)
15
-
16
- This is a demonstration about how `pydantic` models can track their subclasses
17
- and round-trip through serialization, both within the package in which they are
18
- defined and in other packages via `pluggy`.
19
-
20
- This package is not intended for public use yet. It's strictly a
21
- proof-of-concept.
@@ -1,9 +0,0 @@
1
- dynapydantic/__init__.py,sha256=vIK1dasCAghxjXaUjw8blTHLFwsHZ9L2txuX_8AB7cQ,452
2
- dynapydantic/exceptions.py,sha256=R1wJj-FmKv2JdYSG5HVMkZ0zLyFRKJRuUsBxXGnEjsQ,394
3
- dynapydantic/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
- dynapydantic/subclass_tracking_model.py,sha256=-pE7gEd3ZWmeJ6QW32a4-g-KIK4yEZ-gfvBUPAv0AnM,4145
5
- dynapydantic/tracking_group.py,sha256=-rorYSrjHCCV6yeiulA8lI8e9aVrqG-u57UDgD5gZKA,7342
6
- dynapydantic-0.1.0.dist-info/METADATA,sha256=WWZavxdQLdBBtxOZ0li5JTeNCNtu_QCb573_1s2ytZg,905
7
- dynapydantic-0.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
8
- dynapydantic-0.1.0.dist-info/licenses/LICENSE,sha256=I6pwCRw86q30bFjJohgVzXYgCLNCWN3A4jNGJX2iVM4,1073
9
- dynapydantic-0.1.0.dist-info/RECORD,,