pico-ioc 1.2.0__py3-none-any.whl → 1.3.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/builder.py ADDED
@@ -0,0 +1,242 @@
1
+ # src/pico_ioc/builder.py
2
+ from __future__ import annotations
3
+ import inspect as _inspect
4
+ import logging
5
+ import os
6
+ from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence, Tuple
7
+ from typing import get_origin, get_args, Annotated
8
+
9
+ # Add missing imports for interceptor types
10
+ from .interceptors import MethodInterceptor, ContainerInterceptor
11
+ from .container import PicoContainer, _is_compatible
12
+ from .policy import apply_policy, _conditional_active
13
+ from .plugins import PicoPlugin, run_plugin_hook
14
+ from .scanner import scan_and_configure
15
+ from .resolver import Resolver, _get_hints
16
+ from . import _state
17
+
18
+
19
+ class PicoContainerBuilder:
20
+ def __init__(self):
21
+ self._scan_plan: List[Tuple[Any, Optional[Callable[[str], bool]], Tuple[PicoPlugin, ...]]] = []
22
+ self._overrides: Dict[Any, Any] = {}
23
+ self._profiles: Optional[List[str]] = None
24
+ self._plugins: Tuple[PicoPlugin, ...] = ()
25
+ self._include_tags: Optional[set[str]] = None
26
+ self._exclude_tags: Optional[set[str]] = None
27
+ self._roots: Iterable[type] = ()
28
+ self._providers: Dict[Any, Dict] = {}
29
+ self._interceptor_decls: List[Tuple[Any, dict]] = []
30
+
31
+ def with_plugins(self, plugins: Tuple[PicoPlugin, ...]) -> PicoContainerBuilder:
32
+ self._plugins = plugins
33
+ return self
34
+
35
+ def with_profiles(self, profiles: Optional[List[str]]) -> PicoContainerBuilder:
36
+ self._profiles = profiles
37
+ return self
38
+
39
+ def add_scan_package(self, package: Any, exclude: Optional[Callable[[str], bool]] = None) -> PicoContainerBuilder:
40
+ self._scan_plan.append((package, exclude, self._plugins))
41
+ return self
42
+
43
+ def with_overrides(self, overrides: Optional[Dict[Any, Any]]) -> PicoContainerBuilder:
44
+ self._overrides = overrides or {}
45
+ return self
46
+
47
+ def with_tag_filters(self, include: Optional[set[str]], exclude: Optional[set[str]]) -> PicoContainerBuilder:
48
+ self._include_tags = include
49
+ self._exclude_tags = exclude
50
+ return self
51
+
52
+ def with_roots(self, roots: Iterable[type]) -> PicoContainerBuilder:
53
+ self._roots = roots
54
+ return self
55
+
56
+ def build(self) -> PicoContainer:
57
+ requested_profiles = _resolve_profiles(self._profiles)
58
+
59
+ # We now create a single container instance upfront and configure it.
60
+ container = PicoContainer(providers=self._providers)
61
+ container._active_profiles = tuple(requested_profiles)
62
+
63
+ for pkg, exclude, scan_plugins in self._scan_plan:
64
+ with _state.scanning_flag():
65
+ c, f, decls = scan_and_configure(pkg, container, exclude=exclude, plugins=scan_plugins)
66
+ logging.info("Scanned '%s' (components: %d, factories: %d)", getattr(pkg, "__name__", pkg), c, f)
67
+ self._interceptor_decls.extend(decls)
68
+
69
+ _activate_and_build_interceptors(
70
+ container=container,
71
+ interceptor_decls=self._interceptor_decls,
72
+ profiles=requested_profiles
73
+ )
74
+
75
+ binder = container.binder()
76
+
77
+ if self._overrides:
78
+ _apply_overrides(container, self._overrides)
79
+
80
+ run_plugin_hook(self._plugins, "after_bind", container, binder)
81
+ run_plugin_hook(self._plugins, "before_eager", container, binder)
82
+ apply_policy(container, profiles=requested_profiles)
83
+ _filter_by_tags(container, self._include_tags, self._exclude_tags)
84
+ if self._roots:
85
+ _restrict_to_subgraph(container, self._roots, self._overrides)
86
+
87
+ run_plugin_hook(self._plugins, "after_ready", container, binder)
88
+ container.eager_instantiate_all()
89
+ logging.info("Container configured and ready.")
90
+ return container
91
+
92
+ # ... (Helper functions like _resolve_profiles, _apply_overrides etc. remain here) ...
93
+ # --- Start of moved helpers ---
94
+ def _resolve_profiles(profiles: Optional[list[str]]) -> list[str]:
95
+ if profiles is not None:
96
+ return list(profiles)
97
+ env_val = os.getenv("PICO_PROFILE", "")
98
+ return [p.strip() for p in env_val.split(",") if p.strip()]
99
+
100
+ def _as_provider(val):
101
+ if isinstance(val, tuple) and len(val) == 2 and callable(val[0]) and isinstance(val[1], bool):
102
+ return val[0], val[1]
103
+ if callable(val):
104
+ return val, False
105
+ return (lambda v=val: v), False
106
+
107
+ def _apply_overrides(container: PicoContainer, overrides: Dict[Any, Any]) -> None:
108
+ for key, val in overrides.items():
109
+ provider, lazy = _as_provider(val)
110
+ container.bind(key, provider, lazy=lazy)
111
+
112
+ def _filter_by_tags(container: PicoContainer, include_tags: Optional[set[str]], exclude_tags: Optional[set[str]]) -> None:
113
+ if not include_tags and not exclude_tags:
114
+ return
115
+
116
+ def _tag_ok(meta: dict) -> bool:
117
+ tags = set(meta.get("tags", ()))
118
+ if include_tags and not tags.intersection(include_tags):
119
+ return False
120
+ if exclude_tags and tags.intersection(exclude_tags):
121
+ return False
122
+ return True
123
+ container._providers = {k: v for k, v in container._providers.items() if _tag_ok(v)}
124
+
125
+ def _compute_allowed_subgraph(container: PicoContainer, roots: Iterable[type]) -> set:
126
+ allowed: set[Any] = set(roots) # Start with roots
127
+ stack = list(roots or ())
128
+ # ... (rest of the function is the same, just ensure it's here)
129
+ def _add_impls_for_base(base_t):
130
+ for prov_key, meta in container._providers.items():
131
+ cls = prov_key if isinstance(prov_key, type) else None
132
+ if cls is not None and _is_compatible(cls, base_t):
133
+ if prov_key not in allowed:
134
+ allowed.add(prov_key)
135
+ stack.append(prov_key)
136
+
137
+ while stack:
138
+ k = stack.pop()
139
+ # if k in allowed: continue # Redundant, add() handles it
140
+ allowed.add(k)
141
+ if isinstance(k, type): _add_impls_for_base(k)
142
+ cls = k if isinstance(k, type) else None
143
+ if cls is None or not container.has(k): continue
144
+ try:
145
+ sig = _inspect.signature(cls.__init__)
146
+ hints = _get_hints(cls.__init__, owner_cls=cls)
147
+ except Exception:
148
+ continue
149
+ for pname, param in sig.parameters.items():
150
+ if pname == "self": continue
151
+ ann = hints.get(pname, param.annotation)
152
+ origin = get_origin(ann) or ann
153
+ if origin in (list, tuple):
154
+ inner = (get_args(ann) or (object,))[0]
155
+ if get_origin(inner) is Annotated: inner = (get_args(inner) or (object,))[0]
156
+ if isinstance(inner, type):
157
+ if inner not in allowed:
158
+ stack.append(inner)
159
+ continue
160
+ if isinstance(ann, type) and ann not in allowed:
161
+ stack.append(ann)
162
+ elif container.has(pname) and pname not in allowed:
163
+ stack.append(pname)
164
+ return allowed
165
+
166
+
167
+ def _restrict_to_subgraph(container: PicoContainer, roots: Iterable[type], overrides: Optional[Dict[Any, Any]]) -> None:
168
+ allowed = _compute_allowed_subgraph(container, roots)
169
+ keep_keys: set[Any] = allowed | (set(overrides.keys()) if overrides else set())
170
+ container._providers = {k: v for k, v in container._providers.items() if k in keep_keys}
171
+
172
+ def _activate_and_build_interceptors(
173
+ *, container: PicoContainer, interceptor_decls: list[tuple[Any, dict]], profiles: list[str],
174
+ ) -> None:
175
+ resolver = Resolver(container)
176
+ active: list[tuple[int, str, str, Any]] = []
177
+ activated_method_names: list[str] = []
178
+ activated_container_names: list[str] = []
179
+ skipped_debug: list[str] = []
180
+
181
+ def _interceptor_meta_active(meta: dict) -> bool:
182
+ profs = tuple(meta.get("profiles", ())) or ()
183
+ if profs and (not profiles or not any(p in profs for p in profiles)): return False
184
+ req_env = tuple(meta.get("require_env", ())) or ()
185
+ if req_env and not all(os.getenv(k) not in (None, "") for k in req_env): return False
186
+ pred = meta.get("predicate", None)
187
+ if callable(pred):
188
+ try:
189
+ if not bool(pred()): return False
190
+ except Exception:
191
+ logging.exception("Interceptor predicate failed; skipping")
192
+ return False
193
+ return True
194
+
195
+ def _looks_like_container_interceptor(inst: Any) -> bool:
196
+ return all(hasattr(inst, m) for m in ("on_resolve", "on_before_create", "on_after_create", "on_exception"))
197
+
198
+ for raw_obj, meta in interceptor_decls:
199
+ owner_cls, obj = (raw_obj[0], raw_obj[1]) if isinstance(raw_obj, tuple) and len(raw_obj) == 2 else (None, raw_obj)
200
+ qn = getattr(obj, "__qualname__", repr(obj))
201
+ if not _conditional_active(obj, profiles=profiles) or not _interceptor_meta_active(meta):
202
+ skipped_debug.append(f"skip:{qn}")
203
+ continue
204
+ try:
205
+ if isinstance(obj, type):
206
+ inst = resolver.create_instance(obj)
207
+ elif owner_cls is not None:
208
+ owner_inst = resolver.create_instance(owner_cls)
209
+ bound = obj.__get__(owner_inst, owner_cls)
210
+ kwargs = resolver.kwargs_for_callable(bound, owner_cls=owner_cls)
211
+ inst = bound(**kwargs)
212
+ else:
213
+ kwargs = resolver.kwargs_for_callable(obj, owner_cls=None)
214
+ inst = obj(**kwargs)
215
+ except Exception:
216
+ logging.exception("Failed to construct interceptor %r", obj)
217
+ continue
218
+ kind = meta.get("kind", "method")
219
+ if kind == "method" and not callable(inst):
220
+ logging.error("Interceptor %s is not valid for kind %s; skipping", qn, kind)
221
+ continue
222
+ if kind == "container" and not _looks_like_container_interceptor(inst):
223
+ logging.error("Container interceptor %s lacks required methods; skipping", qn)
224
+ continue
225
+ order = int(meta.get("order", 0))
226
+ active.append((order, qn, kind, inst))
227
+
228
+ active.sort(key=lambda t: (t[0], t[1]))
229
+
230
+ for _order, _qn, kind, inst in active:
231
+ if kind == "container":
232
+ container.add_container_interceptor(inst)
233
+ activated_container_names.append(_qn)
234
+ else:
235
+ container.add_method_interceptor(inst)
236
+ activated_method_names.append(_qn)
237
+
238
+ if activated_method_names or activated_container_names:
239
+ logging.info("Interceptors activated: method=%d, container=%d", len(activated_method_names), len(activated_container_names))
240
+ logging.debug("Activated method=%s; Activated container=%s", ", ".join(activated_method_names) or "-", ", ".join(activated_container_names) or "-")
241
+ if skipped_debug:
242
+ logging.debug("Skipped interceptors: %s", ", ".join(skipped_debug))
pico_ioc/container.py CHANGED
@@ -1,11 +1,12 @@
1
- # pico_ioc/container.py
1
+ # src/pico_ioc/container.py (Refactorizado)
2
2
  from __future__ import annotations
