xtr-dependency-injection 1.0.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.
- xtr_dependency_injection-1.0.0/LICENSE +21 -0
- xtr_dependency_injection-1.0.0/PKG-INFO +469 -0
- xtr_dependency_injection-1.0.0/README.md +448 -0
- xtr_dependency_injection-1.0.0/pyproject.toml +183 -0
- xtr_dependency_injection-1.0.0/pyproject.toml.orig +180 -0
- xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/__init__.py +72 -0
- xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/builder/__init__.py +7 -0
- xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/builder/autoconfigurator.py +71 -0
- xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/builder/conflict_policy.py +94 -0
- xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/builder/container_builder.py +125 -0
- xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/builder/definition.py +67 -0
- xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/builder/service_configurator.py +232 -0
- xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/bundle/__init__.py +9 -0
- xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/bundle/as_bundle.py +110 -0
- xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/bundle/bundle.py +93 -0
- xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/bundle/bundle_metadata.py +29 -0
- xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/compiler/__init__.py +9 -0
- xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/compiler/_wireup_bridge.py +114 -0
- xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/compiler/registration.py +347 -0
- xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/compiler/wireup_compiler.py +201 -0
- xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/config/__init__.py +9 -0
- xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/config/config_prepender.py +88 -0
- xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/config/config_provider.py +129 -0
- xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/config/config_resolver.py +235 -0
- xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/config/configure.py +61 -0
- xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/config/env.py +80 -0
- xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/config/parameters.py +99 -0
- xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/decorator/__init__.py +21 -0
- xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/decorator/_marker.py +27 -0
- xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/decorator/as_decorator.py +116 -0
- xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/decorator/compiler_pass.py +45 -0
- xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/decorator/exclude.py +23 -0
- xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/decorator/lifecycle.py +64 -0
- xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/decorator/when.py +65 -0
- xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/diagnostics/__init__.py +7 -0
- xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/diagnostics/report.py +217 -0
- xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/discovery/__init__.py +7 -0
- xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/discovery/bundle_discovery.py +84 -0
- xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/discovery/bundle_resolver.py +302 -0
- xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/exception/__init__.py +51 -0
- xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/exception/_naming.py +32 -0
- xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/exception/builder_frozen_error.py +18 -0
- xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/exception/builder_phase_error.py +24 -0
- xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/exception/bundle_definition_error.py +25 -0
- xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/exception/circular_bundle_dependency_error.py +18 -0
- xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/exception/config_provider_error.py +25 -0
- xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/exception/conflicting_config_providers_error.py +23 -0
- xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/exception/decorator_signature_error.py +20 -0
- xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/exception/dependency_injection_error.py +14 -0
- xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/exception/duplicate_bundle_error.py +26 -0
- xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/exception/duplicate_service_error.py +35 -0
- xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/exception/invalid_environment_error.py +20 -0
- xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/exception/invalid_environment_variable_error.py +28 -0
- xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/exception/kernel_already_booted_error.py +18 -0
- xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/exception/missing_bundle_error.py +24 -0
- xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/exception/missing_environment_variable_error.py +18 -0
- xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/exception/parameter_conflict_error.py +22 -0
- xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/exception/resource_import_error.py +18 -0
- xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/exception/unknown_config_type_error.py +32 -0
- xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/exception/unknown_locator_key_error.py +26 -0
- xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/exception/unknown_service_error.py +26 -0
- xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/kernel/__init__.py +10 -0
- xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/kernel/booted_kernel.py +106 -0
- xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/kernel/compiled_kernel.py +102 -0
- xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/kernel/kernel.py +563 -0
- xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/kernel/kernel_bundle.py +63 -0
- xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/kernel/kernel_interface.py +109 -0
- xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/py.typed +0 -0
- xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/runtime/__init__.py +9 -0
- xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/runtime/bind_callable.py +143 -0
- xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/runtime/service_locator.py +60 -0
- xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/runtime/services_resetter.py +63 -0
- xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/scan/__init__.py +7 -0
- xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/scan/default_excludes.py +21 -0
- xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/scan/scanned_object.py +26 -0
- xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/scan/scanner.py +192 -0
- xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/standalone.py +100 -0
- xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/testing/__init__.py +57 -0
- xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/testing/pytest_plugin.py +54 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 xterr
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: xtr-dependency-injection
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: A Symfony-style bundle and kernel layer for the xtr libraries, compiled to a wireup container.
|
|
5
|
+
Keywords: dependency-injection,kernel,bundle,wireup,container
|
|
6
|
+
Author: Razvan Ceana
|
|
7
|
+
Author-email: Razvan Ceana <razvan@ceana.ro>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Requires-Dist: wireup>=2.12,<3
|
|
18
|
+
Requires-Dist: typing-extensions>=4.4
|
|
19
|
+
Requires-Python: >=3.11
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
<div align="center">
|
|
23
|
+
|
|
24
|
+
# xtr-dependency-injection
|
|
25
|
+
|
|
26
|
+
**A Symfony-style bundle and kernel layer for Python, compiled to a wireup container.**
|
|
27
|
+
|
|
28
|
+
<img alt="python 3.11+" src="https://img.shields.io/badge/python-%E2%89%A5%203.11-3776AB?logo=python&logoColor=white">
|
|
29
|
+
<img alt="core dependencies: 2" src="https://img.shields.io/badge/core%20deps-2-3FB950">
|
|
30
|
+
<img alt="typed" src="https://img.shields.io/badge/typed-ty%20%2B%20basedpyright-1f6feb">
|
|
31
|
+
<img alt="license MIT" src="https://img.shields.io/badge/license-MIT-blue">
|
|
32
|
+
|
|
33
|
+
</div>
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## Why?
|
|
38
|
+
|
|
39
|
+
Every library that wants to live in a container ends up shipping its own `injectables(...)`
|
|
40
|
+
helper, its own "import this before building the container" rule, and its own way of asking
|
|
41
|
+
whether a peer is there. The application then glues five of them together by hand.
|
|
42
|
+
|
|
43
|
+
This package does for Python what Symfony's bundles do for PHP: **each library ships one
|
|
44
|
+
bundle, installed bundles register themselves, and an application is one line.** It is not a
|
|
45
|
+
container — [wireup](https://github.com/maldoinc/wireup) is. It decides *what* goes into
|
|
46
|
+
wireup, in *which order*, *for which environment*, and runs the lifecycle around it.
|
|
47
|
+
|
|
48
|
+
- 🔌 **Installed means registered** — bundles are found through entry points; the app lists nothing.
|
|
49
|
+
- 🐍 **Python-first configuration** — typed config objects and `@configure` functions, no files.
|
|
50
|
+
- 🌗 **Per environment** — `@when("prod")` on anything scanned; an excluded object never exists.
|
|
51
|
+
- 🧱 **Per kernel** — two kernels in one process share no container, no registry, no import side effect.
|
|
52
|
+
- 🔍 **Explainable** — every bundle, config step and override is recorded, and printable.
|
|
53
|
+
- 🪶 **Two dependencies** — `wireup` and `typing-extensions`.
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
kernel = Kernel("app")
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Install
|
|
60
|
+
|
|
61
|
+
```sh
|
|
62
|
+
uv add xtr-dependency-injection
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Libraries depend on it through an extra, so using them without a container costs nothing:
|
|
66
|
+
|
|
67
|
+
```toml
|
|
68
|
+
[project.optional-dependencies]
|
|
69
|
+
di = ["xtr-dependency-injection>=1.0,<2"]
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## The golden path
|
|
73
|
+
|
|
74
|
+
The target shape of an application once the xtr libraries ship their bundles — clock, logging,
|
|
75
|
+
console and messenger are discovered because they are installed:
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
# app/kernel.py
|
|
79
|
+
from xtr_dependency_injection import Kernel
|
|
80
|
+
|
|
81
|
+
kernel = Kernel("app") # does no work; env from APP_ENV (default "dev"), debug from APP_DEBUG
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
# app/__main__.py
|
|
86
|
+
# async def console(application: Injected[Application]) -> int
|
|
87
|
+
from xtr_console.bundle import console
|
|
88
|
+
|
|
89
|
+
from app.kernel import kernel
|
|
90
|
+
|
|
91
|
+
raise SystemExit(kernel.run(console))
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
# app/config/messenger.py
|
|
96
|
+
from dataclasses import replace
|
|
97
|
+
|
|
98
|
+
from xtr_dependency_injection import configure, env
|
|
99
|
+
from xtr_messenger import MessageBusConfig, TransportConfig
|
|
100
|
+
|
|
101
|
+
from app.billing.messages import IssueInvoice
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@configure
|
|
105
|
+
def messenger(config: MessageBusConfig) -> MessageBusConfig:
|
|
106
|
+
return replace(
|
|
107
|
+
config,
|
|
108
|
+
transports={"async": TransportConfig(env("MESSENGER_DSN"))},
|
|
109
|
+
routing={IssueInvoice: "async"},
|
|
110
|
+
)
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
# app/billing/services.py — plain wireup
|
|
115
|
+
from wireup import injectable
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@injectable
|
|
119
|
+
class InvoiceRepository:
|
|
120
|
+
def __init__(self, session: Session) -> None: ...
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
# app/boot.py
|
|
125
|
+
from wireup import Injected
|
|
126
|
+
|
|
127
|
+
from xtr_dependency_injection import on_boot
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@on_boot
|
|
131
|
+
async def warm_cache(cache: Injected[Cache]) -> None: ...
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
That is the whole application. Other entry points use the same kernel:
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
# FastAPI: the container must exist before the app is wired; boot hooks run in the lifespan
|
|
138
|
+
compiled = kernel.build()
|
|
139
|
+
app = FastAPI(lifespan=compiled.lifespan)
|
|
140
|
+
wireup.integration.fastapi.setup(compiled.container, app)
|
|
141
|
+
|
|
142
|
+
# a script or a worker
|
|
143
|
+
async with await kernel.boot() as booted:
|
|
144
|
+
invoices = await booted.container.get(InvoiceRepository)
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
`build()` compiles; `boot()` also runs every bundle's `boot` and the application's `@on_boot`
|
|
148
|
+
hooks; `run(main)` boots, calls `main` with its `Injected[...]` parameters filled, shuts down,
|
|
149
|
+
and returns `main`'s exit code. A `Kernel` is a recipe: every `build()` is independent.
|
|
150
|
+
|
|
151
|
+
| Argument | Default | Meaning |
|
|
152
|
+
|---|---|---|
|
|
153
|
+
| `package` | — | The application package, scanned recursively |
|
|
154
|
+
| `env` / `debug` | `APP_ENV` or `"dev"` / `APP_DEBUG` or "not prod" | The environment built for |
|
|
155
|
+
| `name` | last component of `package` | The application's name |
|
|
156
|
+
| `bundles` | every discovered bundle | Exactly these (plus what they require) |
|
|
157
|
+
| `exclude_bundles` | `()` | Names to leave out |
|
|
158
|
+
| `bundle_envs` | `None` | Per-bundle environments, overriding the bundle's own |
|
|
159
|
+
| `resources` | `(package,)` | What to scan instead of `package` |
|
|
160
|
+
| `exclude` | `DEFAULT_EXCLUDES` | `fnmatch` patterns of modules never imported |
|
|
161
|
+
| `allowed_envs` | `None` | Refuse any other environment |
|
|
162
|
+
|
|
163
|
+
## Bundles for library authors
|
|
164
|
+
|
|
165
|
+
A bundle is the integration, never the library: the library keeps working without a container.
|
|
166
|
+
|
|
167
|
+
```python
|
|
168
|
+
# acme_mail/bundle/mail_bundle.py
|
|
169
|
+
from dataclasses import dataclass
|
|
170
|
+
|
|
171
|
+
from xtr_dependency_injection import Bundle, ServiceConfigurator, as_bundle
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@dataclass(frozen=True)
|
|
175
|
+
class MailConfig:
|
|
176
|
+
host: str = "localhost"
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@as_bundle("mail", config=MailConfig, optional=("logging",))
|
|
180
|
+
class MailBundle(Bundle[MailConfig]):
|
|
181
|
+
def load(self, config: MailConfig, services: ServiceConfigurator) -> None:
|
|
182
|
+
services.factory(mailer) # def mailer(config: MailConfig) -> Mailer
|
|
183
|
+
services.resettable(Mailer)
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
```toml
|
|
187
|
+
[project.entry-points."xtr_dependency_injection.bundles"]
|
|
188
|
+
mail = "acme_mail.bundle:MailBundle"
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
`@as_bundle(name, *, config, requires, optional, envs, resources)`:
|
|
192
|
+
|
|
193
|
+
- `requires` must be active too; `optional` only orders this bundle after a peer that *is*
|
|
194
|
+
active. Both name bundles, so naming an optional peer never imports it.
|
|
195
|
+
- `envs` keeps a bundle to some environments; `resources` are scanned like the application.
|
|
196
|
+
- A bundle whose only job is to contribute commands or handlers is an empty class with
|
|
197
|
+
`resources=("acme_tools.commands",)`.
|
|
198
|
+
|
|
199
|
+
The hooks, all optional, run in dependency order: `prepend(configs)` adjusts other bundles'
|
|
200
|
+
configs, `load(config, services)` defines services, `process(builder)` sees and adjusts every
|
|
201
|
+
definition, and `boot(container)` / `shutdown(container)` run around the container's life.
|
|
202
|
+
**Fail at boot, not on first use**: bind handlers and check signatures in `boot` when that is
|
|
203
|
+
cheap.
|
|
204
|
+
|
|
205
|
+
The kernel registers every active bundle's resolved config under its type, so any service can
|
|
206
|
+
inject `MailConfig`. A bundle never registers its own config.
|
|
207
|
+
|
|
208
|
+
### The zero-config contract
|
|
209
|
+
|
|
210
|
+
A package can arrive transitively, and installed means registered. So with its default config
|
|
211
|
+
a bundle must build and boot, do no I/O until a service is requested, and never require the
|
|
212
|
+
application to configure it. Every bundle's test suite checks it:
|
|
213
|
+
|
|
214
|
+
```python
|
|
215
|
+
from xtr_dependency_injection.testing import assert_zero_config
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
async def test_mail_bundle_works_unconfigured() -> None:
|
|
219
|
+
await assert_zero_config(MailBundle)
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
## Configuration
|
|
223
|
+
|
|
224
|
+
Configuration is Python. A config type is a frozen dataclass or msgspec Struct buildable with no
|
|
225
|
+
arguments; its `__post_init__` validates it. The application provides or transforms it with
|
|
226
|
+
`@configure`, told apart by the signature:
|
|
227
|
+
|
|
228
|
+
```python
|
|
229
|
+
@configure # base: replaces the bundle's default
|
|
230
|
+
def mail() -> MailConfig:
|
|
231
|
+
return MailConfig(host="smtp.internal")
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
@configure # transform: receives the current value
|
|
235
|
+
@when("prod")
|
|
236
|
+
def mail_prod(config: MailConfig) -> MailConfig:
|
|
237
|
+
return replace(config, host=env("SMTP_HOST"))
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
Each bundle's config resolves in one deterministic order, and every step is recorded:
|
|
241
|
+
|
|
242
|
+
1. the default, `MailConfig()`;
|
|
243
|
+
2. the application's base provider — one under `@when`/`@when_not` wins over an unconditional one;
|
|
244
|
+
3. other bundles' prepends, in bundle order (so they add to what the application chose);
|
|
245
|
+
4. the application's transforms — unconditional, then conditional; by `priority`, then scan order.
|
|
246
|
+
|
|
247
|
+
A bundle adjusts a peer's config in `prepend`, by name when the peer is optional:
|
|
248
|
+
|
|
249
|
+
```python
|
|
250
|
+
def prepend(self, configs: ConfigPrepender) -> None:
|
|
251
|
+
if configs.has_bundle("logging"):
|
|
252
|
+
configs.transform("logging", add_channel("mail"))
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
**Parameters** are values injected with `Inject(config="...")`. The kernel provides
|
|
256
|
+
`kernel.name`, `kernel.environment`, `kernel.debug` and `kernel.project_dir`; bundles add theirs
|
|
257
|
+
with `services.parameters(...)`, the application with `@parameters`. They merge into nested
|
|
258
|
+
mappings and never override: a leaf set twice is an error naming both sources.
|
|
259
|
+
|
|
260
|
+
`env(name, cast=str, *, default=...)` reads the environment while the kernel builds. `bool`
|
|
261
|
+
reads `1/true/yes/on` and `0/false/no/off`.
|
|
262
|
+
|
|
263
|
+
## Scanning and autoconfiguration
|
|
264
|
+
|
|
265
|
+
The kernel imports the application package — and every active bundle's `resources` — sorted by
|
|
266
|
+
name, skipping `*.tests`, `*.test_*`, `*.conftest` and `*.__main__`. It considers only the
|
|
267
|
+
functions and classes a module *defines*. Each goes to exactly one place:
|
|
268
|
+
|
|
269
|
+
| Found | Becomes |
|
|
270
|
+
|---|---|
|
|
271
|
+
| `@exclude` | nothing |
|
|
272
|
+
| `@when` / `@when_not` not matching | nothing, reported as skipped |
|
|
273
|
+
| `@configure`, `@parameters`, `@compiler_pass`, `@on_boot`, `@on_shutdown`, `@as_decorator` | that queue |
|
|
274
|
+
| wireup's `@injectable` | a definition, and a service candidate |
|
|
275
|
+
| anything else | a service candidate |
|
|
276
|
+
|
|
277
|
+
Service candidates are what bundles' **autoconfigurators** see. A bundle registers a reader and
|
|
278
|
+
an apply function; the console bundle, for instance, reads `@as_command` metadata and registers
|
|
279
|
+
each command — nothing else needs to know what a command is:
|
|
280
|
+
|
|
281
|
+
```python
|
|
282
|
+
services.autoconfigure(commands_declared_on, register_command)
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
A bundle can scan a module only when a peer is active: `services.scan("pkg.commands")` in
|
|
286
|
+
`load` runs after every bundle has loaded. Application services still use wireup's own
|
|
287
|
+
`@injectable`: DTOs, enums and exceptions are not services, and wireup validates everything it
|
|
288
|
+
is given.
|
|
289
|
+
|
|
290
|
+
## Overrides and decoration
|
|
291
|
+
|
|
292
|
+
The application overrides a bundle's service by defining the same key — silently, as in
|
|
293
|
+
Symfony, and recorded in the report. Two bundles defining one key is an error unless one
|
|
294
|
+
calls `builder.replace(...)` in `process`.
|
|
295
|
+
|
|
296
|
+
A decorator takes a service's place and receives the original:
|
|
297
|
+
|
|
298
|
+
```python
|
|
299
|
+
@as_decorator(Mailer)
|
|
300
|
+
class LoggingMailer(Mailer):
|
|
301
|
+
def __init__(self, inner: Inner[Mailer], logger: LoggerInterface) -> None: ...
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
Decorations of one service apply by `priority`, highest first: the highest wraps the original.
|
|
305
|
+
The decorator gets the decorated service's lifetime, and the original stays out of every
|
|
306
|
+
`Sequence[Mailer]`. `builder.decorate(Mailer, LoggingMailer)` does the same from a bundle.
|
|
307
|
+
|
|
308
|
+
A `@compiler_pass` receives the `ContainerBuilder` after every bundle's `process`, to inspect
|
|
309
|
+
or change any definition before compilation.
|
|
310
|
+
|
|
311
|
+
## Lifecycle
|
|
312
|
+
|
|
313
|
+
```
|
|
314
|
+
build() environment → bundles → scan → configs → load → late scan
|
|
315
|
+
→ declared definitions → autoconfigure → process → finalize → compile
|
|
316
|
+
boot() bundle.boot() in order → @on_boot (priority, then scan order)
|
|
317
|
+
shutdown() @on_shutdown → bundle.shutdown() in reverse → container.close()
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
Hooks are injected: `Injected[...]` parameters are filled, sync or async. A boot that fails shuts
|
|
321
|
+
down what already booted and closes the container. Shutdown runs every step even if one fails,
|
|
322
|
+
and raises the failures together as an `ExceptionGroup`. A generator factory's cleanup runs as
|
|
323
|
+
the container closes; put cleanup that must survive an error in `finally`, because wireup throws
|
|
324
|
+
a scope's error into the generator.
|
|
325
|
+
|
|
326
|
+
## Runtime helpers
|
|
327
|
+
|
|
328
|
+
- `bind_callable(container, target, *, per_call_scope=False)` returns a coroutine function
|
|
329
|
+
calling a handler, a command or any callable with its `Injected[...]` parameters filled. A
|
|
330
|
+
class target is resolved from the container on first call. What it needs is checked at bind
|
|
331
|
+
time, so a bundle binding in `boot` fails at boot.
|
|
332
|
+
- `ServiceLocator(container, {name: key})` builds a service only when asked for by name.
|
|
333
|
+
- `ServicesResetter` resets every *built* resettable service; a worker calls
|
|
334
|
+
`await resetter.reset()` between messages.
|
|
335
|
+
|
|
336
|
+
## Testing
|
|
337
|
+
|
|
338
|
+
```python
|
|
339
|
+
from xtr_dependency_injection.testing import boot_for_test
|
|
340
|
+
|
|
341
|
+
async with await boot_for_test(kernel, overrides={Mailer: FakeMailer()}) as booted:
|
|
342
|
+
...
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
`boot_for_test` builds for the `test` environment and applies overrides before any boot hook
|
|
346
|
+
runs. The opt-in pytest plugin provides `booted_kernel` and `container` fixtures; override
|
|
347
|
+
`xtr_kernel` to return your kernel:
|
|
348
|
+
|
|
349
|
+
```python
|
|
350
|
+
pytest_plugins = ["xtr_dependency_injection.testing.pytest_plugin"]
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
@pytest.fixture
|
|
354
|
+
def xtr_kernel() -> Kernel:
|
|
355
|
+
return kernel
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
## Diagnostics
|
|
359
|
+
|
|
360
|
+
`compiled.report` — also `KernelInterface.report` inside the container — records what the
|
|
361
|
+
build decided. `report.render()` prints it as plain-text tables:
|
|
362
|
+
|
|
363
|
+
```
|
|
364
|
+
Bundles
|
|
365
|
+
=======
|
|
366
|
+
Name Source State Requires Optional Class Reason
|
|
367
|
+
------ ---------- ------ -------- -------- ---------------------------------------------------------- ------
|
|
368
|
+
kernel explicit active - - xtr_dependency_injection.kernel.kernel_bundle:KernelBundle
|
|
369
|
+
mail discovered active - - acme_mail:MailBundle
|
|
370
|
+
|
|
371
|
+
Configs
|
|
372
|
+
=======
|
|
373
|
+
Bundle Steps Value
|
|
374
|
+
------ ------------------------------------------------------------------- ------------------------------------
|
|
375
|
+
kernel default NoConfig()
|
|
376
|
+
mail default -> base shop.config:mail -> transform shop.config:mail_prod MailConfig(host='smtp.internal:465')
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
Render one section with `render("bundles" | "configs" | "definitions" | "scan")`. The console
|
|
380
|
+
bundle exposes them as `debug:bundles`, `debug:config` and `debug:container`.
|
|
381
|
+
|
|
382
|
+
## Standalone wireup
|
|
383
|
+
|
|
384
|
+
Without a kernel, `injectables()` runs the same pipeline — no discovery, no application scan —
|
|
385
|
+
and returns what to give wireup:
|
|
386
|
+
|
|
387
|
+
```python
|
|
388
|
+
container = wireup.create_async_container(
|
|
389
|
+
injectables=[
|
|
390
|
+
app.services,
|
|
391
|
+
*injectables(
|
|
392
|
+
[MailBundle()], configs=[MailConfig(host="smtp.internal")], scan=["app.handlers"]
|
|
393
|
+
),
|
|
394
|
+
],
|
|
395
|
+
)
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
Required bundles must be listed. Boot hooks do not run, and parameters need the kernel, because
|
|
399
|
+
wireup's `config=` stays yours.
|
|
400
|
+
|
|
401
|
+
## Symfony mapping
|
|
402
|
+
|
|
403
|
+
| Symfony | xtr-dependency-injection |
|
|
404
|
+
|---|---|
|
|
405
|
+
| `AbstractBundle` | `Bundle[ConfigT]` + `@as_bundle(...)`: `prepend()` / `load()` |
|
|
406
|
+
| Bundle config tree | A typed config class with defaults; `__post_init__` validates |
|
|
407
|
+
| `config/packages/*.yaml` | `@configure` functions |
|
|
408
|
+
| `when@prod:` / `#[When]` / `#[WhenNot]` | `@when("prod")` / `@when_not("prod")` |
|
|
409
|
+
| `parameters:` / `%env(X)%` | `@parameters` / `env("X", int, default=...)` |
|
|
410
|
+
| Auto-registered component bundles | Entry-point discovery |
|
|
411
|
+
| `#[RequiredBundle(ignoreOnInvalid:)]` | `@as_bundle(requires=..., optional=...)` |
|
|
412
|
+
| `bundles.php` per env | `@as_bundle(envs=...)` + `Kernel(bundle_envs=...)` |
|
|
413
|
+
| `resource: '../src/'` + autoconfigure | The kernel scan + `services.autoconfigure(reader, apply)` |
|
|
414
|
+
| Compiler passes | `Bundle.process(builder)` + `@compiler_pass` |
|
|
415
|
+
| `decorates:` / `#[AsDecorator]` | `@as_decorator(T)` + `Inner[T]` |
|
|
416
|
+
| Tagged iterator / locator | `Sequence[T]` / `Mapping[Hashable, T]` / `ServiceLocator` |
|
|
417
|
+
| `kernel.reset` | `services.resettable(T)` + `ServicesResetter` |
|
|
418
|
+
| `Bundle::boot()` / `shutdown()` | `Bundle.boot(container)` / `shutdown(container)`; `@on_boot` / `@on_shutdown` |
|
|
419
|
+
| `debug:container` / `debug:config` | `CompiledKernel.report`, `debug:*` commands |
|
|
420
|
+
|
|
421
|
+
## Errors
|
|
422
|
+
|
|
423
|
+
Every error derives from `DependencyInjectionError` and carries its data as typed attributes:
|
|
424
|
+
|
|
425
|
+
| Error | Raised when |
|
|
426
|
+
|---|---|
|
|
427
|
+
| `BundleDefinitionError` | a bad `@as_bundle`, a reserved name, a bundle or config not buildable with no arguments, two bundles sharing a config type |
|
|
428
|
+
| `DuplicateBundleError` | two bundles or entry points share a name |
|
|
429
|
+
| `MissingBundleError` | a required bundle is absent, skipped, excluded or disabled |
|
|
430
|
+
| `CircularBundleDependencyError` | bundle dependencies loop |
|
|
431
|
+
| `InvalidEnvironmentError` | the environment is not in `allowed_envs` |
|
|
432
|
+
| `ResourceImportError` | a scanned module failed to import |
|
|
433
|
+
| `ConfigProviderError` | a bad `@configure`/`@parameters`, one found too late, `NoConfig` targeted, a bad parameter key, two queue markers on one object |
|
|
434
|
+
| `UnknownConfigTypeError` | no active bundle owns a configured type |
|
|
435
|
+
| `ConflictingConfigProvidersError` | two base providers in one group |
|
|
436
|
+
| `ParameterConflictError` | a parameter set twice |
|
|
437
|
+
| `MissingEnvironmentVariableError` / `InvalidEnvironmentVariableError` | `env()` |
|
|
438
|
+
| `DuplicateServiceError` | two bundles, or two application definitions, claim one key |
|
|
439
|
+
| `UnknownServiceError` | `replace`/`remove`/`decorate`/`resettable` target nothing |
|
|
440
|
+
| `DecoratorSignatureError` | a decorator without exactly one `Inner[T]` of the decorated type |
|
|
441
|
+
| `BuilderPhaseError` / `BuilderFrozenError` | a builder operation in the wrong phase / after compilation |
|
|
442
|
+
| `KernelAlreadyBootedError` | a compiled kernel booted twice |
|
|
443
|
+
| `UnknownLocatorKeyError` | `ServiceLocator.get` with an unknown name |
|
|
444
|
+
|
|
445
|
+
## Known limitations
|
|
446
|
+
|
|
447
|
+
- **Global registries remain.** The libraries' decorators still fill their process-wide
|
|
448
|
+
default registries, so a command name is registered at import: `@when("dev")` and
|
|
449
|
+
`@when("prod")` commands of one name clash. Use distinct names.
|
|
450
|
+
- **Some library state is process-wide** across kernels: `Clock.set`, and messenger's
|
|
451
|
+
message-name registry.
|
|
452
|
+
- **asyncio only** for `kernel.run`.
|
|
453
|
+
- **Annotations must be importable at runtime** wherever wireup or the kernel reads them — not
|
|
454
|
+
under `TYPE_CHECKING`.
|
|
455
|
+
|
|
456
|
+
## Development
|
|
457
|
+
|
|
458
|
+
Developed in the [python-xtr](https://github.com/xterr/python-xtr) monorepo, under
|
|
459
|
+
`packages/xtr-dependency-injection`; run the commands below from there. The `python-xtr-dependency-injection` repository is a
|
|
460
|
+
read-only copy, so send issues and pull requests to the monorepo.
|
|
461
|
+
|
|
462
|
+
```sh
|
|
463
|
+
uv sync
|
|
464
|
+
uv run ruff check && uv run ruff format --check && uv run basedpyright && uv run ty check && uv run pytest
|
|
465
|
+
```
|
|
466
|
+
|
|
467
|
+
## License
|
|
468
|
+
|
|
469
|
+
MIT
|