pico-ioc 0.3.1__py3-none-any.whl → 0.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 +135 -32
- pico_ioc/_version.py +1 -1
- {pico_ioc-0.3.1.dist-info → pico_ioc-0.4.0.dist-info}/METADATA +83 -1
- pico_ioc-0.4.0.dist-info/RECORD +6 -0
- pico_ioc-0.3.1.dist-info/RECORD +0 -6
- {pico_ioc-0.3.1.dist-info → pico_ioc-0.4.0.dist-info}/WHEEL +0 -0
- {pico_ioc-0.3.1.dist-info → pico_ioc-0.4.0.dist-info}/top_level.txt +0 -0
pico_ioc/__init__.py
CHANGED
|
@@ -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
|
pico_ioc/_version.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = '0.
|
|
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?**
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
pico_ioc/__init__.py,sha256=IqQOM75teE_gplSaemvxrfZjjj3w8-PBoRWRzFtZsz4,15219
|
|
2
|
+
pico_ioc/_version.py,sha256=2eiWQI55fd-roDdkt4Hvl9WzrTJ4xQo33VzFud6D03U,22
|
|
3
|
+
pico_ioc-0.4.0.dist-info/METADATA,sha256=vpgj38emsZxoJoGcW8-CWBt5K59U8cUF2GZrCzGy2ss,10733
|
|
4
|
+
pico_ioc-0.4.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
5
|
+
pico_ioc-0.4.0.dist-info/top_level.txt,sha256=_7_RLu616z_dtRw16impXn4Mw8IXe2J4BeX5912m5dQ,9
|
|
6
|
+
pico_ioc-0.4.0.dist-info/RECORD,,
|
pico_ioc-0.3.1.dist-info/RECORD
DELETED
|
@@ -1,6 +0,0 @@
|
|
|
1
|
-
pico_ioc/__init__.py,sha256=8VKuQM7hgXNrkPNMnpZ8ixg6LK4DpKCU-hvSSidGzEU,11874
|
|
2
|
-
pico_ioc/_version.py,sha256=TZkGuMIRSRmUY3XCIs5owt2o60vXyqYMHWIkhx65uYE,22
|
|
3
|
-
pico_ioc-0.3.1.dist-info/METADATA,sha256=oR-ft50RlnmX5BB_bFOoq5tIuaVflb3GTGXU9rI5oFM,7751
|
|
4
|
-
pico_ioc-0.3.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
5
|
-
pico_ioc-0.3.1.dist-info/top_level.txt,sha256=_7_RLu616z_dtRw16impXn4Mw8IXe2J4BeX5912m5dQ,9
|
|
6
|
-
pico_ioc-0.3.1.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|