Skip to main content

Simple Python3 dependency injector

Project description

Synthol

Juice up your dependencies 💉

Introduction

synthol is a lightweight, asynchronous framework for dependency injection in Python 3. It populates a typed constructor with instances of all of its non-primitive arguments. To do this, it first discovers all of the classes contained in some packages, and then tracks down implementations for each class and instantiates them.

The advantage of using this framework is that a developer can define a strongly-typed constructor for a class without concerning themselves with where and how the class and its dependencies should be instantiated. This reduces the overhead (in boilerplate, time, etc.) associated with using the composite pattern and decouples interface implementations.

Synthol is useful for highly modular applications. Some motivating use cases might be as follows:

  • A microservices-based application, where there may be a large number of clients and services that need to interact with other services. As an alternative to each service/client creating a specific instance of its dependencies (resulting in tight coupling between service implementations) or repetitive, factory-like boilerplate, each client/service simply defines what type of services it relies on in its constructor, and the injector manages creating and providing instances of these to each constructor.
  • An application that has some "registration" procedure for implementations of an interface. There are multiple ways to accomplish this, possibly requiring a "register" call for every implementation. Using the dependency injector requires very little extra code: simply a call to discover modules where implementations live and a call to get_instance of a list of interface implementations. This scales favorably when more implementations are added or more interfaces need their implementations registered.
  • Mocking services for unit testing is easy with the dependency injector, since service instantiation for an application is already centralized in the injector. Binding the mocked interfaces to mock implementations is all that is required, and then an application using the injector will get the mocked implementations where the interface is needed.
  • Simplifying a big block of code which sets up a context for an application by instantiating several required services.

Install

Install Synthol with pip.

python3 -m pip install synthol

Zoo Example

"Foo" is overdone, so let's create a Zoo:

from synthol import DependencyInjector
from animal_api import CatInterface, DogInterface, KangarooInterface
import animal_api_implementations

class Zoo:
    def __init__(
        self,
        cat: CatInterface,
        dog: DogInterface,
        kangaroo: KangarooInterface,
    ):
        self.cat = cat
        self.dog = dog
        self.kangaroo = kangaroo


injector = DependencyInjector()
injector.discover(animal_api_implementations)

...
# In an async function...
    zoo = await injector.get_instance(Zoo)

This discovers all classes defined in animal_api_implementations when discover is called. Then, when get_instance is called, the injector searches those known classes for implementations of CatInterface, DogInterface, and KangarooInterface. Let's assume it finds one of each. Then each of those concrete implementations will be instantiated. But what if there are multiple possible implementations?

Binding a Specific Implementation for an Interface

In order to guarantee our zoo has the most amazing animals, we can be more specific about what kind of animals to include. For example, rather than accepting any old CatInterface implementation, let's specify that we want a Chimera.

from synthol import DependencyInjector
from animal_api import CatInterface, DogInterface, KangarooInterface
import animal_api_implementations
from animal_api_implementations import Chimera

class Zoo:
    def __init__(
        self,
        cat: CatInterface,
        dog: DogInterface,
        kangaroo: KangarooInterface,
    ):
        self.cat = cat
        self.dog = dog
        self.kangaroo = kangaroo


injector = DependencyInjector()
injector.discover(animal_api_implementations)
injector.bind_factory(Chimera)

The addition of the bind_factory specifies that the Chimera class should be used when any of the interfaces it implements (CatInterface) are required. Notice that this required no change to our Zoo class; all we touched was the setup for the injector.

See the Providers section for details on the different ways to bind implementations (or instances) for interfaces.

Recursive Instantiation

Peeking at the source code for Chimera, we see that Chimera itself has some dependencies:

from example_api import CatInterface, GoatInterface, SnakeInterface

class Chimera(CatInterface):
    def __init__(
        self,
        goat: GoatInterface,
        snake: SnakeInterface,
    ):
        self.goat = goat
        self.snake = snake

    ...

Since a Chimera has the body and head of a lion, with a second head of a goat on its back, and whose tail is the head and body of a snake, it is only logical that it requires instances of GoatInterface and SnakeInterface (explaining the use of composition over inheritance in this case is left as an exercise for the reader).

The injector will find implementations of each one and instantiate objects to pass to the Chimera constructor so that an instance of Chimera can be passed to the Zoo constructor. This would be true even if we did not specifically tell the injector to use Chimera; that is, the injector will search for implementations for any interfaces required by whichever implementations need to be instantiated for the top level get_instances.

List Types

So far, the zoo has exactly one instance of each of the interfaces discovered. But what if we (naturally) want some more variety and want to set up a whole exhibit of all known types of cats? The framework lets us do this easily:

from synthol import DependencyInjector
from animal_api import CatInterface, DogInterface, KangarooInterface
import animal_api_implementations
from typing import List

class Zoo:
    def __init__(
        self,
        cats: List[CatInterface],
        dog: DogInterface,
        kangaroo: KangarooInterface,
    ):
        self.cats = cats
        self.dog = dog
        self.kangaroo = kangaroo


injector = DependencyInjector()
injector.discover(animal_api_implementations)

...
# In an async function...
    zoo = await injector.get_instance(Zoo)

Now, rather than looking for exactly one implementation of CatInterface, the injector will collect all implementations, create an instance for each one, and pass all of these to the constructor as a list.

The dependency injector only understands typing.List for this functionality! For example, typing cats with Set[CatInterface] will raise an error.

Union Types

