pico-ioc 2.1.1__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.1'
1
+ __version__ = '2.1.3'
pico_ioc/analysis.py CHANGED
@@ -1,7 +1,14 @@
1
1
  import inspect
2
2
  from dataclasses import dataclass
3
- from typing import Any, Callable, List, Optional, Tuple, Union, get_args, get_origin, Annotated
3
+ import collections
4
+ import collections.abc
5
+ from typing import (
6
+ Any, Callable, List, Optional, Tuple, Union, get_args, get_origin, Annotated,
7
+ Iterable, Set, Sequence, Collection, Deque, FrozenSet, MutableSequence, MutableSet,
8
+ Dict, Mapping
9
+ )
4
10
  from .decorators import Qualifier
11
+ from .constants import LOGGER
5
12
 
6
13
  KeyT = Union[str, type]
7
14
 
@@ -12,6 +19,8 @@ class DependencyRequest:
12
19
  is_list: bool = False
13
20
  qualifier: Optional[str] = None
14
21
  is_optional: bool = False
22
+ is_dict: bool = False
23
+ dict_key_type: Any = None
15
24
 
16
25
  def _extract_annotated(ann: Any) -> Tuple[Any, Optional[str]]:
17
26
  qualifier = None
@@ -39,11 +48,27 @@ def _check_optional(ann: Any) -> Tuple[Any, bool]:
39
48
  def analyze_callable_dependencies(callable_obj: Callable[..., Any]) -> Tuple[DependencyRequest, ...]:
40
49
  try:
41
50
  sig = inspect.signature(callable_obj)
42
- except (ValueError, TypeError):
51
+ except (ValueError, TypeError) as e:
52
+ LOGGER.debug(f"Could not analyze dependencies for {callable_obj!r}: {e}")
43
53
  return ()
44
54
 
45
55
  plan: List[DependencyRequest] = []
46
56
 
57
+ SUPPORTED_COLLECTION_ORIGINS = (
58
+ list,
59
+ set,
60
+ tuple,
61
+ frozenset,
62
+ collections.deque,
63
+ collections.abc.Iterable,
64
+ collections.abc.Collection,
65
+ collections.abc.Sequence,
66
+ collections.abc.MutableSequence,
67
+ collections.abc.MutableSet
68
+ )
69
+
70
+ SUPPORTED_DICT_ORIGINS = (dict, collections.abc.Mapping)
71
+
47
72
  for name, param in sig.parameters.items():
48
73
  if name in ("self", "cls"):
49
74
  continue
@@ -56,19 +81,35 @@ def analyze_callable_dependencies(callable_obj: Callable[..., Any]) -> Tuple[Dep
56
81
  base_type, qualifier = _extract_annotated(base_type)
57
82
 
58
83
  is_list = False
84
+ is_dict = False
59
85
  elem_t = None
86
+ dict_key_t = None
60
87
 
61
88
  origin = get_origin(base_type)
62
- if origin in (list, List):
89
+
90
+ if origin in SUPPORTED_COLLECTION_ORIGINS:
63
91
  is_list = True
64
92
  elem_t = get_args(base_type)[0] if get_args(base_type) else Any
65
93
  elem_t, list_qualifier = _extract_annotated(elem_t)
66
94
  if qualifier is None:
67
95
  qualifier = list_qualifier
96
+ elif origin in SUPPORTED_DICT_ORIGINS:
97
+ is_dict = True
98
+ args = get_args(base_type)
99
+ dict_key_t = args[0] if args else Any
100
+ elem_t = args[1] if len(args) > 1 else Any
101
+ elem_t, dict_qualifier = _extract_annotated(elem_t)
102
+ if qualifier is None:
103
+ qualifier = dict_qualifier
68
104
 
69
105
  final_key: KeyT
106
+ final_dict_key_type: Any = None
107
+
70
108
  if is_list:
71
109
  final_key = elem_t if isinstance(elem_t, type) else Any
110
+ elif is_dict:
111
+ final_key = elem_t if isinstance(elem_t, type) else Any
112
+ final_dict_key_type = dict_key_t
72
113
  elif isinstance(base_type, type):
73
114
  final_key = base_type
74
115
  elif isinstance(base_type, str):
@@ -84,9 +125,10 @@ def analyze_callable_dependencies(callable_obj: Callable[..., Any]) -> Tuple[Dep
84
125
  key=final_key,
85
126
  is_list=is_list,
86
127
  qualifier=qualifier,
87
- is_optional=is_optional or (param.default is not inspect._empty)
128
+ is_optional=is_optional or (param.default is not inspect._empty),
129
+ is_dict=is_dict,
130
+ dict_key_type=final_dict_key_type
88
131
  )
89
132
  )
