Skip to main content

A small Python dependency injection container

Project description

Maintainability Absolute coverage and coverage changes GitHub Workflow Actions Status Supported Python versions Wheel Downloads Requirements Status License: MIT

Overview

Dependency Injection is a technique in which an object receives other objects that it depends on, called dependencies. Typically, the receiving object is called a client and the passed-in (‘injected’) object is called a service. The code that passes the service to the client is called the injector. Instead of the client specifying which service it will use, the injector tells the client what service to use. The ‘injection’ refers to the passing of a dependency (a service) into the client that uses it.

Dependency injection solves the following problems:

  • How can a class be independent of how the objects on which it depends are created?

  • How can the way objects are created be specified in separate configuration files?

  • How can an application support different configurations?

Creating objects directly within the class commits the class to particular implementations. This makes it difficult to change the instantiation at runtime, especially in compiled languages where changing the underlying objects can require re-compiling the source code.

Dependency injection separates the creation of a client’s dependencies from the client’s behavior, which promotes loosely coupled programs and the dependency inversion and single responsibility principles. Fundamentally, dependency injection is based on passing parameters to a method.

Dependency injection is an example of the more general concept of inversion of control.

Roles

Dependency injection involves four roles:

  • the service objects to be used

  • the client object, whose behavior depends on the services it uses

  • the interfaces that define how the client may use the services

  • the injector, which constructs the services and injects them into the client

Any object that may be used can be considered a service. Any object that uses other objects can be considered a client. The names relate only to the role the objects play in an injection.

The interfaces are the types the client expects its dependencies to be. The client should not know the specific implementation of its dependencies, only know the interface’s name and API. As a result, the client will not need to change even if what is behind the interface changes. Dependency injection can work with true interfaces or abstract classes, but also concrete services, though this would violate the dependency inversion principle and sacrifice the dynamic decoupling that enables testing. It is only required that the client never treats its interfaces as concrete by constructing or extending them. If the interface is refactored from a class to an interface type (or vice versa) the client will need to be recompiled. This is significant if the client and services are published separately.

The injector introduces services to the client. Often, it also constructs the client. An injector may connect a complex object graph by treating the same object as both a client at one point and as a service at another. The injector itself may actually be many objects working together, but may not be the client (as this would create a circular dependency). The injector may be referred to as an assembler, provider, container, factory, builder, spring, or construction code.

Pros

A basic benefit of dependency injection is decreased coupling between classes and their dependencies. By removing a client’s knowledge of how its dependencies are implemented, programs become more reusable, testable and maintainable.

This also results in increased flexibility: a client may act on anything that supports the intrinsic interface the client expects.

Many of dependency injection’s benefits are particularly relevant to unit-testing.

For example, dependency injection can be used to externalize a system’s configuration details into configuration files, allowing the system to be reconfigured without recompilation. Separate configurations can be written for different situations that require different implementations of components. This includes testing. Similarly, because dependency injection does not require any change in code behavior it can be applied to legacy code as a refactoring. The result is clients that are more independent and that are easier to unit test in isolation using stubs or mock objects that simulate other objects not under test. This ease of testing is often the first benefit noticed when using dependency injection.

More generally, dependency injection reduces boilerplate code, since all dependency creation is handled by a singular component.

Finally, dependency injection allows concurrent development. Two developers can independently develop classes that use each other, while only needing to know the interface the classes will communicate through. Plugins are often developed by third party shops that never even talk to the developers who created the product that uses the plugins.

Cons

Creates clients that demand configuration details, which can be onerous when obvious defaults are available.

Make code difficult to trace because it separates behavior from construction.

Is typically implemented with reflection or dynamic programming. This canhinder IDE automation.

Typically requires more upfront development effort.

Forces complexity out of classes and into the links between classes which might be harder to manage.

Encourage dependence on a framework.

Installation

Before using mediapills.dependency_injection in your project, add it to your requirements.txt file:

$ echo "-e git+ssh://git@github.com/mediapills/dependency_injection.git@0.0.2#egg=mediapills.dependency_injection" >> requirements.txt

or

$ echo "mediapills.dependency_injection==0.0.2" >> requirements.txt

Usage

Creating a injector is a matter of creating a Injector instance:

from mediapills.dependency_injection import Injector

injector = Injector()

As many other dependency injection containers, mediapills.dependency_injection manages two different kind of data: services and parameters.

Defining Services

A service is an object that does something as part of a larger system. Examples of services: a database connection, a templating engine, or a mailer. Almost any object can be a service.

Services are defined by anonymous functions that return an instance of an object:

# define some services
injector['session_storage'] = lambda di: (
    SessionStorage('SESSION_ID')
)

injector['session'] = lambda di: (
    Session(di['session_storage'])
)

Notice that the anonymous function has access to the current injector instance, allowing references to other services or parameters.

As objects are only created when you get them, the order of the definitions does not matter.

Using the defined services is also very easy:

# get the session object
session = injector['session']

# the above call is roughly equivalent to the following code:
# storage = SessionStorage('SESSION_ID')
# session = Session(storage)

Defining Factory Services

By default, each time you get a service, Injector returns the same instance of it. If you want a different instance to be returned for all calls, wrap your anonymous function with the factory() method

injector['session'] = injector.factory(lambda di: (
    Session(di['session_storage'])
))

Now, each call to injector['session'] returns a new instance of the session.

Defining Parameters

Defining a parameter allows to ease the configuration of your container from the outside and to store global values:

# define some parameters
injector['cookie_name'] = 'SESSION_ID'
injector['session_storage_cls'] = SessionStorage

If you change the session_storage service definition like below:

injector['session_storage'] = lambda di: (
    di['session_storage_cls'](di['cookie_name'])
)

You can now easily change the cookie name by overriding the cookie_name parameter instead of redefining the service definition.

Protecting Parameters

Because Pimple sees anonymous functions as service definitions, you need to wrap anonymous functions with the protect() method to store them as parameters:

injector['random_func'] = lambda i: rand()
injector.protect('random_func')

Modifying Services after Definition

In some cases you may want to modify a service definition after it has been defined. You can use the extend() method to define additional code to be run on your service just after it is created:

injector['session_storage'] = lambda di: (
    di['session_storage_class'](di['cookie_name'])
)

def session_storage_ext(storage: Callable, di: Injector):
    # Do something with base storage using di

    return storage

injector.extend('session_storage', session_storage_ext)

The first argument is the name of the service to extend, the second a function that gets access to the object instance and the container.

Fetching the Service Creation Function

When you access an object, Injector automatically calls the anonymous function that you defined, which creates the service object for you. If you want to get raw access to this function, you can use the raw() method:

injector['session'] = lambda di: (
    Session(di['session_storage'])
)

sessionFunction = container.raw('session')

Project details


Download files

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

Source Distributions

No source distribution files available for this release.See tutorial on generating distribution archives.

Built Distribution

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