pico-ioc 0.1.0__tar.gz → 0.2.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.
@@ -0,0 +1,233 @@
1
+ Metadata-Version: 2.4
2
+ Name: pico-ioc
3
+ Version: 0.2.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)
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
+
@@ -1,29 +1,32 @@
1
- # Pico-IoC: A Minimalist IoC Container for Python
1
+
2
+ # 📦 Pico-IoC: A Minimalist IoC Container for Python
2
3
 
3
4
  [![PyPI](https://img.shields.io/pypi/v/pico-ioc.svg)](https://pypi.org/project/pico-ioc/)
4
5
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
5
6
  ![CI (tox matrix)](https://github.com/dperezcabrera/pico-ioc/actions/workflows/ci.yml/badge.svg)
6
7
 
7
- **Pico-IoC** is a tiny, zero-dependency, decorator-based Inversion of Control (IoC) container for Python.
8
+ **Pico-IoC** is a tiny, zero-dependency, decorator-based Inversion of Control (IoC) container for Python.
8
9
  It helps you manage dependencies in a clean, intuitive, and *Pythonic* way.
9
10
 
10
- The core idea is to let you build loosely coupled, easily testable applications without manually wiring components.
11
+ The core idea is to let you build loosely coupled, easily testable applications without manually wiring components.
11
12
  *Inspired by the IoC philosophy popularized by the Spring Framework.*
12
13
 
13
14
  ---
14
15
 
15
- ## Key Features
16
+ ## Key Features
16
17
 
17
- * **Zero Dependencies:** Pure Python, no external libraries.
18
- * 🚀 **Decorator-Based API:** Simple decorators like `@component` and `@provides`.
19
- * 🔍 **Automatic Discovery:** Scans your package to auto-register components.
20
- * 🧩 **Lazy Instantiation:** Objects are created on first use.
21
- * 🏭 **Flexible Factories:** Encapsulate complex creation logic.
22
- * 🤝 **Framework-Agnostic:** Works with Flask, FastAPI, CLIs, scripts, etc.
18
+ * **Zero Dependencies:** Pure Python, no external libraries.
19
+ * **Decorator-Based API:** Simple decorators like `@component` and `@provides`.
20
+ * **Automatic Discovery:** Scans your package to auto-register components.
21
+ * **Lazy Instantiation:** Objects are created on first use.
22
+ * **Flexible Factories:** Encapsulate complex creation logic.
23
+ * **Framework-Agnostic:** Works with Flask, FastAPI, CLIs, scripts, etc.
24
+ * **Smart Dependency Resolution:** Resolves by **parameter name**, then **type annotation**, then **MRO fallback**.
25
+ * **Auto-Exclude Caller:** `init()` automatically skips the calling module to avoid double-initialization during scans.
23
26
 
24
27
  ---
25
28
 
26
- ## Installation
29
+ ## 📦 Installation
27
30
 
28
31
  ```bash
29
32
  pip install pico-ioc
@@ -31,9 +34,7 @@ pip install pico-ioc
31
34
 
32
35
  ---
33
36
 
34
- ## Quick Start
35
-
36
- Getting started is simple. Decorate your classes and let Pico-IoC wire them up.
37
+ ## 🚀 Quick Start
37
38
 
38
39
  ```python
39
40
  from pico_ioc import component, init
@@ -60,21 +61,22 @@ print(db.get_data()) # Data from postgresql://user:pass@host/db
60
61
 
61
62
  ---
62
63
 
63
- ## More Examples
64
+ ## 🧩 Custom Component Keys
64
65
 
65
- ### 🧩 Custom Component Name
66
+ You can register a component with a **custom key** (string, class, enum…).
67
+ `key=` is the preferred syntax. For backwards compatibility, `name=` still works.
66
68
 
67
69
  ```python
68
70
  from pico_ioc import component, init
69
71
 
70
- @component(name="config")
72
+ @component(name="config") # still supported for legacy code
71
73
  class AppConfig:
72
74
  def __init__(self):
73
75
  self.db_url = "postgresql://user:pass@localhost/db"
74
76
 
75
77
  @component
76
78
  class Repository:
77
- def __init__(self, config: "config"): # refer by custom name if you prefer
79
+ def __init__(self, config: "config"): # resolve by name
78
80
  self._url = config.db_url
79
81
 
80
82
  container = init(__name__)
@@ -83,9 +85,12 @@ print(repo._url) # postgresql://user:pass@localhost/db
83
85
  print(container.get("config").db_url)
84
86
  ```
85
87
 
86
- > Pico-IoC prefers **type annotations** to resolve deps; if missing, it falls back to the **parameter name**.
88
+ ---
87
89
 
88
- ### 💤 Lazy Factories (only build when used)
90
+ ## 🏭 Factory Components and `@provides`
91
+
92
+ Factories can provide components under a specific **key**.
93
+ Default is lazy creation (via `LazyProxy`).
89
94
 
90
95
  ```python
91
96
  from pico_ioc import factory_component, provides, init
@@ -94,7 +99,7 @@ CREATION_COUNTER = {"value": 0}
94
99
 
95
100
  @factory_component
96
101
  class ServicesFactory:
97
- @provides(name="heavy_service") # returns a LazyProxy by default
102
+ @provides(key="heavy_service") # preferred
98
103
  def make_heavy(self):
99
104
  CREATION_COUNTER["value"] += 1
100
105
  return {"payload": "Hello from heavy service"}
@@ -107,7 +112,9 @@ print(svc["payload"]) # triggers creation
107
112
  print(CREATION_COUNTER["value"]) # 1
108
113
  ```
109
114
 
110
- ### 📦 Project-Style Package Scanning
115
+ ---
116
+
117
+ ## 📦 Project-Style Scanning
111
118
 
112
119
  ```
113
120
  project_root/
@@ -142,61 +149,62 @@ class ApiClient:
142
149
  import pico_ioc
143
150
  from myapp.services import ApiClient
144
151
 
145
- # Scan the whole 'myapp' package
146
152
  container = pico_ioc.init("myapp")
147
-
148
153
  client = container.get(ApiClient)
149
154
  print(client.get("status")) # GET https://api.example.com/status
150
155
  ```
151
156
 
152
157
  ---
153
158
 
154
- ## API Reference (mini)
159
+ ## 🧠 Dependency Resolution Order
155
160
 
156
- ### `init(root_package_or_module) -> PicoContainer`
161
+ When Pico-IoC instantiates a component, it tries to resolve each parameter in this order:
157
162
 
158
- Initialize the container by scanning a root **package** (str) or **module**. Returns the configured container.
163
+ 1. **Exact parameter name** (string key in container)
164
+ 2. **Exact type annotation** (class key in container)
165
+ 3. **MRO fallback** (walk base classes until match)
166
+ 4. **String version** of the parameter name
159
167
 
160
- ### `@component(cls=None, *, name: str | None = None)`
168
+ ---
161
169
 
162
- Mark a class as a component. Registered by **class type** by default, or by **name** if provided.
170
+ ## 🛠 API Reference
163
171
 
164
- ### `@factory_component`
172
+ ### `init(root_package_or_module, *, exclude=None, auto_exclude_caller=True) -> PicoContainer`
165
173
 
166
- Mark a class as a factory holder. Methods inside can be `@provides(...)`.
174
+ Scan the given root **package** (str) or **module**.
175
+ By default, excludes the calling module.
167
176
 
168
- ### `@provides(name: str, lazy: bool = True)`
177
+ ### `@component(cls=None, *, name=None)`
169
178
 
170
- Declare that a factory method **produces** a component registered under `name`. By default it’s **lazy** (a proxy that creates on first real use).
179
+ Register a class as a component.
180
+ If `name` is given, registers under that string; otherwise under the class type.
171
181
 
172
- ---
182
+ ### `@factory_component`
173
183
 
174
- ## Testing
184
+ Register a class as a factory of components.
175
185
 
176
- `pico-ioc` ships with `pytest` tests and a `tox` matrix.
186
+ ### `@provides(key=None, *, name=None, lazy=True)`
177
187
 
178
- ```bash
179
- pip install tox
180
- tox -e py311 # run on a specific version
181
- tox # run all configured envs
182
- ```
188
+ Declare that a factory method provides a component under `key`.
189
+ `name` is accepted for backwards compatibility.
190
+ If `lazy=True`, returns a `LazyProxy` that instantiates on first real use.
183
191
 
184
192
  ---
185
193
 
186
- ## Contributing
194
+ ## 🧪 Testing
187
195
 
188
- Issues and PRs are welcome. If you spot a bug or have an idea, open an issue!
196
+ ```bash
197
+ pip install tox
198
+ tox -e py311
199
+ ```
189
200
 
190
201
  ---
191
202
 
192
- ## License
203
+ ## 📜 License
193
204
 
194
- MIT — see the [LICENSE](https://opensource.org/licenses/MIT).
205
+ MIT — see [LICENSE](https://opensource.org/licenses/MIT)
195
206
 
196
207
  ---
197
208
 
198
- ## Authors
209
+ ¿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.
199
210
 
200
- * **David Perez Cabrera**
201
- * **Gemini 2.5-Pro**
202
- * **GPT-5**
@@ -1,7 +1,7 @@
1
1
  # src/pico_ioc/__init__.py
2
2
 
3
3
  import functools, inspect, pkgutil, importlib, logging, sys
4
- from typing import Callable, Any, Iterator, AsyncIterator
4
+ from typing import Callable, Any, Iterator, Optional, AsyncIterator
5
5
 
6
6
  try:
7
7
  # written at build time by setuptools-scm
@@ -16,12 +16,15 @@ __all__ = ["__version__"]
16
16
  # ==============================================================================
17
17
  class PicoContainer:
18
18
  def __init__(self):
19
- self._providers = {}
20
- self._singletons = {}
19
+ self._providers: dict[Any, Callable[[], Any]] = {}
20
+ self._singletons: dict[Any, Any] = {}
21
21
 
22
22
  def bind(self, key: Any, provider: Callable[[], Any]):
23
23
  self._providers[key] = provider
24
24
 
25
+ def has(self, key: Any) -> bool:
26
+ return key in self._providers or key in self._singletons
27
+
25
28
  def get(self, key: Any) -> Any:
26
29
  if key in self._singletons:
27
30
  return self._singletons[key]
@@ -136,11 +139,47 @@ class LazyProxy:
136
139
  # ==============================================================================
137
140
  # --- 2. The Scanner and `init` Facade ---
138
141
  # ==============================================================================
139
- def _scan_and_configure(package_or_name, container: PicoContainer):
142
+ def _resolve_param(container: PicoContainer, p: inspect.Parameter) -> Any:
143
+ if p.name == 'self' or p.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
144
+ raise RuntimeError("Invalid param for resolution")
145
+
146
+ if container.has(p.name):
147
+ return container.get(p.name)
148
+
149
+ ann = p.annotation
150
+ if ann is not inspect._empty and container.has(ann):
151
+ return container.get(ann)
152
+
153
+ if ann is not inspect._empty:
154
+ try:
155
+ for base in getattr(ann, "__mro__", ())[1:]:
156
+ if base is object:
157
+ break
158
+ if container.has(base):
159
+ return container.get(base)
160
+ except Exception:
161
+ pass
162
+
163
+ if container.has(str(p.name)):
164
+ return container.get(str(p.name))
165
+
166
+ key = p.name if not (ann and ann is not inspect._empty) else ann
167
+ return container.get(key)
168
+
169
+
170
+ def _scan_and_configure(
171
+ package_or_name,
172
+ container: PicoContainer,
173
+ exclude: Optional[Callable[[str], bool]] = None
174
+ ):
140
175
  package = importlib.import_module(package_or_name) if isinstance(package_or_name, str) else package_or_name
141
176
  logging.info(f"🚀 Scanning in '{package.__name__}'...")
142
177
  component_classes, factory_classes = [], []
143
178
  for _, name, _ in pkgutil.walk_packages(package.__path__, package.__name__ + '.'):
179
+ # Skip excluded modules (used by auto_exclude_caller and custom excludes)
180
+ if exclude and exclude(name):
181
+ logging.info(f" ⏭️ Skipping module {name} (excluded)")
182
+ continue
144
183
  try:
145
184
  module = importlib.import_module(name)
146
185
  for _, obj in inspect.getmembers(module, inspect.isclass):
@@ -167,24 +206,41 @@ def _scan_and_configure(package_or_name, container: PicoContainer):
167
206
  sig = inspect.signature(cls.__init__)
168
207
  deps = {}
169
208
  for p in sig.parameters.values():
170
- if p.name == 'self' or p.kind in (
171
- inspect.Parameter.VAR_POSITIONAL, # *args
172
- inspect.Parameter.VAR_KEYWORD, # **kwargs
173
- ):
209
+ if p.name == 'self' or p.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
174
210
  continue
175
- dep_key = p.annotation if p.annotation is not inspect._empty else p.name
176
- deps[p.name] = container.get(dep_key)
211
+ deps[p.name] = _resolve_param(container, p)
177
212
  return cls(**deps)
178
213
  container.bind(key, create_component)
179
214
 
180
215
  _container = None
181
- def init(root_package):
216
+
217
+ def init(root_package, *, exclude: Optional[Callable[[str], bool]] = None, auto_exclude_caller: bool = True):
182
218
  global _container
183
219
  if _container:
184
220
  return _container
221
+
222
+ combined_exclude = exclude
223
+ if auto_exclude_caller:
224
+ # módulo que invoca a init()
225
+ try:
226
+ caller_frame = inspect.stack()[1].frame
227
+ caller_module = inspect.getmodule(caller_frame)
228
+ caller_name = getattr(caller_module, "__name__", None)
229
+ except Exception:
230
+ caller_name = None
231
+
232
+ if caller_name:
233
+ if combined_exclude is None:
234
+ def combined_exclude(mod: str, _caller=caller_name):
235
+ return mod == _caller
236
+ else:
237
+ prev = combined_exclude
238
+ def combined_exclude(mod: str, _caller=caller_name, _prev=prev):
239
+ return mod == _caller or _prev(mod)
240
+
185
241
  _container = PicoContainer()
186
242
  logging.info("🔌 Initializing 'pico-ioc'...")
187
- _scan_and_configure(root_package, _container)
243
+ _scan_and_configure(root_package, _container, exclude=combined_exclude)
188
244
  logging.info("✅ Container configured and ready.")
189
245
  return _container
190
246
 
@@ -195,12 +251,13 @@ def factory_component(cls):
195
251
  setattr(cls, '_is_factory_component', True)
196
252
  return cls
197
253
 
198
- def provides(name: str, lazy: bool = True):
254
+ def provides(key: Any, *, lazy: bool = True):
199
255
  def decorator(func):
200
256
  @functools.wraps(func)
201
257
  def wrapper(*args, **kwargs):
202
258
  return LazyProxy(lambda: func(*args, **kwargs)) if lazy else func(*args, **kwargs)
203
- setattr(wrapper, '_provides_name', name)
259
+ # mantenemos compat con _provides_name (por si alguien lo usa)
260
+ setattr(wrapper, '_provides_name', key)
204
261
  return wrapper
205
262
  return decorator
206
263
 
@@ -0,0 +1 @@
1
+ __version__ = '0.2.0'