Skip to content

Commit 783323d

Browse files
authored
feat: ✨ Introduce MappedScope
1 parent b71a797 commit 783323d

File tree

6 files changed

+295
-73
lines changed

6 files changed

+295
-73
lines changed

documentation/scoped-dependencies.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,50 @@ def main() -> None:
4141
...
4242
```
4343

44+
## MappedScope
45+
46+
`MappedScope` allows you to open a dependency injection scope and register values annotated with `Scoped[...]` so they
47+
can be retrieved by other dependencies within that scope.
48+
49+
### How it works
50+
51+
1. **Define bindings**: Create a class with fields annotated with `Scoped`.
52+
2. **Create scope**: Instantiate `MappedScope` with a scope name.
53+
3. **Open scope**: Use `define` or `adefine` context manager to register the scoped values.
54+
4. **Access dependencies**: Other dependencies can now inject these scoped values within the context.
55+
56+
This is particularly useful for request-scoped dependencies in web applications, where you need to make request-specific
57+
data available throughout the request lifecycle.
58+
59+
Example:
60+
61+
```python
62+
from dataclasses import dataclass
63+
from injection import MappedScope, Scoped
64+
65+
class Request: ...
66+
67+
@dataclass
68+
class RequestBindings:
69+
request: Scoped[Request]
70+
71+
scope = MappedScope("request")
72+
73+
def process_request(request: Request) -> None:
74+
with RequestBindings(request).scope.define():
75+
# Dependencies can now access the scoped Request instance
76+
...
77+
```
78+
79+
For asynchronous contexts, use `adefine`:
80+
81+
```python
82+
async def process_request_async(request: Request) -> None:
83+
async with RequestBindings(request).scope.adefine():
84+
# Dependencies can now access the scoped Request instance
85+
...
86+
```
87+
4488
## Register a scoped dependencies
4589

4690
`@scoped` works exactly like `@injectable`, it just has extra features.
@@ -99,6 +143,9 @@ def client_recipe() -> Iterator[Client]:
99143

100144
### Scoped slots
101145

146+
> [!IMPORTANT]
147+
> It's preferable to use `MappedScope` instead.
148+
102149
Scoped slots allow you to reserve a place for an instance within a predefined scope. This ensures that injected
103150
functions can resolve dependencies efficiently without unnecessary recomputation. This is why the syntax can seem a
104151
little verbose.

injection/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from ._core.asfunction import asfunction
2-
from ._core.descriptors import LazyInstance
2+
from ._core.descriptors import LazyInstance, MappedScope, Scoped
33
from ._core.injectables import Injectable
44
from ._core.module import Mode, Module, Priority, mod
55
from ._core.scope import ScopeFacade as Scope
@@ -9,10 +9,12 @@
99
__all__ = (
1010
"Injectable",
1111
"LazyInstance",
12+
"MappedScope",
1213
"Mode",
1314
"Module",
1415
"Priority",
1516
"Scope",
17+
"Scoped",
1618
"ScopeKind",
1719
"SlotKey",
1820
"adefine_scope",

injection/__init__.pyi

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ from ._core.module import InjectableFactory as _InjectableFactory
1313
from ._core.module import ModeStr, PriorityStr
1414
from ._core.scope import ScopeKindStr
1515

16+
type Scoped[T] = T
1617
type _Decorator[T] = Callable[[T], T]
1718

1819
__MODULE: Final[Module] = ...
@@ -90,6 +91,34 @@ class Scope(Protocol):
9091

9192
class SlotKey[T]: ...
9293

94+
class MappedScope:
95+
def __init__(self, name: str, /, module: Module = ...) -> None: ...
96+
@overload
97+
def __get__(
98+
self,
99+
instance: object,
100+
owner: type | None = ...,
101+
) -> _BoundMappedScope: ...
102+
@overload
103+
def __get__(self, instance: None = ..., owner: type | None = ...) -> Self: ...
104+
def __set_name__(self, owner: type, name: str) -> None: ...
105+
106+
class _BoundMappedScope:
107+
@asynccontextmanager
108+
def adefine(
109+
self,
110+
/,
111+
kind: ScopeKind | ScopeKindStr = ...,
112+
threadsafe: bool | None = ...,
113+
) -> AsyncIterator[None]: ...
114+
@contextmanager
115+
def define(
116+
self,
117+
/,
118+
kind: ScopeKind | ScopeKindStr = ...,
119+
threadsafe: bool | None = ...,
120+
) -> Iterator[None]: ...
121+
93122
class LazyInstance[T]:
94123
def __init__(
95124
self,

injection/_core/descriptors.py

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,98 @@
1-
from typing import Self
1+
from __future__ import annotations
2+
3+
from collections.abc import AsyncIterator, Iterator, Mapping
4+
from contextlib import asynccontextmanager, contextmanager
5+
from dataclasses import dataclass
6+
from types import MappingProxyType
7+
from typing import Any, Self, get_args, get_origin, get_type_hints
28

39
from injection._core.common.invertible import Invertible
410
from injection._core.common.type import InputType
511
from injection._core.module import Module, mod
12+
from injection._core.scope import ScopeKind, ScopeKindStr, adefine_scope, define_scope
13+
from injection._core.slots import SlotKey
14+
15+
type Scoped[T] = T
16+
17+
18+
class MappedScope:
19+
__slots__ = ("__keys", "__module", "__name", "__owner")
20+
21+
__keys: Mapping[str, SlotKey[Any]]
22+
__module: Module
23+
__name: str
24+
__owner: type | None
25+
26+
def __init__(self, name: str, /, module: Module | None = None) -> None:
27+
self.__module = module or mod()
28+
self.__name = name
29+
self.__owner = None
30+
31+
def __get__(
32+
self,
33+
instance: object | None = None,
34+
owner: type | None = None,
35+
) -> Self | BoundMappedScope:
36+
if instance is None:
37+
return self
38+
39+
mapping = self.__mapping_from(instance)
40+
return BoundMappedScope(self.__name, mapping)
41+
42+
def __set_name__(self, owner: type, name: str) -> None:
43+
if self.__owner:
44+
raise TypeError(f"`{self}` owner is already defined.")
45+
46+
self.__keys = MappingProxyType(dict(self.__generate_keys(owner)))
47+
self.__owner = owner
48+
49+
def __generate_keys(self, cls: type) -> Iterator[tuple[str, SlotKey[Any]]]:
50+
for name, hint in get_type_hints(cls).items():
51+
if get_origin(hint) is not Scoped:
52+
continue
53+
54+
annotation = get_args(hint)[0]
55+
key = self.__module.reserve_scoped_slot(annotation, scope_name=self.__name)
56+
yield name, key
57+
58+
def __mapping_from(self, instance: object) -> dict[SlotKey[Any], Any]:
59+
return {
60+
key: value
61+
for name, key in self.__keys.items()
62+
if (value := getattr(instance, name, None)) is not None
63+
}
64+
65+
66+
@dataclass(repr=False, eq=False, frozen=True, slots=True)
67+
class BoundMappedScope:
68+
name: str
69+
mapping: Mapping[SlotKey[Any], Any]
70+
71+
@asynccontextmanager
72+
async def adefine(
73+
self,
74+
/,
75+
kind: ScopeKind | ScopeKindStr = ScopeKind.get_default(),
76+
threadsafe: bool | None = None,
77+
) -> AsyncIterator[None]:
78+
async with adefine_scope(self.name, kind, threadsafe) as scope:
79+
if mapping := self.mapping:
80+
scope.slot_map(mapping)
81+
82+
yield
83+
84+
@contextmanager
85+
def define(
86+
self,
87+
/,
88+
kind: ScopeKind | ScopeKindStr = ScopeKind.get_default(),
89+
threadsafe: bool | None = None,
90+
) -> Iterator[None]:
91+
with define_scope(self.name, kind, threadsafe) as scope:
92+
if mapping := self.mapping:
93+
scope.slot_map(mapping)
94+
95+
yield
696

797

898
class LazyInstance[T]:

tests/core/test_descriptors.py

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,58 @@
1-
from injection import LazyInstance, injectable
1+
from dataclasses import dataclass
2+
3+
import pytest
4+
5+
from injection import LazyInstance, MappedScope, Scoped, injectable
6+
7+
8+
class _RawData: ...
9+
10+
11+
class TestMappedScope:
12+
def test_set_name_with_multiple_owner_raise_type_error(self):
13+
class ContextA:
14+
scope = MappedScope("some_scope")
15+
16+
with pytest.raises(TypeError):
17+
18+
class ContextB:
19+
scope = ContextA.scope
20+
21+
async def test_aopen_with_success(self, module):
22+
@dataclass
23+
class ScopeContext:
24+
data: Scoped[_RawData]
25+
26+
scope = MappedScope("some_scope", module=module)
27+
28+
data = _RawData()
29+
context = ScopeContext(data)
30+
31+
assert module.get_instance(_RawData) is NotImplemented
32+
33+
async with context.scope.adefine():
34+
assert module.get_instance(_RawData) is data
35+
36+
assert module.get_instance(_RawData) is NotImplemented
37+
38+
def test_open_with_success(self, module):
39+
@dataclass
40+
class ScopeContext:
41+
data: Scoped[_RawData]
42+
unscoped_data: int
43+
44+
scope = MappedScope("some_scope", module=module)
45+
46+
data = _RawData()
47+
context = ScopeContext(data, 2)
48+
49+
assert module.get_instance(_RawData) is NotImplemented
50+
51+
with context.scope.define():
52+
assert module.get_instance(_RawData) is data
53+
assert module.get_instance(int) is NotImplemented
54+
55+
assert module.get_instance(_RawData) is NotImplemented
256

357

458
class TestLazyInstance:

0 commit comments

Comments
 (0)