3
3
  import inspect
4
- from typing import Any, Dict, get_origin, get_args, Annotated
5
- import typing as _t
6
-
4
+ from typing import Any, Dict, get_origin, get_args, Annotated, Sequence, Optional, Callable, Union, Tuple
5
+ import typing as _t
6
+ from .proxy import IoCProxy
7
+ from .interceptors import MethodInterceptor, ContainerInterceptor
7
8
  from .decorators import QUALIFIERS_KEY
8
- from . import _state # re-entrancy guard
9
+ from . import _state
9
10
 
10
11
 
11
12
  class Binder:
@@ -23,14 +24,35 @@ class Binder:
23
24
 
24
25
 
25
26
  class PicoContainer:
26
- def __init__(self):
27
- self._providers: Dict[Any, Dict[str, Any]] = {}
27
+ def __init__(self, providers: Dict[Any, Dict[str, Any]]):
28
+ self._providers = providers
28
29
  self._singletons: Dict[Any, Any] = {}
30
+ self._method_interceptors: tuple[MethodInterceptor, ...] = ()
31
+ self._container_interceptors: tuple[ContainerInterceptor, ...] = ()
32
+ self._active_profiles: tuple[str, ...] = ()
33
+ self._seen_interceptor_types: set[type] = set()
34
+
35
+ def add_method_interceptor(self, it: MethodInterceptor) -> None:
36
+ t = type(it)
37
+ if t in self._seen_interceptor_types:
38
+ return
39
+ self._seen_interceptor_types.add(t)
40
+ self._method_interceptors = self._method_interceptors + (it,)
41
+
42
+ def add_container_interceptor(self, it: ContainerInterceptor) -> None:
43
+ t = type(it)
44
+ if t in self._seen_interceptor_types:
45
+ return
46
+ self._seen_interceptor_types.add(t)
47
+ self._container_interceptors = self._container_interceptors + (it,)
48
+
49
+ def binder(self) -> Binder:
50
+ """Returns a binder for this container."""
51
+ return Binder(self)
29
52
 
