PySyringe
PySyringe

PySyringe

An opinionated dependency injection library for Python.
Keep your domain clean. Inject only at call sites.

$ pip install pysyringe

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:

  1. Mock store — Check the current thread’s mock store. If a mock was registered via use_mock() or override(), return it.
  2. Alias lookup — If the type was registered with alias(), recursively resolve the mapped implementation type.
  3. 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 Container parameter, the container passes itself.
  4. 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.

Note

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:

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:

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:

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:

  1. The decorator inspects the function’s signature and type hints.
  2. Parameters annotated with Provide[T] are resolved from the container.
  3. All other parameters are left untouched for the caller to provide.
  4. 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.

Note

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")
Recommended

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:

Resolution Cache

PySyringe includes a lightweight cache to speed up dependency resolution without affecting instance semantics.

What is cached

What is NOT cached

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.

ParameterTypeDescription
factoryobject | NoneAn 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.

ParameterTypeDescription
clstype[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.

ParameterTypeDescription
functionCallableThe 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).

ParameterTypeDescription
interfacetypeThe abstract type or interface.
implementationtypeThe 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.

ParameterTypeDescription
clstype[T]The type to override.
mockTThe 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.

ParameterTypeDescription
override_mapdict[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.

ParameterTypeDescription
clstype[T]The type to mock.
mockTThe 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.

ParameterTypeDescription
type_type[T]The class to instantiate.
*type_argsAnyPositional arguments for the constructor.
**type_kwargsAnyKeyword 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.

ParameterTypeDescription
type_type[T]The class to instantiate.
*type_argsAnyPositional arguments for the constructor.
**type_kwargsAnyKeyword 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