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/__init__.py CHANGED
@@ -1,16 +1,20 @@
1
1
  # pico_ioc/__init__.py
2
-
3
2
  try:
4
3
  from ._version import __version__
5
4
  except Exception:
6
5
  __version__ = "0.0.0"
7
6
 
8
7
  from .container import PicoContainer, Binder
9
- from .decorators import component, factory_component, provides, plugin, Qualifier, qualifier
8
+ from .decorators import (
9
+ component, factory_component, provides, plugin,
10
+ Qualifier, qualifier,
11
+ on_missing, primary, conditional, interceptor,
12
+ )
10
13
  from .plugins import PicoPlugin
11
14
  from .resolver import Resolver
12
- from .api import init, reset, scope
13
- from .proxy import ComponentProxy
15
+ from .api import init, reset, scope, container_fingerprint
16
+ from .proxy import ComponentProxy, IoCProxy
17
+ from .interceptors import Invocation, MethodInterceptor, ContainerInterceptor
14
18
 
15
19
  __all__ = [
16
20
  "__version__",
@@ -18,15 +22,24 @@ __all__ = [
18
22
  "Binder",
19
23
  "PicoPlugin",
20
24
  "ComponentProxy",
25
+ "IoCProxy",
26
+ "Invocation",
27
+ "MethodInterceptor",
28
+ "ContainerInterceptor",
21
29
  "init",
22
30
  "scope",
23
31
  "reset",
32
+ "container_fingerprint",
24
33
  "component",
25
34
  "factory_component",
26
35
  "provides",
27
36
  "plugin",
28
37
  "Qualifier",
29
38
  "qualifier",
39
+ "on_missing",
40
+ "primary",
41
+ "conditional",
42
+ "interceptor",
30
43
  "Resolver",
31
44
  ]
32
45
 
pico_ioc/_state.py CHANGED
@@ -1,10 +1,40 @@
1
1
  # pico_ioc/_state.py
2
2
  from contextvars import ContextVar
3
3
  from typing import Optional
4
+ from contextlib import contextmanager
4
5
 
5
6
  _scanning: ContextVar[bool] = ContextVar("pico_scanning", default=False)
6
7
  _resolving: ContextVar[bool] = ContextVar("pico_resolving", default=False)
7
8
 
8
9
  _container = None
9
10
  _root_name: Optional[str] = None
11
+ _fingerprint: Optional[tuple] = None
12
+ _fp_observed: bool = False
10
13
 
14
+ @contextmanager
15
+ def scanning_flag():
16
+ """Context manager: mark scanning=True within the block."""
17
+ tok = _scanning.set(True)
18
+ try:
19
+ yield
20
+ finally:
21
+ _scanning.reset(tok)
22
+
23
+ # ---- fingerprint helpers (public via api) ----
24
+ def set_fingerprint(fp: Optional[tuple]) -> None:
25
+ global _fingerprint
26
+ _fingerprint = fp
27
+
28
+ def get_fingerprint() -> Optional[tuple]:
29
+ return _fingerprint
30
+
31
+ def reset_fp_observed() -> None:
32
+ global _fp_observed
33
+ _fp_observed = False
34
+
35
+ def mark_fp_observed() -> None:
36
+ global _fp_observed
37
+ _fp_observed = True
38
+
39
+ def was_fp_observed() -> bool:
40
+ return _fp_observed
pico_ioc/_version.py CHANGED
@@ -1 +1 @@
1
- __version__ = '1.2.0'
1
+ __version__ = '1.3.0'
pico_ioc/api.py CHANGED
@@ -1,283 +1,231 @@
1
- # pico_ioc/api.py
2
-
1
+ # src/pico_ioc/api.py
3
2
  from __future__ import annotations
4
3
 
5
- import inspect
4
+ import inspect as _inspect
5
+ import importlib
6
6
  import logging
7
- from contextlib import contextmanager
8
- from typing import Callable, Optional, Tuple, Any, Dict, Iterable
7
+ import os
8
+ from types import ModuleType
9
+ from typing import Callable, Optional, Tuple, Any, Dict, Iterable, Sequence
9
10
 
10
- from .container import PicoContainer, Binder
11
+ from .container import PicoContainer
11
12
  from .plugins import PicoPlugin
12
- from .scanner import scan_and_configure
13
13
  from . import _state
14
+ from .builder import PicoContainerBuilder
14
15
 
15
-
16
+ # The only helpers left are those directly related to the public API signature or fingerprinting
16
17
  def reset() -> None:
17
- """Reset the global container."""
18
18
  _state._container = None
19
19
  _state._root_name = None
20
+ _state.set_fingerprint(None)
20
21
 
22
+ def _combine_excludes(a: Optional[Callable[[str], bool]], b: Optional[Callable[[str], bool]]):
23
+ if not a and not b: return None
24
+ if a and not b: return a
25
+ if b and not a: return b
26
+ return lambda mod, _a=a, _b=b: _a(mod) or _b(mod)
21
27
 
22
- def init(
23
- root_package,
24
- *,
25
- exclude: Optional[Callable[[str], bool]] = None,
26
- auto_exclude_caller: bool = True,
27
- plugins: Tuple[PicoPlugin, ...] = (),
28
- reuse: bool = True,
29
- overrides: Optional[Dict[Any, Any]] = None,
30
- ) -> PicoContainer:
31
-
32
- root_name = root_package if isinstance(root_package, str) else getattr(root_package, "__name__", None)
28
+ # -------- fingerprint helpers --------
29
+ def _callable_id(cb) -> tuple:
30
+ try:
31
+ mod = getattr(cb, "__module__", None)
32
+ qn = getattr(cb, "__qualname__", None)
33
+ code = getattr(cb, "__code__", None)
34
+ fn_line = getattr(code, "co_firstlineno", None) if code else None
35
+ return (mod, qn, fn_line)
36
+ except Exception:
37
+ return (repr(cb),)
38
+
39
+ def _plugins_id(plugins: Tuple[PicoPlugin, ...]) -> tuple:
40
+ out = [(type(p).__module__, type(p).__qualname__) for p in plugins or ()]
41
+ return tuple(sorted(out))
42
+
43
+ def _normalize_for_fp(value):
44
+ if isinstance(value, ModuleType):
45
+ return getattr(value, "__name__", repr(value))
46
+ if isinstance(value, (tuple, list)):
47
+ return tuple(_normalize_for_fp(v) for v in value)
48
+ if isinstance(value, set):
49
+ return tuple(sorted(_normalize_for_fp(v) for v in value))
50
+ if callable(value):
51
+ return ("callable",) + _callable_id(value)
52
+ return value
53
+
54
+ _FP_EXCLUDE_KEYS = set()
55
+
56
+ def _normalize_overrides_for_fp(overrides: Optional[Dict[Any, Any]]) -> tuple:
57
+ if not overrides:
58
+ return ()
59
+ items = []
60
+ for k, v in overrides.items():
61
+ nk = _normalize_for_fp(k)
62
+ nv = _normalize_for_fp(v)
63
+ items.append((nk, nv))
64
+ return tuple(sorted(items))
65
+
66
+ def _make_fingerprint_from_signature(locals_in_init: dict) -> tuple:
67
+ sig = _inspect.signature(init)
68
+ entries = []
69
+ for name in sig.parameters.keys():
70
+ if name in _FP_EXCLUDE_KEYS: continue
71
+ if name == "root_package":
72
+ rp = locals_in_init.get("root_package")
73
+ root_name = rp if isinstance(rp, str) else getattr(rp, "__name__", None)
74
+ entries.append(("root", root_name))
75
+ continue
76
+ val = locals_in_init.get(name, None)
77
+ if name == "plugins":
78
+ val = _plugins_id(val or ())
79
+ elif name in ("profiles", "auto_scan"):
80
+ val = tuple(val or ())
81
+ elif name in ("exclude", "auto_scan_exclude"):
82
+ val = _callable_id(val) if val else None
83
+ elif name == "overrides":
84
+ val = _normalize_overrides_for_fp(val)
85
+ else:
86
+ val = _normalize_for_fp(val)
87
+ entries.append((name, val))
88
+ return tuple(sorted(entries))
33
89
 
34
- if reuse and _state._container and _state._root_name == root_name:
35
- if overrides:
36
- _apply_overrides(_state._container, overrides)
90
+ # -------- container reuse and caller exclusion helpers --------
91
+ def _maybe_reuse_existing(fp: tuple, overrides: Optional[Dict[Any, Any]]) -> Optional[PicoContainer]:
92
+ if _state.get_fingerprint() == fp:
37
93
  return _state._container
94
+ return None
38
95
 
39
- combined_exclude = _build_exclude(exclude, auto_exclude_caller, root_name=root_name)
96
+ def _build_exclude(
97
+ exclude: Optional[Callable[[str], bool]], auto_exclude_caller: bool, *, root_name: Optional[str] = None
98
+ ) -> Optional[Callable[[str], bool]]:
99
+ if not auto_exclude_caller: return exclude
100
+ caller = _get_caller_module_name()
101
+ if not caller: return exclude
102
+ def _under_root(mod: str) -> bool:
103
+ return bool(root_name) and (mod == root_name or mod.startswith(root_name + "."))
104
+ if exclude is None:
105
+ return lambda mod, _caller=caller: (mod == _caller) and not _under_root(mod)
106
+ return lambda mod, _caller=caller, _prev=exclude: (((mod == _caller) and not _under_root(mod)) or _prev(mod))
40
107
 
41
- container = PicoContainer()
42
- binder = Binder(container)
43
- logging.info("Initializing pico-ioc...")
108
+ def _get_caller_module_name() -> Optional[str]:
109
+ try:
110
+ f = _inspect.currentframe()
111
+ # Stack: _get_caller -> _build_exclude -> init -> caller
112
+ if f and f.f_back and f.f_back.f_back and f.f_back.f_back.f_back:
113
+ mod = _inspect.getmodule(f.f_back.f_back.f_back)
114
+ return getattr(mod, "__name__", None)
115
+ except Exception:
116
+ pass
117
+ return None
44
118
 
45
- with _scanning_flag():
46
- scan_and_configure(
47
- root_package,
48
- container,
49
- exclude=combined_exclude,
50
- plugins=plugins,
51
- )
119
+ # ---------------- public API ----------------
120
+ def init(
121
+ root_package, *, profiles: Optional[list[str]] = None, exclude: Optional[Callable[[str], bool]] = None,
122
+ auto_exclude_caller: bool = True, plugins: Tuple[PicoPlugin, ...] = (), reuse: bool = True,
123
+ overrides: Optional[Dict[Any, Any]] = None, auto_scan: Sequence[str] = (),
124
+ auto_scan_exclude: Optional[Callable[[str], bool]] = None, strict_autoscan: bool = False,
125
+ ) -> PicoContainer:
126
+ root_name = root_package if isinstance(root_package, str) else getattr(root_package, "__name__", None)
127
+ fp = _make_fingerprint_from_signature(locals())
52
128
 
53
- if overrides:
54
- _apply_overrides(container, overrides)
129
+ if reuse:
130
+ reused = _maybe_reuse_existing(fp, overrides)
131
+ if reused is not None:
132
+ return reused
55
133
 
56
- _run_hooks(plugins, "after_bind", container, binder)
57
- _run_hooks(plugins, "before_eager", container, binder)
134
+ builder = PicoContainerBuilder().with_plugins(plugins).with_profiles(profiles).with_overrides(overrides)
58
135
 
59
- container.eager_instantiate_all()
136
+ combined_exclude = _build_exclude(exclude, auto_exclude_caller, root_name=root_name)
137
+ builder.add_scan_package(root_package, exclude=combined_exclude)
60
138
 
61
- _run_hooks(plugins, "after_ready", container, binder)
139
+ if auto_scan:
140
+ for pkg in auto_scan:
141
+ try:
142
+ mod = importlib.import_module(pkg)
143
+ scan_exclude = _combine_excludes(exclude, auto_scan_exclude)
144
+ builder.add_scan_package(mod, exclude=scan_exclude)
145
+ except ImportError as e:
146
+ msg = f"pico-ioc: auto_scan package not found: {pkg}"
147
+ if strict_autoscan:
148
+ logging.error(msg)
149
+ raise e
150
+ logging.warning(msg)
151
+
152
+ container = builder.build()
62
153
 
63
- logging.info("Container configured and ready.")
64
154
  _state._container = container
65
155
  _state._root_name = root_name
156
+ _state.set_fingerprint(fp)
66
157
  return container
67
158
 
68
-
69
159
  def scope(
70
- *,
71
- modules: Iterable[Any] = (),
72
- roots: Iterable[type] = (),
73
- overrides: Optional[Dict[Any, Any]] = None,
74
- base: Optional[PicoContainer] = None,
75
- include: Optional[set[str]] = None, # tag include (any-match)
76
- exclude: Optional[set[str]] = None, # tag exclude (any-match)
77
- strict: bool = True,
78
- lazy: bool = True, # if True -> do NOT instantiate roots here
160
+ *, modules: Iterable[Any] = (), roots: Iterable[type] = (), profiles: Optional[list[str]] = None,
161
+ overrides: Optional[Dict[Any, Any]] = None, base: Optional[PicoContainer] = None,
162
+ include_tags: Optional[set[str]] = None, exclude_tags: Optional[set[str]] = None,
163
+ strict: bool = True, lazy: bool = True,
79
164
  ) -> PicoContainer:
80
- """
81
- Build a lightweight container: scan, apply overrides, filter by tags, prune
82
- to the dependency subgraph reachable from `roots`, and (optionally) instantiate roots.
83
- - No global eager.
84
- - If strict=False and base is provided, missing keys fall back to base.
85
- """
86
- c = _ScopedContainer(base=base, strict=strict)
87
-
88
- logging.info("Initializing pico-ioc scope...")
89
- with _scanning_flag():
90
- for m in modules:
91
- scan_and_configure(m, c, exclude=None, plugins=())
92
-
93
- if overrides:
94
- _apply_overrides(c, overrides)
95
-
96
- # Tag filter (apply BEFORE reachability pruning)
97
- def _tag_ok(meta: dict) -> bool:
98
- if include and not set(include).intersection(meta.get("tags", ())):
99
- return False
100
- if exclude and set(exclude).intersection(meta.get("tags", ())):
101
- return False
102
- return True
103
-
104
- c._providers = {k: v for k, v in c._providers.items() if _tag_ok(v)} # type: ignore[attr-defined]
105
-
106
- # Reachability from roots (subgraph) + keep overrides
107
- allowed = _compute_allowed_subgraph(c, roots)
108
- keep_keys: set[Any] = set(allowed) | (set(overrides.keys()) if overrides else set())
109
- c._providers = {k: v for k, v in c._providers.items() if k in keep_keys} # type: ignore[attr-defined]
110
-
111
- # Instantiate roots only when NOT lazy
165
+ builder = PicoContainerBuilder()
166
+
167
+ if base is not None and not strict:
168
+ base_providers = getattr(base, "_providers", {})
169
+ builder._providers.update(base_providers)
170
+ if profiles is None:
171
+ builder.with_profiles(list(getattr(base, "_active_profiles", ())))
172
+
173
+ builder.with_profiles(profiles)\
174
+ .with_overrides(overrides)\
175
+ .with_tag_filters(include=include_tags, exclude=exclude_tags)\
176
+ .with_roots(roots)
177
+
178
+ for m in modules:
179
+ builder.add_scan_package(m)
180
+
181
+ built_container = builder.build()
182
+
183
+ scoped_container = _ScopedContainer(base=base, strict=strict, built_container=built_container)
184
+
112
185
  if not lazy:
113
186
  from .proxy import ComponentProxy
114
187
  for rk in roots or ():
115
188
  try:
116
- obj = c.get(rk)
189
+ obj = scoped_container.get(rk)
117
190
  if isinstance(obj, ComponentProxy):
118
191
  _ = obj._get_real_object()
119
192
  except NameError:
120
- if strict:
121
- raise
122
- # non-strict: skip missing root
123
- continue
193
+ if strict: raise
124
194
 
125
195
  logging.info("Scope container ready.")
126
- return c
127
-
128
- # -------------------- helpers --------------------
129
-
130
- def _apply_overrides(container: PicoContainer, overrides: Dict[Any, Any]) -> None:
131
- for key, val in overrides.items():
132
- lazy = False
133
- if isinstance(val, tuple) and len(val) == 2 and callable(val[0]) and isinstance(val[1], bool):
134
- provider = val[0]
135
- lazy = val[1]
136
- elif callable(val):
137
- provider = val
138
- else:
139
- def provider(v=val):
140
- return v
141
- container.bind(key, provider, lazy=lazy)
142
-
196
+ return scoped_container
143
197
 
144
- def _build_exclude(
145
- exclude: Optional[Callable[[str], bool]],
146
- auto_exclude_caller: bool,
147
- *,
148
- root_name: Optional[str] = None,
149
- ) -> Optional[Callable[[str], bool]]:
150
- if not auto_exclude_caller:
151
- return exclude
152
-
153
- caller = _get_caller_module_name()
154
- if not caller:
155
- return exclude
156
-
157
- def _under_root(mod: str) -> bool:
158
- return bool(root_name) and (mod == root_name or mod.startswith(root_name + "."))
159
-
160
- if exclude is None:
161
- return lambda mod, _caller=caller: (mod == _caller) and not _under_root(mod)
162
-
163
- prev = exclude
164
- return lambda mod, _caller=caller, _prev=prev: (((mod == _caller) and not _under_root(mod)) or _prev(mod))
165
-
166
-
167
- def _get_caller_module_name() -> Optional[str]:
168
- try:
169
- f = inspect.currentframe()
170
- # frame -> _get_caller_module_name -> _build_exclude -> init
171
- if f and f.f_back and f.f_back.f_back and f.f_back.f_back.f_back:
172
- mod = inspect.getmodule(f.f_back.f_back.f_back)
173
- return getattr(mod, "__name__", None)
174
- except Exception:
175
- pass
176
- return None
177
-
178
-
179
- def _run_hooks(
180
- plugins: Tuple[PicoPlugin, ...],
181
- hook_name: str,
182
- container: PicoContainer,
183
- binder: Binder,
184
- ) -> None:
185
- for pl in plugins:
186
- try:
187
- fn = getattr(pl, hook_name, None)
188
- if fn:
189
- fn(container, binder)
190
- except Exception:
191
- logging.exception("Plugin %s failed", hook_name)
192
-
193
-
194
- @contextmanager
195
- def _scanning_flag():
196
- tok = _state._scanning.set(True)
197
- try:
198
- yield
199
- finally:
200
- _state._scanning.reset(tok)
198
+ class _ScopedContainer(PicoContainer):
199
+ def __init__(self, built_container: PicoContainer, base: Optional[PicoContainer], strict: bool):
200
+ super().__init__(providers=getattr(built_container, "_providers", {}).copy())
201
201
 
202
- def _compute_allowed_subgraph(container: PicoContainer, roots: Iterable[type]) -> set:
203
- """
204
- Traverse constructor annotations from roots to collect reachable provider keys.
205
- Includes implementations for collection injections (list[T]/tuple[T]).
206
- """
207
- from .resolver import _get_hints
208
- from .container import _is_compatible # structural / subclass check
209
- import inspect
210
- from typing import get_origin, get_args, Annotated
211
-
212
- allowed: set[Any] = set()
213
- stack = list(roots or ())
214
-
215
- # Helper: add all provider keys whose class is compatible with `base`
216
- def _add_impls_for_base(base_t):
217
- for prov_key, meta in container._providers.items(): # type: ignore[attr-defined]
218
- cls = prov_key if isinstance(prov_key, type) else None
219
- if cls is None:
220
- continue
221
- if _is_compatible(cls, base_t):
222
- if prov_key not in allowed:
223
- allowed.add(prov_key)
224
- stack.append(prov_key)
225
-
226
- while stack:
227
- k = stack.pop()
228
- if k in allowed:
229
- continue
230
- allowed.add(k)
231
-
232
- cls = k if isinstance(k, type) else None
233
- if cls is None or not container.has(k):
234
- # not a class or not currently bound → no edges to follow
235
- continue
236
-
237
- try:
238
- sig = inspect.signature(cls.__init__)
239
- except Exception:
240
- continue
241
-
242
- hints = _get_hints(cls.__init__, owner_cls=cls)
243
- for pname, param in sig.parameters.items():
244
- if pname == "self":
245
- continue
246
- ann = hints.get(pname, param.annotation)
247
-
248
- origin = get_origin(ann) or ann
249
- if origin in (list, tuple):
250
- inner = (get_args(ann) or (object,))[0]
251
- if get_origin(inner) is Annotated:
252
- inner = (get_args(inner) or (object,))[0]
253
- # We don’t know exact impls yet, so:
254
- if isinstance(inner, type):
255
- # keep the base “type” in allowed for clarity
256
- allowed.add(inner)
257
- # And include ALL implementations present in providers
258
- _add_impls_for_base(inner)
259
- continue
260
-
261
- if isinstance(ann, type):
262
- stack.append(ann)
263
- elif container.has(pname):
264
- stack.append(pname)
265
-
266
- return allowed
202
+ self._active_profiles = getattr(built_container, "_active_profiles", ())
203
+
204
+ base_method_its = getattr(base, "_method_interceptors", ()) if base else ()
205
+ base_container_its = getattr(base, "_container_interceptors", ()) if base else ()
206
+
207
+ self._method_interceptors = base_method_its
208
+ self._container_interceptors = base_container_its
209
+ self._seen_interceptor_types = {type(it) for it in (base_method_its + base_container_its)}
267
210
 
211
+ for it in getattr(built_container, "_method_interceptors", ()):
212
+ self.add_method_interceptor(it)
213
+ for it in getattr(built_container, "_container_interceptors", ()):
214
+ self.add_container_interceptor(it)
268
215
 
269
- class _ScopedContainer(PicoContainer):
270
- def __init__(self, base: Optional[PicoContainer], strict: bool):
271
- super().__init__()
272
216
  self._base = base
273
217
  self._strict = strict
274
218
 
275
- # allow `with pico_ioc.scope(...) as c:`
276
- def __enter__(self):
277
- return self
219
+ if base:
220
+ self._singletons.update(getattr(base, "_singletons", {}))
278
221
 
279
- # no resource suppression; placeholder for future cleanup/shutdown
280
- def __exit__(self, exc_type, exc, tb):
222
+ def __enter__(self): return self
223
+ def __exit__(self, exc_type, exc, tb): return False
224
+
225
+ def has(self, key: Any) -> bool:
226
+ if super().has(key): return True
227
+ if not self._strict and self._base is not None:
228
+ return self._base.has(key)
281
229
  return False
282
230
 
283
231
  def get(self, key: Any):
@@ -287,3 +235,6 @@ class _ScopedContainer(PicoContainer):
287
235
  if not self._strict and self._base is not None and self._base.has(key):
288
236
  return self._base.get(key)
289
237
  raise e
238
+
239
+ def container_fingerprint() -> Optional[tuple]:
240
+ return _state.get_fingerprint()