pico-ioc 0.2.0__py3-none-any.whl → 0.3.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,7 +1,6 @@
1
- # src/pico_ioc/__init__.py
2
-
3
- import functools, inspect, pkgutil, importlib, logging, sys
4
- from typing import Callable, Any, Iterator, Optional, AsyncIterator
1
+ import functools, inspect, pkgutil, importlib, logging
2
+ from typing import Callable, Any, Optional
3
+ from contextvars import ContextVar
5
4
 
6
5
  try:
7
6
  # written at build time by setuptools-scm
@@ -11,6 +10,15 @@ except Exception: # pragma: no cover
11
10
 
12
11
  __all__ = ["__version__"]
13
12
 
13
+ # ------------------------------------------------------------------------------
14
+ # Re-entrancy guards
15
+ # ------------------------------------------------------------------------------
16
+ # True while init/scan is running. Blocks userland container access during scan.
17
+ _scanning: ContextVar[bool] = ContextVar("pico_scanning", default=False)
18
+
19
+ # True while the container is resolving deps for a component (internal use allowed).
20
+ _resolving: ContextVar[bool] = ContextVar("pico_resolving", default=False)
21
+
14
22
  # ==============================================================================
15
23
  # --- 1. Container and Chameleon Proxy (Framework-Agnostic) ---
16
24
  # ==============================================================================
@@ -26,6 +34,16 @@ class PicoContainer:
26
34
  return key in self._providers or key in self._singletons
27
35
 
28
36
  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
+ 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
+
29
47
  if key in self._singletons:
30
48
  return self._singletons[key]
31
49
  if key in self._providers:
@@ -143,13 +161,17 @@ def _resolve_param(container: PicoContainer, p: inspect.Parameter) -> Any:
143
161
  if p.name == 'self' or p.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
144
162
  raise RuntimeError("Invalid param for resolution")
145
163
 
164
+ # 1) NAME
146
165
  if container.has(p.name):
147
166
  return container.get(p.name)
148
167
 
149
168
  ann = p.annotation
169
+
170
+ # 2) TYPE
150
171
  if ann is not inspect._empty and container.has(ann):
151
172
  return container.get(ann)
152
173
 
174
+ # 3) TYPE MRO
153
175
  if ann is not inspect._empty:
154
176
  try:
155
177
  for base in getattr(ann, "__mro__", ())[1:]:
@@ -160,10 +182,11 @@ def _resolve_param(container: PicoContainer, p: inspect.Parameter) -> Any:
160
182
  except Exception:
161
183
  pass
162
184
 
185
+ # 4) str(NAME)
163
186
  if container.has(str(p.name)):
164
187
  return container.get(str(p.name))
165
188
 
166
- key = p.name if not (ann and ann is not inspect._empty) else ann
189
+ key = p.name if ann is inspect._empty else ann
167
190
  return container.get(key)
168
191
 
169
192
 
