Skip to content

Commit f4c1a51

Browse files
author
remimd
committed
feat: ⚡️ Scoped slot rework
1 parent 9baf265 commit f4c1a51

11 files changed

Lines changed: 163 additions & 118 deletions

File tree

documentation/integrations.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ class InjectionScope(StrEnum):
5858

5959
@asynccontextmanager
6060
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
61-
async with adefine_scope(InjectionScope.LIFESPAN, shared=True):
61+
async with adefine_scope(InjectionScope.LIFESPAN, kind="shared"):
6262
yield
6363

6464
app = FastAPI(lifespan=lifespan)
@@ -70,7 +70,7 @@ async def define_request_scope_middleware(
7070
request: Request,
7171
handler: Callable[[Request], Awaitable[Response]],
7272
) -> Response:
73-
async with adefine_scope(InjectionScope.REQUEST):
74-
request_slot.set(request)
73+
async with adefine_scope(InjectionScope.REQUEST) as scope:
74+
scope.set_slot(request_slot, request)
7575
return await handler(request)
7676
```

documentation/scoped-dependencies.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,15 @@ There are two kinds of scopes:
1919

2020
First of all, the scope must be defined:
2121

22-
_By default, the `shared` parameter is `False`._
22+
_By default, the `kind` parameter is `"contextual"`._
2323

2424
> Define an asynchronous scope:
2525
2626
```python
2727
from injection import adefine_scope
2828

2929
async def main() -> None:
30-
async with adefine_scope("<scope-name>", shared=True):
30+
async with adefine_scope("<scope-name>"):
3131
...
3232
```
3333

@@ -37,7 +37,7 @@ async def main() -> None:
3737
from injection import define_scope
3838

3939
def main() -> None:
40-
with define_scope("<scope-name>", shared=True):
40+
with define_scope("<scope-name>"):
4141
...
4242
```
4343

@@ -113,7 +113,7 @@ class Request: ...
113113
request_slot = reserve_scoped_slot(Request, scope_name="request")
114114

