Getting started

This is a beginner friendly tutorial on how to use Antidote. It is a series of steps to show what can be done easily. Note that Antidote can do a lot more than presented here, don’t hesitate to check out the recipes and references for more in depth documentation.

1. Introduction

Let’s start with the basics and define a simple class that can be injected, an injectable():

from antidote import inject, injectable

@injectable
class Database:
    ...

@inject
def load_database(db: Database = inject.me()) -> Database:
    # doing stuff
    return db

Now you don’t need to provide Database to do_stuff() anymore!

>>> load_database()
<Database object at ...>

By default injectable() declares singletons, meaning there’s only one instance of Database:

>>> load_database() is load_database()
True

You can override the injected dependency explicitly, which is particularly useful for testing:

>>> load_database(Database())
<Database object at ...>

Note

Antidote provides more advanced testing mechanism which are presented in a later section.

Lastly but not the least, world.get can also retrieve dependencies:

>>> from antidote import world
>>> world.get(Database)
<Database object at ...>

Mypy will usually be able to correctly infer the typing. But, if you find yourself in a corner case, you can use the alternative syntax to provide type information:

>>> # Specifying the return type explicitly
... world.get[Database](Database)
<Database object at ...>

Antidote will enforce the type when possible, if the provided type information is really a type.

Note

Prefer using inject() to world.get:

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

def bad():
    db = world.get(Database)
    return db

bad does not rely on dependency injection making it harder to test! inject() is also considerably faster thanks to heavily tuned cython code.

But how does Antidote work underneath ? To simplify a bit, Antidote can be summarized as single catalog of dependencies world. Decorators like injectable() declares dependencies and inject retrieves them:

             +-----------+
      +----->|   world   +------+
      |      +-----------+      |

   @injectable                  @inject

      |                         |
      |                         v
+-----+------+             +----------+
| Dependency |             | Function |
+------------+             +----------+

2. Injection

As seen before inject() is used to inject dependencies in functions. There are multiple ways to define the dependencies to be injected. Most of them will be used in this tutorial. The priority is defined as such:

from antidote import inject, injectable

@injectable
class Database:
    ...

@injectable
class Cache:
    ...
  1. Markers which replace the default value:, such as Inject.me() or Inject.get():

    @inject
    def f(db: Database = inject.me()):
        ...
    
    @inject
    def f2(db = inject.get(Database)):
        ...
    
  1. Annotated type hints as defined by PEP-593. It cannot be used with markers on the same argument.

    from antidote import Inject
    
    @inject
    def f(db: Inject[Database]):
        ...
    
  2. dependencies Defines explicitly which dependency to associate with which argument:

    @inject(dependencies=dict(db=Database, cache=Cache))
    def f(db: Database, cache: Cache):
        ...
    
    # To ignore one argument use `None` as a placeholder.
    @inject(dependencies=[Database, Cache])
    def f2(db: Database, cache: Cache):
        ...
    
    # Or more concisely
    @inject({'db': Database, 'cache': Cache})
    def f3(db: Database, cache: Cache):
        ...
    
    @inject([Database, Cache])
    def f4(db: Database, cache: Cache):
        ...
    

Antidote will only inject dependencies for missing arguments. If not possible, a DependencyNotFoundError is raised. The only exception is the Inject.me() marker which will provide None if the argument is Optional:

>>> from typing import Optional
>>> class Dummy:
...     ...
>>> @inject
... def f(dummy: Optional[Dummy] = inject.me()) -> Optional[Dummy]:
...     return dummy
>>> f() is None
True

3. Injectables

Any class decorated with @injectable can be provided by Antidote:.

from antidote import injectable

@injectable
class Database:
    ...

By default it’s a singleton, so only one instance will exist. This behavior can be controlled with:

@injectable(singleton=False)
class Database:
    ...

On top of declaring the dependency, injectable() also wires the class and so injects all methods by default:

from antidote import inject

@injectable
class AuthenticationService:
    def __init__(self, db: Database = inject.me()):
        self.db = db
>>> from antidote import world
>>> world.get(AuthenticationService).db
<Database object at ...>

You can customize injection by applying a custom inject() on methods:

@injectable
class AuthenticationService:
    @inject({'db': Database})
    def __init__(self, db: Database):
        self.db = db

or by specifying your own Wiring.

from antidote import Wiring

@injectable(wiring=Wiring(methods=['__init__']))
class AuthenticationService:
    def __init__(self, db: Database = inject.me()):
        self.db = db

Note

This class wiring behavior can be used through wire(), it isn’t specific to injectable().

You can also specify a factory method to control to have fine control over the instantiation:

from __future__ import annotations


@injectable(factory_method='build')
class AuthenticationService:
    @classmethod
    def build(cls) -> AuthenticationService:
        return cls()

One last point, injectable() is best used on your own classes. If you want to register external classes in Antidote, you should rely on a factory() instead presented in a later section.

4. Configuration

Configuration, or more generally constants, can be found in any application. Antidote provides a simple abstraction layer Constants which allows you to re-define later how you retrieve those constants without breaking your users:

from antidote import Constants, inject, const

class Config(Constants):
    PORT = const(3000)
    DOMAIN = const('example.com')

@inject
def absolute_url(path: str,
                 domain: str = Config.DOMAIN,
                 port: int = Config.PORT
                 ) -> str:
    return f"https://{domain}:{port}{path}"
>>> absolute_url("/user/1")
'https://example.com:3000/user/1'
>>> absolute_url('/dog/2', port=80)
'https://example.com:80/dog/2'

