pico-ioc 1.2.0__py3-none-any.whl → 1.4.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,25 @@
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 .scope import ScopedContainer
9
+ from .decorators import (
10
+ component, factory_component, provides, plugin,
11
+ Qualifier, qualifier,
12
+ on_missing, primary, conditional, interceptor,
13
+ )
10
14
  from .plugins import PicoPlugin
11
15
  from .resolver import Resolver
12
- from .api import init, reset, scope
13
- from .proxy import ComponentProxy
16
+ from .api import init, reset, scope, container_fingerprint
17
+ from .proxy import ComponentProxy, IoCProxy
18
+ from .interceptors import Invocation, MethodInterceptor, ContainerInterceptor
19
+ from .config import (
20
+ config_component, EnvSource, FileSource,
21
+ Env, File, Path, Value,
22
+ )
14
23
 
15
24
  __all__ = [
16
25
  "__version__",
@@ -18,15 +27,32 @@ __all__ = [
18
27
  "Binder",
19
28
  "PicoPlugin",
20
29
  "ComponentProxy",
30
+ "IoCProxy",
31
+ "Invocation",
32
+ "MethodInterceptor",
33
+ "ContainerInterceptor",
21
34
  "init",
22
35
  "scope",
23
36
  "reset",
37
+ "container_fingerprint",
24
38
  "component",
25
39
  "factory_component",
26
40
  "provides",
27
41
  "plugin",
28
42
  "Qualifier",
29
43
  "qualifier",
44
+ "on_missing",
45
+ "primary",
46
+ "conditional",
47
+ "interceptor",
30
48
  "Resolver",
49
+ "ScopedContainer",
50
+ "config_component",
51
+ "EnvSource",
52
+ "FileSource",
53
+ "Env",
54
+ "File",
55
+ "Path",
56
+ "Value",
31
57
  ]
32
58
 
pico_ioc/_state.py CHANGED
@@ -1,10 +1,75 @@
1
- # pico_ioc/_state.py
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from threading import RLock
2
5
  from contextvars import ContextVar
3
- from typing import Optional
6
+ from contextlib import contextmanager
7
+ from typing import Optional, TYPE_CHECKING
8
+
9
+ # Type-only import to avoid cycles
10
+ if TYPE_CHECKING:
11
+ from .container import PicoContainer
12
+
13
+
14
+ # ---- Task/process context for the active container ----
15
+
16
+ @dataclass(frozen=True, slots=True)
17
+ class ContainerContext:
18
+ """Immutable snapshot for the active container state."""
19
+ container: "PicoContainer"
20
+ fingerprint: tuple
21
+ root_name: Optional[str]
22
+
23
+
24
+ # Process-wide fallback (for non-async code) guarded by a lock
25
+ _lock = RLock()
26
+ _current_context: Optional[ContainerContext] = None
27
+
28
+ # Task-local context (for async isolation)
29
+ _ctxvar: ContextVar[Optional[ContainerContext]] = ContextVar("pico_ioc_ctx", default=None)
30
+
31
+
32
+ def get_context() -> Optional[ContainerContext]:
33
+ """Return the current context (task-local first, then process-global)."""
34
+ ctx = _ctxvar.get()
35
+ return ctx if ctx is not None else _current_context
36
+
37
+
38
+ def set_context(ctx: Optional[ContainerContext]) -> None:
39
+ """Atomically set both task-local and process-global context."""
40
+ with _lock:
41
+ _ctxvar.set(ctx)
42
+ globals()["_current_context"] = ctx
43
+
44
+
45
+ # Optional compatibility helpers (only used by legacy API paths)
46
+ def get_fingerprint() -> Optional[tuple]:
47
+ ctx = get_context()
48
+ return ctx.fingerprint if ctx else None
49
+
50
+
51
+ def set_fingerprint(fp: Optional[tuple]) -> None:
52
+ """Compatibility shim: setting None clears the active context."""
53
+ if fp is None:
54
+ set_context(None)
55
+ return
56
+ ctx = get_context()
57
+ if ctx is not None:
58
+ set_context(ContainerContext(container=ctx.container, fingerprint=fp, root_name=ctx.root_name))
59
+
60
+
61
+ # ---- Scan/resolve guards (kept as-is) ----
4
62
 
5
63
  _scanning: ContextVar[bool] = ContextVar("pico_scanning", default=False)
6
64
  _resolving: ContextVar[bool] = ContextVar("pico_resolving", default=False)
7
65
 
8
- _container = None
9
- _root_name: Optional[str] = None
66
+
67
+ @contextmanager
68
+ def scanning_flag():
69
+ """Mark scanning=True within the block."""
70
+ tok = _scanning.set(True)
71
+ try:
72
+ yield
73
+ finally:
74
+ _scanning.reset(tok)
10
75
 
pico_ioc/_version.py CHANGED
@@ -1 +1 @@
1
- __version__ = '1.2.0'
1
+ __version__ = '1.4.0'
pico_ioc/api.py CHANGED
@@ -1,289 +1,221 @@
1
- # pico_ioc/api.py
2
-
3
1
  from __future__ import annotations
4
2
 
5
- import inspect
3
+ import inspect as _inspect
4
+ import importlib
6
5
  import logging
7
- from contextlib import contextmanager
8
- from typing import Callable, Optional, Tuple, Any, Dict, Iterable
6
+ from types import ModuleType
7
+ from typing import Callable, Optional, Tuple, Any, Dict, Iterable, Sequence
9
8
 
10
- from .container import PicoContainer, Binder
9
+ from .container import PicoContainer
11
10
  from .plugins import PicoPlugin
12
- from .scanner import scan_and_configure
13
11
  from . import _state
12
+ from .builder import PicoContainerBuilder
13
+ from .scope import ScopedContainer
14
+ from .config import ConfigRegistry, ConfigSource
14
15
 
15
16
 
16
17
  def reset() -> None:
17
- """Reset the global container."""
18
- _state._container = None
19
- _state._root_name = None
18
+ _state.set_context(None)
19
+
20
+ def _combine_excludes(a: Optional[Callable[[str], bool]], b: Optional[Callable[[str], bool]]):
21
+ if not a and not b: return None
22
+ if a and not b: return a
23
+ if b and not a: return b
24
+ return lambda mod, _a=a, _b=b: _a(mod) or _b(mod)
25
+
26
+ # -------- fingerprint helpers --------
27
+ def _callable_id(cb) -> tuple:
28
+ try:
29
+ mod = getattr(cb, "__module__", None)
30
+ qn = getattr(cb, "__qualname__", None)
31
+ code = getattr(cb, "__code__", None)
32
+ fn_line = getattr(code, "co_firstlineno", None) if code else None
33
+ return (mod, qn, fn_line)
34
+ except Exception:
35
+ return (repr(cb),)
36
+
37
+ def _plugins_id(plugins: Tuple[PicoPlugin, ...]) -> tuple:
38
+ out = [(type(p).__module__, type(p).__qualname__) for p in plugins or ()]
39
+ return tuple(sorted(out))
40
+
41
+ def _normalize_for_fp(value):
42
+ if isinstance(value, ModuleType):
43
+ return getattr(value, "__name__", repr(value))
44
+ if isinstance(value, (tuple, list)):
45
+ return tuple(_normalize_for_fp(v) for v in value)
46
+ if isinstance(value, set):
47
+ return tuple(sorted(_normalize_for_fp(v) for v in value))
48
+ if callable(value):
49
+ return ("callable",) + _callable_id(value)
50
+ return value
51
+
52
+ _FP_EXCLUDE_KEYS = set()
53
+
54
+ def _normalize_overrides_for_fp(overrides: Optional[Dict[Any, Any]]) -> tuple:
55
+ if not overrides:
56
+ return ()
57
+ items = []
58
+ for k, v in overrides.items():
59
+ nk = _normalize_for_fp(k)
60
+ nv = _normalize_for_fp(v)
61
+ items.append((nk, nv))
62
+ return tuple(sorted(items))
63
+
64
+ def _make_fingerprint_from_signature(locals_in_init: dict) -> tuple:
65
+ sig = _inspect.signature(init)
66
+ entries = []
67
+ for name in sig.parameters.keys():
68
+ if name in _FP_EXCLUDE_KEYS: continue
69
+ if name == "root_package":
70
+ rp = locals_in_init.get("root_package")
71
+ root_name = rp if isinstance(rp, str) else getattr(rp, "__name__", None)
72
+ entries.append(("root", root_name))
73
+ continue
74
+ val = locals_in_init.get(name, None)
75
+ if name == "plugins":
76
+ val = _plugins_id(val or ())
77
+ elif name in ("profiles", "auto_scan"):
78
+ val = tuple(val or ())
79
+ elif name in ("exclude", "auto_scan_exclude"):
80
+ val = _callable_id(val) if val else None
81
+ elif name == "overrides":
82
+ val = _normalize_overrides_for_fp(val)
83
+ elif name == "config":
84
+ cfg = locals_in_init.get("config") or ()
85
+ norm = []
86
+ for s in cfg:
87
+ try:
88
+ if type(s).__name__ == "EnvSource":
89
+ norm.append(("env", getattr(s, "prefix", "")))
90
+ elif type(s).__name__ == "FileSource":
91
+ norm.append(("file", str(getattr(s, "path", ""))))
92
+ else:
93
+ norm.append((type(s).__module__, type(s).__qualname__))
94
+ except Exception:
95
+ norm.append(repr(s))
96
+ val = tuple(norm)
97
+ else:
98
+ val = _normalize_for_fp(val)
99
+ entries.append((name, val))
100
+ return tuple(sorted(entries))
101
+
102
+ # -------- container reuse and caller exclusion helpers --------
103
+ def _maybe_reuse_existing(fp: tuple, overrides: Optional[Dict[Any, Any]]) -> Optional[PicoContainer]:
104
+ ctx = _state.get_context()
105
+ if ctx and ctx.fingerprint == fp:
106
+ return ctx.container
107
+ return None
20
108
 
109
+ def _build_exclude(
110
+ exclude: Optional[Callable[[str], bool]], auto_exclude_caller: bool, *, root_name: Optional[str] = None
111
+ ) -> Optional[Callable[[str], bool]]:
112
+ if not auto_exclude_caller: return exclude
113
+ caller = _get_caller_module_name()
114
+ if not caller: return exclude
115
+ def _under_root(mod: str) -> bool:
116
+ return bool(root_name) and (mod == root_name or mod.startswith(root_name + "."))
117
+ if exclude is None:
118
+ return lambda mod, _caller=caller: (mod == _caller) and not _under_root(mod)
119
+ return lambda mod, _caller=caller, _prev=exclude: (((mod == _caller) and not _under_root(mod)) or _prev(mod))
21
120
 
121
+ def _get_caller_module_name() -> Optional[str]:
122
+ try:
123
+ f = _inspect.currentframe()
124
+ # Stack: _get_caller -> _build_exclude -> init -> caller
125
+ if f and f.f_back and f.f_back.f_back and f.f_back.f_back.f_back:
126
+ mod = _inspect.getmodule(f.f_back.f_back.f_back)
127
+ return getattr(mod, "__name__", None)
128
+ except Exception:
129
+ pass
130
+ return None
131
+
132
+ # ---------------- public API ----------------
22
133
  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,
134
+ root_package, *, profiles: Optional[list[str]] = None, exclude: Optional[Callable[[str], bool]] = None,
135
+ auto_exclude_caller: bool = True, plugins: Tuple[PicoPlugin, ...] = (), reuse: bool = True,
136
+ overrides: Optional[Dict[Any, Any]] = None, auto_scan: Sequence[str] = (),
137
+ auto_scan_exclude: Optional[Callable[[str], bool]] = None, strict_autoscan: bool = False,
138
+ config: Sequence[ConfigSource] = (),
30
139
  ) -> PicoContainer:
