Antidote

https://img.shields.io/pypi/v/antidote.svg https://img.shields.io/pypi/l/antidote.svg https://img.shields.io/pypi/pyversions/antidote.svg https://github.com/Finistere/antidote/actions/workflows/main.yml/badge.svg?branch=master https://codecov.io/gh/Finistere/antidote/branch/master/graph/badge.svg https://readthedocs.org/projects/antidote/badge/?version=latest

Antidotes is a dependency injection micro-framework for Python 3.7+. It is built on the idea of ensuring best maintainability of your code while being as easy to use as possible. It also provides the fastest injection with @inject allowing you to use it virtually anywhere and fast full isolation of your tests.

Antidote provides the following features:

  • Ease of use
    • Injection anywhere you need through a decorator @inject, be it static methods, functions, etc.. By default, it will only rely on annotated type hints, but it supports a lot more!

    • No **kwargs arguments hiding actual arguments and fully mypy typed, helping you and your IDE.

    • Documented, everything has tested examples.

    • No need for any custom setup, just use your injected function as usual. You just don’t have to specify injected arguments anymore. Allowing you to gradually migrate an existing project.

  • Flexibility
    • Most common dependencies out of the box: services, configuration, factories, interface/implementation.

    • All of those are implemented on top of the core implementation. If Antidote doesn’t provide what you need, there’s a good chance you can implement it yourself.

    • Scope support

    • Async injection

  • Maintainability
    • All dependencies can be tracked back to their declaration/implementation easily.

    • Mypy compatibility and usage of type hints as much as possible.

    • Overriding dependencies will raise an error outside of tests.

    • Dependencies can be frozen, which blocks any new declarations.

    • No double injection.

    • Everything is as explicit as possible, @inject does not inject anything implicitly.

    • Type checks when a type is explicitly defined with world.get and constants.

    • Thread-safe, cycle detection.

    • Immutable whenever possible.

  • Testability
    • @inject lets you override any injections by passing explicitly the arguments.

    • Fully isolate each test with world.test.clone. They will work on separate objects.

    • Override globally any dependency locally in a test.

    • When encountering issues you can retrieve the full dependency tree, nicely formatted, with world.debug.

  • Performance*
    • Fastest @inject with heavily tuned Cython.

    • As much as possible is done at import time.

    • Testing utilities are tuned to ensure that even with full isolation it stays fast.

    • Benchmarks: comparison, injection, test utilities

*with the compiled version, in Cython. Pre-built wheels for Linux. See further down for more details.

Comparison benchmark image

Installation

To install Antidote, simply run this command:

pip install antidote

Documentation

Beginner friendly tutorial, recipes, the reference and a FAQ can be found in the documentation.

Here are some links:

Issues / Questions

Feel free to open an issue on Github for questions or issues !

Hands-on quick start

Showcase of the most important features of Antidote with short and concise examples. Checkout the Getting started for a full beginner friendly tutorial.

Injection

from antidote import inject, injectable

@injectable
class Database:
    pass

@inject
def f(db: Database = inject.me()):
    return db

assert isinstance(f(), Database)  # works !

Simple, right ? And you can still use it like a normal function, typically when testing it:

f(Database())

.inject here used the marker inject.me() with the help of the type hint to determine the dependency. But it also supports the following ways to express the dependency wiring:

  • annotated type hints:
    from antidote import Inject
    
    @inject
    def f(db: Inject[Database]):
        pass
    
  • list (matching argument position):
    @inject([Database])
    def f(db):
        pass
    
  • dictionary:
    @inject({'db': Database})
    def f(db):
        pass
    
  • optional dependencies:
    from typing import Optional
    
    class Dummy:
        pass
    
    # When the type_hint is optional and a marker like `inject.me()` is used, None will be
    # provided if the dependency does not exists.
    @inject
    def f(dummy: Optional[Dummy] = inject.me()):
        return dummy
    
    assert f() is None
    

You can also retrieve the dependency by hand with world.get:

from antidote import world

# Retrieve dependencies by hand, in tests typically
world.get(Database)
world.get[Database](Database)  # with type hint, enforced when possible

Injectable

Any class marked as @injectable can be provided by Antidote. It can be a singleton or not. Scopes and a factory method are also supported. Every method is injected by default, relying on annotated type hints and markers such as inject.me():

from antidote import injectable, inject

@injectable(singleton=False)
class QueryBuilder:
    # methods are also injected by default
    def __init__(self, db: Database = inject.me()):
        self._db = db

@inject
def load_data(builder: QueryBuilder = inject.me()):
    pass

load_data()  # yeah !

Constants

Constants can be provided lazily by Antidote:

from antidote import inject, Constants, const

class Config(Constants):
    DB_HOST = const('localhost')

@inject
def ping_db(db_host: str = Config.DB_HOST):
    pass

ping_db()  # nice !

This feature really shines when your constants aren’t hard-coded:

from typing import Optional
from antidote import inject, Constants, const

class Config(Constants):
    # Like world.get, a type hint can be provided and is enforced.
    DB_HOST = const[str]()
    DB_PORT = const[int]()
    DB_USER = const[str](default='postgres')  # default is used on LookupError

    # name of the constant and the arg given to const() if any.
    def provide_const(self, name: str, arg: Optional[object]):
        return os.environ[name]

import os
os.environ['DB_HOST'] = 'localhost'
os.environ['DB_PORT'] = '5432'

