pico-ioc 1.4.0__py3-none-any.whl → 2.0.0__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/event_bus.py ADDED
@@ -0,0 +1,224 @@
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, primary
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
+ @primary
209
+ class PicoEventBusProvider:
210
+ @provides(EventBus)
211
+ def build(self) -> EventBus:
212
+ return EventBus()
213
+ @cleanup
214
+ def shutdown(self, event_bus: EventBus) -> None:
215
+ try:
216
+ loop = asyncio.get_running_loop()
217
+ except RuntimeError:
218
+ asyncio.run(event_bus.aclose())
219
+ return
220
+ if loop.is_running():
221
+ loop.create_task(event_bus.aclose())
222
+ else:
223
+ asyncio.run(event_bus.aclose())
224
+
pico_ioc/exceptions.py ADDED
@@ -0,0 +1,66 @@
1
+ # src/pico_ioc/exceptions.py
2
+ from typing import Any, Iterable
3
+
4
+ class PicoError(Exception):
5
+ pass
6
+
7
+ class ProviderNotFoundError(PicoError):
8
+ def __init__(self, key: Any):
9
+ super().__init__(f"Provider not found for key: {getattr(key, '__name__', key)}")
10
+ self.key = key
11
+
12
+ class CircularDependencyError(PicoError):
13
+ def __init__(self, chain: Iterable[Any], current: Any):
14
+ chain_str = " -> ".join(getattr(k, "__name__", str(k)) for k in chain)
15
+ cur_str = getattr(current, "__name__", str(current))
16
+ super().__init__(f"Circular dependency detected: {chain_str} -> {cur_str}")
17
+ self.chain = tuple(chain)
18
+ self.current = current
19
+
20
+ class ComponentCreationError(PicoError):
21
+ def __init__(self, key: Any, cause: Exception):
22
+ k = getattr(key, "__name__", key)
23
+ super().__init__(f"Failed to create component for key: {k}; cause: {cause.__class__.__name__}: {cause}")
24
+ self.key = key
25
+ self.cause = cause
26
+
27
+ class ScopeError(PicoError):
28
+ def __init__(self, msg: str):
29
+ super().__init__(msg)
30
+
31
+ class ConfigurationError(PicoError):
32
+ def __init__(self, msg: str):
33
+ super().__init__(msg)
34
+
35
+ class SerializationError(PicoError):
36
+ def __init__(self, msg: str):
37
+ super().__init__(msg)
38
+
39
+ class ValidationError(PicoError):
40
+ def __init__(self, msg: str):
41
+ super().__init__(msg)
42
+
43
+ class InvalidBindingError(ValidationError):
44
+ def __init__(self, errors: list[str]):
45
+ super().__init__("Invalid bindings:\n" + "\n".join(f"- {e}" for e in errors))
46
+ self.errors = errors
47
+
48
+ class EventBusError(PicoError):
49
+ def __init__(self, msg: str):
50
+ super().__init__(msg)
51
+
52
+ class EventBusClosedError(EventBusError):
53
+ def __init__(self):
54
+ super().__init__("EventBus is closed")
55
+
56
+ class EventBusQueueFullError(EventBusError):
57
+ def __init__(self):
58
+ super().__init__("Event queue is full")
59
+
60
+ class EventBusHandlerError(EventBusError):
61
+ def __init__(self, event_name: str, handler_name: str, cause: Exception):
62
+ super().__init__(f"Handler {handler_name} failed for event {event_name}: {cause.__class__.__name__}: {cause}")
63
+ self.event_name = event_name
64
+ self.handler_name = handler_name
65
+ self.cause = cause
66
+
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,46 +1,112 @@
1
- from __future__ import annotations
1
+ import contextvars
2
+ from typing import Any, Dict, Optional, Tuple
3
+ from collections import OrderedDict
2
4
 
3
- from typing import Any, Optional
5
+ class ScopeProtocol:
6
+ def get_id(self) -> Any | None: ...
4
7
 
5
- from .container import PicoContainer
8
+ class ContextVarScope(ScopeProtocol):
9
+ def __init__(self, var: contextvars.ContextVar) -> None:
10
+ self._var = var
11
+ def get_id(self) -> Any | None:
12
+ return self._var.get()
13
+ def activate(self, scope_id: Any) -> contextvars.Token:
14
+ return self._var.set(scope_id)
15
+ def deactivate(self, token: contextvars.Token) -> None:
16
+ self._var.reset(token)
6
17
 
7
- class ScopedContainer(PicoContainer):
8
- def __init__(self, built_container: PicoContainer, base: Optional[PicoContainer], strict: bool):
9
- super().__init__(providers=getattr(built_container, "_providers", {}).copy())
10
-
11
- self._active_profiles = getattr(built_container, "_active_profiles", ())
12
-
13
- base_method_its = getattr(base, "_method_interceptors", ()) if base else ()
14
- base_container_its = getattr(base, "_container_interceptors", ()) if base else ()
15
-
16
- self._method_interceptors = base_method_its
17
- self._container_interceptors = base_container_its
18
- self._seen_interceptor_types = {type(it) for it in (base_method_its + base_container_its)}
18
+ class ComponentContainer:
19
+ def __init__(self) -> None:
20
+ self._instances: Dict[object, object] = {}
21
+ def get(self, key):
22
+ return self._instances.get(key)
23
+ def put(self, key, value):
24
+ self._instances[key] = value
25
+ def items(self):
26
+ return list(self._instances.items())
19
27
 
