pico-ioc 1.1.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/policy.py ADDED
@@ -0,0 +1,332 @@
1
+ # pico_ioc/policy.py
2
+ from __future__ import annotations
3
+
4
+ import inspect
5
+ import os
6
+ from collections import defaultdict
7
+ from typing import Any, Dict, Iterable, List, Tuple, Optional
8
+ from .utils import create_alias_provider
9
+ from .decorators import (
10
+ CONDITIONAL_META,
11
+ PRIMARY_FLAG,
12
+ ON_MISSING_META,
13
+ )
14
+
15
+ # ---------------- helpers ----------------
16
+
17
+ def _target_from_provider(provider):
18
+ """
19
+ Best-effort: find the real target behind a provider closure.
20
+ Priority: bound method > plain function > class. Fallback to the provider itself.
21
+ """
22
+ fn = provider
23
+ try:
24
+ cells = getattr(fn, "__closure__", None) or ()
25
+ first_func = None
26
+ first_cls = None
27
+ for cell in cells:
28
+ cc = getattr(cell, "cell_contents", None)
29
+ if inspect.ismethod(cc):
30
+ return cc # highest priority: bound method
31
+ if first_func is None and inspect.isfunction(cc):
32
+ first_func = cc # keep first function seen
33
+ elif first_cls is None and inspect.isclass(cc):
34
+ first_cls = cc # keep first class seen
35
+ if first_func is not None:
36
+ return first_func
37
+ if first_cls is not None:
38
+ return first_cls
39
+ except Exception:
40
+ pass
41
+ return fn
42
+
43
+
44
+ def _owner_func(obj):
45
+ """
46
+ If obj is a bound method, return the unbound function owned by the class (if resolvable).
47
+ This lets us read decorator flags placed on the original function.
48
+ """
49
+ try:
50
+ if inspect.ismethod(obj) and getattr(obj, "__self__", None) is not None:
51
+ owner = obj.__self__.__class__
52
+ name = getattr(obj, "__name__", None)
53
+ if name and hasattr(owner, name):
54
+ cand = getattr(owner, name)
55
+ if inspect.isfunction(cand):
56
+ return cand
57
+ except Exception:
58
+ pass
59
+ return None
60
+
61
+
62
+ def _find_attribute_on_target(target: Any, attr_name: str) -> Any:
63
+ """
64
+ Finds an attribute on a target object, searching the object itself,
65
+ its __func__ (for bound methods), or the owning class's function.
66
+ """
67
+ # 1. Check the object itself
68
+ value = getattr(target, attr_name, None)
69
+ if value is not None:
70
+ return value
71
+
72
+ # 2. Check the underlying function for bound methods
73
+ base_func = getattr(target, "__func__", None)
74
+ if base_func is not None:
75
+ value = getattr(base_func, attr_name, None)
76
+ if value is not None:
77
+ return value
78
+
79
+ # 3. Check the function on the owner class
80
+ owner_func = _owner_func(target)
81
+ if owner_func is not None:
82
+ value = getattr(owner_func, attr_name, None)
83
+ if value is not None:
84
+ return value
85
+
86
+ return None
87
+
88
+
89
+ def _has_flag(obj, flag_name: str) -> bool:
90
+ """Reads a boolean decorator flag from the target."""
91
+ return bool(_find_attribute_on_target(obj, flag_name))
92
+
93
+
94
+ def _get_meta(obj, meta_name: str) -> Any:
95
+ """Reads metadata (e.g., a dict) from the target."""
96
+ return _find_attribute_on_target(obj, meta_name)
97
+
98
+
99
+ def _on_missing_meta(target):
100
+ """
101
+ Normalize @on_missing metadata.
102
+
103
+ IMPORTANT: The decorator stores {"selector": <T>, "priority": <int>}.
104
+ """
105
+ meta = _get_meta(target, ON_MISSING_META)
106
+ if not meta:
107
+ return None
108
+ selector = meta.get("selector")
109
+ prio = int(meta.get("priority", 0))
110
+ return (selector, prio)
111
+
112
+ def _conditional_active(target, *, profiles: List[str]) -> bool:
113
+ """
114
+ Returns True if the target is active given profiles/env/predicate.
115
+ Activation logic (conjunctive across what's provided):
116
+ - If `profiles` present on target → at least one must match requested profiles.
117
+ - If `require_env` present → all listed env vars must be non-empty.
118
+ - If `predicate` present → must return True; exceptions → inactive (fail-closed).
119
+ - If none provided → active by default.
120
+ """
121
+ meta = _get_meta(target, CONDITIONAL_META)
122
+ if not meta:
123
+ return True
124
+
125
+ profs = tuple(meta.get("profiles", ())) or ()
126
+ req_env = tuple(meta.get("require_env", ())) or ()
127
+ pred = meta.get("predicate", None)
128
+
129
+ # 1) profiles (if declared)
130
+ if profs:
131
+ if not profiles or not any(p in profs for p in profiles):
132
+ return False
133
+
134
+ # 2) require_env (if declared)
135
+ if req_env:
136
+ if not all(os.getenv(k) not in (None, "") for k in req_env):
137
+ return False
138
+
139
+ # 3) predicate (if declared)
140
+ if callable(pred):
141
+ try:
142
+ if not bool(pred()):
143
+ return False
144
+ except Exception:
145
+ return False
146
+
147
+ # default: active
148
+ return True
149
+
150
+ # ---------------- public API ----------------
151
+
152
+ def apply_policy(container, *, profiles: Optional[List[str]] = None) -> None:
153
+ profiles = list(profiles or [])
154
+
155
+ _filter_inactive_factory_candidates(container, profiles=profiles)
156
+ _collapse_identical_keys_preferring_primary(container)
157
+ _create_active_component_base_aliases(container, profiles=profiles)
158
+ apply_defaults(container)
159
+
160
+
161
+ def apply_defaults(container) -> None:
162
+ """
163
+ Bind default providers declared via @on_missing when no binding exists for the selector.
164
+
165
+ Supports:
166
+ - Class components decorated with @on_missing(Selector, priority=...)
167
+ - Factory @provides(...) where the provided method (or its owner) carries @on_missing
168
+ AND the provider got tagged with _pico_alias_for (the base type key).
169
+ """
170
+ defaults: dict[Any, list[tuple[int, Any]]] = {}
171
+
172
+ # (1) Class components with @on_missing
173
+ for prov_key, meta in list(container._providers.items()): # type: ignore[attr-defined]
174
+ if not isinstance(prov_key, type):
175
+ continue
176
+ target = _target_from_provider(meta.get("factory"))
177
+ om = _on_missing_meta(target)
178
+ if not om:
179
+ continue
180
+ selector, prio = om
181
+ defaults.setdefault(selector, []).append((prio, prov_key))
182
+
183
+ # (2) Factory @provides(...) with @on_missing on the provided method/owner
184
+ for prov_key, meta in list(container._providers.items()): # type: ignore[attr-defined]
185
+ prov = meta.get("factory")
186
+ base = getattr(prov, "_pico_alias_for", None)
187
+ if base is None:
188
+ continue
189
+ target = _target_from_provider(prov)
190
+ om = _on_missing_meta(target)
191
+ if not om:
192
+ continue
193
+ _selector_from_flag, prio = om
194
+ defaults.setdefault(base, []).append((prio, prov_key))
195
+
196
+ # Bind highest priority default for each selector if not already bound
197
+ for base, candidates in defaults.items():
198
+ if container.has(base):
199
+ continue
200
+ candidates.sort(key=lambda t: t[0], reverse=True)
201
+ chosen_key = candidates[0][1]
202
+
203
+ def _make_delegate(_chosen_key=chosen_key):
204
+ def _factory():
205
+ return container.get(_chosen_key)
206
+ return _factory
207
+
208
+ container.bind(base, _make_delegate(), lazy=True)
209
+
210
+ # ---------------- stages ----------------
211
+
212
+ def _filter_inactive_factory_candidates(container, *, profiles: List[str]) -> None:
213
+ """
214
+ Remove factory-provided candidates whose target is inactive under the given profiles/env/predicate.
215
+ This trims the candidate set early so later selection/aliasing runs on active options only.
216
+ """
217
+ to_delete = []
218
+ for prov_key, meta in list(container._providers.items()): # type: ignore[attr-defined]
219
+ prov = meta.get("factory")
220
+ base = getattr(prov, "_pico_alias_for", None)
221
+ if base is None:
222
+ continue
223
+ target = _target_from_provider(prov)
224
+ active = _conditional_active(target, profiles=profiles)
225
+ if not active:
226
+ to_delete.append(prov_key)
227
+ for k in to_delete:
228
+ container._providers.pop(k, None) # type: ignore[attr-defined]
229
+
230
+
231
+ def _collapse_identical_keys_preferring_primary(container) -> None:
232
+ """
233
+ For factory-provided products of the same base type, collapse to a single alias:
234
+ - If any candidate is marked @primary -> pick the first primary.
235
+ - Else leave multiple to be decided by defaults or later alias logic.
236
+ """
237
+ alias_groups: dict[Any, list[tuple[Any, dict]]] = defaultdict(list)
238
+ for k, m in list(container._providers.items()): # type: ignore[attr-defined]
239
+ prov = m.get("factory")
240
+ base_key = getattr(prov, "_pico_alias_for", None)
241
+ if base_key is not None:
242
+ alias_groups[base_key].append((k, m))
243
+
244
+ for base, entries in alias_groups.items():
245
+ if not entries:
246
+ continue
247
+
248
+ if len(entries) == 1:
249
+ keep_key, _ = entries[0]
250
+ if (not container.has(base)) or (base != keep_key):
251
+ factory = create_alias_provider(container, keep_key)
252
+ container.bind(base, factory, lazy=True)
253
+ continue
254
+
255
+ primaries: list[tuple[Any, dict]] = []
256
+ for (kk, mm) in entries:
257
+ tgt = _target_from_provider(mm.get("factory"))
258
+ if _has_flag(tgt, PRIMARY_FLAG):
259
+ primaries.append((kk, mm))
260
+
261
+ if primaries:
262
+ keep_key, _ = primaries[0]
263
+ if (not container.has(base)) or (base != keep_key):
264
+ factory = create_alias_provider(container, keep_key)
265
+ container.bind(base, factory, lazy=True)
266
+ for (kk, _mm) in entries:
267
+ if kk != keep_key and kk != base:
268
+ container._providers.pop(kk, None) # type: ignore[attr-defined]
269
+ else:
270
+ # multiple, no @primary -> leave for defaults
271
+ pass
272
+
273
+
274
+ def _create_active_component_base_aliases(container, *, profiles: List[str]) -> None:
275
+ """
276
+ For class components (not factory-bound), create base->impl aliases among ACTIVE implementations.
277
+
278
+ Preference order:
279
+ 1) Regular active implementations (non-@on_missing), prefer @primary; else first.
280
+ 2) If none, fall back to @on_missing implementations, prefer @primary; else first.
281
+ """
282
+ base_to_impls: Dict[Any, List[Tuple[Any, Dict[str, Any]]]] = defaultdict(list)
283
+
284
+ # Collect active implementations
285
+ impls: List[Tuple[type, Dict[str, Any]]] = []
286
+ for key, meta in list(container._providers.items()): # type: ignore[attr-defined]
287
+ if not isinstance(key, type):
288
+ continue
289
+ tgt = _target_from_provider(meta.get("factory"))
290
+ if _conditional_active(tgt, profiles=profiles):
291
+ impls.append((key, meta))
292
+
293
+ # Map each impl to all bases in its MRO (excluding itself and object)
294
+ for impl_key, impl_meta in impls:
295
+ for base in getattr(impl_key, "__mro__", ())[1:]:
296
+ if base is object:
297
+ break
298
+ base_to_impls.setdefault(base, []).append((impl_key, impl_meta))
299
+
300
+ # Choose per-base implementation with correct priority
301
+ for base, impl_list in base_to_impls.items():
302
+ if container.has(base) or not impl_list:
303
+ continue
304
+
305
+ regular: List[Tuple[Any, Dict[str, Any]]] = []
306
+ fallbacks: List[Tuple[Any, Dict[str, Any]]] = []
307
+
308
+ for (impl_key, impl_meta) in impl_list:
309
+ tgt = _target_from_provider(impl_meta.get("factory"))
310
+ if _on_missing_meta(tgt):
311
+ fallbacks.append((impl_key, impl_meta))
312
+ else:
313
+ regular.append((impl_key, impl_meta))
314
+
315
+ def pick(cands: List[Tuple[Any, Dict[str, Any]]]) -> Optional[Any]:
316
+ if not cands:
317
+ return None
318
+ primaries = []
319
+ for (ik, im) in cands:
320
+ tgt = _target_from_provider(im.get("factory"))
321
+ if _has_flag(tgt, PRIMARY_FLAG):
322
+ primaries.append((ik, im))
323
+ chosen_key, _ = primaries[0] if primaries else cands[0]
324
+ return chosen_key
325
+
326
+ chosen_key = pick(regular) or pick(fallbacks)
327
+ if chosen_key is None:
328
+ continue
329
+
330
+ factory = create_alias_provider(container, chosen_key)
331
+ container.bind(base, factory, lazy=True)
332
+
pico_ioc/proxy.py CHANGED
@@ -1,6 +1,11 @@
1
1
  # pico_ioc/proxy.py
