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:
...
Markers which replace the default value:, such as
Inject.me()
orInject.get()
:@inject def f(db: Database = inject.me()): ... @inject def f2(db = inject.get(Database)): ...
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]): ...
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 fromworld
:>>> 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
LookUpError
s.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 usingDatabase
.
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