31
-
32
140
  root_name = root_package if isinstance(root_package, str) else getattr(root_package, "__name__", None)
141
+ fp = _make_fingerprint_from_signature(locals())
33
142
 
34
- if reuse and _state._container and _state._root_name == root_name:
35
- if overrides:
36
- _apply_overrides(_state._container, overrides)
37
- return _state._container
143
+ if reuse:
144
+ reused = _maybe_reuse_existing(fp, overrides)
145
+ if reused is not None:
146
+ return reused
38
147
 
39
- combined_exclude = _build_exclude(exclude, auto_exclude_caller, root_name=root_name)
148
+ builder = (PicoContainerBuilder()
149
+ .with_plugins(plugins)
150
+ .with_profiles(profiles)
151
+ .with_overrides(overrides)
152
+ .with_config(ConfigRegistry(config or ())))
40
153
 
41
- container = PicoContainer()
42
- binder = Binder(container)
43
- logging.info("Initializing pico-ioc...")
154
+ combined_exclude = _build_exclude(exclude, auto_exclude_caller, root_name=root_name)
155
+ builder.add_scan_package(root_package, exclude=combined_exclude)
44
156
 
45
- with _scanning_flag():
46
- scan_and_configure(
47
- root_package,
48
- container,
49
- exclude=combined_exclude,
50
- plugins=plugins,
51
- )
157
+ if auto_scan:
158
+ for pkg in auto_scan:
159
+ try:
160
+ mod = importlib.import_module(pkg)
161
+ scan_exclude = _combine_excludes(exclude, auto_scan_exclude)
162
+ builder.add_scan_package(mod, exclude=scan_exclude)
163
+ except ImportError as e:
164
+ msg = f"pico-ioc: auto_scan package not found: {pkg}"
165
+ if strict_autoscan:
166
+ logging.error(msg)
167
+ raise e
168
+ logging.warning(msg)
169
+
170
+ container = builder.build()
171
+
172
+ # Activate new context atomically
173
+ new_ctx = _state.ContainerContext(container=container, fingerprint=fp, root_name=root_name)
174
+ _state.set_context(new_ctx)
175
+ return container
52
176
 
