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 CHANGED
@@ -1,18 +1,32 @@
1
- import functools, inspect, pkgutil, importlib, logging
2
- from typing import Callable, Any, Optional, Dict
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__ = ["__version__"]
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(package_or_name, container: PicoContainer, exclude: Optional[Callable[[str], bool]] = None):
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 as e:
177
- logging.error(f"Error in factory {factory_cls.__name__}: {e}", exc_info=True)
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 create_component(cls=component_cls):
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(maker)
197
- return maker()
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
- _container = None
203
-
204
-
205
- def init(root_package, *, exclude: Optional[Callable[[str], bool]] = None, auto_exclude_caller: bool = True):
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.3.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.1
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,,
@@ -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,,