30
53
  def bind(self, key: Any, provider, *, lazy: bool, tags: tuple[str, ...] = ()):
31
54
  self._singletons.pop(key, None)
32
55
  meta = {"factory": provider, "lazy": bool(lazy)}
33
- # qualifiers already present:
34
56
  try:
35
57
  q = getattr(key, QUALIFIERS_KEY, ())
36
58
  except Exception:
@@ -43,25 +65,41 @@ class PicoContainer:
43
65
  return key in self._providers
44
66
 
45
67
  def get(self, key: Any):
46
- # block only when scanning and NOT currently resolving a dependency
47
68
  if _state._scanning.get() and not _state._resolving.get():
48
69
  raise RuntimeError("re-entrant container access during scan")
49
-
50
70
  prov = self._providers.get(key)
51
71
  if prov is None:
52
72
  raise NameError(f"No provider found for key {key!r}")
53
-
54
73
  if key in self._singletons:
55
74
  return self._singletons[key]
56
75
 
57
- # mark resolving around factory execution
76
+ for ci in self._container_interceptors:
77
+ try: ci.on_before_create(key)
78
+ except Exception: pass
79
+
58
80
  tok = _state._resolving.set(True)
59
81
  try:
60
- instance = prov["factory"]()
82
+ try:
83
+ instance = prov["factory"]()
84
+ except BaseException as exc:
85
+ for ci in self._container_interceptors:
86
+ try: ci.on_exception(key, exc)
87
+ except Exception: pass
88
+ raise
61
89
  finally:
62
90
  _state._resolving.reset(tok)
63
91
 
64
- # memoize always (both lazy and non-lazy after first get)
92
+ if self._method_interceptors and not isinstance(instance, IoCProxy):
93
+ instance = IoCProxy(instance, self._method_interceptors)
94
+
95
+ for ci in self._container_interceptors:
96
+ try:
97
+ maybe = ci.on_after_create(key, instance)
98
+ if maybe is not None:
99
+ instance = maybe
100
+ except Exception:
101
+ pass
102
+
65
103
  self._singletons[key] = instance
66
104
  return instance
67
105
 
@@ -82,13 +120,8 @@ class PicoContainer:
82
120
  cls = provider_key if isinstance(provider_key, type) else None
83
121
  if cls is None:
84
122
  continue
85
-
86
- # Avoid self-inclusion loops: if the class itself requires a collection
87
- # of `base_type` in its __init__, don't treat it as an implementation
88
- # of `base_type` when building that collection.
89
123
  if _requires_collection_of_base(cls, base_type):
90
124
  continue
91
-
92
125
  if _is_compatible(cls, base_type):
93
126
  prov_qs = meta.get("qualifiers", ())
94
127
  if all(q in prov_qs for q in qualifiers):
@@ -96,6 +129,9 @@ class PicoContainer:
96
129
  matches.append(inst)
