pico-ioc 0.2.0__tar.gz → 0.2.1__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.2.0
3
+ Version: 0.2.1
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
@@ -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
 
@@ -0,0 +1 @@
1
+ __version__ = '0.2.1'
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pico-ioc
3
- Version: 0.2.0
3
+ Version: 0.2.1
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
@@ -1,5 +1,6 @@
1
1
  import pytest
2
2
  import sys
3
+ import logging
3
4
  import pico_ioc
4
5
 
5
6
  # --- Test Environment Setup Fixture ---
@@ -24,13 +25,7 @@ def test_project(tmp_path):
24
25
  package_dir.mkdir()
25
26
  (package_dir / "__init__.py").touch()
26
27
 
27
- # Components:
28
- # - SimpleService (no deps)
29
- # - AnotherService (depends on SimpleService by type)
30
- # - CustomNameService (registered by custom name)
31
- # - NeedsByName (depends by name only)
32
- # - NeedsNameVsType (name should win over type)
33
- # - NeedsTypeFallback (fallback to base type via MRO)
28
+ # Components
34
29
  (package_dir / "components.py").write_text(
35
30
  """
36
31
  from pico_ioc import component
@@ -81,10 +76,7 @@ class MissingDep:
81
76
  """
82
77
  )
83
78
 
84
- # Factories:
85
- # - complex_service (lazy via LazyProxy; counter to assert laziness)
86
- # - fast_model (by NAME)
87
- # - base_interface (by TYPE: BaseInterface)
79
+ # Factories
88
80
  (package_dir / "factories.py").write_text(
89
81
  """
90
82
  from pico_ioc import factory_component, provides
@@ -106,7 +98,7 @@ class ServiceFactory:
106
98
  @provides(key="fast_model")
107
99
  def create_fast_model(self):
108
100
  FAST_COUNTER["value"] += 1
109
- return {"who": "fast"} # any object; dict is convenient for identity checks
101
+ return {"who": "fast"}
110
102
 
111
103
  @provides(key=BaseInterface)
112
104
  def create_base_interface(self):
@@ -115,23 +107,25 @@ class ServiceFactory:
115
107
  """
116
108
  )
117
109
 
118
- # Optional module that calls init() at import-time:
119
- # used to test auto-exclude of the caller (to prevent re-entrancy).
110
+ # Module that triggers re-entrant access: init() + get() at import-time
120
111
  (project_root / "entry.py").write_text(
121
112
  """
122
113
  import pico_ioc
123
114
  import test_project
115
+ from test_project.services.components import SimpleService
124
116
 
125
- # If auto-exclude-caller is on AND _scan_and_configure() honors 'exclude',
126
- # importing this module during scanning should NOT recurse infinitely.
117
+ # This runs at import-time when the scanner imports this module.
118
+ # init() returns the (global) container set by the outer scan,
119
+ # then get() is attempted while scanning is still in progress -> guard should raise.
127
120
  ioc = pico_ioc.init(test_project)
121
+ ioc.get(SimpleService) # should raise RuntimeError due to re-entrant access during scan
128
122
  """
129
123
  )
130
124
 
131
- # Yield the root package name used by pico_ioc.init()
125
+ # Yield root package name used by pico_ioc.init()
132
126
  yield "test_project"
133
127
 
134
- # Teardown: remove path, reset container, purge modules from cache
128
+ # Teardown
135
129
  sys.path.pop(0)
136
130
  pico_ioc._container = None
137
131
  mods_to_del = [m for m in list(sys.modules.keys()) if m == "test_project" or m.startswith("test_project.")]
@@ -153,10 +147,7 @@ def test_simple_component_retrieval(test_project):
153
147
 
154
148
 
155
149
  def test_dependency_injection_by_type_hint(test_project):
156
- """
157
- When a constructor parameter has a type hint and no provider is bound by name,
158
- the container should resolve it by TYPE.
159
- """
150
+ """Type-hinted dependency resolves by TYPE when no NAME-bound provider exists."""
160
151
  from test_project.services.components import SimpleService, AnotherService
