Skip to content

Commit fdcb515

Browse files
Allow entering scope when using @Inject. (#189)
* Updated benchmarks with logging. * Updated benchmark. * Enabled automatic dependency injection by type. * Wrote tests for type based injections. * Minor documentation fixes. * Refactored inject method. * Added covariant binding. * Corrected to contravariant and added docs. * Extended docs. * Improved bind docstring. * Added enter_scope to inject. * Extended documentation. * Adjusted migration guide. * Removed some white space in docs.
1 parent 8d7a24c commit fdcb515

File tree

4 files changed

+92
-24
lines changed

4 files changed

+92
-24
lines changed

docs/introduction/scopes.md

Lines changed: 13 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ def foo(...):
144144
```
145145

146146
The `@inject` wrapper will enter a new context for each injected provider that matches the specified scope.
147-
However, it will not enter the scope!
147+
However, it will not enter the scope by default!
148148

149149
Here is a simple example:
150150
```python hl_lines="5 10"
@@ -188,32 +188,30 @@ injected()
188188
2. Context for `Container.provider` is initialized and will exit when the function returns.
189189
3. This assertion will pass since the context for this provider is still the same.
190190

191-
If you want to enter a scope for the duration of the function you can use the following pattern:
192-
```python hl_lines="4 10"
191+
This implementation might seem complex at first glance, but it providers the following advantages:
192+
193+
- Only context for `ContextResource` providers you need is initialized. This improves performance.
194+
- It discourages explicit resolution via `.resolve()` or `.resolve_sync()` in the function body.
195+
This pattern should be avoided since defining providers in function parameters allows for overriding by just passing an argument
196+
instead of having to override the provider.
197+
198+
### Entering a scope with `@inject`
199+
If you want to enter a scope for the duration of the function you can set `enter_scope=True` when using `@inject`:
200+
```python hl_lines="4 9"
193201
class Container(BaseContainer):
194202
default_scope = ContextScopes.INJECT
195203
provider = providers.ContextResource(iterator).with_config(scope=ContextScopes.INJECT)
196204
another_provider = providers.ContextResource(iterator).with_config(scope=ContextScopes.INJECT)
197205

198-
@container_context(scope=ContextScopes.INJECT)
199-
@inject(scope=None) # (3)!
200-
def injected(v: int = Provide[Container.provider]) -> int: # (2)!
206+
@inject(scope=ContextScopes.INJECT, enter_scope=True)
207+
def injected(v: int = Provide[Container.provider]) -> int:
201208
assert get_current_scope() == ContextScopes.INJECT
202209
Container.another_provider.resolve_sync() # (1)!
203210
return v
204211
```
205212

206213
1. This will resolve since this resource has been initialized when you entered the `INJECT` scope.
207-
2. `Container.provider` was resolved successfully since the `INJECT` scope was entered before the function was called.
208-
3. `scope=None` means that the `@inject` wrapper will ignore scopes. It will not resolve `None`-scoped ContextResources!
209-
210-
211-
This implementation is complex but it providers the following advantages:
212214

213-
- Only context for `ContextResources` you need is initialized. This improves performance.
214-
- It discourages explicit resolution via `.resolve()` or `.resolve_sync()` in the function body.
215-
This pattern should be avoided since defining providers in function parameters allows for overriding by just passing an argument
216-
instead of having to override the provider.
217215

218216
## Implementing custom scopes
219217

docs/migration/v3.md

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -59,14 +59,13 @@ def injected(...): ...
5959
```
6060

6161
In `3.*` the scope is only used to resolve relevant dependencies that match the provided scope.
62-
Thus, to achieve the same behaviour as in `2.*` you need to use the `@container_context` context manager:
63-
```python
64-
@container_context(scope=ContextScope.REQUEST)
65-
@inject(scope=None) # None is required for exactly the same behaviour as in 2.*
62+
Thus, to achieve the same behaviour as in `2.*` you need to set `enter_scope=True`:
63+
```python
64+
@inject(scope=ContextScopes.REQUEST, enter_scope=True)
6665
def injected(...): ...
6766
assert get_current_scope() == ContextScopes.REQUEST
6867
```
69-
This fix will work, but is not recommended, please refer to the [scopes documentation](../introduction/scopes.md#named-scopes-with-the-inject-wrapper)
68+
For further details, please refer to the [scopes documentation](../introduction/scopes.md#named-scopes-with-the-inject-wrapper)
7069

7170
---
7271

tests/test_injection.py

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import pytest
1010

1111
from tests import container
12-
from that_depends import BaseContainer, ContextScopes, Provide, container_context, inject, providers
12+
from that_depends import BaseContainer, ContextScopes, Provide, container_context, get_current_scope, inject, providers
1313
from that_depends.injection import ContextProviderError, StringProviderDefinition
1414

1515

@@ -1038,3 +1038,54 @@ async def _injected(val: float = Provide()) -> float:
10381038

10391039
with pytest.raises(RuntimeError):
10401040
await _injected()
1041+
1042+
1043+
def test_inject_with_enter_scope_enters_scope_sync() -> None:
1044+
def _sync_creator() -> typing.Iterator[float]:
1045+
yield random.random()
1046+
1047+
class _Container(BaseContainer):
1048+
resource = providers.ContextResource(_sync_creator).with_config(scope=ContextScopes.INJECT)
1049+
1050+
@inject(enter_scope=True)
1051+
def _injected(val: float = Provide[_Container.resource]) -> float:
1052+
assert get_current_scope() is ContextScopes.INJECT
1053+
return val
1054+
1055+
assert isinstance(_injected(), float)
1056+
1057+
1058+
async def test_inject_with_enter_scope_enters_scope_async() -> None:
1059+
async def _async_creator() -> typing.AsyncIterator[float]:
1060+
yield random.random()
1061+
1062+
class _Container(BaseContainer):
1063+
resource = providers.ContextResource(_async_creator).with_config(scope=ContextScopes.INJECT)
1064+
1065+
@inject(enter_scope=True)
1066+
async def _injected(val: float = Provide[_Container.resource]) -> float:
1067+
assert get_current_scope() is ContextScopes.INJECT
1068+
return val
1069+
1070+
assert isinstance(await _injected(), float)
1071+
1072+
1073+
def test_inject_with_none_scope_and_enter_scope_raises() -> None:
1074+
with pytest.raises(ValueError, match="enter_scope cannot be used with scope=None."):
1075+
inject(scope=None, enter_scope=True)
1076+
1077+
1078+
def test_enter_scope_raises_with_generator_sync() -> None:
1079+
with pytest.raises(ValueError, match="enter_scope cannot be used with generator functions."):
1080+
1081+
@inject(enter_scope=True)
1082+
def _injected(val: float = Provide["C.a"]) -> typing.Generator[float, None, None]:
1083+
yield val # pragma: no cover
1084+
1085+
1086+
async def test_enter_scope_raises_with_generator_async() -> None:
1087+
with pytest.raises(ValueError, match="enter_scope cannot be used with async generator functions."):
1088+
1089+
@inject(enter_scope=True)
1090+
async def _injected(val: float = Provide["C.a"]) -> typing.AsyncGenerator[float, None]:
1091+
yield val # pragma: no cover

that_depends/injection.py

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from that_depends.exceptions import TypeNotBoundError
1313
from that_depends.meta import BaseContainerMeta
1414
from that_depends.providers import AbstractProvider, ContextResource
15-
from that_depends.providers.context_resources import ContextScope, ContextScopes
15+
from that_depends.providers.context_resources import ContextScope, ContextScopes, container_context
1616
from that_depends.providers.mixin import ProviderWithArguments
1717

1818

@@ -38,20 +38,23 @@ def inject(
3838
*,
3939
scope: ContextScope | None = ContextScopes.INJECT,
4040
container: BaseContainerMeta | None = None,
41+
enter_scope: bool = False,
4142
) -> typing.Callable[[typing.Callable[P, T]], typing.Callable[P, T]]: ...
4243

4344

4445
def inject( # noqa: C901
4546
func: typing.Callable[P, T] | None = None,
4647
scope: ContextScope | None = ContextScopes.INJECT,
4748
container: BaseContainerMeta | None = None,
49+
enter_scope: bool = False,
4850
) -> typing.Callable[P, T] | typing.Callable[[typing.Callable[P, T]], typing.Callable[P, T]]:
4951
"""Mark a function for dependency injection.
5052
5153
Args:
5254
func: function or generator function to be wrapped.
5355
scope: scope to initialize ContextResources for.
5456
container: container from which to resolve dependencies marked with `Provide()`.
57+
enter_scope: enter the provided scope.
5558
5659
Returns:
5760
wrapped function.
@@ -60,13 +63,22 @@ def inject( # noqa: C901
6063
if scope == ContextScopes.ANY:
6164
msg = f"{scope} is not allowed in inject decorator."
6265
raise ValueError(msg)
66+
if scope is None and enter_scope:
67+
msg = "enter_scope cannot be used with scope=None."
68+
raise ValueError(msg)
6369

6470
def _inject(
6571
func: typing.Callable[P, T],
6672
) -> typing.Callable[P, T]:
6773
if inspect.isasyncgenfunction(func):
74+
if enter_scope:
75+
msg = "enter_scope cannot be used with async generator functions."
76+
raise ValueError(msg)
6877
return typing.cast(typing.Callable[P, T], _inject_to_async_gen(func))
6978
if inspect.isgeneratorfunction(func):
79+
if enter_scope:
80+
msg = "enter_scope cannot be used with generator functions."
81+
raise ValueError(msg)
7082
return typing.cast(typing.Callable[P, T], _inject_to_sync_gen(func))
7183
if inspect.iscoroutinefunction(func):
7284
return typing.cast(typing.Callable[P, T], _inject_to_async(func))
@@ -112,7 +124,11 @@ def _inject_to_async(
112124
) -> typing.Callable[P, typing.Coroutine[typing.Any, typing.Any, T]]:
113125
@functools.wraps(func)
114126
async def inner(*args: P.args, **kwargs: P.kwargs) -> T:
115-
return await _resolve_async(func, scope, container, *args, **kwargs)
127+
if enter_scope:
128+
async with container_context(scope=scope):
129+
return await _resolve_async(func, None, container, *args, **kwargs)
130+
else:
131+
return await _resolve_async(func, scope, container, *args, **kwargs)
116132

117133
return inner
118134

@@ -121,7 +137,11 @@ def _inject_to_sync(
121137
) -> typing.Callable[P, T]:
122138
@functools.wraps(func)
123139
def inner(*args: P.args, **kwargs: P.kwargs) -> T:
124-
return _resolve_sync(func, scope, container, *args, **kwargs)
140+
if enter_scope:
141+
with container_context(scope=scope):
142+
return _resolve_sync(func, None, container, *args, **kwargs)
143+
else:
144+
return _resolve_sync(func, scope, container, *args, **kwargs)
125145

126146
return inner
127147

0 commit comments

Comments
 (0)