97
130
  return matches
98
131
 
132
+ def get_providers(self) -> Dict[Any, Dict]:
133
+ return self._providers.copy()
134
+
99
135
 
100
136
  def _is_protocol(t) -> bool:
101
137
  return getattr(t, "_is_protocol", False) is True
@@ -109,7 +145,6 @@ def _is_compatible(cls, base) -> bool:
109
145
  pass
110
146
 
111
147
  if _is_protocol(base):
112
- # simple structural check: ensure methods/attrs declared on the Protocol exist on the class
113
148
  names = set(getattr(base, "__annotations__", {}).keys())
114
149
  names.update(n for n in getattr(base, "__dict__", {}).keys() if not n.startswith("_"))
115
150
  for n in names:
@@ -121,20 +156,15 @@ def _is_compatible(cls, base) -> bool:
121
156
 
122
157
  return False
123
158
 
159
+
124
160
  def _requires_collection_of_base(cls, base) -> bool:
125
- """
126
- Return True if `cls.__init__` has any parameter annotated as a collection
127
- (list/tuple, including Annotated variants) of `base`. This prevents treating
128
- `cls` as an implementation of `base` while building that collection,
129
- avoiding recursion.
130
- """
131
161
  try:
132
162
  sig = inspect.signature(cls.__init__)
133
163
  except Exception:
134
164
  return False
135
165
 
136
166
  try:
137
- from .resolver import _get_hints # type: ignore
167
+ from .resolver import _get_hints
138
168
  hints = _get_hints(cls.__init__, owner_cls=cls)
139
169
  except Exception:
140
170
  hints = {}
@@ -146,7 +176,6 @@ def _requires_collection_of_base(cls, base) -> bool:
146
176
  origin = get_origin(ann) or ann
147
177
  if origin in (list, tuple, _t.List, _t.Tuple):
148
178
  inner = (get_args(ann) or (object,))[0]
149
- # Unwrap Annotated[T, ...] si aparece
150
179
  if get_origin(inner) is Annotated:
151
180
  args = get_args(inner)
152
181
  if args:
@@ -155,4 +184,3 @@ def _requires_collection_of_base(cls, base) -> bool:
155
184
  return True
156
185
  return False
157
186
 
158
-
pico_ioc/decorators.py CHANGED
@@ -1,7 +1,7 @@
1
1
  # pico_ioc/decorators.py
2
2
  from __future__ import annotations
3
3
  import functools
4
- from typing import Any, Iterable
4
+ from typing import Any, Iterable, Optional, Callable, Tuple, Literal
5
5
 
6
6
  COMPONENT_FLAG = "_is_component"
7
7
  COMPONENT_KEY = "_component_key"
