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 a quick example:
from antidote import inject, world, Provide, Service, service
class MyService(Service):
pass
# or
@service
class MyService:
pass
@inject
def f(my_service: Provide[MyService]):
# doing stuff
return my_service
>>> f()
<MyService object at ...>
Now you don’t need to provide MyService
anymore ! Let’s start with the basics to
understand what’s going on. To simplify a bit, Antidote works this way
+-----------+
+----->| world +------+
| +-----------+ |
declaration @inject
| |
| v
+-----+------+ +----------+
| Dependency | | Function |
+------------+ +----------+
Dependencies are declared and become part of world
. It works somewhat like a
big dictionary of dependencies mapping to their value. From there on you can either
retrieve them yourself or let Antidote inject them for you with inject()
.
By default it’ll only rely on annotated type hints (see typing.Annotated
and PEP-593). Here we specify with Provide
that the type hint itself is the
dependency. Only missing arguments will be injected, hence you can always use the function
normally:
>>> f(MyService())
<MyService object at ...>
Note
inject()
is designed to be very flexible. It supports multiple ways to link
an argument to its dependency if any. You’ll encounter some of them later in this
tutorial, but don’t hesitate to check out its documentation by clicking on
inject()
!
You surely noticed the declaration of MyService
with:
class MyService(Service):
pass
This declares MyService
as a Service
just by inheriting it. By default
it will be a singleton. A singleton is a dependency that never changes, it always returns
the same object. inject()
allows us to retrieve it in a function, but you also
can retrieve with world.get
:
>>> my_service = world.get(MyService)
>>> my_service
<MyService object at ...>
Any dependency can be retrieved with it, not just singletons. Unfortunately, we lost type
information for Mypy and your IDE for auto completion. They both see my_service
as
an object
. To avoid this, Antidote provides a syntax similar to static languages:
>>> world.get[MyService](MyService) # Mypy will understand that this returns a MyService
<MyService object at ...>
>>> # As `MyService` is redundant here, you can omit it:
... world.get[MyService]()
<MyService object at ...>
Antidote ensures that the type you specify is valid. A TypeError
will be raised
otherwise:
>>> world.get[str](MyService)
Traceback (most recent call last):
File "<stdin>", line 1, in ?
TypeError
Note
While you’re free to use world.get
anywhere you want, better use
inject()
:
@inject
def good_function(my_service: Provide[MyService]):
return my_service
def bad_function():
"""
We're not doing any dependency injection anymore ! We only use Antidote to
manage dependencies, not more. This makes bad_function() *a lot harder* to
test !
"""
my_service = world.get[MyService]()
return my_service
Furthermore good_function
is actually faster ! This even more true when using
the compiled version of Antidote (with Cython), making good_function
10x faster.
The compiled version of Antidote is heavily tuned to have best performance
with inject()
. You can check whether you’re using the compiled version
with is_compiled()
. Pre-built wheels are only available for Linux currently.
2. Injection
Injection is done with the decorator inject()
. By default it relies only on
annotated type hints. Here is are the different ways to use it:
Annotated type hints.
from antidote import inject, Service, Provide class MyService(Service): pass @inject def f(my_service: Provide[MyService]): pass
dependencies
Defines explicitly which dependency to associate with which argument. The most common usage are either with a dictionary:class AnotherService(Service): pass @inject(dependencies=dict(my_service=MyService, another=AnotherService)) def f(my_service: MyService, another: AnotherService): pass # Or more concisely @inject(dict(my_service=MyService, another=AnotherService)) def f(my_service: MyService, another: AnotherService): pass
Or with an iterable of dependencies. In this case the ordering of the dependencies is used to
# When needed None can be used a placeholder for argument that should be ignored. @inject(dependencies=[MyService, AnotherService]) def f(my_service: MyService, another: AnotherService): pass # Or more concisely @inject([MyService, AnotherService]) def f(my_service: MyService, another: AnotherService): pass
auto_provide
: When set toTrue
, class type hints will be treated as dependencies. You can restrict this behavior by specifying a list of classes for which it should be used:# Both `my_service` and `another` will be injected @inject(auto_provide=True) def f(my_service: MyService, another: AnotherService): pass # argument `another` won't be injected @inject(auto_provide=[MyService]) def f(my_service: MyService, another: AnotherService): pass
Antidote will only try to retrieve dependencies for an argument when it’s missing. If
found, it’ll be injected. If not, a DependencyNotFoundError
will
be raised if there is no default argument.
3. Services
We’ve seend :py:class`.Service` before to declare MyService
! Let’s take a better
look at it. A service is a class which provides some sort of functionality. A common
example is a class serving as an interface to some external system like a database:
from antidote import inject, Service, Provide
class Database(Service):
def __init__(self):
self.users = [dict(name='Bob')]
@inject
def get_user_count(db: Provide[Database]):
return len(db.users)
# Or without annotated type hints
@inject([Database])
def get_user_count(db: Database):
return len(db.users)
>>> get_user_count()
1
By default Service
s are singletons, they are only instantiated once:
>>> from antidote import world
>>> world.get[Database]() is world.get[Database]()
True
If you cannot inherit from Service
, you can use the class decorator
service()
:
>>> from antidote import service, world
>>> @service
... class Database:
... pass
>>> world.get[Database]()
<Database ...>
Note
You should only use it to register your own classes. If you want to register external classes in Antidote, you should rely on a factory instead presented later.
As services will depend on each other, all methods are wired with inject()
by default, including __init__()
. Meaning that you can use annotated type
hints anywhere and they will be taken into account as shown hereafter:
class UserAPI(Service):
# We didn't need to specify @inject as UserAPI is a Service
def __init__(self, database: Provide[Database]):
self.database = database
def get_user_count(self):
return len(self.database.users)
>>> from antidote import world
>>> world.get[UserAPI]().get_user_count()
1
This simplifies the code as annotated type hints are a good enough indication that something will be injected.
All those default behaviors can be changed easily with a custom
Service.Conf
in your Service
. For example you could create
a non singleton service which uses auto_provide=True
on all methods by default:
class QueryBuilder(Service):
__antidote__ = Service.Conf(singleton=False).with_wiring(auto_provide=True)
def __init__(self, database: Database):
self.database = database
>>> world.get[QueryBuilder]() is world.get[QueryBuilder]()
False
You may also find yourself in situations where a single service should be used with
different parameters. For example, a simple service which accumulates metrics
during the application lifetime and flushes it to the database. We could create subclasses
for each possible metric or have one service handle all metrics. But Antidote provides a
nicer way: you to specify constructor arguments when requesting a Service
:
class MetricAccumulator(Service):
__antidote__ = Service.Conf(parameters=['name'])
def __init__(self, name: str, database: Provide[Database]):
self.name = name
self._database = database
self._buffer = []
@classmethod
def of(cls, name: str):
"""
Provides a clean interface with arguments and type hints as parameterized()
only accepts **kwargs.
"""
return cls.parameterized(name=name)
def add(self, value: int):
self._buffer.append(value)
def flush(self):
"""flushes buffer to database"""
>>> count_metric = world.get[MetricAccumulator](MetricAccumulator.of('count'))
>>> count_metric.name
'count'
>>> # The same instance is returned because `MetricAccumulator` is defined as a singleton.
... count_metric is world.get(MetricAccumulator.of('count'))
True
When the same arguments are specified, the same service instance will be returned if the service is defined as a singleton. Simple, yet effective when you need the same service with different configuration at the same time. With annotated type hints, it would look like this:
from typing import Annotated
# from typing_extensions import Annotated # Python < 3.9
from antidote import Get
CountMetricAccumulator = Annotated[MetricAccumulator,
Get(MetricAccumulator.of('count'))]
@inject
def f(count_metric: CountMetricAccumulator):
pass
# Or without annotated type hints. Here we're passing a list of dependencies, so
# its mapped to the arguments through their position.
@inject([MetricAccumulator.of('count')])
def f(count_metric: MetricAccumulator):
pass
4. Wiring
When declaring a service with Service
we’ve seen that methods, such
as __init__()
will be automatically wired. Underneath it relies on Wiring
which will by default inject all methods. It supports the same arguments as inject()
,
namely auto_provide
and dependencies
. Those will be used
for all injected methods. You can also specify explicitly which methods to inject with
methods
:
from antidote import Service, Wiring, Provide
class Database:
pass
class PostgreSQL(Database, Service):
pass
class MySQL(Database, Service):
pass
class CustomWiring(Service):
# Only get_host() will be injected. By default, all methods are.
__antidote__ = Service.Conf(wiring=Wiring(methods=['load_db'],
auto_provide=[PostgreSQL]))
def load_db(self, mysql: Provide[MySQL], postgres: PostgreSQL) -> Database:
return postgres
>>> from antidote import world
>>> world.get[CustomWiring]().load_db()
<PostgreSQL ...>
If you don’t want any wiring at all, you just have to set it to None
:
class NoWiring(Service):
# No wiring, nothing will be injected not even annotated type hints.
__antidote__ = Service.Conf(wiring=None)
You can also inject()
to override any Wiring
:
from antidote import inject
class MultiWiring(Service):
__antidote__ = Service.Conf(wiring=Wiring(dependencies=dict(db=PostgreSQL)))
def __init__(self, db: Database):
self.db = db
def load_db(self, db: Database) -> Database:
return db
# Wiring will not override any injection made explicitly.
@inject(dict(db=MySQL))
def load_different_db(self, db: Database) -> Database:
return db
>>> x = world.get[MultiWiring]()
>>> x.db == x.load_db()
True
>>> x.load_different_db()
<MySQL ...>
For conciseness, Antidote provides some shortcuts:
with_wiring()
: allows to keep existingWiring
configuration and only change some parameters:class AutoProvidedWiring(Service): __antidote__ = Service.Conf().with_wiring(auto_provide=True) def __init__(self, db: PostgreSQL): self.db = db
>>> world.get[AutoProvidedWiring]().db <PostgreSQL ...>
If you want to wire classes outside of Antidote, you can use the class decorator
wire()
which has the same arguments asWiring
:from antidote import wire @wire class DatabaseUser: def load_db(self, db: Provide[PostgreSQL]): return db
>>> DatabaseUser().load_db() is world.get[PostgreSQL]() True
5. Configuration
Antidote Constants
allows you to define configuration that you can inject
and maintain easily. Like a service where you only need to use “go to definition” to know
how a constant is actually defined. And you know where it’s used:
from typing import Annotated
# from typing_extensions import Annotated # Python < 3.9
from antidote import Constants, inject, const, Get
class Config(Constants):
PORT = const[int](3000)
DOMAIN = const('example.com') # type is not required
@inject
def absolute_url(path: str,
domain: Annotated[str, Get(Config.DOMAIN)],
port: Annotated[int, Get(Config.PORT)]):
return f"https://{domain}:{port}{path}"
# Or without any annotated type hints.
# Here None is simply a placeholder, nothing will be injected.
@inject([None, Config.DOMAIN, Config.PORT])
def absolute_url(path: str, domain: str, port: int):
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'
>>> # For easier testing you can also use a Config instance directly
... Config().DOMAIN
'example.com'
Pretty easy isn’t it ? But it feels a bit overkill to just define some constants in Python. But more often than not your configuration will be coming from a file or even a database. This can become increasingly complicated if you need to lazily load configuration. Luckily Antidote forces you to encapsulate how you retrieve the configuration, so it’s easy to change:
class Config(Constants):
PORT = const[int]('port')
DOMAIN = const('domain')
def __init__(self):
# Load configuration from somewhere. Config will only be instantiated if
# necessary.
self._data = dict(domain='example.com', port='80')
def provide_const(self, name: str, arg: str):
# Only called when needed.
return self._data[arg]
>>> from antidote import world
>>> world.get(Config.PORT)
80
>>> Config().DOMAIN
'example.com'
You probably noticed that Config.PORT
we explicitly stated that it was an integer.
it serves several purposes:
the actual type of constant value is type checked at runtime.
>>> class InvalidConf(Constants): ... WRONG_TYPE = const[Constants]('test') >>> InvalidConf().WRONG_TYPE Traceback (most recent call last): File "<stdin>", line 1, in ? TypeError
providing a type hint for Mypy:
>>> Config().PORT # treated as an `int` by Mypy 80 >>> world.get(Config.PORT) # same 80
If the type is one of
str
,float
orint
, the result ofprovide_const()
will be cast automatically. This allows you to handle simply cases where the configuration is retrieved as a string. You can either deactivate this behavior or extend it to support enums withauto_cast
.
In the same spirit, const()
allows you to define a default value. It will
only be used if provide_const()
raises a LookUpError
:
class Config(Constants):
PORT = const[int]('port', default=80)
DOMAIN = const('domain')
def __init__(self):
self._data = dict(domain='example.com')
def provide_const(self, name: str, arg: str):
return self._data[arg]
>>> world.get(Config.PORT)
80
>>> Config().DOMAIN
'example.com'
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 function
decorator factory()
:
from typing import Annotated
# from typing_extensions import Annotated # Python < 3.9
from antidote import factory, inject, From, Constants, const, Get
class Config(Constants):
URL = const[str]('localhost:5432')
# Suppose we don't own the class code, hence we can't define it as a Service
class Database:
def __init__(self, url: str):
self.url = url
@factory
def default_db(url: Annotated[str, Get(Config.URL)]) -> Database:
return Database(url)
@inject
def f(db: Annotated[Database, From(default_db)]) -> Database:
return db
# Or without annotated type hints
@factory
@inject([Config.URL])
def default_db(url: str) -> Database:
return Database(url)
@inject([Database @ default_db])
def f(db: Database) -> Database:
return db
>>> from antidote import world
>>> f()
<Database ...>
>>> world.get[Database](Database @ default_db)
<Database ...>
The return type MUST always be specified, this is how Antidote knows which dependency you
intend to provide. factory()
will apply inject()
on the function if not
done already. Hence you can use annotated type hints out of the box but no more without
injecting explicitly. You’re probably wondering about the custom syntax when not using
annotated type hints Database @ default_db
. It provides some very nice properties
You can trace back how
Database
is instantiated.The factory
default_db
will always be loaded by Python before usingDatabase
.
If you need more complex factories, you can use a class instead by inheriting Factory
:
from antidote import Factory
class Database:
def __init__(self, url: str):
self.url = url
class DefaultDB(Factory):
def __init__(self, url: Annotated[str, Get(Config.URL)]):
self.url = url
# Will be called to instantiate Database
def __call__(self) -> Database:
return Database(self.url)
Factory
has more or less the same configuration parameters than Service
:
And you use it the same way as factory()
:
>>> world.get[Database](Database @ DefaultDB)
<Database ...>
7. Tests
You’ve seen until now that Antidote’s inject()
does not force you to rely on
the injection to be used:
from antidote import Service, inject, Provide
class MyService(Service):
pass
@inject
def f(my_service: Provide[MyService]) -> MyService:
return my_service
# injected
f()
# manual override
f(MyService())
f(my_service=MyService())
This allows to test easily individual components in unit-tests easily. But that’s not always enough in more complex tests or integration tests. First of all, let’s recap how Antidote works. In the first section Antidote was roughly described as working as follows:
+-----------+
+----->| world +------+
| +-----------+ |
declaration @inject
| |
| v
+-----+------+ +----------+
| Dependency | | Function |
+------------+ +----------+
But that’s not really what is happening, in reality we have:
+-----------+
| world |
+-----+-----+
|
+
controls
+
|
v
+------+------+
+---->+ container +-----+
| +-------------+ |
+ +
declaration @inject
+ +
| |
+-----+------+ v
| Dependency | +----+-----+
+------------+ | Function |
+----------+
The container handles all of the state of Antidote such as singletons. The good news is
that world
does provide to you the tools to control it in world.test
.
Allowing you to override dependencies or test in isolated environments. The most important
one is world.test.clone()
. It’ll keep all of your dependency declarations and
isolate you from the outside world:
>>> 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
It’ll also world.freeze()
the local world, meaning that no new dependencies
cannot be added. After all you want to test your existing dependencies not create new ones.
>>> with world.test.clone():
... class NewService(Service):
... pass
Traceback (most recent call last):
File "<stdin>", line 1, in ?
FrozenWorldError
Note
To test new dependencies, you should use world.test.new()
instead:
>>> with world.test.new():
... class NewService(Service):
... pass
... world.get[NewService]()
<NewService ...>
>>> world.get[NewService]()
Traceback (most recent call last):
File "<stdin>", line 1, in ?
DependencyNotFoundError
To change dependencies, you need to use world.test.override
:
>>> with world.test.clone():
... world.test.override.singleton(MyService, 'dummy')
... f()
'dummy'
world.test.override
exposes three ways to override dependencies:
-
>>> # Use either world.test.override or override directly ... from antidote.world.test import override >>> with world.test.clone(): ... override.singleton(MyService, 'dummy') ... # Can be redefined ... override.singleton(MyService, 'dummy') ... # Multiple dependencies can be declared with a dict ... override.singleton({MyService: 'dummy'}) ... f() 'dummy'
-
>>> with world.test.clone(): ... @override.factory() ... def override_my_service() -> MyService: ... return 'dummy' ... # Can be redefined and will remove any existing instance ... # (if singleton for example) ... @override.factory() ... def override_my_service() -> MyService: ... return 'dummy' ... f() 'dummy'
-
>>> from antidote.core import DependencyValue >>> with world.test.clone(): ... @override.provider() ... def dummy_provider(dependency): ... if dependency is MyService: ... return DependencyValue('dummy') ... f() 'dummy'
The decorated function will be called each time a dependency is needed. If it can be provided it should be returned wrapped by a
DependencyValue
which also defines whether the dependency value is a singleton or not.Warning
Beware of
provider()
, it can conflict withfactory()
andsingleton()
. Dependencies declared withsingleton()
will hideprovider()
. Andprovider()
will hidefactory()
.Moreover it won’t appear in
world.debug()
.
world.test.clone()
will not keep any existing singleton by default, but you may change
it:
>>> my_service = world.get[MyService]()
>>> with world.test.clone():
... my_service is world.get[MyService]()
False
>>> with world.test.clone(keep_singletons=True):
... my_service is world.get[MyService]()
True
Warning
Beware. keeping singletons will re-use the same object:
>>> world.get[MyService]().marker = 'marker'
>>> with world.test.clone(keep_singletons=True):
... world.get[MyService]().marker = 'different'
>>> world.get[MyService]().marker # We changed the singleton of the outside world.
'different'
world.test
provides additional utilities when extending Antidote or defining abstract
factories / services.