Let's now imagine that we aren't able to actually find an implementation for KangarooInterface (kangaroos are rather rare, after all). We can make the constructor more lenient about what animal becomes part of our zoo:

from synthol import DependencyInjector
from animal_api import CatInterface, DogInterface, KangarooInterface, RabbitInterface
import animal_api_implementations
from typing import Union

class Zoo:
    def __init__(
        self,
        cats: List[CatInterface],
        dog: DogInterface,
        kangaroo: Union[KangarooInterface, RabbitInterface],
    ):
        self.cats = cats
        self.dog = dog
        self.kangaroo = kangaroo


injector = DependencyInjector()
injector.discover(animal_api_implementations)

...
# In an async function...
    zoo = await injector.get_instance(Zoo)

The dependency injector will now search not only for implementations of KangarooInterface but also for implementations of RabbitInterface. It only needs to find one implementation, which will be instantiated and passed to the constructor.

This behavior is useful when the possible types for an argument may not have a common class ancestor.

The dependency injector only understands typing.Union for this functionality!

Optional Types

Alternatively, we could say the kangaroo is entirely optional:

from synthol import DependencyInjector
from animal_api import CatInterface, DogInterface, KangarooInterface
import animal_api_implementations
from typing import Optional

class Zoo:
    def __init__(
        self,
        cats: List[CatInterface],
        dog: DogInterface,
        kangaroo: Optional[KangarooInterface],
    ):
        self.cats = cats
        self.dog = dog
        self.kangaroo = kangaroo


injector = DependencyInjector()
injector.discover(animal_api_implementations)

...
# In an async function...
    zoo = await injector.get_instance(Zoo)

In this case, if no implementation for KangarooInterface is found, then None will be passed to the Zoo constructor.

The dependency injector only understands typing.Optional for this functionality!

Singletons

The get_instance method has an optional boolean argument, existing_instance (which defaults to False). If this argument is True, and if a valid instance for the given interface has already been provided, the instance will be re-used and returned a second time. This is useful for implementing the singleton pattern. If this argument is set to False, a unique instance of each interface will created and provided every time the interface is requested.

Providers

Providers (inheriting from AbstractInstanceProvider) handle the creation of new instances when they are requested. Each provider can provide instances of one or more interfaces, and may depend on getting instances of other interfaces.

It is uncommon for a user to have to interact with any providers directly. The DependencyInjector exposes three methods that offer some control over the injector's providers: bind_instance, bind_factory, and bind.

Note that each of these methods binds a provider for one or more interfaces. The provider supplies an instance of an interface, and is unrelated to the existing_instance argument to get_instance. Once a provider has supplied an instance of an interface, if existing_instance is True, that instance will be used, and the provider will not be asked for that interface again. If existing_instance is False, the provider will be asked for a new instance each time. However, the provider is allowed to return the same object for multiple queries. Using this machinery, there are multiple ways to implement the singleton pattern.

bind_instance

bind_instance allows the user to directly bind an object, such that any request for an instance of that type returns this object. For example:

# Skipping imports
# [ ... ]

class A:
    pass

class B(A):
    pass

class C(B):
    pass

class D(C):
    pass

# Skipping injector setup
# [ ... ]

c_singleton_instance = C()
injector.bind_instance(c_singleton_instance)

# In async function:
# [ ... ]
    await injector.get_instance(C)  # returns c_singleton_instance
    await injector.get_instance(B)  # returns c_singleton_instance
    await injector.get_instance(A)  # returns c_singleton_instance
    await injector.get_instance(D)  # returns new instance of D

bind_factory

bind_factory allows the user to bind a Callable (or AsyncCallable) object, which returns an instance of the desired interface T when an instance of T is required.

T is determined by the type of the callable which is passed to bind_factory. If the callable is a function, T is the return type of the function. If the callable is a class, T is that class itself. bind_factory also accepts arbitrary keyword arguments, which will be passed to the factory each time it is called.

Pseudocode usage example:

class A:
    def __init__(self, x: int):
        ...

injector.bind_factory(A, x=5)

# always calls A with x set to 5
await injector.get_instance(A)

global_ticker = 0
def create_a_instance() -> A:
    global global_ticker
    return A(global_ticker)
injector.bind_factory(A, create_a_instance)

# returns A with x set to whatever the value of global_ticker is at time of instantiation
await injector.get_instance(A)


class B:
    def __init__(self, a_instance: A, other_arg: int):
        ...

injector.bind_factory(B, other_arg=10)

# returns an instance of B with other_arg set to 10, and a_instance populated by dependency
injection
await injector.get_instance(B)

bind

bind is the most open-ended option. The user can create their own instance of an AbstractInstanceProvider (or one of its implementations) and bind it here. That abstract class has abstract methods like get_dependencies and create_instance which must be defined. It would be very unusual to need to call bind directly like this, as bind_instance or bind_factory should be sufficient for most use cases. Because it is such an unlikely case, creating a provider this way is out of scope for this README.

bind_factory and bind_instance are implemented using bind.

Contributing & Project Status

We will happily accept merge requests for code and/or documentation from the community!

Red Balloon uses Synthol both internally, and as a dependency of other open source projects. As such, the project is actively maintained, even if there are no recent commits.

Running tests

The following commands will run the tests:

make develop  # or make install-test
make test

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

synthol-0.1.1.tar.gz (19.7 kB view hashes)

Uploaded Source

Built Distribution

synthol-0.1.1-py3-none-any.whl (15.8 kB view hashes)

Uploaded Python 3

Supported by

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