Skip to content

Commit 8b48cf9

Browse files
authored
feat: ✨ Scoped dependencies
1 parent b7705fe commit 8b48cf9

20 files changed

Lines changed: 988 additions & 217 deletions

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ if __name__ == "__main__":
5656
## Resources
5757

5858
* [**Basic usage**](https://github.com/100nm/python-injection/tree/prod/documentation/basic-usage.md)
59+
* [**Scoped dependencies**](https://github.com/100nm/python-injection/tree/prod/documentation/scoped-dependencies.md)
5960
* [**Testing**](https://github.com/100nm/python-injection/tree/prod/documentation/testing.md)
6061
* [**Advanced usage**](https://github.com/100nm/python-injection/tree/prod/documentation/advanced-usage.md)
6162
* [**Utils**](https://github.com/100nm/python-injection/tree/prod/documentation/utils.md)
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
# Scoped dependencies
2+
3+
The scoped dependencies were created for two reasons:
4+
* To have dependencies that have a defined lifespan.
5+
* To be able to open and close things in a dependency recipe.
6+
7+
## Best practices
8+
9+
* Avoid making a singleton dependent on a scoped dependency.
10+
* Define scope names in a `StrEnum`.
11+
12+
## Scope
13+
14+
The scope is responsible for instance persistence and for cleaning up when it closes.
15+
16+
There are two kinds of scopes:
17+
* **Contextual**: All threads have access to a different scope (based on [contextvars](https://docs.python.org/3.13/library/contextvars.html)).
18+
* **Shared**: All threads have access to the same scope.
19+
20+
First of all, the scope must be defined:
21+
22+
*By default, the `shared` parameter is `False`.*
23+
24+
> Define an asynchronous scope:
25+
26+
```python
27+
from injection import adefine_scope
28+
29+
async def main() -> None:
30+
async with adefine_scope("<scope-name>", shared=True):
31+
...
32+
```
33+
34+
> Define a synchronous scope:
35+
36+
```python
37+
from injection import define_scope
38+
39+
def main() -> None:
40+
with define_scope("<scope-name>", shared=True):
41+
...
42+
```
43+
44+
## Register a scoped dependencies
45+
46+
`@scoped` works exactly like `@injectable`, it just has extra features.
47+
48+
### "contextmanager-like" recipes
49+
50+
*Anything after the `yield` keyword will be executed when the scope is closed.*
51+
52+
> Asynchronous (asynchronous scope required):
53+
54+
```python
55+
from collections.abc import AsyncIterator
56+
from injection import scoped
57+
58+
class Client:
59+
async def open_connection(self) -> None: ...
60+
61+
async def close_connection(self) -> None: ...
62+
63+
@scoped("<scope-name>")
64+
async def client_recipe() -> AsyncIterator[Client]:
65+
# On resolving dependency
66+
client = Client()
67+
await client.open_connection()
68+
69+
try:
70+
yield client
71+
finally:
72+
# On scope close
73+
await client.close_connection()
74+
```
75+
76+
> Synchronous:
77+
78+
```python
79+
from collections.abc import Iterator
80+
from injection import scoped
81+
82+
class Client:
83+
def open_connection(self) -> None: ...
84+
85+
def close_connection(self) -> None: ...
86+
87+
@scoped("<scope-name>")
88+
def client_recipe() -> Iterator[Client]:
89+
# On resolving dependency
90+
client = Client()
91+
client.open_connection()
92+
93+
try:
94+
yield client
95+
finally:
96+
# On scope close
97+
client.close_connection()
98+
```

injection/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,27 @@
11
from ._core.descriptors import LazyInstance
22
from ._core.injectables import Injectable
33
from ._core.module import Mode, Module, Priority, mod
4+
from ._core.scope import adefine_scope, define_scope
45

56
__all__ = (
67
"Injectable",
78
"LazyInstance",
89
"Mode",
910
"Module",
1011
"Priority",
12+
"adefine_scope",
1113
"afind_instance",
1214
"aget_instance",
1315
"aget_lazy_instance",
1416
"constant",
17+
"define_scope",
1518
"find_instance",
1619
"get_instance",
1720
"get_lazy_instance",
1821
"inject",
1922
"injectable",
2023
"mod",
24+
"scoped",
2125
"set_constant",
2226
"should_be_injectable",
2327
"singleton",
@@ -32,6 +36,7 @@
3236
get_lazy_instance = mod().get_lazy_instance
3337
inject = mod().inject
3438
injectable = mod().injectable
39+
scoped = mod().scoped
3540
set_constant = mod().set_constant
3641
should_be_injectable = mod().should_be_injectable
3742
singleton = mod().singleton

injection/__init__.pyi

Lines changed: 38 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,36 @@
11
from abc import abstractmethod
2-
from collections.abc import Awaitable, Callable
3-
from contextlib import ContextDecorator
2+
from collections.abc import AsyncIterator, Awaitable, Callable, Iterator
3+
from contextlib import asynccontextmanager, contextmanager
44
from enum import Enum
55
from logging import Logger
6-
from typing import (
7-
Any,
8-
ContextManager,
9-
Protocol,
10-
Self,
11-
final,
12-
overload,
13-
runtime_checkable,
14-
)
6+
from typing import Any, Final, Protocol, Self, final, overload, runtime_checkable
157

168
from ._core.common.invertible import Invertible as _Invertible
179
from ._core.common.type import InputType as _InputType
1810
from ._core.common.type import TypeInfo as _TypeInfo
1911
from ._core.module import InjectableFactory as _InjectableFactory
2012
from ._core.module import ModeStr, PriorityStr
2113

22-
__module: Module = ...
14+
__MODULE: Final[Module] = ...
2315

24-
afind_instance = __module.afind_instance
25-
aget_instance = __module.aget_instance
26-
aget_lazy_instance = __module.aget_lazy_instance
27-
constant = __module.constant
28-
find_instance = __module.find_instance
29-
get_instance = __module.get_instance
30-
get_lazy_instance = __module.get_lazy_instance
31-
inject = __module.inject
32-
injectable = __module.injectable
33-
set_constant = __module.set_constant
34-
should_be_injectable = __module.should_be_injectable
35-
singleton = __module.singleton
16+
afind_instance = __MODULE.afind_instance
17+
aget_instance = __MODULE.aget_instance
18+
aget_lazy_instance = __MODULE.aget_lazy_instance
19+
constant = __MODULE.constant
20+
find_instance = __MODULE.find_instance
21+
get_instance = __MODULE.get_instance
22+
get_lazy_instance = __MODULE.get_lazy_instance
23+
inject = __MODULE.inject
24+
injectable = __MODULE.injectable
25+
scoped = __MODULE.scoped
26+
set_constant = __MODULE.set_constant
27+
should_be_injectable = __MODULE.should_be_injectable
28+
singleton = __MODULE.singleton
3629

30+
@asynccontextmanager
31+
def adefine_scope(name: str, *, shared: bool = ...) -> AsyncIterator[None]: ...
32+
@contextmanager
33+
def define_scope(name: str, *, shared: bool = ...) -> Iterator[None]: ...
3734
def mod(name: str = ..., /) -> Module:
3835
"""
3936
Short syntax for `Module.from_name`.
@@ -109,6 +106,21 @@ class Module:
109106
always be the same.
110107
"""
111108

109+
def scoped[**P, T](
110+
self,
111+
scope_name: str,
112+
/,
113+
*,
114+
inject: bool = ...,
115+
on: _TypeInfo[T] = (),
116+
mode: Mode | ModeStr = ...,
117+
) -> Any:
118+
"""
119+
Decorator applicable to a class or function or generator function. It is used
120+
to indicate how the scoped instance will be constructed. At injection time, the
121+
injected instance is retrieved from the scope.
122+
"""
123+
112124
def should_be_injectable[T](self, wrapped: type[T] = ..., /) -> Any:
113125
"""
114126
Decorator applicable to a class. It is used to specify whether an injectable
@@ -248,12 +260,13 @@ class Module:
248260
Function to remove a module in use.
249261
"""
250262

263+
@contextmanager
251264
def use_temporarily(
252265
self,
253266
module: Module,
254267
*,
255268
priority: Priority | PriorityStr = ...,
256-
) -> ContextManager[None] | ContextDecorator:
269+
) -> Iterator[None]:
257270
"""
258271
Context manager or decorator for temporary use of a module.
259272
"""

injection/_core/common/asynchronous.py

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,19 @@
11
import asyncio
22
from abc import abstractmethod
3-
from collections.abc import Awaitable, Callable, Generator
3+
from collections.abc import Awaitable, Callable, Coroutine, Generator
44
from dataclasses import dataclass
55
from typing import Any, Protocol, runtime_checkable
66

77

8+
def run_sync[T](coroutine: Coroutine[Any, Any, T]) -> T:
9+
loop = asyncio.get_event_loop()
10+
11+
try:
12+
return loop.run_until_complete(coroutine)
13+
finally:
14+
coroutine.close()
15+
16+
817
@dataclass(repr=False, eq=False, frozen=True, slots=True)
918
class SimpleAwaitable[T](Awaitable[T]):
1019
callable: Callable[..., Awaitable[T]]
@@ -28,21 +37,13 @@ def call(self, /, *args: P.args, **kwargs: P.kwargs) -> T:
2837

2938
@dataclass(repr=False, eq=False, frozen=True, slots=True)
3039
class AsyncCaller[**P, T](Caller[P, T]):
31-
callable: Callable[P, Awaitable[T]]
40+
callable: Callable[P, Coroutine[Any, Any, T]]
3241

3342
async def acall(self, /, *args: P.args, **kwargs: P.kwargs) -> T:
3443
return await self.callable(*args, **kwargs)
3544

3645
def call(self, /, *args: P.args, **kwargs: P.kwargs) -> T:
37-
loop = asyncio.get_event_loop()
38-
39-
if loop.is_running():
40-
raise RuntimeError(
41-
"Can't call an asynchronous function in a synchronous context."
42-
)
43-
44-
coroutine = self.callable(*args, **kwargs)
45-
return loop.run_until_complete(coroutine)
46+
return run_sync(self.callable(*args, **kwargs))
4647

4748

4849
@dataclass(repr=False, eq=False, frozen=True, slots=True)

injection/_core/common/key.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from uuid import uuid4
2+
3+
4+
def new_short_key() -> str:
5+
return uuid4().hex[:7]

injection/_core/common/type.py

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,12 @@
1-
from collections.abc import Callable, Iterable, Iterator
1+
from collections.abc import (
2+
AsyncGenerator,
3+
AsyncIterable,
4+
AsyncIterator,
5+
Callable,
6+
Generator,
7+
Iterable,
8+
Iterator,
9+
)
210
from inspect import isfunction
311
from types import GenericAlias, UnionType
412
from typing import (
@@ -23,7 +31,7 @@ def get_return_types(*args: TypeInfo[Any]) -> Iterator[InputType[Any]]:
2331
):
2432
inner_args = arg
2533

26-
elif isfunction(arg) and (return_type := get_type_hints(arg).get("return")):
34+
elif isfunction(arg) and (return_type := get_return_hint(arg)):
2735
inner_args = (return_type,)
2836

2937
else:
@@ -33,6 +41,29 @@ def get_return_types(*args: TypeInfo[Any]) -> Iterator[InputType[Any]]:
3341
yield from get_return_types(*inner_args)
3442

3543

44+
def get_return_hint[T](function: Callable[..., T]) -> InputType[T] | None:
45+
return get_type_hints(function).get("return")
46+
47+
48+
def get_yield_hint[T](
49+
function: Callable[..., Iterator[T]] | Callable[..., AsyncIterator[T]],
50+
) -> InputType[T] | None:
51+
return_type = get_return_hint(function)
52+
53+
if get_origin(return_type) not in {
54+
AsyncGenerator,
55+
AsyncIterable,
56+
AsyncIterator,
57+
Generator,
58+
Iterable,
59+
Iterator,
60+
}:
61+
return None
62+
63+
args = get_args(return_type)
64+
return next(iter(args), None)
65+
66+
3667
def standardize_types(
3768
*types: InputType[Any],
3869
with_origin: bool = False,

0 commit comments

Comments
 (0)