Skip to content

Commit 0379d2b

Browse files
committed
feat: allow manual override of dependencies
This change introduces the ability to manually override dependencies in `fastapi-injectable`. Any arguments explicitly passed to an `@injectable` function will now take priority over the dependency injection system. This is particularly useful for: - Mocking dependencies in tests. - Providing values directly in CLI tools. This change also fixes a bug where `fastapi-injectable` would still resolve a dependency even if it was explicitly provided as an argument, causing unexpected behavior. Fixes: #133
1 parent 89c057f commit 0379d2b

File tree

5 files changed

+162
-12
lines changed

5 files changed

+162
-12
lines changed

README.md

Lines changed: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -69,12 +69,13 @@ print(result) # Output: 'data'
6969

7070
## Key Features
7171

72-
1. **Flexible Injection**: Use decorators, function wrappers, or utility functions.
73-
2. **Full Async Support**: Works with both sync and async code.
74-
3. **Resource Management**: Built-in cleanup for dependencies.
75-
4. **Dependency Caching**: Optional caching for better performance.
76-
5. **Graceful Shutdown**: Automatic cleanup on program exit.
77-
6. **Event Loop Management**: Control the event loop to ensure the objects created by `fastapi-injectable` are executed in the right loop.
72+
1. **Basic Injection**: Use decorators, function wrappers, or utility functions.
73+
2. **Manual Overrides**: Explicit arguments you pass always take priority over injected dependencies (great for tests and mocks).
74+
3. **Full Async Support**: Works with both sync and async code.
75+
4. **Resource Management**: Built-in cleanup for dependencies.
76+
5. **Dependency Caching**: Optional caching for better performance.
77+
6. **Graceful Shutdown**: Automatic cleanup on program exit.
78+
7. **Event Loop Management**: Control the event loop to ensure the objects created by `fastapi-injectable` are executed in the right loop.
7879

7980
## Overview
8081

