pico-ioc 1.4.0__py3-none-any.whl → 1.5.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,4 +1,3 @@
1
- # pico_ioc/__init__.py
2
1
  try:
3
2
  from ._version import __version__
4
3
  except Exception:
@@ -9,17 +8,24 @@ from .scope import ScopedContainer
9
8
  from .decorators import (
10
9
  component, factory_component, provides, plugin,
11
10
  Qualifier, qualifier,
12
- on_missing, primary, conditional, interceptor,
11
+ on_missing, primary, conditional, infrastructure,
13
12
  )
14
13
  from .plugins import PicoPlugin
15
14
  from .resolver import Resolver
16
15
  from .api import init, reset, scope, container_fingerprint
17
16
  from .proxy import ComponentProxy, IoCProxy
18
- from .interceptors import Invocation, MethodInterceptor, ContainerInterceptor
17
+ from .interceptors import (
18
+ MethodInterceptor,
19
+ ContainerInterceptor,
20
+ MethodCtx,
21
+ ResolveCtx,
22
+ CreateCtx,
23
+ )
19
24
  from .config import (
20
25
  config_component, EnvSource, FileSource,
21
26
  Env, File, Path, Value,
22
27
  )
28
+ from .infra import Infra, Select
23
29
 
24
30
  __all__ = [
25
31
  "__version__",
@@ -28,9 +34,11 @@ __all__ = [
28
34
  "PicoPlugin",
29
35
  "ComponentProxy",
30
36
  "IoCProxy",
31
- "Invocation",
32
37
  "MethodInterceptor",
33
38
  "ContainerInterceptor",
39
+ "MethodCtx",
40
+ "ResolveCtx",
41
+ "CreateCtx",
34
42
  "init",
35
43
  "scope",
36
44
  "reset",
@@ -44,15 +52,17 @@ __all__ = [
44
52
  "on_missing",
45
53
  "primary",
46
54
  "conditional",
47
- "interceptor",
55
+ "infrastructure",
48
56
  "Resolver",
49
57
  "ScopedContainer",
50
- "config_component",
51
- "EnvSource",
52
- "FileSource",
53
- "Env",
54
- "File",
55
- "Path",
58
+ "config_component",
59
+ "EnvSource",
60
+ "FileSource",
61
+ "Env",
62
+ "File",
63
+ "Path",
56
64
  "Value",
65
+ "Infra",
66
+ "Select",
57
67
  ]
58
68
 
pico_ioc/_version.py CHANGED
@@ -1 +1 @@
1
- __version__ = '1.4.0'
1
+ __version__ = '1.5.0'
pico_ioc/api.py CHANGED
@@ -129,7 +129,6 @@ def _get_caller_module_name() -> Optional[str]:
129
129
  pass
130
130
  return None
131
131
 
132
- # ---------------- public API ----------------
133
132
  def init(
134
133
  root_package, *, profiles: Optional[list[str]] = None, exclude: Optional[Callable[[str], bool]] = None,
135
134
  auto_exclude_caller: bool = True, plugins: Tuple[PicoPlugin, ...] = (), reuse: bool = True,
@@ -137,6 +136,8 @@ def init(
137
136
  auto_scan_exclude: Optional[Callable[[str], bool]] = None, strict_autoscan: bool = False,
138
137
  config: Sequence[ConfigSource] = (),
139
138
  ) -> PicoContainer:
139
+ if _state._scanning.get():
140
+ logging.info("re-entrant container access during scan")
140
141
  root_name = root_package if isinstance(root_package, str) else getattr(root_package, "__name__", None)
141
142
  fp = _make_fingerprint_from_signature(locals())
142
143
 
@@ -169,11 +170,11 @@ def init(
169
170
 
170
171
  container = builder.build()
171
172
 
172
- # Activate new context atomically
173
173
  new_ctx = _state.ContainerContext(container=container, fingerprint=fp, root_name=root_name)
174
174
  _state.set_context(new_ctx)
175
175
  return container
176
176
 
177
+
177
178
  def scope(
178
179
  *, modules: Iterable[Any] = (), roots: Iterable[type] = (), profiles: Optional[list[str]] = None,
179
180
  overrides: Optional[Dict[Any, Any]] = None, base: Optional[PicoContainer] = None,
pico_ioc/builder.py CHANGED
@@ -1,6 +1,4 @@
1
- # src/pico_ioc/builder.py
2
1
  from __future__ import annotations
3
-
4
2
  import inspect as _inspect
5
3
  import logging
6
4
  import os
@@ -17,8 +15,6 @@ from . import _state
17
15
  from .config import ConfigRegistry
18
16
 
19
17
  class PicoContainerBuilder:
20
- """Configures and builds a PicoContainer. Does not touch global context."""
21
-
22
18
  def __init__(self):
23
19
  self._scan_plan: List[Tuple[Any, Optional[Callable[[str], bool]], Tuple[PicoPlugin, ...]]] = []
24
20
  self._overrides: Dict[Any, Any] = {}
@@ -28,12 +24,9 @@ class PicoContainerBuilder:
28
24
  self._exclude_tags: Optional[set[str]] = None
29
25
  self._roots: Iterable[type] = ()
30
26
  self._providers: Dict[Any, Dict] = {}
31
- self._interceptor_decls: List[Tuple[Any, dict]] = []
32
27
  self._eager: bool = True
33
28
  self._config_registry: ConfigRegistry | None = None
34
29
 
35
- # -------- fluent config --------
36
-
37
30
  def with_config(self, registry: ConfigRegistry) -> "PicoContainerBuilder":
38
31
  self._config_registry = registry
39
32
  return self
@@ -67,59 +60,39 @@ class PicoContainerBuilder:
67
60
  self._eager = bool(eager)
68
61
  return self
69
62
 
70
- # -------- build --------
71
-
72
63
  def build(self) -> PicoContainer:
73
- """Build and return a fully configured container."""
74
64
  requested_profiles = _resolve_profiles(self._profiles)
75
-
76
65
  container = PicoContainer(providers=self._providers)
77
66
  container._active_profiles = tuple(requested_profiles)
78
67
  setattr(container, "_config_registry", self._config_registry)
79
-
68
+ all_infras: list[tuple[type, dict]] = []
80
69
  for pkg, exclude, scan_plugins in self._scan_plan:
81
70
  with _state.scanning_flag():
82
- c, f, decls = scan_and_configure(pkg, container, exclude=exclude, plugins=scan_plugins)
71
+ c, f, infra_decls = scan_and_configure(pkg, container, exclude=exclude, plugins=scan_plugins)
83
72
  logging.info("Scanned '%s' (components: %d, factories: %d)", getattr(pkg, "__name__", pkg), c, f)
84
- self._interceptor_decls.extend(decls)
85
-
86
- _activate_and_build_interceptors(
87
- container=container,
88
- interceptor_decls=self._interceptor_decls,
89
- profiles=requested_profiles,
90
- )
91
-
73
+ all_infras.extend(infra_decls)
74
+ _run_infrastructure(container=container, infra_decls=all_infras, profiles=requested_profiles)
92
75
  binder = container.binder()
93
-
94
76
  if self._overrides:
95
77
  _apply_overrides(container, self._overrides)
96
-
97
78
  run_plugin_hook(self._plugins, "after_bind", container, binder)
98
79
  run_plugin_hook(self._plugins, "before_eager", container, binder)
99
-
100
80
  apply_policy(container, profiles=requested_profiles)
101
81
  _filter_by_tags(container, self._include_tags, self._exclude_tags)
102
-
103
82
  if self._roots:
104
83
  _restrict_to_subgraph(container, self._roots, self._overrides)
105
-
106
84
  run_plugin_hook(self._plugins, "after_ready", container, binder)
107
-
108
85
  if self._eager:
109
86
  container.eager_instantiate_all()
110
87
  logging.info("Container configured and ready.")
111
88
  return container
112
89
 
113
-
114
- # ---------------- helpers ----------------
115
-
116
90
  def _resolve_profiles(profiles: Optional[List[str]]) -> List[str]:
117
91
  if profiles is not None:
118
92
  return list(profiles)
119
93
  env_val = os.getenv("PICO_PROFILE", "")
120
94
  return [p.strip() for p in env_val.split(",") if p.strip()]
121
95
 
122
-
123
96
  def _as_provider(val):
124
97
  if isinstance(val, tuple) and len(val) == 2 and callable(val[0]) and isinstance(val[1], bool):
125
98
  return val[0], val[1]
@@ -127,17 +100,14 @@ def _as_provider(val):
127
100
  return val, False
128
101
  return (lambda v=val: v), False
129
102
 
130
-
131
103
  def _apply_overrides(container: PicoContainer, overrides: Dict[Any, Any]) -> None:
132
104
  for key, val in overrides.items():
133
105
  provider, lazy = _as_provider(val)
134
106
  container.bind(key, provider, lazy=lazy)
135
107
 
136
-
137
108
  def _filter_by_tags(container: PicoContainer, include_tags: Optional[set[str]], exclude_tags: Optional[set[str]]) -> None:
138
109
  if not include_tags and not exclude_tags:
139
110
  return
140
-
141
111
  def _tag_ok(meta: dict) -> bool:
142
112
  tags = set(meta.get("tags", ()))
143
113
  if include_tags and not tags.intersection(include_tags):
@@ -145,14 +115,11 @@ def _filter_by_tags(container: PicoContainer, include_tags: Optional[set[str]],
145
115
  if exclude_tags and tags.intersection(exclude_tags):
146
116
  return False
147
117
  return True
148
-
149
118
  container._providers = {k: v for k, v in container._providers.items() if _tag_ok(v)}
150
119
 
151
-
152
120
  def _compute_allowed_subgraph(container: PicoContainer, roots: Iterable[type]) -> set:
153
121
  allowed: set[Any] = set(roots)
154
122
  stack = list(roots or ())
155
-
156
123
  def _add_impls_for_base(base_t):
157
124
  for prov_key, meta in container._providers.items():
158
125
  cls = prov_key if isinstance(prov_key, type) else None
@@ -160,23 +127,19 @@ def _compute_allowed_subgraph(container: PicoContainer, roots: Iterable[type]) -
160
127
  if prov_key not in allowed:
161
128
  allowed.add(prov_key)
162
129
  stack.append(prov_key)
163
-
164
130
  while stack:
165
131
  k = stack.pop()
166
132
  allowed.add(k)
167
133
  if isinstance(k, type):
168
134
  _add_impls_for_base(k)
169
-
170
135
  cls = k if isinstance(k, type) else None
171
136
  if cls is None or not container.has(k):
172
137
  continue
173
-
174
138
  try:
175
139
  sig = _inspect.signature(cls.__init__)
176
140
  hints = _get_hints(cls.__init__, owner_cls=cls)
177
141
  except Exception:
178
142
  continue
179
-
180
143
  for pname, param in sig.parameters.items():
181
144
  if pname == "self":
182
145
  continue
@@ -196,99 +159,52 @@ def _compute_allowed_subgraph(container: PicoContainer, roots: Iterable[type]) -
196
159
  stack.append(pname)
197
160
  return allowed
198
161
 
199
-
200
162
  def _restrict_to_subgraph(container: PicoContainer, roots: Iterable[type], overrides: Optional[Dict[Any, Any]]) -> None:
201
163
  allowed = _compute_allowed_subgraph(container, roots)
202
164
  keep_keys: set[Any] = allowed | (set(overrides.keys()) if overrides else set())
203
165
  container._providers = {k: v for k, v in container._providers.items() if k in keep_keys}
204
166
 
205
-
206
- def _activate_and_build_interceptors(
207
- *, container: PicoContainer, interceptor_decls: List[Tuple[Any, dict]], profiles: List[str],
208
- ) -> None:
209
- resolver = Resolver(container)
210
- active: List[Tuple[int, str, str, Any]] = []
211
- activated_method_names: List[str] = []
212
- activated_container_names: List[str] = []
213
- skipped_debug: List[str] = []
214
-
215
- def _interceptor_meta_active(meta: dict) -> bool:
167
+ def _run_infrastructure(*, container: PicoContainer, infra_decls: List[tuple[type, dict]], profiles: List[str]) -> None:
168
+ def _active(meta: dict) -> bool:
216
169
  profs = tuple(meta.get("profiles", ())) or ()
217
170
  if profs and (not profiles or not any(p in profs for p in profiles)):
218
171
  return False
219
172
  req_env = tuple(meta.get("require_env", ())) or ()
220
- if req_env and not all(os.getenv(k) not in (None, "") for k in req_env):
221
- return False
173
+ if req_env:
174
+ import os
175
+ if not all(os.getenv(k) not in (None, "") for k in req_env):
176
+ return False
222
177
  pred = meta.get("predicate", None)
223
178
  if callable(pred):
224
179
  try:
225
180
  if not bool(pred()):
226
181
  return False
227
182
  except Exception:
228
- logging.exception("Interceptor predicate failed; skipping")
229
183
  return False
230
184
  return True
231
-
232
- def _looks_like_container_interceptor(inst: Any) -> bool:
233
- return all(
234
- hasattr(inst, m) for m in ("on_resolve", "on_before_create", "on_after_create", "on_exception")
235
- )
236
-
237
- for raw_obj, meta in interceptor_decls:
238
- owner_cls, obj = (raw_obj[0], raw_obj[1]) if isinstance(raw_obj, tuple) and len(raw_obj) == 2 else (None, raw_obj)
239
- qn = getattr(obj, "__qualname__", repr(obj))
240
-
241
- if not _conditional_active(obj, profiles=profiles) or not _interceptor_meta_active(meta):
242
- skipped_debug.append(f"skip:{qn}")
185
+ from .resolver import Resolver
186
+ from .infra import Infra
187
+ resolver = Resolver(container)
188
+ active_infras: List[tuple[int, type]] = []
189
+ for cls, meta in infra_decls:
190
+ if not _active(meta):
243
191
  continue
244
-
192
+ order = int(meta.get("order", 0))
193
+ active_infras.append((order, cls))
194
+ active_infras.sort(key=lambda t: (t[0], getattr(t[1], "__qualname__", "")))
195
+ for _ord, cls in active_infras:
245
196
  try:
246
- if isinstance(obj, type):
247
- inst = resolver.create_instance(obj)
248
- elif owner_cls is not None:
249
- owner_inst = resolver.create_instance(owner_cls)
250
- bound = obj.__get__(owner_inst, owner_cls)
251
- kwargs = resolver.kwargs_for_callable(bound, owner_cls=owner_cls)
252
- inst = bound(**kwargs)
253
- else:
254
- kwargs = resolver.kwargs_for_callable(obj, owner_cls=None)
255
- inst = obj(**kwargs)
197
+ inst = resolver.create_instance(cls)
256
198
  except Exception:
257
- logging.exception("Failed to construct interceptor %r", obj)
258
- continue
259
-
260
- kind = meta.get("kind", "method")
261
- if kind == "method" and not callable(inst):
262
- logging.error("Interceptor %s is not valid for kind %s; skipping", qn, kind)
263
- continue
264
- if kind == "container" and not _looks_like_container_interceptor(inst):
265
- logging.error("Container interceptor %s lacks required methods; skipping", qn)
199
+ import logging
200
+ logging.exception("Failed to construct infrastructure %r", cls)
266
201
  continue
267
-
268
- order = int(meta.get("order", 0))
269
- active.append((order, qn, kind, inst))
270
-
271
- active.sort(key=lambda t: (t[0], t[1]))
272
-
273
- for _order, _qn, kind, inst in active:
274
- if kind == "container":
275
- container.add_container_interceptor(inst) # type: ignore[arg-type]
276
- activated_container_names.append(_qn)
277
- else:
278
- container.add_method_interceptor(inst) # type: ignore[arg-type]
279
- activated_method_names.append(_qn)
280
-
281
- if activated_method_names or activated_container_names:
282
- logging.info(
283
- "Interceptors activated: method=%d, container=%d",
284
- len(activated_method_names),
285
- len(activated_container_names),
286
- )
287
- logging.debug(
288
- "Activated method=%s; Activated container=%s",
289
- ", ".join(activated_method_names) or "-",
290
- ", ".join(activated_container_names) or "-",
291
- )
292
- if skipped_debug:
293
- logging.debug("Skipped interceptors: %s", ", ".join(skipped_debug))
202
+ infra = Infra(container=container, profiles=tuple(profiles))
203
+ fn = getattr(inst, "configure", None)
204
+ if callable(fn):
205
+ try:
206
+ fn(infra)
207
+ except Exception:
208
+ import logging
209
+ logging.exception("Infrastructure configure() failed for %r", cls)
294
210
 
pico_ioc/container.py CHANGED
@@ -1,4 +1,3 @@
1
- # src/pico_ioc/container.py
2
1
  from __future__ import annotations
3
2
 
4
3
  import inspect
@@ -6,11 +5,10 @@ from typing import Any, Dict, get_origin, get_args, Annotated
6
5
  import typing as _t
7
6
 
8
7
  from .proxy import IoCProxy
9
- from .interceptors import MethodInterceptor, ContainerInterceptor
8
+ from .interceptors import MethodInterceptor, ContainerInterceptor, MethodCtx, ResolveCtx, CreateCtx, run_resolve_chain, run_create_chain
10
9
  from .decorators import QUALIFIERS_KEY
11
10
  from . import _state
12
11
 
13
-
14
12
  class Binder:
15
13
  def __init__(self, container: PicoContainer):
16
14
  self._c = container
@@ -24,7 +22,6 @@ class Binder:
24
22
  def get(self, key: Any):
25
23
  return self._c.get(key)
26
24
 
27
-
28
25
  class PicoContainer:
29
26
  def __init__(self, providers: Dict[Any, Dict[str, Any]] | None = None):
30
27
  self._providers = providers or {}
@@ -33,14 +30,9 @@ class PicoContainer:
33
30
  self._container_interceptors: tuple[ContainerInterceptor, ...] = ()
34
31
  self._active_profiles: tuple[str, ...] = ()
35
32
  self._seen_interceptor_types: set[type] = set()
36
-
37
- # --- interceptors ---
33
+ self._method_cap: int | None = None
38
34
 
39
35
  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
36
  self._method_interceptors = self._method_interceptors + (it,)
45
37
 
46
38
  def add_container_interceptor(self, it: ContainerInterceptor) -> None:
@@ -50,7 +42,8 @@ class PicoContainer:
50
42
  self._seen_interceptor_types.add(t)
51
43
  self._container_interceptors = self._container_interceptors + (it,)
52
44
 
53
- # --- binding ---
45
+ def set_method_cap(self, n: int | None) -> None:
46
+ self._method_cap = (int(n) if n is not None else None)
54
47
 
55
48
  def binder(self) -> Binder:
56
49
  return Binder(self)
@@ -66,65 +59,44 @@ class PicoContainer:
66
59
  meta["tags"] = tuple(tags) if tags else ()
67
60
  self._providers[key] = meta
68
61
 
69
- # --- resolution ---
70
-
71
62
  def has(self, key: Any) -> bool:
72
63
  return key in self._providers
73
64
 
65
+ def _notify_resolve(self, key: Any, ann: Any, quals: tuple[str, ...] | tuple()):
66
+ ctx = ResolveCtx(key=key, qualifiers={q: True for q in quals or ()}, requested_by=None, profiles=self._active_profiles)
67
+ run_resolve_chain(self._container_interceptors, ctx)
68
+
74
69
  def get(self, key: Any):
75
70
  if _state._scanning.get() and not _state._resolving.get():
76
71
  raise RuntimeError("re-entrant container access during scan")
77
-
78
72
  prov = self._providers.get(key)
79
73
  if prov is None:
80
74
  raise NameError(f"No provider found for key {key!r}")
81
-
82
75
  if key in self._singletons:
83
76
  return self._singletons[key]
84
-
85
- for ci in self._container_interceptors:
86
- try:
87
- ci.on_before_create(key)
88
- except Exception:
89
- pass
90
-
77
+ def base_provider():
78
+ return prov["factory"]()
79
+ cls = key if isinstance(key, type) else None
80
+ ctx = CreateCtx(key=key, component=cls, provider=base_provider, profiles=self._active_profiles)
91
81
  tok = _state._resolving.set(True)
92
82
  try:
93
- 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
101
- raise
83
+ instance = run_create_chain(self._container_interceptors, ctx)
102
84
  finally:
103
85
  _state._resolving.reset(tok)
104
-
105
86
  if self._method_interceptors and not isinstance(instance, IoCProxy):
106
- instance = IoCProxy(instance, self._method_interceptors)
107
-
108
- for ci in self._container_interceptors:
109
- try:
110
- maybe = ci.on_after_create(key, instance)
111
- if maybe is not None:
112
- instance = maybe
113
- except Exception:
114
- pass
115
-
87
+ chain = self._method_interceptors
88
+ cap = getattr(self, "_method_cap", None)
89
+ if isinstance(cap, int) and cap >= 0:
90
+ chain = chain[:cap]
91
+ instance = IoCProxy(instance, chain, container=self, request_key=key)
116
92
  self._singletons[key] = instance
117
93
  return instance
118
94
 
119
- # --- lifecycle ---
120
-
121
95
  def eager_instantiate_all(self):
122
96
  for key, prov in list(self._providers.items()):
123
97
  if not prov["lazy"]:
124
98
  self.get(key)
125
99
 
126
- # --- helpers for multiples ---
127
-
128
100
  def get_all(self, base_type: Any):
129
101
  return tuple(self._resolve_all_for_base(base_type, qualifiers=()))
130
102
 
@@ -149,20 +121,15 @@ class PicoContainer:
149
121
  def get_providers(self) -> Dict[Any, Dict]:
150
122
  return self._providers.copy()
151
123
 
152
-
153
- # --- compatibility helpers ---
154
-
155
124
  def _is_protocol(t) -> bool:
156
125
  return getattr(t, "_is_protocol", False) is True
157
126
 
158
-
159
127
  def _is_compatible(cls, base) -> bool:
160
128
  try:
161
129
  if isinstance(base, type) and issubclass(cls, base):
162
130
  return True
163
131
  except TypeError:
164
132
  pass
165
-
166
133
  if _is_protocol(base):
167
134
  names = set(getattr(base, "__annotations__", {}).keys())
168
135
  names.update(n for n in getattr(base, "__dict__", {}).keys() if not n.startswith("_"))
@@ -172,22 +139,18 @@ def _is_compatible(cls, base) -> bool:
172
139
  if not hasattr(cls, n):
173
140
  return False
174
141
  return True
175
-
176
142
  return False
177
143
 
178
-
179
144
  def _requires_collection_of_base(cls, base) -> bool:
180
145
  try:
181
146
  sig = inspect.signature(cls.__init__)
182
147
  except Exception:
183
148
  return False
184
-
185
149
  try:
186
150
  from .resolver import _get_hints
187
151
  hints = _get_hints(cls.__init__, owner_cls=cls)
188
152
  except Exception:
189
153
  hints = {}
190
-
191
154
  for name, param in sig.parameters.items():
192
155
  if name == "self":
193
156
  continue