@inject
def check_connection(db_host: str = Config.DB_HOST,
                     db_port: int = Config.DB_PORT,
                     db_user: str = Config.DB_USER):
    pass

check_connection()  # perfect !

Note that on the injection site, nothing changed!

Factory

Factories are used by Antidote to generate a dependency, typically a class from an external code:

from antidote import factory, inject

class User:
    pass

@factory(singleton=False)  # function is injected by default
def current_user(db: Database = inject.me()) -> User:
    return User()

# Consistency between the type hint and the factory result type hint is enforced.
@inject
def is_admin(user: User = inject.me(source=current_user)):
    pass

While it’s a bit verbose, you always know how the dependency is created. Obviously you can retrieve it from world:

from antidote import world

world.get(User, source=current_user)

Interface/Implementation

Antidote also works with interfaces which can have one or multiple implementations

from typing import Protocol, TypeVar

from antidote import implements, inject, interface, world


class Event:
    ...


class InitializationEvent(Event):
    ...


E = TypeVar('E', bound=Event, contravariant=True)


@interface  # can be applied on protocols and "standard" classes
class EventSubscriber(Protocol[E]):
    def process(self, event: E) -> None:
        ...


# Ensures OnInitialization is really a EventSubscriber if possible
@implements(EventSubscriber).when(qualified_by=InitializationEvent)
class OnInitialization:
    def process(self, event: InitializationEvent) -> None:
        ...


@inject
def process_initialization(event: InitializationEvent,
                           # injects all subscribers qualified by InitializationEvent
                           subscribers: list[EventSubscriber[InitializationEvent]] \
                                   = inject.me(qualified_by=InitializationEvent)
                           ) -> None:
    for subscriber in subscribers:
        subscriber.process(event)


event = InitializationEvent()
process_initialization(event)
process_initialization(
    event,
    # Explicitly retrieving the subscribers
    subscribers=world.get[EventSubscriber].all(qualified_by=InitializationEvent)
)

Implementations can be can be retrieved in multiple ways:

# When you want to retrieve a single implementation matching your constraints
@inject
def f(subscriber: EventSubscriber[InitializationEvent] \
              = inject.me(qualified_by=InitializationEvent)
      ) -> EventSubscriber[InitializationEvent]:
    return subscriber


assert world.get[EventSubscriber].single(qualified_by=InitializationEvent) is f()

# When there's only one implementation
@inject
def f2(subscriber: EventSubscriber[InitializationEvent] = inject.me()
      ) -> EventSubscriber[InitializationEvent]:
    return subscriber


assert world.get(EventSubscriber) is f2()

Testing and Debugging

inject always allows you to pass your own argument to override the injection:

from antidote import injectable, inject

@injectable
class Database:
    pass

@inject
def f(db: Database = inject.me()):
    pass

f()
f(Database())  # test with specific arguments in unit tests

You can also fully isolate your tests from each other and override any dependency within that context:

from antidote import world

# Clone current world to isolate it from the rest
with world.test.clone():
    x = object()
    # Override the Database
    world.test.override.singleton(Database, x)
    f()  # will have `x` injected for the Database

    @world.test.override.factory(Database)
    def override_database():
        class DatabaseMock:
            pass

        return DatabaseMock()

    f()  # will have `DatabaseMock()` injected for the Database

If you ever need to debug your dependency injections, Antidote also provides a tool to have a quick summary of what is actually going on:

def function_with_complex_dependencies():
    pass

world.debug(function_with_complex_dependencies)
# would output something like this:
"""
function_with_complex_dependencies
└── Permanent implementation: MovieDB @ current_movie_db
    └──<∅> IMDBMovieDB
        └── ImdbAPI @ imdb_factory
            └── imdb_factory
                ├── Config.IMDB_API_KEY
                ├── Config.IMDB_PORT
                └── Config.IMDB_HOST

Singletons have no scope markers.
<∅> = no scope (new instance each time)
<name> = custom scope
"""

Hooked ? Check out the documentation ! There are still features not presented here !

Compiled

The compiled implementation is roughly 10x faster than the Python one and strictly follows the same API than the pure Python implementation. Pre-compiled wheels are available only for Linux currently. You can check whether you’re using the compiled version or not with:

from antidote import is_compiled

f"Is Antidote compiled ? {is_compiled()}"

You can force the compilation of antidote yourself when installing:

ANTIDOTE_COMPILED=true pip install antidote

On the contrary, you can force the pure Python version with:

pip install --no-binary antidote

Note

The compiled version is not tested against PyPy. The compiled version relies currently on Cython, but it is not part of the public API. Relying on it in your own Cython code is at your risk.

How to Contribute

  1. Check for open issues or open a fresh issue to start a discussion around a feature or a bug.

  2. Fork the repo on GitHub. Run the tests to confirm they all pass on your machine. If you cannot find why it fails, open an issue.

  3. Start making your changes to the master branch.

  4. Writes tests which shows that your code is working as intended. (This also means 100% coverage.)

  5. Send a pull request.

Be sure to merge the latest from “upstream” before making a pull request!

If you have any issue during development or just want some feedback, don’t hesitate to open a pull request and ask for help !

Pull requests will not be accepted if:

  • public classes/functions have not docstrings documenting their behavior with examples.

  • tests do not cover all of code changes (100% coverage) in the pure python.

If you face issues with the Cython part of Antidote, I may implement it myself.

Table of Content