pico-ioc 0.1.1__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.
- {pico_ioc-0.1.1 → pico_ioc-0.2.0}/PKG-INFO +58 -50
- {pico_ioc-0.1.1 → pico_ioc-0.2.0}/README.md +57 -49
- {pico_ioc-0.1.1 → pico_ioc-0.2.0}/src/pico_ioc/__init__.py +71 -14
- pico_ioc-0.2.0/src/pico_ioc/_version.py +1 -0
- {pico_ioc-0.1.1 → pico_ioc-0.2.0}/src/pico_ioc.egg-info/PKG-INFO +58 -50
- pico_ioc-0.2.0/tests/test_pico_ioc.py +321 -0
- pico_ioc-0.1.1/src/pico_ioc/_version.py +0 -1
- pico_ioc-0.1.1/tests/test_pico_ioc.py +0 -178
- {pico_ioc-0.1.1 → pico_ioc-0.2.0}/.github/workflows/ci.yml +0 -0
- {pico_ioc-0.1.1 → pico_ioc-0.2.0}/.github/workflows/publish-to-pypi.yml +0 -0
- {pico_ioc-0.1.1 → pico_ioc-0.2.0}/Dockerfile.test +0 -0
- {pico_ioc-0.1.1 → pico_ioc-0.2.0}/Makefile +0 -0
- {pico_ioc-0.1.1 → pico_ioc-0.2.0}/pyproject.toml +0 -0
- {pico_ioc-0.1.1 → pico_ioc-0.2.0}/setup.cfg +0 -0
- {pico_ioc-0.1.1 → pico_ioc-0.2.0}/src/pico_ioc.egg-info/SOURCES.txt +0 -0
- {pico_ioc-0.1.1 → pico_ioc-0.2.0}/src/pico_ioc.egg-info/dependency_links.txt +0 -0
- {pico_ioc-0.1.1 → pico_ioc-0.2.0}/src/pico_ioc.egg-info/top_level.txt +0 -0
- {pico_ioc-0.1.1 → pico_ioc-0.2.0}/tox.ini +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pico-ioc
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.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
|
|
@@ -21,32 +21,35 @@ Classifier: Operating System :: OS Independent
|
|
|
21
21
|
Requires-Python: >=3.8
|
|
22
22
|
Description-Content-Type: text/markdown
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
|
|
25
|
+
# 📦 Pico-IoC: A Minimalist IoC Container for Python
|
|
25
26
|
|
|
26
27
|
[](https://pypi.org/project/pico-ioc/)
|
|
27
28
|
[](https://opensource.org/licenses/MIT)
|
|
28
29
|

|
|
29
30
|
|
|
30
|
-
**Pico-IoC** is a tiny, zero-dependency, decorator-based Inversion of Control (IoC) container for Python.
|
|
31
|
+
**Pico-IoC** is a tiny, zero-dependency, decorator-based Inversion of Control (IoC) container for Python.
|
|
31
32
|
It helps you manage dependencies in a clean, intuitive, and *Pythonic* way.
|
|
32
33
|
|
|
33
|
-
The core idea is to let you build loosely coupled, easily testable applications without manually wiring components.
|
|
34
|
+
The core idea is to let you build loosely coupled, easily testable applications without manually wiring components.
|
|
34
35
|
*Inspired by the IoC philosophy popularized by the Spring Framework.*
|
|
35
36
|
|
|
36
37
|
---
|
|
37
38
|
|
|
38
|
-
## Key Features
|
|
39
|
+
## ✨ Key Features
|
|
39
40
|
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
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.
|
|
46
49
|
|
|
47
50
|
---
|
|
48
51
|
|
|
49
|
-
## Installation
|
|
52
|
+
## 📦 Installation
|
|
50
53
|
|
|
51
54
|
```bash
|
|
52
55
|
pip install pico-ioc
|
|
@@ -54,9 +57,7 @@ pip install pico-ioc
|
|
|
54
57
|
|
|
55
58
|
---
|
|
56
59
|
|
|
57
|
-
## Quick Start
|
|
58
|
-
|
|
59
|
-
Getting started is simple. Decorate your classes and let Pico-IoC wire them up.
|
|
60
|
+
## 🚀 Quick Start
|
|
60
61
|
|
|
61
62
|
```python
|
|
62
63
|
from pico_ioc import component, init
|
|
@@ -83,21 +84,22 @@ print(db.get_data()) # Data from postgresql://user:pass@host/db
|
|
|
83
84
|
|
|
84
85
|
---
|
|
85
86
|
|
|
86
|
-
##
|
|
87
|
+
## 🧩 Custom Component Keys
|
|
87
88
|
|
|
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.
|
|
89
91
|
|
|
90
92
|
```python
|
|
91
93
|
from pico_ioc import component, init
|
|
92
94
|
|
|
93
|
-
@component(name="config")
|
|
95
|
+
@component(name="config") # still supported for legacy code
|
|
94
96
|
class AppConfig:
|
|
95
97
|
def __init__(self):
|
|
96
98
|
self.db_url = "postgresql://user:pass@localhost/db"
|
|
97
99
|
|
|
98
100
|
@component
|
|
99
101
|
class Repository:
|
|
100
|
-
def __init__(self, config: "config"): #
|
|
102
|
+
def __init__(self, config: "config"): # resolve by name
|
|
101
103
|
self._url = config.db_url
|
|
102
104
|
|
|
103
105
|
container = init(__name__)
|
|
@@ -106,9 +108,12 @@ print(repo._url) # postgresql://user:pass@localhost/db
|
|
|
106
108
|
print(container.get("config").db_url)
|
|
107
109
|
```
|
|
108
110
|
|
|
109
|
-
|
|
111
|
+
---
|
|
110
112
|
|
|
111
|
-
|
|
113
|
+
## 🏭 Factory Components and `@provides`
|
|
114
|
+
|
|
115
|
+
Factories can provide components under a specific **key**.
|
|
116
|
+
Default is lazy creation (via `LazyProxy`).
|
|
112
117
|
|
|
113
118
|
```python
|
|
114
119
|
from pico_ioc import factory_component, provides, init
|
|
@@ -117,7 +122,7 @@ CREATION_COUNTER = {"value": 0}
|
|
|
117
122
|
|
|
118
123
|
@factory_component
|
|
119
124
|
class ServicesFactory:
|
|
120
|
-
@provides(
|
|
125
|
+
@provides(key="heavy_service") # preferred
|
|
121
126
|
def make_heavy(self):
|
|
122
127
|
CREATION_COUNTER["value"] += 1
|
|
123
128
|
return {"payload": "Hello from heavy service"}
|
|
@@ -130,7 +135,9 @@ print(svc["payload"]) # triggers creation
|
|
|
130
135
|
print(CREATION_COUNTER["value"]) # 1
|
|
131
136
|
```
|
|
132
137
|
|
|
133
|
-
|
|
138
|
+
---
|
|
139
|
+
|
|
140
|
+
## 📦 Project-Style Scanning
|
|
134
141
|
|
|
135
142
|
```
|
|
136
143
|
project_root/
|
|
@@ -165,61 +172,62 @@ class ApiClient:
|
|
|
165
172
|
import pico_ioc
|
|
166
173
|
from myapp.services import ApiClient
|
|
167
174
|
|
|
168
|
-
# Scan the whole 'myapp' package
|
|
169
175
|
container = pico_ioc.init("myapp")
|
|
170
|
-
|
|
171
176
|
client = container.get(ApiClient)
|
|
172
177
|
print(client.get("status")) # GET https://api.example.com/status
|
|
173
178
|
```
|
|
174
179
|
|
|
175
180
|
---
|
|
176
181
|
|
|
177
|
-
##
|
|
182
|
+
## 🧠 Dependency Resolution Order
|
|
178
183
|
|
|
179
|
-
|
|
184
|
+
When Pico-IoC instantiates a component, it tries to resolve each parameter in this order:
|
|
180
185
|
|
|
181
|
-
|
|
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
|
|
182
190
|
|
|
183
|
-
|
|
191
|
+
---
|
|
184
192
|
|
|
185
|
-
|
|
193
|
+
## 🛠 API Reference
|
|
186
194
|
|
|
187
|
-
###
|
|
195
|
+
### `init(root_package_or_module, *, exclude=None, auto_exclude_caller=True) -> PicoContainer`
|
|
188
196
|
|
|
189
|
-
|
|
197
|
+
Scan the given root **package** (str) or **module**.
|
|
198
|
+
By default, excludes the calling module.
|
|
190
199
|
|
|
191
|
-
### `@
|
|
200
|
+
### `@component(cls=None, *, name=None)`
|
|
192
201
|
|
|
193
|
-
|
|
202
|
+
Register a class as a component.
|
|
203
|
+
If `name` is given, registers under that string; otherwise under the class type.
|
|
194
204
|
|
|
195
|
-
|
|
205
|
+
### `@factory_component`
|
|
196
206
|
|
|
197
|
-
|
|
207
|
+
Register a class as a factory of components.
|
|
198
208
|
|
|
199
|
-
|
|
209
|
+
### `@provides(key=None, *, name=None, lazy=True)`
|
|
200
210
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
tox # run all configured envs
|
|
205
|
-
```
|
|
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.
|
|
206
214
|
|
|
207
215
|
---
|
|
208
216
|
|
|
209
|
-
##
|
|
217
|
+
## 🧪 Testing
|
|
210
218
|
|
|
211
|
-
|
|
219
|
+
```bash
|
|
220
|
+
pip install tox
|
|
221
|
+
tox -e py311
|
|
222
|
+
```
|
|
212
223
|
|
|
213
224
|
---
|
|
214
225
|
|
|
215
|
-
## License
|
|
226
|
+
## 📜 License
|
|
216
227
|
|
|
217
|
-
MIT — see
|
|
228
|
+
MIT — see [LICENSE](https://opensource.org/licenses/MIT)
|
|
218
229
|
|
|
219
230
|
---
|
|
220
231
|
|
|
221
|
-
|
|
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.
|
|
222
233
|
|
|
223
|
-
* **David Perez Cabrera**
|
|
224
|
-
* **Gemini 2.5-Pro**
|
|
225
|
-
* **GPT-5**
|
|
@@ -1,29 +1,32 @@
|
|
|
1
|
-
|
|
1
|
+
|
|
2
|
+
# 📦 Pico-IoC: A Minimalist IoC Container for Python
|
|
2
3
|
|
|
3
4
|
[](https://pypi.org/project/pico-ioc/)
|
|
4
5
|
[](https://opensource.org/licenses/MIT)
|
|
5
6
|

|
|
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
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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
|
-
##
|
|
64
|
+
## 🧩 Custom Component Keys
|
|
64
65
|
|
|
65
|
-
|
|
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"): #
|
|
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
|
-
|
|
88
|
+
---
|
|
87
89
|
|
|
88
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
##
|
|
159
|
+
## 🧠 Dependency Resolution Order
|
|
155
160
|
|
|
156
|
-
|
|
161
|
+
When Pico-IoC instantiates a component, it tries to resolve each parameter in this order:
|
|
157
162
|
|
|
158
|
-
|
|
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
|
-
|
|
168
|
+
---
|
|
161
169
|
|
|
162
|
-
|
|
170
|
+
## 🛠 API Reference
|
|
163
171
|
|
|
164
|
-
###
|
|
172
|
+
### `init(root_package_or_module, *, exclude=None, auto_exclude_caller=True) -> PicoContainer`
|
|
165
173
|
|
|
166
|
-
|
|
174
|
+
Scan the given root **package** (str) or **module**.
|
|
175
|
+
By default, excludes the calling module.
|
|
167
176
|
|
|
168
|
-
### `@
|
|
177
|
+
### `@component(cls=None, *, name=None)`
|
|
169
178
|
|
|
170
|
-
|
|
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
|
-
|
|
184
|
+
Register a class as a factory of components.
|
|
175
185
|
|
|
176
|
-
|
|
186
|
+
### `@provides(key=None, *, name=None, lazy=True)`
|
|
177
187
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
##
|
|
194
|
+
## 🧪 Testing
|
|
187
195
|
|
|
188
|
-
|
|
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
|
|
205
|
+
MIT — see [LICENSE](https://opensource.org/licenses/MIT)
|
|
195
206
|
|
|
196
207
|
---
|
|
197
208
|
|
|
198
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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'
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pico-ioc
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.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
|
|
@@ -21,32 +21,35 @@ Classifier: Operating System :: OS Independent
|
|
|
21
21
|
Requires-Python: >=3.8
|
|
22
22
|
Description-Content-Type: text/markdown
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
|
|
25
|
+
# 📦 Pico-IoC: A Minimalist IoC Container for Python
|
|
25
26
|
|
|
26
27
|
[](https://pypi.org/project/pico-ioc/)
|
|
27
28
|
[](https://opensource.org/licenses/MIT)
|
|
28
29
|

|
|
29
30
|
|
|
30
|
-
**Pico-IoC** is a tiny, zero-dependency, decorator-based Inversion of Control (IoC) container for Python.
|
|
31
|
+
**Pico-IoC** is a tiny, zero-dependency, decorator-based Inversion of Control (IoC) container for Python.
|
|
31
32
|
It helps you manage dependencies in a clean, intuitive, and *Pythonic* way.
|
|
32
33
|
|
|
33
|
-
The core idea is to let you build loosely coupled, easily testable applications without manually wiring components.
|
|
34
|
+
The core idea is to let you build loosely coupled, easily testable applications without manually wiring components.
|
|
34
35
|
*Inspired by the IoC philosophy popularized by the Spring Framework.*
|
|
35
36
|
|
|
36
37
|
---
|
|
37
38
|
|
|
38
|
-
## Key Features
|
|
39
|
+
## ✨ Key Features
|
|
39
40
|
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
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.
|
|
46
49
|
|
|
47
50
|
---
|
|
48
51
|
|
|
49
|
-
## Installation
|
|
52
|
+
## 📦 Installation
|
|
50
53
|
|
|
51
54
|
```bash
|
|
52
55
|
pip install pico-ioc
|
|
@@ -54,9 +57,7 @@ pip install pico-ioc
|
|
|
54
57
|
|
|
55
58
|
---
|
|
56
59
|
|
|
57
|
-
## Quick Start
|
|
58
|
-
|
|
59
|
-
Getting started is simple. Decorate your classes and let Pico-IoC wire them up.
|
|
60
|
+
## 🚀 Quick Start
|
|
60
61
|
|
|
61
62
|
```python
|
|
62
63
|
from pico_ioc import component, init
|
|
@@ -83,21 +84,22 @@ print(db.get_data()) # Data from postgresql://user:pass@host/db
|
|
|
83
84
|
|
|
84
85
|
---
|
|
85
86
|
|
|
86
|
-
##
|
|
87
|
+
## 🧩 Custom Component Keys
|
|
87
88
|
|
|
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.
|
|
89
91
|
|
|
90
92
|
```python
|
|
91
93
|
from pico_ioc import component, init
|
|
92
94
|
|
|
93
|
-
@component(name="config")
|
|
95
|
+
@component(name="config") # still supported for legacy code
|
|
94
96
|
class AppConfig:
|
|
95
97
|
def __init__(self):
|
|
96
98
|
self.db_url = "postgresql://user:pass@localhost/db"
|
|
97
99
|
|
|
98
100
|
@component
|
|
99
101
|
class Repository:
|
|
100
|
-
def __init__(self, config: "config"): #
|
|
102
|
+
def __init__(self, config: "config"): # resolve by name
|
|
101
103
|
self._url = config.db_url
|
|
102
104
|
|
|
103
105
|
container = init(__name__)
|
|
@@ -106,9 +108,12 @@ print(repo._url) # postgresql://user:pass@localhost/db
|
|
|
106
108
|
print(container.get("config").db_url)
|
|
107
109
|
```
|
|
108
110
|
|
|
109
|
-
|
|
111
|
+
---
|
|
110
112
|
|
|
111
|
-
|
|
113
|
+
## 🏭 Factory Components and `@provides`
|
|
114
|
+
|
|
115
|
+
Factories can provide components under a specific **key**.
|
|
116
|
+
Default is lazy creation (via `LazyProxy`).
|
|
112
117
|
|
|
113
118
|
```python
|
|
114
119
|
from pico_ioc import factory_component, provides, init
|
|
@@ -117,7 +122,7 @@ CREATION_COUNTER = {"value": 0}
|
|
|
117
122
|
|
|
118
123
|
@factory_component
|
|
119
124
|
class ServicesFactory:
|
|
120
|
-
@provides(
|
|
125
|
+
@provides(key="heavy_service") # preferred
|
|
121
126
|
def make_heavy(self):
|
|
122
127
|
CREATION_COUNTER["value"] += 1
|
|
123
128
|
return {"payload": "Hello from heavy service"}
|
|
@@ -130,7 +135,9 @@ print(svc["payload"]) # triggers creation
|
|
|
130
135
|
print(CREATION_COUNTER["value"]) # 1
|
|
131
136
|
```
|
|
132
137
|
|
|
133
|
-
|
|
138
|
+
---
|
|
139
|
+
|
|
140
|
+
## 📦 Project-Style Scanning
|
|
134
141
|
|
|
135
142
|
```
|
|
136
143
|
project_root/
|
|
@@ -165,61 +172,62 @@ class ApiClient:
|
|
|
165
172
|
import pico_ioc
|
|
166
173
|
from myapp.services import ApiClient
|
|
167
174
|
|
|
168
|
-
# Scan the whole 'myapp' package
|
|
169
175
|
container = pico_ioc.init("myapp")
|
|
170
|
-
|
|
171
176
|
client = container.get(ApiClient)
|
|
172
177
|
print(client.get("status")) # GET https://api.example.com/status
|
|
173
178
|
```
|
|
174
179
|
|
|
175
180
|
---
|
|
176
181
|
|
|
177
|
-
##
|
|
182
|
+
## 🧠 Dependency Resolution Order
|
|
178
183
|
|
|
179
|
-
|
|
184
|
+
When Pico-IoC instantiates a component, it tries to resolve each parameter in this order:
|
|
180
185
|
|
|
181
|
-
|
|
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
|
|
182
190
|
|
|
183
|
-
|
|
191
|
+
---
|
|
184
192
|
|
|
185
|
-
|
|
193
|
+
## 🛠 API Reference
|
|
186
194
|
|
|
187
|
-
###
|
|
195
|
+
### `init(root_package_or_module, *, exclude=None, auto_exclude_caller=True) -> PicoContainer`
|
|
188
196
|
|
|
189
|
-
|
|
197
|
+
Scan the given root **package** (str) or **module**.
|
|
198
|
+
By default, excludes the calling module.
|
|
190
199
|
|
|
191
|
-
### `@
|
|
200
|
+
### `@component(cls=None, *, name=None)`
|
|
192
201
|
|
|
193
|
-
|
|
202
|
+
Register a class as a component.
|
|
203
|
+
If `name` is given, registers under that string; otherwise under the class type.
|
|
194
204
|
|
|
195
|
-
|
|
205
|
+
### `@factory_component`
|
|
196
206
|
|
|
197
|
-
|
|
207
|
+
Register a class as a factory of components.
|
|
198
208
|
|
|
199
|
-
|
|
209
|
+
### `@provides(key=None, *, name=None, lazy=True)`
|
|
200
210
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
tox # run all configured envs
|
|
205
|
-
```
|
|
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.
|
|
206
214
|
|
|
207
215
|
---
|
|
208
216
|
|
|
209
|
-
##
|
|
217
|
+
## 🧪 Testing
|
|
210
218
|
|
|
211
|
-
|
|
219
|
+
```bash
|
|
220
|
+
pip install tox
|
|
221
|
+
tox -e py311
|
|
222
|
+
```
|
|
212
223
|
|
|
213
224
|
---
|
|
214
225
|
|
|
215
|
-
## License
|
|
226
|
+
## 📜 License
|
|
216
227
|
|
|
217
|
-
MIT — see
|
|
228
|
+
MIT — see [LICENSE](https://opensource.org/licenses/MIT)
|
|
218
229
|
|
|
219
230
|
---
|
|
220
231
|
|
|
221
|
-
|
|
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.
|
|
222
233
|
|
|
223
|
-
* **David Perez Cabrera**
|
|
224
|
-
* **Gemini 2.5-Pro**
|
|
225
|
-
* **GPT-5**
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
import sys
|
|
3
|
+
import pico_ioc
|
|
4
|
+
|
|
5
|
+
# --- Test Environment Setup Fixture ---
|
|
6
|
+
|
|
7
|
+
@pytest.fixture
|
|
8
|
+
def test_project(tmp_path):
|
|
9
|
+
"""
|
|
10
|
+
Creates a fake project in a temporary directory so the pico_ioc scanner
|
|
11
|
+
can find components/factories via import.
|
|
12
|
+
"""
|
|
13
|
+
project_root = tmp_path / "test_project"
|
|
14
|
+
project_root.mkdir()
|
|
15
|
+
|
|
16
|
+
# Make the temp root importable
|
|
17
|
+
sys.path.insert(0, str(tmp_path))
|
|
18
|
+
|
|
19
|
+
# Turn 'test_project' into a real package
|
|
20
|
+
(project_root / "__init__.py").touch()
|
|
21
|
+
|
|
22
|
+
# Create the package 'services'
|
|
23
|
+
package_dir = project_root / "services"
|
|
24
|
+
package_dir.mkdir()
|
|
25
|
+
(package_dir / "__init__.py").touch()
|
|
26
|
+
|
|
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)
|
|
34
|
+
(package_dir / "components.py").write_text(
|
|
35
|
+
"""
|
|
36
|
+
from pico_ioc import component
|
|
37
|
+
|
|
38
|
+
class BaseInterface: ...
|
|
39
|
+
class SubInterface(BaseInterface): ...
|
|
40
|
+
|
|
41
|
+
@component
|
|
42
|
+
class SimpleService:
|
|
43
|
+
def get_id(self):
|
|
44
|
+
return id(self)
|
|
45
|
+
|
|
46
|
+
@component
|
|
47
|
+
class AnotherService:
|
|
48
|
+
def __init__(self, simple_service: SimpleService):
|
|
49
|
+
# Will resolve by TYPE because there is no provider bound by the name "simple_service"
|
|
50
|
+
self.child = simple_service
|
|
51
|
+
|
|
52
|
+
@component(name="custom_name_service")
|
|
53
|
+
class CustomNameService:
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
@component
|
|
57
|
+
class NeedsByName:
|
|
58
|
+
def __init__(self, fast_model):
|
|
59
|
+
# Will resolve by NAME, since there will be a provider bound to "fast_model"
|
|
60
|
+
self.model = fast_model
|
|
61
|
+
|
|
62
|
+
@component
|
|
63
|
+
class NeedsNameVsType:
|
|
64
|
+
def __init__(self, fast_model: BaseInterface):
|
|
65
|
+
# There will be providers for BOTH the name "fast_model" and the base type.
|
|
66
|
+
# NAME must win over TYPE.
|
|
67
|
+
self.model = fast_model
|
|
68
|
+
|
|
69
|
+
@component
|
|
70
|
+
class NeedsTypeFallback:
|
|
71
|
+
def __init__(self, impl: SubInterface):
|
|
72
|
+
# There will NOT be a provider for the name "impl" nor for SubInterface directly,
|
|
73
|
+
# but there will be one for BaseInterface → must fallback via MRO.
|
|
74
|
+
self.impl = impl
|
|
75
|
+
|
|
76
|
+
@component
|
|
77
|
+
class MissingDep:
|
|
78
|
+
def __init__(self, missing):
|
|
79
|
+
# No provider by name nor type: must raise on resolution.
|
|
80
|
+
self.missing = missing
|
|
81
|
+
"""
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
# Factories:
|
|
85
|
+
# - complex_service (lazy via LazyProxy; counter to assert laziness)
|
|
86
|
+
# - fast_model (by NAME)
|
|
87
|
+
# - base_interface (by TYPE: BaseInterface)
|
|
88
|
+
(package_dir / "factories.py").write_text(
|
|
89
|
+
"""
|
|
90
|
+
from pico_ioc import factory_component, provides
|
|
91
|
+
from .components import BaseInterface
|
|
92
|
+
|
|
93
|
+
# Used to assert lazy instantiation
|
|
94
|
+
CREATION_COUNTER = {"value": 0}
|
|
95
|
+
FAST_COUNTER = {"value": 0}
|
|
96
|
+
BASE_COUNTER = {"value": 0}
|
|
97
|
+
|
|
98
|
+
@factory_component
|
|
99
|
+
class ServiceFactory:
|
|
100
|
+
@provides(key="complex_service")
|
|
101
|
+
def create_complex_service(self):
|
|
102
|
+
# Increment ONLY when the real object is created (not when proxy is returned)
|
|
103
|
+
CREATION_COUNTER["value"] += 1
|
|
104
|
+
return "This is a complex service"
|
|
105
|
+
|
|
106
|
+
@provides(key="fast_model")
|
|
107
|
+
def create_fast_model(self):
|
|
108
|
+
FAST_COUNTER["value"] += 1
|
|
109
|
+
return {"who": "fast"} # any object; dict is convenient for identity checks
|
|
110
|
+
|
|
111
|
+
@provides(key=BaseInterface)
|
|
112
|
+
def create_base_interface(self):
|
|
113
|
+
BASE_COUNTER["value"] += 1
|
|
114
|
+
return {"who": "base"}
|
|
115
|
+
"""
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
# Optional module that calls init() at import-time:
|
|
119
|
+
# used to test auto-exclude of the caller (to prevent re-entrancy).
|
|
120
|
+
(project_root / "entry.py").write_text(
|
|
121
|
+
"""
|
|
122
|
+
import pico_ioc
|
|
123
|
+
import test_project
|
|
124
|
+
|
|
125
|
+
# If auto-exclude-caller is on AND _scan_and_configure() honors 'exclude',
|
|
126
|
+
# importing this module during scanning should NOT recurse infinitely.
|
|
127
|
+
ioc = pico_ioc.init(test_project)
|
|
128
|
+
"""
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
# Yield the root package name used by pico_ioc.init()
|
|
132
|
+
yield "test_project"
|
|
133
|
+
|
|
134
|
+
# Teardown: remove path, reset container, purge modules from cache
|
|
135
|
+
sys.path.pop(0)
|
|
136
|
+
pico_ioc._container = None
|
|
137
|
+
mods_to_del = [m for m in list(sys.modules.keys()) if m == "test_project" or m.startswith("test_project.")]
|
|
138
|
+
for m in mods_to_del:
|
|
139
|
+
sys.modules.pop(m, None)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# --- Test Suite ---
|
|
143
|
+
|
|
144
|
+
def test_simple_component_retrieval(test_project):
|
|
145
|
+
"""A plain component registered by class can be retrieved by its class key."""
|
|
146
|
+
from test_project.services.components import SimpleService
|
|
147
|
+
|
|
148
|
+
container = pico_ioc.init(test_project)
|
|
149
|
+
service = container.get(SimpleService)
|
|
150
|
+
|
|
151
|
+
assert service is not None
|
|
152
|
+
assert isinstance(service, SimpleService)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
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
|
+
"""
|
|
160
|
+
from test_project.services.components import SimpleService, AnotherService
|
|
161
|
+
|
|
162
|
+
container = pico_ioc.init(test_project)
|
|
163
|
+
another = container.get(AnotherService)
|
|
164
|
+
|
|
165
|
+
assert another is not None
|
|
166
|
+
assert isinstance(another.child, SimpleService)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def test_components_are_singletons_by_default(test_project):
|
|
170
|
+
"""
|
|
171
|
+
Providers bound by the scanner are singletons: get() returns the same instance.
|
|
172
|
+
"""
|
|
173
|
+
from test_project.services.components import SimpleService
|
|
174
|
+
|
|
175
|
+
container = pico_ioc.init(test_project)
|
|
176
|
+
s1 = container.get(SimpleService)
|
|
177
|
+
s2 = container.get(SimpleService)
|
|
178
|
+
|
|
179
|
+
assert s1 is s2
|
|
180
|
+
assert s1.get_id() == s2.get_id()
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
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
|
+
"""
|
|
187
|
+
container = pico_ioc.init(test_project)
|
|
188
|
+
|
|
189
|
+
class Unregistered: ...
|
|
190
|
+
with pytest.raises(NameError, match="No provider found for key"):
|
|
191
|
+
container.get(Unregistered)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
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
|
+
"""
|
|
198
|
+
container = pico_ioc.init(test_project)
|
|
199
|
+
svc = container.get("complex_service")
|
|
200
|
+
|
|
201
|
+
# Proxy must behave like the real string for equality
|
|
202
|
+
assert svc == "This is a complex service"
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
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
|
+
"""
|
|
210
|
+
from test_project.services.factories import CREATION_COUNTER
|
|
211
|
+
|
|
212
|
+
container = pico_ioc.init(test_project)
|
|
213
|
+
|
|
214
|
+
assert CREATION_COUNTER["value"] == 0
|
|
215
|
+
|
|
216
|
+
proxy = container.get("complex_service")
|
|
217
|
+
# Accessing attributes/methods of the proxy should trigger creation exactly once
|
|
218
|
+
assert CREATION_COUNTER["value"] == 0
|
|
219
|
+
up = proxy.upper()
|
|
220
|
+
assert up == "THIS IS A COMPLEX SERVICE"
|
|
221
|
+
assert CREATION_COUNTER["value"] == 1
|
|
222
|
+
|
|
223
|
+
# Re-accessing via the same proxy does not create again
|
|
224
|
+
_ = proxy.lower()
|
|
225
|
+
assert CREATION_COUNTER["value"] == 1
|
|
226
|
+
|
|
227
|
+
# Getting the same key again should return the same singleton instance (no extra creations)
|
|
228
|
+
again = container.get("complex_service")
|
|
229
|
+
assert again is proxy # same object returned by container
|
|
230
|
+
_ = again.strip()
|
|
231
|
+
assert CREATION_COUNTER["value"] == 1
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
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
|
+
"""
|
|
239
|
+
from test_project.services.components import CustomNameService
|
|
240
|
+
|
|
241
|
+
container = pico_ioc.init(test_project)
|
|
242
|
+
svc = container.get("custom_name_service")
|
|
243
|
+
assert isinstance(svc, CustomNameService)
|
|
244
|
+
|
|
245
|
+
with pytest.raises(NameError):
|
|
246
|
+
container.get(CustomNameService)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
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
|
+
"""
|
|
254
|
+
from test_project.services.components import NeedsNameVsType
|
|
255
|
+
from test_project.services.factories import FAST_COUNTER, BASE_COUNTER
|
|
256
|
+
|
|
257
|
+
container = pico_ioc.init(test_project)
|
|
258
|
+
comp = container.get(NeedsNameVsType)
|
|
259
|
+
|
|
260
|
+
# "fast_model" name must win → uses the fast provider
|
|
261
|
+
assert comp.model == {"who": "fast"}
|
|
262
|
+
assert FAST_COUNTER["value"] == 1
|
|
263
|
+
# Base provider should NOT be used for this resolution
|
|
264
|
+
assert BASE_COUNTER["value"] == 0
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
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
|
+
"""
|
|
271
|
+
from test_project.services.components import NeedsByName
|
|
272
|
+
from test_project.services.factories import FAST_COUNTER
|
|
273
|
+
|
|
274
|
+
container = pico_ioc.init(test_project)
|
|
275
|
+
comp = container.get(NeedsByName)
|
|
276
|
+
|
|
277
|
+
assert comp.model == {"who": "fast"}
|
|
278
|
+
assert FAST_COUNTER["value"] == 1
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
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
|
+
"""
|
|
286
|
+
from test_project.services.components import NeedsTypeFallback
|
|
287
|
+
from test_project.services.factories import BASE_COUNTER
|
|
288
|
+
|
|
289
|
+
container = pico_ioc.init(test_project)
|
|
290
|
+
comp = container.get(NeedsTypeFallback)
|
|
291
|
+
|
|
292
|
+
# Resolved via MRO to BaseInterface provider
|
|
293
|
+
assert comp.impl == {"who": "base"}
|
|
294
|
+
assert BASE_COUNTER["value"] == 1
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
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
|
+
"""
|
|
301
|
+
from test_project.services.components import MissingDep
|
|
302
|
+
|
|
303
|
+
container = pico_ioc.init(test_project)
|
|
304
|
+
with pytest.raises(NameError, match="No provider found for key"):
|
|
305
|
+
container.get(MissingDep)
|
|
306
|
+
|
|
307
|
+
|
|
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):
|
|
313
|
+
"""
|
|
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.
|
|
317
|
+
"""
|
|
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")
|
|
321
|
+
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = '0.1.1'
|
|
@@ -1,178 +0,0 @@
|
|
|
1
|
-
# tests/test_pico_ioc.py
|
|
2
|
-
|
|
3
|
-
import pytest
|
|
4
|
-
import sys
|
|
5
|
-
import pico_ioc
|
|
6
|
-
|
|
7
|
-
# --- Test Environment Setup Fixture ---
|
|
8
|
-
|
|
9
|
-
@pytest.fixture
|
|
10
|
-
def test_project(tmp_path):
|
|
11
|
-
"""
|
|
12
|
-
Creates a fake project structure in a temporary directory
|
|
13
|
-
so that the pico_ioc scanner can find the components.
|
|
14
|
-
"""
|
|
15
|
-
project_root = tmp_path / "test_project"
|
|
16
|
-
project_root.mkdir()
|
|
17
|
-
|
|
18
|
-
# Add the root directory to the Python path so modules can be imported
|
|
19
|
-
sys.path.insert(0, str(tmp_path))
|
|
20
|
-
|
|
21
|
-
# >>> THE LINE THAT FIXES THE PROBLEM <<<
|
|
22
|
-
# Turn 'test_project' into a regular package.
|
|
23
|
-
(project_root / "__init__.py").touch()
|
|
24
|
-
|
|
25
|
-
# Create the package and modules with test components
|
|
26
|
-
package_dir = project_root / "services"
|
|
27
|
-
package_dir.mkdir()
|
|
28
|
-
|
|
29
|
-
# __init__.py file to turn 'services' into a sub-package
|
|
30
|
-
(package_dir / "__init__.py").touch()
|
|
31
|
-
|
|
32
|
-
# Module with simple components and components with dependencies
|
|
33
|
-
(package_dir / "components.py").write_text("""
|
|
34
|
-
from pico_ioc import component
|
|
35
|
-
|
|
36
|
-
@component
|
|
37
|
-
class SimpleService:
|
|
38
|
-
def get_id(self):
|
|
39
|
-
return id(self)
|
|
40
|
-
|
|
41
|
-
@component
|
|
42
|
-
class AnotherService:
|
|
43
|
-
def __init__(self, simple_service: SimpleService):
|
|
44
|
-
self.child = simple_service
|
|
45
|
-
|
|
46
|
-
@component(name="custom_name_service")
|
|
47
|
-
class CustomNameService:
|
|
48
|
-
pass
|
|
49
|
-
""")
|
|
50
|
-
|
|
51
|
-
# Module with a component factory
|
|
52
|
-
(package_dir / "factories.py").write_text("""
|
|
53
|
-
from pico_ioc import factory_component, provides
|
|
54
|
-
|
|
55
|
-
# To test that instantiation is lazy
|
|
56
|
-
CREATION_COUNTER = {"value": 0}
|
|
57
|
-
|
|
58
|
-
@factory_component
|
|
59
|
-
class ServiceFactory:
|
|
60
|
-
@provides(name="complex_service")
|
|
61
|
-
def create_complex_service(self):
|
|
62
|
-
CREATION_COUNTER["value"] += 1
|
|
63
|
-
return "This is a complex service"
|
|
64
|
-
""")
|
|
65
|
-
|
|
66
|
-
# Return the root package name for init() to use
|
|
67
|
-
yield "test_project"
|
|
68
|
-
|
|
69
|
-
sys.path.pop(0)
|
|
70
|
-
|
|
71
|
-
# Reset the global pico_ioc container to isolate tests.
|
|
72
|
-
pico_ioc._container = None
|
|
73
|
-
|
|
74
|
-
# Purge the temporary package from the module cache so each test starts
|
|
75
|
-
# with a fresh module state (e.g., CREATION_COUNTER resets to 0).
|
|
76
|
-
mods_to_del = [
|
|
77
|
-
m for m in list(sys.modules.keys())
|
|
78
|
-
if m == "test_project" or m.startswith("test_project.")
|
|
79
|
-
]
|
|
80
|
-
for m in mods_to_del:
|
|
81
|
-
sys.modules.pop(m, None)
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
# --- Test Suite ---
|
|
85
|
-
|
|
86
|
-
def test_simple_component_retrieval(test_project):
|
|
87
|
-
"""Verifies that a simple component can be registered and retrieved."""
|
|
88
|
-
# Import the TEST classes after the fixture has created them
|
|
89
|
-
from test_project.services.components import SimpleService
|
|
90
|
-
|
|
91
|
-
container = pico_ioc.init(test_project)
|
|
92
|
-
service = container.get(SimpleService)
|
|
93
|
-
|
|
94
|
-
assert service is not None
|
|
95
|
-
assert isinstance(service, SimpleService)
|
|
96
|
-
|
|
97
|
-
def test_dependency_injection(test_project):
|
|
98
|
-
"""Verifies that a dependency is correctly injected into another component."""
|
|
99
|
-
from test_project.services.components import SimpleService, AnotherService
|
|
100
|
-
|
|
101
|
-
container = pico_ioc.init(test_project)
|
|
102
|
-
another_service = container.get(AnotherService)
|
|
103
|
-
|
|
104
|
-
assert another_service is not None
|
|
105
|
-
assert hasattr(another_service, "child")
|
|
106
|
-
assert isinstance(another_service.child, SimpleService)
|
|
107
|
-
|
|
108
|
-
def test_components_are_singletons_by_default(test_project):
|
|
109
|
-
"""Verifies that get() always returns the same instance for a component."""
|
|
110
|
-
from test_project.services.components import SimpleService
|
|
111
|
-
|
|
112
|
-
container = pico_ioc.init(test_project)
|
|
113
|
-
service1 = container.get(SimpleService)
|
|
114
|
-
service2 = container.get(SimpleService)
|
|
115
|
-
|
|
116
|
-
assert service1 is service2
|
|
117
|
-
assert service1.get_id() == service2.get_id()
|
|
118
|
-
|
|
119
|
-
def test_get_unregistered_component_raises_error(test_project):
|
|
120
|
-
"""Verifies that requesting an unregistered component raises a NameError."""
|
|
121
|
-
container = pico_ioc.init(test_project)
|
|
122
|
-
|
|
123
|
-
class UnregisteredClass:
|
|
124
|
-
pass
|
|
125
|
-
|
|
126
|
-
with pytest.raises(NameError, match="No provider found for key"):
|
|
127
|
-
container.get(UnregisteredClass)
|
|
128
|
-
|
|
129
|
-
def test_factory_provides_component(test_project):
|
|
130
|
-
"""Verifies that a component created by a factory can be retrieved."""
|
|
131
|
-
container = pico_ioc.init(test_project)
|
|
132
|
-
|
|
133
|
-
service = container.get("complex_service")
|
|
134
|
-
|
|
135
|
-
# The object is a proxy, but it should delegate the comparison
|
|
136
|
-
assert service == "This is a complex service"
|
|
137
|
-
|
|
138
|
-
def test_factory_instantiation_is_lazy(test_project):
|
|
139
|
-
"""
|
|
140
|
-
Verifies that a factory's @provides method is only executed
|
|
141
|
-
when the object is first accessed.
|
|
142
|
-
"""
|
|
143
|
-
# Import the counter from the test factory
|
|
144
|
-
from test_project.services.factories import CREATION_COUNTER
|
|
145
|
-
|
|
146
|
-
container = pico_ioc.init(test_project)
|
|
147
|
-
|
|
148
|
-
# Initially, the counter must be 0 because nothing has been created yet
|
|
149
|
-
assert CREATION_COUNTER["value"] == 0
|
|
150
|
-
|
|
151
|
-
# We get the proxy, but this should NOT trigger the creation
|
|
152
|
-
service_proxy = container.get("complex_service")
|
|
153
|
-
assert CREATION_COUNTER["value"] == 0
|
|
154
|
-
|
|
155
|
-
# Now we access an attribute of the real object (through the proxy)
|
|
156
|
-
# This SHOULD trigger the creation
|
|
157
|
-
result = service_proxy.upper() # .upper() is called on the real string
|
|
158
|
-
|
|
159
|
-
assert CREATION_COUNTER["value"] == 1
|
|
160
|
-
assert result == "THIS IS A COMPLEX SERVICE"
|
|
161
|
-
|
|
162
|
-
# If we access it again, the counter should not increment
|
|
163
|
-
_ = service_proxy.lower()
|
|
164
|
-
assert CREATION_COUNTER["value"] == 1
|
|
165
|
-
|
|
166
|
-
def test_component_with_custom_name(test_project):
|
|
167
|
-
"""Verifies that a component with a custom name can be registered and retrieved."""
|
|
168
|
-
from test_project.services.components import CustomNameService
|
|
169
|
-
|
|
170
|
-
container = pico_ioc.init(test_project)
|
|
171
|
-
|
|
172
|
-
# We get the service using its custom name
|
|
173
|
-
service = container.get("custom_name_service")
|
|
174
|
-
assert isinstance(service, CustomNameService)
|
|
175
|
-
|
|
176
|
-
# Verify that requesting it by its class fails, as it was registered by name
|
|
177
|
-
with pytest.raises(NameError):
|
|
178
|
-
container.get(CustomNameService)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|