python-injection 0.25.10__tar.gz → 0.25.12__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. {python_injection-0.25.10 → python_injection-0.25.12}/PKG-INFO +1 -1
  2. python_injection-0.25.12/injection/_core/common/threading.py +13 -0
  3. {python_injection-0.25.10 → python_injection-0.25.12}/injection/_core/injectables.py +27 -14
  4. {python_injection-0.25.10 → python_injection-0.25.12}/injection/_core/locator.py +5 -4
  5. {python_injection-0.25.10 → python_injection-0.25.12}/injection/_core/scope.py +28 -47
  6. {python_injection-0.25.10 → python_injection-0.25.12}/pyproject.toml +1 -1
  7. python_injection-0.25.10/injection/_core/common/threading.py +0 -11
  8. {python_injection-0.25.10 → python_injection-0.25.12}/.gitignore +0 -0
  9. {python_injection-0.25.10 → python_injection-0.25.12}/LICENSE +0 -0
  10. {python_injection-0.25.10 → python_injection-0.25.12}/docs/index.md +0 -0
  11. {python_injection-0.25.10 → python_injection-0.25.12}/injection/__init__.py +0 -0
  12. {python_injection-0.25.10 → python_injection-0.25.12}/injection/__init__.pyi +0 -0
  13. {python_injection-0.25.10 → python_injection-0.25.12}/injection/_core/__init__.py +0 -0
  14. {python_injection-0.25.10 → python_injection-0.25.12}/injection/_core/asfunction.py +0 -0
  15. {python_injection-0.25.10 → python_injection-0.25.12}/injection/_core/common/__init__.py +0 -0
  16. {python_injection-0.25.10 → python_injection-0.25.12}/injection/_core/common/asynchronous.py +0 -0
  17. {python_injection-0.25.10 → python_injection-0.25.12}/injection/_core/common/event.py +0 -0
  18. {python_injection-0.25.10 → python_injection-0.25.12}/injection/_core/common/invertible.py +0 -0
  19. {python_injection-0.25.10 → python_injection-0.25.12}/injection/_core/common/key.py +0 -0
  20. {python_injection-0.25.10 → python_injection-0.25.12}/injection/_core/common/lazy.py +0 -0
  21. {python_injection-0.25.10 → python_injection-0.25.12}/injection/_core/common/type.py +0 -0
  22. {python_injection-0.25.10 → python_injection-0.25.12}/injection/_core/descriptors.py +0 -0
  23. {python_injection-0.25.10 → python_injection-0.25.12}/injection/_core/module.py +0 -0
  24. {python_injection-0.25.10 → python_injection-0.25.12}/injection/_core/slots.py +0 -0
  25. {python_injection-0.25.10 → python_injection-0.25.12}/injection/entrypoint.py +0 -0
  26. {python_injection-0.25.10 → python_injection-0.25.12}/injection/exceptions.py +0 -0
  27. {python_injection-0.25.10 → python_injection-0.25.12}/injection/ext/__init__.py +0 -0
  28. {python_injection-0.25.10 → python_injection-0.25.12}/injection/ext/fastapi.py +0 -0
  29. {python_injection-0.25.10 → python_injection-0.25.12}/injection/ext/fastapi.pyi +0 -0
  30. {python_injection-0.25.10 → python_injection-0.25.12}/injection/loaders.py +0 -0
  31. {python_injection-0.25.10 → python_injection-0.25.12}/injection/py.typed +0 -0
  32. {python_injection-0.25.10 → python_injection-0.25.12}/injection/testing/__init__.py +0 -0
  33. {python_injection-0.25.10 → python_injection-0.25.12}/injection/testing/__init__.pyi +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-injection
3
- Version: 0.25.10
3
+ Version: 0.25.12
4
4
  Summary: Dead-simple dependency injection framework for Python.
5
5
  Project-URL: Documentation, https://python-injection.remimd.dev
6
6
  Project-URL: Repository, https://github.com/100nm/python-injection
@@ -0,0 +1,13 @@
1
+ from contextlib import nullcontext
2
+ from os import getenv
3
+ from threading import RLock
4
+ from typing import Any, ContextManager, Final
5
+
6
+ _PYTHON_INJECTION_THREADSAFE: Final[bool] = bool(
7
+ int(getenv("PYTHON_INJECTION_THREADSAFE", 0))
8
+ )
9
+
10
+
11
+ def get_lock(threadsafe: bool | None = None) -> ContextManager[Any]:
12
+ threadsafe = _PYTHON_INJECTION_THREADSAFE if threadsafe is None else threadsafe
13
+ return RLock() if threadsafe else nullcontext()
@@ -1,6 +1,6 @@
1
1
  from abc import ABC, abstractmethod