2
2
 
3
- from typing import Any, Callable
3
+ from __future__ import annotations
4
+ from functools import lru_cache
5
+ from typing import Any, Callable, Sequence
6
+ import inspect
7
+
8
+ from .interceptors import Invocation, dispatch, MethodInterceptor
4
9
 
5
10
  class ComponentProxy:
6
11
  def __init__(self, object_creator: Callable[[], Any]):
@@ -74,4 +79,39 @@ class ComponentProxy:
74
79
  def __call__(self, *args, **kwargs): return self._get_real_object()(*args, **kwargs)
75
80
  def __enter__(self): return self._get_real_object().__enter__()
76
81
  def __exit__(self, exc_type, exc_val, exc_tb): return self._get_real_object().__exit__(exc_type, exc_val, exc_tb)
82
+
83
+ class IoCProxy:
84
+ __slots__ = ("_target", "_interceptors")
85
+
86
+ def __init__(self, target: object, interceptors: Sequence[MethodInterceptor]):
87
+ self._target = target
88
+ self._interceptors = tuple(interceptors)
89
+
90
+ def __getattr__(self, name: str) -> Any:
91
+ attr = getattr(self._target, name)
92
+ if not callable(attr):
93
+ return attr
94
+ if hasattr(attr, "__get__"):
95
+ fn = attr.__get__(self._target, type(self._target))
96
+ else:
97
+ fn = attr
98
+
99
+ @lru_cache(maxsize=None)
100
+ def _wrap(bound_fn: Callable[..., Any]):
101
+ if inspect.iscoroutinefunction(bound_fn):
102
+ async def aw(*args, **kwargs):
103
+ inv = Invocation(self._target, name, bound_fn, args, kwargs)
104
+ # dispatch returns a coroutine for async methods
105
+ return await dispatch(self._interceptors, inv)
106
+ return aw
107
+ else:
108
+ def sw(*args, **kwargs):
109
+ inv = Invocation(self._target, name, bound_fn, args, kwargs)
110
+ # dispatch returns a *value* for sync methods
111
+ res = dispatch(self._interceptors, inv)
112
+ if inspect.isawaitable(res):
113
+ raise RuntimeError(f"Async interceptor on sync method: {name}")
114
+ return res
115
+ return sw
116
+ return _wrap(fn)
77
117
 
