Skip to main content

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 lifetimestransient, 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

  1. Install
  2. Quick start
  3. Defining injectables
  4. Registration modules and building
  5. Builder vs container
  6. Lifetimes
  7. Registration intents
  8. Factories and the resolver seam
  9. Async factories
  10. Collections and resolve_all
  11. Decorators
  12. Scopes and disposal
  13. Testing with overrides
  14. Validation and exceptions
  15. 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's register receives.
  • IContainer — the runtime surface (resolve, resolve_all, create_scope, dispose, dispose_async, with_overrides). It is what Container.build returns.

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. Use exclusions to 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. Because build() 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


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

pyinjection-2026.6.4.0.tar.gz (36.5 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

pyinjection-2026.6.4.0-py3-none-any.whl (44.5 kB view details)

Uploaded Python 3

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

Hashes for pyinjection-2026.6.4.0.tar.gz
Algorithm Hash digest
SHA256 d294fbd5d5f1b1b9794f3405dd5b005ec177b2feb5427452ec4823ef5565eb95
MD5 30b150fb1da8dd187ad043378b4d233a
BLAKE2b-256 fc40f7ce2b9aea7f3bcebd3f9f64036e981978cd0fc115aca605946d8adcfc0b

See more details on using hashes here.

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

Hashes for pyinjection-2026.6.4.0-py3-none-any.whl
Algorithm Hash digest
SHA256 0ef53e542a17019ae21199a63612e92103f88b4bc1de85f2a29869077627480c
MD5 302f04c82b8e0a5214d4af81d04f268c
BLAKE2b-256 85020572c75e3cc9cb85e173461ad1aab3104fbb6b2eca6754ff85c0cbbdfadc

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page