2
- from collections.abc import Awaitable, Callable, MutableMapping
3
- from contextlib import suppress
2
+ from collections.abc import Awaitable, Callable, Iterator, MutableMapping
3
+ from contextlib import contextmanager, suppress
4
4
  from dataclasses import dataclass, field
5
5
  from functools import partial
6
6
  from typing import (
@@ -16,12 +16,7 @@ from typing import (
16
16
 
17
17
  from injection._core.common.asynchronous import AsyncSemaphore, Caller
18
18
  from injection._core.common.type import InputType
19
- from injection._core.scope import (
20
- Scope,
21
- get_scope,
22
- in_scope_cache,
23
- remove_scoped_values,
24
- )
19
+ from injection._core.scope import Scope, get_scope, in_scope_cache
25
20
  from injection._core.slots import SlotKey
26
21
  from injection.exceptions import EmptySlotError, InjectionError
27
22
 
@@ -58,11 +53,13 @@ class TransientInjectable[T](Injectable[T]):
58
53
 
59
54
 
60
55
  class CacheLogic[T]:
61
- __slots__ = ("__semaphore",)
56
+ __slots__ = ("__is_instantiating", "__semaphore")
62
57
 
58
+ __is_instantiating: bool
63
59
  __semaphore: AsyncContextManager[Any]
64
60
 
65
61
  def __init__(self) -> None:
62
+ self.__is_instantiating = False
66
63
  self.__semaphore = AsyncSemaphore(1)
67
64
 
68
65
  async def aget_or_create[K](
@@ -71,11 +68,14 @@ class CacheLogic[T]:
71
68
  key: K,
72
69
  factory: Callable[..., Awaitable[T]],
73
70
  ) -> T:
71
+ self.__fail_if_instantiating()
74
72
  async with self.__semaphore:
75
73
  with suppress(KeyError):
76
74
  return cache[key]
77
75
 
78
- instance = await factory()
76
+ with self.__instantiating():
77
+ instance = await factory()
78
+
79
79
  cache[key] = instance
80
80
 
81
81
  return instance
@@ -86,13 +86,29 @@ class CacheLogic[T]:
86
86
  key: K,
87
87
  factory: Callable[..., T],
88
88
  ) -> T:
89
+ self.__fail_if_instantiating()
89
90
  with suppress(KeyError):
90
91
  return cache[key]
91
92
 
92
- instance = factory()
93
+ with self.__instantiating():
94
+ instance = factory()
95
+
93
96
  cache[key] = instance
94
97
  return instance
95
98
 
99
+ def __fail_if_instantiating(self) -> None:
100
+ if self.__is_instantiating:
101
+ raise RecursionError("Recursive call detected during instantiation.")
102
+
103
+ @contextmanager
104
+ def __instantiating(self) -> Iterator[None]:
105
+ self.__is_instantiating = True
106
+
107
+ try:
108
+ yield
109
+ finally:
110
+ self.__is_instantiating = False
111
+
96
112
 
97
113
  @dataclass(repr=False, eq=False, frozen=True, slots=True)
98
114
  class SingletonInjectable[T](Injectable[T]):
@@ -204,9 +220,6 @@ class SimpleScopedInjectable[T](ScopedInjectable[T, T]):
204
220
  def build(self, scope: Scope) -> T:
205
221
  return self.factory.call()
206
222
 
207
- def unlock(self) -> None:
208
- remove_scoped_values(self.key, self.scope_name)
209
-
210
223
 
211
224
  @dataclass(repr=False, eq=False, frozen=True, slots=True)
212
225
  class ScopedSlotInjectable[T](Injectable[T]):
@@ -61,6 +61,7 @@ class InjectionProvider(ABC):
61
61
  self,
62
62
  wrapped: Callable[P, T],
63
63
  /,
64
+ threadsafe: bool | None = ...,
64
65
  ) -> Callable[P, T]:
65
66
  raise NotImplementedError
66
67
 
@@ -108,7 +109,7 @@ class DynamicInjectableBroker[T](InjectableBroker[T]):
108
109
 
109
110
  injectable = _make_injectable(
110
111
  self.factory,
111
- provider.make_injected_function(self.recipe), # type: ignore[misc]
112
+ provider.make_injected_function(self.recipe, threadsafe=False), # type: ignore[misc]
112
113
  )
