pico-ioc 0.3.0__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,63 +1,96 @@
1
- import functools, inspect, pkgutil, importlib, logging
2
- from typing import Callable, Any, Optional
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
- # written at build time by setuptools-scm
7
10
  from ._version import __version__
8
- except Exception: # pragma: no cover
11
+ except Exception:
9
12
  __version__ = "0.0.0"
10
13
 
11
- __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
+ ]
12
26
 
13
- # ------------------------------------------------------------------------------
14
- # Re-entrancy guards
15
- # ------------------------------------------------------------------------------
16
- # True while init/scan is running. Blocks userland container access during scan.
17
27
  _scanning: ContextVar[bool] = ContextVar("pico_scanning", default=False)
18
-
19
- # True while the container is resolving deps for a component (internal use allowed).
20
28
  _resolving: ContextVar[bool] = ContextVar("pico_resolving", default=False)
21
29
 
22
- # ==============================================================================
23
- # --- 1. Container and Chameleon Proxy (Framework-Agnostic) ---
24
- # ==============================================================================
25
30
  class PicoContainer:
26
31
  def __init__(self):
27
- self._providers: dict[Any, Callable[[], Any]] = {}
28
- self._singletons: dict[Any, Any] = {}
32
+ self._providers: Dict[Any, Dict[str, Any]] = {}
33
+ self._singletons: Dict[Any, Any] = {}
29
34
 
30
- def bind(self, key: Any, provider: Callable[[], Any]):
31
- self._providers[key] = provider
35
+ def bind(self, key: Any, provider: Callable[[], Any], *, lazy: bool):
36
+ self._providers[key] = {"factory": provider, "lazy": bool(lazy)}
32
37
 
33
38
  def has(self, key: Any) -> bool:
34
39
  return key in self._providers or key in self._singletons
35
40
 
36
41
  def get(self, key: Any) -> Any:
37
- # Forbid user code calling container.get() while the scanner is importing modules.
38
- # Allow only internal calls performed during dependency resolution.
39
42
  if _scanning.get() and not _resolving.get():
40
- raise RuntimeError(
41
- "pico-ioc: re-entrant container access during scan. "
42
- "Avoid calling init()/get() at import time (e.g., in a module body). "
43
- "Move resolution to runtime (e.g., under if __name__ == '__main__':) "
44
- "or delay it until pico-ioc init completes."
45
- )
46
-
43
+ raise RuntimeError("pico-ioc: re-entrant container access during scan.")
47
44
  if key in self._singletons:
48
45
  return self._singletons[key]
49
- if key in self._providers:
50
- instance = self._providers[key]()
51
- self._singletons[key] = instance
52
- return instance
53
- raise NameError(f"No provider found for key: {key}")
54
-
55
- class LazyProxy:
56
- """
57
- A full-fledged lazy proxy that delegates almost all operations
58
- to the real object, which is created only on first access.
59
- It is completely framework-agnostic.
60
- """
46
+ prov = self._providers.get(key)
47
+ if prov is None:
48
+ raise NameError(f"No provider found for key: {key}")
49
+ instance = prov["factory"]()
50
+ self._singletons[key] = instance
51
+ return instance
52
+
53
+ def _eager_instantiate_all(self):
54
+ for key, meta in list(self._providers.items()):
55
+ if not meta.get("lazy", False) and key not in self._singletons:
56
+ self.get(key)
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
92
+
93
+ class ComponentProxy:
61
94
  def __init__(self, object_creator: Callable[[], Any]):
62
95
  object.__setattr__(self, "_object_creator", object_creator)
63
96
  object.__setattr__(self, "__real_object", None)
@@ -69,30 +102,16 @@ class LazyProxy:
69
102
  object.__setattr__(self, "__real_object", real_obj)
70
103
  return real_obj
71
104
 
72
- # --- Core Proxying and Representation ---
73
105
  @property
74
106
  def __class__(self):
75
107
  return self._get_real_object().__class__
76
108
 
