pico-ioc 2.1.2__py3-none-any.whl → 2.1.3__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/_version.py CHANGED
@@ -1 +1 @@
1
- __version__ = '2.1.2'
1
+ __version__ = '2.1.3'
pico_ioc/analysis.py CHANGED
@@ -8,6 +8,7 @@ from typing import (
8
8
  Dict, Mapping
9
9
  )
10
10
  from .decorators import Qualifier
11
+ from .constants import LOGGER
11
12
 
12
13
  KeyT = Union[str, type]
13
14
 
@@ -47,20 +48,18 @@ def _check_optional(ann: Any) -> Tuple[Any, bool]:
47
48
  def analyze_callable_dependencies(callable_obj: Callable[..., Any]) -> Tuple[DependencyRequest, ...]:
48
49
  try:
49
50
  sig = inspect.signature(callable_obj)
50
- except (ValueError, TypeError):
51
+ except (ValueError, TypeError) as e:
52
+ LOGGER.debug(f"Could not analyze dependencies for {callable_obj!r}: {e}")
51
53
  return ()
52
54
 
53
55
  plan: List[DependencyRequest] = []
54
56
 
55
57
  SUPPORTED_COLLECTION_ORIGINS = (
56
- # Runtime types
57
58
  list,
58
59
  set,
59
60
  tuple,
60
61
  frozenset,
61
62
  collections.deque,
62
-
63
- # Typing ABCs (from get_origin)
64
63
  collections.abc.Iterable,
65
64
  collections.abc.Collection,
66
65
  collections.abc.Sequence,
pico_ioc/aop.py CHANGED
@@ -1,5 +1,3 @@
1
- # src/pico_ioc/aop.py
2
-
3
1
  import inspect
4
2
  import pickle
5
3
  import threading
@@ -75,15 +73,18 @@ def health(fn):
75
73
  return fn
76
74
 
77
75
  class UnifiedComponentProxy:
78
- __slots__ = ("_target", "_creator", "_container", "_cache", "_lock")
79
- def __init__(self, *, container: Any, target: Any = None, object_creator: Callable[[], Any] | None = None):
76
+ __slots__ = ("_target", "_creator", "_container", "_cache", "_lock", "_component_key")
77
+
78
+ def __init__(self, *, container: Any, target: Any = None, object_creator: Callable[[], Any] | None = None, component_key: Any = None):
80
79
  if container is None:
81
80
  raise ValueError("UnifiedComponentProxy requires a non-null container")
82
81
  if target is None and object_creator is None:
83
82
  raise ValueError("UnifiedComponentProxy requires either a target or an object_creator")
83
+
84
84
  object.__setattr__(self, "_container", container)
85
85
  object.__setattr__(self, "_target", target)
86
86
  object.__setattr__(self, "_creator", object_creator)
87
+ object.__setattr__(self, "_component_key", component_key)
87
88
  object.__setattr__(self, "_cache", {})
88
89
  object.__setattr__(self, "_lock", threading.RLock())
89
90
 
@@ -98,6 +99,7 @@ class UnifiedComponentProxy:
98
99
  def __setstate__(self, state):
99
100
  object.__setattr__(self, "_container", None)
100
101
  object.__setattr__(self, "_creator", None)
102
+ object.__setattr__(self, "_component_key", None)
101
103
  object.__setattr__(self, "_cache", {})
102
104
  object.__setattr__(self, "_lock", threading.RLock())
103
105
  try:
@@ -110,14 +112,17 @@ class UnifiedComponentProxy:
110
112
  tgt = object.__getattribute__(self, "_target")
111
113
  if tgt is not None:
112
114
  return tgt
115
+
113
116
  lock = object.__getattribute__(self, "_lock")
114
117
  with lock:
115
118
  tgt = object.__getattribute__(self, "_target")
116
119
  if tgt is not None:
117
120
  return tgt
121
+
118
122
  creator = object.__getattribute__(self, "_creator")
119
123
  if not callable(creator):
120
124
  raise TypeError("UnifiedComponentProxy object_creator must be callable")
125
+
121
126
  tgt = creator()
122
127
  if tgt is None:
123
128
  raise RuntimeError("UnifiedComponentProxy object_creator returned None")
@@ -128,27 +133,51 @@ class UnifiedComponentProxy:
128
133
  if inspect.isawaitable(res):
129
134
  raise AsyncResolutionError(
130
135
  f"Lazy component {type(tgt).__name__} requires async "
131
- "@configure but was resolved via sync get()"
136
+ "@configure but was resolved via sync access (proxy __getattr__). "
137
+ "Use 'await container.aget(Component)' to force initialization."
132
138
  )
133
139
 
134
140
  object.__setattr__(self, "_target", tgt)
135
141
  return tgt
142
+
143
+ async def _async_init_if_needed(self) -> None:
144
+ if object.__getattribute__(self, "_target") is not None:
145
+ return
146
+
147
+ lock = object.__getattribute__(self, "_lock")
148
+ tgt = object.__getattribute__(self, "_target")
149
+ if tgt is not None:
150
+ return
151
+
152
+ creator = object.__getattribute__(self, "_creator")
153
+ container = object.__getattribute__(self, "_container")
154
+
155
+ tgt = creator()
156
+
157
+ if container and hasattr(container, "_run_configure_methods"):
158
+ res = container._run_configure_methods(tgt)
159
+ if inspect.isawaitable(res):
160
+ await res
161
+
162
+ with lock:
163
+ object.__setattr__(self, "_target", tgt)
136
164
 
137
165
  def _scope_signature(self) -> Tuple[Any, ...]:
138
166
  container = object.__getattribute__(self, "_container")
139
- target = object.__getattribute__(self, "_target")
167
+ key = object.__getattribute__(self, "_component_key")
140
168
  loc = getattr(container, "_locator", None)
141
- if not loc:
169
+
170
+ if not loc or key is None:
142
171
  return ()
143
- if target is not None:
144
- t = type(target)
145
- for k, md in loc._metadata.items():
146
- typ = md.provided_type or md.concrete_class
147
- if isinstance(typ, type) and t is typ:
148
- sc = md.scope
149
- if sc == "singleton":
150
- return ()
151
- return (container.scopes.get_id(sc),)
172
+
173
+ if key in loc._metadata:
174
+ md = loc._metadata[key]
175
+ sc = md.scope
176
+ if sc == "singleton":
177
+ return ()
178
+
179
+ return (container.scopes.get_id(sc),)
180
+
152
181
  return ()
153
182
 
154
183
  def _build_wrapped(self, name: str, bound: Callable[..., Any], interceptors_cls: Tuple[type, ...]):
@@ -212,6 +241,7 @@ class UnifiedComponentProxy:
212
241
  lock = object.__getattribute__(self, "_lock")
213
242
  with lock:
214
243
  cache: Dict[str, Tuple[Tuple[Any, ...], Callable[..., Any], Tuple[type, ...]]] = object.__getattribute__(self, "_cache")
244
+
215
245
  cur_sig = self._scope_signature()
216
246
  cached = cache.get(name)
217
247
 
@@ -224,7 +254,12 @@ class UnifiedComponentProxy:
224
254
  cache[name] = (sig, wrapped, cls_tuple)
225
255
  return wrapped
226
256
 
227
- def __setattr__(self, name, value): setattr(self._get_real_object(), name, value)
257
+ def __setattr__(self, name, value):
258
+ if name in ("_target", "_creator", "_container", "_cache", "_lock", "_component_key"):
259
+ object.__setattr__(self, name, value)
260
+ else:
261
+ setattr(self._get_real_object(), name, value)
262
+
228
263
  def __delattr__(self, name): delattr(self._get_real_object(), name)
229
264
  def __str__(self): return str(self._get_real_object())
230
265
  def __repr__(self): return repr(self._get_real_object())
@@ -305,8 +305,11 @@ class ObjectGraphBuilder:
305
305
  if t is int:
306
306
  if isinstance(node, int):
307
307
  return node
308
- if isinstance(node, str) and node.strip().isdigit() or (isinstance(node, str) and node.strip().startswith("-") and node.strip()[1:].isdigit()):
309
- return int(node)
308
+ if isinstance(node, str):
309
+ try:
310
+ return int(node.strip())
311
+ except ValueError:
312
+ pass
310
313
  raise ConfigurationError(f"Expected int at {'.'.join(path)}")
311
314
  if t is float:
312
315
  if isinstance(node, (int, float)):
pico_ioc/container.py CHANGED
@@ -205,7 +205,11 @@ class PicoContainer:
205
205
  args = self._resolve_args(configure_deps)
206
206
  res = m(**args)
207
207
  if inspect.isawaitable(res):
208
- LOGGER.warning(f"Async configure method {m} called during sync get. Awaitable ignored.")
208
+ raise AsyncResolutionError(
209
+ f"Component {type(instance).__name__} returned an awaitable from synchronous "
210
+ f"@configure method '{m.__name__}'. You must use 'await container.aget()' "
211
+ "or make the method synchronous."
212
+ )
209
213
  return instance
210
214
 
211
215
  async def runner():
@@ -252,10 +256,12 @@ class PicoContainer:
252
256
  async def aget(self, key: KeyT) -> Any:
253
257
  instance_or_awaitable, took_ms, was_cached = self._resolve_or_create_internal(key)
254
258
 
259
+ instance = instance_or_awaitable
255
260
  if was_cached:
256
- return instance_or_awaitable
261
+ if isinstance(instance, UnifiedComponentProxy):
262
+ await instance._async_init_if_needed()
263
+ return instance
257
264
 
258
- instance = instance_or_awaitable
259
265
  if inspect.isawaitable(instance_or_awaitable):
260
266
  instance = await instance_or_awaitable
261
267
 
@@ -269,6 +275,10 @@ class PicoContainer:
269
275
  instance = instance_or_awaitable_configured
270
276
 
271
277
  final_instance = self._maybe_wrap_with_aspects(key, instance)
278
+
279
+ if isinstance(final_instance, UnifiedComponentProxy):
280
+ await final_instance._async_init_if_needed()
281
+
272
282
  cache = self._cache_for(key)
273
283
  cache.put(key, final_instance)
274
284
  self.context.resolve_count += 1
@@ -282,7 +292,7 @@ class PicoContainer:
282
292
  cls = type(instance)
283
293
  for _, fn in inspect.getmembers(cls, predicate=lambda m: inspect.isfunction(m) or inspect.ismethod(m) or inspect.iscoroutinefunction(m)):
284
294
  if getattr(fn, "_pico_interceptors_", None):
285
- return UnifiedComponentProxy(container=self, target=instance)
295
+ return UnifiedComponentProxy(container=self, target=instance, component_key=key)
286
296
  return instance
287
297
 
288
298
  def _iterate_cleanup_targets(self) -> Iterable[Any]:
@@ -374,7 +384,7 @@ class PicoContainer:
374
384
  self.cleanup_all()
375
385
  PicoContainer._container_registry.pop(self.container_id, None)
376
386
 
377
- def build_resolution_graph(self) -> None:
387
+ def build_resolution_graph(self):
378
388
  return _build_resolution_graph(self._locator)
379
389
 
380
390
  def export_graph(
@@ -424,7 +434,7 @@ class PicoContainer:
424
434
  pid = _node_id(parent)
425
435
  for child in deps:
426
436
  cid = _node_id(child)
427
- lines.append(f" {pid} -> {cid};")
437
+ lines.append(f" {pid} -> {child};")
428
438
 
429
439
  lines.append("}")
430
440
 
pico_ioc/event_bus.py CHANGED
@@ -155,25 +155,28 @@ class EventBus:
155
155
  raise EventBusClosedError()
156
156
  if self._queue is None:
157
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().")
158
+
159
+ queue_ref = self._queue
160
+ loop_ref = self._worker_loop
161
+
162
+ if loop_ref and loop_ref.is_running():
163
+ try:
164
+ current_loop = asyncio.get_running_loop()
165
+ if current_loop is loop_ref:
166
+ try:
167
+ queue_ref.put_nowait(event)
168
+ return
169
+ except asyncio.QueueFull:
170
+ raise EventBusQueueFullError()
171
+ except RuntimeError:
172
+ pass
173
+ try:
174
+ loop_ref.call_soon_threadsafe(queue_ref.put_nowait, event)
175
+ return
176
+ except asyncio.QueueFull:
177
+ raise EventBusQueueFullError()
178
+ else:
179
+ raise EventBusError("Worker queue not initialized or loop not running. Call start_worker().")
177
180
 
178
181
  async def aclose(self) -> None:
179
182
  await self.stop_worker()
pico_ioc/registrar.py CHANGED
@@ -61,8 +61,8 @@ class Registrar:
61
61
  for key, md in list(self._metadata.items()):
62
62
  if md.lazy:
63
63
  original = self._factory.get(key, origin='lazy')
64
- def lazy_proxy_provider(_orig=original, _p=pico):
65
- return UnifiedComponentProxy(container=_p, object_creator=_orig)
64
+ def lazy_proxy_provider(_orig=original, _p=pico, _k=key):
65
+ return UnifiedComponentProxy(container=_p, object_creator=_orig, component_key=_k)
66
66
  self._factory.bind(key, lazy_proxy_provider)
67
67
 
68
68
 
pico_ioc/scope.py CHANGED
@@ -1,4 +1,3 @@
1
- # src/pico_ioc/scope.py
2
1
  import contextvars
3
2
  import inspect
4
3
  from typing import Any, Dict, Optional, Tuple
@@ -95,9 +94,10 @@ class ScopeManager:
95
94
  class ScopedCaches:
96
95
  def __init__(self, max_scopes_per_type: int = 2048) -> None:
97
96
  self._singleton = ComponentContainer()
98
- self._by_scope: Dict[str, OrderedDict[Any, ComponentContainer]] = {}
97
+ self._by_scope: Dict[str, Dict[Any, ComponentContainer]] = {}
99
98
  self._max = int(max_scopes_per_type)
100
99
  self._no_cache = _NoCacheContainer()
100
+
101
101
  def _cleanup_object(self, obj: Any) -> None:
102
102
  try:
103
103
  from .constants import PICO_META
@@ -132,15 +132,19 @@ class ScopedCaches:
132
132
  return self._singleton
133
133
  if scope == "prototype":
134
134
  return self._no_cache
135
+
135
136
  sid = scopes.get_id(scope)
136
- bucket = self._by_scope.setdefault(scope, OrderedDict())
137
+
138
+ if sid is None:
139
+ raise ScopeError(
140
+ f"Cannot resolve component in scope '{scope}': No active scope ID found. "
141
+ f"Are you trying to use a {scope}-scoped component outside of its context?"
142
+ )
143
+
144
+ bucket = self._by_scope.setdefault(scope, {})
137
145
  if sid in bucket:
138
- c = bucket.pop(sid)
139
- bucket[sid] = c
140
- return c
141
- if len(bucket) >= self._max:
142
- _, old = bucket.popitem(last=False)
143
- self._cleanup_container(old)
146
+ return bucket[sid]
147
+
144
148
  c = ComponentContainer()
145
149
  bucket[sid] = c
146
150
  return c
@@ -159,8 +163,11 @@ class ScopedCaches:
159
163
  bucket = self._by_scope.get(scope)
160
164
  if not bucket:
161
165
  return
162
- k = max(0, int(keep))
163
- while len(bucket) > k:
164
- _, old = bucket.popitem(last=False)
165
- self._cleanup_container(old)
166
-
166
+
167
+ # Manual cleanup if needed, though we rely on explicit cleanup now
168
+ if len(bucket) > keep:
169
+ # Simple eviction strategy if forced manually
170
+ keys_to_remove = list(bucket.keys())[:len(bucket)-keep]
171
+ for k in keys_to_remove:
172
+ container = bucket.pop(k)
173
+ self._cleanup_container(container)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pico-ioc
3
- Version: 2.1.2
3
+ Version: 2.1.3
4
4
  Summary: A minimalist, zero-dependency Inversion of Control (IoC) container for Python.
5
5
  Author-email: David Perez Cabrera <dperezcabrera@gmail.com>
6
6
  License: MIT License
@@ -124,6 +124,15 @@ Optional extras:
124
124
 
125
125
  -----
126
126
 
127
+ ### ⚠️ Important Note for v2.1.3+
128
+
129
+ **Breaking Behavior in Custom Integrations:**
130
+ As of version 2.1.3, **Scope LRU Eviction has been removed** to guarantee data integrity under high load.
131
+ * **If you use `pico-fastapi`:** You are safe (the middleware handles cleanup automatically).
132
+ * **If you perform manual scope management:** You **must** explicitly call `container._caches.cleanup_scope("scope_name", scope_id)` when a context ends. Failing to do so will result in a memory leak, as scopes are no longer automatically discarded when the container fills up.
133
+
134
+ -----
135
+
127
136
  ## ⚙️ Quick Example (Unified Configuration)
128
137
 
129
138
  ```python
@@ -1,25 +1,25 @@
1
1
  pico_ioc/__init__.py,sha256=i25Obx7aH_Oy5b6yjjnCswDgni7InIjrGEcG6vLAw6I,2414
2
- pico_ioc/_version.py,sha256=m5qImnzcnIhayvILFVqEnXPYsN-vE0vxokygykKhRfw,22
3
- pico_ioc/analysis.py,sha256=Iy3fuXCVLV8xtT-qp-uxsb1QptHBLLrLYbTSfDkQ-OA,4145
4
- pico_ioc/aop.py,sha256=XcyzsuKPrVPk1_Jad7Mn-qwoL1y0ZuVWwRZBA-CslJk,13301
2
+ pico_ioc/_version.py,sha256=hgD1miBO_f3fboq1GKyV4DdK_igCLGJFnZRD7l9oNRs,22
3
+ pico_ioc/analysis.py,sha256=cxlg3F3bwRIb-kVIqHp1RLYj-sk-GuIgCChE-pf0a7c,4189
4
+ pico_ioc/aop.py,sha256=VkmLzoztPbFjLIqBmXohcCNd4w6UC9ZrqYOE8DsMQ7I,14417
5
5
  pico_ioc/api.py,sha256=0pcRFHzhDcX8ijd67xAsVrTejwXuJKz7kTKRUrIuX2s,6161
6
6
  pico_ioc/component_scanner.py,sha256=S-9XNxrgyq_JFdc4Uqn2bEb-HxafSgIWylIurxyN_UA,7955
7
7
  pico_ioc/config_builder.py,sha256=7kcYIq1Yrb46Tic7uLeaCDvLA-Sa_p1PIoGF00mivso,2848
8
8
  pico_ioc/config_registrar.py,sha256=34iNQY1TUEPTXbb-QV1T-c5VKAn18hBcNt5MLhzDSfY,8456
9
- pico_ioc/config_runtime.py,sha256=hiL1kCxhpjbfOdUaH71jMGNESDpWsaJkQXh7q1T71bg,12781
9
+ pico_ioc/config_runtime.py,sha256=qdPIbGMXOf6KWMM7cva6W-hhbhybEY0swZN01R1emCg,12756
10
10
  pico_ioc/constants.py,sha256=AhIt0ieDZ9Turxb_YbNzj11wUbBbzjKfWh1BDlSx2Nw,183
11
- pico_ioc/container.py,sha256=Ys1yLjiB3Qxxm_fvWCEYLSeaJ18LseWmXueAW8kHunk,20874
11
+ pico_ioc/container.py,sha256=SEEJCUHIOUz4Dghdmac2rDrv65Zlm2wiBKZt4WbZoDc,21354
12
12
  pico_ioc/decorators.py,sha256=ru_YeqyJ3gbfb6M8WeJZlBxfcBBEuGDvxpHJGzU6FIs,6412
13
13
  pico_ioc/dependency_validator.py,sha256=BIR6pKntACiabF6CjNZ3m00RMnet9BPK1_9y1iCJ5KQ,4144
14
- pico_ioc/event_bus.py,sha256=nOL91JLYxap9kbb-HBGEhOVwtXN_bfI4q0mtSRZFlHk,8434
14
+ pico_ioc/event_bus.py,sha256=NSfmFPX6Zm2OmMJz16gJFYMhh65iI0n9UlC9M8GmO0c,8428
15
15
  pico_ioc/exceptions.py,sha256=FBuajj5g29hAGODt2tAWuy2sG5mQojdSddaqFzim-aY,2383
16
16
  pico_ioc/factory.py,sha256=oJXx_BYJuvV8oxYzs5I3gx9WM6uLYZ8GCc43gukNanc,1671
17
17
  pico_ioc/locator.py,sha256=JD6psgdGGsBoCwov-G76BrmTfKUoJ22sdwa6wVdmQV8,5064
18
18
  pico_ioc/provider_selector.py,sha256=pU7NbI5vifvUlJEjlRJmvveQUZVD47T24QmiP0CHRw0,1213
19
- pico_ioc/registrar.py,sha256=hIk48nXghTdA3WBljCbw2q8J_6F_hCk1ljSi4Pb8P3A,8368
20
- pico_ioc/scope.py,sha256=hOdTmjjfrRt8APXoS3lbTbSPxILi7flBXz_qpIkpoKw,6137
21
- pico_ioc-2.1.2.dist-info/licenses/LICENSE,sha256=N1_nOvHTM6BobYnOTNXiQkroDqCEi6EzfGBv8lWtyZ0,1077
22
- pico_ioc-2.1.2.dist-info/METADATA,sha256=yerxK_c9JcZxnKqB-nWQL6bSovNLse9Qa67o2jD9R3I,12339
23
- pico_ioc-2.1.2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
24
- pico_ioc-2.1.2.dist-info/top_level.txt,sha256=_7_RLu616z_dtRw16impXn4Mw8IXe2J4BeX5912m5dQ,9
25
- pico_ioc-2.1.2.dist-info/RECORD,,
19
+ pico_ioc/registrar.py,sha256=O22FYrMXQ4TvBf25b0CB1wwu9tjL-5e00n9Ubd8suNs,8394
20
+ pico_ioc/scope.py,sha256=JmFJPSb9uG2oRSZ7YreCozOgqDHfYsYLcyCpqVI4luU,6427
21
+ pico_ioc-2.1.3.dist-info/licenses/LICENSE,sha256=N1_nOvHTM6BobYnOTNXiQkroDqCEi6EzfGBv8lWtyZ0,1077
22
+ pico_ioc-2.1.3.dist-info/METADATA,sha256=-PWHmvXzShw_t9D-_vbGBN24aG7afVchr3ZFyGR0TrQ,12910
23
+ pico_ioc-2.1.3.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
24
+ pico_ioc-2.1.3.dist-info/top_level.txt,sha256=_7_RLu616z_dtRw16impXn4Mw8IXe2J4BeX5912m5dQ,9
25
+ pico_ioc-2.1.3.dist-info/RECORD,,