Skip to main content

A Python dependency injection container that automatically resolves and injects dependencies without polluting your code with framework-specific decorators. Inspired by Lagom, Svcs, and C# .NET DI, it keeps your code clean and flexible while offering multiple service registration options. 🚀

Project description

handless

Handless is a Python dependency injection container which aims at facilitating creation of your objects and services without polluting your code with framework specific code.

In particular it contains the following features:

  • 🔌 Autowiring: Handless reads your objects constructor to determines its dependencies and resolve them automatically for you without explicit registration
  • ♻️ Lifetimes: Handless allows you to pick between singleton, contextual, and transient lifetimes to determines when to reuse cached object or get new ones
  • 🧹 Context managers: Handless automatically enter and exit context managers of your objects without having you to manage them
  • 🔁 Inversion of control: Handless allows you to alias protocols or abstract classes to concrete implementations
  • 🧠 Fully typed: Handless uses types for registrations. It makes sure that you register only things compatible with provided types and resolves objects with correct type
  • 🧰 Flexible: Handless allows you to provide constant values, factories or lambda functions when registering your types

The following features are not available yet but planned:

  • Async support: Handless will support async functions and context managers
  • Positional only argument: Handless for the moment can not autowire function or constructors using positional only arguments
  • Default values: Handless will use default values of functions or types arguments when its missing type annotation or nothing is registered for this type
  • Change default lifetime: Handless will allow you to specify the default lifetime to use any registered type to fit your needs and reduce boilerplate
  • Partial binding: Handless will provide ability to partially bind a function to a container allowing to execute that function and have the container resolve and inject its arguments on the fly
  • Pings: Handless will allow you to register callbacks for your types allowing you to implement pings/health checks for objects interacting with shared resources (api, databases, ...
  • Captive dependencies detection: Handless will try to provide ability to detect captive dependencies due to lifetimes mismatches during registration (e.g: a singleton type depending on a transient one)

Table of Content

Explanations

🔧 What is Dependency Injection, and Why Should You Care?

In modern software design, dependency injection (DI) is a technique where a component’s dependencies are provided from the outside, rather than hard-coded inside it. This leads to:

  • ✅ More modular and testable code
  • ✅ Easier substitution of dependencies (e.g., mocks, stubs, alternative implementations)
  • ✅ Clearer separation of concerns

Trivial example without DI

class Service:
    def __init__(self):
        self.db = Database()  # tightly coupled

Same exemple with DI

class Service:
    def __init__(self, db: Database):
        self.db = db  # dependency injected

Doing dependency injection push creation and composition of your objects upfront. The place where you're doing this is called the composition root and is close to your application entrypoint(s).

:bulb: Your application can have many entrypoint and then many composition root, a CLI, a HTTP server, an event listener, ... Note that tests are also considered as entrypoints.

Doing dependency injection does not require any framework nor libraries, it can be achieved by "hand" (hence the name of this library "handless") by simply creating and composing your objects as expected. Doing so is called Pure DI.

However, manually composing your objects can be challenging in complex applications, in particular when you have to manage objects with different lifetimes (one per application, one per request, and so on...). It can also be complicated to compose only parts of your object graph with some objects replaced for testing purposes or for a different entrypoint (i.e: reusing some parts of your composition logic).

:warning: Using a dependency injection container is not mandatory. In simple applications it can be easier to do it manually. Always consider pros and cons.

This is where dependency injection containers can help you.

🧱 What is a DI Container?

As your project grows, wiring up dependencies manually becomes tedious and error-prone.

A dependency injection container role is to register once how to create and compose each of your objects in order to get instances of them on demand. The act of asking a container to get an instance of a specific type is called resolve. Finally, when you don't need those instances anymore you or the container will delete them and eventually do some cleanup (if specified). This last step is known as release.

Dependency injection containers can also:

  • 🔍 Scan constructor signatures or factory functions
  • 🔗 Resolve and injecting required dependencies
  • ♻️ Manage object lifetimes (singleton, transient, scoped...)
  • 🧹 Handle cleanup for context-managed resources

Instead of writing all the wiring logic yourself, the container does it for you — predictably and declaratively.

🚀 What This Library Solves

As stated in the introduction Handless provides you a dependency injection container that allows you to register your types and how to resolve them. It also takes care of lifetimes, context managers and is fully typed. Handless is able to read your types __init__ method to determine the dependencies to inject in order to create instances.

All of this is does not require you to add any library specific decorators or attributes to your existing types.

Its API provide lot of flexibility for registering your types.

This library provides a lightweight, flexible dependency injection container for Python that helps you:

  • Register services with factories, values, aliases or constructors
  • Resolve dependencies automatically (with type hints or custom logic)
  • Manage lifecycles — including context-aware caching and cleanup (singleton, transient, contextual)
  • Handle context managers by entering and exiting created objects context managers automatically
  • And more...

It’s designed to be explicit, flexible, and intuitive

🧩 Design

Here are the main concept and design choice of Handless.

Container

Handless provides a handless.Container dependency injection container. This container allows you to register Python types and define how to resolve them. You can provide a function responsible of returning an instance of the type, a constant value, an alias (i.e: another type that should be resolved instead) or using the type constructor itself. This produces a registration.

:bulb: In the end, a registration is a type attached to function. This function is responsible to get an instance of the specified type based on the provided factory, value, alias or constructor.

Resolution Context

In dependency injection container terminology, a Handless resolution context corresponds to a scope. A scope is often referred as a kind of unique "sub container" for a short(er) duration of time. For example, in a HTTP API, you can have one scope per HTTP request. This allows to introduce a "scoped" lifetime to have the container create one instance of a type per scope (and then per request).

In order to resolve any types from a container, a handless.ResolutionContext must always be opened and used.

:warning: You're free to manage your contexts the way you want but using a single context for the whole application duration could be a code smell.

You can not resolve types from the container directly. This design choice has been made for two reasons:

  • Avoid keeping transient values for the whole duration of a container and as a consequence, an application.

    :question: This is because there is no reliable and easy way in Python to automatically cleanup object before garbage collection. Explicit cleanup is required or at least strongly encouraged.

  • Avoid to raise errors when trying to resolve a soped type from a container instead of a scope

    :question: For types registered with a lifetime of a ResolutionContext (i.e: a scope) the question is "what should we do when resolving this from a container? Should we raise an error? Should we resolve it and consider the container as a scope as well?" Our design completly get rid of this choice by forcing usage of a context (scope), always

Lifetimes

When registering your types you can specify a lifetime. The lifetime determines when the container will execute or get a cached value of the function attached to the type to resolve:

  • handless.Singleton
    • On first resolve, the type function is called and its return value is cached for the whole duration of the container and for contexts
    • Singletons are cached in the container itself
    • Singletons context managers (if any) are entered on first resolve and exited on container end (release)
  • handless.Contextual
    • The type function is called and cached once per context. Additional resolve on the same context always return the same cached value
    • Contextuals are cached per resolution context
    • Contextuals context managers (if any) are entered on first resolve and exited on context end (release)
  • handless.Transient
    • The type function is called on each resolve.
    • Transient values are never cached
    • Transient context managers (if any) are entered on resolve and exited on context end (release)

:warning: You must understand that whichever lifetime you choose the container does not actually check returned object identity. The lifetime only determines when the container should execute registered functions or return a previously cached value. In other words, it means that you could register a transient type with a function returning always the same constant. You'll then end up with a singleton anyway.

:bulb: To avoid any troubles or misunderstanding regarding lifetimes when registering factories, ensure that your factories always create new instance of your object and does not do any manual caching upfront. Let the container take care of caching.

Getting started

Install it through your preferred package manager:

pip install handless

Once installed, you can create and use a container. Here is an example.

import smtplib
from dataclasses import dataclass
from typing import Protocol

from handless import Container, Contextual, ResolutionContext, Singleton, Transient


@dataclass
class User:
    email: str


@dataclass
class Config:
    smtp_host: str


class UserRepository(Protocol):
    def add(self, cat: User) -> None: ...
    def get(self, email: str) -> User | None: ...


class InMemoryUserRepository(UserRepository):
    def __init__(self) -> None:
        self._users: list[User] = []

    def add(self, user: User) -> None:
        self._users.append(user)

    def get(self, email: str) -> User | None:
        for user in self._users:
            if user.email == email:
                return user
        return None


class NotificationManager(Protocol):
    def send(self, user: User, message: str) -> None: ...


class StdoutNotificationManager(NotificationManager):
    def send(self, user: User, message: str) -> None:
        print(f"{user.email} - {message}")  # noqa: T201


class EmailNotificationManager(NotificationManager):
    def __init__(self, smtp: smtplib.SMTP) -> None:
        self.server = smtp
        self.server.noop()

    def send(self, user: User, message: str) -> None:
        msg = f"Subject: My Service notification\n{message}"
        self.server.sendmail(
            from_addr="myservice@example.com", to_addrs=[user.email], msg=msg
        )


class UserService:
    def __init__(
        self, users: UserRepository, notifications: NotificationManager
    ) -> None:
        self.users = users
        self.notifications = notifications

    def create_user(self, email: str) -> None:
        user = User(email)
        self.users.add(user)
        self.notifications.send(user, "Your account has been created")

    def get_user(self, email: str) -> User:
        user = self.users.get(email)
        if not user:
            msg = f"There is no user with email {email}"
            raise ValueError(msg)
        return user


config = Config(smtp_host="stdout")

container = Container()
container.register(Config).value(config)

# User repository
container.register(InMemoryUserRepository).self(lifetime=Singleton())
container.register(UserRepository).alias(InMemoryUserRepository)  # type: ignore[type-abstract]

# Notification manager
container.register(smtplib.SMTP).factory(
    lambda ctx: smtplib.SMTP(ctx.resolve(Config).smtp_host),
    lifetime=Singleton(),
    enter=True,
)
container.register(StdoutNotificationManager).self(lifetime=Transient())
container.register(EmailNotificationManager).self()


@container.factory
def create_notification_manager(
    config: Config, ctx: ResolutionContext
) -> NotificationManager:
    if config.smtp_host == "stdout":
        return ctx.resolve(StdoutNotificationManager)
    return ctx.resolve(EmailNotificationManager)


# Top level service
container.register(UserService).self(lifetime=Contextual())


with container.open_context() as ctx:
    service = ctx.resolve(UserService)
    service.create_user("hello.world@handless.io")
    # hello.world@handless.io - Your account has been created
    print(service.get_user("hello.world@handless.io"))  # noqa: T201
    # User(email='hello.world@handless.io')  # noqa: ERA001


container.release()

Core

Create a container

To create a container simply create an instance of it. You can use your container in a context manager or manually call its release method to cleanup all objects resolved so far.

:bulb: .release() does not prevent from reusing your container afterwards.

from handless import Container

container = Container()

# Use your container and release objects on exit
with container:
    ...

# Manually release
container.release()

There should be at most one container per entrypoint in your application (a CLI, a HTTP server, ...). You can share the same container for all your entrypoints. A test is considered as an entrypoint as well.

:bulb: The container should be placed on your application composition root. This can be as simple as a bootstrap.py file on your package root.

:warning The container is the most "high level" component of your application. It can import anything from any sub modules. However, none of your code should depends on the container itself. Otherwise you're going to use the service locator anti-pattern. There can be exceptions to this rule, for example, when used in an HTTP API controllers (as suggested in svcs).

Open a context

To resolve any type from your container you must open a context first. The context should be released when not necessary anymore.

:bulb: Opened context are automatically released on container release if the context still has a strong reference to it.

from handless import Container

container = Container()

# You can manually open and release your context
ctx = container.open_context()
ctx.resolve(...)
ctx.release()

# Or do it with a context manager
with container.open_context():
    ctx.resolve(...)

Context are of type handless.ResolutionContext.

:bulb: We did not chose handless.Context to avoid confusion with other contexts objects from other libraries.

Register a value

You can register a value directly for your type. When resolved, the provided value will be returned as-is.

from handless import Container


class Foo:
    pass


foo = Foo()
container = Container()
container.register(Foo).value(foo)
resolved_foo = container.open_context().resolve(Foo)
assert resolved_foo is foo

Register a factory function

If you're looking for lazy instantiating your objects you can instead register a factory. A factory is a callable taking no or several arguments and returning an instance of the type registered. The callable can be a function, a method or even a type (a class). During resolution, the container will take care of calling the factory and return its return value. If your factory takes arguments, the container will first resolve its arguments using their type annotations and pass them to the factory.

:warning: your callable arguments must have type annotation to be properly resolved. If missing, an error will be raised at registration time.

:bulb: You do not need to create a dedicated factory function. There is nothing that prevents you from using an already existing function from standard library or any other library as long as it has typed parameters (or no parameters).

from handless import Container


class Foo:
    def __init__(self, bar: int) -> None:
        self.bar = bar


def create_foo(bar: int) -> Foo:
    return Foo(bar)


container = Container()
container.register(int).value(42)
container.register(Foo).factory(create_foo)
resolved_foo = container.open_context().resolve(Foo)

assert isinstance(resolved_foo, Foo)
assert resolved_foo.bar == 42

Using factory decorator

Having to write your factory function somewhere then register it on your container elsewhere tends to reduce readability. If you prefer you can opt for using the factory decorator instead.

from handless import Container


class Foo:
    def __init__(self, bar: int) -> None:
        self.bar = bar


container = Container()
container.register(int).value(42)


@container.factory
def create_foo(bar: int) -> Foo:
    return Foo(bar)


resolved_foo = container.open_context().resolve(Foo)

assert isinstance(resolved_foo, Foo)
assert resolved_foo.bar == 42

This is mostly a matter of preference as both ways do the exact same thing. You can also pass parameters to the factory decorator @factory(lifetime=..., enter=...).

Register a lambda function

When registering a factory, you can also pass a lambda function. However, as lambdas arguments can not have type annotation it is handled differently. Lambdas can take 0 or 1 argument. If one is given, a ResolutionContext object will be passed, when called at resolution, as the only argument. This allows you to resolve nested types if required.

from handless import Container


class Foo:
    def __init__(self, bar: int) -> None:
        self.bar = bar


container = Container()
container.register(int).value(42)
container.register(Foo).factory(lambda ctx: Foo(ctx.resolve(int)))
resolved_foo = container.open_context().resolve(Foo)

assert isinstance(resolved_foo, Foo)
assert resolved_foo.bar == 42

Register a type constructor

When you want to register a type and use its constructor (__init__ method) as its own factory, you can use the self() method instead of using .factory(MyType).

from handless import Container


class Foo:
    def __init__(self, bar: int) -> None:
        self.bar = bar


container = Container()
container.register(int).value(42)
container.register(Foo).self()  # Same as: container.register(Foo).factory(Foo)
resolved_foo = container.open_context().resolve(Foo)

assert isinstance(resolved_foo, Foo)
assert resolved_foo.bar == 42

Register an alias

When you want a type to be resolved using resolution of another type you can define an alias.

:bulb: Useful for registering concrete implementations to protocols or abstract classes

from typing import Protocol

from handless import Container


class IFoo(Protocol):
    pass


class Foo(IFoo):
    def __init__(self) -> None:
        pass


foo = Foo()
container = Container()
container.register(Foo).value(foo)
container.register(IFoo).alias(Foo)
resolved_foo = container.open_context().resolve(IFoo)

assert resolved_foo is foo

When resolving IFoo, the container will actually resolve and returns Foo.

Manage lifetime

During registration of factories .factory(...), @container.factory() and .self() you can optionally pass a lifetime.

:warning: You can not change lifetimes for .value(...) and .alias(...) by design.

Lifetimes are actual objects and not enum constants nor literals.

from handless import Container, Singleton, Transient, Contextual


container = Container()
# Singleton
container.register(object).factory(lambda: object(), lifetime=Singleton())
# Contextual
container.register(object).factory(lambda: object(), lifetime=Contextual())
# Transient (The default)
container.register(object).factory(lambda: object(), lifetime=Transient())

As described above, lifetimes allow to determine when the container will execute types factory and cache their result. Generally speaking you may use:

  • handless.Singleton for any objects that should be a singleton for your whole application (one and only one instance per application). For example a HTTP connection pool

    :warning: Singleton should be threadsafe in multi threaded application to avoid any issues

  • handless.Contextual for objects that should be unique per context. For example, a database session should be unique per HTTP request
  • handless.Transient (the default) for stateful objects which should not be shared because their use rely on their internal state. For example an opened file

Context managers and cleanup

Containers and contexts can take care of entering and exiting objects with context managers. Both has a release function which clear their cache and exits any entered context managers.

Factories

Object returned by functions registered with .factory(...) or .self() are automatically entered on resolve and exited on release if it is context managers.

:bulb: You can disable this default behavior by passing enter=False. However, passing False is disallowed if the object return is NOT an instance of the given type.

:warning: Objects are only entered when resolved. Cached values are NOT re-entered afterwards.

If you pass a function which is a generator it will be automatically wrapped as a context manager (contextlib.contextmanager).

:bulb: You pass a function already decorated with contextlib.contextmanager and it will work as expected.

Values

Objects registered with .value(...) are NOT entered by default. If you want their context manager to be handled for you you must pass .value(..., enter=True).

:question: Passing a value means that this value has been created outside of the container and then its lifetime should not container's responsibility.

Context local registry

:construction: Under construction

Override container registrations

Containers does not allow to register the same type twice. The following code will raise an error.

from handless import Container

container = Container()
container.register(str).value("Hello")
container.register(str).value("This will raise an error!")

In order to override your container registered types you must use the override(...) function instead. This function works identically to register(...).

from handless import Container

container = Container()
container.register(str).value("Hello")


def test_my_container():
    container.override(str).value("Overriden!")
    with container.open_context() as ctx:
        resolved = ctx.resolve(str)
        assert resolved == "Overriden!"

:warning: Overriding is primarily made for testing purposes. You should not use overriding in your production code. If you have use cases where it could makes sense please open a ticket.

Please also note the following:

  • Overrides can be overriden as well (each override erase the previous one)
  • Overrides always take precedence over registered type whatever his lifetime (even if the type was previously resolved and cached)
  • Overrides are automatically erased when the container is released
  • On container release, all overrides (even erased one) as well as any previously registered types are properly released as well

Recipes

Release container on application exits

If your application has no shutdown mechanism you can register your container release method using atexit module to release on program exit.

import atexit

from handless import Container

container = Container()

atexit.register(container.release)

Releasing the container is idempotent and can be used several times. Each time, all singletons will be cleared and then context manager exited, if any.

Register primitive types

:construction: Under construction

Register same type for different purposes

:construction: Under construction

Register implementations for protocols and abstract classes

Dependency injection is a key enabler for inversion of control where your objects depends on abstractions or interfaces rather than actual implementation. This mechanism prevents tight coupling between your objects and allows you to swap dependencies with different implementations. This mechanism is mostly used for testing purposes to replace real implementations with fakes or mocks.

handless allows you to do so through various mechanisms. Let's consider you defined an interface of a repository with two implementations, one fo mongoDB and another for SQLite.

:warning: Unrelevant details have been removed for readability.

from typing import Protocol

from handless import Container


class TodoItemRepository(Protocol):
    def add(self, todo: dict) -> None: ...


class MongoTodoItemRepository(TodoItemRepository):
    def __init__(self, mongo_url: str) -> None: ...

    def add(self, todo: dict) -> None: ...


class SqliteTodoItemRepository(TodoItemRepository):
    def __init__(self) -> None: ...

    def add(self, todo: dict) -> None: ...


container = Container()

Static registration

The most simple case is when you want to statically define which implementation use. Then you'll eventually override this during your tests.

# Individually register your implementations
container.register(SqliteTodoItemRepository).self()
container.register(MongoTodoItemRepository).factory(
    lambda ctx: MongoTodoItemRepository(os.getenv("DB_URL"))
)

# Register an alias of your protocol against your choice
container.register(TodoItemRepository).alias(SqliteTodoItemRepository)  # type: ignore[type-abstract]

with container.open_context() as ctx:
    repo = ctx.resolve(TodoItemRepository)  # type: ignore[type-abstract]
    assert isinstance(repo, SqliteTodoItemRepository)

:warning: Mypy does not like calling the .register and .resolve functions on tyîng.Protocol nor abc.ABC hence the type ignore magic comment.

Runtime registration

There can also be situations where you want to pick implementation at runtime depending on some conditions. For this, you can use a factory that will resolve the correct implementation.

import os

from handless import Singleton, Contextual, ResolutionContext


container.register(SqliteTodoItemRepository).self()
container.register(MongoTodoItemRepository).factory(
    lambda ctx: MongoTodoItemRepository(os.getenv("DB_URL"))
)


@container.factory
def get_todo_item_repository(ctx: ResolutionContext) -> TodoItemRepository:
    db_type = os.getenv("DB_TYPE", "sqlite")
    if db_type == "sqlite":
        return ctx.resolve(SqliteTodoItemRepository)
    if db_type == "mongo":
        return ctx.resolve(MongoTodoItemRepository)
    raise ValueError(f"Unknown database type: {db_type}")

:bulb: Use of the factory decorator is not mandatory. You can achieve the same with the registration API (container.register(...)).

:warning: Most of the time you should use a Transient lifetime (the default) for the factory resolving your abstract or protocol to avoid lifetimes mismatches. Indeed, if you use a Singleton lifetime on get_todo_item_repository while one of your implementation is Transient or Contextual you'll end up with a captive dependency.

Testing

:construction: Under construction

Use with FastAPI

:construction: Under construction

Use with Typer

:construction: Under construction

Add custom lifetime(s)

:construction: Under construction

Q&A

Why requiring having a context object to resolve types instead of using the container directly?

  • Separation of concerns
  • Simpler API
  • Transient dependencies captivity
  • Everything is a context
  • Easier management and release of resolved values

Why using a fluent API to register types as a two step process?

  • type hints limitations

Why using objects for lifetimes? (Why not using enums or literals?)

  • Allow creating its own lifetimes
  • Allows to add options in the future
  • Avoid if statements

Alternatives

Other existing alternatives you might be interested in:

Contributing

Running tests: uv run nox

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

handless-0.2.0.tar.gz (56.6 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

handless-0.2.0-py3-none-any.whl (21.4 kB view details)

Uploaded Python 3

File details

Details for the file handless-0.2.0.tar.gz.

File metadata

  • Download URL: handless-0.2.0.tar.gz
  • Upload date:
  • Size: 56.6 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.8.8

File hashes

Hashes for handless-0.2.0.tar.gz
Algorithm Hash digest
SHA256 1514b9059f6ea90ff72cb278245c35df186517176b3d43e284661e5aad38e740
MD5 ca5055c7b30ac3913a971915936624d7
BLAKE2b-256 d6b960911813a6fe88292fc4e3df2364e2cbd3c94bd882e77f6f3ea5cd400adf

See more details on using hashes here.

File details

Details for the file handless-0.2.0-py3-none-any.whl.

File metadata

  • Download URL: handless-0.2.0-py3-none-any.whl
  • Upload date:
  • Size: 21.4 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.8.8

File hashes

Hashes for handless-0.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 1c679b62da93b126ac6ffb653fa647fba180cfb5a2ac178ad7f0fa648eae8edc
MD5 aa56e3db8ed0dbf24ac3ec707e9f319c
BLAKE2b-256 8c1f1e81097a54397fbfff0c29552cfe8bf110cd38fec08af7c367aaed26240b

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page