plugantic 0.2.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.
- plugantic-0.2.0/LICENSE +7 -0
- plugantic-0.2.0/PKG-INFO +330 -0
- plugantic-0.2.0/README.md +314 -0
- plugantic-0.2.0/pyproject.toml +43 -0
- plugantic-0.2.0/setup.cfg +4 -0
- plugantic-0.2.0/src/plugantic/__init__.py +3 -0
- plugantic-0.2.0/src/plugantic/_consts.py +2 -0
- plugantic-0.2.0/src/plugantic/_helpers.py +42 -0
- plugantic-0.2.0/src/plugantic/_types.py +6 -0
- plugantic-0.2.0/src/plugantic/plugin.py +332 -0
- plugantic-0.2.0/src/plugantic.egg-info/PKG-INFO +330 -0
- plugantic-0.2.0/src/plugantic.egg-info/SOURCES.txt +17 -0
- plugantic-0.2.0/src/plugantic.egg-info/dependency_links.txt +1 -0
- plugantic-0.2.0/src/plugantic.egg-info/requires.txt +3 -0
- plugantic-0.2.0/src/plugantic.egg-info/top_level.txt +1 -0
- plugantic-0.2.0/test/test_auto_downcast.py +58 -0
- plugantic-0.2.0/test/test_basic_usage.py +139 -0
- plugantic-0.2.0/test/test_combined_usage.py +125 -0
- plugantic-0.2.0/test/test_schema_error.py +50 -0
plugantic-0.2.0/LICENSE
ADDED
|
@@ -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.
|
plugantic-0.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: plugantic
|
|
3
|
+
Version: 0.2.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
|
+
### 🔌 Extensibility
|
|
114
|
+
|
|
115
|
+
You can add new plugins after the fact!
|
|
116
|
+
|
|
117
|
+
To do so, you will have to ensure one of the following prerequisites:
|
|
118
|
+
|
|
119
|
+
**1. Use `ForwardRef`s**
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
from __future__ import annotations # either by importing annotations from the __future__ package
|
|
123
|
+
|
|
124
|
+
class BaseConfig(PluginModel):
|
|
125
|
+
...
|
|
126
|
+
|
|
127
|
+
...
|
|
128
|
+
|
|
129
|
+
class CommonConfig1(BaseModel):
|
|
130
|
+
config: BaseConfig
|
|
131
|
+
|
|
132
|
+
class CommonConfig2(BaseModel):
|
|
133
|
+
config: "BaseConfig" # or by using a string as the type annotation
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class NumberConfig(BaseConfig): # now you can declare new types after the fact (but before using/validating the models)!
|
|
137
|
+
...
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
**2. Enable `defer_build`**
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
class BaseConfig(PluginModel):
|
|
144
|
+
...
|
|
145
|
+
|
|
146
|
+
class CommonConfig(BaseModel):
|
|
147
|
+
config: BaseConfig
|
|
148
|
+
|
|
149
|
+
model_config = {"defer_build": True}
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
### 🚦 Intersection Types
|
|
154
|
+
|
|
155
|
+
TL;DR: Plugantic introduces a `value: Model1 & Model2` type annotation
|
|
156
|
+
|
|
157
|
+
Sometimes, you want to have the same base interface and then some interfaces built on top of that, with slightly different features.
|
|
158
|
+
|
|
159
|
+
For example you could imaging the following:
|
|
160
|
+
|
|
161
|
+
```python
|
|
162
|
+
class Logger(PluginModel):
|
|
163
|
+
def log(self, text: str): ...
|
|
164
|
+
|
|
165
|
+
class LoggerWithColors(Logger):
|
|
166
|
+
def change_color(self, color: str): ...
|
|
167
|
+
|
|
168
|
+
class LoggerWithEmojis(Logger):
|
|
169
|
+
def log_emoji(self, emoji: str): ...
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Due to multiple inheritance in python, it is easy to define a class that supports both features:
|
|
173
|
+
|
|
174
|
+
```python
|
|
175
|
+
class StdoutLogger(LoggerWithColors, LoggerWithEmojis):
|
|
176
|
+
def log(self, text):
|
|
177
|
+
...
|
|
178
|
+
def change_color(self, color):
|
|
179
|
+
...
|
|
180
|
+
def log_emoji(self, emoji):
|
|
181
|
+
...
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
However, you cannot easily declare a type annotation in python that requires both features. You would wish that something like this existed in python (and plugantic introduces it):
|
|
185
|
+
|
|
186
|
+
```python
|
|
187
|
+
class SomeOtherConfig(BaseModel):
|
|
188
|
+
logger: LoggerWithColor & LoggerWithEmojis
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
Note, that this will break with most type checkers, as this is not a valid type annotation in python. It does work at runtime though and it is very obvious what this syntax means. You can use `# type: ignore[operator]` to the end of the type annotation to stop the warnings about the incorrect type annotation from your linter.
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
### 📝 Type Checker Friendliness
|
|
195
|
+
|
|
196
|
+
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!
|
|
197
|
+
Everything except for the annotated union and the intersection types is based on pydantic and as such can be used like before as type checkers are already familiar with pydantic.
|
|
198
|
+
|
|
199
|
+
### 🌀 Automatic Downcasts
|
|
200
|
+
|
|
201
|
+
Let's say you have the following logger:
|
|
202
|
+
|
|
203
|
+
```python
|
|
204
|
+
class LoggerBase(PluginModel):
|
|
205
|
+
def log_line(self, line: str): ...
|
|
206
|
+
|
|
207
|
+
class LoggerWithPages(LoggerBase):
|
|
208
|
+
def log_line(self, line: str, new_page: bool=False): ...
|
|
209
|
+
|
|
210
|
+
class LoggerStdout(LoggerBase, value="stdout"):
|
|
211
|
+
new_page_token: str|None = None
|
|
212
|
+
def log_line(self, line: str, new_page: bool=False):
|
|
213
|
+
if new_page:
|
|
214
|
+
if not self.new_page_token:
|
|
215
|
+
raise ValueError("new_page_token is not set")
|
|
216
|
+
print(self.new_page_token)
|
|
217
|
+
print(line)
|
|
218
|
+
|
|
219
|
+
class Component1(BaseModel):
|
|
220
|
+
logger: LoggerBase
|
|
221
|
+
|
|
222
|
+
class Component2(BaseModel):
|
|
223
|
+
logger: LoggerWithPages
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
then users could not use `Component2` with `LoggerStdout` as it does not support (i.e. does not implement) the pages feature, even thoudh `LoggerStdout` would support it, if `new_page_token: str` was enforced.
|
|
227
|
+
|
|
228
|
+
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.
|
|
229
|
+
|
|
230
|
+
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:
|
|
231
|
+
|
|
232
|
+
```python
|
|
233
|
+
class LoggerStdout(LoggerBase, value="stdout"):
|
|
234
|
+
new_page_token: str|None = None
|
|
235
|
+
def log_line(self, line: str, new_page: bool=False):
|
|
236
|
+
if new_page:
|
|
237
|
+
if not self.new_page_token:
|
|
238
|
+
raise ValueError("new_page_token is not set")
|
|
239
|
+
print(self.new_page_token)
|
|
240
|
+
print(line)
|
|
241
|
+
|
|
242
|
+
class LoggerStdoutWithPages(LoggerStdout, LoggerWithPages):
|
|
243
|
+
new_page_token: str
|
|
244
|
+
# all the functionality is declared in the base class, we just add some type enforcements
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
If you have multiple features, that may need to be supported individually, you can automate these typed subclasses:
|
|
248
|
+
|
|
249
|
+
```python
|
|
250
|
+
class LoggerBase(PluginModel): ...
|
|
251
|
+
class LoggerWithPages(LoggerBase): ...
|
|
252
|
+
class LoggerWithColors(LoggerBase): ...
|
|
253
|
+
|
|
254
|
+
class LoggerStdout(LoggerBase, value="stdout"):
|
|
255
|
+
new_page_token: str|None = None
|
|
256
|
+
color: str|None = None
|
|
257
|
+
|
|
258
|
+
combinations = [(LoggerWithPages,), (LoggerWithColors,), (LoggerWithPages, LoggerWithColors)] # all the combinations we want to create; could be automated using itertools
|
|
259
|
+
for parents in combinations:
|
|
260
|
+
class _(LoggerStdout, *parents):
|
|
261
|
+
if LoggerWithPages in parents:
|
|
262
|
+
new_page_token: str # add strict requirements for new page feature
|
|
263
|
+
if LoggerWithColors in parents:
|
|
264
|
+
color: str # add strict requirements for color feature
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
In these cases, you will not have type checker support for instantiating the classes using python, but plugantic will automatically downcast a valid parent class to the correct subclass. So the following is valid, even though `LoggerStdout` itself does not inherit from `LoggerWithColors` (because there is a subclass of `LoggerStdout` that _does_ inherit from `LoggerWithColors` _and_ has the same identifier "stdout")
|
|
268
|
+
|
|
269
|
+
```python
|
|
270
|
+
class SomeConfig(BaseModel):
|
|
271
|
+
logger: LoggerWithColors
|
|
272
|
+
|
|
273
|
+
SomeConfig.model_validate({
|
|
274
|
+
"logger": LoggerStdout(color="#801212")
|
|
275
|
+
})
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
## 🏛️ Leading Principles
|
|
280
|
+
|
|
281
|
+
### Composition over Inheritance
|
|
282
|
+
|
|
283
|
+
Composition is preferred over inheritance.
|
|
284
|
+
|
|
285
|
+
### Dont repeat yourself (DRY)
|
|
286
|
+
|
|
287
|
+
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.
|
|
288
|
+
|
|
289
|
+
### Be conservative in what you send and liberal in what you accept
|
|
290
|
+
|
|
291
|
+
Using automatic downcasts, this library allows developers to accept every possible value when validating a model.
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
## 💻 Development
|
|
295
|
+
|
|
296
|
+
### 📁 Code structure
|
|
297
|
+
|
|
298
|
+
The code is structured as follows:
|
|
299
|
+
|
|
300
|
+
- `src/plugantic/` contains the source code
|
|
301
|
+
- `tests/` contains the tests
|
|
302
|
+
|
|
303
|
+
Most of the actual logic is in the `src/plugantic/plugin.py` file.
|
|
304
|
+
|
|
305
|
+
### 📦 Distribution
|
|
306
|
+
|
|
307
|
+
To build the package, you can do the following:
|
|
308
|
+
|
|
309
|
+
```bash
|
|
310
|
+
uv build
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
<details>
|
|
314
|
+
<summary>Publishing</summary>
|
|
315
|
+
|
|
316
|
+
> 💡 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.
|
|
317
|
+
|
|
318
|
+
```bash
|
|
319
|
+
uv run publish --token <token>
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
</details>
|
|
323
|
+
|
|
324
|
+
### 🎯 Tests
|
|
325
|
+
|
|
326
|
+
To run all tests, you can do the following:
|
|
327
|
+
|
|
328
|
+
```bash
|
|
329
|
+
uv run pytest
|
|
330
|
+
```
|
|
@@ -0,0 +1,314 @@
|
|
|
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
|
+
### 🔌 Extensibility
|
|
98
|
+
|
|
99
|
+
You can add new plugins after the fact!
|
|
100
|
+
|
|
101
|
+
To do so, you will have to ensure one of the following prerequisites:
|
|
102
|
+
|
|
103
|
+
**1. Use `ForwardRef`s**
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
from __future__ import annotations # either by importing annotations from the __future__ package
|
|
107
|
+
|
|
108
|
+
class BaseConfig(PluginModel):
|
|
109
|
+
...
|
|
110
|
+
|
|
111
|
+
...
|
|
112
|
+
|
|
113
|
+
class CommonConfig1(BaseModel):
|
|
114
|
+
config: BaseConfig
|
|
115
|
+
|
|
116
|
+
class CommonConfig2(BaseModel):
|
|
117
|
+
config: "BaseConfig" # or by using a string as the type annotation
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class NumberConfig(BaseConfig): # now you can declare new types after the fact (but before using/validating the models)!
|
|
121
|
+
...
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
**2. Enable `defer_build`**
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
class BaseConfig(PluginModel):
|
|
128
|
+
...
|
|
129
|
+
|
|
130
|
+
class CommonConfig(BaseModel):
|
|
131
|
+
config: BaseConfig
|
|
132
|
+
|
|
133
|
+
model_config = {"defer_build": True}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
### 🚦 Intersection Types
|
|
138
|
+
|
|
139
|
+
TL;DR: Plugantic introduces a `value: Model1 & Model2` type annotation
|
|
140
|
+
|
|
141
|
+
Sometimes, you want to have the same base interface and then some interfaces built on top of that, with slightly different features.
|
|
142
|
+
|
|
143
|
+
For example you could imaging the following:
|
|
144
|
+
|
|
145
|
+
```python
|
|
146
|
+
class Logger(PluginModel):
|
|
147
|
+
def log(self, text: str): ...
|
|
148
|
+
|
|
149
|
+
class LoggerWithColors(Logger):
|
|
150
|
+
def change_color(self, color: str): ...
|
|
151
|
+
|
|
152
|
+
class LoggerWithEmojis(Logger):
|
|
153
|
+
def log_emoji(self, emoji: str): ...
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Due to multiple inheritance in python, it is easy to define a class that supports both features:
|
|
157
|
+
|
|
158
|
+
```python
|
|
159
|
+
class StdoutLogger(LoggerWithColors, LoggerWithEmojis):
|
|
160
|
+
def log(self, text):
|
|
161
|
+
...
|
|
162
|
+
def change_color(self, color):
|
|
163
|
+
...
|
|
164
|
+
def log_emoji(self, emoji):
|
|
165
|
+
...
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
However, you cannot easily declare a type annotation in python that requires both features. You would wish that something like this existed in python (and plugantic introduces it):
|
|
169
|
+
|
|
170
|
+
```python
|
|
171
|
+
class SomeOtherConfig(BaseModel):
|
|
172
|
+
logger: LoggerWithColor & LoggerWithEmojis
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
Note, that this will break with most type checkers, as this is not a valid type annotation in python. It does work at runtime though and it is very obvious what this syntax means. You can use `# type: ignore[operator]` to the end of the type annotation to stop the warnings about the incorrect type annotation from your linter.
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
### 📝 Type Checker Friendliness
|
|
179
|
+
|
|
180
|
+
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!
|
|
181
|
+
Everything except for the annotated union and the intersection types is based on pydantic and as such can be used like before as type checkers are already familiar with pydantic.
|
|
182
|
+
|
|
183
|
+
### 🌀 Automatic Downcasts
|
|
184
|
+
|
|
185
|
+
Let's say you have the following logger:
|
|
186
|
+
|
|
187
|
+
```python
|
|
188
|
+
class LoggerBase(PluginModel):
|
|
189
|
+
def log_line(self, line: str): ...
|
|
190
|
+
|
|
191
|
+
class LoggerWithPages(LoggerBase):
|
|
192
|
+
def log_line(self, line: str, new_page: bool=False): ...
|
|
193
|
+
|
|
194
|
+
class LoggerStdout(LoggerBase, value="stdout"):
|
|
195
|
+
new_page_token: str|None = None
|
|
196
|
+
def log_line(self, line: str, new_page: bool=False):
|
|
197
|
+
if new_page:
|
|
198
|
+
if not self.new_page_token:
|
|
199
|
+
raise ValueError("new_page_token is not set")
|
|
200
|
+
print(self.new_page_token)
|
|
201
|
+
print(line)
|
|
202
|
+
|
|
203
|
+
class Component1(BaseModel):
|
|
204
|
+
logger: LoggerBase
|
|
205
|
+
|
|
206
|
+
class Component2(BaseModel):
|
|
207
|
+
logger: LoggerWithPages
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
then users could not use `Component2` with `LoggerStdout` as it does not support (i.e. does not implement) the pages feature, even thoudh `LoggerStdout` would support it, if `new_page_token: str` was enforced.
|
|
211
|
+
|
|
212
|
+
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.
|
|
213
|
+
|
|
214
|
+
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:
|
|
215
|
+
|
|
216
|
+
```python
|
|
217
|
+
class LoggerStdout(LoggerBase, value="stdout"):
|
|
218
|
+
new_page_token: str|None = None
|
|
219
|
+
def log_line(self, line: str, new_page: bool=False):
|
|
220
|
+
if new_page:
|
|
221
|
+
if not self.new_page_token:
|
|
222
|
+
raise ValueError("new_page_token is not set")
|
|
223
|
+
print(self.new_page_token)
|
|
224
|
+
print(line)
|
|
225
|
+
|
|
226
|
+
class LoggerStdoutWithPages(LoggerStdout, LoggerWithPages):
|
|
227
|
+
new_page_token: str
|
|
228
|
+
# all the functionality is declared in the base class, we just add some type enforcements
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
If you have multiple features, that may need to be supported individually, you can automate these typed subclasses:
|
|
232
|
+
|
|
233
|
+
```python
|
|
234
|
+
class LoggerBase(PluginModel): ...
|
|
235
|
+
class LoggerWithPages(LoggerBase): ...
|
|
236
|
+
class LoggerWithColors(LoggerBase): ...
|
|
237
|
+
|
|
238
|
+
class LoggerStdout(LoggerBase, value="stdout"):
|
|
239
|
+
new_page_token: str|None = None
|
|
240
|
+
color: str|None = None
|
|
241
|
+
|
|
242
|
+
combinations = [(LoggerWithPages,), (LoggerWithColors,), (LoggerWithPages, LoggerWithColors)] # all the combinations we want to create; could be automated using itertools
|
|
243
|
+
for parents in combinations:
|
|
244
|
+
class _(LoggerStdout, *parents):
|
|
245
|
+
if LoggerWithPages in parents:
|
|
246
|
+
new_page_token: str # add strict requirements for new page feature
|
|
247
|
+
if LoggerWithColors in parents:
|
|
248
|
+
color: str # add strict requirements for color feature
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
In these cases, you will not have type checker support for instantiating the classes using python, but plugantic will automatically downcast a valid parent class to the correct subclass. So the following is valid, even though `LoggerStdout` itself does not inherit from `LoggerWithColors` (because there is a subclass of `LoggerStdout` that _does_ inherit from `LoggerWithColors` _and_ has the same identifier "stdout")
|
|
252
|
+
|
|
253
|
+
```python
|
|
254
|
+
class SomeConfig(BaseModel):
|
|
255
|
+
logger: LoggerWithColors
|
|
256
|
+
|
|
257
|
+
SomeConfig.model_validate({
|
|
258
|
+
"logger": LoggerStdout(color="#801212")
|
|
259
|
+
})
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
## 🏛️ Leading Principles
|
|
264
|
+
|
|
265
|
+
### Composition over Inheritance
|
|
266
|
+
|
|
267
|
+
Composition is preferred over inheritance.
|
|
268
|
+
|
|
269
|
+
### Dont repeat yourself (DRY)
|
|
270
|
+
|
|
271
|
+
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.
|
|
272
|
+
|
|
273
|
+
### Be conservative in what you send and liberal in what you accept
|
|
274
|
+
|
|
275
|
+
Using automatic downcasts, this library allows developers to accept every possible value when validating a model.
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
## 💻 Development
|
|
279
|
+
|
|
280
|
+
### 📁 Code structure
|
|
281
|
+
|
|
282
|
+
The code is structured as follows:
|
|
283
|
+
|
|
284
|
+
- `src/plugantic/` contains the source code
|
|
285
|
+
- `tests/` contains the tests
|
|
286
|
+
|
|
287
|
+
Most of the actual logic is in the `src/plugantic/plugin.py` file.
|
|
288
|
+
|
|
289
|
+
### 📦 Distribution
|
|
290
|
+
|
|
291
|
+
To build the package, you can do the following:
|
|
292
|
+
|
|
293
|
+
```bash
|
|
294
|
+
uv build
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
<details>
|
|
298
|
+
<summary>Publishing</summary>
|
|
299
|
+
|
|
300
|
+
> 💡 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.
|
|
301
|
+
|
|
302
|
+
```bash
|
|
303
|
+
uv run publish --token <token>
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
</details>
|
|
307
|
+
|
|
308
|
+
### 🎯 Tests
|
|
309
|
+
|
|
310
|
+
To run all tests, you can do the following:
|
|
311
|
+
|
|
312
|
+
```bash
|
|
313
|
+
uv run pytest
|
|
314
|
+
```
|
|
@@ -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"
|