161
152
 
162
153
  container = pico_ioc.init(test_project)
@@ -167,9 +158,7 @@ def test_dependency_injection_by_type_hint(test_project):
167
158
 
168
159
 
169
160
  def test_components_are_singletons_by_default(test_project):
170
- """
171
- Providers bound by the scanner are singletons: get() returns the same instance.
172
- """
161
+ """Providers registered by the scanner behave as singletons."""
173
162
  from test_project.services.components import SimpleService
174
163
 
175
164
  container = pico_ioc.init(test_project)
@@ -181,9 +170,7 @@ def test_components_are_singletons_by_default(test_project):
181
170
 
182
171
 
183
172
  def test_get_unregistered_component_raises_error(test_project):
184
- """
185
- Requesting a key with no provider must raise NameError with a helpful message.
186
- """
173
+ """Requesting an unknown key raises NameError."""
187
174
  container = pico_ioc.init(test_project)
188
175
 
189
176
  class Unregistered: ...
@@ -192,50 +179,36 @@ def test_get_unregistered_component_raises_error(test_project):
192
179
 
193
180
 
194
181
  def test_factory_provides_component_by_name(test_project):
195
- """
196
- A factory method annotated with @provides(key="...") is bound by NAME and is retrievable.
197
- """
182
+ """Factory-provided component is retrievable by key; proxy behaves like real value."""
198
183
  container = pico_ioc.init(test_project)
199
184
  svc = container.get("complex_service")
200
-
201
- # Proxy must behave like the real string for equality
202
185
  assert svc == "This is a complex service"
203
186
 
204
187
 
205
188
  def test_factory_instantiation_is_lazy_and_singleton(test_project):
206
- """
207
- Factory methods with default lazy=True return a LazyProxy. The real object is created on first use.
208
- Also, container should cache the created instance (singleton per key).
209
- """
189
+ """LazyProxy creates real object on first use and remains singleton per key."""
210
190
  from test_project.services.factories import CREATION_COUNTER
211
191
 
212
192
  container = pico_ioc.init(test_project)
213
-
214
193
  assert CREATION_COUNTER["value"] == 0
215
194
 
216
195
  proxy = container.get("complex_service")
217
- # Accessing attributes/methods of the proxy should trigger creation exactly once
218
196
  assert CREATION_COUNTER["value"] == 0
219
197
  up = proxy.upper()
220
198
  assert up == "THIS IS A COMPLEX SERVICE"
221
199
  assert CREATION_COUNTER["value"] == 1
222
200
 
223
- # Re-accessing via the same proxy does not create again
224
201
  _ = proxy.lower()
225
202
  assert CREATION_COUNTER["value"] == 1
226
203
 
227
- # Getting the same key again should return the same singleton instance (no extra creations)
228
204
  again = container.get("complex_service")
229
- assert again is proxy # same object returned by container
205
+ assert again is proxy
230
206
  _ = again.strip()
231
207
  assert CREATION_COUNTER["value"] == 1
232
208
 
233
209
 
234
210
  def test_component_with_custom_name(test_project):
235
- """
236
- A component registered by custom name is retrievable by that name,
237
- and NOT by its class.
238
- """
211
+ """Component registered by custom name is retrievable by that name, not by class."""
239
212
  from test_project.services.components import CustomNameService
240
213
 
241
214
  container = pico_ioc.init(test_project)
@@ -247,27 +220,20 @@ def test_component_with_custom_name(test_project):
247
220
 
248
221
 
249
222
  def test_resolution_prefers_name_over_type(test_project):
250
- """
251
- If both a NAME-bound provider and a TYPE-bound provider exist, resolution MUST
252
- prefer the NAME (parameter name) over the TYPE hint.
253
- """
223
+ """If NAME and TYPE providers exist, NAME must win."""
254
224
  from test_project.services.components import NeedsNameVsType
