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 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
+ ![Idunn](https://github.com/terracoil/idunn/blob/master/docs/images/idunn-logo-square.png)
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
+ ![Idunn2](![Idunn](https://github.com/terracoil/idunn/blob/master/docs/images/idunn-logo-md.png))
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
+