90
133
 
91
134
  return tuple(plan)
92
-
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
@@ -1,9 +1,10 @@
1
- # src/pico_ioc/container.py
2
-
3
1
  import inspect
4
2
  import contextvars
5
3
  import functools
6
- from typing import Any, Dict, List, Optional, Tuple, overload, Union, Callable, Iterable, Set, get_args, get_origin, Annotated, Protocol, Mapping
4
+ from typing import (
5
+ Any, Dict, List, Optional, Tuple, overload, Union, Callable,
6
+ Iterable, Set, get_args, get_origin, Annotated, Protocol, Mapping, Type
7
+ )
7
8
  from contextlib import contextmanager
8
9
  from .constants import LOGGER, PICO_META
9
10
  from .exceptions import ComponentCreationError, ProviderNotFoundError, AsyncResolutionError, ConfigurationError
@@ -204,7 +205,11 @@ class PicoContainer:
204
205
  args = self._resolve_args(configure_deps)
205
206
  res = m(**args)
206
207
  if inspect.isawaitable(res):
207
- 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
+ )
208
213
  return instance
209
214
 
210
215
  async def runner():
@@ -251,10 +256,12 @@ class PicoContainer:
251
256
  async def aget(self, key: KeyT) -> Any:
252
257
  instance_or_awaitable, took_ms, was_cached = self._resolve_or_create_internal(key)
253
258
 
259
+ instance = instance_or_awaitable
254
260
  if was_cached:
255
- return instance_or_awaitable
261
+ if isinstance(instance, UnifiedComponentProxy):
262
+ await instance._async_init_if_needed()
263
+ return instance
256
264
 
257
- instance = instance_or_awaitable
258
265
  if inspect.isawaitable(instance_or_awaitable):
259
266
  instance = await instance_or_awaitable
260
267
 
@@ -268,6 +275,10 @@ class PicoContainer:
268
275
  instance = instance_or_awaitable_configured
269
276
 
270
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
+
271
282
  cache = self._cache_for(key)
272
283
  cache.put(key, final_instance)
273
284
  self.context.resolve_count += 1
@@ -281,7 +292,7 @@ class PicoContainer:
281
292
  cls = type(instance)
282
293
  for _, fn in inspect.getmembers(cls, predicate=lambda m: inspect.isfunction(m) or inspect.ismethod(m) or inspect.iscoroutinefunction(m)):
283
294
  if getattr(fn, "_pico_interceptors_", None):
284
- return UnifiedComponentProxy(container=self, target=instance)
295
+ return UnifiedComponentProxy(container=self, target=instance, component_key=key)
285
296
  return instance
286
297
 
287
298
  def _iterate_cleanup_targets(self) -> Iterable[Any]:
@@ -373,7 +384,7 @@ class PicoContainer:
373
384
  self.cleanup_all()
374
385
  PicoContainer._container_registry.pop(self.container_id, None)
375
386
 
376
- def build_resolution_graph(self) -> None:
387
+ def build_resolution_graph(self):
377
388
  return _build_resolution_graph(self._locator)
378
389
 
