Dependency injection container for Python3
Project description
pyInjection
A .NET-style dependency-injection container for Python 3, driven by type annotations.
- Configure with modules, build once, resolve. Registration happens on a builder;
Container.build([...])validates the whole graph and returns a container you only resolve from. - Per-node lifetimes —
transient,scoped,singleton— honoured by each registration, not by the resolution entry point. - Constructor injection with no decorators. Domain classes depend only on their collaborators'
ABCs; the container wires them by reading__init__annotations. - Resolver-aware factories (sync and async), first-class decorators, collections + composites, scopes with sync/async disposal.
- Two-layer validation — static generic typing at the call site plus an eager whole-graph check at
build()— surfaced through a typed exception hierarchy. - Test doubles via container overrides, no mocking library required.
Versioning. pyInjection uses CalVer (
YYYY.M.D.MICRO); install the latest from PyPI. This generation is a breaking redesign of the 2.x API — see Migrating from 2.x.
Table of contents
- Install
- Quick start
- Defining injectables
- Registration modules and building
- Builder vs container
- Lifetimes
- Registration intents
- Factories and the resolver seam
- Async factories
- Collections and
resolve_all - Decorators
- Scopes and disposal
- Testing with overrides
- Validation and exceptions
- Migrating from 2.x
Install
uv add pyInjection # or: pip install pyInjection
Quick start
from abc import ABC, abstractmethod
from pyInjection import Container, IContainer, IContainerBuilder, IRegistrationModule
class IGreeter(ABC):
@abstractmethod
def greet(self) -> str: ...
class EnglishGreeter(IGreeter):
def greet(self) -> str:
return "Hello"
class GreetingModule(IRegistrationModule):
def register(self, container: IContainerBuilder) -> None:
container.add_singleton(interface=IGreeter, implementation=EnglishGreeter)
container: IContainer = Container.build([GreetingModule()])
print(container.resolve(interface=IGreeter).greet()) # -> "Hello"
Container.build applies each module, validates the whole graph, and returns an IContainer. There is no global state — every build yields an isolated container.
Defining injectables
Every wireable type is an ABC (treated as an interface). Concrete classes depend on those interfaces, and the container injects them by reading the constructor's annotations — no decorator is required on the class:
class ICandleSource(ABC):
@abstractmethod
def latest(self) -> Candle: ...
class CandleRepository(ICandleSource):
__connection: IDatabaseConnection
def __init__(self, connection: IDatabaseConnection) -> None:
self.__connection = connection
def latest(self) -> Candle: ...
At resolution the container inspects CandleRepository.__init__, resolves IDatabaseConnection, and constructs the instance. Generic keys such as ICalculator[Candle, Swing] are supported and resolved as distinct registrations.
Registration modules and building
A module groups related registrations. The composition root passes a list of modules to Container.build:
container: IContainer = Container.build([
RepositoryModule(),
MessageQueueModule(),
ApplicationModule(),
])
build is pure (no I/O), so calling it in a unit or CI test is a fast, deterministic check that the whole graph is sound before deploy.
Builder vs container
The two phases are separate interfaces:
IContainerBuilder— the configuration surface (add_*/register_*). It is what a module'sregisterreceives.IContainer— the runtime surface (resolve,resolve_all,create_scope,dispose,dispose_async,with_overrides). It is whatContainer.buildreturns.
Because the built container exposes no registration methods, it cannot be reconfigured after building — registration mistakes are caught at the type level, not at runtime.
Lifetimes
A registration's lifetime is honoured per node, regardless of the path by which it is reached:
| Lifetime | Instance reused… | Resolvable from |
|---|---|---|
Lifetime.TRANSIENT |
never — a new instance every resolution | container or scope |
Lifetime.SCOPED |
once per scope | a scope only |
Lifetime.SINGLETON |
once per container | container or scope |
Captive-dependency rules (enforced at build()): a node may depend only on one that lives at least as long as itself — a transient may depend on anything, a scoped on scoped/singleton, and a singleton only on singletons. A violation raises ScopeViolationError.
Registration intents
Each kind of binding has exactly one method, so intent is explicit:
from pyInjection import Lifetime
container.add_transient(interface=ICandleSource, implementation=CandleRepository)
container.add_singleton(interface=IClock, implementation=SystemClock)
container.add_scoped(interface=IUnitOfWork, implementation=SqlUnitOfWork)
# A pre-built object — always singleton semantics, so no lifetime argument.
container.add_instance(interface=ICalculator[Candle, Swing], instance=SwingCalculator(candle_range=3))
# A callable — lifetime is a parameter (see Factories).
container.add_factory(interface=IImbalance, factory=lambda r: ImbalanceCalculator(0.5), lifetime=Lifetime.SINGLETON)
add_transient / add_singleton / add_scoped accept a class. Passing a pre-built object or a callable raises RegistrationError naming the method to use instead (add_instance or add_factory).
Factories and the resolver seam
When construction needs explicit wiring, register a factory. It receives a narrow IResolver seam (only resolve / resolve_all) — never the full container — so it can wire dependencies without becoming a service locator:
from pyInjection import IResolver
container.add_factory(
interface=ISwingCalculator,
factory=lambda resolver: SwingCandleAverageCalculator(
candle_range=3,
inner=resolver.resolve(interface=ICalculator[Candle, Swing]),
),
lifetime=Lifetime.SINGLETON,
)
The factory's result is cached according to lifetime, honoured per node like any other registration.
Async factories
Resources that must be awaited to become ready (a connected pool, say) use add_async_factory and are materialised once, eagerly, by Container.build_async. Resolution stays synchronous — resolve never awaits:
from pyInjection import IResolver
async def build_pool(resolver: IResolver) -> IConnectionPool:
pool = ConnectionPool(dsn=...)
await pool.connect()
return pool
class InfraModule(IRegistrationModule):
def register(self, container: IContainerBuilder) -> None:
container.add_async_factory(interface=IConnectionPool, factory=build_pool)
container: IContainer = await Container.build_async([InfraModule()])
pool: IConnectionPool = container.resolve(interface=IConnectionPool) # already connected
An async-factory result is served as a singleton. Resolving one synchronously (via build rather than build_async) raises ResolutionError.
Collections and resolve_all
Register many implementations under one base interface and retrieve them together. Members keep their own lifetimes and resolve in registration order:
class HandlerModule(IRegistrationModule):
def register(self, container: IContainerBuilder) -> None:
container.add_to_collection(interface=IHandler, implementation=LoggingHandler)
container.add_to_collection(interface=IHandler, implementation=MetricsHandler, lifetime=Lifetime.SINGLETON)
handlers: list[IHandler] = container.resolve_all(interface=IHandler)
resolve_all is independent of single-binding resolve: an interface with no collection yields an empty list.
Auto-discovered collections
register_collection discovers every loaded subclass of the interface and registers them as the collection — no per-member call. Abstract classes, exclusions, and the composite are skipped; members not already registered take member_lifetime:
class HandlerModule(IRegistrationModule):
def register(self, container: IContainerBuilder) -> None:
container.register_collection(
interface=IHandler,
exclusions=[InternalHandler],
composite=HandlerPipeline, # optional single binding over the members
composite_lifetime=Lifetime.SINGLETON,
member_lifetime=Lifetime.TRANSIENT,
)
If a composite is given, it is registered as the single binding for the interface, with the discovered list[interface] injected into its matching constructor parameter (other parameters resolved normally); the composite is excluded from its own list.
Caveat. Discovery is by
__subclasses__, so only subclasses whose module has been imported are visible, and every loaded subclass is included — including test doubles and decorators. Useexclusionsto trim, and note a discovered member with unmet constructor dependencies fails validation. Registering both a single binding and a collection for the same interface fails validation.
Decorators
register_decorator wraps a registration in a class that implements the same interface and takes that interface as a constructor parameter (the wrapped inner instance). Last registered = outermost, and the chain inherits the base registration's lifetime:
class LoggingModule(IRegistrationModule):
def register(self, container: IContainerBuilder) -> None:
container.add_transient(interface=IHandler, implementation=CoreHandler)
container.register_decorator(interface=IHandler, decorator=LoggingHandler)
container.register_decorator(interface=IHandler, decorator=RetryHandler)
# resolve(IHandler) -> RetryHandler(LoggingHandler(CoreHandler))
A decorator's other (non-wrapped) dependencies are injected normally. Decorators that take non-injected literal arguments aren't expressible here — register those as a factory instead.
Scopes and disposal
A scope bounds scoped instances and their teardown. It is a context manager — use with for synchronous disposal and async with for awaited disposal:
from pyInjection import IDisposable, IAsyncDisposable
class SqlUnitOfWork(IUnitOfWork, IAsyncDisposable):
async def dispose_async(self) -> None:
await self._connection.close()
with container.create_scope() as scope:
work = scope.resolve(interface=IUnitOfWork) # scoped: shared within this scope
...
# sync IDisposable.dispose() of scoped instances fires here
async with container.create_scope() as scope:
work = scope.resolve(interface=IUnitOfWork)
...
# IAsyncDisposable.dispose_async() is awaited (and IDisposable.dispose() called) here
Within a scope, singletons resolve from the container's shared cache and transients are never cached. Scoped instances are distinct across scopes.
Container teardown. Singletons that hold resources are disposed when you tear the container down — call it once at application shutdown (nothing calls it for you):
try:
await application.run()
finally:
await container.dispose_async() # or container.dispose() for sync resources
Transients are caller-owned: the container does not track or dispose them. If something needs deterministic teardown, register it scoped or singleton so the container/scope owns it — or have the object implement its own with/async with and use it at the call site.
Testing with overrides
Build the real container, swap selected bindings for hand-crafted ABC doubles, and discard it — no global state, no mocking library:
class FixedClock(IClock):
def now(self) -> datetime:
return datetime(2026, 1, 1)
container: IContainer = Container.build([ApplicationModule()])
under_test: IContainer = container.with_overrides([(IClock, FixedClock())])
with_overrides returns a new, isolated, re-validated container; the original is untouched. A pre-built double is served as-is; a double passed as a class is constructor-injected and inherits the replaced binding's lifetime. An override that leaves the graph unsatisfiable fails here.
Validation and exceptions
Validation is two complementary layers:
- Layer 1 — static (IDE / mypy). The generic signatures (
type[T],T,Callable[[IResolver], T]) make registering or resolving the wrong type an inline error at the call site, before anything runs. - Layer 2 — eager, at
build(). The whole graph is walked once: completeness (every dependency registered), no cycles, no duplicates, and the captive-dependency/scope rules. Becausebuild()is pure, running it in a test is a fast graph-validity check.
All failures derive from PyInjectionError, so they are catchable individually or collectively:
PyInjectionError
├── RegistrationError
│ └── DuplicateRegistrationError
├── ResolutionError
│ ├── MissingRegistrationError
│ └── CircularDependencyError
└── ScopeViolationError
from pyInjection import PyInjectionError
try:
container = Container.build([ApplicationModule()])
except PyInjectionError as error:
print(f"container is misconfigured: {error}")
Migrating from 2.x
This generation is a deliberate breaking redesign. The main changes:
| 2.x | Now |
|---|---|
Container.*(..., container_identifier="...") global facade |
container = Container.build([...modules...]) — a real object, no global registry or string ids |
@Container.inject on every constructor |
nothing — constructor injection works by signature inspection |
overloaded implementation= for class / instance / lambda |
one method each: add_transient/add_singleton/add_scoped (class), add_instance (object), add_factory (callable) |
add_transient(I, Impl()) (instance labelled transient) |
add_instance(I, Impl()) — honest singleton semantics |
lambda: Container.resolve(IDep, id=...) reaching into a global |
add_factory(I, lambda resolver: ...resolver.resolve(IDep)) — a narrow seam |
hand-built list[...] / composite lambdas |
add_to_collection + resolve_all, or register_collection |
| nested-lambda decorator chains | register_decorator |
raise Exception(...), optional validate(), no cycle guard |
typed PyInjectionError hierarchy, eager build(), CircularDependencyError |
transient→singleton raised; singleton→transient warned |
standard captive-dependency rules; lifetime honoured per node |
registration and resolution on one Container |
IContainerBuilder (configure) vs IContainer (resolve) |
Adopt it by moving registrations into IRegistrationModules, replacing decorators and global reach-backs as above, and calling Container.build([...]) in the composition root.
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 pyinjection-2026.6.4.0.tar.gz.
File metadata
- Download URL: pyinjection-2026.6.4.0.tar.gz
- Upload date:
- Size: 36.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.9.30 {"installer":{"name":"uv","version":"0.9.30","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Debian GNU/Linux","version":"12","id":"bookworm","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
d294fbd5d5f1b1b9794f3405dd5b005ec177b2feb5427452ec4823ef5565eb95
|
|
| MD5 |
30b150fb1da8dd187ad043378b4d233a
|
|
| BLAKE2b-256 |
fc40f7ce2b9aea7f3bcebd3f9f64036e981978cd0fc115aca605946d8adcfc0b
|
File details
Details for the file pyinjection-2026.6.4.0-py3-none-any.whl.
File metadata
- Download URL: pyinjection-2026.6.4.0-py3-none-any.whl
- Upload date:
- Size: 44.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.9.30 {"installer":{"name":"uv","version":"0.9.30","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Debian GNU/Linux","version":"12","id":"bookworm","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
0ef53e542a17019ae21199a63612e92103f88b4bc1de85f2a29869077627480c
|
|
| MD5 |
302f04c82b8e0a5214d4af81d04f268c
|
|
| BLAKE2b-256 |
85020572c75e3cc9cb85e173461ad1aab3104fbb6b2eca6754ff85c0cbbdfadc
|