plugantic 0.1.0__tar.gz

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,7 @@
1
+ Copyright 2025 Martin Kunze
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4
+
5
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6
+
7
+ 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 OR COPYRIGHT HOLDERS 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.
@@ -0,0 +1,259 @@
1
+ Metadata-Version: 2.4
2
+ Name: plugantic
3
+ Version: 0.1.0
4
+ Summary: Simplified extendable composition with pydantic
5
+ Author-email: Martin Kunze <martin@martinkunze.com>
6
+ Project-URL: Homepage, https://github.com/martinkunze/plugantic
7
+ Project-URL: Documentation, https://github.com/martinkunze/plugantic/blob/main/README.md
8
+ Project-URL: Source, https://github.com/martinkunze/plugantic
9
+ Requires-Python: >=3.8
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+ Requires-Dist: pydantic
13
+ Requires-Dist: propert
14
+ Requires-Dist: typing-extensions
15
+ Dynamic: license-file
16
+
17
+ # 🧩 Plugantic - Simplified extendable composition with pydantic
18
+
19
+ ## 🤔 Why use `plugantic`?
20
+
21
+ You may have learned that you should avoid inheritance in favor of composition. When using pydantic you can achieve that by using something like the following:
22
+
23
+ ```python
24
+ # Declare a base config
25
+ class OutputConfig(BaseModel):
26
+ mode: str
27
+ def print(self): ...
28
+
29
+ # Declare all implementations of the base config
30
+ class TextConfig(OutputConfig):
31
+ mode: Literal["text"] = "text"
32
+ text: str
33
+ def print(self):
34
+ print(self.text)
35
+
36
+ class NumberConfig(OutputConfig):
37
+ mode: Literal["number"] = "number"
38
+ number: float
39
+ precision: int = 2
40
+ def print(self):
41
+ print(f"{self.number:.{self.precision}f}")
42
+
43
+ # Define a union type of all implementations
44
+ AllOutputConfigs = Annotated[Union[
45
+ TextConfig,
46
+ NumberConfig,
47
+ ], Field(discriminator="mode")]
48
+
49
+ # Use the union type in your model
50
+ class CommonConfig(BaseModel):
51
+ output: AllOutputConfigs
52
+
53
+ ...
54
+
55
+ CommonConfig.model_validate({"output": {
56
+ "mode": "text",
57
+ "text": "Hello World"
58
+ }})
59
+ ```
60
+
61
+ Whilst this works, there are multiple issues and annoyances with that approach:
62
+ - **Hard to maintain**: you need to declare a type union and update it with every change
63
+ - **Not extensible**: adding a different config afterwards would required to update the `AllOutputConfigs` type and all of the objects using it
64
+ - **Redundant definition** of the discriminator field (i.e. `Literal[<x>] = <x>`)
65
+
66
+ This library solves all of these issues (and more), so you can just write
67
+
68
+ ```python
69
+ from plugantic import PluginModel
70
+
71
+ class OutputConfig(PluginModel):
72
+ mode: str
73
+ def print(self): ...
74
+
75
+ class TextConfig(OutputConfig):
76
+ # No redundant "text" definition here!
77
+ mode: Literal["text"]
78
+ text: str
79
+ def print(self):
80
+ print(self.text)
81
+
82
+ class NumberConfig(OutputConfig):
83
+ # No redundant definition here either!
84
+ mode: Literal["number"]
85
+ number: float
86
+ precision: int = 2
87
+ def print(self):
88
+ print(f"{self.number:.{self.precision}f}")
89
+
90
+ # No need to define a union type or a discriminator field!
91
+ # You can just use the base type as a field type!
92
+ class CommonConfig(BaseModel):
93
+ output: OutputConfig
94
+
95
+ # You can even add new configs after the fact!
96
+ class BytesConfig(OutputConfig):
97
+ mode: Literal["bytes"]
98
+ content: bytes
99
+ def print(self):
100
+ print(self.content.decode("utf-8"))
101
+
102
+ ...
103
+
104
+ # The actual type is only evaluated when it is actually needed!
105
+ CommonConfig.model_validate({"output": {
106
+ "mode": "text",
107
+ "text": "Hello World"
108
+ }})
109
+ ```
110
+
111
+ ## ✨ Features
112
+
113
+ ### 🌀 Automatic Downcasts
114
+
115
+ Let's say you have the following logger:
116
+
117
+ ```python
118
+ FeatureNewPage = Literal["newline"]
119
+
120
+ class LoggerBase(PluginModel):
121
+ def log_line(self, line: str, new_page: bool=False): ...
122
+
123
+ class LoggerStdout(LoggerBase, value="stdout"):
124
+ new_page_token: str|None = None
125
+ def log_line(self, line: str, new_page: bool=False):
126
+ if new_page:
127
+ if not self.new_page_token:
128
+ raise ValueError("new_page_token is not set")
129
+ print(self.new_page_token)
130
+ print(line)
131
+
132
+ class Component1(BaseModel):
133
+ logger: LoggerBase
134
+
135
+ class Component2(BaseModel):
136
+ logger: LoggerBase[FeatureNewPage]
137
+ ```
138
+
139
+ then users could not use `Component2` with `LoggerStdout` as it does not support the `FeatureNewPage` feature, even thoudh `LoggerStdout` would support it, if `new_page_token: str` was enforced.
140
+
141
+ Conventionally, this would require the developer to create two classes (i.e. `LoggerStdout` and `LoggerStdoutNewPage`) and then include either one in the final annotated union depending on if the component requires the new page functionality.
142
+
143
+ With `plugantic`, you can automatically create subtypes that are more strict than the base type and they will be automatically validated and downcast when using the model:
144
+
145
+ ```python
146
+ def ensure_new_page_feature(handler: PluginDowncastHandler):
147
+ handler.enable_feature(FeatureNewPage)
148
+ handler.set_field_annotation("new_page_token", str)
149
+ handler.remove_field_default("new_page_token")
150
+
151
+ class LoggerBase(PluginModel, value="stdout", auto_downcasts=(ensure_new_page_feature,)):
152
+ new_page_token: str|None = None
153
+ def log_line(self, line: str, new_page: bool=False):
154
+ if new_page:
155
+ if not self.new_page_token:
156
+ raise ValueError("new_page_token is not set")
157
+ print(self.new_page_token)
158
+ print(line)
159
+ ```
160
+
161
+ By declaring multiple callbacks in `auto_downcasts`, you can create a superset of all possible downcasts and `plugantic` will automatically select the least strict depending on which features you require.
162
+
163
+
164
+ ### 🔌 Extensibility
165
+
166
+ You can add new plugins after the fact!
167
+
168
+ To do so, you will have to ensure one of the following prerequisites:
169
+
170
+ **1. Use `ForwardRef`s**
171
+
172
+ ```python
173
+ from __future__ import annotations # either by importing annotations from the __future__ package
174
+
175
+ class BaseConfig(PluginModel):
176
+ ...
177
+
178
+ ...
179
+
180
+ class CommonConfig1(BaseModel):
181
+ config: BaseConfig
182
+
183
+ class CommonConfig2(BaseModel):
184
+ config: "BaseConfig" # or by using a string as the type annotation
185
+
186
+
187
+ class NumberConfig(BaseConfig): # now you can declare new types after the fact (but before using/validating the models)!
188
+ ...
189
+ ```
190
+
191
+ **2. Enable `defer_build`**
192
+
193
+ ```python
194
+ class BaseConfig(PluginModel):
195
+ ...
196
+
197
+ class CommonConfig(BaseModel):
198
+ config: BaseConfig
199
+
200
+ model_config = {"defer_build": True}
201
+ ```
202
+
203
+ ### 📝 Type Checker Friendliness
204
+
205
+ The type checker can infer the type of the plugin model, so you don't need to define a union type or a discriminator field!
206
+ Everything except for the annotated union is based on pydantic and as such can be used like before as type checkers are already familiar with pydantic.
207
+
208
+ ## 🏛️ Leading Principles
209
+
210
+ ### Composition over Inheritance
211
+
212
+ Composition is preferred over inheritance.
213
+
214
+ ### Dont repeat yourself (DRY)
215
+
216
+ Having to inherit from a base class just to then declare an annotated union or having to declare a discriminator field both as an annotation and with a default being the same as the annotation is a violation of the DRY principle. This library tackles all of these issues at once.
217
+
218
+ ### Be conservative in what you send and liberal in what you accept
219
+
220
+ Using automatic downcasts, this library allows developers to accept every possible value when validating a model.
221
+
222
+
223
+ ## 💻 Development
224
+
225
+ ### 📁 Code structure
226
+
227
+ The code is structured as follows:
228
+
229
+ - `src/plugantic/` contains the source code
230
+ - `tests/` contains the tests
231
+
232
+ Most of the actual logic is in the `src/plugantic/plugin.py` file.
233
+
234
+ ### 📦 Distribution
235
+
236
+ To build the package, you can do the following:
237
+
238
+ ```bash
239
+ uv run build
240
+ ```
241
+
242
+ <details>
243
+ <summary>Publishing</summary>
244
+
245
+ > 💡 This section is primarily relevant for the maintainers of this package (me), as it requires permission to push a package to the `plugantic` repository on PyPI.
246
+
247
+ ```bash
248
+ uv run publish --token <token>
249
+ ```
250
+
251
+ </details>
252
+
253
+ ### 🎯 Tests
254
+
255
+ To run all tests, you can do the following:
256
+
257
+ ```bash
258
+ uv run pytest
259
+ ```
@@ -0,0 +1,243 @@
1
+ # 🧩 Plugantic - Simplified extendable composition with pydantic
2
+
3
+ ## 🤔 Why use `plugantic`?
4
+
5
+ You may have learned that you should avoid inheritance in favor of composition. When using pydantic you can achieve that by using something like the following:
6
+
7
+ ```python
8
+ # Declare a base config
9
+ class OutputConfig(BaseModel):
10
+ mode: str
11
+ def print(self): ...
12
+
13
+ # Declare all implementations of the base config
14
+ class TextConfig(OutputConfig):
15
+ mode: Literal["text"] = "text"
16
+ text: str
17
+ def print(self):
18
+ print(self.text)
19
+
20
+ class NumberConfig(OutputConfig):
21
+ mode: Literal["number"] = "number"
22
+ number: float
23
+ precision: int = 2
24
+ def print(self):
25
+ print(f"{self.number:.{self.precision}f}")
26
+
27
+ # Define a union type of all implementations
28
+ AllOutputConfigs = Annotated[Union[
29
+ TextConfig,
30
+ NumberConfig,
31
+ ], Field(discriminator="mode")]
32
+
33
+ # Use the union type in your model
34
+ class CommonConfig(BaseModel):
35
+ output: AllOutputConfigs
36
+
37
+ ...
38
+
39
+ CommonConfig.model_validate({"output": {
40
+ "mode": "text",
41
+ "text": "Hello World"
42
+ }})
43
+ ```
44
+
45
+ Whilst this works, there are multiple issues and annoyances with that approach:
46
+ - **Hard to maintain**: you need to declare a type union and update it with every change
47
+ - **Not extensible**: adding a different config afterwards would required to update the `AllOutputConfigs` type and all of the objects using it
48
+ - **Redundant definition** of the discriminator field (i.e. `Literal[<x>] = <x>`)
49
+
50
+ This library solves all of these issues (and more), so you can just write
51
+
52
+ ```python
53
+ from plugantic import PluginModel
54
+
55
+ class OutputConfig(PluginModel):
56
+ mode: str
57
+ def print(self): ...
58
+
59
+ class TextConfig(OutputConfig):
60
+ # No redundant "text" definition here!
61
+ mode: Literal["text"]
62
+ text: str
63
+ def print(self):
64
+ print(self.text)
65
+
66
+ class NumberConfig(OutputConfig):
67
+ # No redundant definition here either!
68
+ mode: Literal["number"]
69
+ number: float
70
+ precision: int = 2
71
+ def print(self):
72
+ print(f"{self.number:.{self.precision}f}")
73
+
74
+ # No need to define a union type or a discriminator field!
75
+ # You can just use the base type as a field type!
76
+ class CommonConfig(BaseModel):
77
+ output: OutputConfig
78
+
79
+ # You can even add new configs after the fact!
80
+ class BytesConfig(OutputConfig):
81
+ mode: Literal["bytes"]
82
+ content: bytes
83
+ def print(self):
84
+ print(self.content.decode("utf-8"))
85
+
86
+ ...
87
+
88
+ # The actual type is only evaluated when it is actually needed!
89
+ CommonConfig.model_validate({"output": {
90
+ "mode": "text",
91
+ "text": "Hello World"
92
+ }})
93
+ ```
94
+
95
+ ## ✨ Features
96
+
97
+ ### 🌀 Automatic Downcasts
98
+
99
+ Let's say you have the following logger:
100
+
101
+ ```python
102
+ FeatureNewPage = Literal["newline"]
103
+
104
+ class LoggerBase(PluginModel):
105
+ def log_line(self, line: str, new_page: bool=False): ...
106
+
107
+ class LoggerStdout(LoggerBase, value="stdout"):
108
+ new_page_token: str|None = None
109
+ def log_line(self, line: str, new_page: bool=False):
110
+ if new_page:
111
+ if not self.new_page_token:
112
+ raise ValueError("new_page_token is not set")
113
+ print(self.new_page_token)
114
+ print(line)
115
+
116
+ class Component1(BaseModel):
117
+ logger: LoggerBase
118
+
119
+ class Component2(BaseModel):
120
+ logger: LoggerBase[FeatureNewPage]
121
+ ```
122
+
123
+ then users could not use `Component2` with `LoggerStdout` as it does not support the `FeatureNewPage` feature, even thoudh `LoggerStdout` would support it, if `new_page_token: str` was enforced.
124
+
125
+ Conventionally, this would require the developer to create two classes (i.e. `LoggerStdout` and `LoggerStdoutNewPage`) and then include either one in the final annotated union depending on if the component requires the new page functionality.
126
+
127
+ With `plugantic`, you can automatically create subtypes that are more strict than the base type and they will be automatically validated and downcast when using the model:
128
+
129
+ ```python
130
+ def ensure_new_page_feature(handler: PluginDowncastHandler):
131
+ handler.enable_feature(FeatureNewPage)
132
+ handler.set_field_annotation("new_page_token", str)
133
+ handler.remove_field_default("new_page_token")
134
+
135
+ class LoggerBase(PluginModel, value="stdout", auto_downcasts=(ensure_new_page_feature,)):
136
+ new_page_token: str|None = None
137
+ def log_line(self, line: str, new_page: bool=False):
138
+ if new_page:
139
+ if not self.new_page_token:
140
+ raise ValueError("new_page_token is not set")
141
+ print(self.new_page_token)
142
+ print(line)
143
+ ```
144
+
145
+ By declaring multiple callbacks in `auto_downcasts`, you can create a superset of all possible downcasts and `plugantic` will automatically select the least strict depending on which features you require.
146
+
147
+
148
+ ### 🔌 Extensibility
149
+
150
+ You can add new plugins after the fact!
151
+
152
+ To do so, you will have to ensure one of the following prerequisites:
153
+
154
+ **1. Use `ForwardRef`s**
155
+
156
+ ```python
157
+ from __future__ import annotations # either by importing annotations from the __future__ package
158
+
159
+ class BaseConfig(PluginModel):
160
+ ...
161
+
162
+ ...
163
+
164
+ class CommonConfig1(BaseModel):
165
+ config: BaseConfig
166
+
167
+ class CommonConfig2(BaseModel):
168
+ config: "BaseConfig" # or by using a string as the type annotation
169
+
170
+
171
+ class NumberConfig(BaseConfig): # now you can declare new types after the fact (but before using/validating the models)!
172
+ ...
173
+ ```
174
+
175
+ **2. Enable `defer_build`**
176
+
177
+ ```python
178
+ class BaseConfig(PluginModel):
179
+ ...
180
+
181
+ class CommonConfig(BaseModel):
182
+ config: BaseConfig
183
+
184
+ model_config = {"defer_build": True}
185
+ ```
186
+
187
+ ### 📝 Type Checker Friendliness
188
+
189
+ The type checker can infer the type of the plugin model, so you don't need to define a union type or a discriminator field!
190
+ Everything except for the annotated union is based on pydantic and as such can be used like before as type checkers are already familiar with pydantic.
191
+
192
+ ## 🏛️ Leading Principles
193
+
194
+ ### Composition over Inheritance
195
+
196
+ Composition is preferred over inheritance.
197
+
198
+ ### Dont repeat yourself (DRY)
199
+
200
+ Having to inherit from a base class just to then declare an annotated union or having to declare a discriminator field both as an annotation and with a default being the same as the annotation is a violation of the DRY principle. This library tackles all of these issues at once.
201
+
202
+ ### Be conservative in what you send and liberal in what you accept
203
+
204
+ Using automatic downcasts, this library allows developers to accept every possible value when validating a model.
205
+
206
+
207
+ ## 💻 Development
208
+
209
+ ### 📁 Code structure
210
+
211
+ The code is structured as follows:
212
+
213
+ - `src/plugantic/` contains the source code
214
+ - `tests/` contains the tests
215
+
216
+ Most of the actual logic is in the `src/plugantic/plugin.py` file.
217
+
218
+ ### 📦 Distribution
219
+
220
+ To build the package, you can do the following:
221
+
222
+ ```bash
223
+ uv run build
224
+ ```
225
+
226
+ <details>
227
+ <summary>Publishing</summary>
228
+
229
+ > 💡 This section is primarily relevant for the maintainers of this package (me), as it requires permission to push a package to the `plugantic` repository on PyPI.
230
+
231
+ ```bash
232
+ uv run publish --token <token>
233
+ ```
234
+
235
+ </details>
236
+
237
+ ### 🎯 Tests
238
+
239
+ To run all tests, you can do the following:
240
+
241
+ ```bash
242
+ uv run pytest
243
+ ```
@@ -0,0 +1,43 @@
1
+ [project]
2
+ name = "plugantic"
3
+ description = "Simplified extendable composition with pydantic"
4
+ authors = [
5
+ { name = "Martin Kunze", email = "martin@martinkunze.com" }
6
+ ]
7
+ license-files = ["LICENSE"]
8
+ readme = "README.md"
9
+ requires-python = ">=3.8"
10
+ dynamic = ["version"]
11
+
12
+ dependencies = [
13
+ "pydantic",
14
+ "propert",
15
+ "typing-extensions",
16
+ ]
17
+
18
+ [dependency-groups]
19
+ dev = [
20
+ "pytest",
21
+ ]
22
+
23
+
24
+ [project.urls]
25
+ Homepage = "https://github.com/martinkunze/plugantic"
26
+ Documentation = "https://github.com/martinkunze/plugantic/blob/main/README.md"
27
+ Source = "https://github.com/martinkunze/plugantic"
28
+
29
+ [tool.pytest.ini_options]
30
+ testpaths = ["test"]
31
+
32
+ [tool.setuptools]
33
+ package-dir = { "" = "src" }
34
+
35
+ [tool.setuptools.packages.find]
36
+ where = ["src"]
37
+
38
+ [tool.setuptools.dynamic]
39
+ version = { attr = "plugantic._consts.version" }
40
+
41
+ [build-system]
42
+ requires = ["setuptools>=68", "wheel"]
43
+ build-backend = "setuptools.build_meta"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """Plugantic: Simplify extendable composition with Pydantic."""
2
+ from ._consts import version as __version__
3
+ from .plugin import PluginModel, PluginDowncastHandler
@@ -0,0 +1,2 @@
1
+ # Single source of truth for information needed at runtime and compile-time (i.e. version)
2
+ version = "0.1.0"
@@ -0,0 +1,42 @@
1
+ from __future__ import annotations
2
+
3
+ from typing_extensions import TypeVar, Iterable, TypeGuard, TypeAliasType, Callable
4
+ from itertools import combinations, product
5
+
6
+ T = TypeVar("T")
7
+ RecursiveList = TypeAliasType("RecursiveList", Iterable["RecursiveListItem[T]"], type_params=(T,))
8
+ RecursiveListItem = TypeAliasType("RecursiveListItem", T | RecursiveList[T], type_params=(T,))
9
+
10
+ def recursive_linear(items: RecursiveList[T], typeguard: Callable[[RecursiveListItem[T]], TypeGuard[T]], join: Callable[[Iterable[T]], T]) -> Iterable[T]:
11
+ result = []
12
+ for item in items:
13
+ if typeguard(item):
14
+ result.append(item)
15
+ else:
16
+ result.extend(recursive_powerset(item, typeguard, join))
17
+ return result
18
+
19
+ def recursive_powerset(items: RecursiveList[T], typeguard: Callable[[RecursiveListItem[T]], TypeGuard[T]], join: Callable[[Iterable[T]], T]) -> Iterable[T]:
20
+ arbitrary_subset = []
21
+ subsets = []
22
+ for downcast in items:
23
+ if typeguard(downcast):
24
+ arbitrary_subset.append(downcast)
25
+ else:
26
+ linear_subset = recursive_linear(downcast, typeguard, join)
27
+ linear_subset = ((), *((d,) for d in linear_subset))
28
+ subsets.append(linear_subset)
29
+
30
+ arbitrary_powerset = [()]
31
+ for l in range(1, len(arbitrary_subset) + 1):
32
+ arbitrary_powerset.extend(combinations(arbitrary_subset, l))
33
+ subsets.append(arbitrary_powerset)
34
+
35
+ powerset = []
36
+ for subset in product(*subsets):
37
+ callbacks = [c for s in subset for c in s]
38
+ if not callbacks:
39
+ continue
40
+ powerset.append(join(callbacks))
41
+
42
+ return powerset
@@ -0,0 +1,6 @@
1
+ class _InjectBase:
2
+ def __init__(self, *bases: type):
3
+ self._bases = bases
4
+ def __mro_entries__(self, bases):
5
+ return tuple(base for base in self._bases if base not in bases)
6
+ _VanishBase = _InjectBase()