TL;DR

In order to keep your code clean and testable, use Dependency Injection. In order to keep the readability, move at least to Python 3.6 and use type annotations. Afterwards simplify your code using the Injector framework.

Dependency Injection is a well-known practice among programmers. When properly applied, various parts of an application become less coupled, and modules become reusable and easier to maintain and test. But all of this comes at some cost, and one of the biggest disadvantages of the DI is the decrease in code readability. This is particularly painful in the case of dynamic languages like Python, where types are not explicitly declared. For developers, it means jumping between dozens of files to trace the dependencies. This is a frustrating and time consuming process, especially for a developer that has just recently begun working on a project.

In the beginning there was Knot

The team I work in at WI is one of the most recently formed, and from the very beginning we have paid a lot of attention to having code that is as clean and well-tested as possible. We couldn't achieve that without injecting dependencies. The first container we chose was Knot, a small but nice piece of software that uses strings as keys for retrieving dependencies. Internally it is just an extension of the basic Python dictionary.

Here you can see an example of how dependencies are managed using Knot:

import knot


class DependencyFoo:
    pass

class DependencyBar:
    pass

class ServiceBaz:
    def __init__(self, foo, bar):
        pass

def foo(c):
    return DependencyFoo()

def bar(c):
    return DependencyBar()

def baz(c):
    return ServiceBaz(c('foo'), c('bar'))

c = knot.Container()

c.add_service(foo)

c.add_service(bar)

c.add_service(baz)

c('baz')
<__main__.ServiceBaz at 0x7f44900e2940>

Unfortunately, as our project grew in size, the limitations of Knot became more and more apparent. The string-based resolving of dependencies did not scale as the keys grew in length and were distributed among several configuration files. The example beneath illustrates how the configuration looked.

#repositories.py

def user_repository(c):
    return UserRepository(
        c['settings']['db_settings']
    )


def register(c):
    c.add_service(user_repository, 'repositories.user')


#  user.py
def create_user_service(c):
    return CreateUserServiece(
        c('respositories.user'),
        c('notifiers.user_added')
    )


def register(c):
    c.add_service(create_user_service, 'services.user.add')

The length of the keys and tracing the dependencies were not the only problems we were facing; the other issue was refactoring. For example, if we wanted to change the name of the service from CreateUserService to CreateMameberService, we had also to change the names of the keys for consistency. This added an often-forgotten manual step to the process. All of these issues made the management of the dependencies more and more time-consuming and frustration began to be felt among the team members.

Types to the rescue!

The first problem we solved was the tracing of the dependencies. We simply wanted to know what was injected into our classes. We realized type annotations added in Python 3.6 could be a solution to the problem so as soon as we began a new project, we decided to update Python to version 3.6 and started to add type annotations to the public APIs of our classes. After that our code looked like this:

class CreateUserService:
    def __init__(
            self, repository: UserRepository,
            notification_service: UserNotificationService
    ) -> None:
        pass

Simpler configuration with Injector!

After adding the type hints we were freed from tracing our dependencies manually. However, as previously mentioned, there was yet another problem to solve: the configuration files. For a medium-sized project like ours, the configuration code already stretched to a few hundred lines, which is rather a lot for configuration code, and therefore we decided to seek out something that would simplify the process of building dependencies. What we found was Injector, a framework that can inject dependencies using type annotations.

A simple example of Injector

from injector import inject, Injector


class DependencyFoo:
    def value(self) -> int:
        return 3


class DependencyBar:
    def value(self) -> int:
        return 5


class ServiceBaz:

    @inject
    def __init__(self, foo: DependencyFoo, bar: DependencyBar):
        self.foo = foo
        self.bar = bar

    def value(self):
        return self.foo.value() + self.bar.value()


injector = Injector()

injector.get(ServiceBaz).value()

Additionally, thanks to Injector's Module class, we were able to structure our dependencies into logical units. The following code-sample, based both on our experience and the example shown on Injector's site, shows the concept:

import sqlite3

import injector


Settings = type('Settings', (dict,), {})
DBSettings = type('Settings', (dict,), {})


class DBRepository:

    def __init__(self, db_connection: sqlite3.Connection) -> None:
        self._db_connection = db_connection

    def get(self):
        cursor = self._db_connection.cursor()
        cursor.execute(
            'SELECT first_name, last_name FROM users ORDER by first_name')
        return cursor.fetchall()


class DBModule(injector.Module):

    @injector.provider
    @injector.singleton
    @injector.inject
    def _provide_db_connection(
            self, db_settings: DBSettings) -> sqlite3.Connection:
        conn = sqlite3.connect(db_settings['db_connection_string'])
        cursor = conn.cursor()
        cursor.execute(
            (
                'CREATE TABLE IF NOT EXISTS users'
                '(first_name PRIMARY KEY, last_name)'

            ))
        cursor.execute(
            'INSERT OR REPLACE INTO users VALUES ("Adam", "Smith")')
        return conn

    @injector.provider
    @injector.singleton
    @injector.inject
    def provide_repository(
            self, connection: sqlite3.Connection) -> DBRepository:
        return DBRepository(connection)


class Main(injector.Module):
    def __init__(self, db_connection_string: str):
        self._db_connection_string = db_connection_string

    def configure(self, binder: injector.Binder) -> None:
        binder.install(DBModule)

    @injector.provider
    def db_settings(self) -> DBSettings:
        return DBSettings(db_connection_string=self._db_connection_string)

inj = injector.Injector(Main(':memory:'))
inj.get(DBRepository).get()
Out: [('Adam', 'Smith')]

Thanks to Injector we managed to make the process of instantiating our dependencies more automatic. Additionally, we got rid of the string keys, which meant simpler refactoring.

Conclusion

Dependency injection is highly important to any serious IT-project, therefore developers should pay high attention when choosing a container framework. As I hopefully have shown, Injector can move the whole concept of DI in Python one step further by using type annotations. However, that is not the only way in which your project can benefit from type annotations. They can be also be of use in checking types while running a test. But that is a topic for another article...