pico-ioc 0.5.2__py3-none-any.whl → 1.0.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,408 +1,31 @@
1
- import functools
2
- import importlib
3
- import inspect
4
- import logging
5
- import pkgutil
6
- from contextvars import ContextVar
7
- from typing import Any, Callable, Dict, Optional, Protocol, Tuple, runtime_checkable
1
+ # pico_ioc/__init__.py
8
2
 
9
3
  try:
10
4
  from ._version import __version__
11
5
  except Exception:
12
6
  __version__ = "0.0.0"
13
7
 
8
+ from .container import PicoContainer, Binder
9
+ from .decorators import component, factory_component, provides, plugin, Qualifier, qualifier
10
+ from .plugins import PicoPlugin
11
+ from .resolver import Resolver
12
+ from .api import init, reset
13
+ from .proxy import ComponentProxy
14
+
14
15
  __all__ = [
15
16
  "__version__",
16
17
  "PicoContainer",
17
18
  "Binder",
18
19
  "PicoPlugin",
20
+ "ComponentProxy",
19
21
  "init",
22
+ "reset",
20
23
  "component",
21
24
  "factory_component",
22
25
  "provides",
23
- "resolve_param",
24
- "create_instance",
26
+ "plugin",
27
+ "Qualifier",
28
+ "qualifier",
29
+ "Resolver",
25
30
  ]
26
31
 