20
- for it in getattr(built_container, "_method_interceptors", ()):
21
- self.add_method_interceptor(it)
22
- for it in getattr(built_container, "_container_interceptors", ()):
23
- self.add_container_interceptor(it)
28
+ class _NoCacheContainer(ComponentContainer):
29
+ def __init__(self) -> None:
30
+ pass
31
+ def get(self, key):
32
+ return None
33
+ def put(self, key, value):
34
+ return
35
+ def items(self):
36
+ return []
24
37
 
25
- self._base = base
26
- self._strict = strict
38
+ class ScopeManager:
39
+ def __init__(self) -> None:
40
+ self._scopes: Dict[str, ScopeProtocol] = {
41
+ "request": ContextVarScope(contextvars.ContextVar("pico_request_id", default=None)),
42
+ "session": ContextVarScope(contextvars.ContextVar("pico_session_id", default=None)),
43
+ "transaction": ContextVarScope(contextvars.ContextVar("pico_tx_id", default=None)),
44
+ }
45
+ def register_scope(self, name: str, implementation: ScopeProtocol) -> None:
46
+ if not isinstance(name, str) or not name:
47
+ from .exceptions import ScopeError
48
+ raise ScopeError("Scope name must be a non-empty string")
49
+ if name in ("singleton", "prototype"):
50
+ from .exceptions import ScopeError
51
+ raise ScopeError("Cannot register or override reserved scopes: 'singleton' or 'prototype'")
52
+ self._scopes[name] = implementation
53
+ def get_id(self, name: str) -> Any | None:
54
+ if name in ("singleton", "prototype"):
55
+ return None
56
+ impl = self._scopes.get(name)
57
+ return impl.get_id() if impl else None
58
+ def activate(self, name: str, scope_id: Any) -> Optional[contextvars.Token]:
59
+ if name in ("singleton", "prototype"):
60
+ return None
61
+ impl = self._scopes.get(name)
62
+ if impl is None:
63
+ from .exceptions import ScopeError
64
+ raise ScopeError(f"Unknown scope: {name}")
65
+ if hasattr(impl, "activate"):
66
+ return getattr(impl, "activate")(scope_id)
67
+ return None
68
+ def deactivate(self, name: str, token: Optional[contextvars.Token]) -> None:
69
+ if name in ("singleton", "prototype"):
70
+ return
71
+ impl = self._scopes.get(name)
72
+ if impl is None:
73
+ from .exceptions import ScopeError
74
+ raise ScopeError(f"Unknown scope: {name}")
75
+ if token is not None and hasattr(impl, "deactivate"):
76
+ getattr(impl, "deactivate")(token)
77
+ def names(self) -> Tuple[str, ...]:
78
+ return tuple(n for n in self._scopes.keys() if n not in ("singleton", "prototype"))
79
+ def signature(self, names: Tuple[str, ...]) -> Tuple[Any, ...]:
80
+ return tuple(self.get_id(n) for n in names)
81
+ def signature_all(self) -> Tuple[Any, ...]:
82
+ return self.signature(self.names())
27
83
 
28
- if base:
29
- self._singletons.update(getattr(base, "_singletons", {}))
30
-
31
- def __enter__(self): return self
32
- def __exit__(self, exc_type, exc, tb): return False
33
-
34
- def has(self, key: Any) -> bool:
35
- if super().has(key): return True
36
- if not self._strict and self._base is not None:
37
- return self._base.has(key)
38
- return False
39
-
40
- def get(self, key: Any):
41
- try:
42
- return super().get(key)
43
- except NameError as e:
44
- if not self._strict and self._base is not None and self._base.has(key):
45
- return self._base.get(key)
46
- raise e
84
+ class ScopedCaches:
85
+ def __init__(self, max_scopes_per_type: int = 2048) -> None:
86
+ self._singleton = ComponentContainer()
87
+ self._by_scope: Dict[str, OrderedDict[Any, ComponentContainer]] = {}
88
+ self._max = int(max_scopes_per_type)
89
+ self._no_cache = _NoCacheContainer()
90
+ def for_scope(self, scopes: ScopeManager, scope: str) -> ComponentContainer:
91
+ if scope == "singleton":
92
+ return self._singleton
93
+ if scope == "prototype":
94
+ return self._no_cache
95
+ sid = scopes.get_id(scope)
96
+ bucket = self._by_scope.setdefault(scope, OrderedDict())
97
+ if sid in bucket:
98
+ c = bucket.pop(sid)
99
+ bucket[sid] = c
100
+ return c
101
+ if len(bucket) >= self._max:
102
+ bucket.popitem(last=False)
103
+ c = ComponentContainer()
104
+ bucket[sid] = c
105
+ return c
106
+ def all_items(self):
107
+ for item in self._singleton.items():
108
+ yield item
109
+ for b in self._by_scope.values():
110
+ for c in b.values():
111
+ for item in c.items():
112
+ yield item