pico-ioc 2.1.2__py3-none-any.whl → 2.2.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/__init__.py CHANGED
@@ -1,4 +1,3 @@
1
- # src/pico_ioc/__init__.py
2
1
  from .constants import LOGGER_NAME, LOGGER, PICO_INFRA, PICO_NAME, PICO_KEY, PICO_META
3
2
  from .exceptions import (
4
3
  PicoError,
@@ -31,6 +30,7 @@ from .container import PicoContainer
31
30
  from .event_bus import EventBus, ExecPolicy, ErrorPolicy, Event, subscribe, AutoSubscriberMixin
32
31
  from .config_runtime import JsonTreeSource, YamlTreeSource, DictSource, Discriminator, Value
33
32
  from .analysis import DependencyRequest, analyze_callable_dependencies
33
+ from .component_scanner import CustomScanner
34
34
 
35
35
  __all__ = [
36
36
  "LOGGER_NAME",
@@ -91,4 +91,5 @@ __all__ = [
91
91
  "Value",
92
92
  "DependencyRequest",
93
93
  "analyze_callable_dependencies",
94
+ "CustomScanner",
94
95
  ]
pico_ioc/_version.py CHANGED
@@ -1 +1 @@
1
- __version__ = '2.1.2'
1
+ __version__ = '2.2.0'
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())
pico_ioc/api.py CHANGED
@@ -1,5 +1,3 @@
1
- # src/pico_ioc/api.py
2
-
3
1
  import importlib
4
2
  import pkgutil
5
3
  import logging
@@ -13,6 +11,8 @@ from .container import PicoContainer
13
11
  from .decorators import component, factory, provides, Qualifier, configure, cleanup, configured
14
12
  from .config_builder import ContextConfig, configuration
15
13
  from .registrar import Registrar
14
+ from .aop import ContainerObserver
15
+ from .component_scanner import CustomScanner
16
16
 
17
17
  KeyT = Union[str, type]
18
18
  Provider = Callable[[], Any]
@@ -63,7 +63,8 @@ def init(
63
63
  custom_scopes: Optional[Iterable[str]] = None,
64
64
  validate_only: bool = False,
65
65
  container_id: Optional[str] = None,
66
- observers: Optional[List["ContainerObserver"]] = None,
66
+ observers: Optional[List[ContainerObserver]] = None,
67
+ custom_scanners: Optional[List[CustomScanner]] = None,
67
68
  ) -> PicoContainer:
68
69
  active = tuple(p.strip() for p in profiles if p)
69
70
 
@@ -82,6 +83,11 @@ def init(
82
83
 
83
84
  pico = PicoContainer(factory, caches, scopes, container_id=container_id, profiles=active, observers=observers or [])
84
85
  registrar = Registrar(factory, profiles=active, environ=environ, logger=logger, config=config)
86
+
87
+ if custom_scanners:
88
+ for scanner in custom_scanners:
89
+ registrar.register_custom_scanner(scanner)
90
+
85
91
  for m in _iter_input_modules(modules):
86
92
  registrar.register_module(m)
87
93
 
@@ -1,6 +1,6 @@
1
1
  import inspect
2
2
  import os
3
- from typing import Any, Callable, Dict, List, Optional, Tuple, Union, Set
3
+ from typing import Any, Callable, Dict, List, Optional, Tuple, Union, Set, Protocol
4
4
  from .constants import PICO_INFRA, PICO_NAME, PICO_KEY, PICO_META
5
5
  from .factory import ProviderMetadata, DeferredProvider
6
6
  from .decorators import get_return_type
@@ -10,6 +10,10 @@ from .analysis import analyze_callable_dependencies, DependencyRequest
10
10
  KeyT = Union[str, type]
11
11
  Provider = Callable[[], Any]
12
12
 
13
+ class CustomScanner(Protocol):
14
+ def should_scan(self, obj: Any) -> bool: ...
15
+ def scan(self, obj: Any) -> Optional[Tuple[KeyT, Provider, ProviderMetadata]]: ...
16
+
13
17
  class ComponentScanner:
14
18
  def __init__(self, profiles: Set[str], environ: Dict[str, str], config_manager: ConfigurationManager):
15
19
  self._profiles = profiles
@@ -19,6 +23,10 @@ class ComponentScanner:
19
23
  self._on_missing: List[Tuple[int, KeyT, type]] = []
20
24
  self._deferred: List[DeferredProvider] = []
21
25
  self._provides_functions: Dict[KeyT, Callable[..., Any]] = {}
26
+ self._custom_scanners: List[CustomScanner] = []
27
+
28
+ def register_custom_scanner(self, scanner: CustomScanner) -> None:
29
+ self._custom_scanners.append(scanner)
22
30
 
23
31
  def get_scan_results(self) -> Tuple[Dict[KeyT, List[Tuple[bool, Provider, ProviderMetadata]]], List[Tuple[int, KeyT, type]], List[DeferredProvider], Dict[KeyT, Callable[..., Any]]]:
24
32
  return self._candidates, self._on_missing, self._deferred, self._provides_functions
@@ -86,7 +94,6 @@ class ComponentScanner:
86
94
  if has_instance_provides:
87
95
  factory_deps = analyze_callable_dependencies(cls.__init__)
88
96
 
89
-
90
97
  for name in dir(cls):
91
98
  try:
92
99
  raw = inspect.getattr_static(cls, name)
@@ -140,27 +147,44 @@ class ComponentScanner:
140
147
  self._queue(k, provider, md)
141
148
  self._provides_functions[k] = fn
142
149
 
150
+ def _try_custom_scanners(self, obj: Any) -> bool:
151
+ for scanner in self._custom_scanners:
152
+ if scanner.should_scan(obj):
153
+ result = scanner.scan(obj)
154
+ if result:
155
+ key, provider, md = result
156
+ self._queue(key, provider, md)
157
+ if isinstance(provider, DeferredProvider):
158
+ self._deferred.append(provider)
159
+ return True
160
+ return False
161
+
143
162
  def scan_module(self, module: Any) -> None:
144
163
  for _, obj in inspect.getmembers(module):
145
- if inspect.isclass(obj):
146
- meta = getattr(obj, PICO_META, {})
147
- if "on_missing" in meta:
148
- sel = meta["on_missing"]["selector"]
149
- pr = int(meta["on_missing"].get("priority", 0))
150
- self._on_missing.append((pr, sel, obj))
151
- continue
152
-
153
- infra = getattr(obj, PICO_INFRA, None)
154
- if infra == "component":
155
- self._register_component_class(obj)
156
- elif infra == "factory":
157
- self._register_factory_class(obj)
158
- elif infra == "configured":
159
- enabled = self._enabled_by_condition(obj)
160
- reg_data = self._config_manager.register_configured_class(obj, enabled)
161
- if reg_data:
162
- self._queue(reg_data[0], reg_data[1], reg_data[2])
163
-
164
- for _, fn in inspect.getmembers(module, predicate=inspect.isfunction):
165
- if getattr(fn, PICO_INFRA, None) == "provides":
166
- self._register_provides_function(fn)
164
+ if self._try_custom_scanners(obj):
165
+ continue
166
+
167
+ if inspect.isclass(obj) or getattr(obj, "_is_protocol", False):
168
+ if inspect.isclass(obj):
169
+ meta = getattr(obj, PICO_META, {})
170
+
171
+ if "on_missing" in meta:
172
+ sel = meta["on_missing"]["selector"]
173
+ pr = int(meta["on_missing"].get("priority", 0))
174
+ self._on_missing.append((pr, sel, obj))
175
+ continue
176
+
177
+ infra = getattr(obj, PICO_INFRA, None)
178
+ if infra == "component":
179
+ self._register_component_class(obj)
180
+ elif infra == "factory":
181
+ self._register_factory_class(obj)
182
+ elif infra == "configured":
183
+ enabled = self._enabled_by_condition(obj)
184
+ reg_data = self._config_manager.register_configured_class(obj, enabled)
185
+ if reg_data:
186
+ self._queue(reg_data[0], reg_data[1], reg_data[2])
187
+
188
+ elif inspect.isfunction(obj):
189
+ if getattr(obj, PICO_INFRA, None) == "provides":
190
+ self._register_provides_function(obj)
@@ -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,11 @@ 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
+ async def ashutdown(self) -> None:
388
+ await self.cleanup_all_async()
389
+ PicoContainer._container_registry.pop(self.container_id, None)
390
+
391
+ def build_resolution_graph(self):
378
392
  return _build_resolution_graph(self._locator)
379
393
 
380
394
  def export_graph(
@@ -424,7 +438,7 @@ class PicoContainer:
424
438
  pid = _node_id(parent)
425
439
  for child in deps:
426
440
  cid = _node_id(child)
427
- lines.append(f" {pid} -> {cid};")
441
+ lines.append(f" {pid} -> {child};")
428
442
 
429
443
  lines.append("}")
430
444
 
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
@@ -15,7 +15,7 @@ from .config_runtime import TreeSource
15
15
  from .config_registrar import ConfigurationManager
16
16
  from .provider_selector import ProviderSelector
17
17
  from .dependency_validator import DependencyValidator
18
- from .component_scanner import ComponentScanner
18
+ from .component_scanner import ComponentScanner, CustomScanner
19
19
  from .analysis import analyze_callable_dependencies, DependencyRequest
20
20
  from .container import PicoContainer
21
21
 
@@ -49,6 +49,8 @@ class Registrar:
49
49
  self._deferred: List[DeferredProvider] = []
50
50
  self._provides_functions: Dict[KeyT, Callable[..., Any]] = {}
51
51
 
52
+ def register_custom_scanner(self, scanner: CustomScanner) -> None:
53
+ self._scanner.register_custom_scanner(scanner)
52
54
 
53
55
  def locator(self) -> ComponentLocator:
54
56
  loc = ComponentLocator(dict(self._metadata), dict(self._indexes))
@@ -61,20 +63,17 @@ class Registrar:
61
63
  for key, md in list(self._metadata.items()):
62
64
  if md.lazy:
63
65
  original = self._factory.get(key, origin='lazy')
64
- def lazy_proxy_provider(_orig=original, _p=pico):
65
- return UnifiedComponentProxy(container=_p, object_creator=_orig)
66
+ def lazy_proxy_provider(_orig=original, _p=pico, _k=key):
67
+ return UnifiedComponentProxy(container=_p, object_creator=_orig, component_key=_k)
66
68
  self._factory.bind(key, lazy_proxy_provider)
67
69
 
68
-
69
70
  def _bind_if_absent(self, key: KeyT, provider: Provider) -> None:
70
71
  if not self._factory.has(key):
71
72
  self._factory.bind(key, provider)
72
73
 
73
-
74
74
  def register_module(self, module: Any) -> None:
75
75
  self._scanner.scan_module(module)
76
76
 
77
-
78
77
  def _find_md_for_type(self, t: type) -> Optional[ProviderMetadata]:
79
78
  cands: List[ProviderMetadata] = []
80
79
  for md in self._metadata.values():
@@ -133,7 +132,6 @@ class Registrar:
133
132
  if md.pico_name is not None:
134
133
  add("pico_name", md.pico_name, k)
135
134
 
136
-
137
135
  def finalize(self, overrides: Optional[Dict[KeyT, Any]], *, pico_instance: PicoContainer) -> None:
138
136
  candidates, on_missing, deferred_providers, provides_functions = self._scanner.get_scan_results()
139
137
  self._deferred = deferred_providers
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
@@ -93,11 +92,11 @@ class ScopeManager:
93
92
  return self.signature(self.names())
94
93
 
95
94
  class ScopedCaches:
96
- def __init__(self, max_scopes_per_type: int = 2048) -> None:
95
+ def __init__(self) -> None:
97
96
  self._singleton = ComponentContainer()
98
- self._by_scope: Dict[str, OrderedDict[Any, ComponentContainer]] = {}
99
- self._max = int(max_scopes_per_type)
97
+ self._by_scope: Dict[str, Dict[Any, ComponentContainer]] = {}
100
98
  self._no_cache = _NoCacheContainer()
99
+
101
100
  def _cleanup_object(self, obj: Any) -> None:
102
101
  try:
103
102
  from .constants import PICO_META
@@ -132,15 +131,19 @@ class ScopedCaches:
132
131
  return self._singleton
133
132
  if scope == "prototype":
134
133
  return self._no_cache
134
+
135
135
  sid = scopes.get_id(scope)
136
- bucket = self._by_scope.setdefault(scope, OrderedDict())
136
+
137
+ if sid is None:
138
+ raise ScopeError(
139
+ f"Cannot resolve component in scope '{scope}': No active scope ID found. "
140
+ f"Are you trying to use a {scope}-scoped component outside of its context?"
141
+ )
142
+
143
+ bucket = self._by_scope.setdefault(scope, {})
137
144
  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)
145
+ return bucket[sid]
146
+
144
147
  c = ComponentContainer()
145
148
  bucket[sid] = c
146
149
  return c
@@ -159,8 +162,11 @@ class ScopedCaches:
159
162
  bucket = self._by_scope.get(scope)
160
163
  if not bucket:
161
164
  return
162
- k = max(0, int(keep))
163
- while len(bucket) > k:
164
- _, old = bucket.popitem(last=False)
165
- self._cleanup_container(old)
166
-
165
+
166
+ # Manual cleanup if needed, though we rely on explicit cleanup now
167
+ if len(bucket) > keep:
168
+ # Simple eviction strategy if forced manually
169
+ keys_to_remove = list(bucket.keys())[:len(bucket)-keep]
170
+ for k in keys_to_remove:
171
+ container = bucket.pop(k)
172
+ 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.2.0
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
@@ -28,7 +28,7 @@ License: MIT License
28
28
  Project-URL: Homepage, https://github.com/dperezcabrera/pico-ioc
29
29
  Project-URL: Repository, https://github.com/dperezcabrera/pico-ioc
30
30
  Project-URL: Issue Tracker, https://github.com/dperezcabrera/pico-ioc/issues
31
- Keywords: ioc,di,dependency injection,inversion of control,decorator
31
+ Keywords: python,ioc,dependency-injection,di-container,inversion-of-control,ioc-container,zero-dependency,minimalistic,async,asyncio,modular,pluggable,ioc-framework,ioc-containers,inversion-of-control-container
32
32
  Classifier: Development Status :: 4 - Beta
33
33
  Classifier: Programming Language :: Python :: 3
34
34
  Classifier: Programming Language :: Python :: 3 :: Only
@@ -62,7 +62,6 @@ Dynamic: license-file
62
62
  [![Docs](https://img.shields.io/badge/Docs-pico--ioc-blue?style=flat&logo=readthedocs&logoColor=white)](https://dperezcabrera.github.io/pico-ioc/)
63
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
64
 
65
-
66
65
  **Pico-IoC** is a **lightweight, async-ready, decorator-driven IoC container** built for clarity, testability, and performance.
67
66
  It brings Inversion of Control and dependency injection to Python in a deterministic, modern, and framework-agnostic way.
68
67
 
@@ -97,14 +96,14 @@ Pico-IoC eliminates that friction by letting you declare how components relate
97
96
 
98
97
  ---
99
98
 
100
- ## 🧩 Highlights (v2.0+)
99
+ ## 🧩 Highlights (v2.2+)
101
100
 
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.
101
+ - **Unified Configuration**: Use `@configured` to bind both flat (ENV-like) and tree (YAML/JSON) sources via the `configuration(...)` builder (ADR-0010).
102
+ - **Extensible Scanning**: Use `CustomScanner` to hook into the discovery phase and register functions or custom decorators (ADR-0011).
103
+ - **Async-aware AOP**: Method interceptors via `@intercepted_by`.
104
+ - **Scoped resolution**: singleton, prototype, request, session, transaction, and custom scopes.
105
+ - **Tree-based configuration**: Advanced mapping with reusable adapters (`Annotated[Union[...], Discriminator(...)]`).
106
+ - **Observable context**: Built-in stats, health checks (`@health`), observer hooks (`ContainerObserver`), and dependency graph export.
108
107
 
109
108
  ---
110
109
 
@@ -116,11 +115,21 @@ pip install pico-ioc
116
115
 
117
116
  Optional extras:
118
117
 
119
- - YAML configuration support (requires PyYAML)
118
+ - YAML configuration support (requires PyYAML)
120
119
 
121
- ```bash
122
- pip install pico-ioc[yaml]
123
- ```
120
+ ```bash
121
+ pip install pico-ioc[yaml]
122
+ ```
123
+
124
+ -----
125
+
126
+ ### ⚠️ Important Note
127
+
128
+ **Breaking Behavior in Scope Management (v2.1.3+):**
129
+ **Scope LRU Eviction has been removed** to guarantee data integrity.
130
+
131
+ * **Frameworks (pico-fastapi):** Handled automatically.
132
+ * **Manual usage:** You **must** explicitly call `container._caches.cleanup_scope("scope_name", scope_id)` when a context ends to prevent memory leaks.
124
133
 
125
134
  -----
126
135
 
@@ -238,12 +247,16 @@ async def main():
238
247
  container = init(modules=[__name__])
239
248
  repo = await container.aget(AsyncRepo) # Async resolution
240
249
  print(await repo.fetch())
250
+
251
+ # Graceful async shutdown (calls @cleanup async methods)
252
+ await container.ashutdown()
241
253
 
242
254
  asyncio.run(main())
243
255
  ```
244
256
 
245
- - `__ainit__` runs after construction if defined.
246
- - Use `container.aget(Type)` to resolve components that require async initialization or whose providers are async.
257
+ - `__ainit__` runs after construction if defined.
258
+ - Use `container.aget(Type)` to resolve components that require async initialization.
259
+ - Use `await container.ashutdown()` to close resources cleanly.
247
260
 
248
261
  -----
249
262
 
@@ -283,35 +296,26 @@ result = c.get(Demo).work()
283
296
  print(f"Result: {result}")
284
297
  ```
285
298
 
286
- Output:
287
-
288
- ```
289
- → calling Demo.work
290
- Working...
291
- ← Demo.work done (10.xxms)
292
- Result: ok
293
- ```
294
-
295
299
  -----
296
300
 
297
301
  ## 👁️ Observability & Cleanup
298
302
 
299
- - Export a dependency graph in DOT format:
303
+ - Export a dependency graph in DOT format:
300
304
 
301
- ```python
302
- c = init(modules=[...])
303
- dot = c.export_graph() # Returns DOT graph as a string
304
- with open("dependencies.dot", "w") as f:
305
- f.write(dot)
306
- ```
305
+ ```python
306
+ c = init(modules=[...])
307
+ c.export_graph("dependencies.dot") # Writes directly to file
308
+ ```
307
309
 
308
- - Health checks:
309
- - Annotate health probes inside components with `@health` for container-level reporting.
310
- - The container exposes health information that can be queried in observability tooling.
310
+ - Health checks:
311
311
 
312
- - Container cleanup:
313
- - For sync components: `container.close()`
314
- - For async components/resources: `await container.aclose()`
312
+ - Annotate health probes inside components with `@health` for container-level reporting.
313
+ - The container exposes health information that can be queried in observability tooling.
314
+
315
+ - Container cleanup:
316
+
317
+ - For sync apps: `container.shutdown()`
318
+ - For async apps: `await container.ashutdown()`
315
319
 
316
320
  Use cleanup in application shutdown hooks to release resources deterministically.
317
321
 
@@ -321,14 +325,14 @@ Use cleanup in application shutdown hooks to release resources deterministically
321
325
 
322
326
  The full documentation is available within the `docs/` directory of the project repository. Start with `docs/README.md` for navigation.
323
327
 
324
- - Getting Started: `docs/getting-started.md`
325
- - User Guide: `docs/user-guide/README.md`
326
- - Advanced Features: `docs/advanced-features/README.md`
327
- - Observability: `docs/observability/README.md`
328
- - Cookbook (Patterns): `docs/cookbook/README.md`
329
- - Architecture: `docs/architecture/README.md`
330
- - API Reference: `docs/api-reference/README.md`
331
- - ADR Index: `docs/adr/README.md`
328
+ - Getting Started: `docs/getting-started.md`
329
+ - User Guide: `docs/user-guide/README.md`
330
+ - Advanced Features: `docs/advanced-features/README.md`
331
+ - Observability: `docs/observability/README.md`
332
+ - Cookbook (Patterns): `docs/cookbook/README.md`
333
+ - Architecture: `docs/architecture/README.md`
334
+ - API Reference: `docs/api-reference/README.md`
335
+ - ADR Index: `docs/adr/README.md`
332
336
 
333
337
  -----
334
338
 
@@ -343,10 +347,11 @@ tox
343
347
 
344
348
  ## 🧾 Changelog
345
349
 
346
- See [CHANGELOG.md](./CHANGELOG.md) — Significant redesigns and features in v2.0+.
350
+ See [CHANGELOG.md](https://www.google.com/search?q=./CHANGELOG.md) — Significant redesigns and features in v2.0+.
347
351
 
348
352
  -----
349
353
 
350
354
  ## 📜 License
351
355
 
352
356
  MIT — [LICENSE](https://opensource.org/licenses/MIT)
357
+
@@ -0,0 +1,25 @@
1
+ pico_ioc/__init__.py,sha256=MDneoBBB0JHD_o0_xzkOUeW4e3XjDJSaGMNTb5miBqw,2453
2
+ pico_ioc/_version.py,sha256=Vyf6P6UCZKFeQtRzYujPmFfdlqSfnc01VEMWE3O0ZrA,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=KlZjI_4pJfD_1T32DgmOykii6L1V-5Xtcynd5d57YIA,6400
6
+ pico_ioc/component_scanner.py,sha256=rRZSQbJ7SkssJ5SJBr_m2ih-lpFvVnDQpt-JrYH5Xsg,8999
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=ipTpMwtCYuYfmJCjd7dfEtFFVR1PG0C1lZQp-wJ_UHw,21512
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=0abgnJMJrEvEyqsvxBNTi6wl0iJHHjSaw75IuqGk56c,8531
20
+ pico_ioc/scope.py,sha256=TFchqFE9ooDCtYV_9YaLdeJDMtmLdNbB63nbZp-AqI8,6349
21
+ pico_ioc-2.2.0.dist-info/licenses/LICENSE,sha256=N1_nOvHTM6BobYnOTNXiQkroDqCEi6EzfGBv8lWtyZ0,1077
22
+ pico_ioc-2.2.0.dist-info/METADATA,sha256=HnQ9xsjsyPFA7zCIkzkkHLSngVut4bZQUjHHBsk9PXI,12901
23
+ pico_ioc-2.2.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
24
+ pico_ioc-2.2.0.dist-info/top_level.txt,sha256=_7_RLu616z_dtRw16impXn4Mw8IXe2J4BeX5912m5dQ,9
25
+ pico_ioc-2.2.0.dist-info/RECORD,,
@@ -1,25 +0,0 @@
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
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=Ys1yLjiB3Qxxm_fvWCEYLSeaJ18LseWmXueAW8kHunk,20874
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=JD6psgdGGsBoCwov-G76BrmTfKUoJ22sdwa6wVdmQV8,5064
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,,