pico-ioc 0.6.0__py3-none-any.whl → 1.1.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,27 +1,31 @@
1
- from ._version import __version__
2
- from .container import PicoContainer, Binder
3
- from .decorators import component, factory_component, provides
4
- from .plugins import PicoPlugin
5
- from .resolver import Resolver
6
- from .api import init, reset
7
- from .proxy import ComponentProxy
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",
20
- "reset",
22
+ "reset",
21
23
  "component",
22
24
  "factory_component",
23
25
  "provides",
24
- "resolve_param",
25
- "create_instance",
26
+ "plugin",
27
+ "Qualifier",
28
+ "qualifier",
29
+ "Resolver",
26
30
  ]
27
31
 
pico_ioc/_state.py CHANGED
@@ -1,8 +1,10 @@
1
1
  # pico_ioc/_state.py
2
2
  from contextvars import ContextVar
3
+ from typing import Optional
3
4
 
4
5
  _scanning: ContextVar[bool] = ContextVar("pico_scanning", default=False)
5
6
  _resolving: ContextVar[bool] = ContextVar("pico_resolving", default=False)
6
7
 
7
8
  _container = None
9
+ _root_name: Optional[str] = None
8
10
 
pico_ioc/_version.py CHANGED
@@ -1 +1 @@
1
- __version__ = '0.6.0'
1
+ __version__ = '1.1.0'
pico_ioc/api.py CHANGED
@@ -1,6 +1,10 @@
1
+ # pico_ioc/api.py
2
+
1
3
  import inspect
2
4
  import logging
3
- from typing import Callable, Optional, Tuple
5
+ from contextlib import contextmanager
6
+ from typing import Callable, Optional, Tuple, Any, Dict # ⬅️ Any, Dict
7
+
4
8
  from .container import PicoContainer, Binder
5
9
  from .plugins import PicoPlugin
6
10
  from .scanner import scan_and_configure
@@ -8,7 +12,9 @@ from . import _state
8
12
 
9
13
 
10
14
  def reset() -> None:
15
+ """Reset the global container."""
11
16
  _state._container = None
17
+ _state._root_name = None
12
18
 
13
19
 