379
390
  def export_graph(
@@ -423,7 +434,7 @@ class PicoContainer:
423
434
  pid = _node_id(parent)
424
435
  for child in deps:
425
436
  cid = _node_id(child)
426
- lines.append(f" {pid} -> {cid};")
437
+ lines.append(f" {pid} -> {child};")
427
438
 
428
439
  lines.append("}")
429
440
 
@@ -433,19 +444,61 @@ class PicoContainer:
433
444
 
434
445
  def _resolve_args(self, dependencies: Tuple[DependencyRequest, ...]) -> Dict[str, Any]:
435
446
  kwargs: Dict[str, Any] = {}
436
- if not dependencies:
447
+ if not dependencies or self._locator is None:
437
448
  return kwargs
438
449
 
439
450
  for dep in dependencies:
440
451
  if dep.is_list:
441
452
  keys: Tuple[KeyT, ...] = ()
442
- if self._locator is not None and isinstance(dep.key, type):
453
+ if isinstance(dep.key, type):
443
454
  keys = tuple(self._locator.collect_by_type(dep.key, dep.qualifier))
444
455
  kwargs[dep.parameter_name] = [self.get(k) for k in keys]
445
456
  continue
457
+
458
+ if dep.is_dict:
459
+ value_type = dep.key
460
+ key_type = dep.dict_key_type
461
+ result_map: Dict[Any, Any] = {}
462
+
463
+ keys_to_resolve: Tuple[KeyT, ...] = ()
464
+ if isinstance(value_type, type):
465
+ keys_to_resolve = tuple(self._locator.collect_by_type(value_type, dep.qualifier))
466
+
467
+ for comp_key in keys_to_resolve:
468
+ instance = self.get(comp_key)
469
+ md = self._locator._metadata.get(comp_key)
470
+ if md is None:
471
+ continue
472
+
473
+ dict_key: Any = None
474
+ if key_type is str:
475
+ dict_key = md.pico_name
476
+ if dict_key is None:
477
+ if isinstance(comp_key, str):
478
+ dict_key = comp_key
479
+ else:
480
+ dict_key = getattr(comp_key, "__name__", str(comp_key))
481
+ elif key_type is type or key_type is Type:
482
+ dict_key = md.concrete_class or md.provided_type
483
+ elif key_type is Any:
484
+ dict_key = md.pico_name
485
+ if dict_key is None:
486
+ if isinstance(comp_key, str):
487
+ dict_key = comp_key
488
+ else:
489
+ dict_key = getattr(comp_key, "__name__", str(comp_key))
490
+
491
+ if dict_key is not None:
492
+ if (key_type is type or key_type is Type) and not isinstance(dict_key, type):
493
+ continue
494
+
495
+ result_map[dict_key] = instance
496
+
497
+ kwargs[dep.parameter_name] = result_map
498
+ continue
446
499
 
447
500
  primary_key = dep.key
448
- if isinstance(primary_key, str) and self._locator is not None:
501
+ if isinstance(primary_key, str):
449
502
  mapped = self._locator.find_key_by_name(primary_key)
450
503
  primary_key = mapped if mapped is not None else primary_key
451
504
 
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/locator.py CHANGED
@@ -80,9 +80,13 @@ class ComponentLocator:
80
80
  return isinstance(inst, proto)
81
81
  except Exception:
82
82
  pass
83
+
83
84
  for name, val in proto.__dict__.items():
84
- if name.startswith("_") or not callable(val):
85
+ if name.startswith("_") or not (callable(val) or name in getattr(proto, "__annotations__", {})):
85
86
  continue
87
+
88
+ if not hasattr(typ, name):
89
+ return False
86
90
  return True
87
91
 
88
92
  def collect_by_type(self, t: type, q: Optional[str]) -> List[KeyT]:
@@ -122,6 +126,10 @@ class ComponentLocator:
122
126
  if isinstance(dep.key, type):
123
127
  keys = self.collect_by_type(dep.key, dep.qualifier)
124
128
  deps.extend(keys)
129
+ elif dep.is_dict:
130
+ if isinstance(dep.key, type):
131
+ keys = self.collect_by_type(dep.key, dep.qualifier)
132
+ deps.extend(keys)
125
133
  else:
126
134
  deps.append(dep.key)
127
135
  return tuple(deps)
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.1
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
@@ -58,49 +58,53 @@ Dynamic: license-file
58
58
  [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=dperezcabrera_pico-ioc&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=dperezcabrera_pico-ioc)
59
59
  [![Duplicated Lines (%)](https://sonarcloud.io/api/project_badges/measure?project=dperezcabrera_pico-ioc&metric=duplicated_lines_density)](https://sonarcloud.io/summary/new_code?id=dperezcabrera_pico-ioc)
60
60
  [![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=dperezcabrera_pico-ioc&metric=sqale_rating)](https://sonarcloud.io/summary/new_code?id=dperezcabrera_pico-ioc)
61
+ [![PyPI Downloads](https://static.pepy.tech/personalized-badge/pico-ioc?period=monthly&units=INTERNATIONAL_SYSTEM&left_color=BLACK&right_color=GREEN&left_text=Monthly+downloads)](https://pepy.tech/projects/pico-ioc)
62
+ [![Docs](https://img.shields.io/badge/Docs-pico--ioc-blue?style=flat&logo=readthedocs&logoColor=white)](https://dperezcabrera.github.io/pico-ioc/)
63
+ [![Interactive Lab](https://img.shields.io/badge/Learn-online-green?style=flat&logo=python&logoColor=white)](https://dperezcabrera.github.io/learn-pico-ioc/)
64
+
61
65
 
62
66
  **Pico-IoC** is a **lightweight, async-ready, decorator-driven IoC container** built for clarity, testability, and performance.
63
- It brings *Inversion of Control* and *dependency injection* to Python in a deterministic, modern, and framework-agnostic way.
67
+ It brings Inversion of Control and dependency injection to Python in a deterministic, modern, and framework-agnostic way.
64
68
 
65
- > 🐍 Requires **Python 3.10+**
69
+ > 🐍 Requires Python 3.10+
66
70
 
67
71
  ---
68
72
 
69
73
  ## ⚖️ Core Principles
70
74
 
71
- - **Single Purpose** – Do one thing: dependency management.
72
- - **Declarative** – Use simple decorators (`@component`, `@factory`, `@provides`, `@configured`) instead of complex config files.
73
- - **Deterministic** – No hidden scanning or side-effects; everything flows from an explicit `init()`.
74
- - **Async-Native** – Fully supports async providers, async lifecycle hooks (`__ainit__`), and async interceptors.
75
- - **Fail-Fast** – Detects missing bindings and circular dependencies at bootstrap (`init()`).
76
- - **Testable by Design** – Use `overrides` and `profiles` to swap components instantly.
77
- - **Zero Core Dependencies** – Built entirely on the Python standard library. Optional features may require external packages (see Installation).
75
+ - Single Purpose – Do one thing: dependency management.
76
+ - Declarative – Use simple decorators (`@component`, `@factory`, `@provides`, `@configured`) instead of complex config files.
77
+ - Deterministic – No hidden scanning or side-effects; everything flows from an explicit `init()`.
78
+ - Async-Native – Fully supports async providers, async lifecycle hooks (`__ainit__`), and async interceptors.
79
+ - Fail-Fast – Detects missing bindings and circular dependencies at bootstrap (`init()`).
80
+ - Testable by Design – Use `overrides` and `profiles` to swap components instantly.
81
+ - Zero Core Dependencies – Built entirely on the Python standard library. Optional features may require external packages (see Installation).
78
82
 
79
83
  ---
80
84
 
81
85
  ## 🚀 Why Pico-IoC?
82
86
 
83
87
  As Python systems evolve, wiring dependencies by hand becomes fragile and unmaintainable.
84
- **Pico-IoC** eliminates that friction by letting you declare how components relate — not how they’re created.
88
+ Pico-IoC eliminates that friction by letting you declare how components relate — not how they’re created.
85
89
 
86
- | Feature | Manual Wiring | With Pico-IoC |
87
- | :-------------- | :------------------------- | :-------------------------------- |
88
- | Object creation | `svc = Service(Repo(Config()))` | `svc = container.get(Service)` |
89
- | Replacing deps | Monkey-patch | `overrides={Repo: FakeRepo()}` |
90
- | Coupling | Tight | Loose |
91
- | Testing | Painful | Instant |
92
- | Async support | Manual | Built-in (`aget`, `__ainit__`, ...) |
90
+ | Feature | Manual Wiring | With Pico-IoC |
91
+ | :-------------- | :----------------------------- | :------------------------------ |
92
+ | Object creation | `svc = Service(Repo(Config()))` | `svc = container.get(Service)` |
93
+ | Replacing deps | Monkey-patch | `overrides={Repo: FakeRepo()}` |
94
+ | Coupling | Tight | Loose |
95
+ | Testing | Painful | Instant |
96
+ | Async support | Manual | Built-in (`aget`, `__ainit__`) |
93
97
 
94
98
  ---
95
99
 
96
100
  ## 🧩 Highlights (v2.0+)
97
101
 
98
- - **Unified Configuration:** Use `@configured` to bind both **flat** (ENV-like) and **tree** (YAML/JSON) sources via the `configuration(...)` builder (ADR-0010).
99
- - **Async-aware AOP system:** Method interceptors via `@intercepted_by`.
100
- - **Scoped resolution:** singleton, prototype, request, session, transaction, and custom scopes.
101
- - **`UnifiedComponentProxy`:** Transparent `lazy=True` and AOP proxy supporting serialization.
102
- - **Tree-based configuration runtime:** Advanced mapping with reusable adapters and discriminators (`Annotated[Union[...], Discriminator(...)]`).
103
- - **Observable container context:** Built-in stats, health checks (`@health`), observer hooks (`ContainerObserver`), dependency graph export (`export_graph`), and async cleanup.
102
+ - Unified Configuration: Use `@configured` to bind both flat (ENV-like) and tree (YAML/JSON) sources via the `configuration(...)` builder (ADR-0010).
103
+ - Async-aware AOP system: Method interceptors via `@intercepted_by`.
104
+ - Scoped resolution: singleton, prototype, request, session, transaction, and custom scopes.
105
+ - `UnifiedComponentProxy`: Transparent `lazy=True` and AOP proxy supporting serialization.
106
+ - Tree-based configuration runtime: Advanced mapping with reusable adapters and discriminators (`Annotated[Union[...], Discriminator(...)]`).
107
+ - Observable container context: Built-in stats, health checks (`@health`), observer hooks (`ContainerObserver`), dependency graph export (`export_graph`), and async cleanup.
104
108
 
105
109
  ---
106
110
 
@@ -108,26 +112,24 @@ As Python systems evolve, wiring dependencies by hand becomes fragile and unmain
108
112
 
109
113
  ```bash
110
114
  pip install pico-ioc
111
- ````
115
+ ```
112
116
 
113
- For optional features, you can install extras:
117
+ Optional extras:
114
118
 
115
- * **YAML Configuration:**
119
+ - YAML configuration support (requires PyYAML)
116
120
 
117
- ```bash
118
- pip install pico-ioc[yaml]
119
- ```
121
+ ```bash
122
+ pip install pico-ioc[yaml]
123
+ ```
120
124
 
121
- (Requires `PyYAML`)
125
+ -----
122
126
 
123
- * **Dependency Graph Export (Rendering):**
127
+ ### ⚠️ Important Note for v2.1.3+
124
128
 
125
- ```bash
126
- # You still need Graphviz command-line tools installed separately
127
- # This extra is currently not required by the code,
128
- # as export_graph generates the .dot file content directly.
129
- # pip install pico-ioc[graphviz] # Consider removing if not used by code
130
- ```
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.
131
133
 
132
134
  -----
133
135
 
@@ -139,7 +141,7 @@ from dataclasses import dataclass
139
141
  from pico_ioc import component, configured, configuration, init, EnvSource
140
142
 
141
143
  # 1. Define configuration with @configured
142
- @configured(prefix="APP_", mapping="auto") # Auto-detects flat mapping
144
+ @configured(prefix="APP_", mapping="auto") # Auto-detects flat mapping
143
145
  @dataclass
144
146
  class Config:
145
147
  db_url: str = "sqlite:///demo.db"
@@ -147,14 +149,14 @@ class Config:
147
149
  # 2. Define components
148
150
  @component
149
151
  class Repo:
150
- def __init__(self, cfg: Config): # Inject config
152
+ def __init__(self, cfg: Config): # Inject config
151
153
  self.cfg = cfg
152
154
  def fetch(self):
153
155
  return f"fetching from {self.cfg.db_url}"
154
156
 
155
157
  @component
156
158
  class Service:
157
- def __init__(self, repo: Repo): # Inject Repo
159
+ def __init__(self, repo: Repo): # Inject Repo
158
160
  self.repo = repo
159
161
  def run(self):
160
162
  return self.repo.fetch()
@@ -164,11 +166,11 @@ os.environ['APP_DB_URL'] = 'postgresql://user:pass@host/db'
164
166
 
165
167
  # 3. Build configuration context
166
168
  config_ctx = configuration(
167
- EnvSource(prefix="") # Read APP_DB_URL from environment
169
+ EnvSource(prefix="") # Read APP_DB_URL from environment
168
170
  )
169
171
 
170
172
  # 4. Initialize container
171
- container = init(modules=[__name__], config=config_ctx) # Pass context via 'config'
173
+ container = init(modules=[__name__], config=config_ctx) # Pass context via 'config'
172
174
 
173
175
  # 5. Get and use the service
174
176
  svc = container.get(Service)
@@ -178,7 +180,7 @@ print(svc.run())
178
180
  del os.environ['APP_DB_URL']
179
181
  ```
180
182
 
181
- **Output:**
183
+ Output:
182
184
 
183
185
  ```
184
186
  fetching from postgresql://user:pass@host/db
@@ -199,7 +201,7 @@ test_config_ctx = configuration()
199
201
  container = init(
200
202
  modules=[__name__],
201
203
  config=test_config_ctx,
202
- overrides={Repo: FakeRepo()} # Replace Repo with FakeRepo
204
+ overrides={Repo: FakeRepo()} # Replace Repo with FakeRepo
203
205
  )
204
206
 
205
207
  svc = container.get(Service)
@@ -208,10 +210,56 @@ assert svc.run() == "fake-data"
208
210
 
209
211
  -----
210
212
 
213
+ ## 🧰 Profiles
214
+
215
+ Use profiles to enable/disable components or configuration branches conditionally.
216
+
217
+ ```python
218
+ # Enable "test" profile when bootstrapping the container
219
+ container = init(
220
+ modules=[__name__],
221
+ profiles=["test"]
222
+ )
223
+ ```
224
+
225
+ Profiles are typically referenced in decorators or configuration mappings to include/exclude components and bindings.
226
+
227
+ -----
228
+
229
+ ## ⚡ Async Components
230
+
231
+ Pico-IoC supports async lifecycle and resolution.
232
+
233
+ ```python
234
+ import asyncio
235
+ from pico_ioc import component, init
236
+
237
+ @component
238
+ class AsyncRepo:
239
+ async def __ainit__(self):
240
+ # e.g., open async connections
241
+ self.ready = True
242
+
243
+ async def fetch(self):
244
+ return "async-data"
245
+
246
+ async def main():
247
+ container = init(modules=[__name__])
248
+ repo = await container.aget(AsyncRepo) # Async resolution
249
+ print(await repo.fetch())
250
+
251
+ asyncio.run(main())
252
+ ```
253
+
254
+ - `__ainit__` runs after construction if defined.
255
+ - Use `container.aget(Type)` to resolve components that require async initialization or whose providers are async.
256
+
257
+ -----
258
+
211
259
  ## 🩺 Lifecycle & AOP
212
260
 
213
261
  ```python
214
- import time # For example
262
+ import time
215
263
  from pico_ioc import component, init, intercepted_by, MethodInterceptor, MethodCtx
216
264
 
217
265
  # Define an interceptor component
@@ -232,7 +280,7 @@ class LogInterceptor(MethodInterceptor):
232
280
 
233
281
  @component
234
282
  class Demo:
235
- @intercepted_by(LogInterceptor) # Apply the interceptor
283
+ @intercepted_by(LogInterceptor) # Apply the interceptor
236
284
  def work(self):
237
285
  print(" Working...")
238
286
  time.sleep(0.01)
@@ -244,7 +292,7 @@ result = c.get(Demo).work()
244
292
  print(f"Result: {result}")
245
293
  ```
246
294
 
247
- **Output:**
295
+ Output:
248
296
 
249
297
  ```
250
298
  → calling Demo.work
@@ -255,19 +303,41 @@ Result: ok
255
303
 
256
304
  -----
257
305
 
306
+ ## 👁️ Observability & Cleanup
307
+
308
+ - Export a dependency graph in DOT format:
309
+
310
+ ```python
311
+ c = init(modules=[...])
312
+ dot = c.export_graph() # Returns DOT graph as a string
313
+ with open("dependencies.dot", "w") as f:
314
+ f.write(dot)
315
+ ```
316
+
317
+ - Health checks:
318
+ - Annotate health probes inside components with `@health` for container-level reporting.
319
+ - The container exposes health information that can be queried in observability tooling.
320
+
321
+ - Container cleanup:
322
+ - For sync components: `container.close()`
323
+ - For async components/resources: `await container.aclose()`
324
+
325
+ Use cleanup in application shutdown hooks to release resources deterministically.
326
+
327
+ -----
328
+
258
329
  ## 📖 Documentation
259
330
 
260
331
  The full documentation is available within the `docs/` directory of the project repository. Start with `docs/README.md` for navigation.
261
332
 
262
- * **Getting Started:** `docs/getting-started.md`
263
- * **User Guide:** `docs/user-guide/README.md`
264
- * **Advanced Features:** `docs/advanced-features/README.md`
265
- * **Observability:** `docs/observability/README.md`
266
- * **Integrations:** `docs/integrations/README.md`
267
- * **Cookbook (Patterns):** `docs/cookbook/README.md`
268
- * **Architecture:** `docs/architecture/README.md`
269
- * **API Reference:** `docs/api-reference/README.md`
270
- * **ADR Index:** `docs/adr/README.md`
333
+ - Getting Started: `docs/getting-started.md`
334
+ - User Guide: `docs/user-guide/README.md`
335
+ - Advanced Features: `docs/advanced-features/README.md`
336
+ - Observability: `docs/observability/README.md`
337
+ - Cookbook (Patterns): `docs/cookbook/README.md`
338
+ - Architecture: `docs/architecture/README.md`
339
+ - API Reference: `docs/api-reference/README.md`
340
+ - ADR Index: `docs/adr/README.md`
271
341
 
272
342
  -----
273
343
 
@@ -282,11 +352,10 @@ tox
282
352
 
283
353
  ## 🧾 Changelog
284
354
 
285
- See [CHANGELOG.md](./CHANGELOG.md) — *Significant redesigns and features in v2.0+.*
355
+ See [CHANGELOG.md](./CHANGELOG.md) — Significant redesigns and features in v2.0+.
286
356
 
287
357
  -----
288
358
 
289
359
  ## 📜 License
290
360
 
291
361
  MIT — [LICENSE](https://opensource.org/licenses/MIT)
292
-
@@ -0,0 +1,25 @@
1
+ pico_ioc/__init__.py,sha256=i25Obx7aH_Oy5b6yjjnCswDgni7InIjrGEcG6vLAw6I,2414
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
+ pico_ioc/api.py,sha256=0pcRFHzhDcX8ijd67xAsVrTejwXuJKz7kTKRUrIuX2s,6161
6
+ pico_ioc/component_scanner.py,sha256=S-9XNxrgyq_JFdc4Uqn2bEb-HxafSgIWylIurxyN_UA,7955
7
+ pico_ioc/config_builder.py,sha256=7kcYIq1Yrb46Tic7uLeaCDvLA-Sa_p1PIoGF00mivso,2848
8
+ pico_ioc/config_registrar.py,sha256=34iNQY1TUEPTXbb-QV1T-c5VKAn18hBcNt5MLhzDSfY,8456
9
+ pico_ioc/config_runtime.py,sha256=qdPIbGMXOf6KWMM7cva6W-hhbhybEY0swZN01R1emCg,12756
10
+ pico_ioc/constants.py,sha256=AhIt0ieDZ9Turxb_YbNzj11wUbBbzjKfWh1BDlSx2Nw,183
11
+ pico_ioc/container.py,sha256=SEEJCUHIOUz4Dghdmac2rDrv65Zlm2wiBKZt4WbZoDc,21354
12
+ pico_ioc/decorators.py,sha256=ru_YeqyJ3gbfb6M8WeJZlBxfcBBEuGDvxpHJGzU6FIs,6412
13
+ pico_ioc/dependency_validator.py,sha256=BIR6pKntACiabF6CjNZ3m00RMnet9BPK1_9y1iCJ5KQ,4144
14
+ pico_ioc/event_bus.py,sha256=NSfmFPX6Zm2OmMJz16gJFYMhh65iI0n9UlC9M8GmO0c,8428
15
+ pico_ioc/exceptions.py,sha256=FBuajj5g29hAGODt2tAWuy2sG5mQojdSddaqFzim-aY,2383
16
+ pico_ioc/factory.py,sha256=oJXx_BYJuvV8oxYzs5I3gx9WM6uLYZ8GCc43gukNanc,1671
17
+ pico_ioc/locator.py,sha256=JD6psgdGGsBoCwov-G76BrmTfKUoJ22sdwa6wVdmQV8,5064
18
+ pico_ioc/provider_selector.py,sha256=pU7NbI5vifvUlJEjlRJmvveQUZVD47T24QmiP0CHRw0,1213
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,,
@@ -1,25 +0,0 @@
1
- pico_ioc/__init__.py,sha256=i25Obx7aH_Oy5b6yjjnCswDgni7InIjrGEcG6vLAw6I,2414
2
- pico_ioc/_version.py,sha256=Aht2295j8FswZ-nPYofCYr3fBZ6Uyf0thTfl5Oc2mWA,22
3
- pico_ioc/analysis.py,sha256=k49R-HcDyvpSNid8mxv7Fc6fPHnDu1C_b4HxrGLNF2g,2780
4
- pico_ioc/aop.py,sha256=XcyzsuKPrVPk1_Jad7Mn-qwoL1y0ZuVWwRZBA-CslJk,13301
5
- pico_ioc/api.py,sha256=0pcRFHzhDcX8ijd67xAsVrTejwXuJKz7kTKRUrIuX2s,6161
6
- pico_ioc/component_scanner.py,sha256=S-9XNxrgyq_JFdc4Uqn2bEb-HxafSgIWylIurxyN_UA,7955
7
- pico_ioc/config_builder.py,sha256=7kcYIq1Yrb46Tic7uLeaCDvLA-Sa_p1PIoGF00mivso,2848
8
- pico_ioc/config_registrar.py,sha256=34iNQY1TUEPTXbb-QV1T-c5VKAn18hBcNt5MLhzDSfY,8456
9
- pico_ioc/config_runtime.py,sha256=hiL1kCxhpjbfOdUaH71jMGNESDpWsaJkQXh7q1T71bg,12781
10
- pico_ioc/constants.py,sha256=AhIt0ieDZ9Turxb_YbNzj11wUbBbzjKfWh1BDlSx2Nw,183
11
- pico_ioc/container.py,sha256=gXgQT12ChpexbZALUfb0YYohlcRbUUeJ8-ltdR7xitc,18956
12
- pico_ioc/decorators.py,sha256=ru_YeqyJ3gbfb6M8WeJZlBxfcBBEuGDvxpHJGzU6FIs,6412
13
- pico_ioc/dependency_validator.py,sha256=BIR6pKntACiabF6CjNZ3m00RMnet9BPK1_9y1iCJ5KQ,4144
14
- pico_ioc/event_bus.py,sha256=nOL91JLYxap9kbb-HBGEhOVwtXN_bfI4q0mtSRZFlHk,8434
15
- pico_ioc/exceptions.py,sha256=FBuajj5g29hAGODt2tAWuy2sG5mQojdSddaqFzim-aY,2383
16
- pico_ioc/factory.py,sha256=oJXx_BYJuvV8oxYzs5I3gx9WM6uLYZ8GCc43gukNanc,1671
17
- pico_ioc/locator.py,sha256=4WN1qvXXW_LRInB2XJR8pTgIuJ8RyWBSpVo28HwtlL0,4737
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.1.dist-info/licenses/LICENSE,sha256=N1_nOvHTM6BobYnOTNXiQkroDqCEi6EzfGBv8lWtyZ0,1077
22
- pico_ioc-2.1.1.dist-info/METADATA,sha256=deaBUX6MOzClrMVDDhk5siAh2W0QrtQI-w4n2ZI-gGI,10673
23
- pico_ioc-2.1.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
24
- pico_ioc-2.1.1.dist-info/top_level.txt,sha256=_7_RLu616z_dtRw16impXn4Mw8IXe2J4BeX5912m5dQ,9
25
- pico_ioc-2.1.1.dist-info/RECORD,,