Dependency Injection¶
Once you have defined your dependency providers,
you need a way to supply their results to the functions or methods that require them.
This process is called injection, and Picodi handles it using the inject()
decorator and the Provide() marker.
The @inject Decorator¶
The @inject decorator is the core mechanism that enables dependency injection
for a specific function or method.
from picodi import inject, Provide
def get_dependency():
return "some_value"
@inject # Enable dependency injection for this function
def my_function(param=Provide(get_dependency)):
# ... function body ...
print(f"Injected value: {param}")
my_function()
Output:
Injected value: some_value
How it works:
@injectwraps the decorated function (my_functionin this case).When the wrapped function is called,
@injectintercepts the call before the original function’s code executes.It inspects the function’s signature for parameters whose default values are
Provide()markers.For each such parameter, it resolves the specified dependency provider (e.g., calls
get_dependency).It manages the lifecycle of the resolved dependency based on its scope.
Finally, it calls the original function, passing the resolved dependencies as arguments for the corresponding parameters (unless arguments were explicitly passed during the call).
Placement:
The @inject decorator should generally be the first decorator applied to your function
(i.e., the one closest to the def keyword). This ensures it can correctly analyze
the function signature before other decorators potentially modify it.
# Correct placement
@other_decorator
@inject
def my_func(val=Provide(...)): ...
# Incorrect placement (might work, but not guaranteed)
@inject
@other_decorator
def my_func(val=Provide(...)): ...
The Provide Marker¶
Provide() is used as a default value for a function parameter to signal
to @inject that this parameter should be filled by a dependency.
from picodi import Provide, inject
def get_user_name() -> str:
return "Alice"
def get_user_id() -> int:
return 123
@inject
def process_user(
user_id: int = Provide(get_user_id), # Inject user_id
name: str = Provide(get_user_name), # Inject name
):
print(f"Processing user {name} (ID: {user_id})")
process_user()
Output:
Processing user Alice (ID: 123)
Key Points:
Provide()takes exactly one argument: the dependency provider callable (e.g.,get_user_id). Do not call the provider function insideProvide(e.g.,Provide(get_user_id())is incorrect).It acts as a placeholder default value. If you explicitly pass an argument for a parameter marked with
Providewhen calling the function, your explicitly passed value will be used instead of the injected dependency.# Explicitly passing user_id overrides injection for that parameter process_user(user_id=999)
Output:
Processing user Alice (ID: 999)
Type hints (
user_id: int,name: str) are strongly recommended for clarity and static analysis but are not required by Picodi for injection itself. Picodi relies on theProvide()marker, not type hints.
Dependency Resolution Graph¶
Picodi automatically handles cases where dependencies depend on other dependencies. It builds a dependency graph and resolves it in the correct order.
from picodi import Provide, inject
def get_config() -> dict:
print("Resolving: get_config")
return {"db_url": "sqlite:///:memory:"}
@inject # Depends on get_config
def get_db_connection(config: dict = Provide(get_config)) -> str:
print("Resolving: get_db_connection")
return f"Connection({config['db_url']})"
@inject # Depends on get_db_connection
def get_user_repo(conn: str = Provide(get_db_connection)) -> str:
print("Resolving: get_user_repo")
return f"UserRepo({conn})"
@inject # Depends on get_user_repo
def main_service(repo: str = Provide(get_user_repo)):
print(f"Running main_service with {repo}")
main_service()
Output:
Resolving: get_config
Resolving: get_db_connection
Resolving: get_user_repo
Running main_service with UserRepo(Connection(sqlite:///:memory:))
Picodi resolved the chain: get_config -> get_db_connection -> get_user_repo -> main_service.
Injecting into Methods¶
You can use @inject on methods, including __init__, just like regular functions.
from picodi import Provide, inject
def get_logger():
print("Creating logger")
return "MyLogger"
class MyService:
@inject
def __init__(self, logger=Provide(get_logger)):
print("MyService.__init__ called")
self.logger = logger
def do_something(self):
print(f"Doing something with {self.logger}")
service = MyService()
service.do_something()
Output:
Creating logger
MyService.__init__ called
Doing something with MyLogger
Sync vs. Async Injection¶
A synchronous function (
def) can only inject synchronous dependencies. Attempting toProvideanasync defdependency in a synchronous function will result in the coroutine object being injected, not its result. (Exception: See the section on injecting async dependencies into sync functions in Asynchronous Code for manually initialized async dependencies).An asynchronous function (
async def) can inject both synchronous and asynchronous dependencies. Picodi will correctlyawaitasync dependencies when resolving them within an async function.
Key Takeaways¶
Use
@inject(placed first) to enable dependency injection for a function/method.Use
Provide(dependency_provider)as the default value for parameters that need injection.Picodi resolves the full dependency graph automatically.
Injection works for regular functions and methods (like
__init__).Sync functions generally require sync dependencies; async functions can handle both.
Next, let’s dive deeper into controlling the lifecycle of these injected dependencies using Scopes.