Skip to main content

A lightweight, type-safe dependency injection container with automatic wiring, scoped lifetimes, and zero dependencies

Project description

diwire - dependency injection for Python

Type-safe dependency injection with automatic wiring, scoped lifetimes, and async-safe factories.

PyPI version Python versions License: MIT codecov

diwire is a lightweight DI container for Python 3.10+ that resolves dependency graphs from type hints, supports scoped lifetimes, and cleans up resources via generator factories. It is async-first, thread-safe, and has zero runtime dependencies.

Why diwire

  • Automatic wiring from type hints (constructor and function injection)
  • Scoped lifetimes for request/session workflows
  • Generator factories with cleanup on scope exit
  • Async support with aresolve() and async factories
  • Interface + component registration for multiple implementations
  • Zero dependencies and minimal overhead

Installation

uv add diwire
pip install diwire

Quick start

from dataclasses import dataclass

from diwire import Container, Lifetime


@dataclass
class Database:
    host: str = "localhost"


@dataclass
class UserRepository:
    db: Database


@dataclass
class UserService:
    repo: UserRepository


container = Container(autoregister_default_lifetime=Lifetime.TRANSIENT)
service = container.resolve(UserService)

print(service.repo.db.host)

Registering services

You can register classes, factories, or instances. concrete_class lets you register by interface or abstract base class.

from dataclasses import dataclass
from datetime import datetime
from typing import Protocol

from diwire import Container, Lifetime


class Clock(Protocol):
    def now(self) -> str: ...


@dataclass
class SystemClock:
    def now(self) -> str:
        return datetime.now().isoformat(timespec="seconds")


container = Container()
container.register(Clock, concrete_class=SystemClock, lifetime=Lifetime.SINGLETON)
clock = container.resolve(Clock)

Open generics

Register open generic factories and resolve closed generics with type-safe validation. Type arguments can be injected by annotating parameters as type[T], and TypeVar bounds/constraints are enforced at resolution time.

from dataclasses import dataclass
from typing import Generic, TypeVar

from diwire import Container


class Model:
    pass


T = TypeVar("T")
M = TypeVar("M", bound=Model)


@dataclass
class AnyBox(Generic[T]):
    value: str


@dataclass
class ModelBox(Generic[M]):
    model: M


container = Container()


@container.register(AnyBox[T])
def create_any_box(type_arg: type[T]) -> AnyBox[T]:
    return AnyBox(value=type_arg.__name__)


@container.register(ModelBox[M])
def create_model_box(model_cls: type[M]) -> ModelBox[M]:
    return ModelBox(model=model_cls())


print(container.resolve(AnyBox[int]))
print(container.resolve(ModelBox[Model]))

Function injection

Mark parameters with Injected() to inject dependencies while keeping other parameters caller-provided.

from dataclasses import dataclass
from typing import Annotated

from diwire import Container, Injected


@dataclass
class EmailService:
    smtp_host: str = "smtp.example.com"

    def send(self, to: str, subject: str) -> str:
        return f"Sent '{subject}' to {to} via {self.smtp_host}"


def send_email(
    to: str,
    *,
    mailer: Annotated[EmailService, Injected()],
) -> str:
    return mailer.send(to, "Hello!")


container = Container()
send = container.resolve(send_email)
print(send(to="user@example.com"))

Scopes and cleanup

Use scopes to manage request/session lifetimes. Generator factories clean up automatically when the scope exits.

Important: Cleanup code in generator factories must be wrapped in try/finally blocks. When a scope exits, the container calls close() (sync) or aclose() (async) on generators, which raises GeneratorExit at the yield point. Without try/finally, code after yield will not execute. This is standard Python generator behavior, used by FastAPI's Depends, pytest fixtures, and other frameworks.

from collections.abc import Generator

from diwire import Container, Lifetime


class Session:
    def __init__(self) -> None:
        self.closed = False

    def close(self) -> None:
        self.closed = True


def session_factory() -> Generator[Session, None, None]:
    session = Session()
    try:
        yield session
    finally:
        # Cleanup code MUST be in finally block to run on scope exit
        session.close()


container = Container()
container.register(Session, factory=session_factory, lifetime=Lifetime.SCOPED, scope="request")

with container.enter_scope("request") as scope:
    session = scope.resolve(Session)
    assert session.closed is False

# Or close scopes by name (closes child scopes automatically)
container.enter_scope("app")
container.enter_scope("request")
container.close_scope("app")  # Closes both "request" and "app" in LIFO order

Named components

Use Component and ServiceKey to register multiple implementations of the same interface.

from dataclasses import dataclass
from typing import Annotated, Protocol

from diwire import Container
from diwire.service_key import Component


class Cache(Protocol):
    def get(self, key: str) -> str: ...


@dataclass
class RedisCache:
    def get(self, key: str) -> str:
        return f"redis:{key}"


@dataclass
class MemoryCache:
    def get(self, key: str) -> str:
        return f"memory:{key}"


container = Container()
container.register(Annotated[Cache, Component("primary")], instance=RedisCache())
container.register(Annotated[Cache, Component("fallback")], instance=MemoryCache())

primary: Cache = container.resolve(Annotated[Cache, Component("primary")])
fallback: Cache = container.resolve(Annotated[Cache, Component("fallback")])

Async support

Use aresolve() with async factories and async generator cleanup.

import asyncio
from collections.abc import AsyncGenerator

from diwire import Container, Lifetime


class AsyncClient:
    async def close(self) -> None: ...


async def client_factory() -> AsyncGenerator[AsyncClient, None]:
    client = AsyncClient()
    try:
        yield client
    finally:
        # Cleanup code MUST be in finally block to run on scope exit
        await client.close()


async def main() -> None:
    container = Container()
    container.register(
        AsyncClient,
        factory=client_factory,
        lifetime=Lifetime.SCOPED,
        scope="request",
    )

    async with container.enter_scope("request") as scope:
        await scope.aresolve(AsyncClient)


asyncio.run(main())

Global container context

For larger apps, container_context provides a context-local global container.

from dataclasses import dataclass
from typing import Annotated

from diwire import Container, Injected, container_context


@container_context.register()
@dataclass
class Service:
    name: str = "diwire"


@container_context.resolve()
def greet(service: Annotated[Service, Injected()]) -> str:
    return f"hello {service.name}"


container = Container()
container_context.set_current(container)


print(greet())

API at a glance

  • Container: register, resolve, aresolve, enter_scope, close_scope, aclose_scope, compile
  • Lifetime: TRANSIENT, SINGLETON, SCOPED
  • Injected: Annotated[T, Injected()] parameter marker
  • container_context: context-local global container
  • Component and ServiceKey: named registrations

Performance

Container.compile() precompiles providers to reduce reflection and dict lookups. By default, the container auto-compiles on first resolve (set auto_compile=False to disable) and auto-registers constructor-injected types using autoregister_default_lifetime.

Examples

See examples/README.md for a guided tour of patterns, async usage, FastAPI-style integration, and error handling.

License

MIT

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

diwire-0.0.5.tar.gz (215.4 kB view details)

Uploaded Source

Built Distribution

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

diwire-0.0.5-py3-none-any.whl (41.4 kB view details)

Uploaded Python 3

File details

Details for the file diwire-0.0.5.tar.gz.

File metadata

  • Download URL: diwire-0.0.5.tar.gz
  • Upload date:
  • Size: 215.4 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.9.27 {"installer":{"name":"uv","version":"0.9.27","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for diwire-0.0.5.tar.gz
Algorithm Hash digest
SHA256 6b2b59ae2f60c6ccf24d99c31df65d875ee8ad73a79b4875d3311d1205221e0b
MD5 e4febddfbe42ad4260c2226a7d80859f
BLAKE2b-256 14e9b2da6a57e872c72094c21ec918166e04096ece5bd988e37c510a22b9e1d6

See more details on using hashes here.

File details

Details for the file diwire-0.0.5-py3-none-any.whl.

File metadata

  • Download URL: diwire-0.0.5-py3-none-any.whl
  • Upload date:
  • Size: 41.4 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.9.27 {"installer":{"name":"uv","version":"0.9.27","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for diwire-0.0.5-py3-none-any.whl
Algorithm Hash digest
SHA256 be941d37c2477ea2f0664c6925a2f0a98b12140a78c18f6c50694505e6e25aaa
MD5 bd9fcc30f26c072678b93948a0eb14c9
BLAKE2b-256 42b575d890f272892f06d4910809a3b17e2e584dd7201220f5446d580f5dc7ba

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