@@ -148,6 +149,42 @@ result = get_injected_obj(process_data)
148149
print(result) # Output: 'data'
149150
```
150151

152+
### Manual Overrides
153+
154+
Sometimes you want to use FastAPI’s dependency injection system, but still explicitly pass certain arguments yourself.
155+
156+
For example, in tests you may want to supply a mock instead of the default dependency, or in CLI tools you may want to provide a value directly.
157+
158+
`fastapi-injectable` makes this possible by allowing manual overrides: any arguments you pass will take priority over injected dependencies.
159+
160+
```python
161+
from typing import Annotated
162+
from fastapi import Depends
163+
from fastapi_injectable import get_injected_obj, injectable
164+
165+
class Database:
166+
def query(self) -> str:
167+
return "real data"
168+
169+
def get_db() -> Database:
170+
return Database()
171+
172+
@injectable
173+
def process_data(db: Annotated[Database, Depends(get_db)]) -> str:
174+
return db.query()
175+
176+
# Normal usage – resolved through DI
177+
print(process_data())
178+
# Output: "real data"
179+
180+
# Override dependency manually (great for tests)
181+
mock_db = Database()
182+
mock_db.query = lambda: "mock data"
183+
184+
print(process_data(db=mock_db)) # Explicitly pass the mock dependency
185+
# Output: "mock data"
186+
```
187+
151188
### Generator Dependencies with Cleanup
152189

153190
When working with generator dependencies that require cleanup (like database connections or file handles), `fastapi-injectable` provides built-in support for controlling dependency lifecycles and proper resource management with error handling.

src/fastapi_injectable/decorator.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -125,18 +125,20 @@ def decorator(
125125

126126
@wraps(target)
127127
async def async_wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
128-
dependencies = await resolve_dependencies(func=target, use_cache=use_cache)
128+
dependencies = await resolve_dependencies(func=target, use_cache=use_cache, provided_kwargs=kwargs)
129129
return await cast("Callable[..., Coroutine[Any, Any, T]]", target)(*args, **{**dependencies, **kwargs})
130130

131131
@wraps(target)
132132
async def async_gen_wrapper(*args: P.args, **kwargs: P.kwargs) -> AsyncGenerator[T, Any]:
133-
dependencies = await resolve_dependencies(func=target, use_cache=use_cache)
133+
dependencies = await resolve_dependencies(func=target, use_cache=use_cache, provided_kwargs=kwargs)
134134
async for x in cast("Callable[..., AsyncGenerator[T, Any]]", target)(*args, **{**dependencies, **kwargs}):
135135
yield x
136136

137137
@wraps(target)
138138
def sync_wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
139-
dependencies = run_coroutine_sync(resolve_dependencies(func=target, use_cache=use_cache))
139+
dependencies = run_coroutine_sync(
140+
resolve_dependencies(func=target, use_cache=use_cache, provided_kwargs=kwargs)
141+
)
140142
return cast("Callable[..., T]", target)(*args, **{**dependencies, **kwargs})
141143

142144
if is_async_generator:

src/fastapi_injectable/main.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,10 @@ def _get_app() -> FastAPI | None:
2727

2828

2929
async def resolve_dependencies(
30-
func: Callable[P, T] | Callable[P, Awaitable[T]], *, use_cache: bool = True
30+
func: Callable[P, T] | Callable[P, Awaitable[T]],
31+
*,
32+
use_cache: bool = True,
33+
provided_kwargs: dict[str, Any] | None = None,
3134
) -> dict[str, Any]:
3235
"""Resolve dependencies for the given function using FastAPI's dependency injection system.
3336
@@ -38,18 +41,24 @@ async def resolve_dependencies(
3841
func: The function for which dependencies need to be resolved. It can be a synchronous
3942
or asynchronous callable.
4043
use_cache: Whether to use a cache for dependency resolution. Defaults to True.
44+
provided_kwargs: Explicit kwargs passed by the caller (these override DI).
4145
4246
Returns:
4347
A dictionary mapping argument names to resolved dependency values.
4448
4549
Notes:
4650
- A fake HTTP request is created to mimic FastAPI's request-based dependency resolution.
4751
"""
52+
provided_kwargs = provided_kwargs or {}
4853
root_dep = get_dependant(path="command", call=func)
4954

5055
# Get names of actual dependency (Depends()) parameters
5156
dependency_names = {param.name for param in root_dep.dependencies if param.name}
5257

58+
# Drop dependencies that are already satisfied by provided kwargs
59+
effective_dependencies = [dep for dep in root_dep.dependencies if dep.name not in provided_kwargs]
60+
root_dep.dependencies = effective_dependencies
61+
5362
fake_request_scope: dict[str, Any] = {
5463
"type": "http",
5564
"headers": [],
@@ -59,7 +68,7 @@ async def resolve_dependencies(
5968
if app is not None:
6069
fake_request_scope["app"] = app
6170
fake_request = Request(fake_request_scope)
62-
root_dep.call = cast(Callable[..., Any], root_dep.call)
71+
root_dep.call = cast("Callable[..., Any]", root_dep.call)
6372
async_exit_stack = await async_exit_stack_manager.get_stack(root_dep.call)
6473
cache = dependency_cache.get() if use_cache else None
6574
solved_dependency = await solve_dependencies(
@@ -73,6 +82,8 @@ async def resolve_dependencies(
7382
if cache is not None:
7483
cache.update(solved_dependency.dependency_cache)
7584

76-
return {
85+
resolved = {
7786
param_name: value for param_name, value in solved_dependency.values.items() if param_name in dependency_names
7887
}
88+
89+
return {**resolved, **provided_kwargs}

test/test_injectable.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -409,3 +409,77 @@ def get_capital(mayor: Annotated[Mayor | None, Depends(get_mayor)]) -> Capital |
409409
param = next(iter(sig.parameters.values()))
410410

411411
assert type(param.default).__name__.startswith("Injected")
412+
413+
414+
def test_injectable_sync_override_country() -> None:
415+
def get_mayor() -> Mayor:
416+
return Mayor()
417+
418+
def get_capital(mayor: Annotated[Mayor, Depends(get_mayor)]) -> Capital:
419+
return Capital(mayor)
420+
421+
@injectable
422+
def get_country(capital: Annotated[Capital, Depends(get_capital)]) -> Country:
423+
return Country(capital)
424+
425+
# automatic DI
426+
country_injected = get_country()
427+
assert isinstance(country_injected, Country)
428+
assert isinstance(country_injected.capital, Capital)
429+
assert isinstance(country_injected.capital.mayor, Mayor)
430+
431+
# manual override
432+
capital = Capital(Mayor())
433+
country_manual = get_country(capital=capital)
434+
assert country_manual.capital is capital
435+
assert country_manual.capital.mayor is capital.mayor
436+
437+
438+
async def test_injectable_async_override_country() -> None:
439+
async def get_mayor() -> Mayor:
440+
return Mayor()
441+
442+
async def get_capital(mayor: Annotated[Mayor, Depends(get_mayor)]) -> Capital:
443+
return Capital(mayor)
444+
445+
@injectable
446+
async def get_country(capital: Annotated[Capital, Depends(get_capital)]) -> Country:
447+
return Country(capital)
448+
449+
country_injected = await get_country()
450+
assert isinstance(country_injected, Country)
451+
assert isinstance(country_injected.capital, Capital)
452+
assert isinstance(country_injected.capital.mayor, Mayor)
453+
454+
capital = Capital(Mayor())
455+
country_manual = await get_country(capital=capital)
456+
assert country_manual.capital is capital
457+
assert country_manual.capital.mayor is capital.mayor
458+
459+
460+
async def test_injectable_async_gen_override_country() -> None:
461+
async def get_mayor() -> Mayor:
462+
return Mayor()
463+
464+
async def get_capital(mayor: Annotated[Mayor, Depends(get_mayor)]) -> Capital:
465+
return Capital(mayor)
466+
467+
@injectable
468+
async def get_country(capital: Annotated[Capital, Depends(get_capital)]) -> Country:
469+
yield Country(capital)
470+
471+
country_injected: Country | None = None
472+
async for c in get_country():
473+
country_injected = c
474+
break
475+
assert isinstance(country_injected, Country)
476+
assert isinstance(country_injected.capital, Capital)
477+
assert isinstance(country_injected.capital.mayor, Mayor)
478+
479+
capital = Capital(Mayor())
480+
country_manual: Country | None = None
481+
async for c in get_country(capital=capital):
482+
country_manual = c
483+
break
484+
assert country_manual.capital is capital
485+
assert country_manual.capital.mayor is capital.mayor

test/test_main.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,3 +221,29 @@ def func() -> None:
221221
# Verify app was included in request scope
222222
called_args = mock_solve_dependencies.call_args[1]
223223
assert called_args["request"].scope["app"] == mock_get_app.return_value
224+
225+
226+
async def test_resolve_dependencies_with_provided_kwargs(
227+
mock_solve_dependencies: AsyncMock,
228+
mock_get_dependant: Mock,
229+
mock_dependency_cache: Mock,
230+
mock_async_exit_stack_manager: Mock,
231+
) -> None:
232+
dependency_param_name = "dep"
233+
mock_dependant_dependency = Mock()
234+
mock_dependant_dependency.name = dependency_param_name
235+
mock_get_dependant.return_value.dependencies = [
236+
mock_dependant_dependency,
237+
]
238+
mock_solve_dependencies.return_value = AsyncMock(values={}, dependency_cache={})
239+
240+
def func(dep: DummyDependency) -> None:
241+
return None
242+
243+
provided_dep = DummyDependency()
244+
provided_kwargs = {dependency_param_name: provided_dep}
245+
dependencies = await resolve_dependencies(func, provided_kwargs=provided_kwargs)
246+
247+
assert dependencies == {dependency_param_name: provided_dep}
248+
assert mock_get_dependant.return_value.dependencies == []
249+
mock_solve_dependencies.assert_awaited_once()

0 commit comments

Comments
 (0)