Skip to main content

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

Project description

diwire

Type-driven dependency injection for Python. Zero dependencies. Zero boilerplate.

PyPI version Python versions License: MIT codecov

diwire is a dependency injection container for Python 3.10+ that builds your object graph from type hints alone. It supports scoped lifetimes, async-first resolution, generator-based cleanup, open generics, and free-threaded Python (no-GIL) — all with zero runtime dependencies.

Quick Start

Define your classes. Resolve the top-level one. diwire figures out the rest.

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)  # => localhost

No registration calls. No configuration. diwire reads the type hints on UserService, sees it needs a UserRepository, which needs a Database, and builds the entire chain automatically.

Installation

uv add diwire
pip install diwire

Features

Auto-Wiring

Dependencies are resolved from type hints — no manual wiring required.

from dataclasses import dataclass
from diwire import Container


@dataclass
class Logger:
    level: str = "INFO"


@dataclass
class AuthService:
    logger: Logger


@dataclass
class App:
    auth: AuthService
    logger: Logger


container = Container()
app = container.resolve(App)

print(app.auth.logger.level)  # => INFO
print(app.logger.level)  # => INFO

Decorator Registration

Use @container.register as a decorator on classes, factory functions, and static methods — with or without parameters.

from dataclasses import dataclass
from typing import Annotated, Protocol

from diwire import Container, Lifetime, Component


class IDatabase(Protocol):
    def query(self, sql: str) -> str: ...


container = Container()


# Bare decorator — registers the class with default lifetime
@container.register
class Config:
    debug: bool = True


# With lifetime parameter
@container.register(lifetime=Lifetime.SINGLETON)
class Logger:
    def log(self, msg: str) -> None:
        print(f"[LOG] {msg}")


# Interface binding via decorator
@container.register(IDatabase, lifetime=Lifetime.SINGLETON)
class PostgresDatabase:
    def query(self, sql: str) -> str:
        return f"result of: {sql}"


# Factory function — return type is inferred from annotation
@container.register
def create_connection_string(config: Config) -> Annotated[str, Component("connection_string")]:
    return f"postgres://localhost?debug={config.debug}"


print(container.resolve(Config).debug)  # => True
print(container.resolve(IDatabase).query("SELECT 1"))  # => result of: SELECT 1
print(container.resolve(Annotated[str, Component("connection_string")]))  # => postgres://localhost?debug=True

Lifetimes

Control how instances are created and shared.

Lifetime Behavior
TRANSIENT New instance every time
SINGLETON One shared instance for the container's lifetime
SCOPED One instance per scope (e.g. per request)
from dataclasses import dataclass
from diwire import Container, Lifetime


@dataclass
class Config:
    debug: bool = True


container = Container()
container.register(Config, lifetime=Lifetime.SINGLETON)

a = container.resolve(Config)
b = container.resolve(Config)
print(a is b)  # => True

container.register(Config, lifetime=Lifetime.TRANSIENT)
c = container.resolve(Config)
print(a is c)  # => False

Scopes & Cleanup

Scopes manage per-request lifetimes. Generator factories clean up automatically when the scope exits.

from collections.abc import Generator
from diwire import Container, Lifetime


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

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


def session_factory() -> Generator[DBSession, None, None]:
    session = DBSession()
    try:
        yield session
    finally:
        session.close()  # runs automatically on scope exit


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

with container.enter_scope("request") as scope:
    session = scope.resolve(DBSession)
    print(session.closed)  # => False

print(session.closed)  # => True

Auto-Register Safety

When auto-registration is enabled and a type already has a scoped registration, diwire raises DIWireScopeMismatchError instead of silently creating a second, unscoped instance. This prevents bugs where you expect a scoped service but accidentally resolve it outside the correct scope.

from dataclasses import dataclass

from diwire import Container, Lifetime


@dataclass
class Session:
    active: bool = True


container = Container(autoregister=True)
container.register(Session, lifetime=Lifetime.SCOPED, scope="request")

# Resolving outside any scope raises — no silent fallback
# container.resolve(Session)  # => DIWireScopeMismatchError

# Resolving inside the correct scope works
with container.enter_scope("request") as scope:
    session = scope.resolve(Session)
    print(session.active)  # => True

# Unregistered types still auto-register normally
@dataclass
class Logger:
    level: str = "INFO"

print(container.resolve(Logger).level)  # => INFO

Async Support