255
225
  from test_project.services.factories import FAST_COUNTER, BASE_COUNTER
256
226
 
257
227
  container = pico_ioc.init(test_project)
258
228
  comp = container.get(NeedsNameVsType)
259
229
 
260
- # "fast_model" name must win → uses the fast provider
261
230
  assert comp.model == {"who": "fast"}
262
231
  assert FAST_COUNTER["value"] == 1
263
- # Base provider should NOT be used for this resolution
264
232
  assert BASE_COUNTER["value"] == 0
265
233
 
266
234
 
267
235
  def test_resolution_by_name_only(test_project):
268
- """
269
- When a ctor parameter has NO type hint, the container must resolve strictly by NAME.
270
- """
236
+ """Ctor param without type hint resolves strictly by NAME."""
271
237
  from test_project.services.components import NeedsByName
272
238
  from test_project.services.factories import FAST_COUNTER
273
239
 
@@ -279,25 +245,19 @@ def test_resolution_by_name_only(test_project):
279
245
 
280
246
 
281
247
  def test_resolution_fallback_to_type_mro(test_project):
282
- """
283
- When there is no provider for the parameter NAME nor the exact TYPE,
284
- the container must try TYPE's MRO and use the first available provider.
285
- """
248
+ """If neither NAME nor exact TYPE match, fallback via TYPE MRO."""
286
249
  from test_project.services.components import NeedsTypeFallback
287
250
  from test_project.services.factories import BASE_COUNTER
288
251
 
289
252
  container = pico_ioc.init(test_project)
290
253
  comp = container.get(NeedsTypeFallback)
291
254
 
292
- # Resolved via MRO to BaseInterface provider
293
255
  assert comp.impl == {"who": "base"}
294
256
  assert BASE_COUNTER["value"] == 1
295
257
 
296
258
 
297
259
  def test_missing_dependency_raises_clear_error(test_project):
298
- """
299
- If no provider exists for NAME nor TYPE nor MRO, resolution must raise NameError.
300
- """
260
+ """Missing dep across NAME/TYPE/MRO raises NameError."""
301
261
  from test_project.services.components import MissingDep
302
262
 
303
263
  container = pico_ioc.init(test_project)
@@ -305,17 +265,23 @@ def test_missing_dependency_raises_clear_error(test_project):
305
265
  container.get(MissingDep)
306
266
 
307
267
 
308
- @pytest.mark.skipif(
309
- not hasattr(pico_ioc, "init"),
310
- reason="init not available"
311
- )
312
- def test_auto_exclude_caller_prevents_reentrant_scan(test_project):
268
+ def test_reentrant_access_is_blocked_and_container_still_initializes(test_project, caplog):
313
269
  """
314
- Smoke test: importing a module that calls pico_ioc.init(root) at import-time
315
- should not cause re-entrant scans if init() auto-excludes the caller AND
316
- _scan_and_configure honors the 'exclude' predicate.
270
+ Importing a module that calls init() and then container.get() at import-time should:
271
+ - raise a RuntimeError from the guard (caught as a warning by the scanner),
272
+ - NOT prevent the container from finishing initialization,
273
+ - allow normal component retrieval afterwards.
317
274
  """
318
- # If the library correctly auto-excludes the caller and passes 'exclude'
319
- # into the scanner (which must skip excluded modules), this import should be safe.
320
- __import__("test_project.entry")
275
+ caplog.set_level(logging.INFO)
276
+
277
+ container = pico_ioc.init(test_project)
278
+
279
+ assert any(
280
+ "re-entrant container access during scan" in rec.message
281
+ for rec in caplog.records
282
+ ), "Expected a warning about re-entrant access during scan"
283
+
284
+ from test_project.services.components import SimpleService
285
+ svc = container.get(SimpleService)
286
+ assert isinstance(svc, SimpleService)
321
287
 
@@ -1 +0,0 @@
1
- __version__ = '0.2.0'
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes