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.
@@ -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
+ [![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.
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.