pico-ioc 0.6.0__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,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.0.0'
pico_ioc/api.py CHANGED
@@ -1,6 +1,10 @@
1
+ # pico_ioc/api.py
2
+
1
3
  import inspect
2
4
  import logging
5
+ from contextlib import contextmanager
3
6
  from typing import Callable, Optional, Tuple
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(
@@ -19,56 +25,104 @@ def init(
19
25
  plugins: Tuple[PicoPlugin, ...] = (),
20
26
  reuse: bool = True,
21
27
  ) -> PicoContainer:
22
- if reuse and _state._container:
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:
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
+ _run_hooks(plugins, "after_bind", container, binder)
52
+ _run_hooks(plugins, "before_eager", container, binder)
62
53
 
63
54
  container.eager_instantiate_all()
64
55
 
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")
56
+ _run_hooks(plugins, "after_ready", container, binder)
70
57
 
71
58
  logging.info("Container configured and ready.")
72
59
  _state._container = container
60
+ _state._root_name = root_name # remember which root this container represents
73
61
  return container
74
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
+
pico_ioc/container.py CHANGED
@@ -1,43 +1,155 @@
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
+ meta = {"factory": provider, "lazy": bool(lazy)}
32
+ try:
33
+ q = getattr(key, QUALIFIERS_KEY, ())
34
+ except Exception:
35
+ q = ()
36
+ meta["qualifiers"] = tuple(q) if q else ()
37
+ self._providers[key] = meta
13
38
 
14
39
  def has(self, key: Any) -> bool:
15
- return key in self._providers or key in self._singletons
40
+ return key in self._providers
16
41
 
42
+ def get(self, key: Any):
43
+ # block only when scanning and NOT currently resolving a dependency
44
+ if _state._scanning.get() and not _state._resolving.get():
45
+ raise RuntimeError("re-entrant container access during scan")
17
46
 
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.")
47
+ prov = self._providers.get(key)
48
+ if prov is None:
49
+ raise NameError(f"No provider found for key {key!r}")
21
50
 
22
51
  if key in self._singletons:
23
52
  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"]()
53
+
54
+ # mark resolving around factory execution
55
+ tok = _state._resolving.set(True)
56
+ try:
57
+ instance = prov["factory"]()
58
+ finally:
59
+ _state._resolving.reset(tok)
60
+
61
+ # memoize always (both lazy and non-lazy after first get)
28
62
  self._singletons[key] = instance
29
63
  return instance
30
64
 
31
-
32
65
  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:
66
+ for key, prov in list(self._providers.items()):
67
+ if not prov["lazy"]:
35
68
  self.get(key)
36
69
 
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)
70
+ def get_all(self, base_type: Any):
71
+ return tuple(self._resolve_all_for_base(base_type, qualifiers=()))
72
+
73
+ def get_all_qualified(self, base_type: Any, *qualifiers: str):
74
+ return tuple(self._resolve_all_for_base(base_type, qualifiers=qualifiers))
75
+
76
+ def _resolve_all_for_base(self, base_type: Any, qualifiers=()):
77
+ matches = []
78
+ for provider_key, meta in self._providers.items():
79
+ cls = provider_key if isinstance(provider_key, type) else None
80
+ if cls is None:
81
+ continue
82
+
83
+ # Avoid self-inclusion loops: if the class itself requires a collection
84
+ # of `base_type` in its __init__, don't treat it as an implementation
85
+ # of `base_type` when building that collection.
86
+ if _requires_collection_of_base(cls, base_type):
87
+ continue
88
+
89
+ if _is_compatible(cls, base_type):
90
+ prov_qs = meta.get("qualifiers", ())
91
+ if all(q in prov_qs for q in qualifiers):
92
+ inst = self.get(provider_key)
93
+ matches.append(inst)
94
+ return matches
95
+
96
+
97
+ def _is_protocol(t) -> bool:
98
+ return getattr(t, "_is_protocol", False) is True
99
+
100
+
101
+ def _is_compatible(cls, base) -> bool:
102
+ try:
103
+ if isinstance(base, type) and issubclass(cls, base):
104
+ return True
105
+ except TypeError:
106
+ pass
107
+
108
+ if _is_protocol(base):
109
+ # simple structural check: ensure methods/attrs declared on the Protocol exist on the class
110
+ names = set(getattr(base, "__annotations__", {}).keys())
111
+ names.update(n for n in getattr(base, "__dict__", {}).keys() if not n.startswith("_"))
112
+ for n in names:
113
+ if n.startswith("__") and n.endswith("__"):
114
+ continue
115
+ if not hasattr(cls, n):
116
+ return False
117
+ return True
118
+
119
+ return False
120
+
121
+ def _requires_collection_of_base(cls, base) -> bool:
122
+ """
123
+ Return True if `cls.__init__` has any parameter annotated as a collection
124
+ (list/tuple, including Annotated variants) of `base`. This prevents treating
125
+ `cls` as an implementation of `base` while building that collection,
126
+ avoiding recursion.
127
+ """
128
+ try:
129
+ sig = inspect.signature(cls.__init__)
130
+ except Exception:
131
+ return False
132
+
133
+ try:
134
+ from .resolver import _get_hints # type: ignore
135
+ hints = _get_hints(cls.__init__, owner_cls=cls)
136
+ except Exception:
137
+ hints = {}
138
+
139
+ for name, param in sig.parameters.items():
140
+ if name == "self":
141
+ continue
142
+ ann = hints.get(name, param.annotation)
143
+ origin = get_origin(ann) or ann
144
+ if origin in (list, tuple, _t.List, _t.Tuple):
145
+ inner = (get_args(ann) or (object,))[0]
146
+ # Unwrap Annotated[T, ...] si aparece
147
+ if get_origin(inner) is Annotated:
148
+ args = get_args(inner)
149
+ if args:
150
+ inner = args[0]
151
+ if inner is base:
152
+ return True
153
+ return False
154
+
43
155
 
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
+