14
20
  def init(
@@ -18,57 +24,117 @@ def init(
18
24
  auto_exclude_caller: bool = True,
19
25
  plugins: Tuple[PicoPlugin, ...] = (),
20
26
  reuse: bool = True,
27
+ overrides: Optional[Dict[Any, Any]] = None, # ⬅️ NUEVO
21
28
  ) -> PicoContainer:
22
- if reuse and _state._container:
29
+
30
+ root_name = root_package if isinstance(root_package, str) else getattr(root_package, "__name__", None)
31
+
32
+ if reuse and _state._container and _state._root_name == root_name:
33
+ if overrides:
34
+ _apply_overrides(_state._container, overrides)
23
35
  return _state._container
24
36
 
25
- combined_exclude = exclude
26
- if auto_exclude_caller:
27
- try:
28
- caller_frame = inspect.stack()[1].frame
29
- caller_module = inspect.getmodule(caller_frame)
30
- caller_name = getattr(caller_module, "__name__", None)
31
- except Exception:
32
- caller_name = None
33
- if caller_name:
34
- if combined_exclude is None:
35
- def combined_exclude(mod: str, _caller=caller_name):
36
- return mod == _caller
37
- else:
38
- prev = combined_exclude
39
- def combined_exclude(mod: str, _caller=caller_name, _prev=prev):
40
- return mod == _caller or _prev(mod)
37
+ combined_exclude = _build_exclude(exclude, auto_exclude_caller, root_name=root_name)
41
38
 
42
39
  container = PicoContainer()
43
40
  binder = Binder(container)
44
41
  logging.info("Initializing pico-ioc...")
45
42
 
46
- tok = _state._scanning.set(True)
47
- try:
48
- scan_and_configure(root_package, container, exclude=combined_exclude, plugins=plugins)
49
- finally:
50
- _state._scanning.reset(tok)
43
+ with _scanning_flag():
44
+ scan_and_configure(
45
+ root_package,
46
+ container,
47
+ exclude=combined_exclude,
48
+ plugins=plugins,
49
+ )
51
50
 
52
- for pl in plugins:
53
- try:
54
- getattr(pl, "after_bind", lambda *a, **k: None)(container, binder)
55
- except Exception:
56
- logging.exception("Plugin after_bind failed")
57
- for pl in plugins:
58
- try:
59
- getattr(pl, "before_eager", lambda *a, **k: None)(container, binder)
60
- except Exception:
61
- logging.exception("Plugin before_eager failed")
51
+ if overrides:
52
+ _apply_overrides(container, overrides)
53
+
54
+ _run_hooks(plugins, "after_bind", container, binder)
55
+ _run_hooks(plugins, "before_eager", container, binder)
62
56
 
63
57
  container.eager_instantiate_all()
64
58
 
65
- for pl in plugins:
66
- try:
67
- getattr(pl, "after_ready", lambda *a, **k: None)(container, binder)
68
- except Exception:
69
- logging.exception("Plugin after_ready failed")
59
+ _run_hooks(plugins, "after_ready", container, binder)
70
60
 
71
61
  logging.info("Container configured and ready.")
72
62
  _state._container = container
63
+ _state._root_name = root_name
73
64
  return container
74
65
 
66
+
67
+ # -------------------- helpers --------------------
68
+
69
+ def _apply_overrides(container: PicoContainer, overrides: Dict[Any, Any]) -> None:
70
+ for key, val in overrides.items():
71
+ lazy = False
72
+ if isinstance(val, tuple) and len(val) == 2 and callable(val[0]) and isinstance(val[1], bool):
73
+ provider = val[0]
74
+ lazy = val[1]
75
+ elif callable(val):
76
+ provider = val
77
+ else:
78
+ def provider(v=val):
79
+ return v
80
+ container.bind(key, provider, lazy=lazy)
81
+
82
+
83
+ def _build_exclude(
84
+ exclude: Optional[Callable[[str], bool]],
85
+ auto_exclude_caller: bool,
86
+ *,
87
+ root_name: Optional[str] = None,
88
+ ) -> Optional[Callable[[str], bool]]:
89
+ if not auto_exclude_caller:
90
+ return exclude
91
+
92
+ caller = _get_caller_module_name()
93
+ if not caller:
94
+ return exclude
95
+
96
+ def _under_root(mod: str) -> bool:
97
+ return bool(root_name) and (mod == root_name or mod.startswith(root_name + "."))
98
+
99
+ if exclude is None:
100
+ return lambda mod, _caller=caller: (mod == _caller) and not _under_root(mod)
101
+
102
+ prev = exclude
103
+ return lambda mod, _caller=caller, _prev=prev: (((mod == _caller) and not _under_root(mod)) or _prev(mod))
104
+
105
+
106
+ def _get_caller_module_name() -> Optional[str]:
107
+ try:
108
+ f = inspect.currentframe()
109
+ # frame -> _get_caller_module_name -> _build_exclude -> init
110
+ if f and f.f_back and f.f_back.f_back and f.f_back.f_back.f_back:
111
+ mod = inspect.getmodule(f.f_back.f_back.f_back)
112
+ return getattr(mod, "__name__", None)
113
+ except Exception:
114
+ pass
115
+ return None
116
+
117
+
118
+ def _run_hooks(
119
+ plugins: Tuple[PicoPlugin, ...],
120
+ hook_name: str,
121
+ container: PicoContainer,
122
+ binder: Binder,
123
+ ) -> None:
124
+ for pl in plugins:
125
+ try:
126
+ fn = getattr(pl, hook_name, None)
127
+ if fn:
128
+ fn(container, binder)
129
+ except Exception:
130
+ logging.exception("Plugin %s failed", hook_name)
131
+
132
+
133
+ @contextmanager
134
+ def _scanning_flag():
135
+ tok = _state._scanning.set(True)
136
+ try:
137
+ yield
138
+ finally:
139
+ _state._scanning.reset(tok)
140
+
pico_ioc/container.py CHANGED
@@ -1,43 +1,158 @@
1
1
  # pico_ioc/container.py
2
+ from __future__ import annotations
3
+ import inspect
4
+ from typing import Any, Dict, get_origin, get_args, Annotated
5
+ import typing as _t
6
+
7
+ from .decorators import QUALIFIERS_KEY
8
+ from . import _state # re-entrancy guard
9
+
10
+
11
+ class Binder:
12
+ def __init__(self, container: "PicoContainer"):
13
+ self._c = container
14
+
15
+ def bind(self, key: Any, provider, *, lazy: bool):
16
+ self._c.bind(key, provider, lazy=lazy)
17
+
18
+ def has(self, key: Any) -> bool:
19
+ return self._c.has(key)
20
+
21
+ def get(self, key: Any):
22
+ return self._c.get(key)
2
23
 
3
- from typing import Any, Callable, Dict
4
- from ._state import _scanning, _resolving
5
24
 
6
25
  class PicoContainer:
7
26
  def __init__(self):
8
27
  self._providers: Dict[Any, Dict[str, Any]] = {}
9
28
  self._singletons: Dict[Any, Any] = {}
10
29
 
11
- def bind(self, key: Any, provider: Callable[[], Any], *, lazy: bool):
12
- self._providers[key] = {"factory": provider, "lazy": bool(lazy)}
30
+ def bind(self, key: Any, provider, *, lazy: bool):
31
+ # 🔧 rebind must evict any cached singleton for this key
32
+ self._singletons.pop(key, None)
33
+
34
+ meta = {"factory": provider, "lazy": bool(lazy)}
35
+ try:
36
+ q = getattr(key, QUALIFIERS_KEY, ())
37
+ except Exception:
38
+ q = ()
39
+ meta["qualifiers"] = tuple(q) if q else ()
40
+ self._providers[key] = meta
13
41
 
14
42
  def has(self, key: Any) -> bool:
15
- return key in self._providers or key in self._singletons
43
+ return key in self._providers
16
44
 
45
+ def get(self, key: Any):
46
+ # block only when scanning and NOT currently resolving a dependency
47
+ if _state._scanning.get() and not _state._resolving.get():
48
+ raise RuntimeError("re-entrant container access during scan")
17
49
 
18
- def get(self, key: Any) -> Any:
19
- if _scanning.get() and not _resolving.get():
20
- raise RuntimeError("pico-ioc: re-entrant container access during scan.")
50
+ prov = self._providers.get(key)
51
+ if prov is None:
52
+ raise NameError(f"No provider found for key {key!r}")
21
53
 
22
54
  if key in self._singletons:
23
55
  return self._singletons[key]
24
- prov = self._providers.get(key)
25
- if prov is None:
26
- raise NameError(f"No provider found for key: {key}")
27
- instance = prov["factory"]()
56
+
57
+ # mark resolving around factory execution
58
+ tok = _state._resolving.set(True)
59
+ try:
60
+ instance = prov["factory"]()
61
+ finally:
62
+ _state._resolving.reset(tok)
63
+
64
+ # memoize always (both lazy and non-lazy after first get)
28
65
  self._singletons[key] = instance
29
66
  return instance
30
67
 
31
-
32
68
  def eager_instantiate_all(self):
33
- for key, meta in list(self._providers.items()):
34
- if not meta.get("lazy", False) and key not in self._singletons:
69
+ for key, prov in list(self._providers.items()):
70
+ if not prov["lazy"]:
35
71
  self.get(key)
36
72
 
37
- class Binder:
38
- def __init__(self, container: PicoContainer):
39
- self._c = container
40
- def bind(self, key, provider, *, lazy=False): self._c.bind(key, provider, lazy=lazy)
41
- def has(self, key) -> bool: return self._c.has(key)
42
- def get(self, key): return self._c.get(key)
73
+ def get_all(self, base_type: Any):
74
+ return tuple(self._resolve_all_for_base(base_type, qualifiers=()))
75
+
76
+ def get_all_qualified(self, base_type: Any, *qualifiers: str):
77
+ return tuple(self._resolve_all_for_base(base_type, qualifiers=qualifiers))
78
+
79
+ def _resolve_all_for_base(self, base_type: Any, qualifiers=()):
80
+ matches = []
81
+ for provider_key, meta in self._providers.items():
82
+ cls = provider_key if isinstance(provider_key, type) else None
83
+ if cls is None:
84
+ continue
85
+
86
+ # Avoid self-inclusion loops: if the class itself requires a collection
87
+ # of `base_type` in its __init__, don't treat it as an implementation
88
+ # of `base_type` when building that collection.
89
+ if _requires_collection_of_base(cls, base_type):
90
+ continue
91
+
92
+ if _is_compatible(cls, base_type):
93
+ prov_qs = meta.get("qualifiers", ())
94
+ if all(q in prov_qs for q in qualifiers):
95
+ inst = self.get(provider_key)
96
+ matches.append(inst)
97
+ return matches
98
+
99
+
100
+ def _is_protocol(t) -> bool:
101
+ return getattr(t, "_is_protocol", False) is True
102
+
103
+
104
+ def _is_compatible(cls, base) -> bool:
105
+ try:
106
+ if isinstance(base, type) and issubclass(cls, base):
107
+ return True
108
+ except TypeError:
109
+ pass
110
+
111
+ if _is_protocol(base):
112
+ # simple structural check: ensure methods/attrs declared on the Protocol exist on the class
113
+ names = set(getattr(base, "__annotations__", {}).keys())
114
+ names.update(n for n in getattr(base, "__dict__", {}).keys() if not n.startswith("_"))
115
+ for n in names:
116
+ if n.startswith("__") and n.endswith("__"):
117
+ continue
118
+ if not hasattr(cls, n):
119
+ return False
120
+ return True
121
+
122
+ return False
123
+
124
+ def _requires_collection_of_base(cls, base) -> bool:
125
+ """
126
+ Return True if `cls.__init__` has any parameter annotated as a collection
127
+ (list/tuple, including Annotated variants) of `base`. This prevents treating
128
+ `cls` as an implementation of `base` while building that collection,
129
+ avoiding recursion.
130
+ """
131
+ try:
132
+ sig = inspect.signature(cls.__init__)
133
+ except Exception:
134
+ return False
135
+
136
+ try:
137
+ from .resolver import _get_hints # type: ignore
138
+ hints = _get_hints(cls.__init__, owner_cls=cls)
139
+ except Exception:
140
+ hints = {}
141
+
142
+ for name, param in sig.parameters.items():
143
+ if name == "self":
144
+ continue
145
+ ann = hints.get(name, param.annotation)
146
+ origin = get_origin(ann) or ann
147
+ if origin in (list, tuple, _t.List, _t.Tuple):
148
+ inner = (get_args(ann) or (object,))[0]
149
+ # Unwrap Annotated[T, ...] si aparece
150
+ if get_origin(inner) is Annotated:
151
+ args = get_args(inner)
152
+ if args:
153
+ inner = args[0]
154
+ if inner is base:
155
+ return True
156
+ return False
157
+
43
158
 
pico_ioc/decorators.py CHANGED
@@ -1,28 +1,36 @@
1
1
  # pico_ioc/decorators.py
2
-
2
+ from __future__ import annotations
3
3
  import functools
4
- from typing import Any
4
+ from typing import Any, Iterable
5
5
 
6
6
  COMPONENT_FLAG = "_is_component"
7
7
  COMPONENT_KEY = "_component_key"
8
8
  COMPONENT_LAZY = "_component_lazy"
9
+
9
10
  FACTORY_FLAG = "_is_factory_component"
10
11
  PROVIDES_KEY = "_provides_name"
11
12
  PROVIDES_LAZY = "_pico_lazy"
12
13
 
14
+ PLUGIN_FLAG = "_is_pico_plugin"
15
+ QUALIFIERS_KEY = "_pico_qualifiers"
16
+
17
+
13
18
  def factory_component(cls):
14
19
  setattr(cls, FACTORY_FLAG, True)
15
20
  return cls
16
21
 
22
+
17
23
  def provides(key: Any, *, lazy: bool = False):
18
- def dec(func):
19
- @functools.wraps(func)
20
- def w(*a, **k): return func(*a, **k)
24
+ def dec(fn):
25
+ @functools.wraps(fn)
26
+ def w(*a, **k):
27
+ return fn(*a, **k)
21
28
  setattr(w, PROVIDES_KEY, key)
22
29
  setattr(w, PROVIDES_LAZY, bool(lazy))
23
30
  return w
24
31
  return dec
25
32
 
33
+
26
34
  def component(cls=None, *, name: Any = None, lazy: bool = False):
27
35
  def dec(c):
28
36
  setattr(c, COMPONENT_FLAG, True)
@@ -31,3 +39,38 @@ def component(cls=None, *, name: Any = None, lazy: bool = False):
31
39
  return c
32
40
  return dec(cls) if cls else dec
33
41
 
42
+
43
+ def plugin(cls):
44
+ setattr(cls, PLUGIN_FLAG, True)
45
+ return cls
46
+
47
+
48
+ class Qualifier(str):
49
+ __slots__ = () # tiny memory win; immutable like str
50
+
51
+
52
+ def qualifier(*qs: Qualifier):
53
+ def dec(cls):
54
+ current: Iterable[Qualifier] = getattr(cls, QUALIFIERS_KEY, ())
55
+ seen = set(current)
56
+ merged = list(current)
57
+ for q in qs:
58
+ if q not in seen:
59
+ merged.append(q)
60
+ seen.add(q)
61
+ setattr(cls, QUALIFIERS_KEY, tuple(merged))
62
+ return cls
63
+ return dec
64
+
65
+
66
+ __all__ = [
67
+ # decorators
68
+ "component", "factory_component", "provides", "plugin", "qualifier",
69
+ # qualifier type
70
+ "Qualifier",
71
+ # metadata keys (exported for advanced use/testing)
72
+ "COMPONENT_FLAG", "COMPONENT_KEY", "COMPONENT_LAZY",
73
+ "FACTORY_FLAG", "PROVIDES_KEY", "PROVIDES_LAZY",
74
+ "PLUGIN_FLAG", "QUALIFIERS_KEY",
75
+ ]
76
+
pico_ioc/public_api.py ADDED
@@ -0,0 +1,76 @@
1
+ # pico_ioc/public_api.py
2
+
3
+ from __future__ import annotations
4
+ import importlib
5
+ import inspect
6
+ import pkgutil
7
+ import sys
8
+ from types import ModuleType
9
+ from typing import Dict, Iterable, Optional, Tuple
10
+
11
+ from .decorators import COMPONENT_FLAG, FACTORY_FLAG, PLUGIN_FLAG
12
+
13
+
14
+ def export_public_symbols_decorated(
15
+ *packages: str,
16
+ include_also: Optional[Iterable[str]] = None,
17
+ include_plugins: bool = True,
18
+ ):
19
+ index: Dict[str, Tuple[str, str]] = {}
20
+
21
+ def _collect(m: ModuleType):
22
+ names = getattr(m, "__all__", None)
23
+ if isinstance(names, (list, tuple, set)):
24
+ for n in names:
25
+ if hasattr(m, n):
26
+ index.setdefault(n, (m.__name__, n))
27
+ return
28
+
29
+ for n, obj in m.__dict__.items():
30
+ if not inspect.isclass(obj):
31
+ continue
32
+ is_component = getattr(obj, COMPONENT_FLAG, False)
33
+ is_factory = getattr(obj, FACTORY_FLAG, False)
34
+ is_plugin = include_plugins and getattr(obj, PLUGIN_FLAG, False)
35
+ if is_component or is_factory or is_plugin:
36
+ index.setdefault(n, (m.__name__, n))
37
+
38
+ for pkg_name in packages:
39
+ try:
40
+ base = importlib.import_module(pkg_name)
41
+ except Exception:
42
+ continue
43
+ if hasattr(base, "__path__"):
44
+ prefix = base.__name__ + "."
45
+ for _, modname, _ in pkgutil.walk_packages(base.__path__, prefix):
46
+ try:
47
+ m = importlib.import_module(modname)
48
+ except Exception:
49
+ continue
50
+ _collect(m)
51
+ else:
52
+ _collect(base)
53
+
54
+ for qual in tuple(include_also or ()):
55
+ modname, _, attr = qual.partition(":")
56
+ if modname and attr:
57
+ try:
58
+ m = importlib.import_module(modname)
59
+ if hasattr(m, attr):
60
+ index.setdefault(attr, (m.__name__, attr))
61
+ except Exception:
62
+ pass
63
+
64
+ def __getattr__(name: str):
65
+ try:
66
+ modname, attr = index[name]
67
+ except KeyError as e:
68
+ raise AttributeError(f"module has no attribute {name!r}") from e
69
+ mod = sys.modules.get(modname) or importlib.import_module(modname)
70
+ return getattr(mod, attr)
71
+
72
+ def __dir__():
73
+ return sorted(index.keys())
74
+
75
+ return __getattr__, __dir__
76
+