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


@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

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.0.8.tar.gz (280.1 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.8-py3-none-any.whl (53.7 kB view details)

Uploaded Python 3

File details

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

File metadata

  • Download URL: diwire-0.0.8.tar.gz
  • Upload date:
  • Size: 280.1 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.8.tar.gz
Algorithm Hash digest
SHA256 3fab99659e6eece00b7e7c63519954b05e3d2e601fd1415426d3256943de96aa
MD5 c37068fc739bdd3463d3aeb70aa3970d
BLAKE2b-256 b45eb412938488310fa01cea698f62ddbd9246f858fc5c952249ad5a8229fddc

See more details on using hashes here.

File details

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

File metadata

  • Download URL: diwire-0.0.8-py3-none-any.whl
  • Upload date:
  • Size: 53.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.8-py3-none-any.whl
Algorithm Hash digest
SHA256 ac8b92c2c5fb843a8907b0ecfc26910da4946d58d8d3b54c455ed802b616a872
MD5 e21006a350deac9fd2265e970d166798
BLAKE2b-256 0a895ff39123ab9368588584924456bb1de9c8848b2d9c15a598f1f45310cc45

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