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.
@@ -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?**
@@ -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, 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
@@ -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.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?**
@@ -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; blueprint will instantiate once
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 RuntimeError due to re-entrant access during scan
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
- # --- Test Suite ---
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 blueprint
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