27
- _scanning: ContextVar[bool] = ContextVar("pico_scanning", default=False)
28
- _resolving: ContextVar[bool] = ContextVar("pico_resolving", default=False)
29
-
30
- class PicoContainer:
31
- def __init__(self):
32
- self._providers: Dict[Any, Dict[str, Any]] = {}
33
- self._singletons: Dict[Any, Any] = {}
34
-
35
- def bind(self, key: Any, provider: Callable[[], Any], *, lazy: bool):
36
- self._providers[key] = {"factory": provider, "lazy": bool(lazy)}
37
-
38
- def has(self, key: Any) -> bool:
39
- return key in self._providers or key in self._singletons
40
-
41
- def get(self, key: Any) -> Any:
42
- if _scanning.get() and not _resolving.get():
43
- raise RuntimeError("pico-ioc: re-entrant container access during scan.")
44
- if key in self._singletons:
45
- return self._singletons[key]
46
- prov = self._providers.get(key)
47
- if prov is None:
48
- raise NameError(f"No provider found for key: {key}")
49
- instance = prov["factory"]()
50
- self._singletons[key] = instance
51
- return instance
52
-
53
- def _eager_instantiate_all(self):
54
- for key, meta in list(self._providers.items()):
55
- if not meta.get("lazy", False) and key not in self._singletons:
56
- self.get(key)
57
-
58
- class Binder:
59
- def __init__(self, container: PicoContainer):
60
- self._c = container
61
-
62
- def bind(self, key: Any, provider: Callable[[], Any], *, lazy: bool = False):
63
- self._c.bind(key, provider, lazy=lazy)
64
-
65
- def has(self, key: Any) -> bool:
66
- return self._c.has(key)
67
-
68
- def get(self, key: Any) -> Any:
69
- return self._c.get(key)
70
-
71
- def factory_component(cls):
72
- setattr(cls, '_is_factory_component', True)
73
- return cls
74
-
75
- def provides(key: Any, *, lazy: bool = False):
76
- def decorator(func):
77
- @functools.wraps(func)
78
- def wrapper(*args, **kwargs):
79
- return func(*args, **kwargs)
80
- setattr(wrapper, '_provides_name', key)
81
- setattr(wrapper, '_pico_lazy', bool(lazy))
82
- return wrapper
83
- return decorator
84
-
85
- def component(cls=None, *, name: Any = None, lazy: bool = False):
86
- def decorator(c):
87
- setattr(c, '_is_component', True)
88
- setattr(c, '_component_key', name if name is not None else c)
89
- setattr(c, '_component_lazy', bool(lazy))
90
- return c
91
- return decorator(cls) if cls else decorator
92
-
93
- class ComponentProxy:
94
- def __init__(self, object_creator: Callable[[], Any]):
95
- object.__setattr__(self, "_object_creator", object_creator)
96
- object.__setattr__(self, "__real_object", None)
97
-
98
- def _get_real_object(self) -> Any:
99
- real_obj = object.__getattribute__(self, "__real_object")
100
- if real_obj is None:
101
- real_obj = object.__getattribute__(self, "_object_creator")()
102
- object.__setattr__(self, "__real_object", real_obj)
103
- return real_obj
104
-
105
- @property
106
- def __class__(self):
107
- return self._get_real_object().__class__
108
-
109
- def __getattr__(self, name): return getattr(self._get_real_object(), name)
110
- def __setattr__(self, name, value): setattr(self._get_real_object(), name, value)
111
- def __delattr__(self, name): delattr(self._get_real_object(), name)
112
- def __str__(self): return str(self._get_real_object())
113
- def __repr__(self): return repr(self._get_real_object())
114
- def __dir__(self): return dir(self._get_real_object())
115
- def __len__(self): return len(self._get_real_object())
116
- def __getitem__(self, key): return self._get_real_object()[key]
117
- def __setitem__(self, key, value): self._get_real_object()[key] = value
118
- def __delitem__(self, key): del self._get_real_object()[key]
119
- def __iter__(self): return iter(self._get_real_object())
120
- def __reversed__(self): return reversed(self._get_real_object())
121
- def __contains__(self, item): return item in self._get_real_object()
122
- def __add__(self, other): return self._get_real_object() + other
123
- def __sub__(self, other): return self._get_real_object() - other
124
- def __mul__(self, other): return self._get_real_object() * other
125
- def __matmul__(self, other): return self._get_real_object() @ other
126
- def __truediv__(self, other): return self._get_real_object() / other
127
- def __floordiv__(self, other): return self._get_real_object() // other
128
- def __mod__(self, other): return self._get_real_object() % other
129
- def __divmod__(self, other): return divmod(self._get_real_object(), other)
130
- def __pow__(self, other, modulo=None): return pow(self._get_real_object(), other, modulo)
131
- def __lshift__(self, other): return self._get_real_object() << other
132
- def __rshift__(self, other): return self._get_real_object() >> other
133
- def __and__(self, other): return self._get_real_object() & other
134
- def __xor__(self, other): return self._get_real_object() ^ other
135
- def __or__(self, other): return self._get_real_object() | other
136
- def __radd__(self, other): return other + self._get_real_object()
137
- def __rsub__(self, other): return other - self._get_real_object()
138
- def __rmul__(self, other): return other * self._get_real_object()
139
- def __rmatmul__(self, other): return other @ self._get_real_object()
140
- def __rtruediv__(self, other): return other / self._get_real_object()
141
- def __rfloordiv__(self, other): return other // self._get_real_object()
142
- def __rmod__(self, other): return other % self._get_real_object()
143
- def __rdivmod__(self, other): return divmod(other, self._get_real_object())
144
- def __rpow__(self, other): return pow(other, self._get_real_object())
145
- def __rlshift__(self, other): return other << self._get_real_object()
146
- def __rrshift__(self, other): return other >> self._get_real_object()
147
- def __rand__(self, other): return other & self._get_real_object()
148
- def __rxor__(self, other): return other ^ self._get_real_object()
149
- def __ror__(self, other): return other | self._get_real_object()
150
- def __neg__(self): return -self._get_real_object()
151
- def __pos__(self): return +self._get_real_object()
152
- def __abs__(self): return abs(self._get_real_object())
153
- def __invert__(self): return ~self._get_real_object()
154
- def __eq__(self, other): return self._get_real_object() == other
155
- def __ne__(self, other): return self._get_real_object() != other
156
- def __lt__(self, other): return self._get_real_object() < other
157
- def __le__(self, other): return self._get_real_object() <= other
158
- def __gt__(self, other): return self._get_real_object() > other
159
- def __ge__(self, other): return self._get_real_object() >= other
160
- def __hash__(self): return hash(self._get_real_object())
161
- def __bool__(self): return bool(self._get_real_object())
162
- def __call__(self, *args, **kwargs): return self._get_real_object()(*args, **kwargs)
163
- def __enter__(self): return self._get_real_object().__enter__()
164
- def __exit__(self, exc_type, exc_val, exc_tb): return self._get_real_object().__exit__(exc_type, exc_val, exc_tb)
165
-
166
- def resolve_param(container: PicoContainer, p: inspect.Parameter) -> Any:
167
- if p.name == 'self' or p.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
168
- raise RuntimeError("Invalid param for resolution")
169
- if container.has(p.name):
170
- return container.get(p.name)
171
- ann = p.annotation
172
- if ann is not inspect._empty and container.has(ann):
173
- return container.get(ann)
174
- if ann is not inspect._empty:
175
- try:
176
- for base in getattr(ann, "__mro__", ())[1:]:
177
- if base is object:
178
- break
179
- if container.has(base):
180
- return container.get(base)
181
- except Exception:
182
- pass
183
- if container.has(str(p.name)):
184
- return container.get(str(p.name))
185
- key = p.name if ann is inspect._empty else ann
186
- return container.get(key)
187
-
188
- def create_instance(cls: type, container: PicoContainer) -> Any:
189
- """
190
- Instantiate `cls` with constructor DI from `container`.
191
-
192
- Resolution rules:
193
- - Skip `self`, *args, **kwargs.
194
- - Try resolve_param(...) for each parameter.
195
- - If resolve_param raises NameError AND the parameter has a default,
196
- skip injecting it so Python uses the default value.
197
- - Otherwise, propagate the error.
198
-
199
- This preserves the "name > type > MRO > str(name)" precedence in resolve_param,
200
- while making defaulted, annotated parameters truly optional.
201
- """
202
- sig = inspect.signature(cls.__init__)
203
- deps: Dict[str, Any] = {}
204
-
205
- tok = _resolving.set(True)
206
- try:
207
- for p in sig.parameters.values():
208
- if p.name == 'self' or p.kind in (
209
- inspect.Parameter.VAR_POSITIONAL,
210
- inspect.Parameter.VAR_KEYWORD,
211
- ):
212
- continue
213
-
214
- try:
215
- deps[p.name] = resolve_param(container, p)
216
- except NameError:
217
- if p.default is not inspect._empty:
218
- continue
219
- raise
220
- finally:
221
- _resolving.reset(tok)
222
-
223
- return cls(**deps)
224
-
225
-
226
- @runtime_checkable
227
- class PicoPlugin(Protocol):
228
- def before_scan(self, package: Any, binder: Binder) -> None: ...
229
- def visit_class(self, module: Any, cls: type, binder: Binder) -> None: ...
230
- def after_scan(self, package: Any, binder: Binder) -> None: ...
231
- def after_bind(self, container: PicoContainer, binder: Binder) -> None: ...
232
- def before_eager(self, container: PicoContainer, binder: Binder) -> None: ...
233
- def after_ready(self, container: PicoContainer, binder: Binder) -> None: ...
234
-
235
- def _scan_and_configure(
236
- package_or_name,
237
- container: PicoContainer,
238
- *,
239
- exclude: Optional[Callable[[str], bool]] = None,
240
- plugins: Tuple[PicoPlugin, ...] = (),
241
- ):
242
- package = importlib.import_module(package_or_name) if isinstance(package_or_name, str) else package_or_name
243
- logging.info(f"Scanning in '{package.__name__}'...")
244
- binder = Binder(container)
245
-
246
- for pl in plugins:
247
- try:
248
- if hasattr(pl, "before_scan"):
249
- pl.before_scan(package, binder)
250
- except Exception:
251
- logging.exception("Plugin before_scan failed")
252
-
253
- component_classes, factory_classes = [], []
254
- for _, name, _ in pkgutil.walk_packages(package.__path__, package.__name__ + '.'):
255
- if exclude and exclude(name):
256
- logging.info(f"Skipping module {name} (excluded)")
257
- continue
258
- try:
259
- module = importlib.import_module(name)
260
- for _, obj in inspect.getmembers(module, inspect.isclass):
261
- for pl in plugins:
262
- try:
263
- if hasattr(pl, "visit_class"):
264
- pl.visit_class(module, obj, binder)
265
- except Exception:
266
- logging.exception("Plugin visit_class failed")
267
- if getattr(obj, '_is_component', False):
268
- component_classes.append(obj)
269
- elif getattr(obj, '_is_factory_component', False):
270
- factory_classes.append(obj)
271
- except Exception as e:
272
- logging.warning(f"Module {name} not processed: {e}")
273
-
274
- for pl in plugins:
275
- try:
276
- if hasattr(pl, "after_scan"):
277
- pl.after_scan(package, binder)
278
- except Exception:
279
- logging.exception("Plugin after_scan failed")
280
-
281
- for component_cls in component_classes:
282
- key = getattr(component_cls, '_component_key', component_cls)
283
- is_lazy = bool(getattr(component_cls, '_component_lazy', False))
284
-
285
- def provider_factory(lazy=is_lazy, cls=component_cls):
286
- def _factory():
287
- if lazy:
288
- return ComponentProxy(lambda: create_instance(cls, container))
289
- return create_instance(cls, container)
290
- return _factory
291
-
292
- container.bind(key, provider_factory(), lazy=is_lazy)
293
-
294
-
295
- def _resolve_method_kwargs(meth) -> Dict[str, Any]:
296
- sig = inspect.signature(meth)
297
- deps = {}
298
- tok = _resolving.set(True)
299
- try:
300
- for p in sig.parameters.values():
301
- if p.name == 'self' or p.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
302
- continue
303
- try:
304
- deps[p.name] = resolve_param(container, p)
305
- except NameError:
306
- if p.default is not inspect._empty:
307
- continue
308
- raise
309
- finally:
310
- _resolving.reset(tok)
311
- return deps
312
-
313
- for factory_cls in factory_classes:
314
- try:
315
- factory_instance = create_instance(factory_cls, container)
316
-
317
- for name, func in inspect.getmembers(factory_cls, predicate=inspect.isfunction):
318
- provides_key = getattr(func, '_provides_name', None)
319
- if provides_key is None:
320
- continue
321
-
322
- is_lazy = bool(getattr(func, '_pico_lazy', False))
323
-
324
- bound_method = getattr(factory_instance, name, None)
325
- if bound_method is None:
326
- bound_method = func.__get__(factory_instance, factory_cls)
327
-
328
- fn_meta_src = getattr(bound_method, '__func__', bound_method)
329
- if getattr(fn_meta_src, '_provides_name', None) is not None:
330
- provides_key = getattr(fn_meta_src, '_provides_name')
331
- is_lazy = bool(getattr(fn_meta_src, '_pico_lazy', is_lazy))
332
-
333
- def make_provider(m=bound_method, lazy=is_lazy):
334
- def _factory():
335
- kwargs = _resolve_method_kwargs(m)
336
-
337
- def _call():
338
- return m(**kwargs)
339
-
340
- if lazy:
341
- return ComponentProxy(lambda: _call())
342
- return _call()
343
- return _factory
344
-
345
- container.bind(provides_key, make_provider(), lazy=is_lazy)
346
- except Exception:
347
- logging.exception(f"Error in factory {factory_cls.__name__}")
348
-
349
-
350
-
351
- _container: Optional[PicoContainer] = None
352
-
353
- def init(
354
- root_package,
355
- *,
356
- exclude: Optional[Callable[[str], bool]] = None,
357
- auto_exclude_caller: bool = True,
358
- plugins: Tuple[PicoPlugin, ...] = (),
359
- ):
360
- global _container
361
- if _container:
362
- return _container
363
- combined_exclude = exclude
364
- if auto_exclude_caller:
365
- try:
366
- caller_frame = inspect.stack()[1].frame
367
- caller_module = inspect.getmodule(caller_frame)
368
- caller_name = getattr(caller_module, "__name__", None)
369
- except Exception:
370
- caller_name = None
371
- if caller_name:
372
- if combined_exclude is None:
373
- def combined_exclude(mod: str, _caller=caller_name):
374
- return mod == _caller
375
- else:
376
- prev = combined_exclude
377
- def combined_exclude(mod: str, _caller=caller_name, _prev=prev):
378
- return mod == _caller or _prev(mod)
379
- _container = PicoContainer()
380
- binder = Binder(_container)
381
- logging.info("Initializing pico-ioc...")
382
- tok = _scanning.set(True)
383
- try:
384
- _scan_and_configure(root_package, _container, exclude=combined_exclude, plugins=plugins)
385
- finally:
386
- _scanning.reset(tok)
387
- for pl in plugins:
388
- try:
389
- if hasattr(pl, "after_bind"):
390
- pl.after_bind(_container, binder)
391
- except Exception:
392
- logging.exception("Plugin after_bind failed")
393
- for pl in plugins:
394
- try:
395
- if hasattr(pl, "before_eager"):
396
- pl.before_eager(_container, binder)
397
- except Exception:
398
- logging.exception("Plugin before_eager failed")
399
- _container._eager_instantiate_all()
400
- for pl in plugins:
401
- try:
402
- if hasattr(pl, "after_ready"):
403
- pl.after_ready(_container, binder)
404
- except Exception:
405
- logging.exception("Plugin after_ready failed")
406
- logging.info("Container configured and ready.")
407
- return _container
408
-
pico_ioc/_state.py ADDED
@@ -0,0 +1,10 @@
1
+ # pico_ioc/_state.py
2
+ from contextvars import ContextVar
3
+ from typing import Optional
4
+
5
+ _scanning: ContextVar[bool] = ContextVar("pico_scanning", default=False)
6
+ _resolving: ContextVar[bool] = ContextVar("pico_resolving", default=False)
7
+
8
+ _container = None
9
+ _root_name: Optional[str] = None
10
+
pico_ioc/_version.py CHANGED
@@ -1 +1 @@
1
- __version__ = '0.5.2'
1
+ __version__ = '1.0.0'
pico_ioc/api.py ADDED
@@ -0,0 +1,128 @@
1
+ # pico_ioc/api.py
2
+
3
+ import inspect
4
+ import logging
5
+ from contextlib import contextmanager
6
+ from typing import Callable, Optional, Tuple
7
+
8
+ from .container import PicoContainer, Binder
9
+ from .plugins import PicoPlugin
10
+ from .scanner import scan_and_configure
11
+ from . import _state
12
+
13
+
14
+ def reset() -> None:
15
+ """Reset the global container."""
16
+ _state._container = None
17
+ _state._root_name = None
18
+
19
+
20
+ def init(
21
+ root_package,
22
+ *,
23
+ exclude: Optional[Callable[[str], bool]] = None,
24
+ auto_exclude_caller: bool = True,
25
+ plugins: Tuple[PicoPlugin, ...] = (),
26
+ reuse: bool = True,
27
+ ) -> PicoContainer:
28
+ """
29
+ Initialize and configure a PicoContainer by scanning a root package.
30
+ """
31
+ root_name = root_package if isinstance(root_package, str) else getattr(root_package, "__name__", None)
32
+
33
+ # Reuse only if the existing container was built for the same root
34
+ if reuse and _state._container and _state._root_name == root_name:
35
+ return _state._container
36
+
37
+ combined_exclude = _build_exclude(exclude, auto_exclude_caller, root_name=root_name)
38
+
39
+ container = PicoContainer()
40
+ binder = Binder(container)
41
+ logging.info("Initializing pico-ioc...")
42
+
43
+ with _scanning_flag():
44
+ scan_and_configure(
45
+ root_package,
46
+ container,
47
+ exclude=combined_exclude,
48
+ plugins=plugins,
49
+ )
50
+
51
+ _run_hooks(plugins, "after_bind", container, binder)
52
+ _run_hooks(plugins, "before_eager", container, binder)
53
+
54
+ container.eager_instantiate_all()
55
+
56
+ _run_hooks(plugins, "after_ready", container, binder)
57
+
58
+ logging.info("Container configured and ready.")
59
+ _state._container = container
60
+ _state._root_name = root_name # remember which root this container represents
61
+ return container
62
+
63
+
64
+ # -------------------- helpers --------------------
65
+
66
+ def _build_exclude(
67
+ exclude: Optional[Callable[[str], bool]],
68
+ auto_exclude_caller: bool,
69
+ *,
70
+ root_name: Optional[str] = None,
71
+ ) -> Optional[Callable[[str], bool]]:
72
+ """
73
+ Compose the exclude predicate. When auto_exclude_caller=True, exclude only
74
+ the exact calling module, but never exclude modules under the root being scanned.
75
+ """
76
+ if not auto_exclude_caller:
77
+ return exclude
78
+
79
+ caller = _get_caller_module_name()
80
+ if not caller:
81
+ return exclude
82
+
83
+ def _under_root(mod: str) -> bool:
84
+ return bool(root_name) and (mod == root_name or mod.startswith(root_name + "."))
85
+
86
+ if exclude is None:
87
+ return lambda mod, _caller=caller: (mod == _caller) and not _under_root(mod)
88
+
89
+ prev = exclude
90
+ return lambda mod, _caller=caller, _prev=prev: (((mod == _caller) and not _under_root(mod)) or _prev(mod))
91
+
92
+
93
+ def _get_caller_module_name() -> Optional[str]:
94
+ """Return the module name that called `init`."""
95
+ try:
96
+ f = inspect.currentframe()
97
+ # frame -> _get_caller_module_name -> _build_exclude -> init
98
+ if f and f.f_back and f.f_back.f_back and f.f_back.f_back.f_back:
99
+ mod = inspect.getmodule(f.f_back.f_back.f_back)
100
+ return getattr(mod, "__name__", None)
101
+ except Exception:
102
+ pass
103
+ return None
104
+
105
+
106
+ def _run_hooks(
107
+ plugins: Tuple[PicoPlugin, ...],
108
+ hook_name: str,
109
+ container: PicoContainer,
110
+ binder: Binder,
111
+ ) -> None:
112
+ for pl in plugins:
113
+ try:
114
+ fn = getattr(pl, hook_name, None)
115
+ if fn:
116
+ fn(container, binder)
117
+ except Exception:
118
+ logging.exception("Plugin %s failed", hook_name)
119
+
120
+
121
+ @contextmanager
122
+ def _scanning_flag():
123
+ tok = _state._scanning.set(True)
124
+ try:
125
+ yield
126
+ finally:
127
+ _state._scanning.reset(tok)
128
+