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, double-checked locking 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:
- Mock store — Check the current thread’s mock store. If a mock was registered via
use_mock()oroverride(), 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
from pysyringe.singleton import 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.
- 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()oruse_mock()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.
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.
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.singleton 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 using double-checked locking—concurrent threads will 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.singleton 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, request-scoped state, and other resources that are not thread-safe and should not be shared across threads.
| Helper | Scope | Thread Safety | Use Case |
|---|---|---|---|
singleton() |
Global | Double-checked locking | Connection pools, HTTP clients |
thread_local_singleton() |
Per-thread | Thread-local storage | Database sessions, request state |
Mocks & Overrides
PySyringe makes it easy to replace dependencies in tests. The recommended approach uses the override() context manager.
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")
Prefer override() / overrides() over the legacy use_mock() API. Context managers guarantee cleanup, preventing mock leakage between tests.
Legacy Mock API
The use_mock() and clear_mocks() methods provide a manual mock API. You are responsible for clearing mocks after each test.
import pytest
@pytest.fixture(autouse=True)
def clear_container_mocks():
yield
container.clear_mocks()
def test_user_signup():
container.use_mock(UserRepository, InMemoryUserRepository())
service = container.provide(SignupUserService)
service.signup("Jane", "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. |
| Factory methods | Global | Indexed at container initialization. Shared across threads. |
use_mock() |
Per-thread | Stored in thread-local storage. No cross-thread leakage. |
clear_mocks() |
Per-thread | Clears only the current thread’s mocks. |
singleton() |
Global | Thread-safe creation via double-checked locking. |
thread_local_singleton() |
Per-thread | One instance per thread via threading.local(). |
Implications:
- Calling
use_mock(SomeType, mock)in one thread does not affect other threads. - Calling
clear_mocks()clears only the current thread’s mocks. - To share behavior globally, use
alias()or a factory method instead of mocks.
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.container 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.container 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.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. |
container.use_mock(cls, mock)
use_mock(cls: type[T], mock: T) -> None
Set a mock for a type in the current thread. Thread-local: does not affect other threads. Prefer override() for new code.
| Parameter | Type | Description |
|---|---|---|
| cls | type[T] | The type to mock. |
| mock | T | The mock instance. |
container.clear_mocks()
clear_mocks() -> None
Clear all mocks for the current thread. Only affects the calling thread.
Singleton API
singleton(type_, args, *kwargs)
singleton(type_: type[T], type_args, *type_kwargs) -> T
Create or retrieve a globally shared singleton instance. Thread-safe via double-checked locking. 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.container 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.container import UnresolvableUnionTypeError