pico-ioc 1.4.0__py3-none-any.whl → 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pico_ioc/container.py CHANGED
@@ -1,205 +1,305 @@
1
1
  # src/pico_ioc/container.py
2
- from __future__ import annotations
3
-
4
2
  import inspect
5
- from typing import Any, Dict, get_origin, get_args, Annotated
6
- import typing as _t
7
-
8
- from .proxy import IoCProxy
9
- from .interceptors import MethodInterceptor, ContainerInterceptor
10
- from .decorators import QUALIFIERS_KEY
11
- from . import _state
12
-
13
-
14
- class Binder:
15
- def __init__(self, container: PicoContainer):
16
- self._c = container
17
-
18
- def bind(self, key: Any, provider, *, lazy: bool, tags: tuple[str, ...] = ()):
19
- self._c.bind(key, provider, lazy=lazy, tags=tags)
20
-
21
- def has(self, key: Any) -> bool:
22
- return self._c.has(key)
23
-
24
- def get(self, key: Any):
25
- return self._c.get(key)
26
-
3
+ import contextvars
4
+ from typing import Any, Dict, List, Optional, Tuple, overload, Union
5
+ from contextlib import contextmanager
6
+ from .constants import LOGGER, PICO_META
7
+ from .exceptions import CircularDependencyError, ComponentCreationError, ProviderNotFoundError
8
+ from .factory import ComponentFactory
9
+ from .locator import ComponentLocator
10
+ from .scope import ScopedCaches, ScopeManager
11
+ from .aop import UnifiedComponentProxy, ContainerObserver
12
+
13
+ KeyT = Union[str, type]
14
+ _resolve_chain: contextvars.ContextVar[Tuple[KeyT, ...]] = contextvars.ContextVar("pico_resolve_chain", default=())
27
15
 
28
16
  class PicoContainer:
29
- def __init__(self, providers: Dict[Any, Dict[str, Any]] | None = None):
30
- self._providers = providers or {}
31
- self._singletons: Dict[Any, Any] = {}
32
- self._method_interceptors: tuple[MethodInterceptor, ...] = ()
33
- self._container_interceptors: tuple[ContainerInterceptor, ...] = ()
34
- self._active_profiles: tuple[str, ...] = ()
35
- self._seen_interceptor_types: set[type] = set()
36
-
37
- # --- interceptors ---
38
-
39
- def add_method_interceptor(self, it: MethodInterceptor) -> None:
40
- t = type(it)
41
- if t in self._seen_interceptor_types:
42
- return
43
- self._seen_interceptor_types.add(t)
44
- self._method_interceptors = self._method_interceptors + (it,)
45
-
46
- def add_container_interceptor(self, it: ContainerInterceptor) -> None:
47
- t = type(it)
48
- if t in self._seen_interceptor_types:
49
- return
50
- self._seen_interceptor_types.add(t)
51
- self._container_interceptors = self._container_interceptors + (it,)
52
-
53
- # --- binding ---
54
-
55
- def binder(self) -> Binder:
56
- return Binder(self)
57
-
58
- def bind(self, key: Any, provider, *, lazy: bool, tags: tuple[str, ...] = ()):
59
- self._singletons.pop(key, None)
60
- meta = {"factory": provider, "lazy": bool(lazy)}
17
+ _container_id_var: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar("pico_container_id", default=None)
18
+ _container_registry: Dict[str, "PicoContainer"] = {}
19
+
20
+ class _Ctx:
21
+ def __init__(self, container_id: str, profiles: Tuple[str, ...], created_at: float) -> None:
22
+ self.container_id = container_id
23
+ self.profiles = profiles
24
+ self.created_at = created_at
25
+ self.resolve_count = 0
26
+ self.cache_hit_count = 0
27
+
28
+ def __init__(self, component_factory: ComponentFactory, caches: ScopedCaches, scopes: ScopeManager, observers: Optional[List["ContainerObserver"]] = None, container_id: Optional[str] = None, profiles: Tuple[str, ...] = ()) -> None:
29
+ self._factory = component_factory
30
+ self._caches = caches
31
+ self.scopes = scopes
32
+ self._locator: Optional[ComponentLocator] = None
33
+ self._observers = list(observers or [])
34
+ self.container_id = container_id or self._generate_container_id()
35
+ import time as _t
36
+ self.context = PicoContainer._Ctx(container_id=self.container_id, profiles=profiles, created_at=_t.time())
37
+ PicoContainer._container_registry[self.container_id] = self
38
+
39
+ @staticmethod
40
+ def _generate_container_id() -> str:
41
+ import time as _t, random as _r
42
+ return f"c{_t.time_ns():x}{_r.randrange(1<<16):04x}"
43
+
44
+ @classmethod
45
+ def get_current(cls) -> Optional["PicoContainer"]:
46
+ cid = cls._container_id_var.get()
47
+ return cls._container_registry.get(cid) if cid else None
48
+
49
+ @classmethod
50
+ def get_current_id(cls) -> Optional[str]:
51
+ return cls._container_id_var.get()
52
+
53
+ @classmethod
54
+ def all_containers(cls) -> Dict[str, "PicoContainer"]:
55
+ return dict(cls._container_registry)
56
+
57
+ def activate(self) -> contextvars.Token:
58
+ return PicoContainer._container_id_var.set(self.container_id)
59
+
60
+ def deactivate(self, token: contextvars.Token) -> None:
61
+ PicoContainer._container_id_var.reset(token)
62
+
63
+ @contextmanager
64
+ def as_current(self):
65
+ token = self.activate()
61
66
  try:
62
- q = getattr(key, QUALIFIERS_KEY, ())
63
- except Exception:
64
- q = ()
65
- meta["qualifiers"] = tuple(q) if q else ()
66
- meta["tags"] = tuple(tags) if tags else ()
67
- self._providers[key] = meta
68
-
69
- # --- resolution ---
70
-
71
- def has(self, key: Any) -> bool:
72
- return key in self._providers
73
-
74
- def get(self, key: Any):
75
- if _state._scanning.get() and not _state._resolving.get():
76
- raise RuntimeError("re-entrant container access during scan")
77
-
78
- prov = self._providers.get(key)
79
- if prov is None:
80
- raise NameError(f"No provider found for key {key!r}")
81
-
82
- if key in self._singletons:
83
- return self._singletons[key]
84
-
85
- for ci in self._container_interceptors:
67
+ yield self
68
+ finally:
69
+ self.deactivate(token)
70
+
71
+ def attach_locator(self, locator: ComponentLocator) -> None:
72
+ self._locator = locator
73
+
74
+ def _cache_for(self, key: KeyT):
75
+ md = self._locator._metadata.get(key) if self._locator else None
76
+ sc = (md.scope if md else "singleton")
77
+ return self._caches.for_scope(self.scopes, sc)
78
+
79
+ def has(self, key: KeyT) -> bool:
80
+ cache = self._cache_for(key)
81
+ return cache.get(key) is not None or self._factory.has(key)
82
+
83
+ @overload
84
+ def get(self, key: type) -> Any: ...
85
+ @overload
86
+ def get(self, key: str) -> Any: ...
87
+ def get(self, key: KeyT) -> Any:
88
+ cache = self._cache_for(key)
89
+ cached = cache.get(key)
90
+ if cached is not None:
91
+ self.context.cache_hit_count += 1
92
+ for o in self._observers: o.on_cache_hit(key)
93
+ return cached
94
+ import time as _tm
95
+ t0 = _tm.perf_counter()
96
+ chain = list(_resolve_chain.get())
97
+ for k in chain:
98
+ if k == key:
99
+ raise CircularDependencyError(chain, key)
100
+ token_chain = _resolve_chain.set(tuple(chain + [key]))
101
+ token_container = self.activate()
102
+ try:
103
+ if not self._factory.has(key):
104
+ alt = None
105
+ if isinstance(key, type):
106
+ alt = self._resolve_type_key(key)
107
+ elif isinstance(key, str) and self._locator:
108
+ for k, md in self._locator._metadata.items():
109
+ if md.pico_name == key:
110
+ alt = k
111
+ break
112
+ if alt is not None:
113
+ self._factory.bind(key, lambda a=alt: self.get(a))
114
+ provider = self._factory.get(key)
86
115
  try:
87
- ci.on_before_create(key)
88
- except Exception:
89
- pass
90
-
91
- tok = _state._resolving.set(True)
116
+ instance = provider()
117
+ except ProviderNotFoundError as e:
118
+ raise
119
+ except Exception as e:
120
+ raise ComponentCreationError(key, e)
121
+ instance = self._maybe_wrap_with_aspects(key, instance)
122
+ cache.put(key, instance)
123
+ self.context.resolve_count += 1
124
+ took_ms = (_tm.perf_counter() - t0) * 1000
125
+ for o in self._observers: o.on_resolve(key, took_ms)
126
+ return instance
127
+ finally:
128
+ _resolve_chain.reset(token_chain)
129
+ self.deactivate(token_container)
130
+
131
+ async def aget(self, key: KeyT) -> Any:
132
+ cache = self._cache_for(key)
133
+ cached = cache.get(key)
134
+ if cached is not None:
135
+ self.context.cache_hit_count += 1
136
+ for o in self._observers: o.on_cache_hit(key)
137
+ return cached
138
+ import time as _tm
139
+ t0 = _tm.perf_counter()
140
+ chain = list(_resolve_chain.get())
141
+ for k in chain:
142
+ if k == key:
143
+ raise CircularDependencyError(chain, key)
144
+ token_chain = _resolve_chain.set(tuple(chain + [key]))
145
+ token_container = self.activate()
92
146
  try:
147
+ if not self._factory.has(key):
148
+ alt = None
149
+ if isinstance(key, type):
150
+ alt = self._resolve_type_key(key)
151
+ elif isinstance(key, str) and self._locator:
152
+ for k, md in self._locator._metadata.items():
153
+ if md.pico_name == key:
154
+ alt = k
155
+ break
156
+ if alt is not None:
157
+ self._factory.bind(key, lambda a=alt: self.get(a))
158
+ provider = self._factory.get(key)
93
159
  try:
94
- instance = prov["factory"]()
95
- except BaseException as exc:
96
- for ci in self._container_interceptors:
97
- try:
98
- ci.on_exception(key, exc)
99
- except Exception:
100
- pass
160
+ instance = provider()
161
+ if inspect.isawaitable(instance):
162
+ instance = await instance
163
+ except ProviderNotFoundError as e:
101
164
  raise
165
+ except Exception as e:
166
+ raise ComponentCreationError(key, e)
167
+ instance = self._maybe_wrap_with_aspects(key, instance)
168
+ cache.put(key, instance)
169
+ self.context.resolve_count += 1
170
+ took_ms = (_tm.perf_counter() - t0) * 1000
171
+ for o in self._observers: o.on_resolve(key, took_ms)
172
+ return instance
102
173
  finally:
103
- _state._resolving.reset(tok)
104
-
105
- if self._method_interceptors and not isinstance(instance, IoCProxy):
106
- instance = IoCProxy(instance, self._method_interceptors)
107
-
108
- for ci in self._container_interceptors:
174
+ _resolve_chain.reset(token_chain)
175
+ self.deactivate(token_container)
176
+
177
+ def _resolve_type_key(self, key: type):
178
+ if not self._locator:
179
+ return None
180
+ cands: List[Tuple[bool, Any]] = []
181
+ for k, md in self._locator._metadata.items():
182
+ typ = md.provided_type or md.concrete_class
183
+ if not isinstance(typ, type):
184
+ continue
109
185
  try:
110
- maybe = ci.on_after_create(key, instance)
111
- if maybe is not None:
112
- instance = maybe
186
+ if typ is not key and issubclass(typ, key):
187
+ cands.append((md.primary, k))
113
188
  except Exception:
114
- pass
115
-
116
- self._singletons[key] = instance
117
- return instance
118
-
119
- # --- lifecycle ---
120
-
121
- def eager_instantiate_all(self):
122
- for key, prov in list(self._providers.items()):
123
- if not prov["lazy"]:
124
- self.get(key)
125
-
126
- # --- helpers for multiples ---
127
-
128
- def get_all(self, base_type: Any):
129
- return tuple(self._resolve_all_for_base(base_type, qualifiers=()))
130
-
131
- def get_all_qualified(self, base_type: Any, *qualifiers: str):
132
- return tuple(self._resolve_all_for_base(base_type, qualifiers=qualifiers))
133
-
134
- def _resolve_all_for_base(self, base_type: Any, qualifiers=()):
135
- matches = []
136
- for provider_key, meta in self._providers.items():
137
- cls = provider_key if isinstance(provider_key, type) else None
138
- if cls is None:
139
- continue
140
- if _requires_collection_of_base(cls, base_type):
141
189
  continue
142
- if _is_compatible(cls, base_type):
143
- prov_qs = meta.get("qualifiers", ())
144
- if all(q in prov_qs for q in qualifiers):
145
- inst = self.get(provider_key)
146
- matches.append(inst)
147
- return matches
148
-
149
- def get_providers(self) -> Dict[Any, Dict]:
150
- return self._providers.copy()
151
-
152
-
153
- # --- compatibility helpers ---
154
-
155
- def _is_protocol(t) -> bool:
156
- return getattr(t, "_is_protocol", False) is True
157
-
190
+ if not cands:
191
+ return None
192
+ prim = [k for is_p, k in cands if is_p]
193
+ return prim[0] if prim else cands[0][1]
194
+
195
+ def _maybe_wrap_with_aspects(self, key, instance: Any) -> Any:
196
+ if isinstance(instance, UnifiedComponentProxy):
197
+ return instance
198
+ cls = type(instance)
199
+ for _, fn in inspect.getmembers(cls, predicate=lambda m: inspect.isfunction(m) or inspect.ismethod(m) or inspect.iscoroutinefunction(m)):
200
+ if getattr(fn, "_pico_interceptors_", None):
201
+ return UnifiedComponentProxy(container=self, target=instance)
202
+ return instance
158
203
 
159
- def _is_compatible(cls, base) -> bool:
160
- try:
161
- if isinstance(base, type) and issubclass(cls, base):
162
- return True
163
- except TypeError:
164
- pass
204
+ def cleanup_all(self) -> None:
205
+ for _, obj in self._caches.all_items():
206
+ for _, m in inspect.getmembers(obj, predicate=inspect.ismethod):
207
+ meta = getattr(m, PICO_META, {})
208
+ if meta.get("cleanup", False):
209
+ from .api import _resolve_args
210
+ kwargs = _resolve_args(m, self)
211
+ m(**kwargs)
212
+ if self._locator:
213
+ seen = set()
214
+ for md in self._locator._metadata.values():
215
+ fc = md.factory_class
216
+ if fc and fc not in seen:
217
+ seen.add(fc)
218
+ inst = self.get(fc) if self._factory.has(fc) else fc()
219
+ for _, m in inspect.getmembers(inst, predicate=inspect.ismethod):
220
+ meta = getattr(m, PICO_META, {})
221
+ if meta.get("cleanup", False):
222
+ from .api import _resolve_args
223
+ kwargs = _resolve_args(m, self)
224
+ m(**kwargs)
225
+
226
+ def activate_scope(self, name: str, scope_id: Any):
227
+ return self.scopes.activate(name, scope_id)
228
+
229
+ def deactivate_scope(self, name: str, token: Optional[contextvars.Token]) -> None:
230
+ self.scopes.deactivate(name, token)
231
+
232
+ def info(self, msg: str) -> None:
233
+ LOGGER.info(f"[{self.container_id[:8]}] {msg}")
234
+
235
+ @contextmanager
236
+ def scope(self, name: str, scope_id: Any):
237
+ tok = self.activate_scope(name, scope_id)
238
+ try:
239
+ yield self
240
+ finally:
241
+ self.deactivate_scope(name, tok)
165
242
 