115115
def process_request(request: Request) -> None:
116-
with define_scope("request"):
117-
request_slot.set(request)
116+
with define_scope("request") as scope:
117+
scope.set_slot(request_slot, request)
118118
# ...
119119
```

injection/__init__.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
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
4+
from ._core.scope import ScopeFacade as Scope
5+
from ._core.scope import ScopeKind, adefine_scope, define_scope
56
from ._core.slots import Slot
67

78
__all__ = (
@@ -10,6 +11,8 @@
1011
"Mode",
1112
"Module",
1213
"Priority",
14+
"Scope",
15+
"ScopeKind",
1316
"Slot",
1417
"adefine_scope",
1518
"afind_instance",

injection/__init__.pyi

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from abc import abstractmethod
2-
from collections.abc import AsyncIterator, Awaitable, Callable, Iterator
2+
from collections.abc import AsyncIterator, Awaitable, Callable, Iterator, Mapping
33
from contextlib import asynccontextmanager, contextmanager
44
from enum import Enum
55
from logging import Logger
@@ -11,6 +11,7 @@ from ._core.common.type import TypeInfo as _TypeInfo
1111
from ._core.module import InjectableFactory as _InjectableFactory
1212
from ._core.module import ModeStr, PriorityStr
1313
from ._core.module import Recipe as _Recipe
14+
from ._core.scope import ScopeKindStr
1415

1516
__MODULE: Final[Module] = ...
1617

@@ -30,9 +31,15 @@ should_be_injectable = __MODULE.should_be_injectable
3031
singleton = __MODULE.singleton
3132

3233
@asynccontextmanager
33-
def adefine_scope(name: str, *, shared: bool = ...) -> AsyncIterator[None]: ...
34+
def adefine_scope(
35+
name: str,
36+
kind: ScopeKind | ScopeKindStr = ...,
37+
) -> AsyncIterator[Scope]: ...
3438
@contextmanager
35-
def define_scope(name: str, *, shared: bool = ...) -> Iterator[None]: ...
39+
def define_scope(
40+
name: str,
41+
kind: ScopeKind | ScopeKindStr = ...,
42+
) -> Iterator[Scope]: ...
3643
def mod(name: str = ..., /) -> Module:
3744
"""
3845
Short syntax for `Module.from_name`.
@@ -47,10 +54,16 @@ class Injectable[T](Protocol):
4754
@abstractmethod
4855
def get_instance(self) -> T: ...
4956

50-
@runtime_checkable
51-
class Slot[T](Protocol):
52-
@abstractmethod
53-
def set(self, instance: T, /) -> None: ...
57+
@final
58+
class ScopeKind(Enum):
59+
CONTEXTUAL = ...
60+
SHARED = ...
61+
62+
class Scope:
63+
def set_slot[T](self, slot: Slot[T], value: T) -> Self: ...
64+
def slot_map(self, values: Mapping[Slot[Any], Any]) -> Self: ...
65+
66+
class Slot[T]: ...
5467

5568
class LazyInstance[T]:
5669
def __init__(
@@ -179,7 +192,7 @@ class Module:
179192

180193
def reserve_scoped_slot[T](
181194
self,
182-
on: _TypeInfo[T],
195+
cls: type[T],
183196
/,
184197
scope_name: str,
185198
*,

injection/_core/injectables.py

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,14 @@
1717
from injection._core.common.asynchronous import (
1818
create_semaphore as _create_async_semaphore,
1919
)
20-
from injection._core.scope import Scope, get_active_scopes, get_scope
21-
from injection.exceptions import InjectionError
20+
from injection._core.scope import (
21+
Scope,
22+
get_scope,
23+
in_scope_cache,
24+
remove_scoped_values,
25+
)
26+
from injection._core.slots import Slot
27+
from injection.exceptions import EmptySlotError, InjectionError
2228

2329

2430
@runtime_checkable
@@ -123,7 +129,7 @@ class ScopedInjectable[R, T](Injectable[T], ABC):
123129

124130
@property
125131
def is_locked(self) -> bool:
126-
return any(self in scope.cache for scope in get_active_scopes(self.scope_name))
132+
return in_scope_cache(self, self.scope_name)
127133

128134
@abstractmethod
129135
async def abuild(self, scope: Scope) -> T:
@@ -143,10 +149,6 @@ def get_instance(self) -> T:
143149
factory = partial(self.build, scope)
144150
return self.logic.get_or_create(scope.cache, self, factory)
145151

146-
def setdefault(self, instance: T) -> T:
147-
scope = self.__get_scope()
148-
return self.logic.get_or_create(scope.cache, self, lambda: instance)
149-
150152
def unlock(self) -> None:
151153
if self.is_locked:
152154
raise RuntimeError(f"To unlock, close the `{self.scope_name}` scope.")
@@ -188,8 +190,35 @@ def build(self, scope: Scope) -> T:
188190
return self.factory.call()
189191

190192
def unlock(self) -> None:
191-
for scope in get_active_scopes(self.scope_name):
192-
scope.cache.pop(self, None)
193+
remove_scoped_values(self, self.scope_name)
194+
195+
196+
@dataclass(repr=False, eq=False, frozen=True, slots=True)
197+
class ScopedSlotInjectable[T](Injectable[T]):
198+
cls: type[T]
199+
scope_name: str
200+
slot: Slot[T] = field(default_factory=Slot)
201+
202+
@property
203+
def is_locked(self) -> bool:
204+
return in_scope_cache(self.slot, self.scope_name)
205+
206+
async def aget_instance(self) -> T:
207+
return self.get_instance()
208+
209+
def get_instance(self) -> T:
210+
scope_name = self.scope_name
211+
scope = get_scope(scope_name)
212+
213+
try:
214+
return scope.cache[self.slot]
215+
except KeyError as exc:
216+
raise EmptySlotError(
217+
f"The slot for `{self.cls}` isn't set in the current `{scope_name}` scope."
218+
) from exc
219+
220+
def unlock(self) -> None:
221+
remove_scoped_values(self.slot, self.scope_name)
193222

194223

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

injection/_core/module.py

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -64,14 +64,14 @@
6464
CMScopedInjectable,
6565
Injectable,
6666
ScopedInjectable,
67+
ScopedSlotInjectable,
6768
ShouldBeInjectable,
6869
SimpleInjectable,
6970
SimpleScopedInjectable,
7071
SingletonInjectable,
7172
)
72-
from injection._core.slots import ScopedSlot, Slot
73+
from injection._core.slots import Slot
7374
from injection.exceptions import (
74-
EmptySlotError,
7575
ModuleError,
7676
ModuleLockError,
7777
ModuleNotUsedError,
@@ -543,21 +543,16 @@ def set_constant[T](
543543

544544
def reserve_scoped_slot[T](
545545
self,
546-
on: TypeInfo[T],
546+
cls: type[T],
547547
/,
548548
scope_name: str,
549549
*,
550550
mode: Mode | ModeStr = Mode.get_default(),
551551
) -> Slot[T]:
552-
def when_empty() -> T:
553-
raise EmptySlotError(
554-
f"The slot for `{on}` isn't set in the current `{scope_name}` scope."
555-
)
556-
557-
injectable = SimpleScopedInjectable(SyncCaller(when_empty), scope_name)
558-
updater = Updater.with_basics(on, injectable, mode)
552+
injectable = ScopedSlotInjectable(cls, scope_name)
553+
updater = Updater.with_basics(cls, injectable, mode)
559554
self.update(updater)
560-
return ScopedSlot(injectable)
555+
return injectable.slot
561556

562557
def inject[**P, T](
563558
self,

injection/_core/scope.py

Lines changed: 70 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,14 @@
77
from contextlib import AsyncExitStack, ExitStack, asynccontextmanager, contextmanager
88
from contextvars import ContextVar
99
from dataclasses import dataclass, field
10+
from enum import StrEnum
1011
from types import EllipsisType, TracebackType
1112
from typing import (
1213
Any,
1314
AsyncContextManager,
1415
ContextManager,
1516
Final,
17+
Literal,
1618
NoReturn,
1719
Protocol,
1820
Self,
@@ -21,13 +23,27 @@
2123
)
2224

2325
from injection._core.common.key import new_short_key
26+
from injection._core.slots import Slot
2427
from injection.exceptions import (
28+
InjectionError,
2529
ScopeAlreadyDefinedError,
2630
ScopeError,
2731
ScopeUndefinedError,
2832
)
2933

3034

35+
class ScopeKind(StrEnum):
36+
CONTEXTUAL = "contextual"
37+
SHARED = "shared"
38+
39+
@classmethod
40+
def get_default(cls) -> ScopeKind:
41+
return cls.CONTEXTUAL
42+
43+
44+
type ScopeKindStr = Literal["contextual", "shared"]
45+
46+
3147
@runtime_checkable
3248
class ScopeState(Protocol):
3349
__slots__ = ()
@@ -109,17 +125,21 @@ def get_scope(self) -> Scope | None:
109125

110126

111127
@asynccontextmanager
112-
async def adefine_scope(name: str, *, shared: bool = False) -> AsyncIterator[None]:
128+
async def adefine_scope(
129+
name: str,
130+
kind: ScopeKind | ScopeKindStr = ScopeKind.get_default(),
131+
) -> AsyncIterator[ScopeFacade]:
113132
async with AsyncScope() as scope:
114-
scope.enter(_bind_scope(name, scope, shared))
115-
yield
133+
yield scope.enter(_bind_scope(name, scope, kind))
116134

117135

118136
@contextmanager
119-
def define_scope(name: str, *, shared: bool = False) -> Iterator[None]:
137+
def define_scope(
138+
name: str,
139+
kind: ScopeKind | ScopeKindStr = ScopeKind.get_default(),
140+
) -> Iterator[ScopeFacade]:
120141
with SyncScope() as scope:
121-
scope.enter(_bind_scope(name, scope, shared))
122-
yield
142+
yield scope.enter(_bind_scope(name, scope, kind))
123143

124144

125145
def get_active_scopes(name: str) -> tuple[Scope, ...]:
@@ -153,23 +173,40 @@ def get_scope(name, default=...): # type: ignore[no-untyped-def]
153173
return default
154174

155175

156-
@contextmanager
157-
def _bind_scope(name: str, scope: Scope, shared: bool) -> Iterator[None]:
158-
if shared:
159-
is_already_defined = bool(get_active_scopes(name))
160-
states = __SHARED_SCOPES
176+
def in_scope_cache(key: Any, scope_name: str) -> bool:
177+
return any(key in scope.cache for scope in get_active_scopes(scope_name))
178+
179+
180+
def remove_scoped_values(key: Any, scope_name: str) -> None:
181+
for scope in get_active_scopes(scope_name):
182+
scope.cache.pop(key, None)
183+
161184

162-
else:
163-
is_already_defined = bool(get_scope(name, default=None))
164-
states = __CONTEXTUAL_SCOPES
185+
@contextmanager
186+
def _bind_scope(
187+
name: str,
188+
scope: Scope,
189+
kind: ScopeKind | ScopeKindStr,
190+
) -> Iterator[ScopeFacade]:
191+
match ScopeKind(kind):
192+
case ScopeKind.CONTEXTUAL:
193+
is_already_defined = bool(get_scope(name, default=None))
194+
states = __CONTEXTUAL_SCOPES
195+
196+
case ScopeKind.SHARED:
197+
is_already_defined = bool(get_active_scopes(name))
198+
states = __SHARED_SCOPES
199+
200+
case _:
201+
raise NotImplementedError
165202

166203
if is_already_defined:
167204
raise ScopeAlreadyDefinedError(
168205
f"Scope `{name}` is already defined in the current context."
169206
)
170207

171208
with states[name].bind(scope):
172-
yield
209+
yield ScopeFacade(scope)
173210

174211

175212
@runtime_checkable
@@ -245,3 +282,21 @@ async def aenter[T](self, context_manager: AsyncContextManager[T]) -> NoReturn:
245282

246283
def enter[T](self, context_manager: ContextManager[T]) -> T:
247284
return self.delegate.enter_context(context_manager)
285+
286+
287+
@dataclass(repr=False, frozen=True, slots=True)
288+
class ScopeFacade:
289+
scope: Scope
290+
291+
def set_slot[T](self, slot: Slot[T], value: T) -> Self:
292+
return self.slot_map({slot: value})
293+
294+
def slot_map(self, values: Mapping[Slot[Any], Any]) -> Self:
295+
cache = self.scope.cache
296+
297+
for slot in values:
298+
if slot in cache:
299+
raise InjectionError("Slot already set.")
300+
301+
cache.update(values)
302+
return self

0 commit comments

Comments
 (0)