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.
@@ -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
+
@@ -0,0 +1,9 @@
1
+ ARG PYTHON_VERSION=3.11
2
+ FROM python:${PYTHON_VERSION}-slim
3
+
4
+ WORKDIR /app
5
+ COPY . .
6
+ RUN python -m pip install --upgrade pip tox
7
+
8
+ CMD bash -lc 'ENV=py$(python -c "import sys;print(f\"{sys.version_info.major}{sys.version_info.minor}\")"); tox -e $ENV'
9
+
@@ -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
+
@@ -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,202 @@
1
+ # Pico-IoC: A Minimalist IoC Container for Python
2
+
3
+ [![PyPI](https://img.shields.io/pypi/v/pico-ioc.svg)](https://pypi.org/project/pico-ioc/)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
5
+ ![CI (tox matrix)](https://github.com/dperezcabrera/pico-ioc/actions/workflows/ci.yml/badge.svg)
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
+
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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
+ 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)
pico_ioc-0.1.0/tox.ini ADDED
@@ -0,0 +1,10 @@
1
+ [tox]
2
+ env_list = py38, py39, py310, py311, py312, py313
3
+ isolated_build = true
4
+ skip_missing_interpreters = false
5
+
6
+ [testenv]
7
+ description = Run tests with pytest
8
+ deps = pytest
9
+ commands = pytest
10
+