PySyringe
An opinionated dependency injection library for Python.
Keep your domain clean. Inject only at call sites.
Zero-Decorator DI
Your domain classes stay free of framework annotations. Injection happens only at the infrastructure boundary.
Inference-Based Wiring
Auto-wire constructor dependencies from type hints. No registration boilerplate needed.
Thread-Safe by Design
Thread-local mocks, lock-guarded singletons, and per-thread instance caching built in.
Test-Friendly
Replace any dependency with a mock using context managers. Mocks never leak between threads.
Factory Support
Wire complex dependencies using factory methods. The container matches methods by return-type annotation.
Zero Dependencies
Pure Python. No external packages required. Works with Python 3.11+.
Introduction
PySyringe is a dependency injection container that does not require decorators on your domain classes. Instead of polluting your business logic with framework-specific annotations, PySyringe wraps only the call sites in your infrastructure layer—keeping your domain and application layer decoupled from the framework and the container.
The philosophy is simple: your domain code should not know that a DI container exists. Injection happens at the call site (HTTP handlers, CLI commands, message consumers) using the @container.inject decorator with Provide[T] type markers, or explicit container.provide() calls.
Installation
pip install pysyringe
PySyringe requires Python 3.11 or later and has no external dependencies.
Quick Start
Here is a Django view using PySyringe for dependency injection:
from datetime import datetime, timezone
from django.http import HttpRequest, HttpResponse
from pysyringe import Container, Provide
# 1. Define an interface and its implementation
class CalendarInterface:
def now(self) -> datetime:
raise NotImplementedError
class Calendar(CalendarInterface):
def now(self) -> datetime:
return datetime.now(timezone.utc)
# 2. Create and configure the container
container = Container()
container.alias(CalendarInterface, Calendar)
# 3. Inject dependencies at the call site
@container.inject
def get_now(request: HttpRequest, calendar: Provide[CalendarInterface]) -> HttpResponse:
return HttpResponse(calendar.now().isoformat())
The CalendarInterface and Calendar classes are plain Python—no decorators, no registration. The container injects only the parameter marked with Provide[T]; the request parameter is left for Django to provide.
The Container
The Container class is the central piece of PySyringe. It manages dependency resolution, mocking, and injection.
from pysyringe import Container
# Without a factory (inference-only)
container = Container()
# With a factory object
container = Container(MyFactory())
The optional factory argument is any object whose public methods serve as factory functions. The container inspects these methods at initialization, indexing them by return-type annotation for O(1) lookup.
How Resolution Works
When you call container.provide(SomeType), the container follows a strict resolution order:
- Active overrides — Check the current thread’s or asyncio task’s override store. If
override()oroverrides()has replaced the type within an activewithblock, return the replacement. - Registered instance — If a pre-built object was registered for the type via
register_instance(), return it. - Alias lookup — If the type was registered with
alias(), recursively resolve the mapped implementation type. - Factory methods — Look up a factory method by return-type annotation. If found, call it and return the result. If the factory method accepts a
Containerparameter, the container passes itself. - Constructor inference — Inspect the type’s constructor, recursively resolve each parameter by its type hint, and construct the instance.
If none of these strategies succeed, an UnknownDependencyError is raised.
Resolution does not cache instances. Each call to provide() creates a new instance (unless you use singleton helpers in your factory). The resolution process (parameter introspection, factory lookup) is cached for performance.
Factory Methods
Factories give you full control over how dependencies are constructed. A factory is any object—the container discovers its public methods and indexes them by their return-type annotation.
from pysyringe import Container, singleton
class EmailSender:
def __init__(self, host: str, port: int) -> None:
self.host = host
self.port = port
class DatabaseClient:
def __init__(self, connection_string: str) -> None:
self.connection_string = connection_string
class Factory:
def __init__(self, environment: str) -> None:
self.environment = environment
def get_mailer(self) -> EmailSender:
if self.environment == "production":
return EmailSender("smtp.example.org", 25)
return EmailSender("localhost", 1025)
def get_database(self) -> DatabaseClient:
return singleton(DatabaseClient, "postgresql://localhost/mydb")
container = Container(Factory("development"))
mailer = container.provide(EmailSender) # Uses factory method
db = container.provide(DatabaseClient) # Uses factory + singleton
Key rules for factory methods:
- Methods must be public (no leading underscore).
- Methods must have a return-type annotation. The container matches requested types to factory methods by this annotation.
- The method name does not matter—only the return type is used for matching.
- Methods must be synchronous. An
async def(or async generator) factory method raisesAsyncFactoryErrorat container construction — see Async Support. - Factory methods can use
singleton()orthread_local_singleton()to control instance sharing.
Container-Aware Factories
Factory methods can receive the Container itself as an argument. If a factory method declares a parameter typed as Container, the container passes itself when invoking the factory. This lets factories resolve sub-dependencies through the container, benefiting from inference, mocks, overrides, and aliases.
from pysyringe import Container
class AppConfig:
def __init__(self) -> None:
self.smtp_host = "smtp.example.org"
self.smtp_port = 25
class Factory:
def get_mailer(self, container: Container) -> EmailSender:
config = container.provide(AppConfig)
return EmailSender(config.smtp_host, config.smtp_port)
container = Container(Factory())
mailer = container.provide(EmailSender) # Factory receives the container
This is especially useful when:
- A factory needs dependencies that are themselves resolvable by the container (via inference or other factories).
- You want factory-created objects to respect active
override()replacements during tests. - You need to combine factory logic with the container’s recursive resolution.
Factory methods without a Container parameter continue to work exactly as before—called with no arguments.
Constructor Inference
When no factory method or alias matches, PySyringe falls back to constructor inference. It inspects the class’s __init__ parameters, resolves each by type hint, and constructs the instance.
class Logger:
pass
class UserRepository:
def __init__(self, logger: Logger) -> None:
self.logger = logger
class UserService:
def __init__(self, repo: UserRepository, logger: Logger) -> None:
self.repo = repo
self.logger = logger
container = Container()
service = container.provide(UserService)
# UserService was constructed with:
# - a UserRepository (auto-constructed with its own Logger)
# - a Logger
Inference rules:
- Parameters must have type annotations. Unannotated parameters are skipped.
- Positional-only parameters are skipped.
- Parameters with default values use the default if the type cannot be resolved.
- Resolution is recursive—nested dependencies are resolved automatically.
Aliases
Aliases map an interface (or abstract class) to a concrete implementation. When the container is asked to provide the interface type, it resolves the mapped implementation instead.
class NotificationService(abc.ABC):
@abc.abstractmethod
def send(self, message: str) -> None:
raise NotImplementedError
class SlackNotificationService(NotificationService):
def send(self, message: str) -> None:
... # send via Slack API
container = Container()
container.alias(NotificationService, SlackNotificationService)
service = container.provide(NotificationService)
# Returns a SlackNotificationService instance
The implementation is constructed via inference, so its dependencies are resolved recursively. You don’t need a factory method for aliased types.
Registered Instances
register_instance(port, instance) binds a pre-built object to a type. When the type is requested, the container hands back the exact object you passed in — no construction, no resolution.
This is the right tool when:
- You need to construct an object yourself because its constructor takes runtime values (settings, secrets, environment-dependent URLs) that aren’t container-resolvable.
- A single concrete object satisfies multiple abstract ports and you want all four ports to resolve to the same instance.
Single port
import os
class AppConfig:
def __init__(self, database_url: str, debug: bool) -> None:
self.database_url = database_url
self.debug = debug
config = AppConfig(
database_url=os.environ["DATABASE_URL"],
debug=os.environ.get("DEBUG") == "1",
)
container.register_instance(AppConfig, config)
Same instance for multiple ports
import abc
class Cache(abc.ABC):
@abc.abstractmethod
def get(self, key: str) -> str | None: ...
class RateLimiter(abc.ABC):
@abc.abstractmethod
def allow(self, key: str) -> bool: ...
class RedisClient(Cache, RateLimiter):
def __init__(self, *, url: str, max_connections: int) -> None:
self.url = url
self.max_connections = max_connections
def get(self, key: str) -> str | None: ...
def allow(self, key: str) -> bool: ...
client = RedisClient(url=os.environ["REDIS_URL"], max_connections=20)
container.register_instance(Cache, client)
container.register_instance(RateLimiter, client)
Both provide(Cache) and provide(RateLimiter) now return the same RedisClient object.
Registrations are process-wide and shared across threads. They take precedence over alias() and factory methods.
override() still beats register_instance(), so tests can replace a registered production object the same way they replace anything else.
The @inject Decorator and Provide[T]
The @container.inject decorator is the primary way to wire dependencies into your application’s entry points (HTTP handlers, CLI commands, message consumers, etc.).
Use the Provide[T] type marker to indicate which parameters should be injected. Only marked parameters are injected; all others are left for the caller.
from pysyringe import Container, Provide
@container.inject
def list_users(request: HttpRequest, user_service: Provide[UserService]) -> HttpResponse:
return JsonResponse(user_service.list())
How it works:
- The decorator inspects the function’s signature and type hints.
- Parameters annotated with
Provide[T]are resolved from the container. - All other parameters are left untouched for the caller to provide.
- The function’s
__signature__is updated to reflect only the remaining (non-injected) parameters.
Declare Provide[T] parameters last, after all caller-supplied parameters (as in the example above). Injected values are passed by keyword, so a caller’s positional argument would otherwise land in an injected parameter’s slot and fail with TypeError.
In the example above, user_service is injected by the container while request is provided by the framework as usual. This makes @container.inject safe to use with any framework—Django, Flask, Dramatiq, etc.—because the container never interferes with framework-controlled parameters.
Dependencies are resolved at call time, not at decoration time. This means mocks set after the decorator is applied will still be picked up.
Providing Dependencies
You can also request dependencies directly without using the decorator:
service = container.provide(UserService)
This is useful for programmatic access to dependencies, such as in application setup code, background workers, or management commands.
If the type cannot be resolved, an UnknownDependencyError is raised.
Global Singleton
The singleton() helper creates or retrieves a globally shared instance. It is designed for use inside factory methods to ensure a single instance is shared across all threads.
from pysyringe import singleton
class DatabaseClient:
def __init__(self, connection_string: str) -> None:
self.connection_string = connection_string
class Factory:
def get_database(self) -> DatabaseClient:
return singleton(DatabaseClient, "postgresql://localhost:5432/mydb")
container = Container(Factory())
client1 = container.provide(DatabaseClient)
client2 = container.provide(DatabaseClient)
assert client1 is client2 # Same instance everywhere
The cache key includes the class, positional arguments, and keyword arguments (order-independent for keywords). Different arguments produce different instances.
Creation is thread-safe: a lock guarantees concurrent threads never produce duplicate instances for the same key.
Best for: connection pools, HTTP clients, configuration objects, and other thread-safe resources that should be shared globally.
Thread-Local Singleton
The thread_local_singleton() helper creates or retrieves a per-thread instance. Each thread gets its own instance; within the same thread, repeated calls return the same object.
from pysyringe import thread_local_singleton
class DatabaseSession:
def __init__(self, dsn: str) -> None:
self.dsn = dsn
class Factory:
def get_session(self) -> DatabaseSession:
return thread_local_singleton(DatabaseSession, "postgresql://localhost/mydb")
Best for: database sessions and other resources that are not thread-safe and should not be shared across threads. Note that instances are per-thread, not per-request: servers reuse worker threads, so an instance survives into the next request served by the same thread — reset any per-request state yourself.
Async caveat
The scope is per-thread, not per-task. In an async app all tasks on the same event loop share one thread — and therefore one instance — so thread_local_singleton() degrades to a plain singleton(). Do not use it for per-request state in async servers.
| Helper | Scope | Thread Safety | Use Case |
|---|---|---|---|
singleton() |
Global | Lock-guarded creation | Connection pools, HTTP clients |
thread_local_singleton() |
Per-thread | Thread-local storage | Database sessions, per-thread state |
Overrides
PySyringe makes it easy to replace dependencies in tests via the override() / overrides() context managers. Cleanup is automatic — even if the test raises — so state never leaks between tests.
Override Context Manager
The override() and overrides() context managers temporarily replace dependencies for the duration of a with block. When the block exits, the original behavior is automatically restored.
Single dependency
def test_user_signup():
mock_repo = InMemoryUserRepository()
with container.override(UserRepository, mock_repo):
service = container.provide(SignupUserService)
service.signup("Jane", "jane@example.org")
assert mock_repo.get_by_email("jane@example.org")
Multiple dependencies
def test_with_multiple_overrides():
with container.overrides({
UserRepository: InMemoryUserRepository(),
EmailSender: FakeEmailSender(),
}):
service = container.provide(SignupUserService)
service.signup("Jane", "jane@example.org")
Shared Setup with Fixtures
For test suites that share dependency replacements, wrap override() in a pytest fixture that yields from inside the with block. The context manager handles cleanup, so no autouse teardown is needed and exceptions can’t leak state.
import pytest
@pytest.fixture
def user_repository():
repo = InMemoryUserRepository()
with container.override(UserRepository, repo):
yield repo
def test_user_signup(user_repository):
service = container.provide(SignupUserService)
service.signup("Jane", "jane@example.org")
assert user_repository.get_by_email("jane@example.org")
Thread Safety
PySyringe is designed with thread safety in mind:
| Feature | Scope | Details |
|---|---|---|
alias() |
Global | Shared across all threads. Configure at startup. |
register_instance() |
Global | Shared across all threads. Configure at startup. |
| Factory methods | Global | Indexed at container initialization. Shared across threads. |
override() / overrides() |
Per-context | Stored in contextvars. No leakage across threads or asyncio tasks. |
singleton() |
Global | Thread-safe creation guarded by a lock. |
thread_local_singleton() |
Per-thread | One instance per thread via threading.local(). |
Implications:
- A
with container.override(SomeType, mock)block in one thread does not affect other threads. - To share behavior globally, use
alias()or a factory method.
Async Support
The container is safe to use in asyncio applications, with explicit limits on what resolution can do.
What works:
@container.injectonasync deffunctions. The decorated function is still a coroutine function, so framework async detection (e.g. Django’sinspect.iscoroutinefunction()check) works. Dependencies are resolved synchronously each time the function is called, before the firstawait.- Overrides are isolated per asyncio task.
override()/overrides()usecontextvars, so concurrent tasks on the same event loop can hold different overrides without interfering. Tasks spawned inside an override block inherit it.
Limitations:
- Factory methods must be synchronous. An
async def(or async generator) factory method raisesAsyncFactoryErrorat container construction — resolution never awaits, so the coroutine would be injected un-awaited. A plaindeffactory can still build and return async objects (engines, clients, pools). - Resolution runs in the event loop. A factory that blocks (opening connections, reading files) blocks the loop while a dependency is being resolved.
thread_local_singleton()is per-thread, not per-task. All tasks on one event loop share one instance — see the caveat in Thread-Local Singleton.
Resolution Cache
PySyringe includes a lightweight cache to speed up dependency resolution without affecting instance semantics.
What is cached
- A precomputed map of factory methods keyed by their return type, built once at container initialization for O(1) lookups.
- Constructor parameter introspection, cached via
functools.lru_cache(512 entries) to avoid repeated signature parsing.
What is NOT cached
- Instances. Each call to
provide()creates a fresh instance unless you usesingleton()orthread_local_singleton()in your factory.
This means your singleton semantics, custom sharing strategies, and factory logic remain fully in your control. The cache only reduces the overhead of figuring out how to construct dependencies.
Optional & Union Types
PySyringe handles Optional and Union types as follows:
Optional types
Optional[T] (or T | None) is automatically unwrapped. The container resolves the non-None type.
class Service:
def __init__(self, logger: Logger | None = None) -> None:
self.logger = logger
# The container resolves Logger for the logger parameter.
# If Logger cannot be resolved, it uses the default value (None).
Union types
Non-Optional union types like A | B are not supported and will raise an UnresolvableUnionTypeError. The container cannot determine which type to provide when multiple options exist.
class Service:
def __init__(self, store: RedisStore | MemoryStore) -> None:
...
# Raises UnresolvableUnionTypeError
# Solution: use an alias or define a factory method instead.
Error Handling
PySyringe raises clear exceptions when resolution fails:
UnknownDependencyError
Raised when container.provide(SomeType) cannot resolve the requested type through any strategy (mocks, aliases, factory, or inference).
from pysyringe import UnknownDependencyError
try:
container.provide(SomeUnknownType)
except UnknownDependencyError as e:
print(e) # "Container does not know how to provide <class 'SomeUnknownType'>"
UnresolvableUnionTypeError
Raised during resolution when a constructor parameter uses a non-Optional union type like A | B.
from pysyringe import UnresolvableUnionTypeError
# "Cannot resolve [A | B]: remove UnionType or define a factory"
Container API
Container(factory=None)
Container(factory: object | None = None)
Create a new dependency injection container.
| Parameter | Type | Description |
|---|---|---|
| factory | object | None | An optional factory object. Public methods with return-type annotations are indexed as factory methods. If None, the container uses inference only. |
container.provide(cls)
provide(cls: type[T]) -> T
Resolve and return an instance of the requested type. Raises UnknownDependencyError if the type cannot be resolved.
| Parameter | Type | Description |
|---|---|---|
| cls | type[T] | The type to resolve. |
container.inject(function)
inject(function: Callable) -> Callable
Decorator that injects dependencies into a function. Only parameters annotated with Provide[T] are resolved from the container; all other parameters are left for the caller. The returned function’s __signature__ is updated to hide injected parameters.
| Parameter | Type | Description |
|---|---|---|
| function | Callable | The function to decorate. |
Provide[T]
Provide[T]
Type marker for use in function signatures decorated with @container.inject. Annotate a parameter as Provide[T] to indicate that the container should inject it. At runtime, Provide[T] expands to typing.Annotated[T, ...], so type checkers treat it as T.
from pysyringe import Provide
def my_view(request: HttpRequest, service: Provide[MyService]) -> HttpResponse:
...
container.alias(interface, implementation)
alias(interface: type, implementation: type) -> None
Map an interface type to a concrete implementation. When the interface is requested, the container resolves the implementation instead (via inference).
| Parameter | Type | Description |
|---|---|---|
| interface | type | The abstract type or interface. |
| implementation | type | The concrete type to use. |
container.register_instance(cls, instance)
register_instance(cls: type[T], instance: T) -> None
Bind a pre-built object as the implementation for a type. Subsequent calls to provide(cls) return the registered object as-is. The same instance can be registered for several types to share one concrete object across multiple ports. Registrations are process-wide (not thread-local) and take precedence over alias() and factory methods, but can still be replaced by override().
| Parameter | Type | Description |
|---|---|---|
| cls | type[T] | The type (typically an abstract port) to bind. |
| instance | T | The pre-built object to return when this type is requested. |
container.override(cls, mock)
@contextmanager override(cls: type[T], mock: T) -> Iterator[None]
Context manager that temporarily replaces a single dependency. The original behavior is restored when the with block exits.
| Parameter | Type | Description |
|---|---|---|
| cls | type[T] | The type to override. |
| mock | T | The replacement instance. |
container.overrides(override_map)
@contextmanager overrides(override_map: dict[type[T], T]) -> Iterator[None]
Context manager that temporarily replaces multiple dependencies at once.
| Parameter | Type | Description |
|---|---|---|
| override_map | dict[type, object] | A mapping of types to their replacement instances. |
Singleton API
singleton(type_, args, *kwargs)
singleton(type_: type[T], type_args, *type_kwargs) -> T
Create or retrieve a globally shared singleton instance. Creation is thread-safe, guarded by a lock. The cache key is the combination of the class, positional args, and keyword args.
| Parameter | Type | Description |
|---|---|---|
| type_ | type[T] | The class to instantiate. |
| *type_args | Any | Positional arguments for the constructor. |
| **type_kwargs | Any | Keyword arguments for the constructor. |
thread_local_singleton(type_, args, *kwargs)
thread_local_singleton(type_: type[T], type_args, *type_kwargs) -> T
Create or retrieve a per-thread singleton instance. Each thread gets its own instance; within the same thread, repeated calls return the same object. Uses threading.local() for storage.
| Parameter | Type | Description |
|---|---|---|
| type_ | type[T] | The class to instantiate. |
| *type_args | Any | Positional arguments for the constructor. |
| **type_kwargs | Any | Keyword arguments for the constructor. |
Exceptions
UnknownDependencyError
class UnknownDependencyError(Exception)
Raised when container.provide() cannot resolve the requested type.
Message format: "Container does not know how to provide <type>"
from pysyringe import UnknownDependencyError
UnresolvableUnionTypeError
class UnresolvableUnionTypeError(Exception)
Raised when a constructor parameter uses a non-Optional union type (e.g. A | B) that the container cannot disambiguate.
Message format: "Cannot resolve [type]: remove UnionType or define a factory"
from pysyringe import UnresolvableUnionTypeError