53
- if overrides:
54
- _apply_overrides(container, overrides)
177
+ def scope(
178
+ *, modules: Iterable[Any] = (), roots: Iterable[type] = (), profiles: Optional[list[str]] = None,
179
+ overrides: Optional[Dict[Any, Any]] = None, base: Optional[PicoContainer] = None,
180
+ include_tags: Optional[set[str]] = None, exclude_tags: Optional[set[str]] = None,
181
+ strict: bool = True, lazy: bool = True,
182
+ ) -> PicoContainer:
183
+ builder = PicoContainerBuilder()
55
184
 
56
- _run_hooks(plugins, "after_bind", container, binder)
57
- _run_hooks(plugins, "before_eager", container, binder)
185
+ if base is not None and not strict:
186
+ base_providers = getattr(base, "_providers", {})
187
+ builder._providers.update(base_providers)
188
+ if profiles is None:
189
+ builder.with_profiles(list(getattr(base, "_active_profiles", ())))
58
190
 
59
- container.eager_instantiate_all()
191
+ builder.with_profiles(profiles)\
192
+ .with_overrides(overrides)\
193
+ .with_tag_filters(include=include_tags, exclude=exclude_tags)\
194
+ .with_roots(roots)
60
195
 
61
- _run_hooks(plugins, "after_ready", container, binder)
196
+ for m in modules:
197
+ builder.add_scan_package(m)
62
198
 
