pico-ioc 1.2.0__tar.gz → 1.3.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-1.3.0/.llm/ARCHITECTURE.md +387 -0
- pico_ioc-1.3.0/.llm/DECISIONS.md +174 -0
- pico_ioc-1.3.0/.llm/GUIDE.md +466 -0
- pico_ioc-1.3.0/.llm/GUIDE_CQRS.md +110 -0
- pico_ioc-1.3.0/.llm/GUIDE_CREATING_PLUGINS_AND_INTERCEPTORS.md +182 -0
- pico_ioc-1.3.0/.llm/OVERVIEW.md +143 -0
- {pico_ioc-1.2.0 → pico_ioc-1.3.0}/CHANGELOG.md +19 -0
- {pico_ioc-1.2.0/src/pico_ioc.egg-info → pico_ioc-1.3.0}/PKG-INFO +59 -16
- pico_ioc-1.3.0/README.md +190 -0
- pico_ioc-1.3.0/src/pico_ioc/__init__.py +45 -0
- pico_ioc-1.3.0/src/pico_ioc/_state.py +40 -0
- pico_ioc-1.3.0/src/pico_ioc/_version.py +1 -0
- pico_ioc-1.3.0/src/pico_ioc/api.py +240 -0
- pico_ioc-1.3.0/src/pico_ioc/builder.py +242 -0
- {pico_ioc-1.2.0 → pico_ioc-1.3.0}/src/pico_ioc/container.py +57 -29
- {pico_ioc-1.2.0 → pico_ioc-1.3.0}/src/pico_ioc/decorators.py +63 -8
- pico_ioc-1.3.0/src/pico_ioc/interceptors.py +50 -0
- {pico_ioc-1.2.0 → pico_ioc-1.3.0}/src/pico_ioc/plugins.py +17 -1
- pico_ioc-1.3.0/src/pico_ioc/policy.py +332 -0
- {pico_ioc-1.2.0 → pico_ioc-1.3.0}/src/pico_ioc/proxy.py +41 -1
- {pico_ioc-1.2.0 → pico_ioc-1.3.0}/src/pico_ioc/resolver.py +45 -40
- pico_ioc-1.3.0/src/pico_ioc/scanner.py +203 -0
- pico_ioc-1.3.0/src/pico_ioc/utils.py +25 -0
- {pico_ioc-1.2.0 → pico_ioc-1.3.0/src/pico_ioc.egg-info}/PKG-INFO +59 -16
- {pico_ioc-1.2.0 → pico_ioc-1.3.0}/src/pico_ioc.egg-info/SOURCES.txt +11 -15
- pico_ioc-1.3.0/tests/test_api.py +145 -0
- pico_ioc-1.3.0/tests/test_decorators_and_policy.py +216 -0
- pico_ioc-1.3.0/tests/test_fingerprint_public.py +27 -0
- pico_ioc-1.3.0/tests/test_interceptors_autoreg.py +214 -0
- pico_ioc-1.3.0/tests/test_scope.py +80 -0
- pico_ioc-1.2.0/.llm/ARCHITECTURE.md +0 -357
- pico_ioc-1.2.0/.llm/DECISIONS.md +0 -150
- pico_ioc-1.2.0/.llm/GUIDE.md +0 -504
- pico_ioc-1.2.0/.llm/OVERVIEW.md +0 -160
- pico_ioc-1.2.0/README.md +0 -147
- pico_ioc-1.2.0/src/pico_ioc/__init__.py +0 -32
- pico_ioc-1.2.0/src/pico_ioc/_state.py +0 -10
- pico_ioc-1.2.0/src/pico_ioc/_version.py +0 -1
- pico_ioc-1.2.0/src/pico_ioc/api.py +0 -289
- pico_ioc-1.2.0/src/pico_ioc/scanner.py +0 -230
- pico_ioc-1.2.0/src/pico_ioc/typing_utils.py +0 -29
- pico_ioc-1.2.0/tests/test_api_unit.py +0 -197
- pico_ioc-1.2.0/tests/test_container_get_all.py +0 -23
- pico_ioc-1.2.0/tests/test_container_unit.py +0 -125
- pico_ioc-1.2.0/tests/test_decorators_unit.py +0 -138
- pico_ioc-1.2.0/tests/test_pico_ioc.py +0 -280
- pico_ioc-1.2.0/tests/test_pico_ioc_additional.py +0 -192
- pico_ioc-1.2.0/tests/test_pico_ioc_discovery.py +0 -114
- pico_ioc-1.2.0/tests/test_proxy_unit.py +0 -322
- pico_ioc-1.2.0/tests/test_public_api.py +0 -220
- pico_ioc-1.2.0/tests/test_qualifiers_unit.py +0 -70
- pico_ioc-1.2.0/tests/test_resolver_unit.py +0 -121
- pico_ioc-1.2.0/tests/test_scanner_unit.py +0 -190
- pico_ioc-1.2.0/tests/test_scope_unit.py +0 -232
- pico_ioc-1.2.0/tests/test_typing_utils_unit.py +0 -102
- {pico_ioc-1.2.0 → pico_ioc-1.3.0}/.coveragerc +0 -0
- {pico_ioc-1.2.0 → pico_ioc-1.3.0}/.github/workflows/ci.yml +0 -0
- {pico_ioc-1.2.0 → pico_ioc-1.3.0}/.github/workflows/publish-to-pypi.yml +0 -0
- {pico_ioc-1.2.0 → pico_ioc-1.3.0}/LICENSE +0 -0
- {pico_ioc-1.2.0 → pico_ioc-1.3.0}/MANIFEST.in +0 -0
- {pico_ioc-1.2.0 → pico_ioc-1.3.0}/pyproject.toml +0 -0
- {pico_ioc-1.2.0 → pico_ioc-1.3.0}/setup.cfg +0 -0
- {pico_ioc-1.2.0 → pico_ioc-1.3.0}/src/pico_ioc/public_api.py +0 -0
- {pico_ioc-1.2.0 → pico_ioc-1.3.0}/src/pico_ioc.egg-info/dependency_links.txt +0 -0
- {pico_ioc-1.2.0 → pico_ioc-1.3.0}/src/pico_ioc.egg-info/top_level.txt +0 -0
- {pico_ioc-1.2.0 → pico_ioc-1.3.0}/tox.ini +0 -0
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
# pico-ioc — Architecture
|
|
2
|
+
|
|
3
|
+
> **Scope:** internal model, wiring algorithm, lifecycle, and design trade-offs.
|
|
4
|
+
> **Non-goals:** tutorials/recipes (see `GUIDE_CREATING_PLUGINS_AND_INTERCEPTORS.md`), product pitch.
|
|
5
|
+
>
|
|
6
|
+
> ⚠️ **Requires Python 3.10+** (uses `typing.Annotated` with `include_extras=True`).
|
|
7
|
+
|
|
8
|
+
-----
|
|
9
|
+
|
|
10
|
+
## 1\) Design goals & non-goals
|
|
11
|
+
|
|
12
|
+
### Goals
|
|
13
|
+
|
|
14
|
+
- **Tiny, predictable DI** for Python apps (CLIs, Flask/FastAPI, services).
|
|
15
|
+
- **Fail fast** at bootstrap; deterministic resolution.
|
|
16
|
+
- **Ergonomic**: typed constructors; minimal reflection; explicit decorators.
|
|
17
|
+
- **Framework-agnostic**: no hard deps on web frameworks.
|
|
18
|
+
- **Safe by default**: thread/async-friendly; no global mutable singletons.
|
|
19
|
+
|
|
20
|
+
### Non-goals
|
|
21
|
+
|
|
22
|
+
- Full Spring feature set (complex scopes, bean post-processors).
|
|
23
|
+
- Hot reload or runtime graph mutation beyond explicit overrides.
|
|
24
|
+
- Magical filesystem-wide auto-imports.
|
|
25
|
+
|
|
26
|
+
-----
|
|
27
|
+
|
|
28
|
+
## 2\) High-level model
|
|
29
|
+
|
|
30
|
+
- **Component** → class marked with `@component`. Instantiated by the container.
|
|
31
|
+
- **Factory component** → class marked with `@factory_component`; owns provider methods via `@provides(key=TypeOrToken)`. Providers return *externals* (e.g., `Flask`, DB clients).
|
|
32
|
+
- **Interceptor** → class or function marked with `@interceptor`. Discovered automatically to apply cross-cutting logic.
|
|
33
|
+
- **Container** → built by `pico_ioc.init(mod_or_list, ...)`; resolve with `container.get(KeyOrType)`.
|
|
34
|
+
|
|
35
|
+
### Bootstrap sequence
|
|
36
|
+
|
|
37
|
+
```mermaid
|
|
38
|
+
sequenceDiagram
|
|
39
|
+
participant App as Your package(s)
|
|
40
|
+
participant IOC as pico-ioc Container
|
|
41
|
+
App->>IOC: init(packages, ...)
|
|
42
|
+
IOC->>App: scan decorators (@component, @factory_component, @interceptor)
|
|
43
|
+
IOC->>IOC: register providers and collect interceptor declarations
|
|
44
|
+
IOC->>IOC: build and activate interceptors
|
|
45
|
+
IOC->>IOC: apply policy (e.g., @primary, @on_missing aliases)
|
|
46
|
+
IOC->>IOC: apply overrides (replace providers/constants)
|
|
47
|
+
IOC->>IOC: instantiate eager components
|
|
48
|
+
App->>IOC: get(Service)
|
|
49
|
+
IOC->>IOC: resolve dependencies (with interception)
|
|
50
|
+
IOC-->>App: instance(Service)
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
-----
|
|
54
|
+
|
|
55
|
+
## 3\) Discovery & registration
|
|
56
|
+
|
|
57
|
+
1. **Scan inputs** passed to `init(...)`: module or list of modules/packages.
|
|
58
|
+
2. **Collect**:
|
|
59
|
+
* `@component` classes → registered by a **key** (defaults to the class type).
|
|
60
|
+
* `@factory_component` classes → introspected for `@provides(key=...)` methods.
|
|
61
|
+
* `@interceptor` classes/functions → collected for activation.
|
|
62
|
+
* `@plugin` classes → if explicitly passed via `init(..., plugins=(...))`.
|
|
63
|
+
3. **Registry** (frozen after bootstrap):
|
|
64
|
+
* Map **key → provider**. Keys are typically **types**; string tokens are also supported.
|
|
65
|
+
|
|
66
|
+
**Precedence:** If multiple providers are active for the same key (e.g., one with `@primary`, another regular), a deterministic policy is applied to choose the winner. Direct overrides are applied last, having the final say.
|
|
67
|
+
|
|
68
|
+
-----
|
|
69
|
+
|
|
70
|
+
## 4\) Resolution algorithm (deterministic)
|
|
71
|
+
|
|
72
|
+
When constructing a component `C`:
|
|
73
|
+
|
|
74
|
+
1. Inspect `__init__(self, ...)`; collect **type-annotated** parameters (excluding `self`).
|
|
75
|
+
2. For each parameter `p: T`, resolve by this order:
|
|
76
|
+
1. **By name**: if a provider key matches the parameter name `p` (if `prefer_name_first=True`).
|
|
77
|
+
2. **Exact type** `T`.
|
|
78
|
+
3. **MRO walk**: first registered base class of `T`.
|
|
79
|
+
4. **By name (fallback)**: if a provider key matches the parameter name `p`.
|
|
80
|
+
3. Instantiate dependencies depth-first; cache singletons.
|
|
81
|
+
4. Construct `C` with resolved instances.
|
|
82
|
+
|
|
83
|
+
### Failure modes
|
|
84
|
+
|
|
85
|
+
* **No provider** for a required key → **bootstrap error** (fail fast) with a full dependency chain.
|
|
86
|
+
* **Ambiguous/incompatible** registrations → policy resolves to a single provider or raises an error.
|
|
87
|
+
|
|
88
|
+
### 4b) Collection resolution
|
|
89
|
+
|
|
90
|
+
If the constructor requests `list[T]` or `list[Annotated[T, Q]]`:
|
|
91
|
+
|
|
92
|
+
* Return **all** compatible providers for `T`.
|
|
93
|
+
* If `Q` (qualifier) is present, filter to matching ones.
|
|
94
|
+
* Registration order is preserved; no implicit sorting.
|
|
95
|
+
* Returns an empty list if no matches.
|
|
96
|
+
|
|
97
|
+
-----
|
|
98
|
+
|
|
99
|
+
## 5\) Lifecycles & scopes
|
|
100
|
+
|
|
101
|
+
* **Singleton per container**: a provider is instantiated at most once and cached.
|
|
102
|
+
* **Lazy proxies (optional)**: `@component(lazy=True)` or `@provides(lazy=True)` defers instantiation until first use via a `ComponentProxy`. Prefer eager to catch errors early.
|
|
103
|
+
|
|
104
|
+
**Rationale:** Most Python app composition (config, clients, web apps) fits singleton-per-container; it’s simple and fast.
|
|
105
|
+
|
|
106
|
+
-----
|
|
107
|
+
|
|
108
|
+
## 6\) Factories & providers
|
|
109
|
+
|
|
110
|
+
Use `@factory_component` for **externals** (framework apps, DB clients, engines).
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
from pico_ioc import factory_component, provides
|
|
114
|
+
from flask import Flask
|
|
115
|
+
|
|
116
|
+
@factory_component
|
|
117
|
+
class AppFactory:
|
|
118
|
+
@provides(key=Flask)
|
|
119
|
+
def provide_flask(self) -> Flask:
|
|
120
|
+
app = Flask(__name__)
|
|
121
|
+
app.config["JSON_AS_ASCII"] = False
|
|
122
|
+
return app
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Guidelines:
|
|
126
|
+
|
|
127
|
+
* Providers should be **pure constructors** (no long-running work).
|
|
128
|
+
* Prefer **typed keys** (e.g., `Flask`) over strings.
|
|
129
|
+
|
|
130
|
+
-----
|
|
131
|
+
|
|
132
|
+
## 7\) Concurrency model
|
|
133
|
+
|
|
134
|
+
* Container state is **immutable after init**.
|
|
135
|
+
* Caches & resolution are **thread/async safe** (internal isolation; no global singletons).
|
|
136
|
+
* Instances you create **must** be safe for your usage patterns; the container cannot fix non-thread-safe libraries.
|
|
137
|
+
|
|
138
|
+
-----
|
|
139
|
+
|
|
140
|
+
## 8\) Error handling & diagnostics
|
|
141
|
+
|
|
142
|
+
* **Bootstrap**:
|
|
143
|
+
* Missing providers → explicit `NameError` with full dependency chain details.
|
|
144
|
+
* Duplicate keys → resolved by policy, preferring `@primary`.
|
|
145
|
+
* **Runtime**:
|
|
146
|
+
* Exceptions from providers/constructors bubble up. The resolver path is included in the error for easier debugging.
|
|
147
|
+
|
|
148
|
+
**Tip:** Keep constructors **cheap**; push I/O to explicit start/serve methods.
|
|
149
|
+
|
|
150
|
+
-----
|
|
151
|
+
|
|
152
|
+
## 9\) Configuration
|
|
153
|
+
|
|
154
|
+
Treat config as a **component**:
|
|
155
|
+
|
|
156
|
+
```python
|
|
157
|
+
@component
|
|
158
|
+
class Config:
|
|
159
|
+
WORKERS: int = int(os.getenv("WORKERS", "4"))
|
|
160
|
+
DEBUG: bool = os.getenv("DEBUG", "0") == "1"
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Inject `Config` where needed; avoid scattered `os.getenv` calls.
|
|
164
|
+
|
|
165
|
+
-----
|
|
166
|
+
|
|
167
|
+
## 10\) Overrides & composition
|
|
168
|
+
|
|
169
|
+
### 10.1 Module-ordered overrides
|
|
170
|
+
|
|
171
|
+
The policy engine respects definition order. While not a strict "last-wins", providers marked `@primary` will take precedence over others discovered during the scan.
|
|
172
|
+
|
|
173
|
+
### 10.2 Direct overrides argument
|
|
174
|
+
|
|
175
|
+
`init()` accepts an `overrides` dictionary for ad-hoc replacement.
|
|
176
|
+
|
|
177
|
+
```python
|
|
178
|
+
c = init(app, overrides={
|
|
179
|
+
Repo: FakeRepo(), # constant instance
|
|
180
|
+
"fast_model": lambda: {"mock": True}, # provider
|
|
181
|
+
"expensive": (lambda: object(), True), # provider with lazy=True
|
|
182
|
+
})
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
**Semantics:**
|
|
186
|
+
|
|
187
|
+
* Applied **after scanning and policy** but before eager instantiation → replaced providers never run.
|
|
188
|
+
* Accepted forms:
|
|
189
|
+
* `key: instance`
|
|
190
|
+
* `key: callable`
|
|
191
|
+
* `key: (callable, lazy_bool)`
|
|
192
|
+
* With `reuse=True`, re-calling `init(..., overrides=...)` applies new overrides to the cached container.
|
|
193
|
+
|
|
194
|
+
-----
|
|
195
|
+
|
|
196
|
+
## 11\) Interceptors (AOP & Lifecycle Hooks)
|
|
197
|
+
|
|
198
|
+
Interceptors are components that apply cross-cutting logic like logging, metrics, or policy enforcement. They are discovered automatically via the `@interceptor` decorator.
|
|
199
|
+
|
|
200
|
+
`pico-ioc` supports two kinds of interceptors:
|
|
201
|
+
|
|
202
|
+
### Method Interceptors
|
|
203
|
+
|
|
204
|
+
These implement the `MethodInterceptor` protocol and wrap method calls on any component, enabling Aspect-Oriented Programming (AOP). They are ideal for tracing, timing, caching, or feature toggles.
|
|
205
|
+
|
|
206
|
+
```python
|
|
207
|
+
from pico_ioc import interceptor
|
|
208
|
+
from pico_ioc.interceptors import MethodInterceptor, Invocation
|
|
209
|
+
|
|
210
|
+
@interceptor(order=-10) # lower order runs first
|
|
211
|
+
class LoggingInterceptor(MethodInterceptor):
|
|
212
|
+
def __call__(self, inv: Invocation, proceed):
|
|
213
|
+
print(f"Calling {inv.method_name}...")
|
|
214
|
+
result = proceed()
|
|
215
|
+
print(f"Finished {inv.method_name}.")
|
|
216
|
+
return result
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
### Container Interceptors
|
|
220
|
+
|
|
221
|
+
These implement the `ContainerInterceptor` protocol and hook into the container's internal lifecycle events.
|
|
222
|
+
|
|
223
|
+
**Hook points**:
|
|
224
|
+
|
|
225
|
+
* `on_resolve(key, annotation, qualifiers)`
|
|
226
|
+
* `on_before_create(key)`
|
|
227
|
+
* `on_after_create(key, instance)` → may return a **wrapped/replaced** instance.
|
|
228
|
+
* `on_exception(key, exc)`
|
|
229
|
+
|
|
230
|
+
**Registration:** Interceptors are discovered by the scanner during `init()` or `scope()`. There is no need to pass them manually. Their activation can be controlled with the same `@conditional` decorator and gates (`profiles`, `require_env`) used for other components.
|
|
231
|
+
|
|
232
|
+
-----
|
|
233
|
+
|
|
234
|
+
## 12\) Profiles & conditional providers
|
|
235
|
+
|
|
236
|
+
Use `@conditional` to **activate providers based on profiles, environment variables, or a predicate function**.
|
|
237
|
+
|
|
238
|
+
```python
|
|
239
|
+
from pico_ioc import component, conditional
|
|
240
|
+
|
|
241
|
+
class Cache: ...
|
|
242
|
+
|
|
243
|
+
@component
|
|
244
|
+
@conditional(profiles=("prod", "staging"))
|
|
245
|
+
class RedisCache(Cache): ...
|
|
246
|
+
|
|
247
|
+
@component
|
|
248
|
+
@conditional(require_env=("REDIS_URL",))
|
|
249
|
+
class AnotherRedisCache(Cache): ...
|
|
250
|
+
|
|
251
|
+
@component
|
|
252
|
+
@conditional(predicate=lambda: os.path.exists("/tmp/use_mem"))
|
|
253
|
+
class InMemoryCache(Cache): ...
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
**Rules**:
|
|
257
|
+
|
|
258
|
+
* `profiles=("A","B")` → active if any profile passed to `init()` or `scope()` matches.
|
|
259
|
+
* `require_env=("A","B")` → all environment variables must exist and be non-empty.
|
|
260
|
+
* `predicate=callable` → must return a truthy value to activate.
|
|
261
|
+
* If no active provider satisfies a required type and something depends on it → **bootstrap error** (fail fast).
|
|
262
|
+
|
|
263
|
+
-----
|
|
264
|
+
|
|
265
|
+
## 13\) Qualifiers & collection injection
|
|
266
|
+
|
|
267
|
+
Attach qualifiers to group/select implementations using `@qualifier`.
|
|
268
|
+
|
|
269
|
+
* Request `list[T]` → injects all registered implementations of `T`.
|
|
270
|
+
* Request `list[Annotated[T, Q]]` → injects only those implementations of `T` tagged with qualifier `Q`.
|
|
271
|
+
|
|
272
|
+
This preserves registration order and returns a stable list.
|
|
273
|
+
|
|
274
|
+
-----
|
|
275
|
+
|
|
276
|
+
## 14\) Plugins
|
|
277
|
+
|
|
278
|
+
`@plugin` classes implementing the `PicoPlugin` protocol can observe the **container lifecycle**.
|
|
279
|
+
|
|
280
|
+
* `before_scan(package, binder)`
|
|
281
|
+
* `after_scan(package, binder)`
|
|
282
|
+
* `after_bind(container, binder)`
|
|
283
|
+
* `before_eager(container, binder)`
|
|
284
|
+
* `after_ready(container, binder)`
|
|
285
|
+
|
|
286
|
+
Plugins are passed **explicitly** via `init(..., plugins=(MyPlugin(),))`. Prefer **interceptors** for fine-grained wiring events; use **plugins** for coarse lifecycle integration.
|
|
287
|
+
|
|
288
|
+
-----
|
|
289
|
+
|
|
290
|
+
## 15\) Scoped subgraphs (`scope`)
|
|
291
|
+
|
|
292
|
+
Build a **bounded container** containing only dependencies reachable from selected **roots**.
|
|
293
|
+
|
|
294
|
+
```python
|
|
295
|
+
from pico_ioc import scope
|
|
296
|
+
from src.runner_service import RunnerService
|
|
297
|
+
from tests.fakes import FakeDocker
|
|
298
|
+
import src
|
|
299
|
+
|
|
300
|
+
c = scope(
|
|
301
|
+
modules=[src],
|
|
302
|
+
roots=[RunnerService],
|
|
303
|
+
overrides={"docker.DockerClient": FakeDocker()},
|
|
304
|
+
strict=True, lazy=True,
|
|
305
|
+
)
|
|
306
|
+
svc = c.get(RunnerService)
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
### Tag-based pruning
|
|
310
|
+
|
|
311
|
+
Providers may carry `tags` (via `@component(tags=...)` or `@provides(..., tags=...)`). `scope()` can filter the initial set of providers using `include_tags` and `exclude_tags` before traversing the dependency graph.
|
|
312
|
+
|
|
313
|
+
### Semantics
|
|
314
|
+
|
|
315
|
+
* **Limited reach**: only providers transitively reachable from `roots` are included in the final graph.
|
|
316
|
+
* **Deterministic precedence**: `overrides > scoped providers > base container providers` (if `strict=False`).
|
|
317
|
+
* **Strict mode**: controls whether missing dependencies raise an error (`True`) or can be resolved from the `base` container (`False`).
|
|
318
|
+
* **Lifecycle**: still **singleton-per-container**; `scope` does **not** add request/session scopes.
|
|
319
|
+
* **Context manager**: `with scope(...):` is supported.
|
|
320
|
+
|
|
321
|
+
**Use cases:** fast unit tests, integration-lite, CLI tools, microbenchmarks.
|
|
322
|
+
|
|
323
|
+
-----
|
|
324
|
+
|
|
325
|
+
## 16\) Diagnostics & diagrams
|
|
326
|
+
|
|
327
|
+
### Registry & resolution (class diagram)
|
|
328
|
+
|
|
329
|
+
```mermaid
|
|
330
|
+
classDiagram
|
|
331
|
+
class PicoContainer {
|
|
332
|
+
+get(key) instance
|
|
333
|
+
+get_all(base_type) sequence
|
|
334
|
+
+add_method_interceptor(it)
|
|
335
|
+
+add_container_interceptor(it)
|
|
336
|
+
- _providers: Map[key, ProviderMeta]
|
|
337
|
+
- _singletons: Map[key, instance]
|
|
338
|
+
}
|
|
339
|
+
class ProviderMeta {
|
|
340
|
+
+ factory: Callable
|
|
341
|
+
+ lazy: bool
|
|
342
|
+
+ tags: tuple
|
|
343
|
+
+ qualifiers: tuple
|
|
344
|
+
}
|
|
345
|
+
class MethodInterceptor {
|
|
346
|
+
+__call__(inv, proceed)
|
|
347
|
+
}
|
|
348
|
+
class ContainerInterceptor {
|
|
349
|
+
+on_resolve()
|
|
350
|
+
+on_before_create()
|
|
351
|
+
+on_after_create()
|
|
352
|
+
+on_exception()
|
|
353
|
+
}
|
|
354
|
+
PicoContainer "1" o-- "*" MethodInterceptor
|
|
355
|
+
PicoContainer "1" o-- "*" ContainerInterceptor
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
### Resolution flow (activity)
|
|
359
|
+
|
|
360
|
+
```mermaid
|
|
361
|
+
flowchart TD
|
|
362
|
+
A[get(Type T)] --> B{Cached?}
|
|
363
|
+
B -- yes --> Z[Return cached instance]
|
|
364
|
+
B -- no --> D[Resolve dependencies for T (recurse)]
|
|
365
|
+
D --> I_BEFORE[ContainerInterceptors: on_before_create]
|
|
366
|
+
I_BEFORE --> F[Instantiate T]
|
|
367
|
+
F -- exception --> I_EXC[ContainerInterceptors: on_exception]
|
|
368
|
+
F -- success --> H[Wrap with MethodInterceptors if needed]
|
|
369
|
+
H --> I_AFTER[ContainerInterceptors: on_after_create]
|
|
370
|
+
I_AFTER --> G[Cache instance]
|
|
371
|
+
G --> Z
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
-----
|
|
375
|
+
|
|
376
|
+
## 17\) Rationale & trade-offs
|
|
377
|
+
|
|
378
|
+
* **Typed keys first**: better IDE support; fewer foot-guns than strings.
|
|
379
|
+
* **Singleton-per-container**: matches typical Python app composition; simpler mental model.
|
|
380
|
+
* **Explicit decorators**: determinism and debuggability over magical auto-wiring.
|
|
381
|
+
* **Fail fast**: configuration and graph issues surface at startup, not mid-request.
|
|
382
|
+
* **Interceptors over AOP**: precise, opt-in hooks without full-blown aspect weavers.
|
|
383
|
+
|
|
384
|
+
-----
|
|
385
|
+
|
|
386
|
+
**TL;DR**
|
|
387
|
+
`pico-ioc` builds a **deterministic, typed dependency graph** from decorated components, factories, and interceptors. It resolves by **type** (with qualifiers and collections), memoizes **singletons**, supports **overrides**, **plugins**, **conditionals/profiles**, and **scoped subgraphs**—keeping wiring **predictable, testable, and framework-agnostic**.
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# DECISIONS.md — pico-ioc
|
|
2
|
+
|
|
3
|
+
This document records **technical and architectural decisions** for pico-ioc.
|
|
4
|
+
Each entry includes a rationale and implications. If a decision is later changed, mark it **[REVOKED]** and link to the replacement.
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## ✅ Current Decisions
|
|
9
|
+
|
|
10
|
+
### 1) Minimum Python version: **3.10**
|
|
11
|
+
**Decision**: Require Python **3.10+**; drop 3.8/3.9.
|
|
12
|
+
**Rationale**: `typing.Annotated` + `get_type_hints(..., include_extras=True)` simplify internals and enable qualifiers/collections.
|
|
13
|
+
**Implications**: Users on older runtimes must upgrade; CI matrix targets 3.10+.
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
### 2) Resolution order: **param name → exact type → MRO → string token**
|
|
18
|
+
**Decision**: Name-first resolution with deterministic fallback.
|
|
19
|
+
**Rationale**: Ergonomic for common configs by name; still strongly typed.
|
|
20
|
+
**Implications**: Documented behavior; potential behavior change from older pre-1.0 versions.
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
### 3) Lifecycle model: **singleton per container**
|
|
25
|
+
**Decision**: One instance per key per container.
|
|
26
|
+
**Rationale**: Matches typical Python app composition (config/clients/services); simple and fast.
|
|
27
|
+
**Implications**: No request/session scopes at IoC level; use framework facilities if needed. `lazy=True` is available but not default.
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
### 4) Fail-fast bootstrap
|
|
32
|
+
**Decision**: Instantiate eager components after `init()`; surface errors early.
|
|
33
|
+
**Rationale**: Deterministic startup; no hidden runtime surprises.
|
|
34
|
+
**Implications**: Keep constructors cheap; push heavy I/O to explicit start/serve phases.
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
### 5) Keys: **typed keys preferred; string tokens allowed but discouraged**
|
|
39
|
+
**Decision**: Prefer class/type keys (e.g., `Flask`) over string tokens.
|
|
40
|
+
**Rationale**: Better IDE support, fewer collisions, clearer intent.
|
|
41
|
+
**Implications**: String tokens remain for interop, but are a last resort.
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
### 6) Qualifiers & collection injection are **first-class**
|
|
46
|
+
**Decision**: Support `Annotated[T, Q]` and `list[Annotated[T, Q]]` for filtering.
|
|
47
|
+
**Rationale**: Enables side-by-side implementations (primary/fallback) without custom registries.
|
|
48
|
+
**Implications**: Stable registration order is preserved for lists; empty lists are valid.
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
### 7) Plugins are **explicitly registered**
|
|
53
|
+
**Decision**: No magical discovery; pass plugins to `init(..., plugins=(...))`.
|
|
54
|
+
**Rationale**: Predictability and testability.
|
|
55
|
+
**Implications**: Slightly more verbose, but boundaries stay explicit.
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
### 8) Public API helper (`export_public_symbols_decorated`)
|
|
60
|
+
**Decision**: Provide a helper to auto-export decorated symbols in a package’s `__init__.py`.
|
|
61
|
+
**Rationale**: Reduces boilerplate; favors convention over configuration.
|
|
62
|
+
**Implications**: Dynamic export is opt-in; does not auto-register providers by itself.
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
### 9) Overrides in `init(...)`
|
|
67
|
+
**Decision**: `init(..., overrides={...})` replaces bindings at bootstrap.
|
|
68
|
+
**Rationale**: Simple unit testing/mocking without extra modules.
|
|
69
|
+
**Implications**:
|
|
70
|
+
- Applied **before eager instantiation** → replaced providers never run.
|
|
71
|
+
- Accepted forms:
|
|
72
|
+
- `key: instance` (constant)
|
|
73
|
+
- `key: callable` (provider, non-lazy)
|
|
74
|
+
- `key: (callable, lazy_bool)` (provider with explicit laziness)
|
|
75
|
+
- With `reuse=True`, subsequent `init(..., overrides=...)` mutates cached bindings.
|
|
76
|
+
|
|
77
|
+
---
|
|
78
|
+
|
|
79
|
+
### 10) Scoped subgraphs with `scope(...)`
|
|
80
|
+
**Decision**: Provide `scope(...)` to build a bounded container limited to the dependency subgraph of given roots.
|
|
81
|
+
**Rationale**: Faster, deterministic unit/integration setups; great for CLIs/benchmarks.
|
|
82
|
+
**Implications**:
|
|
83
|
+
- **Not** a new lifecycle: still singleton-per-container.
|
|
84
|
+
- Supports `include_tags`/`exclude_tags` (tags from `@component(..., tags=...)` / `@provides(..., tags=...)`).
|
|
85
|
+
- `strict=True` fails if deps are outside subgraph.
|
|
86
|
+
- Works as a context manager to ensure clean teardown.
|
|
87
|
+
|
|
88
|
+
---
|
|
89
|
+
|
|
90
|
+
### 11) **Interceptors API** (AOP & Lifecycle Hooks)
|
|
91
|
+
**Decision**: Introduce two types of interceptors, auto-discovered via `@interceptor`, to observe or modify container and component behavior.
|
|
92
|
+
**Kinds & Hooks**:
|
|
93
|
+
- **`MethodInterceptor`**: Implements `__call__(self, inv: Invocation, proceed)`. Wraps method calls on components for Aspect-Oriented Programming (AOP) use cases like logging, tracing, or caching.
|
|
94
|
+
- **`ContainerInterceptor`**: Implements container lifecycle hooks:
|
|
95
|
+
- `on_resolve(key, annotation, qualifiers)`
|
|
96
|
+
- `on_before_create(key)`
|
|
97
|
+
- `on_after_create(key, instance)` (may wrap/replace the instance)
|
|
98
|
+
- `on_exception(key, exc)`
|
|
99
|
+
**Rationale**: Provides structured extension points for both cross-cutting concerns (AOP) and container lifecycle events without the complexity of full aspect weavers.
|
|
100
|
+
**Implications**:
|
|
101
|
+
- Deterministic ordering via `order` (lower runs first).
|
|
102
|
+
- Auto-registered via `@interceptor(...)` on classes or provider methods.
|
|
103
|
+
- `on_exception` must re-raise if the error is not meant to be suppressed.
|
|
104
|
+
|
|
105
|
+
---
|
|
106
|
+
|
|
107
|
+
### 12) **Conditional providers** (profiles, env, predicate)
|
|
108
|
+
**Decision**: `@conditional(profiles=(...), require_env=(...), predicate=callable)` activates providers based on environment vars or logic.
|
|
109
|
+
**Rationale**: Profile-driven wiring (`PROFILE=test/prod/ci`), optional integrations (Redis/S3) without code changes.
|
|
110
|
+
**Implications**:
|
|
111
|
+
- If no active provider satisfies a required type → **bootstrap error** (or at resolution if lazy).
|
|
112
|
+
- Multiple candidates may be active (use qualifiers/collections to select).
|
|
113
|
+
- Works seamlessly with `scope(...)`; conditionals apply before traversal.
|
|
114
|
+
|
|
115
|
+
---
|
|
116
|
+
|
|
117
|
+
### 13) Deterministic Provider Selection: **Policy-driven, preferring @primary**
|
|
118
|
+
**Decision**: When multiple providers implement the same base type, the selection is not a simple "last-wins". Instead, a policy is applied:
|
|
119
|
+
1. If one or more candidates are decorated with `@primary`, the first one found will be chosen.
|
|
120
|
+
2. If no candidates are marked as primary, the selection may fall back to providers decorated with `@on_missing`, or other aliasing logic.
|
|
121
|
+
A true "last-wins" only occurs when binding the *exact same key* multiple times, which overwrites the previous provider entry.
|
|
122
|
+
**Rationale**: This provides more explicit control over which implementation is the default, making the dependency graph more predictable than relying solely on scan order.
|
|
123
|
+
**Implications**: To select a default implementation for an interface, use `@primary`. Module ordering is a less reliable mechanism for this purpose.
|
|
124
|
+
|
|
125
|
+
---
|
|
126
|
+
|
|
127
|
+
### 14) Concurrency & safety
|
|
128
|
+
**Decision**: Container is immutable after `init()`; caches are isolated and safe across threads/tasks; no global singletons.
|
|
129
|
+
**Rationale**: Avoid shared mutable state; enable safe parallel use.
|
|
130
|
+
**Implications**: Make your **own** instances thread/async-safe if they’re shared.
|
|
131
|
+
|
|
132
|
+
---
|
|
133
|
+
|
|
134
|
+
## ❌ Won’t-Do Decisions
|
|
135
|
+
|
|
136
|
+
### A) Alternative scopes (request/session)
|
|
137
|
+
**Decision**: No extra lifecycles beyond singleton-per-container.
|
|
138
|
+
**Rationale**: Keep model simple; delegate per-request/session to frameworks.
|
|
139
|
+
**Implications**: Avoids ownership ambiguity and complexity.
|
|
140
|
+
|
|
141
|
+
---
|
|
142
|
+
|
|
143
|
+
### B) Asynchronous providers (`async def`)
|
|
144
|
+
**Decision**: Not supported inside the container.
|
|
145
|
+
**Rationale**: Simplicity and determinism; no loop coupling in core.
|
|
146
|
+
**Implications**: If async init is required, handle it inside your component explicitly.
|
|
147
|
+
|
|
148
|
+
---
|
|
149
|
+
|
|
150
|
+
### C) Hot reload / dynamic re-scan
|
|
151
|
+
**Decision**: Not supported.
|
|
152
|
+
**Rationale**: Conflicts with fail-fast and immutability; complicates debugging.
|
|
153
|
+
**Implications**: Use framework/dev tools for code reload (e.g., `uvicorn --reload`).
|
|
154
|
+
|
|
155
|
+
---
|
|
156
|
+
|
|
157
|
+
## 🗃️ Deprecated / Revoked
|
|
158
|
+
|
|
159
|
+
_No entries currently._
|
|
160
|
+
|
|
161
|
+
---
|
|
162
|
+
|
|
163
|
+
## 📜 Changelog of Decisions
|
|
164
|
+
|
|
165
|
+
- **2025-08**: Minimum Python 3.10; name-first resolution; fail-fast clarified; typed keys preferred.
|
|
166
|
+
- **2025-09-08**: Introduced `init(..., overrides)` with defined precedence and laziness semantics.
|
|
167
|
+
- **2025-09-13**: Added `scope(...)` for bounded containers with tag pruning and strict mode.
|
|
168
|
+
- **2025-09-14**: Added **Interceptors API** and **Conditional providers** as first-class features; documented last-wins registration and concurrency stance.
|
|
169
|
+
|
|
170
|
+
---
|
|
171
|
+
|
|
172
|
+
**Summary**: pico-ioc remains **simple, deterministic, and fail-fast**.
|
|
173
|
+
We favor typed wiring, explicit registration, and small, composable primitives (overrides, scope, interceptors, conditionals) instead of heavyweight AOP or multi-scope lifecycles.
|
|
174
|
+
|