pico-ioc 0.1.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.0/.github/workflows/ci.yml +35 -0
- pico_ioc-0.1.0/.github/workflows/publish-to-pypi.yml +32 -0
- pico_ioc-0.1.0/Dockerfile.test +9 -0
- pico_ioc-0.1.0/Makefile +14 -0
- pico_ioc-0.1.0/PKG-INFO +22 -0
- pico_ioc-0.1.0/README.MD +202 -0
- pico_ioc-0.1.0/pyproject.toml +46 -0
- pico_ioc-0.1.0/setup.cfg +4 -0
- pico_ioc-0.1.0/src/pico_ioc/__init__.py +213 -0
- pico_ioc-0.1.0/src/pico_ioc/_version.py +1 -0
- pico_ioc-0.1.0/src/pico_ioc.egg-info/PKG-INFO +22 -0
- pico_ioc-0.1.0/src/pico_ioc.egg-info/SOURCES.txt +14 -0
- pico_ioc-0.1.0/src/pico_ioc.egg-info/dependency_links.txt +1 -0
- pico_ioc-0.1.0/src/pico_ioc.egg-info/top_level.txt +1 -0
- pico_ioc-0.1.0/tests/test_pico_ioc.py +178 -0
- pico_ioc-0.1.0/tox.ini +10 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
name: CI (tox matrix)
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [ main, master ]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [ main, master ]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
tests:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
strategy:
|
|
13
|
+
fail-fast: false
|
|
14
|
+
matrix:
|
|
15
|
+
python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13']
|
|
16
|
+
steps:
|
|
17
|
+
- name: Checkout
|
|
18
|
+
uses: actions/checkout@v4
|
|
19
|
+
with:
|
|
20
|
+
fetch-depth: 0
|
|
21
|
+
|
|
22
|
+
- name: Set up Python ${{ matrix.python-version }}
|
|
23
|
+
uses: actions/setup-python@v5
|
|
24
|
+
with:
|
|
25
|
+
python-version: ${{ matrix.python-version }}
|
|
26
|
+
cache: 'pip'
|
|
27
|
+
|
|
28
|
+
- name: Install tox
|
|
29
|
+
run: |
|
|
30
|
+
python -m pip install --upgrade pip
|
|
31
|
+
pip install tox
|
|
32
|
+
|
|
33
|
+
- name: Run tox for this Python
|
|
34
|
+
run: tox -e py${{ matrix.python-version && join(matrix.python-version, '') }}
|
|
35
|
+
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
name: Publish Python Package to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types: [published]
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
deploy:
|
|
9
|
+
runs-on: ubuntu-latest
|
|
10
|
+
permissions:
|
|
11
|
+
id-token: write
|
|
12
|
+
contents: read
|
|
13
|
+
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v4
|
|
16
|
+
with:
|
|
17
|
+
fetch-depth: 0
|
|
18
|
+
|
|
19
|
+
- name: Set up Python
|
|
20
|
+
uses: actions/setup-python@v5
|
|
21
|
+
with:
|
|
22
|
+
python-version: '3.x'
|
|
23
|
+
|
|
24
|
+
- name: Install build deps
|
|
25
|
+
run: python -m pip install --upgrade pip build
|
|
26
|
+
|
|
27
|
+
- name: Build package
|
|
28
|
+
run: python -m build
|
|
29
|
+
|
|
30
|
+
- name: Publish to PyPI
|
|
31
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
32
|
+
|
pico_ioc-0.1.0/Makefile
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
.PHONY: $(VERSIONS) build-% test-% test-all
|
|
2
|
+
|
|
3
|
+
VERSIONS = 3.8 3.9 3.10 3.11 3.12 3.13
|
|
4
|
+
|
|
5
|
+
build-%:
|
|
6
|
+
docker build --pull --build-arg PYTHON_VERSION=$* \
|
|
7
|
+
-t pico-ioc-test:$* -f Dockerfile.test .
|
|
8
|
+
|
|
9
|
+
test-%: build-%
|
|
10
|
+
docker run --rm pico-ioc-test:$*
|
|
11
|
+
|
|
12
|
+
test-all: $(addprefix test-, $(VERSIONS))
|
|
13
|
+
@echo "✅ All versions done"
|
|
14
|
+
|
pico_ioc-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pico-ioc
|
|
3
|
+
Version: 0.1.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
|
pico_ioc-0.1.0/README.MD
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
# Pico-IoC: A Minimalist IoC Container for Python
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/pico-ioc/)
|
|
4
|
+
[](https://opensource.org/licenses/MIT)
|
|
5
|
+

|
|
6
|
+
|
|
7
|
+
**Pico-IoC** is a tiny, zero-dependency, decorator-based Inversion of Control (IoC) container for Python.
|
|
8
|
+
It helps you manage dependencies in a clean, intuitive, and *Pythonic* way.
|
|
9
|
+
|
|
10
|
+
The core idea is to let you build loosely coupled, easily testable applications without manually wiring components.
|
|
11
|
+
*Inspired by the IoC philosophy popularized by the Spring Framework.*
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Key Features
|
|
16
|
+
|
|
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.
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## Installation
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install pico-ioc
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## Quick Start
|
|
35
|
+
|
|
36
|
+
Getting started is simple. Decorate your classes and let Pico-IoC wire them up.
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from pico_ioc import component, init
|
|
40
|
+
|
|
41
|
+
@component
|
|
42
|
+
class AppConfig:
|
|
43
|
+
def get_db_url(self):
|
|
44
|
+
return "postgresql://user:pass@host/db"
|
|
45
|
+
|
|
46
|
+
@component
|
|
47
|
+
class DatabaseService:
|
|
48
|
+
def __init__(self, config: AppConfig):
|
|
49
|
+
self._cs = config.get_db_url()
|
|
50
|
+
|
|
51
|
+
def get_data(self):
|
|
52
|
+
return f"Data from {self._cs}"
|
|
53
|
+
|
|
54
|
+
# Initialize the container scanning the current module
|
|
55
|
+
container = init(__name__)
|
|
56
|
+
|
|
57
|
+
db = container.get(DatabaseService)
|
|
58
|
+
print(db.get_data()) # Data from postgresql://user:pass@host/db
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## More Examples
|
|
64
|
+
|
|
65
|
+
### 🧩 Custom Component Name
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
from pico_ioc import component, init
|
|
69
|
+
|
|
70
|
+
@component(name="config")
|
|
71
|
+
class AppConfig:
|
|
72
|
+
def __init__(self):
|
|
73
|
+
self.db_url = "postgresql://user:pass@localhost/db"
|
|
74
|
+
|
|
75
|
+
@component
|
|
76
|
+
class Repository:
|
|
77
|
+
def __init__(self, config: "config"): # refer by custom name if you prefer
|
|
78
|
+
self._url = config.db_url
|
|
79
|
+
|
|
80
|
+
container = init(__name__)
|
|
81
|
+
repo = container.get(Repository)
|
|
82
|
+
print(repo._url) # postgresql://user:pass@localhost/db
|
|
83
|
+
print(container.get("config").db_url)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
> Pico-IoC prefers **type annotations** to resolve deps; if missing, it falls back to the **parameter name**.
|
|
87
|
+
|
|
88
|
+
### 💤 Lazy Factories (only build when used)
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
from pico_ioc import factory_component, provides, init
|
|
92
|
+
|
|
93
|
+
CREATION_COUNTER = {"value": 0}
|
|
94
|
+
|
|
95
|
+
@factory_component
|
|
96
|
+
class ServicesFactory:
|
|
97
|
+
@provides(name="heavy_service") # returns a LazyProxy by default
|
|
98
|
+
def make_heavy(self):
|
|
99
|
+
CREATION_COUNTER["value"] += 1
|
|
100
|
+
return {"payload": "Hello from heavy service"}
|
|
101
|
+
|
|
102
|
+
container = init(__name__)
|
|
103
|
+
svc = container.get("heavy_service")
|
|
104
|
+
print(CREATION_COUNTER["value"]) # 0 (not created yet)
|
|
105
|
+
|
|
106
|
+
print(svc["payload"]) # triggers creation
|
|
107
|
+
print(CREATION_COUNTER["value"]) # 1
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
### 📦 Project-Style Package Scanning
|
|
111
|
+
|
|
112
|
+
```
|
|
113
|
+
project_root/
|
|
114
|
+
└── myapp/
|
|
115
|
+
├── __init__.py
|
|
116
|
+
├── services.py
|
|
117
|
+
└── main.py
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
**myapp/services.py**
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
from pico_ioc import component
|
|
124
|
+
|
|
125
|
+
@component
|
|
126
|
+
class Config:
|
|
127
|
+
def __init__(self):
|
|
128
|
+
self.base_url = "https://api.example.com"
|
|
129
|
+
|
|
130
|
+
@component
|
|
131
|
+
class ApiClient:
|
|
132
|
+
def __init__(self, config: Config):
|
|
133
|
+
self.base_url = config.base_url
|
|
134
|
+
|
|
135
|
+
def get(self, path: str):
|
|
136
|
+
return f"GET {self.base_url}/{path}"
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
**myapp/main.py**
|
|
140
|
+
|
|
141
|
+
```python
|
|
142
|
+
import pico_ioc
|
|
143
|
+
from myapp.services import ApiClient
|
|
144
|
+
|
|
145
|
+
# Scan the whole 'myapp' package
|
|
146
|
+
container = pico_ioc.init("myapp")
|
|
147
|
+
|
|
148
|
+
client = container.get(ApiClient)
|
|
149
|
+
print(client.get("status")) # GET https://api.example.com/status
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
---
|
|
153
|
+
|
|
154
|
+
## API Reference (mini)
|
|
155
|
+
|
|
156
|
+
### `init(root_package_or_module) -> PicoContainer`
|
|
157
|
+
|
|
158
|
+
Initialize the container by scanning a root **package** (str) or **module**. Returns the configured container.
|
|
159
|
+
|
|
160
|
+
### `@component(cls=None, *, name: str | None = None)`
|
|
161
|
+
|
|
162
|
+
Mark a class as a component. Registered by **class type** by default, or by **name** if provided.
|
|
163
|
+
|
|
164
|
+
### `@factory_component`
|
|
165
|
+
|
|
166
|
+
Mark a class as a factory holder. Methods inside can be `@provides(...)`.
|
|
167
|
+
|
|
168
|
+
### `@provides(name: str, lazy: bool = True)`
|
|
169
|
+
|
|
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).
|
|
171
|
+
|
|
172
|
+
---
|
|
173
|
+
|
|
174
|
+
## Testing
|
|
175
|
+
|
|
176
|
+
`pico-ioc` ships with `pytest` tests and a `tox` matrix.
|
|
177
|
+
|
|
178
|
+
```bash
|
|
179
|
+
pip install tox
|
|
180
|
+
tox -e py311 # run on a specific version
|
|
181
|
+
tox # run all configured envs
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
---
|
|
185
|
+
|
|
186
|
+
## Contributing
|
|
187
|
+
|
|
188
|
+
Issues and PRs are welcome. If you spot a bug or have an idea, open an issue!
|
|
189
|
+
|
|
190
|
+
---
|
|
191
|
+
|
|
192
|
+
## License
|
|
193
|
+
|
|
194
|
+
MIT — see the [LICENSE](https://opensource.org/licenses/MIT).
|
|
195
|
+
|
|
196
|
+
---
|
|
197
|
+
|
|
198
|
+
## Authors
|
|
199
|
+
|
|
200
|
+
* **David Perez Cabrera**
|
|
201
|
+
* **Gemini 2.5-Pro**
|
|
202
|
+
* **GPT-5**
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=69.0", "wheel", "setuptools-scm>=8.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pico-ioc"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
authors = [{ name = "David Perez Cabrera", email = "dperezcabrera@gmail.com" }]
|
|
9
|
+
description = "A minimalist, zero-dependency Inversion of Control (IoC) container for Python."
|
|
10
|
+
readme = { file = "README.md", content-type = "text/markdown" }
|
|
11
|
+
requires-python = ">=3.8"
|
|
12
|
+
license = { file = "LICENSE" }
|
|
13
|
+
keywords = ["ioc", "di", "dependency injection", "inversion of control", "decorator"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
18
|
+
"Programming Language :: Python :: 3.8",
|
|
19
|
+
"Programming Language :: Python :: 3.9",
|
|
20
|
+
"Programming Language :: Python :: 3.10",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Programming Language :: Python :: 3.13",
|
|
24
|
+
"License :: OSI Approved :: MIT License",
|
|
25
|
+
"Operating System :: OS Independent",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.urls]
|
|
29
|
+
Homepage = "https://github.com/dperezcabrera/pico-ioc"
|
|
30
|
+
Repository = "https://github.com/dperezcabrera/pico-ioc"
|
|
31
|
+
"Issue Tracker" = "https://github.com/dperezcabrera/pico-ioc/issues"
|
|
32
|
+
|
|
33
|
+
[tool.setuptools]
|
|
34
|
+
package-dir = {"" = "src"}
|
|
35
|
+
|
|
36
|
+
[tool.setuptools.packages.find]
|
|
37
|
+
where = ["src"]
|
|
38
|
+
include = ["pico_ioc*"]
|
|
39
|
+
|
|
40
|
+
[tool.setuptools_scm]
|
|
41
|
+
version_scheme = "post-release"
|
|
42
|
+
local_scheme = "no-local-version"
|
|
43
|
+
fallback_version = "0.0.0"
|
|
44
|
+
write_to = "src/pico_ioc/_version.py"
|
|
45
|
+
write_to_template = "__version__ = '{version}'\n"
|
|
46
|
+
|
pico_ioc-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
# src/pico_ioc/__init__.py
|
|
2
|
+
|
|
3
|
+
import functools, inspect, pkgutil, importlib, logging, sys
|
|
4
|
+
from typing import Callable, Any, Iterator, AsyncIterator
|
|
5
|
+
|
|
6
|
+
try:
|
|
7
|
+
# written at build time by setuptools-scm
|
|
8
|
+
from ._version import __version__
|
|
9
|
+
except Exception: # pragma: no cover
|
|
10
|
+
__version__ = "0.0.0"
|
|
11
|
+
|
|
12
|
+
__all__ = ["__version__"]
|
|
13
|
+
|
|
14
|
+
# ==============================================================================
|
|
15
|
+
# --- 1. Container and Chameleon Proxy (Framework-Agnostic) ---
|
|
16
|
+
# ==============================================================================
|
|
17
|
+
class PicoContainer:
|
|
18
|
+
def __init__(self):
|
|
19
|
+
self._providers = {}
|
|
20
|
+
self._singletons = {}
|
|
21
|
+
|
|
22
|
+
def bind(self, key: Any, provider: Callable[[], Any]):
|
|
23
|
+
self._providers[key] = provider
|
|
24
|
+
|
|
25
|
+
def get(self, key: Any) -> Any:
|
|
26
|
+
if key in self._singletons:
|
|
27
|
+
return self._singletons[key]
|
|
28
|
+
if key in self._providers:
|
|
29
|
+
instance = self._providers[key]()
|
|
30
|
+
self._singletons[key] = instance
|
|
31
|
+
return instance
|
|
32
|
+
raise NameError(f"No provider found for key: {key}")
|
|
33
|
+
|
|
34
|
+
class LazyProxy:
|
|
35
|
+
"""
|
|
36
|
+
A full-fledged lazy proxy that delegates almost all operations
|
|
37
|
+
to the real object, which is created only on first access.
|
|
38
|
+
It is completely framework-agnostic.
|
|
39
|
+
"""
|
|
40
|
+
def __init__(self, object_creator: Callable[[], Any]):
|
|
41
|
+
object.__setattr__(self, "_object_creator", object_creator)
|
|
42
|
+
object.__setattr__(self, "__real_object", None)
|
|
43
|
+
|
|
44
|
+
def _get_real_object(self) -> Any:
|
|
45
|
+
real_obj = object.__getattribute__(self, "__real_object")
|
|
46
|
+
if real_obj is None:
|
|
47
|
+
real_obj = object.__getattribute__(self, "_object_creator")()
|
|
48
|
+
object.__setattr__(self, "__real_object", real_obj)
|
|
49
|
+
return real_obj
|
|
50
|
+
|
|
51
|
+
# --- Core Proxying and Representation ---
|
|
52
|
+
@property
|
|
53
|
+
def __class__(self):
|
|
54
|
+
return self._get_real_object().__class__
|
|
55
|
+
|
|
56
|
+
def __getattr__(self, name):
|
|
57
|
+
return getattr(self._get_real_object(), name)
|
|
58
|
+
|
|
59
|
+
def __setattr__(self, name, value):
|
|
60
|
+
setattr(self._get_real_object(), name, value)
|
|
61
|
+
|
|
62
|
+
def __delattr__(self, name):
|
|
63
|
+
delattr(self._get_real_object(), name)
|
|
64
|
+
|
|
65
|
+
def __str__(self):
|
|
66
|
+
return str(self._get_real_object())
|
|
67
|
+
|
|
68
|
+
def __repr__(self):
|
|
69
|
+
return repr(self._get_real_object())
|
|
70
|
+
|
|
71
|
+
def __dir__(self):
|
|
72
|
+
return dir(self._get_real_object())
|
|
73
|
+
|
|
74
|
+
# --- Emulation of container types ---
|
|
75
|
+
def __len__(self): return len(self._get_real_object())
|
|
76
|
+
def __getitem__(self, key): return self._get_real_object()[key]
|
|
77
|
+
def __setitem__(self, key, value): self._get_real_object()[key] = value
|
|
78
|
+
def __delitem__(self, key): del self._get_real_object()[key]
|
|
79
|
+
def __iter__(self): return iter(self._get_real_object())
|
|
80
|
+
def __reversed__(self): return reversed(self._get_real_object())
|
|
81
|
+
def __contains__(self, item): return item in self._get_real_object()
|
|
82
|
+
|
|
83
|
+
# --- Emulation of numeric types and operators ---
|
|
84
|
+
def __add__(self, other): return self._get_real_object() + other
|
|
85
|
+
def __sub__(self, other): return self._get_real_object() - other
|
|
86
|
+
def __mul__(self, other): return self._get_real_object() * other
|
|
87
|
+
def __matmul__(self, other): return self._get_real_object() @ other
|
|
88
|
+
def __truediv__(self, other): return self._get_real_object() / other
|
|
89
|
+
def __floordiv__(self, other): return self._get_real_object() // other
|
|
90
|
+
def __mod__(self, other): return self._get_real_object() % other
|
|
91
|
+
def __divmod__(self, other): return divmod(self._get_real_object(), other)
|
|
92
|
+
def __pow__(self, other, modulo=None): return pow(self._get_real_object(), other, modulo)
|
|
93
|
+
def __lshift__(self, other): return self._get_real_object() << other
|
|
94
|
+
def __rshift__(self, other): return self._get_real_object() >> other
|
|
95
|
+
def __and__(self, other): return self._get_real_object() & other
|
|
96
|
+
def __xor__(self, other): return self._get_real_object() ^ other
|
|
97
|
+
def __or__(self, other): return self._get_real_object() | other
|
|
98
|
+
|
|
99
|
+
# --- Right-hand side numeric operators ---
|
|
100
|
+
def __radd__(self, other): return other + self._get_real_object()
|
|
101
|
+
def __rsub__(self, other): return other - self._get_real_object()
|
|
102
|
+
def __rmul__(self, other): return other * self._get_real_object()
|
|
103
|
+
def __rmatmul__(self, other): return other @ self._get_real_object()
|
|
104
|
+
def __rtruediv__(self, other): return other / self._get_real_object()
|
|
105
|
+
def __rfloordiv__(self, other): return other // self._get_real_object()
|
|
106
|
+
def __rmod__(self, other): return other % self._get_real_object()
|
|
107
|
+
def __rdivmod__(self, other): return divmod(other, self._get_real_object())
|
|
108
|
+
def __rpow__(self, other): return pow(other, self._get_real_object())
|
|
109
|
+
def __rlshift__(self, other): return other << self._get_real_object()
|
|
110
|
+
def __rrshift__(self, other): return other >> self._get_real_object()
|
|
111
|
+
def __rand__(self, other): return other & self._get_real_object()
|
|
112
|
+
def __rxor__(self, other): return other ^ self._get_real_object()
|
|
113
|
+
def __ror__(self, other): return other | self._get_real_object()
|
|
114
|
+
|
|
115
|
+
# --- Unary operators ---
|
|
116
|
+
def __neg__(self): return -self._get_real_object()
|
|
117
|
+
def __pos__(self): return +self._get_real_object()
|
|
118
|
+
def __abs__(self): return abs(self._get_real_object())
|
|
119
|
+
def __invert__(self): return ~self._get_real_object()
|
|
120
|
+
|
|
121
|
+
# --- Comparison operators ---
|
|
122
|
+
def __eq__(self, other): return self._get_real_object() == other
|
|
123
|
+
def __ne__(self, other): return self._get_real_object() != other
|
|
124
|
+
def __lt__(self, other): return self._get_real_object() < other
|
|
125
|
+
def __le__(self, other): return self._get_real_object() <= other
|
|
126
|
+
def __gt__(self, other): return self._get_real_object() > other
|
|
127
|
+
def __ge__(self, other): return self._get_real_object() >= other
|
|
128
|
+
def __hash__(self): return hash(self._get_real_object())
|
|
129
|
+
|
|
130
|
+
# --- Truthiness, Callability and Context Management ---
|
|
131
|
+
def __bool__(self): return bool(self._get_real_object())
|
|
132
|
+
def __call__(self, *args, **kwargs): return self._get_real_object()(*args, **kwargs)
|
|
133
|
+
def __enter__(self): return self._get_real_object().__enter__()
|
|
134
|
+
def __exit__(self, exc_type, exc_val, exc_tb): return self._get_real_object().__exit__(exc_type, exc_val, exc_tb)
|
|
135
|
+
|
|
136
|
+
# ==============================================================================
|
|
137
|
+
# --- 2. The Scanner and `init` Facade ---
|
|
138
|
+
# ==============================================================================
|
|
139
|
+
def _scan_and_configure(package_or_name, container: PicoContainer):
|
|
140
|
+
package = importlib.import_module(package_or_name) if isinstance(package_or_name, str) else package_or_name
|
|
141
|
+
logging.info(f"🚀 Scanning in '{package.__name__}'...")
|
|
142
|
+
component_classes, factory_classes = [], []
|
|
143
|
+
for _, name, _ in pkgutil.walk_packages(package.__path__, package.__name__ + '.'):
|
|
144
|
+
try:
|
|
145
|
+
module = importlib.import_module(name)
|
|
146
|
+
for _, obj in inspect.getmembers(module, inspect.isclass):
|
|
147
|
+
if hasattr(obj, '_is_component'):
|
|
148
|
+
component_classes.append(obj)
|
|
149
|
+
elif hasattr(obj, '_is_factory_component'):
|
|
150
|
+
factory_classes.append(obj)
|
|
151
|
+
except Exception as e:
|
|
152
|
+
logging.warning(f" ⚠️ Module {name} not processed: {e}")
|
|
153
|
+
|
|
154
|
+
for factory_cls in factory_classes:
|
|
155
|
+
try:
|
|
156
|
+
sig = inspect.signature(factory_cls.__init__)
|
|
157
|
+
instance = factory_cls(container) if 'container' in sig.parameters else factory_cls()
|
|
158
|
+
for _, method in inspect.getmembers(instance, inspect.ismethod):
|
|
159
|
+
if hasattr(method, '_provides_name'):
|
|
160
|
+
container.bind(getattr(method, '_provides_name'), method)
|
|
161
|
+
except Exception as e:
|
|
162
|
+
logging.error(f" ❌ Error in factory {factory_cls.__name__}: {e}", exc_info=True)
|
|
163
|
+
|
|
164
|
+
for component_cls in component_classes:
|
|
165
|
+
key = getattr(component_cls, '_component_key', component_cls)
|
|
166
|
+
def create_component(cls=component_cls):
|
|
167
|
+
sig = inspect.signature(cls.__init__)
|
|
168
|
+
deps = {}
|
|
169
|
+
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
|
+
):
|
|
174
|
+
continue
|
|
175
|
+
dep_key = p.annotation if p.annotation is not inspect._empty else p.name
|
|
176
|
+
deps[p.name] = container.get(dep_key)
|
|
177
|
+
return cls(**deps)
|
|
178
|
+
container.bind(key, create_component)
|
|
179
|
+
|
|
180
|
+
_container = None
|
|
181
|
+
def init(root_package):
|
|
182
|
+
global _container
|
|
183
|
+
if _container:
|
|
184
|
+
return _container
|
|
185
|
+
_container = PicoContainer()
|
|
186
|
+
logging.info("🔌 Initializing 'pico-ioc'...")
|
|
187
|
+
_scan_and_configure(root_package, _container)
|
|
188
|
+
logging.info("✅ Container configured and ready.")
|
|
189
|
+
return _container
|
|
190
|
+
|
|
191
|
+
# ==============================================================================
|
|
192
|
+
# --- 3. The Decorators ---
|
|
193
|
+
# ==============================================================================
|
|
194
|
+
def factory_component(cls):
|
|
195
|
+
setattr(cls, '_is_factory_component', True)
|
|
196
|
+
return cls
|
|
197
|
+
|
|
198
|
+
def provides(name: str, lazy: bool = True):
|
|
199
|
+
def decorator(func):
|
|
200
|
+
@functools.wraps(func)
|
|
201
|
+
def wrapper(*args, **kwargs):
|
|
202
|
+
return LazyProxy(lambda: func(*args, **kwargs)) if lazy else func(*args, **kwargs)
|
|
203
|
+
setattr(wrapper, '_provides_name', name)
|
|
204
|
+
return wrapper
|
|
205
|
+
return decorator
|
|
206
|
+
|
|
207
|
+
def component(cls=None, *, name: str = None):
|
|
208
|
+
def decorator(cls):
|
|
209
|
+
setattr(cls, '_is_component', True)
|
|
210
|
+
setattr(cls, '_component_key', name if name is not None else cls)
|
|
211
|
+
return cls
|
|
212
|
+
return decorator(cls) if cls else decorator
|
|
213
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = '0.1.0'
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pico-ioc
|
|
3
|
+
Version: 0.1.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
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
Dockerfile.test
|
|
2
|
+
Makefile
|
|
3
|
+
README.MD
|
|
4
|
+
pyproject.toml
|
|
5
|
+
tox.ini
|
|
6
|
+
.github/workflows/ci.yml
|
|
7
|
+
.github/workflows/publish-to-pypi.yml
|
|
8
|
+
src/pico_ioc/__init__.py
|
|
9
|
+
src/pico_ioc/_version.py
|
|
10
|
+
src/pico_ioc.egg-info/PKG-INFO
|
|
11
|
+
src/pico_ioc.egg-info/SOURCES.txt
|
|
12
|
+
src/pico_ioc.egg-info/dependency_links.txt
|
|
13
|
+
src/pico_ioc.egg-info/top_level.txt
|
|
14
|
+
tests/test_pico_ioc.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pico_ioc
|
|
@@ -0,0 +1,178 @@
|
|
|
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)
|