idunn 0.0.1__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.
- idunn-0.0.1/LICENSE +21 -0
- idunn-0.0.1/PKG-INFO +630 -0
- idunn-0.0.1/README.md +604 -0
- idunn-0.0.1/idunn/__init__.py +34 -0
- idunn-0.0.1/idunn/app/__init__.py +6 -0
- idunn-0.0.1/idunn/app/decorators.py +137 -0
- idunn-0.0.1/idunn/app/idunn.py +85 -0
- idunn-0.0.1/idunn/domain/__init__.py +33 -0
- idunn-0.0.1/idunn/domain/adapter_declaration.py +18 -0
- idunn-0.0.1/idunn/domain/adapter_metadata.py +28 -0
- idunn-0.0.1/idunn/domain/errors.py +29 -0
- idunn-0.0.1/idunn/domain/lifecycle_enum.py +10 -0
- idunn-0.0.1/idunn/domain/port_binding.py +23 -0
- idunn-0.0.1/idunn/domain/registration_key.py +12 -0
- idunn-0.0.1/idunn/domain/report.py +21 -0
- idunn-0.0.1/idunn/internal/__init__.py +21 -0
- idunn-0.0.1/idunn/internal/auto_discovery.py +202 -0
- idunn-0.0.1/idunn/internal/decorator_support.py +59 -0
- idunn-0.0.1/idunn/internal/inversion_mapper.py +152 -0
- idunn-0.0.1/idunn/internal/inversion_resolver.py +168 -0
- idunn-0.0.1/idunn/internal/inversion_validator.py +66 -0
- idunn-0.0.1/idunn/py.typed +0 -0
- idunn-0.0.1/idunn/util/__init__.py +11 -0
- idunn-0.0.1/idunn/util/environment.py +43 -0
- idunn-0.0.1/idunn/util/meta_singleton.py +28 -0
- idunn-0.0.1/idunn/util/qualified_name.py +14 -0
- idunn-0.0.1/pyproject.toml +52 -0
idunn-0.0.1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Steven Miers
|
|
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.
|
idunn-0.0.1/PKG-INFO
ADDED
|
@@ -0,0 +1,630 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: idunn
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: A tiny constructor-time dependency inversion toolkit for Python.
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Keywords: dependency-injection,inversion-of-control,ioc,decorators,protocols
|
|
8
|
+
Author: Steven Miers
|
|
9
|
+
Author-email: steven.miers@gmail.com
|
|
10
|
+
Requires-Python: >=3.11,<4.0
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
20
|
+
Requires-Dist: pytest-cov (>=7.1.0,<8.0.0)
|
|
21
|
+
Project-URL: Documentation, https://github.com/terracoil/idunn/blob/main/docs/README.md
|
|
22
|
+
Project-URL: Homepage, https://github.com/terracoil/idunn/blob/master/README.md
|
|
23
|
+
Project-URL: Repository, https://github.com/terracoil/idunn
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# Idunn π
|
|
27
|
+