Both PORT and DOMAIN have different behavior whether they’re used from the class or from an instance:

  • From the class, it’s a dependency and a marker. So you can use it directly with inject() as shown before and you can retrieve it from world:

    >>> from antidote import world
    >>> world.get[str](Config.DOMAIN)
    'example.com'
    
  • From an instance, it’ll retrieve the actual value which makes testing the class a lot easier:

    >>> Config().DOMAIN
    'example.com'
    

Now Constants really shines when your constants aren’t hard-coded. The class will be lazily instantiated and you can customize how constants are actually retrieved:

from typing import Optional

class Config(Constants):
    PORT = const('serving_port')
    DOMAIN = const()

    # Lazy loading of your configuration
    def __init__(self):
        self._data = dict(domain='example.com', serving_port=80)

    def provide_const(self,
                      name: str,  # name of the const(), ex: "DOMAIN"
                      arg: Optional[str]  # argument given to const() if any, None otherwise.
                      ) -> object:
        if arg is None:
            return self._data[name.lower()]
        return self._data[arg]

const() also provides two additional features:

  • A default value can be provided which will be used on LookUpErrors.

    class Config(Constants):
        PORT = const(default=80)
    
        def provide_const(self, name: str, arg: Optional[object]) -> object:
            raise LookupError(name)
    
    >>> Config().PORT
    80
    
  • type enforcement:

    class Config(Constants):
        PORT = const[int](object())
        DOMAIN = const[str]('example.com')
    
    >>> Config().DOMAIN
    'example.com'
    >>> Config().PORT
    Traceback (most recent call last):
      File "<stdin>", line 1, in ?
    TypeError: ...
    

Constants can even go a step further by not only enforcing types but also casting the value:

class Config(Constants):
    PORT = const[int]('80')
>>> Config().PORT
80

This only works on primitive types out of the box: int, float and str. You can other types like this:

from enum import Enum

class Env(Enum):
    PROD = 'prod'
    DEV = 'dev'

class Config(Constants):
    __antidote__ = Constants.Conf(auto_cast=[int, Env])
    PORT = const[int]('80')
    ENV = const[Env]('dev')
>>> Config().PORT
80
>>> Config().ENV
<Env.DEV: 'dev'>

6. Factories & External dependencies

Factories are ideal to deal with external dependencies which you don’t own, like library classes. The simplest way to declare a factory, is simply to use the decorator factory():

from antidote import factory, inject, Constants, const
# from my_favorite_library import Database

class Config(Constants):
    URL = const[str]('localhost:5432')


@factory
def default_db(url: str = Config.URL) -> Database:  # @factory applies @inject automatically
    return Database(url)


@inject
def f(db: Database = inject.me(source=default_db)) -> Database:
    return db
>>> from antidote import world
>>> f()
<Database ...>
>>> world.get(Database, source=default_db)
<Database ...>

factory() will automatically use inject() which lets us use markers and annotation for dependency injection of the factory itself. You can still apply inject() yourself for total control or even disable the auto-wiring.

You probably noticed how Antidote forces you to specify the factory when using it for dependency injection! There are two reasons for it:

  • You can trace back how Database is instantiated.

  • The factory default_db will always be loaded by Python before using Database.

Antidote will enforce that the specified factory and class are consistent, relying on the return type of the factory:

>>> class Dummy:
...     pass
>>> world.get(Dummy, source=default_db)
Traceback (most recent call last):
  File "<stdin>", line 1, in ?
TypeError: ...

For more complex factories, you can use a class factory:

@factory
class DefaultDB:
    def __init__(self, url: str = Config.URL):
        self.url = url

    # Will be called to instantiate Database
    def __call__(self) -> Database:
        return Database(self.url)

7. Tests

Until now, you’ve seen that you could still use normally injected functions:

from antidote import injectable, inject

@injectable
class MyService:
    pass

@inject
def f(my_service: MyService = inject.me()) -> MyService:
    return my_service

# injected
f()

# manual override
f(MyService())
f(my_service=MyService())

This allows to test easily individual components in unit-tests. But in more complex tests it’s usually not enough. So Antidote provides additional tooling to isolate tests and change dependencies. The most important of them is world.test.clone(). It’ll create an isolated world with the same dependencies declaration, but not the same instances!

>>> from antidote import world
>>> with world.test.clone():
...     # This works as expected !
...     my_service = f()
>>> # but it's isolated from the rest, so you don't have the same instance
... my_service is world.get(MyService)
False
>>> dummy = object()
>>> with world.test.clone():
...     # Override dependencies however you like
...     world.test.override.singleton(MyService, dummy)
...     f() is dummy
True

You can also use a factory to override dependencies:

>>> with world.test.clone():
...     @world.test.override.factory()
...     def override_my_service() -> MyService:
...         return dummy
...     f() is dummy
True

Overrides can be changed at will and override each other. You can also nest test worlds and keep the singletons you defined:

>>> with world.test.clone():
...     world.test.override.singleton(MyService, dummy)
...     # override twice MyService
...     world.test.override.singleton(MyService, dummy)
...     with world.test.clone():
...         f() is dummy
False
>>> with world.test.clone():
...     world.test.override.singleton(MyService, dummy)
...     with world.test.clone(keep_singletons=True):
...         f() is dummy
True

Beware that world.test.clone() will automatically world.freeze(): no new dependencies cannot be defined. After all you want to test your existing dependencies not create new ones.

>>> with world.test.clone():
...     @injectable
...     class NewService:
...         pass
Traceback (most recent call last):
  File "<stdin>", line 1, in ?
FrozenWorldError

To test new dependencies, you should use world.test.new() instead:

>>> with world.test.new():
...     @injectable
...     class NewService:
...         pass
...     world.get(NewService)
<NewService ...>
>>> world.get[NewService]()
Traceback (most recent call last):
  File "<stdin>", line 1, in ?
DependencyNotFoundError