63
- logging.info("Container configured and ready.")
64
- _state._container = container
65
- _state._root_name = root_name
66
- return container
199
+ built_container = builder.with_eager(not lazy).build()
67
200
 
201
+ scoped_container = ScopedContainer(base=base, strict=strict, built_container=built_container)
68
202
 
69
- 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
79
- ) -> 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
112
203
  if not lazy:
113
204
  from .proxy import ComponentProxy
114
205
  for rk in roots or ():
115
206
  try:
116
- obj = c.get(rk)
207
+ obj = scoped_container.get(rk)
117
208
  if isinstance(obj, ComponentProxy):
118
209
  _ = obj._get_real_object()
119
210
  except NameError:
120
- if strict:
121
- raise
122
- # non-strict: skip missing root
123
- continue
211
+ if strict: raise
124
212
 
125
213
  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
-
143
-
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)
214
+ return scoped_container
162
215
 
163
- prev = exclude
164
- return lambda mod, _caller=caller, _prev=prev: (((mod == _caller) and not _under_root(mod)) or _prev(mod))
165
216
 
166
217
 
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)
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
218
+ def container_fingerprint() -> Optional[tuple]:
219
+ ctx = _state.get_context()
220
+ return ctx.fingerprint if ctx else None
241
221
 
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
267
-
268
-
269
- class _ScopedContainer(PicoContainer):
270
- def __init__(self, base: Optional[PicoContainer], strict: bool):
271
- super().__init__()
272
- self._base = base
273
- self._strict = strict
274
-
275
- # allow `with pico_ioc.scope(...) as c:`
276
- def __enter__(self):
277
- return self
278
-
279
- # no resource suppression; placeholder for future cleanup/shutdown
280
- def __exit__(self, exc_type, exc, tb):
281
- return False
282
-
283
- def get(self, key: Any):
284
- try:
285
- return super().get(key)
286
- except NameError as e:
287
- if not self._strict and self._base is not None and self._base.has(key):
288
- return self._base.get(key)
289
- raise e