77
- def __getattr__(self, name):
78
- return getattr(self._get_real_object(), name)
79
-
80
- def __setattr__(self, name, value):
81
- setattr(self._get_real_object(), name, value)
82
-
83
- def __delattr__(self, name):
84
- delattr(self._get_real_object(), name)
85
-
86
- def __str__(self):
87
- return str(self._get_real_object())
88
-
89
- def __repr__(self):
90
- return repr(self._get_real_object())
91
-
92
- def __dir__(self):
93
- return dir(self._get_real_object())
94
-
95
- # --- Emulation of container types ---
109
+ def __getattr__(self, name): return getattr(self._get_real_object(), name)
110
+ def __setattr__(self, name, value): setattr(self._get_real_object(), name, value)
111
+ def __delattr__(self, name): delattr(self._get_real_object(), name)
112
+ def __str__(self): return str(self._get_real_object())
113
+ def __repr__(self): return repr(self._get_real_object())
114
+ def __dir__(self): return dir(self._get_real_object())
96
115
  def __len__(self): return len(self._get_real_object())
97
116
  def __getitem__(self, key): return self._get_real_object()[key]
98
117
  def __setitem__(self, key, value): self._get_real_object()[key] = value
@@ -100,8 +119,6 @@ class LazyProxy:
100
119
  def __iter__(self): return iter(self._get_real_object())
101
120
  def __reversed__(self): return reversed(self._get_real_object())
102
121
  def __contains__(self, item): return item in self._get_real_object()
103
-
104
- # --- Emulation of numeric types and operators ---
105
122
  def __add__(self, other): return self._get_real_object() + other
106
123
  def __sub__(self, other): return self._get_real_object() - other
107
124
  def __mul__(self, other): return self._get_real_object() * other
@@ -116,8 +133,6 @@ class LazyProxy:
116
133
  def __and__(self, other): return self._get_real_object() & other
117
134
  def __xor__(self, other): return self._get_real_object() ^ other
118
135
  def __or__(self, other): return self._get_real_object() | other
119
-
120
- # --- Right-hand side numeric operators ---
121
136
  def __radd__(self, other): return other + self._get_real_object()
122
137
  def __rsub__(self, other): return other - self._get_real_object()
123
138
  def __rmul__(self, other): return other * self._get_real_object()
@@ -132,14 +147,10 @@ class LazyProxy:
132
147
  def __rand__(self, other): return other & self._get_real_object()
133
148
  def __rxor__(self, other): return other ^ self._get_real_object()
134
149
  def __ror__(self, other): return other | self._get_real_object()
135
-
136
- # --- Unary operators ---
137
150
  def __neg__(self): return -self._get_real_object()
138
151
  def __pos__(self): return +self._get_real_object()
139
152
  def __abs__(self): return abs(self._get_real_object())
140
153
  def __invert__(self): return ~self._get_real_object()
141
-
142
- # --- Comparison operators ---
143
154
  def __eq__(self, other): return self._get_real_object() == other
144
155
  def __ne__(self, other): return self._get_real_object() != other
145
156
  def __lt__(self, other): return self._get_real_object() < other
@@ -147,31 +158,19 @@ class LazyProxy:
147
158
  def __gt__(self, other): return self._get_real_object() > other
148
159
  def __ge__(self, other): return self._get_real_object() >= other
149
160
  def __hash__(self): return hash(self._get_real_object())
150
-
151
- # --- Truthiness, Callability and Context Management ---
152
161
  def __bool__(self): return bool(self._get_real_object())
153
162
  def __call__(self, *args, **kwargs): return self._get_real_object()(*args, **kwargs)
154
163
  def __enter__(self): return self._get_real_object().__enter__()
155
164
  def __exit__(self, exc_type, exc_val, exc_tb): return self._get_real_object().__exit__(exc_type, exc_val, exc_tb)
156
165
 
157
- # ==============================================================================
158
- # --- 2. The Scanner and `init` Facade ---
159
- # ==============================================================================
160
- def _resolve_param(container: PicoContainer, p: inspect.Parameter) -> Any:
166
+ def resolve_param(container: PicoContainer, p: inspect.Parameter) -> Any:
161
167
  if p.name == 'self' or p.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
162
168
  raise RuntimeError("Invalid param for resolution")
163
-
164
- # 1) NAME
165
169
  if container.has(p.name):
166
170
  return container.get(p.name)
167
-
168
171
  ann = p.annotation
169
-
170
- # 2) TYPE
171
172
  if ann is not inspect._empty and container.has(ann):
172
173
  return container.get(ann)
173
-
174
- # 3) TYPE MRO
175
174
  if ann is not inspect._empty:
176
175
  try:
177
176
  for base in getattr(ann, "__mro__", ())[1:]:
@@ -181,80 +180,115 @@ def _resolve_param(container: PicoContainer, p: inspect.Parameter) -> Any:
181
180
  return container.get(base)
182
181
  except Exception:
183
182
  pass
184
-
185
- # 4) str(NAME)
186
183
  if container.has(str(p.name)):
187
184
  return container.get(str(p.name))
188
-
189
185
  key = p.name if ann is inspect._empty else ann
190
186
  return container.get(key)
191
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: ...
192
209
 
193
210
  def _scan_and_configure(
194
211
  package_or_name,
195
212
  container: PicoContainer,
196
- exclude: Optional[Callable[[str], bool]] = None
213
+ *,
214
+ exclude: Optional[Callable[[str], bool]] = None,
215
+ plugins: Tuple[PicoPlugin, ...] = (),
197
216
  ):
198
217
  package = importlib.import_module(package_or_name) if isinstance(package_or_name, str) else package_or_name
199
- logging.info(f"🚀 Scanning in '{package.__name__}'...")
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")
200
226
  component_classes, factory_classes = [], []
201
-
202
227
  for _, name, _ in pkgutil.walk_packages(package.__path__, package.__name__ + '.'):
203
228
  if exclude and exclude(name):
204
- logging.info(f" ⏭️ Skipping module {name} (excluded)")
229
+ logging.info(f"Skipping module {name} (excluded)")
205
230
  continue
206
231
  try:
207
232
  module = importlib.import_module(name)
208
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")
209
240
  if getattr(obj, '_is_component', False):
210
241
  component_classes.append(obj)
211
242
  elif getattr(obj, '_is_factory_component', False):
212
243
  factory_classes.append(obj)
213
244
  except Exception as e:
214
- logging.warning(f" ⚠️ Module {name} not processed: {e}")
215
-
216
- # Register factories
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")
217
252
  for factory_cls in factory_classes:
218
253
  try:
219
254
  sig = inspect.signature(factory_cls.__init__)
220
255
  instance = factory_cls(container) if 'container' in sig.parameters else factory_cls()
221
256
  for _, method in inspect.getmembers(instance, inspect.ismethod):
222
257
  if hasattr(method, '_provides_name'):
223
- container.bind(getattr(method, '_provides_name'), method)
224
- except Exception as e:
225
- logging.error(f" ❌ Error in factory {factory_cls.__name__}: {e}", exc_info=True)
226
-
227
- # Register components
258
+ key = getattr(method, '_provides_name')
259
+ is_lazy = bool(getattr(method, '_pico_lazy', False))
260
+ def make_provider(m=method, lazy=is_lazy):
261
+ def _factory():
262
+ if lazy:
263
+ return ComponentProxy(lambda: m())
264
+ return m()
265
+ return _factory
266
+ container.bind(key, make_provider(), lazy=is_lazy)
267
+ except Exception:
268
+ logging.exception(f"Error in factory {factory_cls.__name__}")
228
269
  for component_cls in component_classes:
229
270
  key = getattr(component_cls, '_component_key', component_cls)
230
-
231
- def create_component(cls=component_cls):
232
- sig = inspect.signature(cls.__init__)
233
- deps = {}
234
- tok = _resolving.set(True)
235
- try:
236
- for p in sig.parameters.values():
237
- if p.name == 'self' or p.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
238
- continue
239
- deps[p.name] = _resolve_param(container, p)
240
- finally:
241
- _resolving.reset(tok)
242
- return cls(**deps)
243
-
244
- container.bind(key, create_component)
245
-
246
- _container = None
247
-
248
- def init(root_package, *, exclude: Optional[Callable[[str], bool]] = None, auto_exclude_caller: bool = True):
249
- """
250
- Initialize the global container and scan the given root package/module.
251
- While scanning, re-entrant userland access to container.get() is blocked
252
- to avoid import-time side effects.
253
- """
271
+ is_lazy = bool(getattr(component_cls, '_component_lazy', False))
272
+ def provider_factory(lazy=is_lazy, cls=component_cls):
273
+ def _factory():
274
+ if lazy:
275
+ return ComponentProxy(lambda: create_instance(cls, container))
276
+ return create_instance(cls, container)
277
+ return _factory
278
+ container.bind(key, provider_factory(), lazy=is_lazy)
279
+
280
+ _container: Optional[PicoContainer] = None
281
+
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
+ ):
254
289
  global _container
