A tiny constructor-time dependency inversion toolkit for Python.
Project description
Idunn ๐
Idunn is a tiny Python dependency-inversion / IoC toolkit built around constructor-time injection only โ small enough to read on a coffee break, opinionated enough to keep your wiring honest.
"Everything should be made as simple as possible, but not simpler." โ Albert Einstein
The name comes from Iรฐunn / Idunn, the Norse keeper of the apples that keep the gods young. Idunn borrows that image as its DI metaphor: keep the right dependencies close to the system and the code stays fresh, instead of hardening into the kind of brittle wiring that makes future-you sigh. โจ
๐ New to Idunn? Read About Idunn โ the philosophy, the Port โ Adapter โ
@Invertmodel, and why the short "no" list is the feature. Then come back here for the reference.
๐ Table-of-Contents
- The whole API ๐๏ธ
- The three decorators ๐
- Quick-start Guide ๐ฑ
- Design stance ๐งญ
- Install locally ๐ฆ
- Basic usage ๐ช
- Recommended application layout ๐ณ
- AutoDiscovery rule ๐
- Port implementation rule ๐
- Mapping adapters to ports ๐
- Lifecycles ๐
- Known limitations ๐ง
- Development workflow ๐งช
- What Idunn intentionally does not do ๐ซ
- Going further ๐
- Code style constraints ๐
- Version target ๐
- Before publishing to PyPI ๐
The whole API ๐๏ธ
Everything you import lives at the top level of the idunn package โ you never reach into
sub-packages:
from idunn import Port, Adapter, Invert # the three decorators
from idunn import Idunn # the container (you touch it once: autodiscover)
from idunn import LifecycleEnum # passed to @Adapter
from idunn import IdunnError # base of the exception hierarchy you catch
That's the entire surface: three decorators, the Idunn().autodiscover(...) bootstrap call,
LifecycleEnum, and the exceptions. Registration, selection and construction all
happen behind the container โ see docs/ADVANCED.md if you ever want to peek.
The three decorators ๐
Idunn is just these three decorators. Define a contract, bind an implementation, receive it โ no container code in sight.
@Port ๐
def Port(cls: T) -> T
Marks a typing.Protocol as an injectable contract. Applied to anything that is not a
Protocol, it raises InvalidPortError. The decorated protocol is made runtime_checkable so
Idunn can verify that an adapter actually satisfies it.
from typing import Protocol
from idunn import Port
@Port
class AppleBasketPort(Protocol):
def take_apple(self) -> str: ...
@Adapter ๐งฉ
def Adapter(
port: type,
*,
key: str | None = None,
lifecycle: LifecycleEnum | str = LifecycleEnum.TRANSIENT,
envs: Iterable[str] | str | None = None,
) -> Callable[[T], T]
Declares a concrete class as an implementation of port. It attaches metadata and constructs
nothing.
| Parameter | Meaning |
|---|---|
port |
The @Port this class implements (must be marked, else InvalidPortError). |
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. |
lifecycle |
TRANSIENT (default โ new instance each time) or SINGLETON (built once, reused). |
envs |
Environments the adapter is active in. None = every environment. See environment matching. |
from idunn import Adapter, LifecycleEnum
@Adapter(AppleBasketPort, lifecycle=LifecycleEnum.SINGLETON)
class GoldenAppleBasketAdapter(AppleBasketPort):
def take_apple(self) -> str:
return "๐ youth restored"
The class must satisfy the protocol structurally or by inheritance โ @Adapter never synthesizes or
mutates it. Exactly one unkeyed adapter may be active per port in any environment.
@Invert ๐ช
@Invert # infer ports from the constructor's type hints
@Invert(keys={"basket": "wild"}) # pick keyed adapters per parameter
@Invert({"basket": AppleBasketPort}) # explicit param -> port (for an un-annotated parameter)
Wraps a consumer's __init__. Every parameter whose type hint is a @Port is resolved from the
process-wide Idunn() container when the constructor runs and assigned to self.<name>. This is
the only sanctioned way to start an object graph โ you construct your entry object and the rest wires
itself; you never call the container to resolve.
The full contract:
- It assigns
self.<name>for every injected port parameter, and forwards the resolved value into the wrapped__init__body โ so the body can use the parameter normally. - A caller-supplied argument always wins.
Feast(basket=my_test_basket)skips injection forbasket, which keeps the class trivially testable. - Resolution is recursive. If the injected adapter's own constructor takes
@Portparameters, those are resolved first. - Optional dependencies. A port parameter typed
SomePort | None(or any@Portparameter that has a default value) is optional: if no adapter is active, the default โ orNoneโ is used instead of raising. The same rule applies inside an adapter's own constructor, not just at the@Invertconsumer boundary. - Keyed selection at the point of use.
@Invert(keys={"param": "name"})picks a keyed adapter right where it is consumed, rather than in a container call elsewhere.
from idunn import Invert
class Feast:
basket: AppleBasketPort # declared for the type checker; @Invert assigns it
@Invert
def __init__(self, basket: AppleBasketPort, other: str) -> None:
self.other = other # self.basket is injected and assigned for you
def serve(self) -> str:
return f"{self.other}: {self.basket.take_apple()}"
Quick-start Guide ๐ฑ
Idunn is published on PyPI. Install it with pip:
pip install idunn
(Using Poetry? poetry add idunn.)
idunn(lowercase) is the package you install;Idunn(capital-I, the class) is the one process-wide container you import from it.Idunn()always hands back that same shared container โ call it a thousand times and you still get the one barrel of apples.
Mark a port and an adapter in modules named ports / adapters, decorate the consumer with
@Invert, let Idunn discover everything once at startup, then just construct your entry object:
from idunn import Idunn
Idunn().autodiscover("my_app") # import & register every @Port/@Adapter under my_app
app = MyApp() # MyApp.__init__ is @Invert-decorated; its ports wire themselves
app.run()
autodiscover is the only registration step you need, and @Invert is the only wiring step. A
runnable copy lives in examples/ (python -m examples.basic_usage).
Design stance ๐งญ
| Question | Idunn answer |
|---|---|
| How do I define a dependency? | Create a Protocol and mark it with @Port. |
| How do I bind behavior? | Mark a concrete class with @Adapter(...). |
| How do I receive dependencies without container code? | Decorate the consumer's constructor with @Invert. |
| How do I register everything? | Idunn().autodiscover("my_app") once at startup. |
Does @Adapter make the class implement the port? |
No. The class must satisfy the Protocol, structurally or by inheritance. |
| When are dependencies injected? | When an @Invert-decorated constructor is called. |
| Optional dependency? | Type the parameter `SomePort |
| Field injection? | No. |
| Setter injection? | No. |
| External YAML config? | No. |
| Implicit protocol matching? | No. |
| Auto-discovery? | Yes, but only for decorated ports/adapters inside packages or modules named port, ports, adapter, or adapters. |
| Multiple adapters? | envs separates them per environment; key makes one opt-in. |
| Environments? | IDUNN_ENV, plus decorator-local envs={...}. |
| Tooling? | Poetry, pytest, Ruff, and Mypy are configured in pyproject.toml. |
Install locally ๐ฆ
poetry install --with dev
Basic usage ๐ช
The headline workflow is decorator-only: mark ports and adapters, mark consumer constructors
with @Invert, discover once, then construct. Normal code never touches the container.
from typing import Protocol
from idunn import Adapter, Idunn, Invert, LifecycleEnum, Port
@Port
class AppleBasketPort(Protocol):
def take_apple(self) -> str: ...
@Adapter(AppleBasketPort, lifecycle=LifecycleEnum.SINGLETON)
class GoldenAppleBasketAdapter(AppleBasketPort):
def take_apple(self) -> str:
return "๐ youth restored"
class Feast:
basket: AppleBasketPort # declared for the type checker; @Invert assigns it
@Invert
def __init__(self, basket: AppleBasketPort, other: str) -> None:
self.other = other # self.basket is injected and assigned for you
def serve(self) -> str:
return f"{self.other}: {self.basket.take_apple()}"
Idunn().autodiscover("my_app") # one-time bootstrap (ports/adapters live in my_app)
feast = Feast(other="funky") # basket is resolved & injected automatically
print(feast.serve()) # funky: ๐ youth restored
@Invert inspects the constructor's type hints; every parameter annotated with a @Port is resolved
from the Idunn() singleton at construction time and assigned to self.<name>. A caller-supplied
argument always wins (Feast(basket=my_basket, other="x")), so the class stays trivially testable โ
handy when you'd rather hand it a paper bag of test apples than the real basket. Power users can
target a keyed adapter with @Invert(keys={"basket": "golden"}), or inject an unannotated parameter
with an explicit map: @Invert({"basket": AppleBasketPort}).
Recommended application layout ๐ณ
Idunn can discover decorated ports and adapters automatically, but if you are using IoC, you are interested in structure.
my_app/
__init__.py
domain/
ports/
infrastructure/
adapters/
__init__.py
apples.py
payments.py
billing/
ports.py
adapters/
__init__.py
stripe.py
Then at startup:
from idunn import Idunn
Idunn().autodiscover("my_app")
app = MyApp() # the @Invert-decorated entry object pulls in everything it needs
Idunn().autodiscover("my_app") imports modules whose dotted names contain one of these exact
parts:
port
ports
adapter
adapters
Ports are imported and registered first; adapters second. It does not import arbitrary modules just because they live inside your app, and it does not register undecorated classes. Discovery is a metal detector tuned to one shape of badge, not a vacuum cleaner.
AutoDiscovery rule ๐
Good:
@Port
class AppleBasketPort(Protocol):
def take_apple(self) -> str: ...
@Adapter(AppleBasketPort)
class GoldenAppleBasketAdapter(AppleBasketPort):
...
These classes can be found by discovery because they wear the apple badge.
Not registered:
class GoldenAppleBasketAdapter(AppleBasketPort):
...
Even if the class structurally satisfies the port, Idunn ignores it unless it is marked with
@Adapter(...). Looking the part is not the same as wearing the badge.
Port implementation rule ๐
Adapters must satisfy their ports. Idunn does not synthesize, monkey-patch, or mutate adapter classes.
Recommended style:
@Adapter(AppleBasketPort)
class GoldenAppleBasketAdapter(AppleBasketPort):
...
Also valid in Python protocol terms:
@Adapter(AppleBasketPort)
class GoldenAppleBasketAdapter:
def take_apple(self) -> str:
return "golden apple"
The second form relies on structural typing. The first form is clearer, so examples use explicit inheritance.
Mapping adapters to ports ๐
A port is an empty basket; an adapter is the apples you put in it. How you pick between adapters depends on how many you have. Start simple, and reach for keys only when you genuinely need them.
The one rule: resolving a port without a key only ever sees adapters registered without a key. Keyed adapters are opt-in โ you address them by name, or they sit quietly in the cellar.
With no parameters
Most ports have exactly one implementation, and the calling code does not care which. Register it plain and Idunn just hands it over โ the implementation stays hidden behind the port, which is the whole point of a port.
@Adapter(AppleBasketPort)
class GoldenAppleBasketAdapter(AppleBasketPort):
def take_apple(self) -> str:
return "youth restored"
class Feast:
basket: AppleBasketPort # assigned by @Invert
@Invert
def __init__(self, basket: AppleBasketPort) -> None:
pass
Idunn().autodiscover("my_app")
Feast().basket.take_apple()
One basket, one adapter, zero decisions. This is the case you want most of the time.
Via the environment
When the same role needs different apples in dev, test, and production โ a real gateway in
prod, a fake one in tests โ put the environment right in the decorator. No config files, no YAML,
no 200-line settings.py. The adapters are never active at once, so an unkeyed resolve always has
exactly one answer and the consumer never changes between environments.
@Adapter(PaymentPort, envs={"prod"})
class StripePaymentAdapter(PaymentPort): ...
@Adapter(PaymentPort, envs={"test", "ci"})
class FakePaymentAdapter(PaymentPort): ...
Set the active environment with IDUNN_ENV:
IDUNN_ENV=test
If IDUNN_ENV is unset, Idunn defaults to local. Environment names are normalized to lowercase,
and underscores become hyphens (so My_Env and my-env are the same place). In tests it's often
easier to rebind the singleton directly with Idunn().reset(environment="prod") โ see
docs/ADVANCED.md.
Environment matching rules
| Decorator value | Behavior |
|---|---|
envs=None |
Adapter is active in every environment (the apple for all seasons). |
envs={"test"} |
Adapter is active only when the active environment is test. |
envs={"test", "ci"} |
Adapter is active in either test or ci. |
Via keys
When several implementations are all valid in the same environment and the environment can't
tell them apart, give each one a key. The cleanest way to pick one is at the point of use โ the
consumer's constructor โ with @Invert(keys={...}). The choice lives right next to the code that
depends on it.
@Adapter(NotifierPort, key="email")
class EmailNotifier(NotifierPort): ...
@Adapter(NotifierPort, key="sms")
class SmsNotifier(NotifierPort): ...
class Reminder:
notifier: NotifierPort # assigned by @Invert
@Invert(keys={"notifier": "sms"})
def __init__(self, notifier: NotifierPort) -> None:
pass
And the one rule again, because it earns repeating: a keyed adapter never answers an unkeyed resolve.
If email and sms are your only adapters for NotifierPort, then a plain @Invert parameter
typed NotifierPort raises AdapterNotFoundError โ there's no unkeyed apple in the basket. Pick one
with keys={...}, or register an unkeyed adapter.
Same key, different environments
A key only has to be unique within an environment, so the same key can name different adapters in environments that never overlap:
@Adapter(PaymentPort, key="primary", envs={"prod"})
class StripePaymentAdapter(PaymentPort): ...
@Adapter(PaymentPort, key="primary", envs={"test"})
class FakePaymentAdapter(PaymentPort): ...
This, however, is a collision โ both are active in prod:
@Adapter(PaymentPort, key="primary", envs={"prod"})
class StripePaymentAdapter(PaymentPort): ...
@Adapter(PaymentPort, key="primary", envs={"prod"})
class BraintreePaymentAdapter(PaymentPort): ...
Idunn raises InvalidAdapterError when two adapters share a port and key (unkeyed counts as the
same "no key") and are both active in overlapping environments. Two apples, one label, same
shelf โ somebody's about to grab the wrong one, so Idunn refuses to guess.
Lifecycles ๐
| LifecycleEnum | Behavior |
|---|---|
LifecycleEnum.TRANSIENT |
A new instance is built every time the port is resolved. |
LifecycleEnum.SINGLETON |
One instance is created and reused. |
Known limitations ๐ง
Idunn is deliberately a single process-wide container, which buys simplicity at a few prices worth
knowing:
- One container per process. There is no second, independent container; everything shares the
same
Idunn(). (Multi-container setups are out of scope โ one barrel of apples per kitchen.) - Not thread-safe. Wire everything up at startup on one thread, then resolve. Registration and resolution mutate shared state without locking, and Idunn does not appreciate two cooks resolving in her kitchen at once.
- Test isolation is your job. Reset between tests with
Idunn().reset()(e.g. an autouse fixture). Seedocs/ADVANCED.md.
Development workflow ๐งช
The project uses Poetry with pytest, Ruff, and Mypy configured in pyproject.toml.
poetry install --with dev
poetry run pytest
poetry run ruff format --check .
poetry run ruff check .
poetry run mypy
A GitHub Actions workflow is included at:
.github/workflows/ci.yml
The CI quality gate runs the same checks across Python 3.11, 3.12, 3.13, and 3.14.
What Idunn intentionally does not do ๐ซ
Half of Idunn's design is the features it cheerfully refuses to grow:
- No external YAML configuration
- No package-wide global scanning
- No subclass scanning
- No โclass name ends with
Adapter, so letโs register itโ guesswork - No implicit protocol matching for registration
- No construction during decoration
- No construction during autodiscovery
- No field injection
- No setter injection
- No loose global resolver functions
If an adapter wants in, it wears the apple badge explicitly โ no badge, no basket:
@Adapter(SomePort)
class SomeAdapter(SomePort):
...
That short "no" list is the feature. (See the Einstein quote up top: as simple as possible, and not one apple simpler.)
Going further ๐
The everyday API is the three decorators plus autodiscover. Everything else โ how resolution
actually fires, inspecting the wired graph with describe(), manual registration, rebinding the
environment for tests, and the deliberate non-features (no lifecycle on @Invert, no priority,
no value injection) โ lives in docs/ADVANCED.md. A class-by-class catalog is
in docs/classes.md.
Exceptions
All inherit from IdunnError, and all import from idunn:
| Class | Raised whenโฆ |
|---|---|
InvalidPortError |
@Port is applied to a non-Protocol class. |
InvalidAdapterError |
An adapter registration is invalid (bad target, duplicate key in overlapping environments, unsatisfied port). |
AdapterNotFoundError |
No active adapter is registered for a requested port. |
DiscoveryError |
Autodiscovery fails to import a bounded module. |
MissingTypeHintError |
Constructor injection needs a type hint that is missing (or a non-port param with no default). |
InjectionCycleError |
Constructor dependency resolution loops back on itself. |
Code style constraints ๐
The implementation is intentionally class-heavy:
- decorators are functions because Python decorators are naturally functions;
- support behavior is encapsulated in classes;
- package code avoids loose utility functions;
- package methods/functions use a single return point.
Version target ๐
python = ">=3.11,<4.0"
Before publishing to PyPI ๐
Before the first public release, update these project-specific values:
authorsinpyproject.toml- package homepage / repository URLs, once the repo exists
- the copyright holder in
LICENSE, if needed - package classifiers if the tested Python matrix changes
Then run:
poetry build
poetry publish
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file idunn-0.0.1.tar.gz.
File metadata
- Download URL: idunn-0.0.1.tar.gz
- Upload date:
- Size: 27.6 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: poetry/2.4.1 CPython/3.14.4 Darwin/25.5.0
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
5ada32384e941354a55daad1f2c27857d76004ff1d5be403a9aada8346e8abc0
|
|
| MD5 |
beb3fb0bd53cf46177132757d91eb859
|
|
| BLAKE2b-256 |
169d5071d1d379f1fbdd88e0d7674462b7e7d05981c4c1bf3f81560c442235f5
|
File details
Details for the file idunn-0.0.1-py3-none-any.whl.
File metadata
- Download URL: idunn-0.0.1-py3-none-any.whl
- Upload date:
- Size: 28.9 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: poetry/2.4.1 CPython/3.14.4 Darwin/25.5.0
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
09e000c320791577bafe9e7c2bac6f29ab6e11acb0b04964c69ecfc4889b44f9
|
|
| MD5 |
ef2f04226017fb88d9e8c92eca24b85c
|
|
| BLAKE2b-256 |
b0a85cacd89fd6f3ac661209e287b147c235904f59c07093b135412e826ebee1
|