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 Docs

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


@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, Scope


@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, Scope, 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, Scope


@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, Scope


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=Scope.REQUEST)

with container.enter_scope(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, Scope


@dataclass
class Session:
    active: bool = True


container = Container(autoregister=True)
container.register(Session, lifetime=Lifetime.SCOPED, scope=Scope.REQUEST)

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

# Resolving inside the correct scope works
with container.enter_scope(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, Scope


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=Scope.REQUEST,
    )

    async with container.enter_scope(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[T] to inject dependencies while keeping other parameters caller-provided.

from dataclasses import dataclass

from diwire import Container, Injected, Scope


@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: Injected[EmailService],
) -> 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, Scope


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


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 diwire import Container, Injected, Scope, container_context


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


@container_context.resolve()
def greet(service: Injected[Service]) -> 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, Scope


@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
  • namedtupletyping.NamedTuple
  • 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
Scope APP, SESSION, REQUEST
Injected Parameter marker — Injected[T]
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

Documentation: https://docs.diwire.dev

Examples: https://docs.diwire.dev/howto/examples/ (runnable scripts and real-world scenarios: patterns, async, FastAPI, 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.1.0.tar.gz (288.7 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.1.0-py3-none-any.whl (58.8 kB view details)

Uploaded Python 3

File details

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

File metadata

  • Download URL: diwire-0.1.0.tar.gz
  • Upload date:
  • Size: 288.7 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.1.0.tar.gz
Algorithm Hash digest
SHA256 898a44718d70ef47339a9f525e2813db3d7b6a471f8d4caf1f4d598563bc8663
MD5 006b97183881845409286368cf5675ca
BLAKE2b-256 d5186b1478413a16dd46498072984508b6fc4fbd2c0301494292470e7f938d6a

See more details on using hashes here.

File details

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

File metadata

  • Download URL: diwire-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 58.8 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.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 6b9de8dd8cd8305c6672135b0f18cb17730f759e36b2711e7129a9da56fb797e
MD5 7790249277dbad8b3f2afcccff5ce05b
BLAKE2b-256 fec47a05eadf32fed7bb2b33be245e43b72e4da7ee0ef195d85d6b3f1d010221

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