255
290
  if _container:
256
291
  return _container
257
-
258
292
  combined_exclude = exclude
259
293
  if auto_exclude_caller:
260
294
  try:
@@ -263,7 +297,6 @@ def init(root_package, *, exclude: Optional[Callable[[str], bool]] = None, auto_
263
297
  caller_name = getattr(caller_module, "__name__", None)
264
298
  except Exception:
265
299
  caller_name = None
266
-
267
300
  if caller_name:
268
301
  if combined_exclude is None:
269
302
  def combined_exclude(mod: str, _caller=caller_name):
@@ -272,47 +305,59 @@ def init(root_package, *, exclude: Optional[Callable[[str], bool]] = None, auto_
272
305
  prev = combined_exclude
273
306
  def combined_exclude(mod: str, _caller=caller_name, _prev=prev):
274
307
  return mod == _caller or _prev(mod)
275
-
276
308
  _container = PicoContainer()
277
- logging.info("🔌 Initializing 'pico-ioc'...")
278
-
309
+ binder = Binder(_container)
310
+ logging.info("Initializing pico-ioc...")
279
311
  tok = _scanning.set(True)
280
312
  try:
281
- _scan_and_configure(root_package, _container, exclude=combined_exclude)
313
+ _scan_and_configure(root_package, _container, exclude=combined_exclude, plugins=plugins)
282
314
  finally:
283
315
  _scanning.reset(tok)
284
-
285
- logging.info("✅ Container configured and ready.")
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")
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")
335
+ logging.info("Container configured and ready.")
286
336
  return _container
287
337
 
288
- # ==============================================================================
289
- # --- 3. The Decorators ---
290
- # ==============================================================================
338
+
339
+
291
340
  def factory_component(cls):
292
341
  setattr(cls, '_is_factory_component', True)
293
342
  return cls
294
343
 
295
- def provides(key: Any, *, lazy: bool = True):
296
- """
297
- Declare that a factory method provides a component under 'key'.
298
- By default, returns a LazyProxy that instantiates upon first real use.
299
- """
344
+
345
+ def provides(key: Any, *, lazy: bool = False):
300
346
  def decorator(func):
301
347
  @functools.wraps(func)
302
348
  def wrapper(*args, **kwargs):
303
- return LazyProxy(lambda: func(*args, **kwargs)) if lazy else func(*args, **kwargs)
304
- setattr(wrapper, '_provides_name', key) # legacy-compatible storage
349
+ return func(*args, **kwargs)
350
+ setattr(wrapper, '_provides_name', key)
351
+ setattr(wrapper, '_pico_lazy', bool(lazy))
305
352
  return wrapper
306
353
  return decorator
307
354
 
308
- def component(cls=None, *, name: str = None):
309
- """
310
- Mark a class as a component. Registered by class type by default,
311
- or by 'name' if provided.
312
- """
355
+
356
+ def component(cls=None, *, name: Any = None, lazy: bool = False):
313
357
  def decorator(c):
314
358
  setattr(c, '_is_component', True)
315
359
  setattr(c, '_component_key', name if name is not None else c)
360
+ setattr(c, '_component_lazy', bool(lazy))
316
361
  return c
317
362
  return decorator(cls) if cls else decorator
318
363
 
pico_ioc/_version.py CHANGED
@@ -1 +1 @@
1
- __version__ = '0.3.0'
1
+ __version__ = '0.4.0'
@@ -0,0 +1,321 @@
1
+ Metadata-Version: 2.4
2
+ Name: pico-ioc
3
+ Version: 0.4.0
4
+ Summary: A minimalist, zero-dependency Inversion of Control (IoC) container for Python.
5
+ Author-email: David Perez Cabrera <dperezcabrera@gmail.com>
6
+ Project-URL: Homepage, https://github.com/dperezcabrera/pico-ioc
7
+ Project-URL: Repository, https://github.com/dperezcabrera/pico-ioc
8
+ Project-URL: Issue Tracker, https://github.com/dperezcabrera/pico-ioc/issues
9
+ Keywords: ioc,di,dependency injection,inversion of control,decorator
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3 :: Only
13
+ Classifier: Programming Language :: Python :: 3.8
14
+ Classifier: Programming Language :: Python :: 3.9
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: License :: OSI Approved :: MIT License
20
+ Classifier: Operating System :: OS Independent
21
+ Requires-Python: >=3.8
22
+ Description-Content-Type: text/markdown
23
+
24
+ # 📦 Pico-IoC: A Minimalist IoC Container for Python
25
+
26
+ [![PyPI](https://img.shields.io/pypi/v/pico-ioc.svg)](https://pypi.org/project/pico-ioc/)
27
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
28
+ ![CI (tox matrix)](https://github.com/dperezcabrera/pico-ioc/actions/workflows/ci.yml/badge.svg)
29
+
30
+ **Pico-IoC** is a tiny, zero-dependency, decorator-based Inversion of Control container for Python.
31
+ Build loosely-coupled, testable apps without manual wiring. Inspired by the Spring ecosystem.
32
+
33
+ ---
34
+
35
+ ## ✨ Key Features
36
+
37
+ * **Zero dependencies** — pure Python.
38
+ * **Decorator API** — `@component`, `@factory_component`, `@provides`.
39
+ * **Auto discovery** — scans a package and registers components.
40
+ * **Eager by default, fail-fast** — non-lazy bindings are instantiated immediately after `init()`. Missing deps fail startup.
41
+ * **Opt-in lazy** — set `lazy=True` to defer creation (wrapped in `ComponentProxy`).
42
+ * **Factories** — encapsulate complex creation logic.
43
+ * **Smart resolution** — by **parameter name**, then **type annotation**, then **MRO fallback**, then **string(name)**.
44
+ * **Re-entrancy guard** — prevents `get()` during scanning.
45
+ * **Auto-exclude caller** — `init()` skips the calling module to avoid double scanning.
46
+
47
+ ---
48
+
49
+ ## 📦 Installation
50
+
51
+ ```bash
52
+ pip install pico-ioc
53
+ ```
54
+
55
+ ---
56
+
57
+ ## 🚀 Quick Start
58
+
59
+ ```python
60
+ from pico_ioc import component, init
61
+
62
+ @component
63
+ class AppConfig:
64
+ def get_db_url(self):
65
+ return "postgresql://user:pass@host/db"
66
+
67
+ @component
68
+ class DatabaseService:
69
+ def __init__(self, config: AppConfig):
70
+ self._cs = config.get_db_url()
71
+ def get_data(self):
72
+ return f"Data from {self._cs}"
73
+
74
+ container = init(__name__) # blueprint runs here (eager + fail-fast)
75
+ db = container.get(DatabaseService)
76
+ print(db.get_data())
77
+ ```
78
+
79
+ ---
80
+
81
+ ## 🧩 Custom Component Keys
82
+
83
+ ```python
84
+ from pico_ioc import component, init
85
+
86
+ @component(name="config") # custom key
87
+ class AppConfig:
88
+ db_url = "postgresql://user:pass@localhost/db"
89
+
90
+ @component
91
+ class Repository:
92
+ def __init__(self, config: "config"): # resolve by NAME
93
+ self.url = config.db_url
94
+
95
+ container = init(__name__)
96
+ print(container.get("config").db_url)
97
+ ```
98
+
99
+ ---
100
+
101
+ ## 🏭 Factories and `@provides`
102
+
103
+ * Default is **eager** (`lazy=False`). Eager bindings are constructed at the end of `init()`.
104
+ * Use `lazy=True` for on-first-use creation via `ComponentProxy`.
105
+
106
+ ```python
107
+ from pico_ioc import factory_component, provides, init
108
+
109
+ COUNTER = {"value": 0}
110
+
111
+ @factory_component
112
+ class ServicesFactory:
113
+ @provides(key="heavy_service", lazy=True)
114
+ def heavy(self):
115
+ COUNTER["value"] += 1
116
+ return {"payload": "hello"}
117
+
118
+ container = init(__name__)
119
+ svc = container.get("heavy_service") # not created yet
120
+ print(COUNTER["value"]) # 0
121
+ print(svc["payload"]) # triggers creation
122
+ print(COUNTER["value"]) # 1
123
+ ```
124
+
125
+ ---
126
+
127
+ ## 🧠 Dependency Resolution Order
128
+
129
+ 1. parameter **name**
130
+ 2. exact **type annotation**
131
+ 3. **MRO fallback** (walk base classes)
132
+ 4. `str(name)`
133
+
134
+ ---
135
+
136
+ ## ⚡ Eager vs. Lazy (Blueprint Behavior)
137
+
138
+ At the end of `init()`, Pico-IoC performs a **blueprint**:
139
+
140
+ - **Eager** (`lazy=False`, default): instantiated immediately; failures stop startup.
141
+ - **Lazy** (`lazy=True`): returns a `ComponentProxy`; instantiated on first real use.
142
+
143
+ **Lifecycle:**
144
+
145
+ ┌───────────────────────┐
146
+ │ init() │
147
+ └───────────────────────┘
148
+
149
+
150
+ ┌───────────────────────┐
151
+ │ Scan & bind deps │
152
+ └───────────────────────┘
153
+
154
+
155
+ ┌─────────────────────────────┐
156
+ │ Blueprint instantiates all │
157
+ │ non-lazy (eager) beans │
158
+ └─────────────────────────────┘
159
+
160
+ ┌───────────────────────┐
161
+ │ Container ready │
162
+ └───────────────────────┘
163
+
164
+
165
+ **Best practice:** keep eager+fail-fast for production parity with Spring; use lazy only for heavy/optional deps or to support negative tests.
166
+
167
+ ---
168
+
169
+ ## 🔄 Migration Guide (v0.2.1 → v0.3.0)
170
+
171
+ * **Defaults changed:** `@component` and `@provides` now default to `lazy=False` (eager).
172
+ * **Proxy renamed:** `LazyProxy` → `ComponentProxy` (only relevant if referenced directly).
173
+ * **Tests/fixtures:** components intentionally missing deps should be marked `@component(lazy=True)` (to avoid failing `init()`), or excluded from the scan.
174
+
175
+ Example fix for an intentional failure case:
176
+
177
+ ```python
178
+ @component(lazy=True)
179
+ class MissingDep:
180
+ def __init__(self, missing):
181
+ self.missing = missing
182
+ ```
183
+
184
+ ---
185
+
186
+ ## 🛠 API Reference
187
+
188
+ ### `init(root, *, exclude=None, auto_exclude_caller=True) -> PicoContainer`
189
+
190
+ Scan and bind components in `root` (str module name or module).
191
+ Skips the calling module if `auto_exclude_caller=True`.
192
+ Runs blueprint (instantiate all `lazy=False` bindings).
193
+
194
+ ### `@component(cls=None, *, name=None, lazy=False)`
195
+
196
+ Register a class as a component.
197
+ Use `name` for a custom key.
198
+ Set `lazy=True` to defer creation.
199
+
200
+ ### `@factory_component`
201
+
202
+ Mark a class as a component factory (its methods can `@provides` bindings).
203
+
204
+ ### `@provides(key, *, lazy=False)`
205
+
206
+ Declare that a factory method provides a component under `key`.
207
+ Set `lazy=True` for deferred creation (`ComponentProxy`).
208
+
209
+ ---
210
+
211
+ ## 🧪 Testing
212
+
213
+ ```bash
214
+ pip install tox
215
+ tox -e py311
216
+ ```
217
+
218
+ Tip: for “missing dependency” tests, mark those components as `lazy=True` so `init()` remains fail-fast for real components while your test still asserts failure on resolution.
219
+
220
+ ---
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
+
304
+ ## ❓ FAQ
305
+
306
+ **Q: Can I make the container lenient at startup?**
307
+ A: By design it’s strict. Prefer `lazy=True` on specific bindings or exclude problem modules from the scan.
308
+
309
+ **Q: Thread safety?**
310
+ A: Container uses `ContextVar` to guard re-entrancy during scanning. Singletons are created once per container; typical usage is in single-threaded app startup, then read-mostly.
311
+
312
+ **Q: Frameworks?**
313
+ A: Framework-agnostic. Works with Flask, FastAPI, CLIs, scripts, etc.
314
+
315
+ ---
316
+
317
+ ## 📜 License
318
+
319
+ MIT — see [LICENSE](https://opensource.org/licenses/MIT)
320
+
321
+
@@ -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,228 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: pico-ioc
3
- Version: 0.3.0
4
- Summary: A minimalist, zero-dependency Inversion of Control (IoC) container for Python.
5
- Author-email: David Perez Cabrera <dperezcabrera@gmail.com>
6
- Project-URL: Homepage, https://github.com/dperezcabrera/pico-ioc
7
- Project-URL: Repository, https://github.com/dperezcabrera/pico-ioc
8
- Project-URL: Issue Tracker, https://github.com/dperezcabrera/pico-ioc/issues
9
- Keywords: ioc,di,dependency injection,inversion of control,decorator
10
- Classifier: Development Status :: 4 - Beta
11
- Classifier: Programming Language :: Python :: 3
12
- Classifier: Programming Language :: Python :: 3 :: Only
13
- Classifier: Programming Language :: Python :: 3.8
14
- Classifier: Programming Language :: Python :: 3.9
15
- Classifier: Programming Language :: Python :: 3.10
16
- Classifier: Programming Language :: Python :: 3.11
17
- Classifier: Programming Language :: Python :: 3.12
18
- Classifier: Programming Language :: Python :: 3.13
19
- Classifier: License :: OSI Approved :: MIT License
20
- Classifier: Operating System :: OS Independent
21
- Requires-Python: >=3.8
22
- Description-Content-Type: text/markdown
23
-
24
-
25
- # 📦 Pico-IoC: A Minimalist IoC Container for Python
26
-
27
- [![PyPI](https://img.shields.io/pypi/v/pico-ioc.svg)](https://pypi.org/project/pico-ioc/)
28
- [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
29
- ![CI (tox matrix)](https://github.com/dperezcabrera/pico-ioc/actions/workflows/ci.yml/badge.svg)
30
-
31
- **Pico-IoC** is a tiny, zero-dependency, decorator-based Inversion of Control (IoC) container for Python.
32
- It helps you manage dependencies in a clean, intuitive, and *Pythonic* way.
33
-
34
- The core idea is to let you build loosely coupled, easily testable applications without manually wiring components.
35
- *Inspired by the IoC philosophy popularized by the Spring Framework.*
36
-
37
- ---
38
-
39
- ## ✨ Key Features
40
-
41
- * **Zero Dependencies:** Pure Python, no external libraries.
42
- * **Decorator-Based API:** Simple decorators like `@component` and `@provides`.
43
- * **Automatic Discovery:** Scans your package to auto-register components.
44
- * **Lazy Instantiation:** Objects are created on first use.
45
- * **Flexible Factories:** Encapsulate complex creation logic.
46
- * **Framework-Agnostic:** Works with Flask, FastAPI, CLIs, scripts, etc.
47
- * **Smart Dependency Resolution:** Resolves by **parameter name**, then **type annotation**, then **MRO fallback**.
48
- * **Auto-Exclude Caller:** `init()` automatically skips the calling module to avoid double-initialization during scans.
49
-
50
- ---
51
-
52
- ## 📦 Installation
53
-
54
- ```bash
55
- pip install pico-ioc
56
- ```
57
-
58
- ---
59
-
60
- ## 🚀 Quick Start
61
-
62
- ```python
63
- from pico_ioc import component, init
64
-
65
- @component
66
- class AppConfig:
67
- def get_db_url(self):
68
- return "postgresql://user:pass@host/db"
69
-
70
- @component
71
- class DatabaseService:
72
- def __init__(self, config: AppConfig):
73
- self._cs = config.get_db_url()
74
-
75
- def get_data(self):
76
- return f"Data from {self._cs}"
77
-
78
- # Initialize the container scanning the current module
79
- container = init(__name__)
80
-
81
- db = container.get(DatabaseService)
82
- print(db.get_data()) # Data from postgresql://user:pass@host/db
83
- ```
84
-
85
- ---
86
-
87
- ## 🧩 Custom Component Keys
88
-
89
- You can register a component with a **custom key** (string, class, enum…).
90
- `key=` is the preferred syntax. For backwards compatibility, `name=` still works.
91
-
92
- ```python
93
- from pico_ioc import component, init
94
-
95
- @component(name="config") # still supported for legacy code
96
- class AppConfig:
97
- def __init__(self):
98
- self.db_url = "postgresql://user:pass@localhost/db"
99
-
100
- @component
101
- class Repository:
102
- def __init__(self, config: "config"): # resolve by name
103
- self._url = config.db_url
104
-
105
- container = init(__name__)
106
- repo = container.get(Repository)
107
- print(repo._url) # postgresql://user:pass@localhost/db
108
- print(container.get("config").db_url)
109
- ```
110
-
111
- ---
112
-
113
- ## 🏭 Factory Components and `@provides`
114
-
115
- Factories can provide components under a specific **key**.
116
- Default is lazy creation (via `LazyProxy`).
117
-
118
- ```python
119
- from pico_ioc import factory_component, provides, init
120
-
121
- CREATION_COUNTER = {"value": 0}
122
-
123
- @factory_component
124
- class ServicesFactory:
125
- @provides(key="heavy_service") # preferred
126
- def make_heavy(self):
127
- CREATION_COUNTER["value"] += 1
128
- return {"payload": "Hello from heavy service"}
129
-
130
- container = init(__name__)
131
- svc = container.get("heavy_service")
132
- print(CREATION_COUNTER["value"]) # 0 (not created yet)
133
-
134
- print(svc["payload"]) # triggers creation
135
- print(CREATION_COUNTER["value"]) # 1
136
- ```
137
-
138
- ---
139
-
140
- ## 📦 Project-Style Scanning
141
-
142
- ```
143
- project_root/
144
- └── myapp/
145
- ├── __init__.py
146
- ├── services.py
147
- └── main.py
148
- ```
149
-
150
- **myapp/services.py**
151
-
152
- ```python
153
- from pico_ioc import component
154
-
155
- @component
156
- class Config:
157
- def __init__(self):
158
- self.base_url = "https://api.example.com"
159
-
160
- @component
161
- class ApiClient:
162
- def __init__(self, config: Config):
163
- self.base_url = config.base_url
164
-
165
- def get(self, path: str):
166
- return f"GET {self.base_url}/{path}"
167
- ```
168
-
169
- **myapp/main.py**
170
-
171
- ```python
172
- import pico_ioc
173
- from myapp.services import ApiClient
174
-
175
- container = pico_ioc.init("myapp")
176
- client = container.get(ApiClient)
177
- print(client.get("status")) # GET https://api.example.com/status
178
- ```
179
-
180
- ---
181
-
182
- ## 🧠 Dependency Resolution Order
183
-
184
- When Pico-IoC instantiates a component, it tries to resolve each parameter in this order:
185
-
186
- 1. **Exact parameter name** (string key in container)
187
- 2. **Exact type annotation** (class key in container)
188
- 3. **MRO fallback** (walk base classes until match)
189
- 4. **String version** of the parameter name
190
-
191
- ---
192
-
193
- ## 🛠 API Reference
194
-
195
- ### `init(root_package_or_module, *, exclude=None, auto_exclude_caller=True) -> PicoContainer`
196
-
197
- Scan the given root **package** (str) or **module**.
198
- By default, excludes the calling module.
199
-
200
- ### `@component(cls=None, *, name=None)`
201
-
202
- Register a class as a component.
203
- If `name` is given, registers under that string; otherwise under the class type.
204
-
205
- ### `@factory_component`
206
-
207
- Register a class as a factory of components.
208
-
209
- ### `@provides(key=None, *, name=None, lazy=True)`
210
-
211
- Declare that a factory method provides a component under `key`.
212
- `name` is accepted for backwards compatibility.
213
- If `lazy=True`, returns a `LazyProxy` that instantiates on first real use.
214
-
215
- ---
216
-
217
- ## 🧪 Testing
218
-
219
- ```bash
220
- pip install tox
221
- tox -e py311
222
- ```
223
-
224
- ---
225
-
226
- ## 📜 License
227
-
228
- MIT — see [LICENSE](https://opensource.org/licenses/MIT)
@@ -1,6 +0,0 @@
1
- pico_ioc/__init__.py,sha256=wCgi07l_0ZqhgZRfoRgWld_Q3_0qkqazUPWsO0XbPQI,13474
2
- pico_ioc/_version.py,sha256=3wVEs2QD_7OcTlD97cZdCeizd2hUbJJ0GeIO8wQIjrk,22
3
- pico_ioc-0.3.0.dist-info/METADATA,sha256=nzmDf0ZLTn0OkaoYDytJDnPQstVgVf2ExGZv0nMs15g,6374
4
- pico_ioc-0.3.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
5
- pico_ioc-0.3.0.dist-info/top_level.txt,sha256=_7_RLu616z_dtRw16impXn4Mw8IXe2J4BeX5912m5dQ,9
6
- pico_ioc-0.3.0.dist-info/RECORD,,