Soupape is a dependency injection library for Python, designed to simplify the management of dependencies, keep your codebase clean and promote a modular architecture.
Soupape is what powers Bolinette under the hood, but it requires no framework and can be used in any Python project.
Installation
Soupape has just one external dependency, Peritype, which is used for type inspection. It requires Python 3.12 or above.
$ pip install soupape
Install it through pip or use your favorite package manager.
Typing
Soupape is an opinionated dependency injection library that leverages Python's type hints to automatically resolve and inject dependencies. Any code that runs through Soupape's injectors must be fully type hinted.
Service constructors, for built-in injection, and custom resolvers, must be fully type hinted or Soupape will raise errors at startup or runtime.
Services
Any type that Soupape manages is called a service, whether you let Soupape instantiate it or use a custom resolver to provide it. Services can depend on other services, which will be automatically resolved and injected by Soupape. The dependency chain can be as deep as needed but circular dependencies are not allowed and will raise errors at injection time.
Services must be registered in a ServiceCollection before they
can be resolved by an injector.
from soupape import ServiceCollection
class ServiceA: ...
class ServiceB:
def __init__(self, service_a: ServiceA) -> None:
self.service_a = service_a
services = ServiceCollection()
services.add_singleton(ServiceA)
services.add_scoped(ServiceB)
Soupape supports three service lifetimes: singleton, scoped and transient.
-
Singleton services are registered with
add_singleton().- They are instantiated only once per root injector and the same instance is returned on every resolution, even in scoped injectors.
- They are disposed when the root injector is disposed. If their instantiation is triggered in a scoped injector, they will still be disposed when the root injector is disposed.
- They can only depend on transient and singleton services.
-
Scoped services are registered with
add_scoped().- They cannot be instantiated from the root injector. Their instantiation must be triggered from a scoped injector. Doing so from the root injector will raise an error.
- They are instantiated once per injector scope. Every resolution from the same scope and its child scopes will return the same instance.
- They are disposed when the scoped injector they were instantiated from is disposed.
- They can depend on transient, scoped and singleton services.
-
Transient services are registered with
add_transient().- They are instantiated every time they are resolved, whether from the root injector or a scoped injector.
- They are disposed when the injector they were instantiated from is disposed.
- They can only depend on transient and singleton services.
Injectors
Soupape provides two injectors, AsyncInjector and
SyncInjector. The async injector unlocks the full potential of
Soupape and should be used whenever possible. But the sync injector is also
available for projects that do not use async code.
An injector is created with a service collection as its sole argument.
Injectors implement the context manager protocol, and
should be used with the with statement to ensure
proper resource management. The async injector should be used with
async with.
from soupape import ServiceCollection, AsyncInjector, SyncInjector
class ServiceA: ...
class ServiceB:
def __init__(self, service_a: ServiceA) -> None:
self.service_a = service_a
def define_services() -> ServiceCollection:
services = ServiceCollection()
services.add_singleton(ServiceA)
services.add_singleton(ServiceB)
return services
async def main() -> None:
services = define_services()
# Using the async injector
async with AsyncInjector(services) as injector:
service_b = await injector.require(ServiceB)
# Using the sync injector
with SyncInjector(services) as injector:
service_b = injector.require(ServiceB)
Services are resolved recursively by inspecting their constructors and injecting their dependencies automatically. The instances are created immediately, that is why circular dependencies are not allowed and will raise errors at injection time.
If any service is not registered in the service collection, an error will be
raised at injection time, unless the type hint is marked as optional (e.g.,
Optional[Service] or Service | None). In that case,
None will be injected instead.
Scoped Injectors
Scopes allow you to create child injectors that can instantiate scoped services. This feature is useful to manage lifetimes of services that should be created and disposed of together, such as per-request services in web applications, while keeping the singleton services shared across the entire application lifetime, or any other lifetime management strategy.
A scoped injector is created from a parent injector using the
get_scoped_injector() method.
Like the root injector, scoped injectors should be used as context managers to ensure proper resource management. All scoped services will be disposed of when the scope is exited.
from soupape import ServiceCollection, AsyncInjector, SyncInjector
class ServiceA: ...
class ServiceB:
def __init__(self, service_a: ServiceA) -> None:
self.service_a = service_a
def define_services() -> ServiceCollection:
services = ServiceCollection()
services.add_singleton(ServiceA)
services.add_scoped(ServiceB)
return services
async def main() -> None:
services = define_services()
# Using the async injector
async with AsyncInjector(services) as injector:
async with injector.get_scoped_injector() as scoped_injector:
service_b = await scoped_injector.require(ServiceB)
# Using the sync injector
with SyncInjector(services) as injector:
with injector.get_scoped_injector() as scoped_injector:
service_b = scoped_injector.require(ServiceB)
When using multi-level scopes, be careful to which injector you are using to resolve services. Scoped services are unique to their scope and its childs. Resolving a scoped service from a parent injector will not return the same instance as resolving it from a child scoped injector, but a scoped injector can resolve scoped services from its parent scopes, even as dependencies of other services.
Built-in injection
When registering a service in the collection with its type, Soupape will manage the injection of its dependencies automatically, based on the type hints of its constructor. The injector will also execute a series of lifetime management instructions, described bellow.
Context manager services
If a service implements the context manager protocol (i.e., it has
__enter__ and __exit__ methods), or the async
context manager protocol (i.e., it has __aenter__ and
__aexit__ methods), the built-in resolver will automatically use
the appropriate protocol to manage the service's lifetime.
__enter__ or __aenter__ will be called when the
service is instantiated, and __exit__ or
__aexit__ will be called when the injector or scope managing the
service is exited.
SyncInjector will only manage services implementing the context
manager protocol and will ignore the async context manager protocol.
AsyncInjector can manage both protocols, but will prefer the
async context manager protocol and ignore the synchronous one if both are
implemented.
from typing import Self
from soupape import ServiceCollection, AsyncInjector
class AsyncService:
async def __aenter__(self) -> Self:
print("Entering async service")
return self
async def __aexit__(self, exc_type, exc_value, traceback) -> None:
print("Exiting async service")
def define_services() -> ServiceCollection:
services = ServiceCollection()
services.add_singleton(AsyncService)
return services
async def main() -> None:
services = define_services()
async with AsyncInjector(services) as injector:
async_service = await injector.require(AsyncService)
In this example, when the injector is entered, the
AsyncService.__aenter__ method is called, and when the injector
is exited, the AsyncService.__aexit__ method is called.
Post-initialization methods
When using the built-in resolver, you can decorate a method with
@post_init to have it called automatically after the service is
instantiated and all its dependencies are injected. These methods will be
called in the order they are defined in the class, from base classes to
derived classes.
Asynchronous methods will be awaited automatically when using
AsyncInjector, but SyncInjector will raise an error
if a @post_init method is async.
Post-initialization methods are a good place to put initialization logic in an orderly manner, especially if you need to use a dependency that is not required in the other parts of the class. Contrary to context manager that must use the dependencies injected in the constructor, post-initialization methods can use any dependency declared as a parameter.
These methods are called after the context manager methods, if any.
Keep in mind that post-initialization methods are called during instantiation, so the circular dependency restrictions still apply.
from soupape import ServiceCollection, AsyncInjector, post_init
class ServiceA:
def __init__(self) -> None:
self.initialized = False
@post_init
async def _initialize(self) -> None:
print("Initializing ServiceA")
self.initialized = True
class ServiceB:
def __init__(self, service_a: ServiceA) -> None:
self.service_a = service_a
@post_init
def _check_service_a(self) -> None:
print(f"ServiceA initialized: {self.service_a.initialized}")
def define_services() -> ServiceCollection:
services = ServiceCollection()
services.add_singleton(ServiceA)
services.add_singleton(ServiceB)
return services
async def main() -> None:
services = define_services()
async with AsyncInjector(services) as injector:
service_b = await injector.require(ServiceB)
Prefixing methods with an underscore is a common convention to indicate that they are intended for internal use only. Post-initialization methods are not part of the public API of the class and should not be called from outside. Ideally, they should not be called directly at all and should only be used by the injection system.
Custom resolvers
Coming soon
Function calling
Coming soon