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 +30 -4
- pico_ioc/_state.py +69 -4
- pico_ioc/_version.py +1 -1
- pico_ioc/api.py +183 -251
- pico_ioc/builder.py +294 -0
- pico_ioc/config.py +332 -0
- pico_ioc/container.py +73 -26
- pico_ioc/decorators.py +88 -9
- pico_ioc/interceptors.py +56 -0
- pico_ioc/plugins.py +17 -1
- pico_ioc/policy.py +245 -0
- pico_ioc/proxy.py +59 -7
- pico_ioc/resolver.py +54 -46
- pico_ioc/scanner.py +75 -102
- pico_ioc/scope.py +46 -0
- pico_ioc/utils.py +25 -0
- {pico_ioc-1.2.0.dist-info → pico_ioc-1.4.0.dist-info}/METADATA +65 -16
- pico_ioc-1.4.0.dist-info/RECORD +22 -0
- pico_ioc/typing_utils.py +0 -29
- pico_ioc-1.2.0.dist-info/RECORD +0 -17
- {pico_ioc-1.2.0.dist-info → pico_ioc-1.4.0.dist-info}/WHEEL +0 -0
- {pico_ioc-1.2.0.dist-info → pico_ioc-1.4.0.dist-info}/licenses/LICENSE +0 -0
- {pico_ioc-1.2.0.dist-info → pico_ioc-1.4.0.dist-info}/top_level.txt +0 -0
pico_ioc/container.py
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
1
|
-
# pico_ioc/container.py
|
|
1
|
+
# src/pico_ioc/container.py
|
|
2
2
|
from __future__ import annotations
|
|
3
|
+
|
|
3
4
|
import inspect
|
|
4
5
|
from typing import Any, Dict, get_origin, get_args, Annotated
|
|
5
|
-
import typing as _t
|
|
6
|
+
import typing as _t
|
|
6
7
|
|
|
8
|
+
from .proxy import IoCProxy
|
|
9
|
+
from .interceptors import MethodInterceptor, ContainerInterceptor
|
|
7
10
|
from .decorators import QUALIFIERS_KEY
|
|
8
|
-
from . import _state
|
|
11
|
+
from . import _state
|
|
9
12
|
|
|
10
13
|
|
|
11
14
|
class Binder:
|
|
12
|
-
def __init__(self, container:
|
|
15
|
+
def __init__(self, container: PicoContainer):
|
|
13
16
|
self._c = container
|
|
14
17
|
|
|
15
18
|
def bind(self, key: Any, provider, *, lazy: bool, tags: tuple[str, ...] = ()):
|
|
@@ -23,14 +26,38 @@ class Binder:
|
|
|
23
26
|
|
|
24
27
|
|
|
25
28
|
class PicoContainer:
|
|
26
|
-
def __init__(self):
|
|
27
|
-
self._providers
|
|
29
|
+
def __init__(self, providers: Dict[Any, Dict[str, Any]] | None = None):
|
|
30
|
+
self._providers = providers or {}
|
|
28
31
|
self._singletons: Dict[Any, Any] = {}
|
|
32
|
+
self._method_interceptors: tuple[MethodInterceptor, ...] = ()
|
|
33
|
+
self._container_interceptors: tuple[ContainerInterceptor, ...] = ()
|
|
34
|
+
self._active_profiles: tuple[str, ...] = ()
|
|
35
|
+
self._seen_interceptor_types: set[type] = set()
|
|
36
|
+
|
|
37
|
+
# --- interceptors ---
|
|
38
|
+
|
|
39
|
+
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
|
+
self._method_interceptors = self._method_interceptors + (it,)
|
|
45
|
+
|
|
46
|
+
def add_container_interceptor(self, it: ContainerInterceptor) -> None:
|
|
47
|
+
t = type(it)
|
|
48
|
+
if t in self._seen_interceptor_types:
|
|
49
|
+
return
|
|
50
|
+
self._seen_interceptor_types.add(t)
|
|
51
|
+
self._container_interceptors = self._container_interceptors + (it,)
|
|
52
|
+
|
|
53
|
+
# --- binding ---
|
|
54
|
+
|
|
55
|
+
def binder(self) -> Binder:
|
|
56
|
+
return Binder(self)
|
|
29
57
|
|
|
30
58
|
def bind(self, key: Any, provider, *, lazy: bool, tags: tuple[str, ...] = ()):
|
|
31
59
|
self._singletons.pop(key, None)
|
|
32
60
|
meta = {"factory": provider, "lazy": bool(lazy)}
|
|
33
|
-
# qualifiers already present:
|
|
34
61
|
try:
|
|
35
62
|
q = getattr(key, QUALIFIERS_KEY, ())
|
|
36
63
|
except Exception:
|
|
@@ -39,11 +66,12 @@ class PicoContainer:
|
|
|
39
66
|
meta["tags"] = tuple(tags) if tags else ()
|
|
40
67
|
self._providers[key] = meta
|
|
41
68
|
|
|
69
|
+
# --- resolution ---
|
|
70
|
+
|
|
42
71
|
def has(self, key: Any) -> bool:
|
|
43
72
|
return key in self._providers
|
|
44
73
|
|
|
45
74
|
def get(self, key: Any):
|
|
46
|
-
# block only when scanning and NOT currently resolving a dependency
|
|
47
75
|
if _state._scanning.get() and not _state._resolving.get():
|
|
48
76
|
raise RuntimeError("re-entrant container access during scan")
|
|
49
77
|
|
|
@@ -54,22 +82,49 @@ class PicoContainer:
|
|
|
54
82
|
if key in self._singletons:
|
|
55
83
|
return self._singletons[key]
|
|
56
84
|
|
|
57
|
-
|
|
85
|
+
for ci in self._container_interceptors:
|
|
86
|
+
try:
|
|
87
|
+
ci.on_before_create(key)
|
|
88
|
+
except Exception:
|
|
89
|
+
pass
|
|
90
|
+
|
|
58
91
|
tok = _state._resolving.set(True)
|
|
59
92
|
try:
|
|
60
|
-
|
|
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
|
|
61
102
|
finally:
|
|
62
103
|
_state._resolving.reset(tok)
|
|
63
104
|
|
|
64
|
-
|
|
105
|
+
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
|
+
|
|
65
116
|
self._singletons[key] = instance
|
|
66
117
|
return instance
|
|
67
118
|
|
|
119
|
+
# --- lifecycle ---
|
|
120
|
+
|
|
68
121
|
def eager_instantiate_all(self):
|
|
69
122
|
for key, prov in list(self._providers.items()):
|
|
70
123
|
if not prov["lazy"]:
|
|
71
124
|
self.get(key)
|
|
72
125
|
|
|
126
|
+
# --- helpers for multiples ---
|
|
127
|
+
|
|
73
128
|
def get_all(self, base_type: Any):
|
|
74
129
|
return tuple(self._resolve_all_for_base(base_type, qualifiers=()))
|
|
75
130
|
|
|
@@ -82,13 +137,8 @@ class PicoContainer:
|
|
|
82
137
|
cls = provider_key if isinstance(provider_key, type) else None
|
|
83
138
|
if cls is None:
|
|
84
139
|
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
140
|
if _requires_collection_of_base(cls, base_type):
|
|
90
141
|
continue
|
|
91
|
-
|
|
92
142
|
if _is_compatible(cls, base_type):
|
|
93
143
|
prov_qs = meta.get("qualifiers", ())
|
|
94
144
|
if all(q in prov_qs for q in qualifiers):
|
|
@@ -96,6 +146,11 @@ class PicoContainer:
|
|
|
96
146
|
matches.append(inst)
|
|
97
147
|
return matches
|
|
98
148
|
|
|
149
|
+
def get_providers(self) -> Dict[Any, Dict]:
|
|
150
|
+
return self._providers.copy()
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# --- compatibility helpers ---
|
|
99
154
|
|
|
100
155
|
def _is_protocol(t) -> bool:
|
|
101
156
|
return getattr(t, "_is_protocol", False) is True
|
|
@@ -109,7 +164,6 @@ def _is_compatible(cls, base) -> bool:
|
|
|
109
164
|
pass
|
|
110
165
|
|
|
111
166
|
if _is_protocol(base):
|
|
112
|
-
# simple structural check: ensure methods/attrs declared on the Protocol exist on the class
|
|
113
167
|
names = set(getattr(base, "__annotations__", {}).keys())
|
|
114
168
|
names.update(n for n in getattr(base, "__dict__", {}).keys() if not n.startswith("_"))
|
|
115
169
|
for n in names:
|
|
@@ -121,20 +175,15 @@ def _is_compatible(cls, base) -> bool:
|
|
|
121
175
|
|
|
122
176
|
return False
|
|
123
177
|
|
|
178
|
+
|
|
124
179
|
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
180
|
try:
|
|
132
181
|
sig = inspect.signature(cls.__init__)
|
|
133
182
|
except Exception:
|
|
134
183
|
return False
|
|
135
184
|
|
|
136
185
|
try:
|
|
137
|
-
from .resolver import _get_hints
|
|
186
|
+
from .resolver import _get_hints
|
|
138
187
|
hints = _get_hints(cls.__init__, owner_cls=cls)
|
|
139
188
|
except Exception:
|
|
140
189
|
hints = {}
|
|
@@ -146,7 +195,6 @@ def _requires_collection_of_base(cls, base) -> bool:
|
|
|
146
195
|
origin = get_origin(ann) or ann
|
|
147
196
|
if origin in (list, tuple, _t.List, _t.Tuple):
|
|
148
197
|
inner = (get_args(ann) or (object,))[0]
|
|
149
|
-
# Unwrap Annotated[T, ...] si aparece
|
|
150
198
|
if get_origin(inner) is Annotated:
|
|
151
199
|
args = get_args(inner)
|
|
152
200
|
if args:
|
|
@@ -155,4 +203,3 @@ def _requires_collection_of_base(cls, base) -> bool:
|
|
|
155
203
|
return True
|
|
156
204
|
return False
|
|
157
205
|
|
|
158
|
-
|
pico_ioc/decorators.py
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
|
-
# pico_ioc/decorators.py
|
|
1
|
+
# src/pico_ioc/decorators.py
|
|
2
2
|
from __future__ import annotations
|
|
3
|
+
|
|
3
4
|
import functools
|
|
4
|
-
from typing import Any, Iterable
|
|
5
|
+
from typing import Any, Iterable, Optional, Callable, Tuple, Literal
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
# ---- marker attributes (read by scanner/policy) ----
|
|
5
9
|
|
|
6
10
|
COMPONENT_FLAG = "_is_component"
|
|
7
11
|
COMPONENT_KEY = "_component_key"
|
|
@@ -17,12 +21,23 @@ QUALIFIERS_KEY = "_pico_qualifiers"
|
|
|
17
21
|
COMPONENT_TAGS = "_pico_tags"
|
|
18
22
|
PROVIDES_TAGS = "_pico_tags"
|
|
19
23
|
|
|
24
|
+
ON_MISSING_META = "_pico_on_missing"
|
|
25
|
+
PRIMARY_FLAG = "_pico_primary"
|
|
26
|
+
CONDITIONAL_META = "_pico_conditional"
|
|
27
|
+
|
|
28
|
+
INTERCEPTOR_META = "__pico_interceptor__"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# ---- core decorators ----
|
|
32
|
+
|
|
20
33
|
def factory_component(cls):
|
|
34
|
+
"""Mark a class as a factory component (its methods can @provides)."""
|
|
21
35
|
setattr(cls, FACTORY_FLAG, True)
|
|
22
36
|
return cls
|
|
23
37
|
|
|
24
38
|
|
|
25
39
|
def component(cls=None, *, name: Any = None, lazy: bool = False, tags: Iterable[str] = ()):
|
|
40
|
+
"""Mark a class as a component. Optional: custom key, lazy instantiation, tags."""
|
|
26
41
|
def dec(c):
|
|
27
42
|
setattr(c, COMPONENT_FLAG, True)
|
|
28
43
|
setattr(c, COMPONENT_KEY, name if name is not None else c)
|
|
@@ -31,7 +46,9 @@ def component(cls=None, *, name: Any = None, lazy: bool = False, tags: Iterable[
|
|
|
31
46
|
return c
|
|
32
47
|
return dec(cls) if cls else dec
|
|
33
48
|
|
|
49
|
+
|
|
34
50
|
def provides(key: Any, *, lazy: bool = False, tags: Iterable[str] = ()):
|
|
51
|
+
"""Declare a factory method that provides a binding for `key`."""
|
|
35
52
|
def dec(fn):
|
|
36
53
|
@functools.wraps(fn)
|
|
37
54
|
def w(*a, **k):
|
|
@@ -44,15 +61,20 @@ def provides(key: Any, *, lazy: bool = False, tags: Iterable[str] = ()):
|
|
|
44
61
|
|
|
45
62
|
|
|
46
63
|
def plugin(cls):
|
|
64
|
+
"""Mark a class as a Pico plugin (scanner lifecycle)."""
|
|
47
65
|
setattr(cls, PLUGIN_FLAG, True)
|
|
48
66
|
return cls
|
|
49
67
|
|
|
50
68
|
|
|
69
|
+
# ---- qualifiers ----
|
|
70
|
+
|
|
51
71
|
class Qualifier(str):
|
|
52
|
-
|
|
72
|
+
"""String qualifier type used with Annotated[T, 'q1', ...]."""
|
|
73
|
+
__slots__ = ()
|
|
53
74
|
|
|
54
75
|
|
|
55
76
|
def qualifier(*qs: Qualifier):
|
|
77
|
+
"""Attach one or more qualifiers to a component class key."""
|
|
56
78
|
def dec(cls):
|
|
57
79
|
current: Iterable[Qualifier] = getattr(cls, QUALIFIERS_KEY, ())
|
|
58
80
|
seen = set(current)
|
|
@@ -66,14 +88,71 @@ def qualifier(*qs: Qualifier):
|
|
|
66
88
|
return dec
|
|
67
89
|
|
|
68
90
|
|
|
91
|
+
# ---- defaults / selection ----
|
|
92
|
+
|
|
93
|
+
def on_missing(selector: object, *, priority: int = 0):
|
|
94
|
+
"""Declare this target as a default for `selector` when no binding exists."""
|
|
95
|
+
def dec(obj):
|
|
96
|
+
setattr(obj, ON_MISSING_META, {"selector": selector, "priority": int(priority)})
|
|
97
|
+
return obj
|
|
98
|
+
return dec
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def primary(obj):
|
|
102
|
+
"""Hint this candidate should be preferred among equals."""
|
|
103
|
+
setattr(obj, PRIMARY_FLAG, True)
|
|
104
|
+
return obj
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def conditional(
|
|
108
|
+
*,
|
|
109
|
+
profiles: Tuple[str, ...] = (),
|
|
110
|
+
require_env: Tuple[str, ...] = (),
|
|
111
|
+
predicate: Optional[Callable[[], bool]] = None,
|
|
112
|
+
):
|
|
113
|
+
"""Activate only when profiles/env/predicate conditions pass."""
|
|
114
|
+
def dec(obj):
|
|
115
|
+
setattr(obj, CONDITIONAL_META, {
|
|
116
|
+
"profiles": tuple(profiles),
|
|
117
|
+
"require_env": tuple(require_env),
|
|
118
|
+
"predicate": predicate,
|
|
119
|
+
})
|
|
120
|
+
return obj
|
|
121
|
+
return dec
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# ---- interceptors ----
|
|
125
|
+
|
|
126
|
+
def interceptor(
|
|
127
|
+
_obj=None,
|
|
128
|
+
*,
|
|
129
|
+
kind: Literal["method", "container"] = "method",
|
|
130
|
+
order: int = 0,
|
|
131
|
+
profiles: Tuple[str, ...] = (),
|
|
132
|
+
require_env: Tuple[str, ...] = (),
|
|
133
|
+
predicate: Callable[[], bool] | None = None,
|
|
134
|
+
):
|
|
135
|
+
"""Declare an interceptor (method or container) with optional activation metadata."""
|
|
136
|
+
def dec(obj):
|
|
137
|
+
setattr(obj, INTERCEPTOR_META, {
|
|
138
|
+
"kind": kind,
|
|
139
|
+
"order": int(order),
|
|
140
|
+
"profiles": tuple(profiles),
|
|
141
|
+
"require_env": tuple(require_env),
|
|
142
|
+
"predicate": predicate,
|
|
143
|
+
})
|
|
144
|
+
return obj
|
|
145
|
+
return dec if _obj is None else dec(_obj)
|
|
146
|
+
|
|
147
|
+
|
|
69
148
|
__all__ = [
|
|
70
|
-
|
|
71
|
-
"
|
|
72
|
-
|
|
73
|
-
"Qualifier",
|
|
74
|
-
# metadata keys (exported for advanced use/testing)
|
|
149
|
+
"component", "factory_component", "provides", "plugin",
|
|
150
|
+
"Qualifier", "qualifier",
|
|
151
|
+
"on_missing", "primary", "conditional", "interceptor",
|
|
75
152
|
"COMPONENT_FLAG", "COMPONENT_KEY", "COMPONENT_LAZY",
|
|
76
153
|
"FACTORY_FLAG", "PROVIDES_KEY", "PROVIDES_LAZY",
|
|
77
|
-
"PLUGIN_FLAG", "QUALIFIERS_KEY", "COMPONENT_TAGS", "PROVIDES_TAGS"
|
|
154
|
+
"PLUGIN_FLAG", "QUALIFIERS_KEY", "COMPONENT_TAGS", "PROVIDES_TAGS",
|
|
155
|
+
"ON_MISSING_META", "PRIMARY_FLAG", "CONDITIONAL_META",
|
|
156
|
+
"INTERCEPTOR_META",
|
|
78
157
|
]
|
|
79
158
|
|
pico_ioc/interceptors.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# src/pico_ioc/interceptors.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import inspect
|
|
5
|
+
from typing import Any, Callable, Protocol, Sequence
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Invocation:
|
|
9
|
+
__slots__ = ("target", "method_name", "call", "args", "kwargs", "is_async")
|
|
10
|
+
|
|
11
|
+
def __init__(self, target: object, method_name: str, call: Callable[..., Any],
|
|
12
|
+
args: tuple, kwargs: dict):
|
|
13
|
+
self.target = target
|
|
14
|
+
self.method_name = method_name
|
|
15
|
+
self.call = call
|
|
16
|
+
self.args = args
|
|
17
|
+
self.kwargs = kwargs
|
|
18
|
+
self.is_async = inspect.iscoroutinefunction(call)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class MethodInterceptor(Protocol):
|
|
22
|
+
def __call__(self, inv: Invocation, proceed: Callable[[], Any]) -> Any: ...
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
async def _chain_async(interceptors: Sequence[MethodInterceptor], inv: Invocation, i: int = 0):
|
|
26
|
+
if i >= len(interceptors):
|
|
27
|
+
return await inv.call(*inv.args, **inv.kwargs)
|
|
28
|
+
cur = interceptors[i]
|
|
29
|
+
|
|
30
|
+
async def next_step():
|
|
31
|
+
return await _chain_async(interceptors, inv, i + 1)
|
|
32
|
+
|
|
33
|
+
res = cur(inv, next_step)
|
|
34
|
+
return await res if inspect.isawaitable(res) else res
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _chain_sync(interceptors: Sequence[MethodInterceptor], inv: Invocation, i: int = 0):
|
|
38
|
+
if i >= len(interceptors):
|
|
39
|
+
return inv.call(*inv.args, **inv.kwargs)
|
|
40
|
+
cur = interceptors[i]
|
|
41
|
+
return cur(inv, lambda: _chain_sync(interceptors, inv, i + 1))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def dispatch(interceptors: Sequence[MethodInterceptor], inv: Invocation):
|
|
45
|
+
"""Dispatch invocation through a chain of interceptors."""
|
|
46
|
+
if inv.is_async:
|
|
47
|
+
return _chain_async(interceptors, inv, 0) # coroutine
|
|
48
|
+
return _chain_sync(interceptors, inv, 0) # value
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ContainerInterceptor(Protocol):
|
|
52
|
+
def on_resolve(self, key: Any, annotation: Any, qualifiers: tuple[str, ...] | tuple()) -> None: ...
|
|
53
|
+
def on_before_create(self, key: Any) -> None: ...
|
|
54
|
+
def on_after_create(self, key: Any, instance: Any) -> Any: ...
|
|
55
|
+
def on_exception(self, key: Any, exc: BaseException) -> None: ...
|
|
56
|
+
|
pico_ioc/plugins.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# pico_ioc/plugins.py
|
|
2
|
-
from typing import Protocol, Any
|
|
2
|
+
from typing import Protocol, Any, Tuple
|
|
3
3
|
from .container import Binder, PicoContainer
|
|
4
|
+
import logging
|
|
4
5
|
|
|
5
6
|
class PicoPlugin(Protocol):
|
|
6
7
|
def before_scan(self, package: Any, binder: Binder) -> None: ...
|
|
@@ -10,3 +11,18 @@ class PicoPlugin(Protocol):
|
|
|
10
11
|
def before_eager(self, container: PicoContainer, binder: Binder) -> None: ...
|
|
11
12
|
def after_ready(self, container: PicoContainer, binder: Binder) -> None: ...
|
|
12
13
|
|
|
14
|
+
def run_plugin_hook(
|
|
15
|
+
plugins: Tuple[PicoPlugin, ...],
|
|
16
|
+
hook_name: str,
|
|
17
|
+
*args,
|
|
18
|
+
**kwargs,
|
|
19
|
+
) -> None:
|
|
20
|
+
"""Run a lifecycle hook across all plugins, logging (but not raising) exceptions."""
|
|
21
|
+
for pl in plugins:
|
|
22
|
+
try:
|
|
23
|
+
fn = getattr(pl, hook_name, None)
|
|
24
|
+
if fn:
|
|
25
|
+
fn(*args, **kwargs)
|
|
26
|
+
except Exception:
|
|
27
|
+
logging.exception("Plugin %s failed", hook_name)
|
|
28
|
+
|
pico_ioc/policy.py
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
# src/pico_ioc/policy.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import inspect
|
|
5
|
+
import os
|
|
6
|
+
from collections import defaultdict
|
|
7
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
8
|
+
|
|
9
|
+
from .utils import create_alias_provider
|
|
10
|
+
from .decorators import CONDITIONAL_META, PRIMARY_FLAG, ON_MISSING_META
|
|
11
|
+
from . import _state
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# ------------------- helpers -------------------
|
|
15
|
+
|
|
16
|
+
def _target_from_provider(provider):
|
|
17
|
+
"""Try to resolve the 'real' target behind a provider closure (class, function or bound method)."""
|
|
18
|
+
fn = provider
|
|
19
|
+
try:
|
|
20
|
+
cells = getattr(fn, "__closure__", None) or ()
|
|
21
|
+
first_func, first_cls = None, None
|
|
22
|
+
for cell in cells:
|
|
23
|
+
cc = getattr(cell, "cell_contents", None)
|
|
24
|
+
if inspect.ismethod(cc):
|
|
25
|
+
return cc
|
|
26
|
+
if first_func is None and inspect.isfunction(cc):
|
|
27
|
+
first_func = cc
|
|
28
|
+
elif first_cls is None and inspect.isclass(cc):
|
|
29
|
+
first_cls = cc
|
|
30
|
+
return first_func or first_cls or fn
|
|
31
|
+
except Exception:
|
|
32
|
+
return fn
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _owner_func(obj):
|
|
36
|
+
"""If obj is a bound method, return the unbound function on its owner class."""
|
|
37
|
+
try:
|
|
38
|
+
if inspect.ismethod(obj) and getattr(obj, "__self__", None) is not None:
|
|
39
|
+
owner = obj.__self__.__class__
|
|
40
|
+
name = getattr(obj, "__name__", None)
|
|
41
|
+
if name and hasattr(owner, name):
|
|
42
|
+
cand = getattr(owner, name)
|
|
43
|
+
if inspect.isfunction(cand):
|
|
44
|
+
return cand
|
|
45
|
+
except Exception:
|
|
46
|
+
pass
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _find_attribute_on_target(target: Any, attr_name: str) -> Any:
|
|
51
|
+
"""Look for metadata on object, its underlying function, or owner class method."""
|
|
52
|
+
val = getattr(target, attr_name, None)
|
|
53
|
+
if val is not None:
|
|
54
|
+
return val
|
|
55
|
+
base_func = getattr(target, "__func__", None)
|
|
56
|
+
if base_func:
|
|
57
|
+
val = getattr(base_func, attr_name, None)
|
|
58
|
+
if val is not None:
|
|
59
|
+
return val
|
|
60
|
+
of = _owner_func(target)
|
|
61
|
+
if of:
|
|
62
|
+
val = getattr(of, attr_name, None)
|
|
63
|
+
if val is not None:
|
|
64
|
+
return val
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _has_flag(obj, flag_name: str) -> bool:
|
|
69
|
+
return bool(_find_attribute_on_target(obj, flag_name))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _get_meta(obj, meta_name: str) -> Any:
|
|
73
|
+
return _find_attribute_on_target(obj, meta_name)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _on_missing_meta(target):
|
|
77
|
+
"""Normalize @on_missing metadata."""
|
|
78
|
+
meta = _get_meta(target, ON_MISSING_META)
|
|
79
|
+
if not meta:
|
|
80
|
+
return None
|
|
81
|
+
return (meta.get("selector"), int(meta.get("priority", 0)))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _conditional_active(target, *, profiles: List[str]) -> bool:
|
|
85
|
+
"""Check if target is active given profiles/env/predicate."""
|
|
86
|
+
meta = _get_meta(target, CONDITIONAL_META)
|
|
87
|
+
if not meta:
|
|
88
|
+
return True
|
|
89
|
+
|
|
90
|
+
profs = tuple(meta.get("profiles", ()))
|
|
91
|
+
req_env = tuple(meta.get("require_env", ()))
|
|
92
|
+
pred = meta.get("predicate")
|
|
93
|
+
|
|
94
|
+
if profs and (not profiles or not any(p in profs for p in profiles)):
|
|
95
|
+
return False
|
|
96
|
+
if req_env and not all(os.getenv(k) not in (None, "") for k in req_env):
|
|
97
|
+
return False
|
|
98
|
+
if callable(pred):
|
|
99
|
+
try:
|
|
100
|
+
if not bool(pred()):
|
|
101
|
+
return False
|
|
102
|
+
except Exception:
|
|
103
|
+
return False
|
|
104
|
+
return True
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# ------------------- public API -------------------
|
|
108
|
+
|
|
109
|
+
def apply_policy(container, *, profiles: Optional[List[str]] = None) -> None:
|
|
110
|
+
"""Run all policy stages on the given container."""
|
|
111
|
+
profiles = list(profiles or [])
|
|
112
|
+
|
|
113
|
+
_filter_inactive_factory_candidates(container, profiles=profiles)
|
|
114
|
+
_collapse_identical_keys_preferring_primary(container)
|
|
115
|
+
_create_active_component_base_aliases(container, profiles=profiles)
|
|
116
|
+
apply_defaults(container)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def apply_defaults(container) -> None:
|
|
120
|
+
"""Bind defaults declared with @on_missing if no binding exists for selector."""
|
|
121
|
+
defaults: dict[Any, list[tuple[int, Any]]] = {}
|
|
122
|
+
|
|
123
|
+
# class components
|
|
124
|
+
for prov_key, meta in list(container._providers.items()): # type: ignore
|
|
125
|
+
if not isinstance(prov_key, type):
|
|
126
|
+
continue
|
|
127
|
+
target = _target_from_provider(meta.get("factory"))
|
|
128
|
+
om = _on_missing_meta(target)
|
|
129
|
+
if om:
|
|
130
|
+
selector, prio = om
|
|
131
|
+
defaults.setdefault(selector, []).append((prio, prov_key))
|
|
132
|
+
|
|
133
|
+
# factory provides
|
|
134
|
+
for prov_key, meta in list(container._providers.items()): # type: ignore
|
|
135
|
+
prov = meta.get("factory")
|
|
136
|
+
base = getattr(prov, "_pico_alias_for", None)
|
|
137
|
+
if base is None:
|
|
138
|
+
continue
|
|
139
|
+
target = _target_from_provider(prov)
|
|
140
|
+
om = _on_missing_meta(target)
|
|
141
|
+
if om:
|
|
142
|
+
_sel, prio = om
|
|
143
|
+
defaults.setdefault(base, []).append((prio, prov_key))
|
|
144
|
+
|
|
145
|
+
# bind highest priority candidate
|
|
146
|
+
for base, cands in defaults.items():
|
|
147
|
+
if container.has(base):
|
|
148
|
+
continue
|
|
149
|
+
cands.sort(key=lambda t: t[0], reverse=True)
|
|
150
|
+
chosen_key = cands[0][1]
|
|
151
|
+
|
|
152
|
+
def _delegate(_k=chosen_key):
|
|
153
|
+
def _f():
|
|
154
|
+
return container.get(_k)
|
|
155
|
+
return _f
|
|
156
|
+
|
|
157
|
+
container.bind(base, _delegate(), lazy=True)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# ------------------- stages -------------------
|
|
161
|
+
|
|
162
|
+
def _filter_inactive_factory_candidates(container, *, profiles: List[str]) -> None:
|
|
163
|
+
"""Remove factories inactive under profiles/env/predicate."""
|
|
164
|
+
to_delete = []
|
|
165
|
+
for prov_key, meta in list(container._providers.items()): # type: ignore
|
|
166
|
+
prov = meta.get("factory")
|
|
167
|
+
base = getattr(prov, "_pico_alias_for", None)
|
|
168
|
+
if base is None:
|
|
169
|
+
continue
|
|
170
|
+
target = _target_from_provider(prov)
|
|
171
|
+
if not _conditional_active(target, profiles=profiles):
|
|
172
|
+
to_delete.append(prov_key)
|
|
173
|
+
for k in to_delete:
|
|
174
|
+
container._providers.pop(k, None) # type: ignore
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _collapse_identical_keys_preferring_primary(container) -> None:
|
|
178
|
+
"""For multiple factory candidates of same base, keep one (prefer @primary)."""
|
|
179
|
+
groups: dict[Any, list[tuple[Any, dict]]] = defaultdict(list)
|
|
180
|
+
for k, m in list(container._providers.items()): # type: ignore
|
|
181
|
+
prov = m.get("factory")
|
|
182
|
+
base = getattr(prov, "_pico_alias_for", None)
|
|
183
|
+
if base is not None:
|
|
184
|
+
groups[base].append((k, m))
|
|
185
|
+
|
|
186
|
+
for base, entries in groups.items():
|
|
187
|
+
if not entries:
|
|
188
|
+
continue
|
|
189
|
+
if len(entries) == 1:
|
|
190
|
+
keep, _ = entries[0]
|
|
191
|
+
if (not container.has(base)) or (base != keep):
|
|
192
|
+
factory = create_alias_provider(container, keep)
|
|
193
|
+
container.bind(base, factory, lazy=True)
|
|
194
|
+
continue
|
|
195
|
+
|
|
196
|
+
prims = [(kk, mm) for (kk, mm) in entries if _has_flag(_target_from_provider(mm["factory"]), PRIMARY_FLAG)]
|
|
197
|
+
if prims:
|
|
198
|
+
keep, _ = prims[0]
|
|
199
|
+
if (not container.has(base)) or (base != keep):
|
|
200
|
+
factory = create_alias_provider(container, keep)
|
|
201
|
+
container.bind(base, factory, lazy=True)
|
|
202
|
+
for kk, _mm in entries:
|
|
203
|
+
if kk != keep and kk != base:
|
|
204
|
+
container._providers.pop(kk, None) # type: ignore
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _create_active_component_base_aliases(container, *, profiles: List[str]) -> None:
|
|
208
|
+
"""For active class components, create base->impl aliases (prefer @primary)."""
|
|
209
|
+
impls: List[Tuple[type, dict]] = []
|
|
210
|
+
for key, meta in list(container._providers.items()): # type: ignore
|
|
211
|
+
if not isinstance(key, type):
|
|
212
|
+
continue
|
|
213
|
+
tgt = _target_from_provider(meta.get("factory"))
|
|
214
|
+
if _conditional_active(tgt, profiles=profiles):
|
|
215
|
+
impls.append((key, meta))
|
|
216
|
+
|
|
217
|
+
base_to_impls: Dict[Any, List[Tuple[Any, dict]]] = defaultdict(list)
|
|
218
|
+
for impl_key, impl_meta in impls:
|
|
219
|
+
for base in getattr(impl_key, "__mro__", ())[1:]:
|
|
220
|
+
if base is object:
|
|
221
|
+
break
|
|
222
|
+
base_to_impls[base].append((impl_key, impl_meta))
|
|
223
|
+
|
|
224
|
+
for base, impl_list in base_to_impls.items():
|
|
225
|
+
if container.has(base) or not impl_list:
|
|
226
|
+
continue
|
|
227
|
+
|
|
228
|
+
regular, fallbacks = [], []
|
|
229
|
+
for ik, im in impl_list:
|
|
230
|
+
tgt = _target_from_provider(im["factory"])
|
|
231
|
+
(fallbacks if _on_missing_meta(tgt) else regular).append((ik, im))
|
|
232
|
+
|
|
233
|
+
def pick(cands: List[Tuple[Any, dict]]) -> Optional[Any]:
|
|
234
|
+
if not cands:
|
|
235
|
+
return None
|
|
236
|
+
prims = [(ik, im) for ik, im in cands if _has_flag(_target_from_provider(im["factory"]), PRIMARY_FLAG)]
|
|
237
|
+
return prims[0][0] if prims else cands[0][0]
|
|
238
|
+
|
|
239
|
+
chosen = pick(regular) or pick(fallbacks)
|
|
240
|
+
if not chosen:
|
|
241
|
+
continue
|
|
242
|
+
|
|
243
|
+
factory = create_alias_provider(container, chosen)
|
|
244
|
+
container.bind(base, factory, lazy=True)
|
|
245
|
+
|