dynapydantic 0.1.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 +19 -0
- dynapydantic/exceptions.py +17 -0
- dynapydantic/py.typed +0 -0
- dynapydantic/subclass_tracking_model.py +119 -0
- dynapydantic/tracking_group.py +203 -0
- dynapydantic-0.1.0.dist-info/METADATA +21 -0
- dynapydantic-0.1.0.dist-info/RECORD +9 -0
- dynapydantic-0.1.0.dist-info/WHEEL +4 -0
- dynapydantic-0.1.0.dist-info/licenses/LICENSE +21 -0
dynapydantic/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""dynapydantic - dynamic tracking of pydantic models"""
|
|
2
|
+
|
|
3
|
+
from .exceptions import (
|
|
4
|
+
AmbiguousDiscriminatorValueError,
|
|
5
|
+
ConfigurationError,
|
|
6
|
+
Error,
|
|
7
|
+
RegistrationError,
|
|
8
|
+
)
|
|
9
|
+
from .subclass_tracking_model import SubclassTrackingModel
|
|
10
|
+
from .tracking_group import TrackingGroup
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"AmbiguousDiscriminatorValueError",
|
|
14
|
+
"ConfigurationError",
|
|
15
|
+
"Error",
|
|
16
|
+
"RegistrationError",
|
|
17
|
+
"SubclassTrackingModel",
|
|
18
|
+
"TrackingGroup",
|
|
19
|
+
]
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Custom exception types"""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Error(Exception):
|
|
5
|
+
"""Base class for all dynapydanitc errors"""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class RegistrationError(Error):
|
|
9
|
+
"""Occurs when a model cannot be registered"""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AmbiguousDiscriminatorValueError(Error):
|
|
13
|
+
"""Occurs when the discriminator value is ambiguous"""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ConfigurationError(Error):
|
|
17
|
+
"""Occurs when the user misconfigured a tracking setup"""
|
dynapydantic/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Base class for dynamic pydantic models"""
|
|
2
|
+
|
|
3
|
+
import typing as ty
|
|
4
|
+
|
|
5
|
+
import pydantic
|
|
6
|
+
|
|
7
|
+
from .exceptions import ConfigurationError
|
|
8
|
+
from .tracking_group import TrackingGroup
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def direct_children_of_base_in_mro(derived: type, base: type) -> list[type]:
|
|
12
|
+
"""Find all classes in derived's MRO that are direct subclasses of base.
|
|
13
|
+
|
|
14
|
+
Parameters
|
|
15
|
+
----------
|
|
16
|
+
derived
|
|
17
|
+
The class whose MRO is being examined.
|
|
18
|
+
base
|
|
19
|
+
The base class to find direct subclasses of.
|
|
20
|
+
|
|
21
|
+
Returns
|
|
22
|
+
-------
|
|
23
|
+
Classes in derived's MRO that are direct subclasses of base.
|
|
24
|
+
"""
|
|
25
|
+
return [cls for cls in derived.__mro__ if cls is not base and base in cls.__bases__]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class SubclassTrackingModel(pydantic.BaseModel):
|
|
29
|
+
"""Subclass-tracking BaseModel"""
|
|
30
|
+
|
|
31
|
+
def __init_subclass__(
|
|
32
|
+
cls,
|
|
33
|
+
*args,
|
|
34
|
+
exclude_from_union: bool | None = None,
|
|
35
|
+
**kwargs,
|
|
36
|
+
) -> None:
|
|
37
|
+
"""Subclass hook"""
|
|
38
|
+
# Intercept any kwargs that are intended for TrackingGroup
|
|
39
|
+
super().__pydantic_init_subclass__(
|
|
40
|
+
*args,
|
|
41
|
+
**{k: v for k, v in kwargs.items() if k not in TrackingGroup.model_fields},
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
@classmethod
|
|
45
|
+
def __pydantic_init_subclass__(
|
|
46
|
+
cls,
|
|
47
|
+
*args,
|
|
48
|
+
exclude_from_union: bool | None = None,
|
|
49
|
+
**kwargs,
|
|
50
|
+
) -> None:
|
|
51
|
+
"""Pydantic subclass hook"""
|
|
52
|
+
if SubclassTrackingModel in cls.__bases__:
|
|
53
|
+
# Intercept any kwargs that are intended for TrackingGroup
|
|
54
|
+
super().__pydantic_init_subclass__(
|
|
55
|
+
*args,
|
|
56
|
+
**{
|
|
57
|
+
k: v
|
|
58
|
+
for k, v in kwargs.items()
|
|
59
|
+
if k not in TrackingGroup.model_fields
|
|
60
|
+
},
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
if isinstance(getattr(cls, "tracking_config", None), TrackingGroup):
|
|
64
|
+
cls.__DYNAPYDANTIC__ = cls.tracking_config
|
|
65
|
+
else:
|
|
66
|
+
try:
|
|
67
|
+
cls.__DYNAPYDANTIC__: TrackingGroup = TrackingGroup.model_validate(
|
|
68
|
+
{"name": f"{cls.__name__}-subclasses"} | kwargs,
|
|
69
|
+
)
|
|
70
|
+
except pydantic.ValidationError as e:
|
|
71
|
+
msg = (
|
|
72
|
+
"SubclassTrackingModel subclasses must either have a "
|
|
73
|
+
"tracking_config: ClassVar[dynapydantic.TrackingGroup] "
|
|
74
|
+
"member or pass kwargs sufficient to construct a "
|
|
75
|
+
"dynapydantic.TrackingGroup in the class declaration. "
|
|
76
|
+
"The latter approach produced the following "
|
|
77
|
+
f"ValidationError:\n{e}"
|
|
78
|
+
)
|
|
79
|
+
raise ConfigurationError(msg) from e
|
|
80
|
+
|
|
81
|
+
# Promote the tracking group's methods to the parent class
|
|
82
|
+
if cls.__DYNAPYDANTIC__.plugin_entry_point is not None:
|
|
83
|
+
|
|
84
|
+
def _load_plugins() -> None:
|
|
85
|
+
"""Load plugins to register more models"""
|
|
86
|
+
cls.__DYNAPYDANTIC__.load_plugins()
|
|
87
|
+
|
|
88
|
+
cls.load_plugins = staticmethod(_load_plugins)
|
|
89
|
+
|
|
90
|
+
def _union(*, annotated: bool = True) -> ty.GenericAlias:
|
|
91
|
+
"""Get the union of all tracked subclasses
|
|
92
|
+
|
|
93
|
+
Parameters
|
|
94
|
+
----------
|
|
95
|
+
annotated
|
|
96
|
+
Whether this should be an annotated union for usage as a
|
|
97
|
+
pydantic field annotation, or a plain typing.Union for a
|
|
98
|
+
regular type annotation.
|
|
99
|
+
"""
|
|
100
|
+
return cls.__DYNAPYDANTIC__.union(annotated=annotated)
|
|
101
|
+
|
|
102
|
+
cls.union = staticmethod(_union)
|
|
103
|
+
|
|
104
|
+
def _subclasses() -> dict[str, type[cls]]:
|
|
105
|
+
"""Return a mapping of discriminator values to registered model"""
|
|
106
|
+
return cls.__DYNAPYDANTIC__.models
|
|
107
|
+
|
|
108
|
+
cls.registered_subclasses = staticmethod(_subclasses)
|
|
109
|
+
|
|
110
|
+
return
|
|
111
|
+
|
|
112
|
+
super().__pydantic_init_subclass__(*args, **kwargs)
|
|
113
|
+
|
|
114
|
+
if exclude_from_union:
|
|
115
|
+
return
|
|
116
|
+
|
|
117
|
+
supers = direct_children_of_base_in_mro(cls, SubclassTrackingModel)
|
|
118
|
+
for base in supers:
|
|
119
|
+
base.__DYNAPYDANTIC__.register_model(cls)
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""Base class for dynamic pydantic models"""
|
|
2
|
+
|
|
3
|
+
import typing as ty
|
|
4
|
+
|
|
5
|
+
import pydantic
|
|
6
|
+
import pydantic.fields
|
|
7
|
+
import pydantic_core
|
|
8
|
+
|
|
9
|
+
from .exceptions import AmbiguousDiscriminatorValueError, RegistrationError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _inject_discriminator_field(
|
|
13
|
+
cls: type[pydantic.BaseModel],
|
|
14
|
+
disc_field: str,
|
|
15
|
+
value: str,
|
|
16
|
+
) -> pydantic.fields.FieldInfo:
|
|
17
|
+
"""Injects the discriminator field into the given model
|
|
18
|
+
|
|
19
|
+
Parameters
|
|
20
|
+
----------
|
|
21
|
+
cls
|
|
22
|
+
The BaseModel subclass
|
|
23
|
+
disc_field
|
|
24
|
+
Name of the discriminator field
|
|
25
|
+
value
|
|
26
|
+
Value of the discriminator field
|
|
27
|
+
"""
|
|
28
|
+
cls.model_fields[disc_field] = pydantic.fields.FieldInfo(
|
|
29
|
+
default=value,
|
|
30
|
+
annotation=ty.Literal[value],
|
|
31
|
+
frozen=True,
|
|
32
|
+
)
|
|
33
|
+
cls.model_rebuild(force=True)
|
|
34
|
+
return cls.model_fields[disc_field]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class TrackingGroup(pydantic.BaseModel):
|
|
38
|
+
"""Tracker for pydantic models"""
|
|
39
|
+
|
|
40
|
+
name: str = pydantic.Field(
|
|
41
|
+
description=(
|
|
42
|
+
"Name of the tracking group. This is for human display, so it "
|
|
43
|
+
"doesn't technically need to be globally unique, but it should be "
|
|
44
|
+
"meaningfully named, as it will be used in error messages."
|
|
45
|
+
),
|
|
46
|
+
)
|
|
47
|
+
discriminator_field: str = pydantic.Field(
|
|
48
|
+
description="Name of the discriminator field",
|
|
49
|
+
)
|
|
50
|
+
plugin_entry_point: str | None = pydantic.Field(
|
|
51
|
+
None,
|
|
52
|
+
description=(
|
|
53
|
+
"If given, then plugins packages will be supported through this "
|
|
54
|
+
"Python entrypoint. The entrypoint can either be a function, "
|
|
55
|
+
"which will be called, or simply a module, which will be "
|
|
56
|
+
"imported. In either case, models found along the import path of "
|
|
57
|
+
"the entrypoint will be registered. If the entrypoint is a "
|
|
58
|
+
"function, additional models may be declared in the function."
|
|
59
|
+
),
|
|
60
|
+
)
|
|
61
|
+
discriminator_value_generator: ty.Callable[[type], str] | None = pydantic.Field(
|
|
62
|
+
None,
|
|
63
|
+
description=(
|
|
64
|
+
"A callable that produces default values for the discriminator field"
|
|
65
|
+
),
|
|
66
|
+
)
|
|
67
|
+
models: dict[str, type[pydantic.BaseModel]] = pydantic.Field(
|
|
68
|
+
{},
|
|
69
|
+
description="The tracked models",
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
def load_plugins(self) -> None:
|
|
73
|
+
"""Load plugins to discover/register additional models"""
|
|
74
|
+
if self.plugin_entry_point is None:
|
|
75
|
+
return
|
|
76
|
+
|
|
77
|
+
from importlib.metadata import entry_points # noqa: PLC0415
|
|
78
|
+
|
|
79
|
+
for ep in entry_points().select(group=self.plugin_entry_point):
|
|
80
|
+
plugin = ep.load()
|
|
81
|
+
if callable(plugin):
|
|
82
|
+
plugin()
|
|
83
|
+
|
|
84
|
+
def register(
|
|
85
|
+
self,
|
|
86
|
+
discriminator_value: str | None = None,
|
|
87
|
+
) -> ty.Callable[[type], type]:
|
|
88
|
+
"""Register a model into this group (decorator)
|
|
89
|
+
|
|
90
|
+
Parameters
|
|
91
|
+
----------
|
|
92
|
+
discriminator_value
|
|
93
|
+
Value for the discriminator field. If not given, then
|
|
94
|
+
discriminator_value_generator must be non-None or the
|
|
95
|
+
discriminator field must be declared by hand.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
def _wrapper(cls: type[pydantic.BaseModel]) -> None:
|
|
99
|
+
disc = self.discriminator_field
|
|
100
|
+
field = cls.model_fields.get(self.discriminator_field)
|
|
101
|
+
if field is None:
|
|
102
|
+
if discriminator_value is not None:
|
|
103
|
+
_inject_discriminator_field(cls, disc, discriminator_value)
|
|
104
|
+
elif self.discriminator_value_generator is not None:
|
|
105
|
+
_inject_discriminator_field(
|
|
106
|
+
cls,
|
|
107
|
+
disc,
|
|
108
|
+
self.discriminator_value_generator(cls),
|
|
109
|
+
)
|
|
110
|
+
else:
|
|
111
|
+
msg = (
|
|
112
|
+
f"unable to determine a discriminator value for "
|
|
113
|
+
f'{cls.__name__} in tracking group "{self.name}". No '
|
|
114
|
+
"value was passed to register(), "
|
|
115
|
+
"discriminator_value_generator was None and the "
|
|
116
|
+
f'"{disc}" field was not defined.'
|
|
117
|
+
)
|
|
118
|
+
raise RegistrationError(msg)
|
|
119
|
+
elif (
|
|
120
|
+
discriminator_value is not None and field.default != discriminator_value
|
|
121
|
+
):
|
|
122
|
+
msg = (
|
|
123
|
+
f"the discriminator value for {cls.__name__} was "
|
|
124
|
+
f'ambiguous, it was set to "{discriminator_value}" via '
|
|
125
|
+
f'register() and "{field.default}" via the discriminator '
|
|
126
|
+
f"field ({self.discriminator_field})."
|
|
127
|
+
)
|
|
128
|
+
raise AmbiguousDiscriminatorValueError(msg)
|
|
129
|
+
|
|
130
|
+
self._register_with_discriminator_field(cls)
|
|
131
|
+
return cls
|
|
132
|
+
|
|
133
|
+
return _wrapper
|
|
134
|
+
|
|
135
|
+
def register_model(self, cls: type[pydantic.BaseModel]) -> None:
|
|
136
|
+
"""Register the given model into this group
|
|
137
|
+
|
|
138
|
+
Parameters
|
|
139
|
+
----------
|
|
140
|
+
cls
|
|
141
|
+
The model to register
|
|
142
|
+
"""
|
|
143
|
+
disc = self.discriminator_field
|
|
144
|
+
if cls.model_fields.get(self.discriminator_field) is None:
|
|
145
|
+
if self.discriminator_value_generator is not None:
|
|
146
|
+
_inject_discriminator_field(
|
|
147
|
+
cls,
|
|
148
|
+
disc,
|
|
149
|
+
self.discriminator_value_generator(cls),
|
|
150
|
+
)
|
|
151
|
+
else:
|
|
152
|
+
msg = (
|
|
153
|
+
f"unable to determine a discriminator value for "
|
|
154
|
+
f'{cls.__name__} in tracking group "{self.name}", '
|
|
155
|
+
"discriminator_value_generator was None and the "
|
|
156
|
+
f'"{disc}" field was not defined.'
|
|
157
|
+
)
|
|
158
|
+
raise RegistrationError(msg)
|
|
159
|
+
|
|
160
|
+
self._register_with_discriminator_field(cls)
|
|
161
|
+
|
|
162
|
+
def _register_with_discriminator_field(self, cls: type[pydantic.BaseModel]) -> None:
|
|
163
|
+
"""Register the model with the default of the discriminator field
|
|
164
|
+
|
|
165
|
+
Parameters
|
|
166
|
+
----------
|
|
167
|
+
cls
|
|
168
|
+
The class to register, must have the disciminator field set with a
|
|
169
|
+
unique default value in the group.
|
|
170
|
+
"""
|
|
171
|
+
disc = self.discriminator_field
|
|
172
|
+
field = cls.model_fields.get(disc)
|
|
173
|
+
value = field.default
|
|
174
|
+
if value == pydantic_core.PydanticUndefined:
|
|
175
|
+
msg = (
|
|
176
|
+
f"{cls.__name__}.{disc} had no default value, it must "
|
|
177
|
+
"have one which is unique among all tracked models."
|
|
178
|
+
)
|
|
179
|
+
raise RegistrationError(msg)
|
|
180
|
+
|
|
181
|
+
if (other := self.models.get(value)) is not None and other is not cls:
|
|
182
|
+
msg = (
|
|
183
|
+
f'Cannot register {cls.__name__} under the "{value}" '
|
|
184
|
+
f"identifier, which is already in use by {other.__name__}."
|
|
185
|
+
)
|
|
186
|
+
raise RegistrationError(msg)
|
|
187
|
+
|
|
188
|
+
self.models[value] = cls
|
|
189
|
+
|
|
190
|
+
def union(self, *, annotated: bool = True) -> ty.GenericAlias:
|
|
191
|
+
"""Return the union of all registered models"""
|
|
192
|
+
return (
|
|
193
|
+
ty.Annotated[
|
|
194
|
+
ty.Union[ # noqa: UP007
|
|
195
|
+
tuple(
|
|
196
|
+
ty.Annotated[x, pydantic.Tag(v)] for v, x in self.models.items()
|
|
197
|
+
)
|
|
198
|
+
],
|
|
199
|
+
pydantic.Field(discriminator=self.discriminator_field),
|
|
200
|
+
]
|
|
201
|
+
if annotated
|
|
202
|
+
else ty.Union[tuple(self.models.values())] # noqa: UP007
|
|
203
|
+
)
|
|
@@ -0,0 +1,21 @@
|
|
|
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
|
+
[](https://github.com/psalvaggio/dynapydantic/actions/workflows/ci.yml)
|
|
14
|
+
[](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.
|
|
@@ -0,0 +1,9 @@
|
|
|
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,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Philip Salvaggio
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|