Idunn
Idunn is a tiny, opinionated constructor-time dependency-inversion toolkit for Python:
dependencies arrive when an @Invert() constructor or function is called, and not a moment sooner.
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. Nobody has to know which orchard it came from, which is rather the point of dependency inversion.
This file is the everyday reference.
About Idunn explains why it
looks the way it does, and
Advanced covers
environments, keys, testing with idunn.testing and the engine, for the days the everyday is not
enough.
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, so there is nothing to pass around and nothing
to misplace.
Importing
from idunn import Port, Adapter, Invert # the three decorators
from idunn import Idunn # the container: autodiscover() once, or bootstrap() per entry point
from idunn import LifecycleEnum # TRANSIENT (default) or SINGLETON, passed to @Adapter
from idunn import IdunnError # base of the exception family
from idunn import ReportMap # the TypedDict autodiscover() and bootstrap() return
That, plus the six IdunnError subclasses, is everything an application imports; its tests
borrow one more name, Harness, from idunn.testing. Registration, selection and construction
happen behind the container, where business code cannot get attached to them.
Discovery: autodiscover and bootstrap
Idunn registers nothing on import. Decorators only attach metadata, and one discovery call imports
your modules so the decorated classes get catalogued. Both calls import every module under
root_package (skipping __main__.py, test modules and anything you exclude) and register each
class wearing @Port or @Adapter, whatever its module is called. The package you name is the
boundary: nothing outside it is scanned or registered. Your modules may still import what they
like; Idunn just won't catalogue it. Ports register before adapters, undecorated classes are
ignored, and nothing is constructed.
Since discovery imports everything under the root, module import should be cheap and free of side
effects. Put scripts behind if __name__ == '__main__':, and exclude= the modules that do work
at import or need optional dependencies you have not installed.
| 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. |
Discovers every 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: the first call discovers, later calls return the cached report. Thread-safe, even when called while a module is being imported. Usually you never write it: @Invert(bootstrap='my_lib') on the library's consumers makes the call for you the first time one needs a port. |
Applications
An application owns its process, so it discovers once at startup and then builds the object graph:
# your_app/ports.py
from typing import Protocol
from idunn import Port
@Port
class GreeterPort(Protocol):
def greet(self, name: str) -> str: ...
# your_app/adapters.py
from idunn import Adapter
from .ports import GreeterPort
@Adapter(GreeterPort)
class PlainGreeterAdapter:
def greet(self, name: str) -> str:
return f'Hello, {name}'
# your_app/main.py
from idunn import Idunn, Invert
from .ports import GreeterPort
class App:
greeter: GreeterPort # assigned by @Invert
@Invert()
def __init__(self, *, greeter: GreeterPort) -> None:
pass
def run(self) -> None:
print(self.greeter.greet('Idunn')) # Hello, Idunn
if __name__ == '__main__':
Idunn().autodiscover('your_app') # once, at startup
App().run() # App.__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 ordered.
So let the library wire itself the first time anyone uses it: give its @Invert consumers
bootstrap=, naming the narrowest package that holds your ports and adapters:
# my_lib/ports.py
from typing import Protocol
from idunn import Port
@Port
class StoragePort(Protocol):
def save(self, key: str, value: str) -> None: ...
# my_lib/adapters.py
from idunn import Adapter, LifecycleEnum
from .ports import StoragePort
@Adapter(StoragePort, lifecycle=LifecycleEnum.SINGLETON)
class MemoryStorageAdapter:
def __init__(self) -> None:
self.items: dict[str, str] = {}
def save(self, key: str, value: str) -> None:
self.items[key] = value
# my_lib/api.py
from idunn import Invert
from .ports import StoragePort
class Engine:
storage: StoragePort # assigned by @Invert
@Invert(bootstrap='my_lib') # discovers my_lib on first use; a dict lookup afterwards
def __init__(self, *, storage: StoragePort) -> None:
pass
def store(self, key: str, value: str) -> None:
self.storage.save(key, value)
class MyLib:
"""Public entry point; the Engine it builds wires my_lib on the way in."""
def __init__(self) -> None:
self._engine = Engine()
def remember(self, key: str, value: str) -> None:
self._engine.store(key, value)
bootstrap= runs Idunn().bootstrap('my_lib') only when a call leaves a port to injection, so
Engine(storage=fake) in a test discovers nothing. An entry point with no ports of its own, or
one that needs exclude= or strict=, calls Idunn().bootstrap('my_lib', ...) itself, first
thing; the first call for a root wins, and every later one, bootstrap= included, is a cache hit.
Both calls return a ReportMap listing the modules imported and the classes registered, which
makes a tidy startup log line. autodiscover lists only what that call registered (a repeat run
lists none); bootstrap returns the root's cached report, which covers everything its first
discovery registered. Discovery accumulates, so an application can autodiscover its own package
while every library it uses quietly 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
The same app lives in
examples/classy/simple.
# my_app/ports.py
from typing import Protocol
from idunn import Port
@Port
class AppleBasketPort(Protocol):
"""Source of apples that keep the gods young."""
def take_apple(self) -> str:
"""Return an apple from the basket."""
...
# my_app/adapters.py
from idunn import Adapter, LifecycleEnum
from .ports import AppleBasketPort
@Adapter(AppleBasketPort, lifecycle=LifecycleEnum.SINGLETON)
class GoldenAppleBasketAdapter(AppleBasketPort):
"""The one basket ``AppleBasketPort`` resolves to, built once and shared by everyone."""
def take_apple(self) -> str:
"""Hand out a golden apple."""
return 'youth restored'
# my_app/main.py
from idunn import Idunn, Invert
from .ports import AppleBasketPort
class Feast:
"""An ``@Invert()`` root: its basket arrives when it is constructed.
The port sits after ``*``, keyword-only, so ``Feast('Bragi')`` can only mean the host.
"""
basket: AppleBasketPort # declared for the type checker; @Invert assigns it
@Invert()
def __init__(self, host: str, *, basket: AppleBasketPort) -> None:
"""Keep ``host``; ``self.basket`` is already injected and assigned by the time this runs."""
self.host: str = host
def serve(self) -> str:
"""Serve the feast with an apple from the injected basket."""
return f'{self.host}: {self.basket.take_apple()}'
if __name__ == '__main__':
Idunn().autodiscover('my_app')
print(Feast('Bragi').serve()) # Bragi: youth restored
Run it as a module, python -m my_app.main. Run by path, the relative imports have nothing to be
relative to and Python says so. In this repo the copy sits two packages deeper, so it runs with
poetry run python -m examples.classy.simple.main and discovers 'examples.classy.simple'. That
root is the one line that differs from the listings above; the files also keep module docstrings,
which the listings leave out to spare you the scrolling.
More examples:
examples/functional/simple
is the same app with an @Invert() function where the class was, and
examples/classy/complex
and
examples/functional/complex
run a small shop that leans on most of
Advanced, once with classes and
once with functions. The suite runs all four and checks every line they print.
The three decorators
-
@PortMarks a
typing.Protocolas a contract. Anything that is not a Protocol raisesInvalidPortError. The Protocol is maderuntime_checkableso Idunn can confirm each adapter actually satisfies it, instead of taking its word for 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 itself, by inheritance or structurally; inheritance is recommended because then your type checker, and your colleagues, can see the relationship. Idunn never patches a class to fit. One adapter per port is the normal case.lifecycleisTRANSIENTby default (a new instance per resolve) orSINGLETON(built once, shared).
Choosing 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. On any other function it does the same at call time, minus the assignment: the ports arrive as keyword arguments and nothing is kept anywhere.@Invert() def harvest(orchard: str, *, basket: AppleBasketPort) -> str: return f'{orchard}: {basket.take_apple()}' harvest('Asgard') # basket is resolved and passed in; harvest(..., basket=fake) skips that
The forms, whose arguments combine freely:
@Invert() # infer the ports from the type hints @Invert(keys={'basket': 'golden'}) # pick a keyed adapter for one parameter @Invert({'basket': AppleBasketPort}) # map a parameter whose hint is not a port @Invert(bootstrap='my_lib') # a library: discover my_lib when a port is first missing
Four things to know:
- A caller-supplied argument always wins, so
Feast('x', basket=fake)skips injection and the class stays easy to test. It skipsbootstrap=too, once every port is supplied. - Port parameters are keyword-only: declare them after
*, and@Invert()refuses anything else. SoFeast('Bragi')can only meanhost, and a port you supply yourself goes by name. - Resolution is recursive: an adapter's own
@Portconstructor parameters are resolved first. - Calling an
@Invert()constructor or function 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, and Idunn would rather be told than guess.
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 lock, format, lint and type checks once and the tests on
Python 3.11, 3.12, 3.13.16 and 3.14. poetry.lock is committed, so run poetry lock after touching
dependencies, or CI will notice. 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 list of things it refuses to do.
- Advanced: environments and
keys, the full
@Invertcontract, discovery options,describe(), testing withidunn.testing, exceptions, limitations, and maintainer notes. - Class catalog: every class in the package, public or hidden.
- Changelog: what changed in each release, and what to fix when upgrading to 2.0.
Metadata
Release files for idunn 2.0.0
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-2.0.0.tar.gz | 55.2 kB | Without provenance | Details for idunn-2.0.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| idunn-2.0.0-py3-none-any.whl | Python 3 | none | any | Details for idunn-2.0.0-py3-none-any.whl |
Total release size: 118.7 kB
Release files / idunn-2.0.0.tar.gz
| Download URL | idunn-2.0.0.tar.gz |
|---|---|
| Size | 55.2 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
9d041fada9db86c7b8ac2428adfdc4ac819a1319861c5238d92bcbd2be3a3bc9
|
|
BLAKE2b-256 checksum How to use checksums |
5beaad6298e3ff94abe994c16dae3d0ebbee4039e8d1985b7fa3157a678f71f1
|
| 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-2.0.0-py3-none-any.whl
| Download URL | idunn-2.0.0-py3-none-any.whl |
|---|---|
| Size | 63.4 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
2d2908eacc1a4e9eee26981402dbf731b04c17a253b9ca4145f842fd0d8ed7af
|
|
BLAKE2b-256 checksum How to use checksums |
39f4f5f22580129ae268699c6ea4a1a3da272cbb192af6ba000f04a2d21618d7
|
| 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 2.0.0
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.