113
114
  self.injectables[provider] = injectable
114
115
  return injectable
@@ -116,16 +117,16 @@ class DynamicInjectableBroker[T](InjectableBroker[T]):
116
117
 
117
118
  @dataclass(repr=False, eq=False, frozen=True, slots=True)
118
119
  class StaticInjectableBroker[T](InjectableBroker[T]):
119
- value: Injectable[T]
120
+ injectable: Injectable[T]
120
121
 
121
122
  def get(self, provider: InjectionProvider) -> Injectable[T] | None:
122
- return self.value
123
+ return self.injectable
123
124
 
124
125
  def is_locked(self, provider: InjectionProvider) -> bool:
125
126
  return False
126
127
 
127
128
  def request(self, provider: InjectionProvider) -> Injectable[T]:
128
- return self.value
129
+ return self.injectable
129
130
 
130
131
  @classmethod
131
132
  def from_factory(
@@ -3,7 +3,7 @@ from __future__ import annotations
3
3
  import itertools
4
4
  from abc import ABC, abstractmethod
5
5
  from collections import defaultdict
6
- from collections.abc import AsyncIterator, Iterator, Mapping, MutableMapping
6
+ from collections.abc import AsyncIterator, Collection, Iterator, Mapping, MutableMapping
7
7
  from contextlib import AsyncExitStack, ExitStack, asynccontextmanager, contextmanager
8
8
  from contextvars import ContextVar
9
9
  from dataclasses import dataclass, field
@@ -47,12 +47,12 @@ type ScopeKindStr = Literal["contextual", "shared"]
47
47
 
48
48
 
49
49
  @runtime_checkable
50
- class ScopeState(Protocol):
50
+ class ScopeResolver(Protocol):
51
51
  __slots__ = ()
52
52
 
53
53
  @property
54
54
  @abstractmethod
55
- def active_scopes(self) -> Iterator[Scope]:
55
+ def active_scopes(self) -> Collection[Scope]:
56
56
  raise NotImplementedError
57
57
 
58
58
  @abstractmethod
@@ -65,8 +65,8 @@ class ScopeState(Protocol):
65
65
 
66
66
 
67
67
  @dataclass(repr=False, frozen=True, slots=True)
68
- class _ContextualScopeState(ScopeState):
69
- # Shouldn't be instantiated outside `__CONTEXTUAL_SCOPES`.
68
+ class _ContextualScopeResolver(ScopeResolver):
69
+ # Shouldn't be instantiated outside `__scope_resolvers`.
70
70
 
71
71
  __context_var: ContextVar[Scope] = field(
72
72
  default_factory=lambda: ContextVar(f"scope@{new_short_key()}"),
@@ -78,8 +78,8 @@ class _ContextualScopeState(ScopeState):
78
78
  )
79
79
 
80
80
  @property
81
- def active_scopes(self) -> Iterator[Scope]:
82
- return iter(self.__references)
81
+ def active_scopes(self) -> Collection[Scope]:
82
+ return self.__references
83
83
 
84
84
  @contextmanager
85
85
  def bind(self, scope: Scope) -> Iterator[None]:
@@ -97,13 +97,13 @@ class _ContextualScopeState(ScopeState):
97
97
 
98
98
 
99
99
  @dataclass(repr=False, slots=True)
100
- class _SharedScopeState(ScopeState):
100
+ class _SharedScopeResolver(ScopeResolver):
101
101
  __scope: Scope | None = field(default=None)
102
102
 
103
103
  @property
104
- def active_scopes(self) -> Iterator[Scope]:
105
- if scope := self.__scope:
106
- yield scope
104
+ def active_scopes(self) -> Collection[Scope]:
105
+ scope = self.__scope
106
+ return () if scope is None else (scope,)
107
107
 
108
108
  @contextmanager
109
109
  def bind(self, scope: Scope) -> Iterator[None]:
@@ -118,12 +118,10 @@ class _SharedScopeState(ScopeState):
118
118
  return self.__scope
119
119
 
120
120
 
121
- __CONTEXTUAL_SCOPES: Final[Mapping[str, ScopeState]] = defaultdict(
122
- _ContextualScopeState,
123
- )
124
- __SHARED_SCOPES: Final[Mapping[str, ScopeState]] = defaultdict(
125
- _SharedScopeState,
126
- )
121
+ __scope_resolvers: Final[Mapping[str, Mapping[str, ScopeResolver]]] = {
122
+ ScopeKind.CONTEXTUAL: defaultdict(_ContextualScopeResolver),
123
+ ScopeKind.SHARED: defaultdict(_SharedScopeResolver),
124
+ }
127
125
 
128
126
 
129
127
  @asynccontextmanager
@@ -150,15 +148,6 @@ def define_scope(
150
148
  yield facade
151
149
 
152
150
 
153
- def get_active_scopes(name: str) -> tuple[Scope, ...]:
154
- active_scopes = (
155
- state.active_scopes
156
- for states in (__CONTEXTUAL_SCOPES, __SHARED_SCOPES)
157
- if (state := states.get(name))
158
- )
159
- return tuple(itertools.chain.from_iterable(active_scopes))
160
-
161
-
162
151
  if TYPE_CHECKING: # pragma: no cover
163
152
 
164
153
  @overload
@@ -169,9 +158,9 @@ if TYPE_CHECKING: # pragma: no cover
169
158
 
170
159
 
171
160
  def get_scope[T](name: str, default: T | EllipsisType = ...) -> Scope | T:
172
- for states in (__CONTEXTUAL_SCOPES, __SHARED_SCOPES):
173
- state = states.get(name)
174
- if state and (scope := state.get_scope()):
161
+ for resolvers in __scope_resolvers.values():
162
+ resolver = resolvers.get(name)
163
+ if resolver and (scope := resolver.get_scope()):
175
164
  return scope
176
165
 
177
166
  if default is Ellipsis:
@@ -183,12 +172,16 @@ def get_scope[T](name: str, default: T | EllipsisType = ...) -> Scope | T:
183
172
 
184
173
 
185
174
  def in_scope_cache(key: SlotKey[Any], scope_name: str) -> bool:
186
- return any(key in scope.cache for scope in get_active_scopes(scope_name))
175
+ return any(key in scope.cache for scope in iter_active_scopes(scope_name))
187
176
 
188
177
 
189
- def remove_scoped_values(key: SlotKey[Any], scope_name: str) -> None:
190
- for scope in get_active_scopes(scope_name):
191
- scope.cache.pop(key, None)
178
+ def iter_active_scopes(name: str) -> Iterator[Scope]:
179
+ active_scopes = (
180
+ resolver.active_scopes
181
+ for resolvers in __scope_resolvers.values()
182
+ if (resolver := resolvers.get(name))
183
+ )
184
+ return itertools.chain.from_iterable(active_scopes)
192
185
 
193
186
 
194
187
  @contextmanager
@@ -201,25 +194,13 @@ def _bind_scope(
201
194
  lock = get_lock(threadsafe)
202
195
 
203
196
  with lock:
204
- match ScopeKind(kind):
205
- case ScopeKind.CONTEXTUAL:
206
- is_already_defined = bool(get_scope(name, default=None))
207
- states = __CONTEXTUAL_SCOPES
208
-
209
- case ScopeKind.SHARED:
210
- is_already_defined = bool(get_active_scopes(name))
211
- states = __SHARED_SCOPES
212
-
213
- case _:
214
- raise NotImplementedError
215
-
216
- if is_already_defined:
197
+ if get_scope(name, default=None):
217
198
  raise ScopeAlreadyDefinedError(
218
199
  f"Scope `{name}` is already defined in the current context."
219
200
  )
220
201
 
221
202
  stack = ExitStack()
222
- stack.enter_context(states[name].bind(scope))
203
+ stack.enter_context(__scope_resolvers[kind][name].bind(scope))
223
204
 
224
205
  try:
225
206
  yield _UserScope(scope, lock)
@@ -24,7 +24,7 @@ test = [
24
24
 
25
25
  [project]
26
26
  name = "python-injection"
27
- version = "0.25.10"
27
+ version = "0.25.12"
28
28
  description = "Dead-simple dependency injection framework for Python."
29
29
  license = "MIT"
30
30
  license-files = ["LICENSE"]
@@ -1,11 +0,0 @@
1
- from contextlib import nullcontext
2
- from os import getenv
3
- from threading import RLock
4
- from typing import Any, ContextManager, Final
5
-
6
- _PYTHON_INJECTION_THREADSAFE: Final[bool] = bool(getenv("PYTHON_INJECTION_THREADSAFE"))
7
-
8
-
9
- def get_lock(threadsafe: bool | None = None) -> ContextManager[Any]:
10
- cond = _PYTHON_INJECTION_THREADSAFE if threadsafe is None else threadsafe
11
- return RLock() if cond else nullcontext()