166
- if _is_protocol(base):
167
- names = set(getattr(base, "__annotations__", {}).keys())
168
- names.update(n for n in getattr(base, "__dict__", {}).keys() if not n.startswith("_"))
169
- for n in names:
170
- if n.startswith("__") and n.endswith("__"):
171
- continue
172
- if not hasattr(cls, n):
173
- return False
174
- return True
175
-
176
- return False
177
-
178
-
179
- def _requires_collection_of_base(cls, base) -> bool:
180
- try:
181
- sig = inspect.signature(cls.__init__)
182
- except Exception:
183
- return False
184
-
185
- try:
186
- from .resolver import _get_hints
187
- hints = _get_hints(cls.__init__, owner_cls=cls)
188
- except Exception:
189
- hints = {}
190
-
191
- for name, param in sig.parameters.items():
192
- if name == "self":
193
- continue
194
- ann = hints.get(name, param.annotation)
195
- origin = get_origin(ann) or ann
196
- if origin in (list, tuple, _t.List, _t.Tuple):
197
- inner = (get_args(ann) or (object,))[0]
198
- if get_origin(inner) is Annotated:
199
- args = get_args(inner)
200
- if args:
201
- inner = args[0]
202
- if inner is base:
203
- return True
204
- return False
243
+ def health_check(self) -> Dict[str, bool]:
244
+ out: Dict[str, bool] = {}
245
+ for k, obj in self._caches.all_items():
246
+ for name, m in inspect.getmembers(obj, predicate=callable):
247
+ if getattr(m, PICO_META, {}).get("health_check", False):
248
+ try:
249
+ out[f"{getattr(k,'__name__',k)}.{name}"] = bool(m())
250
+ except Exception:
251
+ out[f"{getattr(k,'__name__',k)}.{name}"] = False
252
+ return out
253
+
254
+ async def cleanup_all_async(self) -> None:
255
+ for _, obj in self._caches.all_items():
256
+ for _, m in inspect.getmembers(obj, predicate=inspect.ismethod):
257
+ meta = getattr(m, PICO_META, {})
258
+ if meta.get("cleanup", False):
259
+ from .api import _resolve_args
260
+ res = m(**_resolve_args(m, self))
261
+ import inspect as _i
262
+ if _i.isawaitable(res):
263
+ await res
264
+ if self._locator:
265
+ seen = set()
266
+ for md in self._locator._metadata.values():
267
+ fc = md.factory_class
268
+ if fc and fc not in seen:
269
+ seen.add(fc)
270
+ inst = self.get(fc) if self._factory.has(fc) else fc()
271
+ for _, m in inspect.getmembers(inst, predicate=inspect.ismethod):
272
+ meta = getattr(m, PICO_META, {})
273
+ if meta.get("cleanup", False):
274
+ from .api import _resolve_args
275
+ res = m(**_resolve_args(m, self))
276
+ import inspect as _i
277
+ if _i.isawaitable(res):
278
+ await res
279
+ try:
280
+ from .event_bus import EventBus
281
+ for _, obj in self._caches.all_items():
282
+ if isinstance(obj, EventBus):
283
+ await obj.aclose()
284
+ except Exception:
285
+ pass
286
+
287
+ def stats(self) -> Dict[str, Any]:
288
+ import time as _t
289
+ resolves = self.context.resolve_count
290
+ hits = self.context.cache_hit_count
291
+ total = resolves + hits
292
+ return {
293
+ "container_id": self.container_id,
294
+ "profiles": self.context.profiles,
295
+ "uptime_seconds": _t.time() - self.context.created_at,
296
+ "total_resolves": resolves,
297
+ "cache_hits": hits,
298
+ "cache_hit_rate": (hits / total) if total > 0 else 0.0,
299
+ "registered_components": len(self._locator._metadata) if self._locator else 0,
300
+ }
301
+
302
+ def shutdown(self) -> None:
303
+ self.cleanup_all()
304
+ PicoContainer._container_registry.pop(self.container_id, None)
205
305