pico-ioc 1.5.0__py3-none-any.whl → 2.0.1__py3-none-any.whl
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.
- pico_ioc/__init__.py +82 -57
- pico_ioc/_version.py +1 -1
- pico_ioc/aop.py +281 -0
- pico_ioc/api.py +1137 -198
- pico_ioc/config_runtime.py +289 -0
- pico_ioc/constants.py +10 -0
- pico_ioc/container.py +420 -148
- pico_ioc/event_bus.py +223 -0
- pico_ioc/exceptions.py +72 -0
- pico_ioc/factory.py +48 -0
- pico_ioc/locator.py +53 -0
- pico_ioc/scope.py +155 -39
- pico_ioc-2.0.1.dist-info/METADATA +243 -0
- pico_ioc-2.0.1.dist-info/RECORD +17 -0
- pico_ioc/_state.py +0 -75
- pico_ioc/builder.py +0 -210
- pico_ioc/config.py +0 -332
- pico_ioc/decorators.py +0 -120
- pico_ioc/infra.py +0 -196
- pico_ioc/interceptors.py +0 -76
- pico_ioc/plugins.py +0 -28
- pico_ioc/policy.py +0 -245
- pico_ioc/proxy.py +0 -115
- pico_ioc/public_api.py +0 -76
- pico_ioc/resolver.py +0 -101
- pico_ioc/scanner.py +0 -178
- pico_ioc/utils.py +0 -25
- pico_ioc-1.5.0.dist-info/METADATA +0 -249
- pico_ioc-1.5.0.dist-info/RECORD +0 -23
- {pico_ioc-1.5.0.dist-info → pico_ioc-2.0.1.dist-info}/WHEEL +0 -0
- {pico_ioc-1.5.0.dist-info → pico_ioc-2.0.1.dist-info}/licenses/LICENSE +0 -0
- {pico_ioc-1.5.0.dist-info → pico_ioc-2.0.1.dist-info}/top_level.txt +0 -0
pico_ioc/event_bus.py
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
# src/pico_ioc/event_bus.py
|
|
2
|
+
import asyncio
|
|
3
|
+
import inspect
|
|
4
|
+
import logging
|
|
5
|
+
import threading
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from enum import Enum, auto
|
|
8
|
+
from typing import Any, Awaitable, Callable, Dict, Iterable, List, Optional, Tuple, Type
|
|
9
|
+
from .api import factory, provides, configure, cleanup
|
|
10
|
+
from .exceptions import EventBusClosedError, EventBusError, EventBusQueueFullError, EventBusHandlerError
|
|
11
|
+
|
|
12
|
+
log = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
class ExecPolicy(Enum):
|
|
15
|
+
INLINE = auto()
|
|
16
|
+
THREADPOOL = auto()
|
|
17
|
+
TASK = auto()
|
|
18
|
+
|
|
19
|
+
class ErrorPolicy(Enum):
|
|
20
|
+
LOG = auto()
|
|
21
|
+
RAISE = auto()
|
|
22
|
+
|
|
23
|
+
class Event: ...
|
|
24
|
+
|
|
25
|
+
@dataclass(order=True)
|
|
26
|
+
class _Subscriber:
|
|
27
|
+
sort_index: int = field(init=False, repr=False, compare=True)
|
|
28
|
+
priority: int = field(compare=False)
|
|
29
|
+
callback: Callable[[Event], Any] | Callable[[Event], Awaitable[Any]] = field(compare=False)
|
|
30
|
+
policy: ExecPolicy = field(compare=False)
|
|
31
|
+
once: bool = field(compare=False)
|
|
32
|
+
def __post_init__(self):
|
|
33
|
+
self.sort_index = -int(self.priority)
|
|
34
|
+
|
|
35
|
+
class EventBus:
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
*,
|
|
39
|
+
default_exec_policy: ExecPolicy = ExecPolicy.INLINE,
|
|
40
|
+
error_policy: ErrorPolicy = ErrorPolicy.LOG,
|
|
41
|
+
max_queue_size: int = 0,
|
|
42
|
+
):
|
|
43
|
+
self._subs: Dict[Type[Event], List[_Subscriber]] = {}
|
|
44
|
+
self._default_policy = default_exec_policy
|
|
45
|
+
self._error_policy = error_policy
|
|
46
|
+
self._queue: Optional[asyncio.Queue[Event]] = asyncio.Queue(max_queue_size) if max_queue_size >= 0 else None
|
|
47
|
+
self._worker_task: Optional[asyncio.Task] = None
|
|
48
|
+
self._worker_loop: Optional[asyncio.AbstractEventLoop] = None
|
|
49
|
+
self._closed = False
|
|
50
|
+
self._lock = threading.RLock()
|
|
51
|
+
|
|
52
|
+
def subscribe(
|
|
53
|
+
self,
|
|
54
|
+
event_type: Type[Event],
|
|
55
|
+
fn: Callable[[Event], Any] | Callable[[Event], Awaitable[Any]],
|
|
56
|
+
*,
|
|
57
|
+
priority: int = 0,
|
|
58
|
+
policy: Optional[ExecPolicy] = None,
|
|
59
|
+
once: bool = False,
|
|
60
|
+
) -> None:
|
|
61
|
+
with self._lock:
|
|
62
|
+
if self._closed:
|
|
63
|
+
raise EventBusClosedError()
|
|
64
|
+
sub = _Subscriber(priority=priority, callback=fn, policy=policy or self._default_policy, once=once)
|
|
65
|
+
lst = self._subs.setdefault(event_type, [])
|
|
66
|
+
if any(s.callback is fn for s in lst):
|
|
67
|
+
return
|
|
68
|
+
lst.append(sub)
|
|
69
|
+
lst.sort()
|
|
70
|
+
|
|
71
|
+
def unsubscribe(self, event_type: Type[Event], fn: Callable[[Event], Any] | Callable[[Event], Awaitable[Any]]) -> None:
|
|
72
|
+
with self._lock:
|
|
73
|
+
lst = self._subs.get(event_type, [])
|
|
74
|
+
self._subs[event_type] = [s for s in lst if s.callback is not fn]
|
|
75
|
+
|
|
76
|
+
def publish_sync(self, event: Event) -> None:
|
|
77
|
+
try:
|
|
78
|
+
loop = asyncio.get_running_loop()
|
|
79
|
+
except RuntimeError:
|
|
80
|
+
asyncio.run(self.publish(event))
|
|
81
|
+
return
|
|
82
|
+
if loop.is_running():
|
|
83
|
+
async def _bridge():
|
|
84
|
+
await self.publish(event)
|
|
85
|
+
loop.create_task(_bridge())
|
|
86
|
+
else:
|
|
87
|
+
asyncio.run(self.publish(event))
|
|
88
|
+
|
|
89
|
+
async def publish(self, event: Event) -> None:
|
|
90
|
+
if self._closed:
|
|
91
|
+
raise EventBusClosedError()
|
|
92
|
+
with self._lock:
|
|
93
|
+
subs = list(self._subs.get(type(event), []))
|
|
94
|
+
to_remove: List[_Subscriber] = []
|
|
95
|
+
pending: List[asyncio.Task] = []
|
|
96
|
+
for sub in subs:
|
|
97
|
+
try:
|
|
98
|
+
cb = sub.callback
|
|
99
|
+
if inspect.iscoroutinefunction(cb):
|
|
100
|
+
if sub.policy is ExecPolicy.TASK:
|
|
101
|
+
pending.append(asyncio.create_task(cb(event)))
|
|
102
|
+
else:
|
|
103
|
+
await cb(event)
|
|
104
|
+
else:
|
|
105
|
+
if sub.policy is ExecPolicy.THREADPOOL:
|
|
106
|
+
loop = asyncio.get_running_loop()
|
|
107
|
+
await loop.run_in_executor(None, cb, event)
|
|
108
|
+
else:
|
|
109
|
+
cb(event)
|
|
110
|
+
if sub.once:
|
|
111
|
+
to_remove.append(sub)
|
|
112
|
+
except Exception as ex:
|
|
113
|
+
self._handle_error(EventBusHandlerError(type(event).__name__, getattr(sub.callback, "__name__", "<callback>"), ex))
|
|
114
|
+
if pending:
|
|
115
|
+
try:
|
|
116
|
+
await asyncio.gather(*pending, return_exceptions=False)
|
|
117
|
+
except Exception as ex:
|
|
118
|
+
self._handle_error(EventBusError(f"Unhandled error awaiting event tasks: {ex}"))
|
|
119
|
+
if to_remove:
|
|
120
|
+
with self._lock:
|
|
121
|
+
lst = self._subs.get(type(event), [])
|
|
122
|
+
self._subs[type(event)] = [s for s in lst if s not in to_remove]
|
|
123
|
+
|
|
124
|
+
async def start_worker(self) -> None:
|
|
125
|
+
if self._closed:
|
|
126
|
+
raise EventBusClosedError()
|
|
127
|
+
if self._worker_task:
|
|
128
|
+
return
|
|
129
|
+
if self._queue is None:
|
|
130
|
+
self._queue = asyncio.Queue()
|
|
131
|
+
loop = asyncio.get_running_loop()
|
|
132
|
+
self._worker_loop = loop
|
|
133
|
+
async def _worker():
|
|
134
|
+
while True:
|
|
135
|
+
evt = await self._queue.get()
|
|
136
|
+
if evt is None:
|
|
137
|
+
self._queue.task_done()
|
|
138
|
+
break
|
|
139
|
+
try:
|
|
140
|
+
await self.publish(evt)
|
|
141
|
+
finally:
|
|
142
|
+
self._queue.task_done()
|
|
143
|
+
self._worker_task = asyncio.create_task(_worker())
|
|
144
|
+
|
|
145
|
+
async def stop_worker(self) -> None:
|
|
146
|
+
if self._worker_task and self._queue and self._worker_loop:
|
|
147
|
+
await self._queue.put(None)
|
|
148
|
+
await self._queue.join()
|
|
149
|
+
await self._worker_task
|
|
150
|
+
self._worker_task = None
|
|
151
|
+
self._worker_loop = None
|
|
152
|
+
|
|
153
|
+
def post(self, event: Event) -> None:
|
|
154
|
+
if self._closed:
|
|
155
|
+
raise EventBusClosedError()
|
|
156
|
+
if self._queue is None:
|
|
157
|
+
raise EventBusError("Worker queue not initialized. Call start_worker().")
|
|
158
|
+
loop = self._worker_loop
|
|
159
|
+
if loop and loop.is_running():
|
|
160
|
+
try:
|
|
161
|
+
current_loop = asyncio.get_running_loop()
|
|
162
|
+
if current_loop is loop:
|
|
163
|
+
try:
|
|
164
|
+
self._queue.put_nowait(event)
|
|
165
|
+
return
|
|
166
|
+
except asyncio.QueueFull:
|
|
167
|
+
raise EventBusQueueFullError()
|
|
168
|
+
except RuntimeError:
|
|
169
|
+
pass
|
|
170
|
+
try:
|
|
171
|
+
loop.call_soon_threadsafe(self._queue.put_nowait, event)
|
|
172
|
+
return
|
|
173
|
+
except asyncio.QueueFull:
|
|
174
|
+
raise EventBusQueueFullError()
|
|
175
|
+
else:
|
|
176
|
+
raise EventBusError("Worker queue not initialized or loop not running. Call start_worker().")
|
|
177
|
+
|
|
178
|
+
async def aclose(self) -> None:
|
|
179
|
+
await self.stop_worker()
|
|
180
|
+
with self._lock:
|
|
181
|
+
self._closed = True
|
|
182
|
+
self._subs.clear()
|
|
183
|
+
|
|
184
|
+
def _handle_error(self, ex: EventBusError) -> None:
|
|
185
|
+
if self._error_policy is ErrorPolicy.RAISE:
|
|
186
|
+
raise ex
|
|
187
|
+
if self._error_policy is ErrorPolicy.LOG:
|
|
188
|
+
log.exception("%s", ex)
|
|
189
|
+
|
|
190
|
+
def subscribe(event_type: Type[Event], *, priority: int = 0, policy: ExecPolicy = ExecPolicy.INLINE, once: bool = False):
|
|
191
|
+
def dec(fn: Callable[[Event], Any] | Callable[[Event], Awaitable[Any]]):
|
|
192
|
+
subs: Iterable[Tuple[Type[Event], int, ExecPolicy, bool]] = getattr(fn, "_pico_subscriptions_", ())
|
|
193
|
+
subs = list(subs)
|
|
194
|
+
subs.append((event_type, int(priority), policy, bool(once)))
|
|
195
|
+
setattr(fn, "_pico_subscriptions_", tuple(subs))
|
|
196
|
+
return fn
|
|
197
|
+
return dec
|
|
198
|
+
|
|
199
|
+
class AutoSubscriberMixin:
|
|
200
|
+
@configure
|
|
201
|
+
def _pico_autosubscribe(self, event_bus: EventBus) -> None:
|
|
202
|
+
for _, attr in inspect.getmembers(self, predicate=callable):
|
|
203
|
+
subs: Iterable[Tuple[Type[Event], int, ExecPolicy, bool]] = getattr(attr, "_pico_subscriptions_", ())
|
|
204
|
+
for evt_t, pr, pol, once in subs:
|
|
205
|
+
event_bus.subscribe(evt_t, attr, priority=pr, policy=pol, once=once)
|
|
206
|
+
|
|
207
|
+
@factory()
|
|
208
|
+
class PicoEventBusProvider:
|
|
209
|
+
@provides(EventBus, primary=True)
|
|
210
|
+
def build(self) -> EventBus:
|
|
211
|
+
return EventBus()
|
|
212
|
+
@cleanup
|
|
213
|
+
def shutdown(self, event_bus: EventBus) -> None:
|
|
214
|
+
try:
|
|
215
|
+
loop = asyncio.get_running_loop()
|
|
216
|
+
except RuntimeError:
|
|
217
|
+
asyncio.run(event_bus.aclose())
|
|
218
|
+
return
|
|
219
|
+
if loop.is_running():
|
|
220
|
+
loop.create_task(event_bus.aclose())
|
|
221
|
+
else:
|
|
222
|
+
asyncio.run(event_bus.aclose())
|
|
223
|
+
|
pico_ioc/exceptions.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from typing import Any, Iterable
|
|
2
|
+
|
|
3
|
+
class PicoError(Exception):
|
|
4
|
+
pass
|
|
5
|
+
|
|
6
|
+
class ProviderNotFoundError(PicoError):
|
|
7
|
+
def __init__(self, key: Any):
|
|
8
|
+
super().__init__(f"Provider not found for key: {getattr(key, '__name__', key)}")
|
|
9
|
+
self.key = key
|
|
10
|
+
|
|
11
|
+
class CircularDependencyError(PicoError):
|
|
12
|
+
def __init__(self, chain: Iterable[Any], current: Any, details: str | None = None, hint: str | None = None):
|
|
13
|
+
chain_str = " -> ".join(getattr(k, "__name__", str(k)) for k in chain)
|
|
14
|
+
cur_str = getattr(current, "__name__", str(current))
|
|
15
|
+
base = f"Circular dependency detected: {chain_str} -> {cur_str}"
|
|
16
|
+
if details:
|
|
17
|
+
base += f"\n\n{details}"
|
|
18
|
+
if hint:
|
|
19
|
+
base += f"\n\nHint: {hint}"
|
|
20
|
+
super().__init__(base)
|
|
21
|
+
self.chain = tuple(chain)
|
|
22
|
+
self.current = current
|
|
23
|
+
self.details = details
|
|
24
|
+
self.hint = hint
|
|
25
|
+
|
|
26
|
+
class ComponentCreationError(PicoError):
|
|
27
|
+
def __init__(self, key: Any, cause: Exception):
|
|
28
|
+
k = getattr(key, "__name__", key)
|
|
29
|
+
super().__init__(f"Failed to create component for key: {k}; cause: {cause.__class__.__name__}: {cause}")
|
|
30
|
+
self.key = key
|
|
31
|
+
self.cause = cause
|
|
32
|
+
|
|
33
|
+
class ScopeError(PicoError):
|
|
34
|
+
def __init__(self, msg: str):
|
|
35
|
+
super().__init__(msg)
|
|
36
|
+
|
|
37
|
+
class ConfigurationError(PicoError):
|
|
38
|
+
def __init__(self, msg: str):
|
|
39
|
+
super().__init__(msg)
|
|
40
|
+
|
|
41
|
+
class SerializationError(PicoError):
|
|
42
|
+
def __init__(self, msg: str):
|
|
43
|
+
super().__init__(msg)
|
|
44
|
+
|
|
45
|
+
class ValidationError(PicoError):
|
|
46
|
+
def __init__(self, msg: str):
|
|
47
|
+
super().__init__(msg)
|
|
48
|
+
|
|
49
|
+
class InvalidBindingError(ValidationError):
|
|
50
|
+
def __init__(self, errors: list[str]):
|
|
51
|
+
super().__init__("Invalid bindings:\n" + "\n".join(f"- {e}" for e in errors))
|
|
52
|
+
self.errors = errors
|
|
53
|
+
|
|
54
|
+
class EventBusError(PicoError):
|
|
55
|
+
def __init__(self, msg: str):
|
|
56
|
+
super().__init__(msg)
|
|
57
|
+
|
|
58
|
+
class EventBusClosedError(EventBusError):
|
|
59
|
+
def __init__(self):
|
|
60
|
+
super().__init__("EventBus is closed")
|
|
61
|
+
|
|
62
|
+
class EventBusQueueFullError(EventBusError):
|
|
63
|
+
def __init__(self):
|
|
64
|
+
super().__init__("Event queue is full")
|
|
65
|
+
|
|
66
|
+
class EventBusHandlerError(EventBusError):
|
|
67
|
+
def __init__(self, event_name: str, handler_name: str, cause: Exception):
|
|
68
|
+
super().__init__(f"Handler {handler_name} failed for event {event_name}: {cause.__class__.__name__}: {cause}")
|
|
69
|
+
self.event_name = event_name
|
|
70
|
+
self.handler_name = handler_name
|
|
71
|
+
self.cause = cause
|
|
72
|
+
|
pico_ioc/factory.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# src/pico_ioc/factory.py
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from typing import Any, Callable, Dict, Optional, Set, Tuple, Union
|
|
4
|
+
from .exceptions import ProviderNotFoundError
|
|
5
|
+
|
|
6
|
+
KeyT = Union[str, type]
|
|
7
|
+
Provider = Callable[[], Any]
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class ProviderMetadata:
|
|
11
|
+
key: KeyT
|
|
12
|
+
provided_type: Optional[type]
|
|
13
|
+
concrete_class: Optional[type]
|
|
14
|
+
factory_class: Optional[type]
|
|
15
|
+
factory_method: Optional[str]
|
|
16
|
+
qualifiers: Set[str]
|
|
17
|
+
primary: bool
|
|
18
|
+
lazy: bool
|
|
19
|
+
infra: Optional[str]
|
|
20
|
+
pico_name: Optional[Any]
|
|
21
|
+
override: bool = False
|
|
22
|
+
scope: str = "singleton"
|
|
23
|
+
|
|
24
|
+
class ComponentFactory:
|
|
25
|
+
def __init__(self) -> None:
|
|
26
|
+
self._providers: Dict[KeyT, Provider] = {}
|
|
27
|
+
def bind(self, key: KeyT, provider: Provider) -> None:
|
|
28
|
+
self._providers[key] = provider
|
|
29
|
+
def has(self, key: KeyT) -> bool:
|
|
30
|
+
return key in self._providers
|
|
31
|
+
def get(self, key: KeyT) -> Provider:
|
|
32
|
+
if key not in self._providers:
|
|
33
|
+
raise ProviderNotFoundError(key)
|
|
34
|
+
return self._providers[key]
|
|
35
|
+
|
|
36
|
+
class DeferredProvider:
|
|
37
|
+
def __init__(self, builder: Callable[[Any, Any], Any]) -> None:
|
|
38
|
+
self._builder = builder
|
|
39
|
+
self._pico: Any = None
|
|
40
|
+
self._locator: Any = None
|
|
41
|
+
def attach(self, pico, locator) -> None:
|
|
42
|
+
self._pico = pico
|
|
43
|
+
self._locator = locator
|
|
44
|
+
def __call__(self) -> Any:
|
|
45
|
+
if self._pico is None or self._locator is None:
|
|
46
|
+
raise RuntimeError("DeferredProvider must be attached before use")
|
|
47
|
+
return self._builder(self._pico, self._locator)
|
|
48
|
+
|
pico_ioc/locator.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from typing import Any, Dict, Iterable, List, Optional, Set, Tuple, Union
|
|
2
|
+
from .factory import ProviderMetadata
|
|
3
|
+
|
|
4
|
+
KeyT = Union[str, type]
|
|
5
|
+
|
|
6
|
+
class ComponentLocator:
|
|
7
|
+
def __init__(self, metadata: Dict[KeyT, ProviderMetadata], indexes: Dict[str, Dict[Any, List[KeyT]]]) -> None:
|
|
8
|
+
self._metadata = metadata
|
|
9
|
+
self._indexes = indexes
|
|
10
|
+
self._candidates: Optional[Set[KeyT]] = None
|
|
11
|
+
def _ensure(self) -> Set[KeyT]:
|
|
12
|
+
return set(self._metadata.keys()) if self._candidates is None else set(self._candidates)
|
|
13
|
+
def _select_index(self, name: str, values: Iterable[Any]) -> Set[KeyT]:
|
|
14
|
+
out: Set[KeyT] = set()
|
|
15
|
+
idx = self._indexes.get(name, {})
|
|
16
|
+
for v in values:
|
|
17
|
+
out.update(idx.get(v, []))
|
|
18
|
+
return out
|
|
19
|
+
def _new(self, candidates: Set[KeyT]) -> "ComponentLocator":
|
|
20
|
+
nl = ComponentLocator(self._metadata, self._indexes)
|
|
21
|
+
nl._candidates = candidates
|
|
22
|
+
return nl
|
|
23
|
+
def with_index_any(self, name: str, *values: Any) -> "ComponentLocator":
|
|
24
|
+
base = self._ensure()
|
|
25
|
+
sel = self._select_index(name, values)
|
|
26
|
+
return self._new(base & sel)
|
|
27
|
+
def with_index_all(self, name: str, *values: Any) -> "ComponentLocator":
|
|
28
|
+
base = self._ensure()
|
|
29
|
+
cur = base
|
|
30
|
+
for v in values:
|
|
31
|
+
cur = cur & set(self._indexes.get(name, {}).get(v, []))
|
|
32
|
+
return self._new(cur)
|
|
33
|
+
def with_qualifier_any(self, *qs: Any) -> "ComponentLocator":
|
|
34
|
+
return self.with_index_any("qualifier", *qs)
|
|
35
|
+
def primary_only(self) -> "ComponentLocator":
|
|
36
|
+
return self.with_index_any("primary", True)
|
|
37
|
+
def lazy(self, is_lazy: bool = True) -> "ComponentLocator":
|
|
38
|
+
return self.with_index_any("lazy", True) if is_lazy else self.with_index_any("lazy", False)
|
|
39
|
+
def infra(self, *names: Any) -> "ComponentLocator":
|
|
40
|
+
return self.with_index_any("infra", *names)
|
|
41
|
+
def pico_name(self, *names: Any) -> "ComponentLocator":
|
|
42
|
+
return self.with_index_any("pico_name", *names)
|
|
43
|
+
def by_key_type(self, t: type) -> "ComponentLocator":
|
|
44
|
+
base = self._ensure()
|
|
45
|
+
if t is str:
|
|
46
|
+
c = {k for k in base if isinstance(k, str)}
|
|
47
|
+
elif t is type:
|
|
48
|
+
c = {k for k in base if isinstance(k, type)}
|
|
49
|
+
else:
|
|
50
|
+
c = {k for k in base if isinstance(k, t)}
|
|
51
|
+
return self._new(c)
|
|
52
|
+
def keys(self) -> List[KeyT]:
|
|
53
|
+
return list(self._ensure())
|
pico_ioc/scope.py
CHANGED
|
@@ -1,41 +1,157 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
from
|
|
6
|
-
|
|
7
|
-
class
|
|
8
|
-
def
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
self.
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
def
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
1
|
+
# src/pico_ioc/scope.py
|
|
2
|
+
import contextvars
|
|
3
|
+
import inspect
|
|
4
|
+
from typing import Any, Dict, Optional, Tuple
|
|
5
|
+
from collections import OrderedDict
|
|
6
|
+
|
|
7
|
+
class ScopeProtocol:
|
|
8
|
+
def get_id(self) -> Any | None: ...
|
|
9
|
+
|
|
10
|
+
class ContextVarScope(ScopeProtocol):
|
|
11
|
+
def __init__(self, var: contextvars.ContextVar) -> None:
|
|
12
|
+
self._var = var
|
|
13
|
+
def get_id(self) -> Any | None:
|
|
14
|
+
return self._var.get()
|
|
15
|
+
def activate(self, scope_id: Any) -> contextvars.Token:
|
|
16
|
+
return self._var.set(scope_id)
|
|
17
|
+
def deactivate(self, token: contextvars.Token) -> None:
|
|
18
|
+
self._var.reset(token)
|
|
19
|
+
|
|
20
|
+
class ComponentContainer:
|
|
21
|
+
def __init__(self) -> None:
|
|
22
|
+
self._instances: Dict[object, object] = {}
|
|
23
|
+
def get(self, key):
|
|
24
|
+
return self._instances.get(key)
|
|
25
|
+
def put(self, key, value):
|
|
26
|
+
self._instances[key] = value
|
|
27
|
+
def items(self):
|
|
28
|
+
return list(self._instances.items())
|
|
29
|
+
|
|
30
|
+
class _NoCacheContainer(ComponentContainer):
|
|
31
|
+
def __init__(self) -> None:
|
|
32
|
+
pass
|
|
33
|
+
def get(self, key):
|
|
34
|
+
return None
|
|
35
|
+
def put(self, key, value):
|
|
36
|
+
return
|
|
37
|
+
def items(self):
|
|
38
|
+
return []
|
|
39
|
+
|
|
40
|
+
class ScopeManager:
|
|
41
|
+
def __init__(self) -> None:
|
|
42
|
+
self._scopes: Dict[str, ScopeProtocol] = {
|
|
43
|
+
"request": ContextVarScope(contextvars.ContextVar("pico_request_id", default=None)),
|
|
44
|
+
"session": ContextVarScope(contextvars.ContextVar("pico_session_id", default=None)),
|
|
45
|
+
"transaction": ContextVarScope(contextvars.ContextVar("pico_tx_id", default=None)),
|
|
46
|
+
}
|
|
47
|
+
def register_scope(self, name: str, implementation: ScopeProtocol) -> None:
|
|
48
|
+
if not isinstance(name, str) or not name:
|
|
49
|
+
from .exceptions import ScopeError
|
|
50
|
+
raise ScopeError("Scope name must be a non-empty string")
|
|
51
|
+
if name in ("singleton", "prototype"):
|
|
52
|
+
from .exceptions import ScopeError
|
|
53
|
+
raise ScopeError("Cannot register or override reserved scopes: 'singleton' or 'prototype'")
|
|
54
|
+
self._scopes[name] = implementation
|
|
55
|
+
def get_id(self, name: str) -> Any | None:
|
|
56
|
+
if name in ("singleton", "prototype"):
|
|
57
|
+
return None
|
|
58
|
+
impl = self._scopes.get(name)
|
|
59
|
+
return impl.get_id() if impl else None
|
|
60
|
+
def activate(self, name: str, scope_id: Any) -> Optional[contextvars.Token]:
|
|
61
|
+
if name in ("singleton", "prototype"):
|
|
62
|
+
return None
|
|
63
|
+
impl = self._scopes.get(name)
|
|
64
|
+
if impl is None:
|
|
65
|
+
from .exceptions import ScopeError
|
|
66
|
+
raise ScopeError(f"Unknown scope: {name}")
|
|
67
|
+
if hasattr(impl, "activate"):
|
|
68
|
+
return getattr(impl, "activate")(scope_id)
|
|
69
|
+
return None
|
|
70
|
+
def deactivate(self, name: str, token: Optional[contextvars.Token]) -> None:
|
|
71
|
+
if name in ("singleton", "prototype"):
|
|
72
|
+
return
|
|
73
|
+
impl = self._scopes.get(name)
|
|
74
|
+
if impl is None:
|
|
75
|
+
from .exceptions import ScopeError
|
|
76
|
+
raise ScopeError(f"Unknown scope: {name}")
|
|
77
|
+
if token is not None and hasattr(impl, "deactivate"):
|
|
78
|
+
getattr(impl, "deactivate")(token)
|
|
79
|
+
def names(self) -> Tuple[str, ...]:
|
|
80
|
+
return tuple(n for n in self._scopes.keys() if n not in ("singleton", "prototype"))
|
|
81
|
+
def signature(self, names: Tuple[str, ...]) -> Tuple[Any, ...]:
|
|
82
|
+
return tuple(self.get_id(n) for n in names)
|
|
83
|
+
def signature_all(self) -> Tuple[Any, ...]:
|
|
84
|
+
return self.signature(self.names())
|
|
85
|
+
|
|
86
|
+
class ScopedCaches:
|
|
87
|
+
def __init__(self, max_scopes_per_type: int = 2048) -> None:
|
|
88
|
+
self._singleton = ComponentContainer()
|
|
89
|
+
self._by_scope: Dict[str, OrderedDict[Any, ComponentContainer]] = {}
|
|
90
|
+
self._max = int(max_scopes_per_type)
|
|
91
|
+
self._no_cache = _NoCacheContainer()
|
|
92
|
+
def _cleanup_object(self, obj: Any) -> None:
|
|
35
93
|
try:
|
|
36
|
-
|
|
37
|
-
except
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
94
|
+
from .constants import PICO_META
|
|
95
|
+
except Exception:
|
|
96
|
+
PICO_META = "_pico_meta"
|
|
97
|
+
try:
|
|
98
|
+
for _, m in inspect.getmembers(obj, predicate=inspect.ismethod):
|
|
99
|
+
meta = getattr(m, PICO_META, {})
|
|
100
|
+
if meta.get("cleanup", False):
|
|
101
|
+
try:
|
|
102
|
+
m()
|
|
103
|
+
except Exception:
|
|
104
|
+
pass
|
|
105
|
+
except Exception:
|
|
106
|
+
pass
|
|
107
|
+
|
|
108
|
+
def cleanup_scope(self, scope_name: str, scope_id: Any) -> None:
|
|
109
|
+
bucket = self._by_scope.get(scope_name)
|
|
110
|
+
if bucket and scope_id in bucket:
|
|
111
|
+
container = bucket.pop(scope_id)
|
|
112
|
+
self._cleanup_container(container)
|
|
113
|
+
|
|
114
|
+
def _cleanup_container(self, container: "ComponentContainer") -> None:
|
|
115
|
+
try:
|
|
116
|
+
for _, obj in container.items():
|
|
117
|
+
self._cleanup_object(obj)
|
|
118
|
+
except Exception:
|
|
119
|
+
pass
|
|
120
|
+
|
|
121
|
+
def for_scope(self, scopes: ScopeManager, scope: str) -> ComponentContainer:
|
|
122
|
+
if scope == "singleton":
|
|
123
|
+
return self._singleton
|
|
124
|
+
if scope == "prototype":
|
|
125
|
+
return self._no_cache
|
|
126
|
+
sid = scopes.get_id(scope)
|
|
127
|
+
bucket = self._by_scope.setdefault(scope, OrderedDict())
|
|
128
|
+
if sid in bucket:
|
|
129
|
+
c = bucket.pop(sid)
|
|
130
|
+
bucket[sid] = c
|
|
131
|
+
return c
|
|
132
|
+
if len(bucket) >= self._max:
|
|
133
|
+
_, old = bucket.popitem(last=False)
|
|
134
|
+
self._cleanup_container(old)
|
|
135
|
+
c = ComponentContainer()
|
|
136
|
+
bucket[sid] = c
|
|
137
|
+
return c
|
|
138
|
+
|
|
139
|
+
def all_items(self):
|
|
140
|
+
for item in self._singleton.items():
|
|
141
|
+
yield item
|
|
142
|
+
for b in self._by_scope.values():
|
|
143
|
+
for c in b.values():
|
|
144
|
+
for item in c.items():
|
|
145
|
+
yield item
|
|
146
|
+
|
|
147
|
+
def shrink(self, scope: str, keep: int) -> None:
|
|
148
|
+
if scope in ("singleton", "prototype"):
|
|
149
|
+
return
|
|
150
|
+
bucket = self._by_scope.get(scope)
|
|
151
|
+
if not bucket:
|
|
152
|
+
return
|
|
153
|
+
k = max(0, int(keep))
|
|
154
|
+
while len(bucket) > k:
|
|
155
|
+
_, old = bucket.popitem(last=False)
|
|
156
|
+
self._cleanup_container(old)
|
|
41
157
|
|