pico_ioc/resolver.py CHANGED
@@ -2,9 +2,12 @@
2
2
 
3
3
  from __future__ import annotations
4
4
  import inspect
5
- from typing import Any, Annotated, get_args, get_origin, get_type_hints
5
+ from typing import Any, Annotated, get_args, get_origin, get_type_hints, Callable
6
+ from contextvars import ContextVar
6
7
 
7
8
 
9
+ _path: ContextVar[list[tuple[str, str]]] = ContextVar("pico_resolve_path", default=[])
10
+
8
11
  def _get_hints(obj, owner_cls=None) -> dict:
9
12
  """type hints with include_extras=True and correct globals/locals."""
10
13
  mod = inspect.getmodule(obj)
@@ -45,66 +48,82 @@ class Resolver:
45
48
  self.c = container
46
49
  self._prefer_name_first = bool(prefer_name_first)
47
50
 
48
- def create_instance(self, cls):
49
- sig = inspect.signature(cls.__init__)
50
- hints = _get_hints(cls.__init__, owner_cls=cls)
51
- kwargs = {}
52
- for name, param in sig.parameters.items():
53
- if name == "self":
54
- continue
55
- if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
56
- continue
57
- ann = hints.get(name, param.annotation)
58
- try:
59
- value = self._resolve_param(name, ann)
60
- except NameError:
61
- if param.default is not inspect._empty:
62
- continue
63
- raise
64
- kwargs[name] = value
65
- return cls(**kwargs)
66
51
 
