pico-ioc 0.3.1__tar.gz → 0.4.0__tar.gz
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-0.3.1 → pico_ioc-0.4.0}/PKG-INFO +83 -1
- {pico_ioc-0.3.1 → pico_ioc-0.4.0}/README.md +82 -0
- {pico_ioc-0.3.1 → pico_ioc-0.4.0}/src/pico_ioc/__init__.py +135 -32
- pico_ioc-0.4.0/src/pico_ioc/_version.py +1 -0
- {pico_ioc-0.3.1 → pico_ioc-0.4.0}/src/pico_ioc.egg-info/PKG-INFO +83 -1
- {pico_ioc-0.3.1 → pico_ioc-0.4.0}/tests/test_pico_ioc.py +45 -5
- pico_ioc-0.3.1/src/pico_ioc/_version.py +0 -1
- {pico_ioc-0.3.1 → pico_ioc-0.4.0}/.github/workflows/ci.yml +0 -0
- {pico_ioc-0.3.1 → pico_ioc-0.4.0}/.github/workflows/publish-to-pypi.yml +0 -0
- {pico_ioc-0.3.1 → pico_ioc-0.4.0}/Dockerfile.test +0 -0
- {pico_ioc-0.3.1 → pico_ioc-0.4.0}/Makefile +0 -0
- {pico_ioc-0.3.1 → pico_ioc-0.4.0}/pyproject.toml +0 -0
- {pico_ioc-0.3.1 → pico_ioc-0.4.0}/setup.cfg +0 -0
- {pico_ioc-0.3.1 → pico_ioc-0.4.0}/src/pico_ioc.egg-info/SOURCES.txt +0 -0
- {pico_ioc-0.3.1 → pico_ioc-0.4.0}/src/pico_ioc.egg-info/dependency_links.txt +0 -0
- {pico_ioc-0.3.1 → pico_ioc-0.4.0}/src/pico_ioc.egg-info/top_level.txt +0 -0
- {pico_ioc-0.3.1 → pico_ioc-0.4.0}/tox.ini +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pico-ioc
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: A minimalist, zero-dependency Inversion of Control (IoC) container for Python.
|
|
5
5
|
Author-email: David Perez Cabrera <dperezcabrera@gmail.com>
|
|
6
6
|
Project-URL: Homepage, https://github.com/dperezcabrera/pico-ioc
|
|
@@ -219,6 +219,88 @@ Tip: for “missing dependency” tests, mark those components as `lazy=True` so
|
|
|
219
219
|
|
|
220
220
|
---
|
|
221
221
|
|
|
222
|
+
## 🔌 Extensibility: Plugins, Binder, and Lifecycle Hooks
|
|
223
|
+
|
|
224
|
+
From `v0.4.0` onward, Pico-IoC can be cleanly extended without patching the core.
|
|
225
|
+
This enables optional integration layers like `pico-ioc-rest` for Flask, FastAPI, etc., while keeping the core dependency-free.
|
|
226
|
+
|
|
227
|
+
### Plugin Protocol
|
|
228
|
+
|
|
229
|
+
A plugin is any object that implements some or all of the following methods:
|
|
230
|
+
|
|
231
|
+
```python
|
|
232
|
+
from pico_ioc import PicoPlugin, Binder
|
|
233
|
+
|
|
234
|
+
class MyPlugin:
|
|
235
|
+
def before_scan(self, package, binder: Binder): ...
|
|
236
|
+
def visit_class(self, module, cls, binder: Binder): ...
|
|
237
|
+
def after_scan(self, package, binder: Binder): ...
|
|
238
|
+
def after_bind(self, container, binder: Binder): ...
|
|
239
|
+
def before_eager(self, container, binder: Binder): ...
|
|
240
|
+
def after_ready(self, container, binder: Binder): ...
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
All hooks are optional. If present, they are called in this order during `init()`:
|
|
244
|
+
|
|
245
|
+
1. **before\_scan** — called before package scanning starts.
|
|
246
|
+
2. **visit\_class** — called for every class discovered during scanning.
|
|
247
|
+
3. **after\_scan** — called after scanning all modules.
|
|
248
|
+
4. **after\_bind** — called after the core has bound all components/factories.
|
|
249
|
+
5. **before\_eager** — called right before eager (non-lazy) instantiation.
|
|
250
|
+
6. **after\_ready** — called after all eager instantiation is complete.
|
|
251
|
+
|
|
252
|
+
### Binder API
|
|
253
|
+
|
|
254
|
+
Plugins receive a [`Binder`](#binder-api) instance in each hook, allowing them to:
|
|
255
|
+
|
|
256
|
+
* **bind**: register new providers in the container.
|
|
257
|
+
* **has**: check if a binding exists.
|
|
258
|
+
* **get**: resolve a binding immediately.
|
|
259
|
+
|
|
260
|
+
Example plugin that binds a “marker” component when a certain class is discovered:
|
|
261
|
+
|
|
262
|
+
```python
|
|
263
|
+
class MarkerPlugin:
|
|
264
|
+
def visit_class(self, module, cls, binder):
|
|
265
|
+
if cls.__name__ == "SpecialService" and not binder.has("marker"):
|
|
266
|
+
binder.bind("marker", lambda: {"ok": True}, lazy=False)
|
|
267
|
+
|
|
268
|
+
container = init("my_app", plugins=(MarkerPlugin(),))
|
|
269
|
+
assert container.get("marker") == {"ok": True}
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
### Creating Extensions
|
|
273
|
+
|
|
274
|
+
With the plugin API, you can build separate packages like `pico-ioc-rest`:
|
|
275
|
+
|
|
276
|
+
```python
|
|
277
|
+
from pico_ioc import PicoPlugin, Binder, create_instance, resolve_param
|
|
278
|
+
from flask import Flask
|
|
279
|
+
|
|
280
|
+
class FlaskRestPlugin:
|
|
281
|
+
def __init__(self):
|
|
282
|
+
self.controllers = []
|
|
283
|
+
|
|
284
|
+
def visit_class(self, module, cls, binder: Binder):
|
|
285
|
+
if getattr(cls, "_is_controller", False):
|
|
286
|
+
self.controllers.append(cls)
|
|
287
|
+
|
|
288
|
+
def after_bind(self, container, binder: Binder):
|
|
289
|
+
app: Flask = container.get(Flask)
|
|
290
|
+
for ctl_cls in self.controllers:
|
|
291
|
+
ctl = create_instance(ctl_cls, container)
|
|
292
|
+
# register routes here using `resolve_param` for handler DI
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
### Public Helpers for Extensions
|
|
296
|
+
|
|
297
|
+
Plugins can reuse Pico-IoC’s DI logic without duplicating it:
|
|
298
|
+
|
|
299
|
+
* **`create_instance(cls, container)`** — instantiate a class with DI, respecting Pico-IoC’s resolution order.
|
|
300
|
+
* **`resolve_param(container, parameter)`** — resolve a single function/class parameter via Pico-IoC rules.
|
|
301
|
+
|
|
302
|
+
---
|
|
303
|
+
|
|
222
304
|
## ❓ FAQ
|
|
223
305
|
|
|
224
306
|
**Q: Can I make the container lenient at startup?**
|
|
@@ -196,6 +196,88 @@ Tip: for “missing dependency” tests, mark those components as `lazy=True` so
|
|
|
196
196
|
|
|
197
197
|
---
|
|
198
198
|
|
|
199
|
+
## 🔌 Extensibility: Plugins, Binder, and Lifecycle Hooks
|
|
200
|
+
|
|
201
|
+
From `v0.4.0` onward, Pico-IoC can be cleanly extended without patching the core.
|
|
202
|
+
This enables optional integration layers like `pico-ioc-rest` for Flask, FastAPI, etc., while keeping the core dependency-free.
|
|
203
|
+
|
|
204
|
+
### Plugin Protocol
|
|
205
|
+
|
|
206
|
+
A plugin is any object that implements some or all of the following methods:
|
|
207
|
+
|
|
208
|
+
```python
|
|
209
|
+
from pico_ioc import PicoPlugin, Binder
|
|
210
|
+
|
|
211
|
+
class MyPlugin:
|
|
212
|
+
def before_scan(self, package, binder: Binder): ...
|
|
213
|
+
def visit_class(self, module, cls, binder: Binder): ...
|
|
214
|
+
def after_scan(self, package, binder: Binder): ...
|
|
215
|
+
def after_bind(self, container, binder: Binder): ...
|
|
216
|
+
def before_eager(self, container, binder: Binder): ...
|
|
217
|
+
def after_ready(self, container, binder: Binder): ...
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
All hooks are optional. If present, they are called in this order during `init()`:
|
|
221
|
+
|
|
222
|
+
1. **before\_scan** — called before package scanning starts.
|
|
223
|
+
2. **visit\_class** — called for every class discovered during scanning.
|
|
224
|
+
3. **after\_scan** — called after scanning all modules.
|
|
225
|
+
4. **after\_bind** — called after the core has bound all components/factories.
|
|
226
|
+
5. **before\_eager** — called right before eager (non-lazy) instantiation.
|
|
227
|
+
6. **after\_ready** — called after all eager instantiation is complete.
|
|
228
|
+
|
|
229
|
+
### Binder API
|
|
230
|
+
|
|
231
|
+
Plugins receive a [`Binder`](#binder-api) instance in each hook, allowing them to:
|
|
232
|
+
|
|
233
|
+
* **bind**: register new providers in the container.
|
|
234
|
+
* **has**: check if a binding exists.
|
|
235
|
+
* **get**: resolve a binding immediately.
|
|
236
|
+
|
|
237
|
+
Example plugin that binds a “marker” component when a certain class is discovered:
|
|
238
|
+
|
|
239
|
+
```python
|
|
240
|
+
class MarkerPlugin:
|
|
241
|
+
def visit_class(self, module, cls, binder):
|
|
242
|
+
if cls.__name__ == "SpecialService" and not binder.has("marker"):
|
|
243
|
+
binder.bind("marker", lambda: {"ok": True}, lazy=False)
|
|
244
|
+
|
|
245
|
+
container = init("my_app", plugins=(MarkerPlugin(),))
|
|
246
|
+
assert container.get("marker") == {"ok": True}
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
### Creating Extensions
|
|
250
|
+
|
|
251
|
+
With the plugin API, you can build separate packages like `pico-ioc-rest`:
|
|
252
|
+
|
|
253
|
+
```python
|
|
254
|
+
from pico_ioc import PicoPlugin, Binder, create_instance, resolve_param
|
|
255
|
+
from flask import Flask
|
|
256
|
+
|
|
257
|
+
class FlaskRestPlugin:
|
|
258
|
+
def __init__(self):
|
|
259
|
+
self.controllers = []
|
|
260
|
+
|
|
261
|
+
def visit_class(self, module, cls, binder: Binder):
|
|
262
|
+
if getattr(cls, "_is_controller", False):
|
|
263
|
+
self.controllers.append(cls)
|
|
264
|
+
|
|
265
|
+
def after_bind(self, container, binder: Binder):
|
|
266
|
+
app: Flask = container.get(Flask)
|
|
267
|
+
for ctl_cls in self.controllers:
|
|
268
|
+
ctl = create_instance(ctl_cls, container)
|
|
269
|
+
# register routes here using `resolve_param` for handler DI
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
### Public Helpers for Extensions
|
|
273
|
+
|
|
274
|
+
Plugins can reuse Pico-IoC’s DI logic without duplicating it:
|
|
275
|
+
|
|
276
|
+
* **`create_instance(cls, container)`** — instantiate a class with DI, respecting Pico-IoC’s resolution order.
|
|
277
|
+
* **`resolve_param(container, parameter)`** — resolve a single function/class parameter via Pico-IoC rules.
|
|
278
|
+
|
|
279
|
+
---
|
|
280
|
+
|
|
199
281
|
## ❓ FAQ
|
|
200
282
|
|
|
201
283
|
**Q: Can I make the container lenient at startup?**
|
|
@@ -1,18 +1,32 @@
|
|
|
1
|
-
import functools
|
|
2
|
-
|
|
1
|
+
import functools
|
|
2
|
+
import importlib
|
|
3
|
+
import inspect
|
|
4
|
+
import logging
|
|
5
|
+
import pkgutil
|
|
3
6
|
from contextvars import ContextVar
|
|
7
|
+
from typing import Any, Callable, Dict, Optional, Protocol, Tuple, runtime_checkable
|
|
4
8
|
|
|
5
9
|
try:
|
|
6
10
|
from ._version import __version__
|
|
7
11
|
except Exception:
|
|
8
12
|
__version__ = "0.0.0"
|
|
9
13
|
|
|
10
|
-
__all__ = [
|
|
14
|
+
__all__ = [
|
|
15
|
+
"__version__",
|
|
16
|
+
"PicoContainer",
|
|
17
|
+
"Binder",
|
|
18
|
+
"PicoPlugin",
|
|
19
|
+
"init",
|
|
20
|
+
"component",
|
|
21
|
+
"factory_component",
|
|
22
|
+
"provides",
|
|
23
|
+
"resolve_param",
|
|
24
|
+
"create_instance",
|
|
25
|
+
]
|
|
11
26
|
|
|
12
27
|
_scanning: ContextVar[bool] = ContextVar("pico_scanning", default=False)
|
|
13
28
|
_resolving: ContextVar[bool] = ContextVar("pico_resolving", default=False)
|
|
14
29
|
|
|
15
|
-
|
|
16
30
|
class PicoContainer:
|
|
17
31
|
def __init__(self):
|
|
18
32
|
self._providers: Dict[Any, Dict[str, Any]] = {}
|
|
@@ -26,9 +40,7 @@ class PicoContainer:
|
|
|
26
40
|
|
|
27
41
|
def get(self, key: Any) -> Any:
|
|
28
42
|
if _scanning.get() and not _resolving.get():
|
|
29
|
-
raise RuntimeError(
|
|
30
|
-
"pico-ioc: re-entrant container access during scan."
|
|
31
|
-
)
|
|
43
|
+
raise RuntimeError("pico-ioc: re-entrant container access during scan.")
|
|
32
44
|
if key in self._singletons:
|
|
33
45
|
return self._singletons[key]
|
|
34
46
|
prov = self._providers.get(key)
|
|
@@ -43,6 +55,40 @@ class PicoContainer:
|
|
|
43
55
|
if not meta.get("lazy", False) and key not in self._singletons:
|
|
44
56
|
self.get(key)
|
|
45
57
|
|
|
58
|
+
class Binder:
|
|
59
|
+
def __init__(self, container: PicoContainer):
|
|
60
|
+
self._c = container
|
|
61
|
+
|
|
62
|
+
def bind(self, key: Any, provider: Callable[[], Any], *, lazy: bool = False):
|
|
63
|
+
self._c.bind(key, provider, lazy=lazy)
|
|
64
|
+
|
|
65
|
+
def has(self, key: Any) -> bool:
|
|
66
|
+
return self._c.has(key)
|
|
67
|
+
|
|
68
|
+
def get(self, key: Any) -> Any:
|
|
69
|
+
return self._c.get(key)
|
|
70
|
+
|
|
71
|
+
def factory_component(cls):
|
|
72
|
+
setattr(cls, '_is_factory_component', True)
|
|
73
|
+
return cls
|
|
74
|
+
|
|
75
|
+
def provides(key: Any, *, lazy: bool = False):
|
|
76
|
+
def decorator(func):
|
|
77
|
+
@functools.wraps(func)
|
|
78
|
+
def wrapper(*args, **kwargs):
|
|
79
|
+
return func(*args, **kwargs)
|
|
80
|
+
setattr(wrapper, '_provides_name', key)
|
|
81
|
+
setattr(wrapper, '_pico_lazy', bool(lazy))
|
|
82
|
+
return wrapper
|
|
83
|
+
return decorator
|
|
84
|
+
|
|
85
|
+
def component(cls=None, *, name: Any = None, lazy: bool = False):
|
|
86
|
+
def decorator(c):
|
|
87
|
+
setattr(c, '_is_component', True)
|
|
88
|
+
setattr(c, '_component_key', name if name is not None else c)
|
|
89
|
+
setattr(c, '_component_lazy', bool(lazy))
|
|
90
|
+
return c
|
|
91
|
+
return decorator(cls) if cls else decorator
|
|
46
92
|
|
|
47
93
|
class ComponentProxy:
|
|
48
94
|
def __init__(self, object_creator: Callable[[], Any]):
|
|
@@ -117,8 +163,7 @@ class ComponentProxy:
|
|
|
117
163
|
def __enter__(self): return self._get_real_object().__enter__()
|
|
118
164
|
def __exit__(self, exc_type, exc_val, exc_tb): return self._get_real_object().__exit__(exc_type, exc_val, exc_tb)
|
|
119
165
|
|
|
120
|
-
|
|
121
|
-
def _resolve_param(container: PicoContainer, p: inspect.Parameter) -> Any:
|
|
166
|
+
def resolve_param(container: PicoContainer, p: inspect.Parameter) -> Any:
|
|
122
167
|
if p.name == 'self' or p.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
|
|
123
168
|
raise RuntimeError("Invalid param for resolution")
|
|
124
169
|
if container.has(p.name):
|
|
@@ -140,10 +185,44 @@ def _resolve_param(container: PicoContainer, p: inspect.Parameter) -> Any:
|
|
|
140
185
|
key = p.name if ann is inspect._empty else ann
|
|
141
186
|
return container.get(key)
|
|
142
187
|
|
|
188
|
+
def create_instance(cls: type, container: PicoContainer) -> Any:
|
|
189
|
+
sig = inspect.signature(cls.__init__)
|
|
190
|
+
deps = {}
|
|
191
|
+
tok = _resolving.set(True)
|
|
192
|
+
try:
|
|
193
|
+
for p in sig.parameters.values():
|
|
194
|
+
if p.name == 'self' or p.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
|
|
195
|
+
continue
|
|
196
|
+
deps[p.name] = resolve_param(container, p)
|
|
197
|
+
finally:
|
|
198
|
+
_resolving.reset(tok)
|
|
199
|
+
return cls(**deps)
|
|
200
|
+
|
|
201
|
+
@runtime_checkable
|
|
202
|
+
class PicoPlugin(Protocol):
|
|
203
|
+
def before_scan(self, package: Any, binder: Binder) -> None: ...
|
|
204
|
+
def visit_class(self, module: Any, cls: type, binder: Binder) -> None: ...
|
|
205
|
+
def after_scan(self, package: Any, binder: Binder) -> None: ...
|
|
206
|
+
def after_bind(self, container: PicoContainer, binder: Binder) -> None: ...
|
|
207
|
+
def before_eager(self, container: PicoContainer, binder: Binder) -> None: ...
|
|
208
|
+
def after_ready(self, container: PicoContainer, binder: Binder) -> None: ...
|
|
143
209
|
|
|
144
|
-
def _scan_and_configure(
|
|
210
|
+
def _scan_and_configure(
|
|
211
|
+
package_or_name,
|
|
212
|
+
container: PicoContainer,
|
|
213
|
+
*,
|
|
214
|
+
exclude: Optional[Callable[[str], bool]] = None,
|
|
215
|
+
plugins: Tuple[PicoPlugin, ...] = (),
|
|
216
|
+
):
|
|
145
217
|
package = importlib.import_module(package_or_name) if isinstance(package_or_name, str) else package_or_name
|
|
146
218
|
logging.info(f"Scanning in '{package.__name__}'...")
|
|
219
|
+
binder = Binder(container)
|
|
220
|
+
for pl in plugins:
|
|
221
|
+
try:
|
|
222
|
+
if hasattr(pl, "before_scan"):
|
|
223
|
+
pl.before_scan(package, binder)
|
|
224
|
+
except Exception:
|
|
225
|
+
logging.exception("Plugin before_scan failed")
|
|
147
226
|
component_classes, factory_classes = [], []
|
|
148
227
|
for _, name, _ in pkgutil.walk_packages(package.__path__, package.__name__ + '.'):
|
|
149
228
|
if exclude and exclude(name):
|
|
@@ -152,12 +231,24 @@ def _scan_and_configure(package_or_name, container: PicoContainer, exclude: Opti
|
|
|
152
231
|
try:
|
|
153
232
|
module = importlib.import_module(name)
|
|
154
233
|
for _, obj in inspect.getmembers(module, inspect.isclass):
|
|
234
|
+
for pl in plugins:
|
|
235
|
+
try:
|
|
236
|
+
if hasattr(pl, "visit_class"):
|
|
237
|
+
pl.visit_class(module, obj, binder)
|
|
238
|
+
except Exception:
|
|
239
|
+
logging.exception("Plugin visit_class failed")
|
|
155
240
|
if getattr(obj, '_is_component', False):
|
|
156
241
|
component_classes.append(obj)
|
|
157
242
|
elif getattr(obj, '_is_factory_component', False):
|
|
158
243
|
factory_classes.append(obj)
|
|
159
244
|
except Exception as e:
|
|
160
245
|
logging.warning(f"Module {name} not processed: {e}")
|
|
246
|
+
for pl in plugins:
|
|
247
|
+
try:
|
|
248
|
+
if hasattr(pl, "after_scan"):
|
|
249
|
+
pl.after_scan(package, binder)
|
|
250
|
+
except Exception:
|
|
251
|
+
logging.exception("Plugin after_scan failed")
|
|
161
252
|
for factory_cls in factory_classes:
|
|
162
253
|
try:
|
|
163
254
|
sig = inspect.signature(factory_cls.__init__)
|
|
@@ -173,36 +264,28 @@ def _scan_and_configure(package_or_name, container: PicoContainer, exclude: Opti
|
|
|
173
264
|
return m()
|
|
174
265
|
return _factory
|
|
175
266
|
container.bind(key, make_provider(), lazy=is_lazy)
|
|
176
|
-
except Exception
|
|
177
|
-
logging.
|
|
267
|
+
except Exception:
|
|
268
|
+
logging.exception(f"Error in factory {factory_cls.__name__}")
|
|
178
269
|
for component_cls in component_classes:
|
|
179
270
|
key = getattr(component_cls, '_component_key', component_cls)
|
|
180
271
|
is_lazy = bool(getattr(component_cls, '_component_lazy', False))
|
|
181
|
-
def
|
|
182
|
-
sig = inspect.signature(cls.__init__)
|
|
183
|
-
deps = {}
|
|
184
|
-
tok = _resolving.set(True)
|
|
185
|
-
try:
|
|
186
|
-
for p in sig.parameters.values():
|
|
187
|
-
if p.name == 'self' or p.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
|
|
188
|
-
continue
|
|
189
|
-
deps[p.name] = _resolve_param(container, p)
|
|
190
|
-
finally:
|
|
191
|
-
_resolving.reset(tok)
|
|
192
|
-
return cls(**deps)
|
|
193
|
-
def provider_factory(lazy=is_lazy, maker=create_component):
|
|
272
|
+
def provider_factory(lazy=is_lazy, cls=component_cls):
|
|
194
273
|
def _factory():
|
|
195
274
|
if lazy:
|
|
196
|
-
return ComponentProxy(
|
|
197
|
-
return
|
|
275
|
+
return ComponentProxy(lambda: create_instance(cls, container))
|
|
276
|
+
return create_instance(cls, container)
|
|
198
277
|
return _factory
|
|
199
278
|
container.bind(key, provider_factory(), lazy=is_lazy)
|
|
200
279
|
|
|
280
|
+
_container: Optional[PicoContainer] = None
|
|
201
281
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
282
|
+
def init(
|
|
283
|
+
root_package,
|
|
284
|
+
*,
|
|
285
|
+
exclude: Optional[Callable[[str], bool]] = None,
|
|
286
|
+
auto_exclude_caller: bool = True,
|
|
287
|
+
plugins: Tuple[PicoPlugin, ...] = (),
|
|
288
|
+
):
|
|
206
289
|
global _container
|
|
207
290
|
if _container:
|
|
208
291
|
return _container
|
|
@@ -223,17 +306,37 @@ def init(root_package, *, exclude: Optional[Callable[[str], bool]] = None, auto_
|
|
|
223
306
|
def combined_exclude(mod: str, _caller=caller_name, _prev=prev):
|
|
224
307
|
return mod == _caller or _prev(mod)
|
|
225
308
|
_container = PicoContainer()
|
|
309
|
+
binder = Binder(_container)
|
|
226
310
|
logging.info("Initializing pico-ioc...")
|
|
227
311
|
tok = _scanning.set(True)
|
|
228
312
|
try:
|
|
229
|
-
_scan_and_configure(root_package, _container, exclude=combined_exclude)
|
|
313
|
+
_scan_and_configure(root_package, _container, exclude=combined_exclude, plugins=plugins)
|
|
230
314
|
finally:
|
|
231
315
|
_scanning.reset(tok)
|
|
316
|
+
for pl in plugins:
|
|
317
|
+
try:
|
|
318
|
+
if hasattr(pl, "after_bind"):
|
|
319
|
+
pl.after_bind(_container, binder)
|
|
320
|
+
except Exception:
|
|
321
|
+
logging.exception("Plugin after_bind failed")
|
|
322
|
+
for pl in plugins:
|
|
323
|
+
try:
|
|
324
|
+
if hasattr(pl, "before_eager"):
|
|
325
|
+
pl.before_eager(_container, binder)
|
|
326
|
+
except Exception:
|
|
327
|
+
logging.exception("Plugin before_eager failed")
|
|
232
328
|
_container._eager_instantiate_all()
|
|
329
|
+
for pl in plugins:
|
|
330
|
+
try:
|
|
331
|
+
if hasattr(pl, "after_ready"):
|
|
332
|
+
pl.after_ready(_container, binder)
|
|
333
|
+
except Exception:
|
|
334
|
+
logging.exception("Plugin after_ready failed")
|
|
233
335
|
logging.info("Container configured and ready.")
|
|
234
336
|
return _container
|
|
235
337
|
|
|
236
338
|
|
|
339
|
+
|
|
237
340
|
def factory_component(cls):
|
|
238
341
|
setattr(cls, '_is_factory_component', True)
|
|
239
342
|
return cls
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = '0.4.0'
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pico-ioc
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: A minimalist, zero-dependency Inversion of Control (IoC) container for Python.
|
|
5
5
|
Author-email: David Perez Cabrera <dperezcabrera@gmail.com>
|
|
6
6
|
Project-URL: Homepage, https://github.com/dperezcabrera/pico-ioc
|
|
@@ -219,6 +219,88 @@ Tip: for “missing dependency” tests, mark those components as `lazy=True` so
|
|
|
219
219
|
|
|
220
220
|
---
|
|
221
221
|
|
|
222
|
+
## 🔌 Extensibility: Plugins, Binder, and Lifecycle Hooks
|
|
223
|
+
|
|
224
|
+
From `v0.4.0` onward, Pico-IoC can be cleanly extended without patching the core.
|
|
225
|
+
This enables optional integration layers like `pico-ioc-rest` for Flask, FastAPI, etc., while keeping the core dependency-free.
|
|
226
|
+
|
|
227
|
+
### Plugin Protocol
|
|
228
|
+
|
|
229
|
+
A plugin is any object that implements some or all of the following methods:
|
|
230
|
+
|
|
231
|
+
```python
|
|
232
|
+
from pico_ioc import PicoPlugin, Binder
|
|
233
|
+
|
|
234
|
+
class MyPlugin:
|
|
235
|
+
def before_scan(self, package, binder: Binder): ...
|
|
236
|
+
def visit_class(self, module, cls, binder: Binder): ...
|
|
237
|
+
def after_scan(self, package, binder: Binder): ...
|
|
238
|
+
def after_bind(self, container, binder: Binder): ...
|
|
239
|
+
def before_eager(self, container, binder: Binder): ...
|
|
240
|
+
def after_ready(self, container, binder: Binder): ...
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
All hooks are optional. If present, they are called in this order during `init()`:
|
|
244
|
+
|
|
245
|
+
1. **before\_scan** — called before package scanning starts.
|
|
246
|
+
2. **visit\_class** — called for every class discovered during scanning.
|
|
247
|
+
3. **after\_scan** — called after scanning all modules.
|
|
248
|
+
4. **after\_bind** — called after the core has bound all components/factories.
|
|
249
|
+
5. **before\_eager** — called right before eager (non-lazy) instantiation.
|
|
250
|
+
6. **after\_ready** — called after all eager instantiation is complete.
|
|
251
|
+
|
|
252
|
+
### Binder API
|
|
253
|
+
|
|
254
|
+
Plugins receive a [`Binder`](#binder-api) instance in each hook, allowing them to:
|
|
255
|
+
|
|
256
|
+
* **bind**: register new providers in the container.
|
|
257
|
+
* **has**: check if a binding exists.
|
|
258
|
+
* **get**: resolve a binding immediately.
|
|
259
|
+
|
|
260
|
+
Example plugin that binds a “marker” component when a certain class is discovered:
|
|
261
|
+
|
|
262
|
+
```python
|
|
263
|
+
class MarkerPlugin:
|
|
264
|
+
def visit_class(self, module, cls, binder):
|
|
265
|
+
if cls.__name__ == "SpecialService" and not binder.has("marker"):
|
|
266
|
+
binder.bind("marker", lambda: {"ok": True}, lazy=False)
|
|
267
|
+
|
|
268
|
+
container = init("my_app", plugins=(MarkerPlugin(),))
|
|
269
|
+
assert container.get("marker") == {"ok": True}
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
### Creating Extensions
|
|
273
|
+
|
|
274
|
+
With the plugin API, you can build separate packages like `pico-ioc-rest`:
|
|
275
|
+
|
|
276
|
+
```python
|
|
277
|
+
from pico_ioc import PicoPlugin, Binder, create_instance, resolve_param
|
|
278
|
+
from flask import Flask
|
|
279
|
+
|
|
280
|
+
class FlaskRestPlugin:
|
|
281
|
+
def __init__(self):
|
|
282
|
+
self.controllers = []
|
|
283
|
+
|
|
284
|
+
def visit_class(self, module, cls, binder: Binder):
|
|
285
|
+
if getattr(cls, "_is_controller", False):
|
|
286
|
+
self.controllers.append(cls)
|
|
287
|
+
|
|
288
|
+
def after_bind(self, container, binder: Binder):
|
|
289
|
+
app: Flask = container.get(Flask)
|
|
290
|
+
for ctl_cls in self.controllers:
|
|
291
|
+
ctl = create_instance(ctl_cls, container)
|
|
292
|
+
# register routes here using `resolve_param` for handler DI
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
### Public Helpers for Extensions
|
|
296
|
+
|
|
297
|
+
Plugins can reuse Pico-IoC’s DI logic without duplicating it:
|
|
298
|
+
|
|
299
|
+
* **`create_instance(cls, container)`** — instantiate a class with DI, respecting Pico-IoC’s resolution order.
|
|
300
|
+
* **`resolve_param(container, parameter)`** — resolve a single function/class parameter via Pico-IoC rules.
|
|
301
|
+
|
|
302
|
+
---
|
|
303
|
+
|
|
222
304
|
## ❓ FAQ
|
|
223
305
|
|
|
224
306
|
**Q: Can I make the container lenient at startup?**
|
|
@@ -86,7 +86,7 @@ class ServiceFactory:
|
|
|
86
86
|
CREATION_COUNTER["value"] += 1
|
|
87
87
|
return "This is a complex service"
|
|
88
88
|
|
|
89
|
-
@provides(key="fast_model") # eager by default;
|
|
89
|
+
@provides(key="fast_model") # eager by default; instantiated once
|
|
90
90
|
def create_fast_model(self):
|
|
91
91
|
FAST_COUNTER["value"] += 1
|
|
92
92
|
return {"who": "fast"}
|
|
@@ -106,7 +106,7 @@ import test_project
|
|
|
106
106
|
from test_project.services.components import SimpleService
|
|
107
107
|
|
|
108
108
|
ioc = pico_ioc.init(test_project)
|
|
109
|
-
ioc.get(SimpleService) # should raise
|
|
109
|
+
ioc.get(SimpleService) # should raise during scan; import is caught/logged by scanner
|
|
110
110
|
"""
|
|
111
111
|
)
|
|
112
112
|
|
|
@@ -121,7 +121,7 @@ ioc.get(SimpleService) # should raise RuntimeError due to re-entrant access dur
|
|
|
121
121
|
sys.modules.pop(m, None)
|
|
122
122
|
|
|
123
123
|
|
|
124
|
-
# ---
|
|
124
|
+
# --- Core behavior tests ---
|
|
125
125
|
|
|
126
126
|
def test_simple_component_retrieval(test_project):
|
|
127
127
|
from test_project.services.components import SimpleService
|
|
@@ -193,7 +193,7 @@ def test_resolution_prefers_name_over_type(test_project):
|
|
|
193
193
|
container = pico_ioc.init(test_project)
|
|
194
194
|
comp = container.get(NeedsNameVsType)
|
|
195
195
|
assert comp.model == {"who": "fast"}
|
|
196
|
-
assert FAST_COUNTER["value"] == 1 # created once by
|
|
196
|
+
assert FAST_COUNTER["value"] == 1 # created once by factory
|
|
197
197
|
assert BASE_COUNTER["value"] == 0 # still lazy
|
|
198
198
|
|
|
199
199
|
|
|
@@ -218,7 +218,6 @@ def test_resolution_fallback_to_type_mro(test_project):
|
|
|
218
218
|
def test_missing_dependency_raises_clear_error(test_project):
|
|
219
219
|
from test_project.services.components import MissingDep
|
|
220
220
|
container = pico_ioc.init(test_project)
|
|
221
|
-
|
|
222
221
|
proxy = container.get(MissingDep) # returns ComponentProxy
|
|
223
222
|
with pytest.raises(NameError, match="No provider found for key"):
|
|
224
223
|
_ = bool(proxy)
|
|
@@ -227,6 +226,7 @@ def test_missing_dependency_raises_clear_error(test_project):
|
|
|
227
226
|
def test_reentrant_access_is_blocked_and_container_still_initializes(test_project, caplog):
|
|
228
227
|
caplog.set_level(logging.INFO)
|
|
229
228
|
container = pico_ioc.init(test_project)
|
|
229
|
+
# The scanner logs a warning including the RuntimeError text thrown by get()
|
|
230
230
|
assert any(
|
|
231
231
|
"re-entrant container access during scan" in rec.message
|
|
232
232
|
for rec in caplog.records
|
|
@@ -235,3 +235,43 @@ def test_reentrant_access_is_blocked_and_container_still_initializes(test_projec
|
|
|
235
235
|
svc = container.get(SimpleService)
|
|
236
236
|
assert isinstance(svc, SimpleService)
|
|
237
237
|
|
|
238
|
+
|
|
239
|
+
# --- Plugin & Binder smoke test ---
|
|
240
|
+
|
|
241
|
+
def test_plugin_hooks_and_binder(test_project):
|
|
242
|
+
"""
|
|
243
|
+
Verifies that a plugin can observe classes during scan, bind a value via Binder,
|
|
244
|
+
and see lifecycle hooks firing in order.
|
|
245
|
+
"""
|
|
246
|
+
calls = []
|
|
247
|
+
|
|
248
|
+
class MyPlugin:
|
|
249
|
+
def before_scan(self, package, binder):
|
|
250
|
+
calls.append("before_scan")
|
|
251
|
+
|
|
252
|
+
def visit_class(self, module, cls, binder):
|
|
253
|
+
# As an example, bind a marker when we see a specific class name
|
|
254
|
+
if cls.__name__ == "SimpleService" and not binder.has("marker"):
|
|
255
|
+
binder.bind("marker", lambda: {"ok": True}, lazy=False)
|
|
256
|
+
|
|
257
|
+
def after_scan(self, package, binder):
|
|
258
|
+
calls.append("after_scan")
|
|
259
|
+
|
|
260
|
+
def after_bind(self, container, binder):
|
|
261
|
+
calls.append("after_bind")
|
|
262
|
+
|
|
263
|
+
def before_eager(self, container, binder):
|
|
264
|
+
calls.append("before_eager")
|
|
265
|
+
|
|
266
|
+
def after_ready(self, container, binder):
|
|
267
|
+
calls.append("after_ready")
|
|
268
|
+
|
|
269
|
+
container = pico_ioc.init(test_project, plugins=(MyPlugin(),))
|
|
270
|
+
|
|
271
|
+
# Order is not strictly enforced between middle hooks, but all should be present.
|
|
272
|
+
for expected in ["before_scan", "after_scan", "after_bind", "before_eager", "after_ready"]:
|
|
273
|
+
assert expected in calls
|
|
274
|
+
|
|
275
|
+
# Marker bound by plugin should be retrievable
|
|
276
|
+
assert container.get("marker") == {"ok": True}
|
|
277
|
+
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = '0.3.1'
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|