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.
diwire is a dependency injection container for Python 3.10+ that builds your object graph from type hints. It supports scopes + deterministic cleanup, async resolution, open generics, fast steady-state resolution via compiled resolvers, and free-threaded Python (no-GIL) — all with zero runtime dependencies.
Installation
uv add diwire
pip install diwire
FastAPI quick start (request scope + resolver_context)
FastAPI already has its own dependency system, but diwire is useful when you want:
- a single typed object graph shared across your app
- request scopes with deterministic cleanup (generator/async-generator providers)
- plain constructor injection for domain/services (not
Dependseverywhere)
This example shows:
- one app-level
Container() - a per-request
Scope.REQUEST - a small nested graph
UserService -> UserRepository -> DbSession - injecting the active
Requestinto a service (middleware-powered)
from __future__ import annotations
from collections.abc import Generator
from dataclasses import dataclass, field
from fastapi import FastAPI
from starlette.requests import Request
from diwire import Container, Injected, Lifetime, Scope, resolver_context
from diwire.integrations.fastapi import RequestContextMiddleware, add_request_context
@dataclass(slots=True)
class DbSession:
closed: bool = field(default=False, init=False)
def close(self) -> None:
self.closed = True
def provide_db_session() -> Generator[DbSession, None, None]:
session = DbSession()
try:
yield session
finally:
session.close()
class UserRepository:
def __init__(self, session: DbSession) -> None:
self._session = session
def get_name(self, user_id: int) -> str:
_ = self._session
return f"user-{user_id}"
class UserService:
def __init__(self, repo: UserRepository, request: Request) -> None:
self._repo = repo
self._request = request
def get_user(self, user_id: int) -> dict[str, int | str]:
return {
"id": user_id,
"name": self._repo.get_name(user_id),
"path": self._request.url.path,
}
app = FastAPI()
app.add_middleware(RequestContextMiddleware)
container = Container()
add_request_context(container)
container.add_generator(provide_db_session, provides=DbSession, scope=Scope.REQUEST, lifetime=Lifetime.SCOPED)
container.add(UserRepository, scope=Scope.REQUEST, lifetime=Lifetime.SCOPED)
container.add(UserService, scope=Scope.REQUEST, lifetime=Lifetime.SCOPED)
container.compile() # optional, but recommended for stable hot-path performance
@app.get("/users/{user_id}")
@resolver_context.inject(scope=Scope.REQUEST)
def get_user(user_id: int, service: Injected[UserService]) -> dict[str, int | str]:
return service.get_user(user_id)
Decorator order matters: apply @resolver_context.inject(...) below the FastAPI decorator so FastAPI sees the
injected wrapper signature (Injected[...] parameters are removed from the public signature).
Run it:
pip install diwire fastapi uvicorn
uvicorn main:app --reload
Why diwire
- Zero runtime dependencies: easy to adopt anywhere. (Why diwire)
- Scopes + deterministic cleanup: generator/async-generator providers clean up on scope exit. (Scopes)
- Async resolution:
aresolve()mirrorsresolve()and async providers are first-class. (Async) - Open generics: register once, resolve for many type parameters. (Open generics)
- Function injection:
Injected[T]for ergonomic handlers. (Function injection) - Framework/task support: works with FastAPI, aiohttp, Flask, Django, and Celery patterns. (Integrations)
- Named components + collect-all:
Component("name")andAll[T]. (Components) - Concurrency + free-threaded builds: configurable locking via
LockMode. (Concurrency)
Performance (benchmarked)
Benchmarks + methodology live in the docs: Performance.
In this benchmark suite on CPython 3.14.3 (Apple M3 Pro, strict mode):
- diwire is the top performer across this suite, reaching up to 6.89× vs
rodi, 30.79× vsdishka, and 4.40× vswireup. - Resolve-only comparisons (scope-capable libraries): diwire reaches up to 3.64× (
rodi), 4.14× (dishka), and 3.10× (wireup). - Current benchmark totals: 11 full-suite scenarios and 5 resolve-only scenarios.
For quick local regression checks, run make benchmark (diwire-only).
For full cross-library runs, use make benchmark-comparison (raw suite) or
make benchmark-report / make benchmark-report-resolve (report artifacts).
Quick start (pure Python auto-wiring)
Define your classes. Resolve the top-level one. diwire figures out the rest.
from dataclasses import dataclass, field
from diwire import Container
@dataclass
class Database:
host: str = field(default="localhost", init=False)
@dataclass
class UserRepository:
db: Database
@dataclass
class UserService:
repo: UserRepository
container = Container()
service = container.resolve(UserService)
print(service.repo.db.host) # => localhost
Registration
Use explicit registrations when you need configuration objects, interfaces/protocols, cleanup, or multiple implementations.
Strict mode (opt-in):
from diwire import Container, DependencyRegistrationPolicy, MissingPolicy
container = Container(
missing_policy=MissingPolicy.ERROR,
dependency_registration_policy=DependencyRegistrationPolicy.IGNORE,
)
Container() enables recursive auto-wiring by default. Use strict mode when you need full
control over registration and want missing dependencies to fail fast.
from typing import Protocol
from diwire import Container, Lifetime
class Clock(Protocol):
def now(self) -> str: ...
class SystemClock:
def now(self) -> str:
return "now"
container = Container()
container.add(
SystemClock,
provides=Clock,
lifetime=Lifetime.SCOPED,
)
print(container.resolve(Clock).now()) # => now
Register factories directly:
from diwire import Container
container = Container()
def build_answer() -> int:
return 42
container.add_factory(build_answer)
print(container.resolve(int)) # => 42
Scopes & cleanup
Use Lifetime.SCOPED for per-request/per-job caching. Use generator/async-generator providers for deterministic
cleanup on scope exit.
from collections.abc import Generator
from diwire import Container, Lifetime, Scope
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:
session.close()
container = Container()
container.add_generator(
session_factory,
provides=Session,
scope=Scope.REQUEST,
lifetime=Lifetime.SCOPED,
)
with container.enter_scope() as request_scope:
session = request_scope.resolve(Session)
print(session.closed) # => False
print(session.closed) # => True
Function injection
Mark injected parameters as Injected[T] and wrap callables with @resolver_context.inject.
from diwire import Container, Injected, resolver_context
class Service:
def run(self) -> str:
return "ok"
container = Container()
container.add(Service)
@resolver_context.inject
def handler(service: Injected[Service]) -> str:
return service.run()
print(handler()) # => ok
Named components
Use Annotated[T, Component("name")] when you need multiple registrations for the same base type.
For registration ergonomics, you can also pass component="name" to add_* methods.
from typing import Annotated, TypeAlias
from diwire import All, Component, Container
class Cache:
def __init__(self, label: str) -> None:
self.label = label
PrimaryCache: TypeAlias = Annotated[Cache, Component("primary")]
FallbackCache: TypeAlias = Annotated[Cache, Component("fallback")]
container = Container()
container.add_instance(Cache(label="redis"), provides=Cache, component="primary")
container.add_instance(Cache(label="memory"), provides=Cache, component="fallback")
print(container.resolve(PrimaryCache).label) # => redis
print(container.resolve(FallbackCache).label) # => memory
print([cache.label for cache in container.resolve(All[Cache])]) # => ['redis', 'memory']
Resolution/injection keys are still Annotated[..., Component(...)] at runtime.
resolver_context (optional)
If you can't (or don't want to) pass a resolver everywhere, use resolver_context.
It is a contextvars-based helper used by @resolver_context.inject and (by default) by Container resolution methods.
Inside with container.enter_scope(...):, injected callables resolve from the bound scope resolver; otherwise they fall
back to the container registered as the resolver_context fallback (Container(..., use_resolver_context=True) is the
default).
from contextvars import ContextVar
from diwire import Container, Injected, Scope, resolver_context
current_user_id_var: ContextVar[int] = ContextVar("current_user_id", default=0)
def read_current_user_id() -> int:
return current_user_id_var.get()
container = Container()
container.add_factory(read_current_user_id, provides=int, scope=Scope.REQUEST)
@resolver_context.inject(scope=Scope.REQUEST)
def handler(value: Injected[int]) -> int:
return value
with container.enter_scope(Scope.REQUEST) as request_scope:
token = current_user_id_var.set(7)
try:
print(handler(diwire_resolver=request_scope)) # => 7
finally:
current_user_id_var.reset(token)
Stability
diwire targets a stable, small public API.
- Backward-incompatible changes only happen in major releases.
- Deprecations are announced first and kept for at least one minor release (when practical).
Docs
License
MIT. See LICENSE.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file diwire-1.3.0.tar.gz.
File metadata
- Download URL: diwire-1.3.0.tar.gz
- Upload date:
- Size: 462.8 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: uv/0.10.7 {"installer":{"name":"uv","version":"0.10.7","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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
63aa43535c7b1afb5853d7e7da843e30692238af92730b28a3e0f6055e754670
|
|
| MD5 |
9c63ac3354b74fb0a74ceed2f8ab808a
|
|
| BLAKE2b-256 |
0569331ff1a08a9c74eb04ea5646f47d23e86c9afb2b3432507561ed68a9adb2
|
File details
Details for the file diwire-1.3.0-py3-none-any.whl.
File metadata
- Download URL: diwire-1.3.0-py3-none-any.whl
- Upload date:
- Size: 96.8 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: uv/0.10.7 {"installer":{"name":"uv","version":"0.10.7","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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
1f19186f51723c9704e81454fcff2837b2ec1a7a9dae28e66a2c1fe164aed338
|
|
| MD5 |
ebbfdfda4a55bec122a116d14f1380a5
|
|
| BLAKE2b-256 |
c566070a9ffad2a5e424cf4d61f0cc6b2d1c1a2b60bd6121793fcca4221e3489
|