@@ -17,6 +17,13 @@ QUALIFIERS_KEY = "_pico_qualifiers"
17
17
  COMPONENT_TAGS = "_pico_tags"
18
18
  PROVIDES_TAGS = "_pico_tags"
19
19
 
20
+ ON_MISSING_META = "_pico_on_missing"
21
+ PRIMARY_FLAG = "_pico_primary"
22
+ CONDITIONAL_META = "_pico_conditional"
23
+
24
+ INTERCEPTOR_META = "__pico_interceptor__"
25
+
26
+
20
27
  def factory_component(cls):
21
28
  setattr(cls, FACTORY_FLAG, True)
22
29
  return cls
@@ -31,6 +38,7 @@ def component(cls=None, *, name: Any = None, lazy: bool = False, tags: Iterable[
31
38
  return c
32
39
  return dec(cls) if cls else dec
33
40
 
41
+
34
42
  def provides(key: Any, *, lazy: bool = False, tags: Iterable[str] = ()):
35
43
  def dec(fn):
36
44
  @functools.wraps(fn)
@@ -49,7 +57,7 @@ def plugin(cls):
49
57
 
50
58
 
51
59
  class Qualifier(str):
52
- __slots__ = () # tiny memory win; immutable like str
60
+ __slots__ = ()
53
61
 
54
62
 
55
63
  def qualifier(*qs: Qualifier):
@@ -66,14 +74,61 @@ def qualifier(*qs: Qualifier):
66
74
  return dec
67
75
 
68
76
 
77
+ def on_missing(selector: object, *, priority: int = 0):
78
+ def dec(obj):
79
+ setattr(obj, ON_MISSING_META, {"selector": selector, "priority": int(priority)})
80
+ return obj
81
+ return dec
82
+
83
+
84
+ def primary(obj):
85
+ setattr(obj, PRIMARY_FLAG, True)
86
+ return obj
87
+
88
+
89
+ def conditional(
90
+ *,
91
+ profiles: tuple[str, ...] = (),
92
+ require_env: tuple[str, ...] = (),
93
+ predicate: Optional[Callable[[], bool]] = None,
94
+ ):
95
+ def dec(obj):
96
+ setattr(obj, CONDITIONAL_META, {
97
+ "profiles": tuple(profiles),
98
+ "require_env": tuple(require_env),
99
+ "predicate": predicate,
100
+ })
101
+ return obj
102
+ return dec
103
+
104
+
105
+ def interceptor(
106
+ _obj=None,
107
+ *,
108
+ kind: Literal["method", "container"] = "method",
109
+ order: int = 0,
110
+ profiles: Tuple[str, ...] = (),
111
+ require_env: Tuple[str, ...] = (),
112
+ predicate: Callable[[], bool] | None = None,
113
+ ):
114
+ def dec(obj):
115
+ setattr(obj, INTERCEPTOR_META, {
116
+ "kind": kind,
117
+ "order": int(order),
118
+ "profiles": tuple(profiles),
119
+ "require_env": tuple(require_env),
120
+ "predicate": predicate,
121
+ })
122
+ return obj
123
+ return dec if _obj is None else dec(_obj)
124
+
69
125
  __all__ = [
70
- # decorators
71
- "component", "factory_component", "provides", "plugin", "qualifier",
72
- # qualifier type
73
- "Qualifier",
74
- # metadata keys (exported for advanced use/testing)
126
+ "component", "factory_component", "provides", "plugin", "qualifier", "Qualifier",
75
127
  "COMPONENT_FLAG", "COMPONENT_KEY", "COMPONENT_LAZY",
76
128
  "FACTORY_FLAG", "PROVIDES_KEY", "PROVIDES_LAZY",
77
- "PLUGIN_FLAG", "QUALIFIERS_KEY", "COMPONENT_TAGS", "PROVIDES_TAGS"
129
+ "PLUGIN_FLAG", "QUALIFIERS_KEY", "COMPONENT_TAGS", "PROVIDES_TAGS",
130
+ "on_missing", "primary", "conditional",
131
+ "ON_MISSING_META", "PRIMARY_FLAG", "CONDITIONAL_META",
132
+ "interceptor", "INTERCEPTOR_META",
78
133
  ]
79
134
 
@@ -0,0 +1,50 @@
1
+ # pico_ioc/interceptors.py
2
+ from __future__ import annotations
3
+ from typing import Any, Callable, Protocol, Sequence
4
+ import inspect
5
+
6
+ class Invocation:
7
+ __slots__ = ("target", "method_name", "call", "args", "kwargs", "is_async")
8
+
9
+ def __init__(self, target: object, method_name: str, call: Callable[..., Any],
10
+ args: tuple, kwargs: dict):
11
+ self.target = target
12
+ self.method_name = method_name
13
+ self.call = call
14
+ self.args = args
15
+ self.kwargs = kwargs
16
+ self.is_async = inspect.iscoroutinefunction(call)
17
+
18
+ class MethodInterceptor(Protocol):
19
+ def __call__(self, inv: Invocation, proceed: Callable[[], Any]) -> Any: ...
20
+
21
+ async def _chain_async(interceptors: Sequence[MethodInterceptor], inv: Invocation, i: int = 0):
22
+ if i >= len(interceptors):
23
+ return await inv.call(*inv.args, **inv.kwargs)
24
+ cur = interceptors[i]
25
+ async def next_step():
26
+ return await _chain_async(interceptors, inv, i + 1)
27
+ res = cur(inv, next_step)
28
+ return await res if inspect.isawaitable(res) else res
29
+
30
+ def _chain_sync(interceptors: Sequence[MethodInterceptor], inv: Invocation, i: int = 0):
31
+ if i >= len(interceptors):
32
+ return inv.call(*inv.args, **inv.kwargs)
33
+ cur = interceptors[i]
34
+ return cur(inv, lambda: _chain_sync(interceptors, inv, i + 1))
35
+
36
+ def dispatch(interceptors: Sequence[MethodInterceptor], inv: Invocation):
37
+ if inv.is_async:
38
+ # return a coroutine that the caller will await
39
+ return _chain_async(interceptors, inv, 0)
40
+ # return the final value directly for sync methods
41
+ res = _chain_sync(interceptors, inv, 0)
42
+ return res
43
+
44
+
45
+ class ContainerInterceptor(Protocol):
46
+ def on_resolve(self, key: Any, annotation: Any, qualifiers: tuple[str, ...] | tuple()) -> None: ...
47
+ def on_before_create(self, key: Any) -> None: ...
48
+ def on_after_create(self, key: Any, instance: Any) -> Any: ...
49
+ def on_exception(self, key: Any, exc: BaseException) -> None: ...
50
+
pico_ioc/plugins.py CHANGED
@@ -1,6 +1,7 @@
1
1
  # pico_ioc/plugins.py
2
- from typing import Protocol, Any
2
+ from typing import Protocol, Any, Tuple
3
3
  from .container import Binder, PicoContainer
4
+ import logging
4
5
 
5
6
  class PicoPlugin(Protocol):
6
7
  def before_scan(self, package: Any, binder: Binder) -> None: ...
@@ -10,3 +11,18 @@ class PicoPlugin(Protocol):
10
11
  def before_eager(self, container: PicoContainer, binder: Binder) -> None: ...
11
12
  def after_ready(self, container: PicoContainer, binder: Binder) -> None: ...
12
13
 
14
+ def run_plugin_hook(
15
+ plugins: Tuple[PicoPlugin, ...],
16
+ hook_name: str,
17
+ *args,
18
+ **kwargs,
19
+ ) -> None:
20
+ """Run a lifecycle hook across all plugins, logging (but not raising) exceptions."""
21
+ for pl in plugins:
22
+ try:
23
+ fn = getattr(pl, hook_name, None)
24
+ if fn:
25
+ fn(*args, **kwargs)
26
+ except Exception:
27
+ logging.exception("Plugin %s failed", hook_name)
28
+