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.
Files changed (79) hide show
  1. xtr_dependency_injection-1.0.0/LICENSE +21 -0
  2. xtr_dependency_injection-1.0.0/PKG-INFO +469 -0
  3. xtr_dependency_injection-1.0.0/README.md +448 -0
  4. xtr_dependency_injection-1.0.0/pyproject.toml +183 -0
  5. xtr_dependency_injection-1.0.0/pyproject.toml.orig +180 -0
  6. xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/__init__.py +72 -0
  7. xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/builder/__init__.py +7 -0
  8. xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/builder/autoconfigurator.py +71 -0
  9. xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/builder/conflict_policy.py +94 -0
  10. xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/builder/container_builder.py +125 -0
  11. xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/builder/definition.py +67 -0
  12. xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/builder/service_configurator.py +232 -0
  13. xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/bundle/__init__.py +9 -0
  14. xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/bundle/as_bundle.py +110 -0
  15. xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/bundle/bundle.py +93 -0
  16. xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/bundle/bundle_metadata.py +29 -0
  17. xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/compiler/__init__.py +9 -0
  18. xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/compiler/_wireup_bridge.py +114 -0
  19. xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/compiler/registration.py +347 -0
  20. xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/compiler/wireup_compiler.py +201 -0
  21. xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/config/__init__.py +9 -0
  22. xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/config/config_prepender.py +88 -0
  23. xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/config/config_provider.py +129 -0
  24. xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/config/config_resolver.py +235 -0
  25. xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/config/configure.py +61 -0
  26. xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/config/env.py +80 -0
  27. xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/config/parameters.py +99 -0
  28. xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/decorator/__init__.py +21 -0
  29. xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/decorator/_marker.py +27 -0
  30. xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/decorator/as_decorator.py +116 -0
  31. xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/decorator/compiler_pass.py +45 -0
  32. xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/decorator/exclude.py +23 -0
  33. xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/decorator/lifecycle.py +64 -0
  34. xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/decorator/when.py +65 -0
  35. xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/diagnostics/__init__.py +7 -0
  36. xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/diagnostics/report.py +217 -0
  37. xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/discovery/__init__.py +7 -0
  38. xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/discovery/bundle_discovery.py +84 -0
  39. xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/discovery/bundle_resolver.py +302 -0
  40. xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/exception/__init__.py +51 -0
  41. xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/exception/_naming.py +32 -0
  42. xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/exception/builder_frozen_error.py +18 -0
  43. xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/exception/builder_phase_error.py +24 -0
  44. xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/exception/bundle_definition_error.py +25 -0
  45. xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/exception/circular_bundle_dependency_error.py +18 -0
  46. xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/exception/config_provider_error.py +25 -0
  47. xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/exception/conflicting_config_providers_error.py +23 -0
  48. xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/exception/decorator_signature_error.py +20 -0
  49. xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/exception/dependency_injection_error.py +14 -0
  50. xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/exception/duplicate_bundle_error.py +26 -0
  51. xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/exception/duplicate_service_error.py +35 -0
  52. xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/exception/invalid_environment_error.py +20 -0
  53. xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/exception/invalid_environment_variable_error.py +28 -0
  54. xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/exception/kernel_already_booted_error.py +18 -0
  55. xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/exception/missing_bundle_error.py +24 -0
  56. xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/exception/missing_environment_variable_error.py +18 -0
  57. xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/exception/parameter_conflict_error.py +22 -0
  58. xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/exception/resource_import_error.py +18 -0
  59. xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/exception/unknown_config_type_error.py +32 -0
  60. xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/exception/unknown_locator_key_error.py +26 -0
  61. xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/exception/unknown_service_error.py +26 -0
  62. xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/kernel/__init__.py +10 -0
  63. xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/kernel/booted_kernel.py +106 -0
  64. xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/kernel/compiled_kernel.py +102 -0
  65. xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/kernel/kernel.py +563 -0
  66. xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/kernel/kernel_bundle.py +63 -0
  67. xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/kernel/kernel_interface.py +109 -0
  68. xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/py.typed +0 -0
  69. xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/runtime/__init__.py +9 -0
  70. xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/runtime/bind_callable.py +143 -0
  71. xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/runtime/service_locator.py +60 -0
  72. xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/runtime/services_resetter.py +63 -0
  73. xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/scan/__init__.py +7 -0
  74. xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/scan/default_excludes.py +21 -0
  75. xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/scan/scanned_object.py +26 -0
  76. xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/scan/scanner.py +192 -0
  77. xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/standalone.py +100 -0
  78. xtr_dependency_injection-1.0.0/src/xtr_dependency_injection/testing/__init__.py +57 -0
  79. 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