@@ -175,21 +198,22 @@ def _scan_and_configure(
175
198
  package = importlib.import_module(package_or_name) if isinstance(package_or_name, str) else package_or_name
176
199
  logging.info(f"🚀 Scanning in '{package.__name__}'...")
177
200
  component_classes, factory_classes = [], []
201
+
178
202
  for _, name, _ in pkgutil.walk_packages(package.__path__, package.__name__ + '.'):
179
- # Skip excluded modules (used by auto_exclude_caller and custom excludes)
180
203
  if exclude and exclude(name):
181
204
  logging.info(f" ⏭️ Skipping module {name} (excluded)")
182
205
  continue
183
206
  try:
184
207
  module = importlib.import_module(name)
185
208
  for _, obj in inspect.getmembers(module, inspect.isclass):
186
- if hasattr(obj, '_is_component'):
209
+ if getattr(obj, '_is_component', False):
187
210
  component_classes.append(obj)
188
- elif hasattr(obj, '_is_factory_component'):
211
+ elif getattr(obj, '_is_factory_component', False):
189
212
  factory_classes.append(obj)
190
213
  except Exception as e:
191
214
  logging.warning(f" ⚠️ Module {name} not processed: {e}")
192
215
 
216
+ # Register factories
193
217
  for factory_cls in factory_classes:
194
218
  try:
195
219
  sig = inspect.signature(factory_cls.__init__)
@@ -200,28 +224,39 @@ def _scan_and_configure(
200
224
  except Exception as e:
201
225
  logging.error(f" ❌ Error in factory {factory_cls.__name__}: {e}", exc_info=True)
202
226
 
227
+ # Register components
203
228
  for component_cls in component_classes:
204
229
  key = getattr(component_cls, '_component_key', component_cls)
230
+
205
231
  def create_component(cls=component_cls):
206
232
  sig = inspect.signature(cls.__init__)
207
233
  deps = {}
208
- for p in sig.parameters.values():
209
- if p.name == 'self' or p.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
210
- continue
211
- deps[p.name] = _resolve_param(container, p)
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)
212
242
  return cls(**deps)
243
+
213
244
  container.bind(key, create_component)
214
245
 
215
246
  _container = None
216
247
 
217
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
+ """
218
254
  global _container
219
255
  if _container:
220
256
  return _container
221
257
 
222
258
  combined_exclude = exclude
223
259
  if auto_exclude_caller:
224
- # módulo que invoca a init()
225
260
  try:
226
261
  caller_frame = inspect.stack()[1].frame
227
262
  caller_module = inspect.getmodule(caller_frame)
@@ -240,7 +275,13 @@ def init(root_package, *, exclude: Optional[Callable[[str], bool]] = None, auto_
240
275
 
241
276
  _container = PicoContainer()
242
277
  logging.info("🔌 Initializing 'pico-ioc'...")
243
- _scan_and_configure(root_package, _container, exclude=combined_exclude)
278
+
279
+ tok = _scanning.set(True)
280
+ try:
281
+ _scan_and_configure(root_package, _container, exclude=combined_exclude)
282
+ finally:
283
+ _scanning.reset(tok)
284
+
244
285
  logging.info("✅ Container configured and ready.")
245
286
  return _container
246
287
 
@@ -252,19 +293,26 @@ def factory_component(cls):
252
293
  return cls
253
294
 
254
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
+ """
255
300
  def decorator(func):
256
301
  @functools.wraps(func)
257
302
  def wrapper(*args, **kwargs):
258
303
  return LazyProxy(lambda: func(*args, **kwargs)) if lazy else func(*args, **kwargs)
259
- # mantenemos compat con _provides_name (por si alguien lo usa)
260
- setattr(wrapper, '_provides_name', key)
304
+ setattr(wrapper, '_provides_name', key) # legacy-compatible storage
261
305
  return wrapper
262
306
  return decorator
263
307
 
264
308
  def component(cls=None, *, name: str = None):
265
- def decorator(cls):
266
- setattr(cls, '_is_component', True)
267
- setattr(cls, '_component_key', name if name is not None else cls)
268
- return cls
309
+ """
310
+ Mark a class as a component. Registered by class type by default,
311
+ or by 'name' if provided.
312
+ """
313
+ def decorator(c):
314
+ setattr(c, '_is_component', True)
315
+ setattr(c, '_component_key', name if name is not None else c)
316
+ return c
269
317
  return decorator(cls) if cls else decorator
270
318
 
pico_ioc/_version.py CHANGED
@@ -1 +1 @@
1
- __version__ = '0.2.0'
1
+ __version__ = '0.3.0'
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pico-ioc
3
- Version: 0.2.0
3
+ Version: 0.3.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
@@ -226,8 +226,3 @@ tox -e py311
226
226
  ## 📜 License
227
227
 
228
228
  MIT — see [LICENSE](https://opensource.org/licenses/MIT)
229
-
230
- ---
231
-
232
- ¿Quieres que también te prepare **un ejemplo completo en el README** con `fast_model` y `BaseChatModel` para que quede documentado el nuevo orden de resolución? Así quedaría clarísimo para cualquiera que lo use.
233
-
@@ -0,0 +1,6 @@
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,,
@@ -1,6 +0,0 @@
1
- pico_ioc/__init__.py,sha256=JkCWRwZb_Mv86Q6gI7wJnsFuKWh0A0B7u-uuAfyeF6Y,11781
2
- pico_ioc/_version.py,sha256=FVHPBGkfhbQDi_z3v0PiKJrXXqXOx0vGW_1VaqNJi7U,22
3
- pico_ioc-0.2.0.dist-info/METADATA,sha256=CgJTdTIs1-MeabncD7fl9sez6t5a41jdYVea8qUDKOY,6599
4
- pico_ioc-0.2.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
5
- pico_ioc-0.2.0.dist-info/top_level.txt,sha256=_7_RLu616z_dtRw16impXn4Mw8IXe2J4BeX5912m5dQ,9
6
- pico_ioc-0.2.0.dist-info/RECORD,,