67
- def kwargs_for_callable(self, fn, *, owner_cls=None):
52
+ def _resolve_dependencies_for_callable(self, fn: Callable, owner_cls: Any = None) -> dict:
68
53
  sig = inspect.signature(fn)
69
54
  hints = _get_hints(fn, owner_cls=owner_cls)
70
55
  kwargs = {}
56
+
57
+ path_owner = getattr(owner_cls, "__name__", getattr(fn, "__qualname__", "callable"))
58
+ if fn.__name__ == "__init__" and owner_cls:
59
+ path_owner = f"{path_owner}.__init__"
60
+
71
61
  for name, param in sig.parameters.items():
72
- if name == "self":
73
- continue
74
- if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
62
+ if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD) or name == "self":
75
63
  continue
64
+
76
65
  ann = hints.get(name, param.annotation)
66
+ st = _path.get()
67
+ _path.set(st + [(path_owner, name)])
77
68
  try:
78
69
  value = self._resolve_param(name, ann)
79
- except NameError:
80
- if param.default is not inspect._empty:
70
+ kwargs[name] = value
71
+ except NameError as e:
72
+ if param.default is not inspect.Parameter.empty:
73
+ _path.set(st)
81
74
  continue
82
- raise
83
- kwargs[name] = value
75
+
76
+ # If the error is already formatted with a chain, re-raise to preserve the full context.
77
+ if "(required by" in str(e):
78
+ raise
79
+
80
+ # Otherwise, this is a fresh error; add the full chain for the first time.
81
+ chain = " -> ".join(f"{owner}.{param}" for owner, param in _path.get())
82
+ raise NameError(f"{e} (required by {chain})") from e
83
+ finally:
84
+ cur = _path.get()
85
+ if cur:
86
+ _path.set(cur[:-1])
84
87
  return kwargs