aresolve() works with async factories and async generators. Independent dependencies are resolved in parallel via asyncio.gather().

import asyncio
from collections.abc import AsyncGenerator

from diwire import Container, Lifetime


class AsyncClient:
    def __init__(self) -> None:
        self.connected: bool = False

    async def connect(self) -> None:
        self.connected = True

    async def close(self) -> None:
        self.connected = False


async def client_factory() -> AsyncGenerator[AsyncClient, None]:
    client = AsyncClient()
    await client.connect()
    try:
        yield client
    finally:
        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:
        client = await scope.aresolve(AsyncClient)
        print(client.connected)  # => True

    print(client.connected)  # => False


asyncio.run(main())

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=to, subject="Hello!")


container = Container()
send = container.resolve(send_email)
print(send(to="user@example.com"))  # => Sent 'Hello!' to user@example.com via smtp.example.com

Interface Binding

Register a protocol or abstract base class and resolve it to a concrete implementation.

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)
print(type(clock).__name__)  # => SystemClock

Named Components

Use Component to register multiple implementations of the same interface.

from dataclasses import dataclass
from typing import Annotated, Protocol
from diwire import Container, 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")])

print(primary.get("user:1"))  # => redis:user:1
print(fallback.get("user:1"))  # => memory:user:1

Open Generics

Register open generic factories and resolve closed generics with type-safe validation. TypeVar bounds and 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]))  # => AnyBox(value='int')
print(container.resolve(ModelBox[Model]))  # => ModelBox(model=<Model ...>)

Global Context

container_context provides a context-local global container for app-wide lazy resolution.

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())  # => hello diwire

Compilation

compile() precomputes the dependency graph into specialized providers, eliminating runtime reflection and dict lookups. The container auto-compiles on first resolve by default.

from dataclasses import dataclass
from diwire import Container, Lifetime


@dataclass
class ServiceA:
    pass


@dataclass
class ServiceB:
    a: ServiceA


container = Container()
container.register(ServiceA, lifetime=Lifetime.SINGLETON)
container.register(ServiceB, lifetime=Lifetime.TRANSIENT)

container.compile()  # pre-resolve the dependency graph

b = container.resolve(ServiceB)  # no reflection at resolve time

Set auto_compile=False on the container to control compilation timing manually.

Tested Integrations

diwire works out of the box with classes that use generated __init__ methods:

  • dataclasses — standard library
  • pydanticBaseModel and @pydantic.dataclasses.dataclass
  • attrs@attrs.define
  • msgspecmsgspec.Struct

No adapters or plugins needed — diwire extracts dependencies from type hints automatically.

API Reference

Symbol Description
Container DI container — register, resolve, aresolve, enter_scope, close_scope, aclose_scope, compile, close, aclose
Lifetime TRANSIENT, SINGLETON, SCOPED
Injected Parameter marker — Annotated[T, Injected()]
Component Named component key — Annotated[T, Component("name")]
container_context Context-local global container — set_current, register, resolve
ScopedContainer Scoped container returned by enter_scope()

Examples & Documentation

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

Contributing

Contributions are welcome. Please open an issue or pull request on GitHub.

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.7.tar.gz (226.0 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.7-py3-none-any.whl (46.7 kB view details)

Uploaded Python 3

File details

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

File metadata

  • Download URL: diwire-0.0.7.tar.gz
  • Upload date:
  • Size: 226.0 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.9.28 {"installer":{"name":"uv","version":"0.9.28","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.7.tar.gz
Algorithm Hash digest
SHA256 8c8357c067c673ec3289db2812b39c2110a0a0fef01039aa52512769bb6699fe
MD5 08cfa60863c8a435275b5e4a5402c5cc
BLAKE2b-256 f8d5a052b4f0e0762dd90394a201e8c7b832593025548dacf25b1dbd08c04c82

See more details on using hashes here.

File details

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

File metadata

  • Download URL: diwire-0.0.7-py3-none-any.whl
  • Upload date:
  • Size: 46.7 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.9.28 {"installer":{"name":"uv","version":"0.9.28","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.7-py3-none-any.whl
Algorithm Hash digest
SHA256 affdf64e419c6d62e30efd1b4bf607f054cfcd1390108c222a408fa645c705c9
MD5 7df0cd104167d3ed59f6a60b273296a9
BLAKE2b-256 05a515ba9dd660a1127a9d25f7114188891004dadf2295a04fa2c2488d1d7675

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