PySyringe - Dependency Injection for Python
Description: An opinionated, zero-decorator dependency injection library for Python. Keep your domain clean, inject only at call sites.

# 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 views 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 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 complete Flask application using PySyringe for dependency injection:

    from datetime import datetime, timezone
    from flask import Flask
    from pysyringe.container import Container

    # 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
    app = Flask(__name__)

    @app.get("/now")
    @container.inject
    def get_now(calendar: CalendarInterface):
        return {"now": calendar.now().isoformat()}

That’s it. The `CalendarInterface` and `Calendar` classes are plain Python—no decorators, no registration. The container resolves the dependency at the call site.

## The Container

The `Container` class is the central piece of PySyringe. It manages dependency resolution, mocking, and injection.

    from pysyringe.container 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. Blacklist check — If the type was registered with `never_provide()`, resolution stops immediately.
2. Mock store — Check the current thread’s mock store. If a mock was registered via `use_mock()` or `override()`, return it.
3. Alias lookup — If the type was registered with `alias()`, recursively resolve the mapped implementation type.
4. Factory methods — Look up a factory method by return-type annotation. If found, call it and return the result.
5. 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.container 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()` or `thread_local_singleton()` to control instance sharing.

## 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:
        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.

## Blacklist

Use `never_provide()` to tell the container it should never attempt to construct certain types. This is useful for framework-provided types that should not be auto-wired.

    from django.http import HttpRequest, HttpResponse

    container.never_provide(HttpRequest)
    container.never_provide(HttpResponse)

When a blacklisted type appears as a constructor parameter, the container skips it. If the parameter has a default value, the default is used. If it doesn’t, inference for the enclosing type fails gracefully.

## The @inject Decorator

The `@container.inject` decorator is the primary way to wire dependencies into your application’s entry points (HTTP handlers, CLI commands, etc.).

    @app.get("/users")
    @container.inject
    def list_users(user_service: UserService, page: int = 1):
        return user_service.list(page=page)

How it works:

1. The decorator inspects the function’s signature.
2. For each parameter, it attempts to resolve the type from the container.
3. Parameters that can be resolved are pre-filled automatically.
4. Parameters that cannot be resolved remain as normal function parameters.
5. The function’s `__signature__` is updated to reflect only the remaining parameters.

In the example above, `user_service` is injected by the container while `page` remains a normal parameter.

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.
`never_provide()` | 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 use `singleton()` or `thread_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 resolvable dependencies into a function. Returns a wrapped function with resolved parameters pre-filled. Unresolvable parameters remain as normal parameters. The returned function’s `__signature__` is updated to reflect only the remaining parameters.

Parameter | Type | Description
--- | --- | ---
function | Callable | The function to decorate.

### 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.never_provide(cls)

`never_provide(cls: type[T]) -> None`

Blacklist a type so the container will never attempt to resolve it. Useful for framework types like HTTP request/response objects.

Parameter | Type | Description
--- | --- | ---
cls | type[T] | The type to blacklist.

### 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