85
88
 
89
+ def create_instance(self, cls: type) -> Any:
90
+ """Creates an instance of a class by resolving its __init__ dependencies."""
91
+ constructor_kwargs = self._resolve_dependencies_for_callable(cls.__init__, owner_cls=cls)
92
+ return cls(**constructor_kwargs)
93
+
94
+ def kwargs_for_callable(self, fn: Callable, *, owner_cls: Any = None) -> dict:
95
+ """Resolves all keyword arguments for any callable."""
96
+ return self._resolve_dependencies_for_callable(fn, owner_cls=owner_cls)
97
+
98
+
99
+ def _notify_resolve(self, key, ann, quals=()):
100
+ for ci in getattr(self.c, "_container_interceptors", ()):
101
+ try: ci.on_resolve(key, ann, tuple(quals) if quals else ())
102
+ except Exception: pass
103
+
86
104
  def _resolve_param(self, name: str, ann: Any):
87
- # collections (list/tuple) with optional qualifiers via Annotated
105
+ # Colecciones (list/tuple)
88
106
  if _is_collection_hint(ann):
89
107
  base, quals, container_kind = _base_and_qualifiers_from_hint(ann)
108
+ self._notify_resolve(base, ann, quals)
90
109
  items = self.c._resolve_all_for_base(base, qualifiers=quals)
91
110
  return list(items) if container_kind is list else tuple(items)
92
111
 
93
- # precedence: by name > by exact annotation > by MRO > by name again
112
+ # Precedencias
94
113
  if self._prefer_name_first and self.c.has(name):
114
+ self._notify_resolve(name, ann, ())
95
115
  return self.c.get(name)
96
-
97
116
  if ann is not inspect._empty and self.c.has(ann):
117
+ self._notify_resolve(ann, ann, ())
98
118
  return self.c.get(ann)
99
-
100
119
  if ann is not inspect._empty and isinstance(ann, type):
101
120
  for base in ann.__mro__[1:]:
102
121
  if self.c.has(base):
122
+ self._notify_resolve(base, ann, ())
103
123
  return self.c.get(base)
104
-
105
124
  if self.c.has(name):
125
+ self._notify_resolve(name, ann, ())
106
126
  return self.c.get(name)
107
127
 
108
128
  missing = ann if ann is not inspect._empty else name
109
129
  raise NameError(f"No provider found for key {missing!r}")
110
-