|
|
28
|
+
|
|
29
|
+
**Idunn** is a tiny Python dependency-inversion / IoC toolkit built around **constructor-time
|
|
30
|
+
injection only** β small enough to read on a coffee break, opinionated enough to keep your wiring
|
|
31
|
+
honest.
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
> *"Everything should be made as simple as possible, but not simpler."* β Albert Einstein
|
|
36
|
+
|
|
37
|
+
The name comes from **IΓ°unn / Idunn**, the Norse keeper of the apples that keep the gods young.
|
|
38
|
+
Idunn borrows that image as its DI metaphor: keep the right dependencies close to the system and the
|
|
39
|
+
code stays fresh, instead of hardening into the kind of brittle wiring that makes future-you
|
|
40
|
+
sigh. β¨
|
|
41
|
+
|
|
42
|
+
> π **New to Idunn?** Read **[About Idunn](./docs/ABOUT.md)** β the philosophy, the
|
|
43
|
+
> Port β Adapter β `@Invert` model, and why the short "no" list *is* the feature. Then come
|
|
44
|
+
> back here for the reference.
|
|
45
|
+
|
|
46
|
+
# π Table-of-Contents
|
|
47
|
+
|
|
48
|
+
- [The whole API ποΈ](#the-whole-api-)
|
|
49
|
+
- [The three decorators π](#the-three-decorators-)
|
|
50
|
+
- [`@Port` π](#port-)
|
|
51
|
+
- [`@Adapter` π§©](#adapter-)
|
|
52
|
+
- [`@Invert` πͺ](#invert-)
|
|
53
|
+
- [Quick-start Guide π±](#quick-start-guide-)
|
|
54
|
+
- [Design stance π§](#design-stance-)
|
|
55
|
+
- [Install locally π¦](#install-locally-)
|
|
56
|
+
- [Basic usage πͺ](#basic-usage-)
|
|
57
|
+
- [Recommended application layout π³](#recommended-application-layout-)
|
|
58
|
+
- [AutoDiscovery rule π](#autodiscovery-rule-)
|
|
59
|
+
- [Port implementation rule π](#port-implementation-rule-)
|
|
60
|
+
- [Mapping adapters to ports π](#mapping-adapters-to-ports-)
|
|
61
|
+
- [With no parameters](#with-no-parameters)
|
|
62
|
+
- [Via the environment](#via-the-environment)
|
|
63
|
+
- [Environment matching rules](#environment-matching-rules)
|
|
64
|
+
- [Via keys](#via-keys)
|
|
65
|
+
- [Same key, different environments](#same-key-different-environments)
|
|
66
|
+
- [Lifecycles π](#lifecycles-)
|
|
67
|
+
- [Known limitations π§](#known-limitations-)
|
|
68
|
+
- [Development workflow π§ͺ](#development-workflow-)
|
|
69
|
+
- [What Idunn intentionally does not do π«](#what-idunn-intentionally-does-not-do-)
|
|
70
|
+
- [Going further π](#going-further-)
|
|
71
|
+
- [Code style constraints π](#code-style-constraints-)
|
|
72
|
+
- [Version target π](#version-target-)
|
|
73
|
+
- [Before publishing to PyPI π](#before-publishing-to-pypi-)
|
|
74
|
+
|
|
75
|
+
## The whole API ποΈ
|
|
76
|
+
|
|
77
|
+
Everything you import lives at the top level of the `idunn` package β you never reach into
|
|
78
|
+
sub-packages:
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
from idunn import Port, Adapter, Invert # the three decorators
|
|
82
|
+
from idunn import Idunn # the container (you touch it once: autodiscover)
|
|
83
|
+
from idunn import LifecycleEnum # passed to @Adapter
|
|
84
|
+
from idunn import IdunnError # base of the exception hierarchy you catch
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
That's the entire surface: **three decorators**, the **`Idunn().autodiscover(...)`** bootstrap call,
|
|
88
|
+
`LifecycleEnum`, and the [exceptions](#exceptions). Registration, selection and construction all
|
|
89
|
+
happen behind the container β see [`docs/ADVANCED.md`](./docs/ADVANCED.md) if you ever want to peek.
|
|
90
|
+
|
|
91
|
+
## The three decorators π
|
|
92
|
+
|
|
93
|
+
Idunn is *just* these three decorators. Define a contract, bind an implementation, receive it β no
|
|
94
|
+
container code in sight.
|
|
95
|
+
|
|
96
|
+
### `@Port` π
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
def Port(cls: T) -> T
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Marks a `typing.Protocol` as an injectable **contract**. Applied to anything that is not a
|
|
103
|
+
`Protocol`, it raises `InvalidPortError`. The decorated protocol is made `runtime_checkable` so
|
|
104
|
+
Idunn can verify that an adapter actually satisfies it.
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
from typing import Protocol
|
|
108
|
+
from idunn import Port
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@Port
|
|
112
|
+
class AppleBasketPort(Protocol):
|
|
113
|
+
def take_apple(self) -> str: ...
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### `@Adapter` π§©
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
def Adapter(
|
|
120
|
+
port: type,
|
|
121
|
+
*,
|
|
122
|
+
key: str | None = None,
|
|
123
|
+
lifecycle: LifecycleEnum | str = LifecycleEnum.TRANSIENT,
|
|
124
|
+
envs: Iterable[str] | str | None = None,
|
|
125
|
+
) -> Callable[[T], T]
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Declares a concrete class as an **implementation** of `port`. It attaches metadata and constructs
|
|
129
|
+
nothing.
|
|
130
|
+
|
|
131
|
+
| Parameter | Meaning |
|
|
132
|
+
|---|---|
|
|
133
|
+
| `port` | The `@Port` this class implements (must be marked, else `InvalidPortError`). |
|
|
134
|
+
| `key` | Omit it and the adapter is **unkeyed** β it answers an ordinary resolve / plain `@Invert`. Give it a `key` and the adapter is **opt-in**: reachable only by that key, never by an unkeyed resolve. |
|
|
135
|
+
| `lifecycle` | `TRANSIENT` (default β new instance each time) or `SINGLETON` (built once, reused). |
|
|
136
|
+
| `envs` | Environments the adapter is active in. `None` = every environment. See [environment matching](#environment-matching-rules). |
|
|
137
|
+
|
|
138
|
+
```python
|
|
139
|
+
from idunn import Adapter, LifecycleEnum
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@Adapter(AppleBasketPort, lifecycle=LifecycleEnum.SINGLETON)
|
|
143
|
+
class GoldenAppleBasketAdapter(AppleBasketPort):
|
|
144
|
+
def take_apple(self) -> str:
|
|
145
|
+
return "π youth restored"
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
The class must satisfy the protocol structurally or by inheritance β `@Adapter` never synthesizes or
|
|
149
|
+
mutates it. Exactly one *unkeyed* adapter may be active per port in any environment.
|
|
150
|
+
|
|
151
|
+
### `@Invert` πͺ
|
|
152
|
+
|
|
153
|
+
```python
|
|
154
|
+
@Invert # infer ports from the constructor's type hints
|
|
155
|
+
@Invert(keys={"basket": "wild"}) # pick keyed adapters per parameter
|
|
156
|
+
@Invert({"basket": AppleBasketPort}) # explicit param -> port (for an un-annotated parameter)
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Wraps a **consumer's `__init__`**. Every parameter whose type hint is a `@Port` is resolved from the
|
|
160
|
+
process-wide `Idunn()` container *when the constructor runs* and assigned to `self.<name>`. This is
|
|
161
|
+
the only sanctioned way to start an object graph β you construct your entry object and the rest wires
|
|
162
|
+
itself; you never call the container to resolve.
|
|
163
|
+
|
|
164
|
+
The full contract:
|
|
165
|
+
|
|
166
|
+
- **It assigns `self.<name>`** for every injected port parameter, *and* forwards the resolved value
|
|
167
|
+
into the wrapped `__init__` body β so the body can use the parameter normally.
|
|
168
|
+
- **A caller-supplied argument always wins.** `Feast(basket=my_test_basket)` skips injection for
|
|
169
|
+
`basket`, which keeps the class trivially testable.
|
|
170
|
+
- **Resolution is recursive.** If the injected adapter's own constructor takes `@Port` parameters,
|
|
171
|
+
those are resolved first.
|
|
172
|
+
- **Optional dependencies.** A port parameter typed `SomePort | None` (or any `@Port` parameter that
|
|
173
|
+
has a default value) is *optional*: if no adapter is active, the default β or `None` β is used
|
|
174
|
+
instead of raising. The same rule applies inside an adapter's own constructor, not just at the
|
|
175
|
+
`@Invert` consumer boundary.
|
|
176
|
+
- **Keyed selection at the point of use.** `@Invert(keys={"param": "name"})` picks a keyed adapter
|
|
177
|
+
right where it is consumed, rather than in a container call elsewhere.
|
|
178
|
+
|
|
179
|
+
```python
|
|
180
|
+
from idunn import Invert
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class Feast:
|
|
184
|
+
basket: AppleBasketPort # declared for the type checker; @Invert assigns it
|
|
185
|
+
|
|
186
|
+
@Invert
|
|
187
|
+
def __init__(self, basket: AppleBasketPort, other: str) -> None:
|
|
188
|
+
self.other = other # self.basket is injected and assigned for you
|
|
189
|
+
|
|
190
|
+
def serve(self) -> str:
|
|
191
|
+
return f"{self.other}: {self.basket.take_apple()}"
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
## Quick-start Guide π±
|
|
195
|
+
|
|
196
|
+
Idunn is published on [PyPI](https://pypi.org/project/idunn/). Install it with pip:
|
|
197
|
+
|
|
198
|
+
```bash
|
|
199
|
+
pip install idunn
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
(Using Poetry? `poetry add idunn`.)
|
|
203
|
+
|
|
204
|
+
> `idunn` (lowercase) is the package you install; `Idunn` (capital-I, the class) is the one
|
|
205
|
+
> process-wide container you import from it. `Idunn()` always hands back that same shared container β
|
|
206
|
+
> call it a thousand times and you still get the one barrel of apples.
|
|
207
|
+
|
|
208
|
+
Mark a **port** and an **adapter** in modules named `ports` / `adapters`, decorate the consumer with
|
|
209
|
+
`@Invert`, let Idunn discover everything once at startup, then just construct your entry object:
|
|
210
|
+
|
|
211
|
+
```python
|
|
212
|
+
from idunn import Idunn
|
|
213
|
+
|
|
214
|
+
Idunn().autodiscover("my_app") # import & register every @Port/@Adapter under my_app
|
|
215
|
+
app = MyApp() # MyApp.__init__ is @Invert-decorated; its ports wire themselves
|
|
216
|
+
app.run()
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
`autodiscover` is the only registration step you need, and `@Invert` is the only wiring step. A
|
|
220
|
+
runnable copy lives in `examples/` (`python -m examples.basic_usage`).
|
|
221
|
+
|
|
222
|
+
## Design stance π§
|
|
223
|
+
|
|
224
|
+
| Question | Idunn answer |
|
|
225
|
+
|---|---|
|
|
226
|
+
| How do I define a dependency? | Create a `Protocol` and mark it with `@Port`. |
|
|
227
|
+
| How do I bind behavior? | Mark a concrete class with `@Adapter(...)`. |
|
|
228
|
+
| How do I receive dependencies without container code? | Decorate the consumer's constructor with `@Invert`. |
|
|
229
|
+
| How do I register everything? | `Idunn().autodiscover("my_app")` once at startup. |
|
|
230
|
+
| Does `@Adapter` make the class implement the port? | No. The class must satisfy the `Protocol`, structurally or by inheritance. |
|
|
231
|
+
| When are dependencies injected? | When an `@Invert`-decorated constructor is called. |
|
|
232
|
+
| Optional dependency? | Type the parameter `SomePort | None` (or give it a default). |
|
|
233
|
+
| Field injection? | No. |
|
|
234
|
+
| Setter injection? | No. |
|
|
235
|
+
| External YAML config? | No. |
|
|
236
|
+
| Implicit protocol matching? | No. |
|
|
237
|
+
| Auto-discovery? | Yes, but only for decorated ports/adapters inside packages or modules named `port`, `ports`, `adapter`, or `adapters`. |
|
|
238
|
+
| Multiple adapters? | `envs` separates them per environment; `key` makes one opt-in. |
|
|
239
|
+
| Environments? | `IDUNN_ENV`, plus decorator-local `envs={...}`. |
|
|
240
|
+
| Tooling? | Poetry, pytest, Ruff, and Mypy are configured in `pyproject.toml`. |
|
|
241
|
+
|
|
242
|
+
## Install locally π¦
|
|
243
|
+
|
|
244
|
+
```bash
|
|
245
|
+
poetry install --with dev
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
## Basic usage πͺ
|
|
249
|
+
|
|
250
|
+
The headline workflow is **decorator-only**: mark ports and adapters, mark consumer constructors
|
|
251
|
+
with `@Invert`, discover once, then construct. Normal code never touches the container.
|
|
252
|
+
|
|
253
|
+
```python
|
|
254
|
+
from typing import Protocol
|
|
255
|
+
|
|
256
|
+
from idunn import Adapter, Idunn, Invert, LifecycleEnum, Port
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
@Port
|
|
260
|
+
class AppleBasketPort(Protocol):
|
|
261
|
+
def take_apple(self) -> str: ...
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
@Adapter(AppleBasketPort, lifecycle=LifecycleEnum.SINGLETON)
|
|
265
|
+
class GoldenAppleBasketAdapter(AppleBasketPort):
|
|
266
|
+
def take_apple(self) -> str:
|
|
267
|
+
return "π youth restored"
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
class Feast:
|
|
271
|
+
basket: AppleBasketPort # declared for the type checker; @Invert assigns it
|
|
272
|
+
|
|
273
|
+
@Invert
|
|
274
|
+
def __init__(self, basket: AppleBasketPort, other: str) -> None:
|
|
275
|
+
self.other = other # self.basket is injected and assigned for you
|
|
276
|
+
|
|
277
|
+
def serve(self) -> str:
|
|
278
|
+
return f"{self.other}: {self.basket.take_apple()}"
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
Idunn().autodiscover("my_app") # one-time bootstrap (ports/adapters live in my_app)
|
|
282
|
+
feast = Feast(other="funky") # basket is resolved & injected automatically
|
|
283
|
+
print(feast.serve()) # funky: π youth restored
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
`@Invert` inspects the constructor's type hints; every parameter annotated with a `@Port` is resolved
|
|
287
|
+
from the `Idunn()` singleton at construction time and assigned to `self.<name>`. A caller-supplied
|
|
288
|
+
argument always wins (`Feast(basket=my_basket, other="x")`), so the class stays trivially testable β
|
|
289
|
+
handy when you'd rather hand it a paper bag of test apples than the real basket. Power users can
|
|
290
|
+
target a keyed adapter with `@Invert(keys={"basket": "golden"})`, or inject an unannotated parameter
|
|
291
|
+
with an explicit map: `@Invert({"basket": AppleBasketPort})`.
|
|
292
|
+
|
|
293
|
+
## Recommended application layout π³
|
|
294
|
+
|
|
295
|
+
Idunn can discover decorated ports and adapters automatically, but if you are using IoC, you are interested in structure.
|
|
296
|
+
|
|
297
|
+
```text
|
|
298
|
+
my_app/
|
|
299
|
+
__init__.py
|
|
300
|
+
domain/
|
|
301
|
+
ports/
|
|
302
|
+
infrastructure/
|
|
303
|
+
adapters/
|
|
304
|
+
__init__.py
|
|
305
|
+
apples.py
|
|
306
|
+
payments.py
|
|
307
|
+
billing/
|
|
308
|
+
ports.py
|
|
309
|
+
adapters/
|
|
310
|
+
__init__.py
|
|
311
|
+
stripe.py
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
Then at startup:
|
|
315
|
+
|
|
316
|
+
```python
|
|
317
|
+
from idunn import Idunn
|
|
318
|
+
|
|
319
|
+
Idunn().autodiscover("my_app")
|
|
320
|
+
app = MyApp() # the @Invert-decorated entry object pulls in everything it needs
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
`Idunn().autodiscover("my_app")` imports modules whose dotted names contain one of these exact
|
|
324
|
+
parts:
|
|
325
|
+
|
|
326
|
+
```text
|
|
327
|
+
port
|
|
328
|
+
ports
|
|
329
|
+
adapter
|
|
330
|
+
adapters
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
Ports are imported and registered first; adapters second. It does **not** import arbitrary modules
|
|
334
|
+
just because they live inside your app, and it does **not** register undecorated classes. Discovery
|
|
335
|
+
is a metal detector tuned to one shape of badge, not a vacuum cleaner.
|
|
336
|
+
|
|
337
|
+
## AutoDiscovery rule π
|
|
338
|
+
|
|
339
|
+
Good:
|
|
340
|
+
|
|
341
|
+
```python
|
|
342
|
+
@Port
|
|
343
|
+
class AppleBasketPort(Protocol):
|
|
344
|
+
def take_apple(self) -> str: ...
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
@Adapter(AppleBasketPort)
|
|
348
|
+
class GoldenAppleBasketAdapter(AppleBasketPort):
|
|
349
|
+
...
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
These classes can be found by discovery because they wear the apple badge.
|
|
353
|
+
|
|
354
|
+
Not registered:
|
|
355
|
+
|
|
356
|
+
```python
|
|
357
|
+
class GoldenAppleBasketAdapter(AppleBasketPort):
|
|
358
|
+
...
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
Even if the class structurally satisfies the port, Idunn ignores it unless it is marked with
|
|
362
|
+
`@Adapter(...)`. Looking the part is not the same as wearing the badge.
|
|
363
|
+
|
|
364
|
+
## Port implementation rule π
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
Adapters must satisfy their ports. Idunn does **not** synthesize, monkey-patch, or mutate adapter
|
|
368
|
+
classes.
|
|
369
|
+
|
|
370
|
+
Recommended style:
|
|
371
|
+
|
|
372
|
+
```python
|
|
373
|
+
@Adapter(AppleBasketPort)
|
|
374
|
+
class GoldenAppleBasketAdapter(AppleBasketPort):
|
|
375
|
+
...
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
Also valid in Python protocol terms:
|
|
379
|
+
|
|
380
|
+
```python
|
|
381
|
+
@Adapter(AppleBasketPort)
|
|
382
|
+
class GoldenAppleBasketAdapter:
|
|
383
|
+
def take_apple(self) -> str:
|
|
384
|
+
return "golden apple"
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
The second form relies on structural typing. The first form is clearer, so examples use explicit
|
|
388
|
+
inheritance.
|
|
389
|
+
|
|
390
|
+
## Mapping adapters to ports π
|
|
391
|
+
|
|
392
|
+
A port is an empty basket; an adapter is the apples you put in it. How you pick between adapters
|
|
393
|
+
depends on how many you have. Start simple, and reach for keys only when you genuinely need them.
|
|
394
|
+
|
|
395
|
+
**The one rule:** resolving a port *without a key* only ever sees adapters registered *without a
|
|
396
|
+
key*. Keyed adapters are opt-in β you address them by name, or they sit quietly in the cellar.
|
|
397
|
+
|
|
398
|
+
### With no parameters
|
|
399
|
+
|
|
400
|
+
Most ports have exactly one implementation, and the calling code does not care which. Register it
|
|
401
|
+
plain and Idunn just hands it over β the implementation stays hidden behind the port, which is the
|
|
402
|
+
whole point of a port.
|
|
403
|
+
|
|
404
|
+
```python
|
|
405
|
+
@Adapter(AppleBasketPort)
|
|
406
|
+
class GoldenAppleBasketAdapter(AppleBasketPort):
|
|
407
|
+
def take_apple(self) -> str:
|
|
408
|
+
return "youth restored"
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
class Feast:
|
|
412
|
+
basket: AppleBasketPort # assigned by @Invert
|
|
413
|
+
|
|
414
|
+
@Invert
|
|
415
|
+
def __init__(self, basket: AppleBasketPort) -> None:
|
|
416
|
+
pass
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
Idunn().autodiscover("my_app")
|
|
420
|
+
Feast().basket.take_apple()
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
One basket, one adapter, zero decisions. This is the case you want most of the time.
|
|
424
|
+
|
|
425
|
+
### Via the environment
|
|
426
|
+
|
|
427
|
+
When the *same role* needs *different apples* in dev, test, and production β a real gateway in
|
|
428
|
+
`prod`, a fake one in tests β put the environment right in the decorator. No config files, no YAML,
|
|
429
|
+
no 200-line `settings.py`. The adapters are never active at once, so an unkeyed resolve always has
|
|
430
|
+
exactly one answer and the consumer never changes between environments.
|
|
431
|
+
|
|
432
|
+
```python
|
|
433
|
+
@Adapter(PaymentPort, envs={"prod"})
|
|
434
|
+
class StripePaymentAdapter(PaymentPort): ...
|
|
435
|
+
|
|
436
|
+
@Adapter(PaymentPort, envs={"test", "ci"})
|
|
437
|
+
class FakePaymentAdapter(PaymentPort): ...
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
Set the active environment with `IDUNN_ENV`:
|
|
441
|
+
|
|
442
|
+
```bash
|
|
443
|
+
IDUNN_ENV=test
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
If `IDUNN_ENV` is unset, Idunn defaults to `local`. Environment names are normalized to lowercase,
|
|
447
|
+
and underscores become hyphens (so `My_Env` and `my-env` are the same place). In tests it's often
|
|
448
|
+
easier to rebind the singleton directly with `Idunn().reset(environment="prod")` β see
|
|
449
|
+
[`docs/ADVANCED.md`](./docs/ADVANCED.md#test-isolation).
|
|
450
|
+
|
|
451
|
+
### Environment matching rules
|
|
452
|
+
|
|
453
|
+
| Decorator value | Behavior |
|
|
454
|
+
|---|---|
|
|
455
|
+
| `envs=None` | Adapter is active in every environment (the apple for all seasons). |
|
|
456
|
+
| `envs={"test"}` | Adapter is active only when the active environment is `test`. |
|
|
457
|
+
| `envs={"test", "ci"}` | Adapter is active in either `test` or `ci`. |
|
|
458
|
+
|
|
459
|
+
### Via keys
|
|
460
|
+
|
|
461
|
+
When several implementations are *all* valid in the *same* environment and the environment can't
|
|
462
|
+
tell them apart, give each one a key. The cleanest way to pick one is at the point of use β the
|
|
463
|
+
consumer's constructor β with `@Invert(keys={...})`. The choice lives right next to the code that
|
|
464
|
+
depends on it.
|
|
465
|
+
|
|
466
|
+
```python
|
|
467
|
+
@Adapter(NotifierPort, key="email")
|
|
468
|
+
class EmailNotifier(NotifierPort): ...
|
|
469
|
+
|
|
470
|
+
@Adapter(NotifierPort, key="sms")
|
|
471
|
+
class SmsNotifier(NotifierPort): ...
|
|
472
|
+
|
|
473
|
+
class Reminder:
|
|
474
|
+
notifier: NotifierPort # assigned by @Invert
|
|
475
|
+
|
|
476
|
+
@Invert(keys={"notifier": "sms"})
|
|
477
|
+
def __init__(self, notifier: NotifierPort) -> None:
|
|
478
|
+
pass
|
|
479
|
+
```
|
|
480
|
+
|
|
481
|
+
And the one rule again, because it earns repeating: a keyed adapter never answers an unkeyed resolve.
|
|
482
|
+
If `email` and `sms` are your *only* adapters for `NotifierPort`, then a plain `@Invert` parameter
|
|
483
|
+
typed `NotifierPort` raises `AdapterNotFoundError` β there's no unkeyed apple in the basket. Pick one
|
|
484
|
+
with `keys={...}`, or register an unkeyed adapter.
|
|
485
|
+
|
|
486
|
+
### Same key, different environments
|
|
487
|
+
|
|
488
|
+
A key only has to be unique *within an environment*, so the same key can name different adapters in
|
|
489
|
+
environments that never overlap:
|
|
490
|
+
|
|
491
|
+
```python
|
|
492
|
+
@Adapter(PaymentPort, key="primary", envs={"prod"})
|
|
493
|
+
class StripePaymentAdapter(PaymentPort): ...
|
|
494
|
+
|
|
495
|
+
@Adapter(PaymentPort, key="primary", envs={"test"})
|
|
496
|
+
class FakePaymentAdapter(PaymentPort): ...
|
|
497
|
+
```
|
|
498
|
+
|
|
499
|
+
This, however, is a collision β both are active in `prod`:
|
|
500
|
+
|
|
501
|
+
```python
|
|
502
|
+
@Adapter(PaymentPort, key="primary", envs={"prod"})
|
|
503
|
+
class StripePaymentAdapter(PaymentPort): ...
|
|
504
|
+
|
|
505
|
+
@Adapter(PaymentPort, key="primary", envs={"prod"})
|
|
506
|
+
class BraintreePaymentAdapter(PaymentPort): ...
|
|
507
|
+
```
|
|
508
|
+
|
|
509
|
+
Idunn raises `InvalidAdapterError` when two adapters share a port and key (unkeyed counts as the
|
|
510
|
+
same "no key") and are both active in overlapping environments. Two apples, one label, same
|
|
511
|
+
shelf β somebody's about to grab the wrong one, so Idunn refuses to guess.
|
|
512
|
+
|
|
513
|
+
## Lifecycles π
|
|
514
|
+
|
|
515
|
+
| LifecycleEnum | Behavior |
|
|
516
|
+
|---|---|
|
|
517
|
+
| `LifecycleEnum.TRANSIENT` | A new instance is built every time the port is resolved. |
|
|
518
|
+
| `LifecycleEnum.SINGLETON` | One instance is created and reused. |
|
|
519
|
+
|
|
520
|
+
## Known limitations π§
|
|
521
|
+
|
|
522
|
+
`Idunn` is deliberately a single process-wide container, which buys simplicity at a few prices worth
|
|
523
|
+
knowing:
|
|
524
|
+
|
|
525
|
+
- **One container per process.** There is no second, independent container; everything shares the
|
|
526
|
+
same `Idunn()`. (Multi-container setups are out of scope β one barrel of apples per kitchen.)
|
|
527
|
+
- **Not thread-safe.** Wire everything up at startup on one thread, *then* resolve. Registration and
|
|
528
|
+
resolution mutate shared state without locking, and Idunn does not appreciate two cooks resolving
|
|
529
|
+
in her kitchen at once.
|
|
530
|
+
- **Test isolation is your job.** Reset between tests with `Idunn().reset()` (e.g. an autouse
|
|
531
|
+
fixture). See [`docs/ADVANCED.md`](./docs/ADVANCED.md#test-isolation).
|
|
532
|
+
|
|
533
|
+
## Development workflow π§ͺ
|
|
534
|
+
|
|
535
|
+
The project uses Poetry with pytest, Ruff, and Mypy configured in `pyproject.toml`.
|
|
536
|
+
|
|
537
|
+
```bash
|
|
538
|
+
poetry install --with dev
|
|
539
|
+
poetry run pytest
|
|
540
|
+
poetry run ruff format --check .
|
|
541
|
+
poetry run ruff check .
|
|
542
|
+
poetry run mypy
|
|
543
|
+
```
|
|
544
|
+
|
|
545
|
+
A GitHub Actions workflow is included at:
|
|
546
|
+
|
|
547
|
+
```text
|
|
548
|
+
.github/workflows/ci.yml
|
|
549
|
+
```
|
|
550
|
+
|
|
551
|
+
The CI quality gate runs the same checks across Python 3.11, 3.12, 3.13, and 3.14.
|
|
552
|
+
|
|
553
|
+
## What Idunn intentionally does not do π«
|
|
554
|
+
|
|
555
|
+
Half of Idunn's design is the features it cheerfully refuses to grow:
|
|
556
|
+
|
|
557
|
+
- No external YAML configuration
|
|
558
|
+
- No package-wide global scanning
|
|
559
|
+
- No subclass scanning
|
|
560
|
+
- No βclass name ends with `Adapter`, so letβs register itβ guesswork
|
|
561
|
+
- No implicit protocol matching for registration
|
|
562
|
+
- No construction during decoration
|
|
563
|
+
- No construction during autodiscovery
|
|
564
|
+
- No field injection
|
|
565
|
+
- No setter injection
|
|
566
|
+
- No loose global resolver functions
|
|
567
|
+
|
|
568
|
+
If an adapter wants in, it wears the apple badge explicitly β no badge, no basket:
|
|
569
|
+
|
|
570
|
+
```python
|
|
571
|
+
@Adapter(SomePort)
|
|
572
|
+
class SomeAdapter(SomePort):
|
|
573
|
+
...
|
|
574
|
+
```
|
|
575
|
+
|
|
576
|
+
That short "no" list *is* the feature. (See the Einstein quote up top: as simple as possible, and
|
|
577
|
+
not one apple simpler.)
|
|
578
|
+
|
|
579
|
+
## Going further π
|
|
580
|
+
|
|
581
|
+
The everyday API is the three decorators plus `autodiscover`. Everything else β how resolution
|
|
582
|
+
actually fires, inspecting the wired graph with `describe()`, manual registration, rebinding the
|
|
583
|
+
environment for tests, and the deliberate non-features (no lifecycle on `@Invert`, no priority,
|
|
584
|
+
no value injection) β lives in [`docs/ADVANCED.md`](./docs/ADVANCED.md). A class-by-class catalog is
|
|
585
|
+
in [`docs/classes.md`](./docs/classes.md).
|
|
586
|
+
|
|
587
|
+
### Exceptions
|
|
588
|
+
|
|
589
|
+
All inherit from `IdunnError`, and all import from `idunn`:
|
|
590
|
+
|
|
591
|
+
| Class | Raised when⦠|
|
|
592
|
+
|-------|--------------|
|
|
593
|
+
| `InvalidPortError` | `@Port` is applied to a non-Protocol class. |
|
|
594
|
+
| `InvalidAdapterError` | An adapter registration is invalid (bad target, duplicate key in overlapping environments, unsatisfied port). |
|
|
595
|
+
| `AdapterNotFoundError` | No active adapter is registered for a requested port. |
|
|
596
|
+
| `DiscoveryError` | Autodiscovery fails to import a bounded module. |
|
|
597
|
+
| `MissingTypeHintError` | Constructor injection needs a type hint that is missing (or a non-port param with no default). |
|
|
598
|
+
| `InjectionCycleError` | Constructor dependency resolution loops back on itself. |
|
|
599
|
+
|
|
600
|
+
## Code style constraints π
|
|
601
|
+
|
|
602
|
+
The implementation is intentionally class-heavy:
|
|
603
|
+
|
|
604
|
+
- decorators are functions because Python decorators are naturally functions;
|
|
605
|
+
- support behavior is encapsulated in classes;
|
|
606
|
+
- package code avoids loose utility functions;
|
|
607
|
+
- package methods/functions use a single return point.
|
|
608
|
+
|
|
609
|
+
## Version target π
|
|
610
|
+
|
|
611
|
+
```toml
|
|
612
|
+
python = ">=3.11,<4.0"
|
|
613
|
+
```
|
|
614
|
+
|
|
615
|
+
## Before publishing to PyPI π
|
|
616
|
+
|
|
617
|
+
Before the first public release, update these project-specific values:
|
|
618
|
+
|
|
619
|
+
- `authors` in `pyproject.toml`
|
|
620
|
+
- package homepage / repository URLs, once the repo exists
|
|
621
|
+
- the copyright holder in `LICENSE`, if needed
|
|
622
|
+
- package classifiers if the tested Python matrix changes
|
|
623
|
+
|
|
624
|
+
Then run:
|
|
625
|
+
|
|
626
|
+
```bash
|
|
627
|
+
poetry build
|
|
628
|
+
poetry publish
|
|
629
|
+
```
|
|
630
|
+
|