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/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
- from __future__ import annotations
2
-
3
- from typing import Any, Optional
4
-
5
- from .container import PicoContainer
6
-
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
- self._active_profiles = getattr(built_container, "_active_profiles", ())
11
- base_method_its = getattr(base, "_method_interceptors", ()) if base else ()
12
- base_container_its = getattr(base, "_container_interceptors", ()) if base else ()
13
- self._method_interceptors = base_method_its
14
- self._container_interceptors = base_container_its
15
- self._seen_interceptor_types = {type(it) for it in base_container_its}
16
- for it in getattr(built_container, "_method_interceptors", ()):
17
- self.add_method_interceptor(it)
18
- for it in getattr(built_container, "_container_interceptors", ()):
19
- self.add_container_interceptor(it)
20
- self._base = base
21
- self._strict = strict
22
- if base:
23
- self._singletons.update(getattr(base, "_singletons", {}))
24
-
25
- def __enter__(self): return self
26
- def __exit__(self, exc_type, exc, tb): return False
27
-
28
- def has(self, key: Any) -> bool:
29
- if super().has(key): return True
30
- if not self._strict and self._base is not None:
31
- return self._base.has(key)
32
- return False
33
-
34
- def get(self, key: Any):
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
- return super().get(key)
37
- except NameError as e:
38
- if not self._strict and self._base is not None and self._base.has(key):
39
- return self._base.get(key)
40
- raise e
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