pico-ioc 2.0.0__py3-none-any.whl → 2.0.1__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 +1 -12
- pico_ioc/_version.py +1 -1
- pico_ioc/aop.py +55 -21
- pico_ioc/api.py +464 -108
- pico_ioc/container.py +161 -26
- pico_ioc/event_bus.py +3 -4
- pico_ioc/exceptions.py +9 -3
- pico_ioc/scope.py +47 -2
- pico_ioc-2.0.1.dist-info/METADATA +243 -0
- pico_ioc-2.0.1.dist-info/RECORD +17 -0
- pico_ioc-2.0.0.dist-info/METADATA +0 -230
- pico_ioc-2.0.0.dist-info/RECORD +0 -17
- {pico_ioc-2.0.0.dist-info → pico_ioc-2.0.1.dist-info}/WHEEL +0 -0
- {pico_ioc-2.0.0.dist-info → pico_ioc-2.0.1.dist-info}/licenses/LICENSE +0 -0
- {pico_ioc-2.0.0.dist-info → pico_ioc-2.0.1.dist-info}/top_level.txt +0 -0
pico_ioc/container.py
CHANGED
|
@@ -13,6 +13,75 @@ from .aop import UnifiedComponentProxy, ContainerObserver
|
|
|
13
13
|
KeyT = Union[str, type]
|
|
14
14
|
_resolve_chain: contextvars.ContextVar[Tuple[KeyT, ...]] = contextvars.ContextVar("pico_resolve_chain", default=())
|
|
15
15
|
|
|
16
|
+
class _TracerFrame:
|
|
17
|
+
__slots__ = ("parent_key", "via")
|
|
18
|
+
def __init__(self, parent_key: KeyT, via: str):
|
|
19
|
+
self.parent_key = parent_key
|
|
20
|
+
self.via = via
|
|
21
|
+
|
|
22
|
+
class ResolutionTracer:
|
|
23
|
+
def __init__(self, container: "PicoContainer") -> None:
|
|
24
|
+
self._container = container
|
|
25
|
+
self._stack_var: contextvars.ContextVar[List[_TracerFrame]] = contextvars.ContextVar("pico_tracer_stack", default=[])
|
|
26
|
+
self._edges: Dict[Tuple[KeyT, KeyT], Tuple[str, str]] = {}
|
|
27
|
+
|
|
28
|
+
def enter(self, parent_key: KeyT, via: str) -> contextvars.Token:
|
|
29
|
+
stack = list(self._stack_var.get())
|
|
30
|
+
stack.append(_TracerFrame(parent_key, via))
|
|
31
|
+
return self._stack_var.set(stack)
|
|
32
|
+
|
|
33
|
+
def leave(self, token: contextvars.Token) -> None:
|
|
34
|
+
self._stack_var.reset(token)
|
|
35
|
+
|
|
36
|
+
def override_via(self, new_via: str) -> Optional[str]:
|
|
37
|
+
stack = self._stack_var.get()
|
|
38
|
+
if not stack:
|
|
39
|
+
return None
|
|
40
|
+
prev = stack[-1].via
|
|
41
|
+
stack[-1].via = new_via
|
|
42
|
+
return prev
|
|
43
|
+
|
|
44
|
+
def restore_via(self, previous: Optional[str]) -> None:
|
|
45
|
+
if previous is None:
|
|
46
|
+
return
|
|
47
|
+
stack = self._stack_var.get()
|
|
48
|
+
if not stack:
|
|
49
|
+
return
|
|
50
|
+
stack[-1].via = previous
|
|
51
|
+
|
|
52
|
+
def note_param(self, child_key: KeyT, param_name: str) -> None:
|
|
53
|
+
stack = self._stack_var.get()
|
|
54
|
+
if not stack:
|
|
55
|
+
return
|
|
56
|
+
parent = stack[-1].parent_key
|
|
57
|
+
via = stack[-1].via
|
|
58
|
+
self._edges[(parent, child_key)] = (via, param_name)
|
|
59
|
+
|
|
60
|
+
def describe_cycle(self, chain: Tuple[KeyT, ...], current: KeyT, locator: Optional[ComponentLocator]) -> str:
|
|
61
|
+
def name_of(k: KeyT) -> str:
|
|
62
|
+
return getattr(k, "__name__", str(k))
|
|
63
|
+
def scope_of(k: KeyT) -> str:
|
|
64
|
+
if not locator:
|
|
65
|
+
return "singleton"
|
|
66
|
+
md = locator._metadata.get(k)
|
|
67
|
+
return md.scope if md else "singleton"
|
|
68
|
+
lines: List[str] = []
|
|
69
|
+
lines.append("Circular dependency detected.")
|
|
70
|
+
lines.append("")
|
|
71
|
+
lines.append("Resolution chain:")
|
|
72
|
+
full = tuple(chain) + (current,)
|
|
73
|
+
for idx, k in enumerate(full, 1):
|
|
74
|
+
mark = " ❌" if idx == len(full) else ""
|
|
75
|
+
lines.append(f" {idx}. {name_of(k)} [scope={scope_of(k)}]{mark}")
|
|
76
|
+
if idx < len(full):
|
|
77
|
+
parent = k
|
|
78
|
+
child = full[idx]
|
|
79
|
+
via, param = self._edges.get((parent, child), ("provider", "?"))
|
|
80
|
+
lines.append(f" └─ via {via} param '{param}' → {name_of(child)}")
|
|
81
|
+
lines.append("")
|
|
82
|
+
lines.append("Hint: break the cycle with a @configure setter or use a factory/provider.")
|
|
83
|
+
return "\n".join(lines)
|
|
84
|
+
|
|
16
85
|
class PicoContainer:
|
|
17
86
|
_container_id_var: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar("pico_container_id", default=None)
|
|
18
87
|
_container_registry: Dict[str, "PicoContainer"] = {}
|
|
@@ -35,6 +104,7 @@ class PicoContainer:
|
|
|
35
104
|
import time as _t
|
|
36
105
|
self.context = PicoContainer._Ctx(container_id=self.container_id, profiles=profiles, created_at=_t.time())
|
|
37
106
|
PicoContainer._container_registry[self.container_id] = self
|
|
107
|
+
self._tracer = ResolutionTracer(self)
|
|
38
108
|
|
|
39
109
|
@staticmethod
|
|
40
110
|
def _generate_container_id() -> str:
|
|
@@ -80,11 +150,35 @@ class PicoContainer:
|
|
|
80
150
|
cache = self._cache_for(key)
|
|
81
151
|
return cache.get(key) is not None or self._factory.has(key)
|
|
82
152
|
|
|
153
|
+
def _canonical_key(self, key: KeyT) -> KeyT:
|
|
154
|
+
if self._factory.has(key):
|
|
155
|
+
return key
|
|
156
|
+
if isinstance(key, type) and self._locator:
|
|
157
|
+
cands: List[Tuple[bool, Any]] = []
|
|
158
|
+
for k, md in self._locator._metadata.items():
|
|
159
|
+
typ = md.provided_type or md.concrete_class
|
|
160
|
+
if not isinstance(typ, type):
|
|
161
|
+
continue
|
|
162
|
+
try:
|
|
163
|
+
if typ is not key and issubclass(typ, key):
|
|
164
|
+
cands.append((md.primary, k))
|
|
165
|
+
except Exception:
|
|
166
|
+
continue
|
|
167
|
+
if cands:
|
|
168
|
+
prim = [k for is_p, k in cands if is_p]
|
|
169
|
+
return prim[0] if prim else cands[0][1]
|
|
170
|
+
if isinstance(key, str) and self._locator:
|
|
171
|
+
for k, md in self._locator._metadata.items():
|
|
172
|
+
if md.pico_name == key:
|
|
173
|
+
return k
|
|
174
|
+
return key
|
|
175
|
+
|
|
83
176
|
@overload
|
|
84
177
|
def get(self, key: type) -> Any: ...
|
|
85
178
|
@overload
|
|
86
179
|
def get(self, key: str) -> Any: ...
|
|
87
180
|
def get(self, key: KeyT) -> Any:
|
|
181
|
+
key = self._canonical_key(key)
|
|
88
182
|
cache = self._cache_for(key)
|
|
89
183
|
cached = cache.get(key)
|
|
90
184
|
if cached is not None:
|
|
@@ -96,28 +190,19 @@ class PicoContainer:
|
|
|
96
190
|
chain = list(_resolve_chain.get())
|
|
97
191
|
for k in chain:
|
|
98
192
|
if k == key:
|
|
99
|
-
|
|
193
|
+
details = self._tracer.describe_cycle(tuple(chain), key, self._locator)
|
|
194
|
+
raise ComponentCreationError(key, CircularDependencyError(chain, key, details=details))
|
|
100
195
|
token_chain = _resolve_chain.set(tuple(chain + [key]))
|
|
101
196
|
token_container = self.activate()
|
|
197
|
+
token_tracer = self._tracer.enter(key, via="provider")
|
|
102
198
|
try:
|
|
103
|
-
if not self._factory.has(key):
|
|
104
|
-
alt = None
|
|
105
|
-
if isinstance(key, type):
|
|
106
|
-
alt = self._resolve_type_key(key)
|
|
107
|
-
elif isinstance(key, str) and self._locator:
|
|
108
|
-
for k, md in self._locator._metadata.items():
|
|
109
|
-
if md.pico_name == key:
|
|
110
|
-
alt = k
|
|
111
|
-
break
|
|
112
|
-
if alt is not None:
|
|
113
|
-
self._factory.bind(key, lambda a=alt: self.get(a))
|
|
114
199
|
provider = self._factory.get(key)
|
|
115
200
|
try:
|
|
116
201
|
instance = provider()
|
|
117
202
|
except ProviderNotFoundError as e:
|
|
118
203
|
raise
|
|
119
204
|
except Exception as e:
|
|
120
|
-
raise ComponentCreationError(key, e)
|
|
205
|
+
raise ComponentCreationError(key, e) from e
|
|
121
206
|
instance = self._maybe_wrap_with_aspects(key, instance)
|
|
122
207
|
cache.put(key, instance)
|
|
123
208
|
self.context.resolve_count += 1
|
|
@@ -125,10 +210,12 @@ class PicoContainer:
|
|
|
125
210
|
for o in self._observers: o.on_resolve(key, took_ms)
|
|
126
211
|
return instance
|
|
127
212
|
finally:
|
|
213
|
+
self._tracer.leave(token_tracer)
|
|
128
214
|
_resolve_chain.reset(token_chain)
|
|
129
215
|
self.deactivate(token_container)
|
|
130
216
|
|
|
131
217
|
async def aget(self, key: KeyT) -> Any:
|
|
218
|
+
key = self._canonical_key(key)
|
|
132
219
|
cache = self._cache_for(key)
|
|
133
220
|
cached = cache.get(key)
|
|
134
221
|
if cached is not None:
|
|
@@ -140,21 +227,12 @@ class PicoContainer:
|
|
|
140
227
|
chain = list(_resolve_chain.get())
|
|
141
228
|
for k in chain:
|
|
142
229
|
if k == key:
|
|
143
|
-
|
|
230
|
+
details = self._tracer.describe_cycle(tuple(chain), key, self._locator)
|
|
231
|
+
raise ComponentCreationError(key, CircularDependencyError(chain, key, details=details))
|
|
144
232
|
token_chain = _resolve_chain.set(tuple(chain + [key]))
|
|
145
233
|
token_container = self.activate()
|
|
234
|
+
token_tracer = self._tracer.enter(key, via="provider")
|
|
146
235
|
try:
|
|
147
|
-
if not self._factory.has(key):
|
|
148
|
-
alt = None
|
|
149
|
-
if isinstance(key, type):
|
|
150
|
-
alt = self._resolve_type_key(key)
|
|
151
|
-
elif isinstance(key, str) and self._locator:
|
|
152
|
-
for k, md in self._locator._metadata.items():
|
|
153
|
-
if md.pico_name == key:
|
|
154
|
-
alt = k
|
|
155
|
-
break
|
|
156
|
-
if alt is not None:
|
|
157
|
-
self._factory.bind(key, lambda a=alt: self.get(a))
|
|
158
236
|
provider = self._factory.get(key)
|
|
159
237
|
try:
|
|
160
238
|
instance = provider()
|
|
@@ -163,7 +241,7 @@ class PicoContainer:
|
|
|
163
241
|
except ProviderNotFoundError as e:
|
|
164
242
|
raise
|
|
165
243
|
except Exception as e:
|
|
166
|
-
raise ComponentCreationError(key, e)
|
|
244
|
+
raise ComponentCreationError(key, e) from e
|
|
167
245
|
instance = self._maybe_wrap_with_aspects(key, instance)
|
|
168
246
|
cache.put(key, instance)
|
|
169
247
|
self.context.resolve_count += 1
|
|
@@ -171,6 +249,7 @@ class PicoContainer:
|
|
|
171
249
|
for o in self._observers: o.on_resolve(key, took_ms)
|
|
172
250
|
return instance
|
|
173
251
|
finally:
|
|
252
|
+
self._tracer.leave(token_tracer)
|
|
174
253
|
_resolve_chain.reset(token_chain)
|
|
175
254
|
self.deactivate(token_container)
|
|
176
255
|
|
|
@@ -303,3 +382,59 @@ class PicoContainer:
|
|
|
303
382
|
self.cleanup_all()
|
|
304
383
|
PicoContainer._container_registry.pop(self.container_id, None)
|
|
305
384
|
|
|
385
|
+
def export_graph(
|
|
386
|
+
self,
|
|
387
|
+
path: str,
|
|
388
|
+
*,
|
|
389
|
+
include_scopes: bool = True,
|
|
390
|
+
include_qualifiers: bool = False,
|
|
391
|
+
rankdir: str = "LR",
|
|
392
|
+
title: Optional[str] = None,
|
|
393
|
+
) -> None:
|
|
394
|
+
|
|
395
|
+
if not self._locator:
|
|
396
|
+
raise RuntimeError("No locator attached; cannot export dependency graph.")
|
|
397
|
+
|
|
398
|
+
from .api import _build_resolution_graph
|
|
399
|
+
|
|
400
|
+
md_by_key = self._locator._metadata
|
|
401
|
+
graph = _build_resolution_graph(self)
|
|
402
|
+
|
|
403
|
+
lines: List[str] = []
|
|
404
|
+
lines.append("digraph Pico {")
|
|
405
|
+
lines.append(f' rankdir="{rankdir}";')
|
|
406
|
+
lines.append(" node [shape=box, fontsize=10];")
|
|
407
|
+
if title:
|
|
408
|
+
lines.append(f' labelloc="t";')
|
|
409
|
+
lines.append(f' label="{title}";')
|
|
410
|
+
|
|
411
|
+
def _node_id(k: KeyT) -> str:
|
|
412
|
+
return f'n_{abs(hash(k))}'
|
|
413
|
+
|
|
414
|
+
def _node_label(k: KeyT) -> str:
|
|
415
|
+
name = getattr(k, "__name__", str(k))
|
|
416
|
+
md = md_by_key.get(k)
|
|
417
|
+
parts = [name]
|
|
418
|
+
if md is not None and include_scopes:
|
|
419
|
+
parts.append(f"[scope={md.scope}]")
|
|
420
|
+
if md is not None and include_qualifiers and md.qualifiers:
|
|
421
|
+
q = ",".join(sorted(md.qualifiers))
|
|
422
|
+
parts.append(f"\\n⟨{q}⟩")
|
|
423
|
+
return "\\n".join(parts)
|
|
424
|
+
|
|
425
|
+
for key in md_by_key.keys():
|
|
426
|
+
nid = _node_id(key)
|
|
427
|
+
label = _node_label(key)
|
|
428
|
+
lines.append(f' {nid} [label="{label}"];')
|
|
429
|
+
|
|
430
|
+
for parent, deps in graph.items():
|
|
431
|
+
pid = _node_id(parent)
|
|
432
|
+
for child in deps:
|
|
433
|
+
cid = _node_id(child)
|
|
434
|
+
lines.append(f" {pid} -> {cid};")
|
|
435
|
+
|
|
436
|
+
lines.append("}")
|
|
437
|
+
|
|
438
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
439
|
+
f.write("\n".join(lines))
|
|
440
|
+
|
pico_ioc/event_bus.py
CHANGED
|
@@ -6,7 +6,7 @@ import threading
|
|
|
6
6
|
from dataclasses import dataclass, field
|
|
7
7
|
from enum import Enum, auto
|
|
8
8
|
from typing import Any, Awaitable, Callable, Dict, Iterable, List, Optional, Tuple, Type
|
|
9
|
-
from .api import factory, provides, configure, cleanup
|
|
9
|
+
from .api import factory, provides, configure, cleanup
|
|
10
10
|
from .exceptions import EventBusClosedError, EventBusError, EventBusQueueFullError, EventBusHandlerError
|
|
11
11
|
|
|
12
12
|
log = logging.getLogger(__name__)
|
|
@@ -204,10 +204,9 @@ class AutoSubscriberMixin:
|
|
|
204
204
|
for evt_t, pr, pol, once in subs:
|
|
205
205
|
event_bus.subscribe(evt_t, attr, priority=pr, policy=pol, once=once)
|
|
206
206
|
|
|
207
|
-
@factory
|
|
208
|
-
@primary
|
|
207
|
+
@factory()
|
|
209
208
|
class PicoEventBusProvider:
|
|
210
|
-
@provides(EventBus)
|
|
209
|
+
@provides(EventBus, primary=True)
|
|
211
210
|
def build(self) -> EventBus:
|
|
212
211
|
return EventBus()
|
|
213
212
|
@cleanup
|
pico_ioc/exceptions.py
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
# src/pico_ioc/exceptions.py
|
|
2
1
|
from typing import Any, Iterable
|
|
3
2
|
|
|
4
3
|
class PicoError(Exception):
|
|
@@ -10,12 +9,19 @@ class ProviderNotFoundError(PicoError):
|
|
|
10
9
|
self.key = key
|
|
11
10
|
|
|
12
11
|
class CircularDependencyError(PicoError):
|
|
13
|
-
def __init__(self, chain: Iterable[Any], current: Any):
|
|
12
|
+
def __init__(self, chain: Iterable[Any], current: Any, details: str | None = None, hint: str | None = None):
|
|
14
13
|
chain_str = " -> ".join(getattr(k, "__name__", str(k)) for k in chain)
|
|
15
14
|
cur_str = getattr(current, "__name__", str(current))
|
|
16
|
-
|
|
15
|
+
base = f"Circular dependency detected: {chain_str} -> {cur_str}"
|
|
16
|
+
if details:
|
|
17
|
+
base += f"\n\n{details}"
|
|
18
|
+
if hint:
|
|
19
|
+
base += f"\n\nHint: {hint}"
|
|
20
|
+
super().__init__(base)
|
|
17
21
|
self.chain = tuple(chain)
|
|
18
22
|
self.current = current
|
|
23
|
+
self.details = details
|
|
24
|
+
self.hint = hint
|
|
19
25
|
|
|
20
26
|
class ComponentCreationError(PicoError):
|
|
21
27
|
def __init__(self, key: Any, cause: Exception):
|
pico_ioc/scope.py
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
# src/pico_ioc/scope.py
|
|
1
2
|
import contextvars
|
|
3
|
+
import inspect
|
|
2
4
|
from typing import Any, Dict, Optional, Tuple
|
|
3
5
|
from collections import OrderedDict
|
|
4
6
|
|
|
@@ -87,6 +89,35 @@ class ScopedCaches:
|
|
|
87
89
|
self._by_scope: Dict[str, OrderedDict[Any, ComponentContainer]] = {}
|
|
88
90
|
self._max = int(max_scopes_per_type)
|
|
89
91
|
self._no_cache = _NoCacheContainer()
|
|
92
|
+
def _cleanup_object(self, obj: Any) -> None:
|
|
93
|
+
try:
|
|
94
|
+
from .constants import PICO_META
|
|
95
|
+
except Exception:
|
|
96
|
+
PICO_META = "_pico_meta"
|
|
97
|
+
try:
|
|
98
|
+
for _, m in inspect.getmembers(obj, predicate=inspect.ismethod):
|
|
99
|
+
meta = getattr(m, PICO_META, {})
|
|
100
|
+
if meta.get("cleanup", False):
|
|
101
|
+
try:
|
|
102
|
+
m()
|
|
103
|
+
except Exception:
|
|
104
|
+
pass
|
|
105
|
+
except Exception:
|
|
106
|
+
pass
|
|
107
|
+
|
|
108
|
+
def cleanup_scope(self, scope_name: str, scope_id: Any) -> None:
|
|
109
|
+
bucket = self._by_scope.get(scope_name)
|
|
110
|
+
if bucket and scope_id in bucket:
|
|
111
|
+
container = bucket.pop(scope_id)
|
|
112
|
+
self._cleanup_container(container)
|
|
113
|
+
|
|
114
|
+
def _cleanup_container(self, container: "ComponentContainer") -> None:
|
|
115
|
+
try:
|
|
116
|
+
for _, obj in container.items():
|
|
117
|
+
self._cleanup_object(obj)
|
|
118
|
+
except Exception:
|
|
119
|
+
pass
|
|
120
|
+
|
|
90
121
|
def for_scope(self, scopes: ScopeManager, scope: str) -> ComponentContainer:
|
|
91
122
|
if scope == "singleton":
|
|
92
123
|
return self._singleton
|
|
@@ -99,14 +130,28 @@ class ScopedCaches:
|
|
|
99
130
|
bucket[sid] = c
|
|
100
131
|
return c
|
|
101
132
|
if len(bucket) >= self._max:
|
|
102
|
-
bucket.popitem(last=False)
|
|
133
|
+
_, old = bucket.popitem(last=False)
|
|
134
|
+
self._cleanup_container(old)
|
|
103
135
|
c = ComponentContainer()
|
|
104
136
|
bucket[sid] = c
|
|
105
137
|
return c
|
|
138
|
+
|
|
106
139
|
def all_items(self):
|
|
107
140
|
for item in self._singleton.items():
|
|
108
141
|
yield item
|
|
109
142
|
for b in self._by_scope.values():
|
|
110
143
|
for c in b.values():
|
|
111
144
|
for item in c.items():
|
|
112
|
-
yield item
|
|
145
|
+
yield item
|
|
146
|
+
|
|
147
|
+
def shrink(self, scope: str, keep: int) -> None:
|
|
148
|
+
if scope in ("singleton", "prototype"):
|
|
149
|
+
return
|
|
150
|
+
bucket = self._by_scope.get(scope)
|
|
151
|
+
if not bucket:
|
|
152
|
+
return
|
|
153
|
+
k = max(0, int(keep))
|
|
154
|
+
while len(bucket) > k:
|
|
155
|
+
_, old = bucket.popitem(last=False)
|
|
156
|
+
self._cleanup_container(old)
|
|
157
|
+
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pico-ioc
|
|
3
|
+
Version: 2.0.1
|
|
4
|
+
Summary: A minimalist, zero-dependency Inversion of Control (IoC) container for Python.
|
|
5
|
+
Author-email: David Perez Cabrera <dperezcabrera@gmail.com>
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2025 David Pérez Cabrera
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
|
|
28
|
+
Project-URL: Homepage, https://github.com/dperezcabrera/pico-ioc
|
|
29
|
+
Project-URL: Repository, https://github.com/dperezcabrera/pico-ioc
|
|
30
|
+
Project-URL: Issue Tracker, https://github.com/dperezcabrera/pico-ioc/issues
|
|
31
|
+
Keywords: ioc,di,dependency injection,inversion of control,decorator
|
|
32
|
+
Classifier: Development Status :: 4 - Beta
|
|
33
|
+
Classifier: Programming Language :: Python :: 3
|
|
34
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
35
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
36
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
37
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
38
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
39
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
40
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
41
|
+
Classifier: Operating System :: OS Independent
|
|
42
|
+
Requires-Python: >=3.8
|
|
43
|
+
Description-Content-Type: text/markdown
|
|
44
|
+
License-File: LICENSE
|
|
45
|
+
Provides-Extra: yaml
|
|
46
|
+
Requires-Dist: PyYAML; extra == "yaml"
|
|
47
|
+
Provides-Extra: graphviz
|
|
48
|
+
Requires-Dist: graphviz; extra == "graphviz"
|
|
49
|
+
Dynamic: license-file
|
|
50
|
+
|
|
51
|
+
# 📦 Pico-IoC: A Robust, Async-Native IoC Container for Python
|
|
52
|
+
|
|
53
|
+
[](https://pypi.org/project/pico-ioc/)
|
|
54
|
+
[](https://deepwiki.com/dperezcabrera/pico-ioc)
|
|
55
|
+
[](https://opensource.org/licenses/MIT)
|
|
56
|
+

|
|
57
|
+
[](https://codecov.io/gh/dperezcabrera/pico-ioc)
|
|
58
|
+
[](https://sonarcloud.io/summary/new_code?id=dperezcabrera_pico-ioc)
|
|
59
|
+
[](https://sonarcloud.io/summary/new_code?id=dperezcabrera_pico-ioc)
|
|
60
|
+
[](https://sonarcloud.io/summary/new_code?id=dperezcabrera_pico-ioc)
|
|
61
|
+
|
|
62
|
+
**Pico-IoC** is a **lightweight, async-ready, decorator-driven IoC container** built for clarity, testability, and performance.
|
|
63
|
+
It brings *Inversion of Control* and *dependency injection* to Python in a deterministic, modern, and framework-agnostic way.
|
|
64
|
+
|
|
65
|
+
> 🐍 Requires **Python 3.10+**
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
## ⚖️ Core Principles
|
|
70
|
+
|
|
71
|
+
- **Single Purpose** – Do one thing: dependency management.
|
|
72
|
+
- **Declarative** – Use simple decorators (`@component`, `@factory`, `@configuration`) instead of config files or YAML magic.
|
|
73
|
+
- **Deterministic** – No hidden scanning or side-effects; everything flows from an explicit `init()`.
|
|
74
|
+
- **Async-Native** – Fully supports async providers, async lifecycle hooks, and async interceptors.
|
|
75
|
+
- **Fail-Fast** – Detects missing bindings and circular dependencies at bootstrap.
|
|
76
|
+
- **Testable by Design** – Use `overrides` and `profiles` to swap components instantly.
|
|
77
|
+
- **Zero Core Dependencies** – Built entirely on the Python standard library. Optional features may require external packages (see Installation).
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
## 🚀 Why Pico-IoC?
|
|
82
|
+
|
|
83
|
+
As Python systems evolve, wiring dependencies by hand becomes fragile and unmaintainable.
|
|
84
|
+
**Pico-IoC** eliminates that friction by letting you declare how components relate — not how they’re created.
|
|
85
|
+
|
|
86
|
+
| Feature | Manual Wiring | With Pico-IoC |
|
|
87
|
+
| :------------- | :------------------------- | :------------------------------ |
|
|
88
|
+
| Object creation| `svc = Service(Repo(Config()))` | `svc = container.get(Service)` |
|
|
89
|
+
| Replacing deps | Monkey-patch | `overrides={Repo: FakeRepo()}` |
|
|
90
|
+
| Coupling | Tight | Loose |
|
|
91
|
+
| Testing | Painful | Instant |
|
|
92
|
+
| Async support | Manual | Built-in |
|
|
93
|
+
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
## 🧩 Highlights (v2.0.0)
|
|
97
|
+
|
|
98
|
+
- **Full redesign:** unified architecture with simpler, more powerful APIs.
|
|
99
|
+
- **Async-aware AOP system** — method interceptors via `@intercepted_by`.
|
|
100
|
+
- **Typed configuration** — dataclasses with JSON/YAML/env sources.
|
|
101
|
+
- **Scoped resolution** — singleton, prototype, request, session, transaction.
|
|
102
|
+
- **UnifiedComponentProxy** — transparent lazy/AOP proxy supporting serialization.
|
|
103
|
+
- **Tree-based configuration runtime** with reusable adapters and discriminators.
|
|
104
|
+
- **Observable container context** with stats, health checks, and async cleanup.
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
## 📦 Installation
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
pip install pico-ioc
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
For optional features, you can install extras:
|
|
115
|
+
|
|
116
|
+
* **YAML Configuration:**
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
pip install pico-ioc[yaml]
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
(Requires `PyYAML`)
|
|
123
|
+
|
|
124
|
+
* **Dependency Graph Export:**
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
pip install pico-ioc[graphviz]
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
(Requires the `graphviz` Python package and the Graphviz command-line tools)
|
|
131
|
+
|
|
132
|
+
-----
|
|
133
|
+
|
|
134
|
+
## ⚙️ Quick Example
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
from dataclasses import dataclass
|
|
138
|
+
from pico_ioc import component, configuration, init
|
|
139
|
+
|
|
140
|
+
@configuration
|
|
141
|
+
@dataclass
|
|
142
|
+
class Config:
|
|
143
|
+
db_url: str = "sqlite:///demo.db"
|
|
144
|
+
|
|
145
|
+
@component
|
|
146
|
+
class Repo:
|
|
147
|
+
def __init__(self, cfg: Config):
|
|
148
|
+
self.cfg = cfg
|
|
149
|
+
def fetch(self):
|
|
150
|
+
return f"fetching from {self.cfg.db_url}"
|
|
151
|
+
|
|
152
|
+
@component
|
|
153
|
+
class Service:
|
|
154
|
+
def __init__(self, repo: Repo):
|
|
155
|
+
self.repo = repo
|
|
156
|
+
def run(self):
|
|
157
|
+
return self.repo.fetch()
|
|
158
|
+
|
|
159
|
+
container = init(modules=[__name__])
|
|
160
|
+
svc = container.get(Service)
|
|
161
|
+
print(svc.run())
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
**Output:**
|
|
165
|
+
|
|
166
|
+
```
|
|
167
|
+
fetching from sqlite:///demo.db
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
-----
|
|
171
|
+
|
|
172
|
+
## 🧪 Testing with Overrides
|
|
173
|
+
|
|
174
|
+
```python
|
|
175
|
+
class FakeRepo:
|
|
176
|
+
def fetch(self): return "fake-data"
|
|
177
|
+
|
|
178
|
+
container = init(modules=[__name__], overrides={Repo: FakeRepo()})
|
|
179
|
+
svc = container.get(Service)
|
|
180
|
+
assert svc.run() == "fake-data"
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
-----
|
|
184
|
+
|
|
185
|
+
## 🩺 Lifecycle & AOP
|
|
186
|
+
|
|
187
|
+
```python
|
|
188
|
+
from pico_ioc import intercepted_by, MethodInterceptor, MethodCtx
|
|
189
|
+
|
|
190
|
+
class LogInterceptor(MethodInterceptor):
|
|
191
|
+
def invoke(self, ctx: MethodCtx, call_next):
|
|
192
|
+
print(f"→ calling {ctx.name}")
|
|
193
|
+
res = call_next(ctx)
|
|
194
|
+
print(f"← {ctx.name} done")
|
|
195
|
+
return res
|
|
196
|
+
|
|
197
|
+
@component
|
|
198
|
+
class Demo:
|
|
199
|
+
@intercepted_by(LogInterceptor)
|
|
200
|
+
def work(self):
|
|
201
|
+
return "ok"
|
|
202
|
+
|
|
203
|
+
c = init(modules=[__name__])
|
|
204
|
+
c.get(Demo).work()
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
-----
|
|
208
|
+
|
|
209
|
+
## 📖 Documentation
|
|
210
|
+
|
|
211
|
+
The full documentation is available within the `docs/` directory of the project repository. Start with `docs/README.md` for navigation.
|
|
212
|
+
|
|
213
|
+
* **Getting Started:** `docs/getting-started.md`
|
|
214
|
+
* **User Guide:** `docs/user-guide/README.md`
|
|
215
|
+
* **Advanced Features:** `docs/advanced-features/README.md`
|
|
216
|
+
* **Observability:** `docs/observability/README.md`
|
|
217
|
+
* **Integrations:** `docs/integrations/README.md`
|
|
218
|
+
* **Cookbook (Patterns):** `docs/cookbook/README.md`
|
|
219
|
+
* **Architecture:** `docs/architecture/README.md`
|
|
220
|
+
* **API Reference:** `docs/api-reference/README.md`
|
|
221
|
+
* **ADR Index:** `docs/adr/README.md`
|
|
222
|
+
|
|
223
|
+
-----
|
|
224
|
+
|
|
225
|
+
## 🧩 Development
|
|
226
|
+
|
|
227
|
+
```bash
|
|
228
|
+
pip install tox
|
|
229
|
+
tox
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
-----
|
|
233
|
+
|
|
234
|
+
## 🧾 Changelog
|
|
235
|
+
|
|
236
|
+
See [CHANGELOG.md](./CHANGELOG.md) — *Full redesign for v2.0.0.*
|
|
237
|
+
|
|
238
|
+
-----
|
|
239
|
+
|
|
240
|
+
## 📜 License
|
|
241
|
+
|
|
242
|
+
MIT — [LICENSE](https://opensource.org/licenses/MIT)
|
|
243
|
+
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
pico_ioc/__init__.py,sha256=AfHqcDJaXLChLJhxej_0gMClHUThUrKABFlIKYVrVtc,2198
|
|
2
|
+
pico_ioc/_version.py,sha256=HVx0XJJ9OYFWBBPBCUFYb8Nm43ChPg9GZLh_dkxh9qI,22
|
|
3
|
+
pico_ioc/aop.py,sha256=prFSlZC6vJYUfTbkMvlSc1T9UvvdEHr94Z0HAvjZ1fg,12985
|
|
4
|
+
pico_ioc/api.py,sha256=Be3bFMPKtkFpHUuToEhDtSriVwyuBg1-b3vUs6WpsQ8,45753
|
|
5
|
+
pico_ioc/config_runtime.py,sha256=z1cHDb5PbM8PMLYRFf5c2dmze8V22xwEzpWcBhtmMpA,11950
|
|
6
|
+
pico_ioc/constants.py,sha256=AhIt0ieDZ9Turxb_YbNzj11wUbBbzjKfWh1BDlSx2Nw,183
|
|
7
|
+
pico_ioc/container.py,sha256=5hLPwoVNY_PsN6XYbbZ6_j1I8IBnteCcahus1vCI_JY,17514
|
|
8
|
+
pico_ioc/event_bus.py,sha256=E8Qb8KZ6K1CuXSbMlG0MNPHkGoWlssLLPzHq1QYdADQ,8346
|
|
9
|
+
pico_ioc/exceptions.py,sha256=GT8flzyXeUWetguc8RRkB4p56waTXMdeNhSKQQ8rh4w,2468
|
|
10
|
+
pico_ioc/factory.py,sha256=Q3aLwZ-MWbXKjm8unr871vlWSeVUDmzFQZ1mXzPkY5I,1557
|
|
11
|
+
pico_ioc/locator.py,sha256=PBxZYO_xCOxG7aJZ0adDtINrJass_ZDNYmPD2O_oNqM,2401
|
|
12
|
+
pico_ioc/scope.py,sha256=GDsDJWw7e5Vpiys-M4vQfKMJWSCiorRsT5cPo6z34Mk,5924
|
|
13
|
+
pico_ioc-2.0.1.dist-info/licenses/LICENSE,sha256=N1_nOvHTM6BobYnOTNXiQkroDqCEi6EzfGBv8lWtyZ0,1077
|
|
14
|
+
pico_ioc-2.0.1.dist-info/METADATA,sha256=U6L0obv__5poIDJvadj9z9w56B1-1HWz8Q3yiCStFAI,8741
|
|
15
|
+
pico_ioc-2.0.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
16
|
+
pico_ioc-2.0.1.dist-info/top_level.txt,sha256=_7_RLu616z_dtRw16impXn4Mw8IXe2J4BeX5912m5dQ,9
|
|
17
|
+
pico_ioc-2.0.1.dist-info/RECORD,,
|