Tutorial¶
To begin dependency injection, you’ll need to configure your dependency providers. Providers are functions that return, or yield only once, an instance of the type they’re providing. Say we have the following type:
from dataclass import dataclass
from typing import Protocol, final
class Database(Protocol):
uri: str
def get_items(self) -> list[str]: ...
def write_item(self, item: str) -> None: ...
@dataclass(slots=True)
class JSONDatabase(Database):
uri: str
def close(self) -> None:
(implementation)
def get_items(self) -> list[str]:
(implementation)
def write_item(self, item: str) -> None:
(implementation)
JSONDatabase implements the Database protocol, and offers an additional read_book method. Creating parent Protocols or abstract classes for your dependencies is preferred to maximize the benefits of dependency injection. However this is not always feasible, especially for third-party tools. Then create a provider in one of two ways. You can create a separate function:
from collections.abc import Generator
import os
from prereq import provides
@provides(level=2)
def create_database() -> Generator[JSONDatabase]:
db = JSONDatabase(os.environ["DB_URI"])
try:
yield db
finally:
db.close()
Or, create a class method on JSONDatabase and make that a provider.
from collections.abc import Generator
import os
from prereq import provides
@dataclass(slots=True)
class JSONDatabase(Database):
uri: str
@provides(level=2)
@classmethod
def create_database(cls) -> Generator[JSONDatabase]:
db = cls(os.environ["DB_URI"])
try:
yield db
finally: # Try-finally isn't necessary for prereq, but a good practice all the same.
db.close()
The function can also be async, and can return the value instead of yielding. In both of these examples, the a provider is created in the function namespace. Meaning, in both examples, the create_database variable is not a function. It is a special provider instance that contains relevant information about the provider to the resolver.
In these snippets, the provider will observe the return type notation. It takes the JSONDatabase return type, and binds the resolver that type, and that type’s parents. So create_database will provide for the JSONDatabase type, and the Database type. To only provide to the JSONDatabase type, add the cover_parents=False kwargs. Alternatively, you can directly set what types are provided using the coverage kwarg.
Setting level=2 tells the resolvers to let the level 2 resolver handle this provider. What this means depends entirely on how Prereq is being used. For example, a web server might have all app lifetime providers at level 1, and request lifetime providers at level 2. Providers can have dependencies that are on the same level, a lower level, but not a higher level. The default level is 1.
from enum import Enum
# Instead of using magic numbers, Prereq can also read integer values from Enums
class Level(Enum):
PROCESS = 1
EVENT = 2
ACTION = 3
@provides
def config() -> Config: ...
@provides(level=Level.EVENT)
async def emailer(config: Config) -> Emailer: ...
# This code is bad! It is strange, and won't work.
@provides
def config(emailer: Emailer) -> Config: ...
@provides(level=Level.EVENT)
async def emailer() -> Emailer: ...
When you’ve created your providers, you’ll need to add them to a resolver.
from prereq import Resolver
resolver = Resolver()
resolver.add_providers(
create_database,
config,
emailer
)
# Or if a classmethod was used:
# resolver.add_providers(JSONDatabase.create_database)
By default, this creates a level 1 Provider. It can resolve level 1 types, or create a child resolver. The child resolver is always 1 plus the previous resolver level.
def setup_logging(config: Config):
...
async def send_emails(db: Database, emailer: Emailer):
...
async def main():
async with resolver.resolve(setup_logging) as kwargs:
setup_logging(**kwargs)
async with resolver() as level_two:
async with level_two.resolve(send_emails) as kwargs:
await send_emails(**kwargs)
# Resolving level 1 from level 2 works fine!
async with level_two.resolve(setup_logging) as kwargs:
setup_logging(**kwargs)
And just like that, dependency injection is setup. Prereq does not call functions the dependencies are for, it will only provide the dependencies.
Potential Pitfalls¶
Prereq isn’t providing all the arguments in the function signature?¶
This is likely the result of the following issues:
No provider exists for the type not being injected. It may not have been added to the Resolver, or added to an unrelated Resolver.
The provider operates at a higher level than the Resolver, and as such the Resolver could not find the provider.
The dependency is not typed, and therefore ignored by the Resolver.
How do I add a dependency for a primitive type? Or two different dependencies of the same type?¶
Both of these scenarios are unlikely, especially wanting to inject a primitive type. But, it can still be done in a few ways. The simplest is
to use typing.NewType and create a subclass of the primitive or duplicate type.
from typing import NewType
from prereq import provides
from sqlalchemy.orm import Session
MemoryUsage = NewType("MemoryUsage", float)
MariaSession = NewType("MariaSession", Session)
PostgresSession = NewType("PostgresSession", Session)
@provides
def get_memory() -> MemoryUsage: ...
@provides(cover_parents=False)
def maria_connection() -> MariaSession: ...
@provides(cover_parents=False)
def postgres_connection() -> PostgresSession: ...