Idunn
Idunn is a tiny Python dependency-inversion toolkit built around constructor-time injection only: three decorators, one discovery call, nothing to configure.
"Everything should be made as simple as possible, but not simpler." — Albert Einstein
The name comes from Iðunn, the Norse keeper of the apples that keep the gods young. A port is an empty basket, an adapter is the apple you put in it, and a consumer just asks for the basket.
This file is the everyday reference. Read About Idunn for the thinking behind it and Advanced for environments, keys, test isolation, and the engine.
Introduction
Installation
pip install idunn # or: poetry add idunn
idunn (lowercase) is the package; Idunn (the class) is the one process-wide container inside it.
Idunn() always returns that same shared instance.
Importing
from idunn import Port, Adapter, Invert # the three decorators
from idunn import Idunn # the container: autodiscover() or bootstrap(), once
from idunn import LifecycleEnum # TRANSIENT (default) or SINGLETON, passed to @Adapter
from idunn import IdunnError # base of the exception hierarchy
from idunn import ReportMap # the TypedDict autodiscover() and bootstrap() return
That is the entire surface. Registration, selection, and construction happen behind the container.
Discovery: autodiscover and bootstrap
Idunn registers nothing on import. Decorators only attach metadata; one discovery call per process
imports your modules so the decorated classes get catalogued. Both calls import every module under
root_package (skipping __main__ and test modules) and register each class wearing @Port or
@Adapter, whatever its module is called. The package you name is the boundary: nothing outside it
is imported or registered. Ports register before adapters, undecorated classes are ignored, and
nothing is constructed.
Discovery imports everything under the root, so keep module import cheap and side-effect free. Put
scripts behind if __name__ == '__main__':, and pass exclude= for modules that do work at import
or need optional dependencies.
| Call | Use it when | Behavior |
|---|---|---|
Idunn().autodiscover('my_app') |
You own the process: an application with a main(), an app factory, or a startup hook. |
Runs discovery each time it is called. Call it once at startup, before the first @Invert() object is built. |
Idunn().bootstrap('my_lib') |
You ship a library and have no startup hook to call autodiscover from. |
autodiscover guarded to run once per root. Call it from every public entry point: the first call discovers, later calls return the cached report. Thread-safe. |
Applications
from idunn import Idunn
Idunn().autodiscover('my_app') # once, at startup
app = MyApp() # MyApp.__init__ is @Invert()-decorated; the graph wires itself
Libraries
A library is imported, not started, and discovery at import time is a side effect nobody asked for.
Put bootstrap inside the library's own entry points so wiring happens the first time anyone uses it,
and bootstrap the narrowest package that holds your ports and adapters:
from idunn import Idunn
class Engine:
"""Example engine class"""
...
class MyLib:
"""Public entry point; wires my_lib on first use."""
def __init__(self) -> None:
Idunn().bootstrap('my_lib') # discovers once; a cheap no-op afterwards
self._engine = Engine() # an @Invert()-decorated worker
Both calls return a ReportMap listing the modules imported and the classes registered, handy for a
startup log. Discovery accumulates, so an application calls autodiscover for its own package and
any library it uses still bootstraps itself.
Normal usage
Discovery finds every @Port and @Adapter under the root you name, whatever the modules are
called. For a small app, the layout below is all you need: a ports.py, an adapters.py, and your
entry point beside them. As it grows, turn either file into a package (ports/, adapters/stripe.py)
or give each feature its own pair (billing/ports.py, billing/adapters.py). The names are for the
people reading your code; Idunn doesn't check them. Decorate the consumer's constructor with
@Invert(), discover once, then construct.
my_app/
__init__.py
main.py
ports.py
adapters.py
See examples in /examples/my_app
# my_app/ports.py
from typing import Protocol
from idunn import Port
@Port
class AppleBasketPort(Protocol):
def take_apple(self) -> str: ...
# my_app/adapters.py
from idunn import Adapter, LifecycleEnum
from .ports import AppleBasketPort
@Adapter(AppleBasketPort, lifecycle=LifecycleEnum.SINGLETON)
class GoldenAppleBasketAdapter(AppleBasketPort):
def take_apple(self) -> str:
return 'youth restored'
# my_app/main.py
from idunn import Idunn, Invert
from .ports import AppleBasketPort
class Feast:
basket: AppleBasketPort # declared for the type checker; @Invert assigns it
@Invert()
def __init__(self, basket: AppleBasketPort, host: str) -> None:
self.host = host # self.basket is injected and assigned for you
def serve(self) -> str:
return f'{self.host}: {self.basket.take_apple()}'
if __name__ == '__main__':
Idunn().autodiscover('my_app')
print(Feast(host='Bragi').serve()) # Bragi: youth restored
Run the example with python -m examples.my_app.main; there the discovery root is 'examples.my_app'.
The three decorators
-
@PortMarks a
typing.Protocolas a contract. Anything that is not a Protocol raisesInvalidPortError. The Protocol is maderuntime_checkableso Idunn can confirm an adapter satisfies it. -
@Adapter(port, lifecycle=...)Declares a concrete class as the implementation of
port. It attaches metadata and constructs nothing. The class must satisfy the Protocol, by inheritance (recommended) or structurally; Idunn never patches it to fit. One adapter per port is the normal case.lifecycleisTRANSIENTby default (a new instance per resolve) orSINGLETON(built once, reused).
Selecting between several adapters withkey=andenvs=is covered in Advanced. -
@Invert()Wraps a consumer's
__init__. Every parameter whose type hint is a@Portis resolved when the constructor runs, assigned toself.<name>, and passed into the body. Three things to know:- A caller-supplied argument always wins, so
Feast(basket=fake, host='x')skips injection and keeps the class easy to test. - Resolution is recursive: an adapter's own
@Portconstructor parameters are resolved first. - Constructing an
@Invert()object is the only way to start a graph. There is noresolve(). Always apply it with parentheses. Keyed selection, explicit parameter maps, and optional dependencies are in Advanced.
- A caller-supplied argument always wins, so
What gets registered
Only classes wearing the decorator, defined at module level in a module under the root. A class that
satisfies a port but lacks @Adapter(...) is invisible to discovery. So is an undecorated subclass of
an adapter, a decorated class in a test module or an excluded one, and a decorated class imported
from another package (discover that package as its own root). Looking the part is not the same as
wearing the badge.
Development
poetry install --with dev
poetry run pytest
poetry run ruff format --check . && poetry run ruff check .
poetry run mypy
CI (.github/workflows/ci.yml) runs the same checks on Python 3.11, 3.12, and 3.13.16. Idunn supports
Python 3.11 and newer (requires-python = ">=3.11,<4.0") and is developed on 3.13.16.
Going further
- About Idunn: the model and the reasons behind the short "no" list.
- Advanced: environments and keys, the full
@Invertcontract, discovery options,describe(), test isolation, exceptions, limitations, and maintainer notes. - Class catalog: every class in the package, public or hidden.
Metadata
Release files for idunn 1.0.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| idunn-1.0.1.tar.gz | 21.7 kB | Without provenance | Details for idunn-1.0.1.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| idunn-1.0.1-py3-none-any.whl | Python 3 | none | any | Details for idunn-1.0.1-py3-none-any.whl |
Total release size: 49.7 kB
Release files / idunn-1.0.1.tar.gz
| Download URL | idunn-1.0.1.tar.gz |
|---|---|
| Size | 21.7 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
fae9613360ee94418346250adaafbab5a73ec14bedbb5d47be2a7cd70f5493c9
|
|
BLAKE2b-256 checksum How to use checksums |
ec51a038d43e9e2b9fc2c55d1ac972757a0b7004729b61bc2baf299ef22099e3
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
poetry/2.5.1 CPython/3.14.4 Darwin/27.0.0
|
Release files / idunn-1.0.1-py3-none-any.whl
| Download URL | idunn-1.0.1-py3-none-any.whl |
|---|---|
| Size | 28.0 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
f59fd399cc13f1e70039a32b02b4f548813ca231556cd9bf48b7b9ccd91f864e
|
|
BLAKE2b-256 checksum How to use checksums |
6f841b8aa15da9eedb0ef21c4dfc992f32debf362435d2ac141a02a79c5b8434
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
poetry/2.5.1 CPython/3.14.4 Darwin/27.0.0
|
Security for idunn 1.0.1
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.