xtr-http-kernel 1.4.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_http_kernel-1.4.0/LICENSE +21 -0
- xtr_http_kernel-1.4.0/PKG-INFO +432 -0
- xtr_http_kernel-1.4.0/README.md +401 -0
- xtr_http_kernel-1.4.0/pyproject.toml +164 -0
- xtr_http_kernel-1.4.0/pyproject.toml.orig +174 -0
- xtr_http_kernel-1.4.0/src/xtr_http_kernel/__init__.py +52 -0
- xtr_http_kernel-1.4.0/src/xtr_http_kernel/_kernel_middleware.py +76 -0
- xtr_http_kernel-1.4.0/src/xtr_http_kernel/_state.py +17 -0
- xtr_http_kernel-1.4.0/src/xtr_http_kernel/bundle/__init__.py +8 -0
- xtr_http_kernel-1.4.0/src/xtr_http_kernel/bundle/http_kernel_bundle.py +211 -0
- xtr_http_kernel-1.4.0/src/xtr_http_kernel/bundle/http_kernel_config.py +62 -0
- xtr_http_kernel-1.4.0/src/xtr_http_kernel/bundle/request_lifecycle_middleware_factory.py +35 -0
- xtr_http_kernel-1.4.0/src/xtr_http_kernel/command/__init__.py +9 -0
- xtr_http_kernel-1.4.0/src/xtr_http_kernel/command/_route_contexts.py +113 -0
- xtr_http_kernel-1.4.0/src/xtr_http_kernel/command/debug_router_command.py +52 -0
- xtr_http_kernel-1.4.0/src/xtr_http_kernel/command/route_description.py +81 -0
- xtr_http_kernel-1.4.0/src/xtr_http_kernel/command/router_command.py +69 -0
- xtr_http_kernel-1.4.0/src/xtr_http_kernel/command/router_match_command.py +96 -0
- xtr_http_kernel-1.4.0/src/xtr_http_kernel/event/__init__.py +35 -0
- xtr_http_kernel-1.4.0/src/xtr_http_kernel/event/exception_event.py +65 -0
- xtr_http_kernel-1.4.0/src/xtr_http_kernel/event/finish_request_event.py +40 -0
- xtr_http_kernel-1.4.0/src/xtr_http_kernel/event/request_event.py +56 -0
- xtr_http_kernel-1.4.0/src/xtr_http_kernel/event/response_event.py +64 -0
- xtr_http_kernel-1.4.0/src/xtr_http_kernel/event/terminate_event.py +43 -0
- xtr_http_kernel-1.4.0/src/xtr_http_kernel/event_listener/__init__.py +23 -0
- xtr_http_kernel-1.4.0/src/xtr_http_kernel/event_listener/disallow_robots_indexing_listener.py +31 -0
- xtr_http_kernel-1.4.0/src/xtr_http_kernel/event_listener/error_logging_listener.py +52 -0
- xtr_http_kernel-1.4.0/src/xtr_http_kernel/event_listener/log_unit_listener.py +39 -0
- xtr_http_kernel-1.4.0/src/xtr_http_kernel/event_listener/request_id_listener.py +72 -0
- xtr_http_kernel-1.4.0/src/xtr_http_kernel/exception/__init__.py +14 -0
- xtr_http_kernel-1.4.0/src/xtr_http_kernel/exception/http_kernel_error.py +15 -0
- xtr_http_kernel-1.4.0/src/xtr_http_kernel/exception/invalid_middleware_priority_error.py +39 -0
- xtr_http_kernel-1.4.0/src/xtr_http_kernel/kernel_events.py +51 -0
- xtr_http_kernel-1.4.0/src/xtr_http_kernel/middleware_stack.py +52 -0
- xtr_http_kernel-1.4.0/src/xtr_http_kernel/middleware_tag.py +19 -0
- xtr_http_kernel-1.4.0/src/xtr_http_kernel/py.typed +0 -0
- xtr_http_kernel-1.4.0/src/xtr_http_kernel/request_lifecycle_middleware.py +182 -0
- xtr_http_kernel-1.4.0/src/xtr_http_kernel/setup.py +96 -0
- xtr_http_kernel-1.4.0/src/xtr_http_kernel/testing.py +52 -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,432 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: xtr-http-kernel
|
|
3
|
+
Version: 1.4.0
|
|
4
|
+
Summary: Requests turned into responses through events: a request lifecycle for FastAPI applications on the xtr kernel.
|
|
5
|
+
Keywords: http,kernel,request,lifecycle,events,middleware,fastapi
|
|
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: fastapi>=0.121.3
|
|
18
|
+
Requires-Dist: starlette>=0.40
|
|
19
|
+
Requires-Dist: xtr-dependency-injection[fastapi]>=1.0,<2
|
|
20
|
+
Requires-Dist: xtr-event-dispatcher>=1.0,<2
|
|
21
|
+
Requires-Dist: xtr-event-dispatcher-contracts>=1.0,<2
|
|
22
|
+
Requires-Dist: xtr-logging-contracts>=1.0,<2
|
|
23
|
+
Requires-Dist: xtr-service-contracts>=1.0,<2
|
|
24
|
+
Requires-Dist: typing-extensions>=4.4
|
|
25
|
+
Requires-Dist: xtr-console>=1.0,<2 ; extra == 'console'
|
|
26
|
+
Requires-Dist: xtr-logging>=1.0,<2 ; extra == 'logging'
|
|
27
|
+
Requires-Python: >=3.11
|
|
28
|
+
Provides-Extra: console
|
|
29
|
+
Provides-Extra: logging
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
<div align="center">
|
|
33
|
+
|
|
34
|
+
# xtr-http-kernel
|
|
35
|
+
|
|
36
|
+
**Requests turned into responses through events: a request lifecycle for FastAPI applications on the xtr kernel.**
|
|
37
|
+
|
|
38
|
+
<img alt="python 3.11+" src="https://img.shields.io/badge/python-%E2%89%A5%203.11-3776AB?logo=python&logoColor=white">
|
|
39
|
+
<img alt="typed" src="https://img.shields.io/badge/typed-ty%20%2B%20basedpyright-1f6feb">
|
|
40
|
+
<img alt="license MIT" src="https://img.shields.io/badge/license-MIT-blue">
|
|
41
|
+
|
|
42
|
+
</div>
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## Why?
|
|
47
|
+
|
|
48
|
+
A web framework routes a request to the function that answers it, and that is the part worth
|
|
49
|
+
writing by hand. Everything an application wants *around* that function — a request id on every
|
|
50
|
+
response, an uncaught exception written to the log with the request that caused it, a header
|
|
51
|
+
keeping a page out of a search index — is not the endpoint's business, and repeating it in each
|
|
52
|
+
endpoint is how it comes to be missing from one.
|
|
53
|
+
|
|
54
|
+
This library puts those between the framework and the endpoint as **events**: a request
|
|
55
|
+
announces itself, the response it produced announces itself, and whoever listens contributes.
|
|
56
|
+
Listeners are services, so they come from the container with everything else they need.
|
|
57
|
+
|
|
58
|
+
- 🔁 **The framework stays the framework.** Routes, `Depends`, request bodies, serialization and
|
|
59
|
+
the generated schema are FastAPI's, untouched.
|
|
60
|
+
- 🧩 **Listeners are services.** Registered by a bundle, injected like any other collaborator.
|
|
61
|
+
- 🪝 **A lifecycle you can join.** Contribute at the request, at the response, at an uncaught
|
|
62
|
+
exception, and once everything has been sent.
|
|
63
|
+
- 🪪 **A request id for free.** Every response carries one; every log record made while handling
|
|
64
|
+
carries the same.
|
|
65
|
+
|
|
66
|
+
## Install
|
|
67
|
+
|
|
68
|
+
```sh
|
|
69
|
+
uv add xtr-http-kernel # the lifecycle, its bundle and setup
|
|
70
|
+
uv add "xtr-http-kernel[logging]" # + the listeners that log
|
|
71
|
+
uv add "xtr-http-kernel[console]" # + the commands that report on the router
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Requires Python 3.11+. The web framework and the container layer come with the package
|
|
75
|
+
itself — this *is* the integration — so neither is an extra.
|
|
76
|
+
|
|
77
|
+
## Quick start
|
|
78
|
+
|
|
79
|
+
Write the application the way FastAPI documents it, list the bundle, and hand the app to
|
|
80
|
+
`setup` beside the kernel. The kernel scans the module, so the listener below is registered
|
|
81
|
+
without another line:
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
# app.py
|
|
85
|
+
from __future__ import annotations
|
|
86
|
+
|
|
87
|
+
from fastapi import FastAPI
|
|
88
|
+
from xtr_dependency_injection import Kernel
|
|
89
|
+
from xtr_event_dispatcher import as_event_listener
|
|
90
|
+
from xtr_http_kernel import ResponseEvent, setup
|
|
91
|
+
from xtr_http_kernel.bundle import HttpKernelBundle
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# A service the container registers, keyed by the event it is annotated with.
|
|
95
|
+
@as_event_listener()
|
|
96
|
+
def name_the_server(event: ResponseEvent) -> None:
|
|
97
|
+
event.headers["server"] = "bookshop"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
app = FastAPI()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@app.get("/books/{isbn}")
|
|
104
|
+
async def read_book(isbn: str) -> dict[str, str]:
|
|
105
|
+
return {"isbn": isbn}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
kernel = Kernel("app", bundles={HttpKernelBundle: {"all": True}})
|
|
109
|
+
setup(app, kernel)
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Serve it with any ASGI server:
|
|
113
|
+
|
|
114
|
+
```sh
|
|
115
|
+
uv run --with uvicorn uvicorn --app-dir . app:app
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
```console
|
|
119
|
+
$ curl -i localhost:8000/books/0262510871
|
|
120
|
+
HTTP/1.1 200 OK
|
|
121
|
+
server: bookshop
|
|
122
|
+
x-request-id: 5f4e8c1a3b2d4e6f9a0b1c2d3e4f5a6b
|
|
123
|
+
content-type: application/json
|
|
124
|
+
|
|
125
|
+
{"isbn":"0262510871"}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
The `x-request-id` and the `server` header are the two listeners at work: one shipped with the
|
|
129
|
+
bundle, one written above.
|
|
130
|
+
|
|
131
|
+
## What `setup` does
|
|
132
|
+
|
|
133
|
+
`setup(app, kernel)` is the one call an application makes. It changes nothing about how routes
|
|
134
|
+
are declared; it wraps two seams around them:
|
|
135
|
+
|
|
136
|
+
- **The lifespan.** Every start of the application's lifespan builds the kernel afresh, boots
|
|
137
|
+
it — a built container boots once, and a test starts the application many times — and attaches
|
|
138
|
+
the container so the injection markers resolve in routes. The application's own lifespan runs
|
|
139
|
+
inside, its state passing through untouched; on the way out the container is detached and shut
|
|
140
|
+
down.
|
|
141
|
+
- **One middleware.** Added when `setup` is called, so **call it before the first request** —
|
|
142
|
+
after the routes and the application's own middleware is fine. Each life fetches the
|
|
143
|
+
[middleware stack](#middleware-from-a-bundle) the kernel's bundles contributed and runs every
|
|
144
|
+
request through it, inside the request scope the scoped services live in.
|
|
145
|
+
|
|
146
|
+
An application that has already started refuses new middleware; the framework's own error
|
|
147
|
+
surfaces then, rather than a request running without its lifecycle.
|
|
148
|
+
|
|
149
|
+
## The lifecycle
|
|
150
|
+
|
|
151
|
+
Every request goes through the same events, and a listener joins wherever it has something to
|
|
152
|
+
contribute. Each is dispatched at most once, however the request went:
|
|
153
|
+
|
|
154
|
+
| Event | `KernelEvents` | When | What a listener may do |
|
|
155
|
+
|---|---|---|---|
|
|
156
|
+
| `RequestEvent` | `REQUEST` | it arrived, nothing has looked at it | read it, or `set_response(...)` to answer instead of the application |
|
|
157
|
+
| `ResponseEvent` | `RESPONSE` | a response is about to start | assign `status_code`, change `headers` in place |
|
|
158
|
+
| `ExceptionEvent` | `EXCEPTION` | handling raised, nothing was sent | read `exception`, or `set_response(...)` to answer with it |
|
|
159
|
+
| `FinishRequestEvent` | `FINISH_REQUEST` | handling finished — **on every path** | put away what the request set up |
|
|
160
|
+
| `TerminateEvent` | `TERMINATE` | everything was sent | work worth doing once the caller has their answer |
|
|
161
|
+
|
|
162
|
+
`RequestEvent.set_response` and `ExceptionEvent.set_response` stop the event: the listeners
|
|
163
|
+
after them do not run, because the request has been dealt with. A response is streamed, so
|
|
164
|
+
`ResponseEvent` carries no body — only the head, which has not left yet, so a listener owns
|
|
165
|
+
exactly `status_code` and `headers`. A listener wanting to answer with a body of its own does
|
|
166
|
+
so at the request or the exception, where nothing has been sent.
|
|
167
|
+
|
|
168
|
+
`ExceptionEvent` fires only while nothing has been sent. Once the response has started, a
|
|
169
|
+
failure part-way through the body cannot be turned into a response, so the exception goes back
|
|
170
|
+
out as it arrived — turning it into a response is the application's job, not the lifecycle's.
|
|
171
|
+
`FinishRequestEvent` runs on every path, which is what makes it the place to close what a
|
|
172
|
+
request opened; `TerminateEvent` carries the status that actually left, `500` when an
|
|
173
|
+
unanswered exception left before the response started.
|
|
174
|
+
|
|
175
|
+
Events are keyed by the qualified name of their class, so a listener declared on a typed
|
|
176
|
+
parameter and one registered under the matching `KernelEvents` constant are the same
|
|
177
|
+
registration. The constants exist for the places a class cannot be written — a listener whose
|
|
178
|
+
event is chosen at runtime, a subscriber mapping names to methods:
|
|
179
|
+
|
|
180
|
+
```python
|
|
181
|
+
from xtr_event_dispatcher import as_event_listener
|
|
182
|
+
|
|
183
|
+
from xtr_http_kernel import KernelEvents, ResponseEvent
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# Declared for a container to register, keyed by the annotated event…
|
|
187
|
+
@as_event_listener(priority=100)
|
|
188
|
+
def keep_it_out_of_the_index(event: ResponseEvent) -> None:
|
|
189
|
+
event.headers["x-robots-tag"] = "noindex"
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# …or registered by hand, under the same name.
|
|
193
|
+
dispatcher.add_listener(KernelEvents.RESPONSE, keep_it_out_of_the_index, priority=100)
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
## What FastAPI already does
|
|
197
|
+
|
|
198
|
+
The lifecycle deliberately stops at the endpoint's door. FastAPI **routes** the request,
|
|
199
|
+
**resolves the endpoint's arguments** — path and query parameters, request bodies, `Depends`
|
|
200
|
+
and container markers alike — and **serializes** the return value, and it does all of that
|
|
201
|
+
better than a re-implementation would. So there is no controller event, no
|
|
202
|
+
controller-arguments event and no view event: the moments those would name are the framework's,
|
|
203
|
+
and the generated OpenAPI schema stays the framework's too.
|
|
204
|
+
|
|
205
|
+
What is left for the lifecycle is everything *around* the endpoint — the id, the log line, the
|
|
206
|
+
header, the unit of work — and that is all it does.
|
|
207
|
+
|
|
208
|
+
## Scoped services
|
|
209
|
+
|
|
210
|
+
A service registered `lifetime="scoped"` is built once per request and **released after the
|
|
211
|
+
response has been sent** — FastAPI's own timing for a dependency's cleanup. A service that
|
|
212
|
+
opens a unit of work per request therefore commits or rolls back once the caller has their
|
|
213
|
+
answer, and a generator factory's cleanup runs then. Put cleanup that must survive an error in
|
|
214
|
+
a `finally`: the engine throws a scope's error into the generator, so a commit that fails
|
|
215
|
+
raises there, and — with the logging listeners active — that failure is written to the log
|
|
216
|
+
against the request that caused it, like any other uncaught exception.
|
|
217
|
+
|
|
218
|
+
## Listeners shipped
|
|
219
|
+
|
|
220
|
+
The bundle registers these; which ones depend on what is installed and active:
|
|
221
|
+
|
|
222
|
+
| Listener | Events | Active when | Does |
|
|
223
|
+
|---|---|---|---|
|
|
224
|
+
| `RequestIdListener` | `RequestEvent`, `ResponseEvent` | always | keeps a trusted incoming id or mints a `uuid4().hex`, puts it on `request.state.request_id`, binds it to the log context when logging is around, and echoes it on the response |
|
|
225
|
+
| `DisallowRobotsIndexingListener` | `ResponseEvent` | always | stamps `X-Robots-Tag: noindex` on every response, when the config turns it on |
|
|
226
|
+
| `LogUnitListener` | `RequestEvent`, `TerminateEvent` | logging bundle active | opens a [logging unit of work](../xtr-logging#units-of-work) per request and closes it once all was sent |
|
|
227
|
+
| `ErrorLoggingListener` | `ExceptionEvent` | logging bundle active | writes every uncaught exception to the request channel — `error` below a 500 status, `critical` otherwise — leaving the response to whoever answers it |
|
|
228
|
+
|
|
229
|
+
The two logging listeners join only when the logging bundle is active, and open and close the
|
|
230
|
+
unit of work outside everything else so every record made while handling carries the request's
|
|
231
|
+
id. The request id settles right after the unit opens, for the same reason.
|
|
232
|
+
|
|
233
|
+
## Use in an application
|
|
234
|
+
|
|
235
|
+
Everything adding this package to an application on
|
|
236
|
+
[xtr-dependency-injection](../xtr-dependency-injection) takes — and, read backwards, what
|
|
237
|
+
removing it undoes.
|
|
238
|
+
|
|
239
|
+
- **Install** — `uv add "xtr-http-kernel[logging,console]"`; `logging` brings the listeners
|
|
240
|
+
that write to a log, `console` the commands that report on the router. Neither is needed to
|
|
241
|
+
serve requests.
|
|
242
|
+
- **Activate** — `HttpKernelBundle: {"all": True}` in `BUNDLES` in `<app>/bundles.py`, imported
|
|
243
|
+
from `xtr_http_kernel.bundle`. Then call `setup(app, kernel)` where the application is built.
|
|
244
|
+
- **Brings along** — the [event dispatcher](../xtr-event-dispatcher) bundle always, because the
|
|
245
|
+
lifecycle dispatches through it; the [logging](../xtr-logging) and [console](../xtr-console)
|
|
246
|
+
bundles whenever those packages are *installed* — they are required peers, pulled in and made
|
|
247
|
+
active without being listed, and left out silently when the package is not installed. Listing
|
|
248
|
+
is not what activates them; installing the extra is.
|
|
249
|
+
- **Configure** — nothing is required: the zero-config path gives a `uuid4` request id under
|
|
250
|
+
`X-Request-Id`, no robots header, and the `request` log channel. A
|
|
251
|
+
`<app>/config/http_kernel.py` `@configure` function returning an `HttpKernelConfig` changes
|
|
252
|
+
that — see [Configure](#configure) and [Kernel / bundle](#kernel--bundle).
|
|
253
|
+
- **Environment** — nothing.
|
|
254
|
+
- **Ignore** — nothing.
|
|
255
|
+
- **Remove** — drop the `setup(app, kernel)` call, drop the `BUNDLES` entry, delete
|
|
256
|
+
`<app>/config/http_kernel.py` if you wrote one, then `uv remove xtr-http-kernel`.
|
|
257
|
+
- **Check** — `debug:bundles` shows `http_kernel` as `listed` and `active`, `event_dispatcher`
|
|
258
|
+
as `required`, and (with the extras) `logging` and `console` as `required`; `debug:router`
|
|
259
|
+
lists the application's routes.
|
|
260
|
+
|
|
261
|
+
## Configure
|
|
262
|
+
|
|
263
|
+
`HttpKernelConfig` is a frozen dataclass buildable with no arguments; every field has a
|
|
264
|
+
default:
|
|
265
|
+
|
|
266
|
+
| Field | Default | What it sets |
|
|
267
|
+
|---|---|---|
|
|
268
|
+
| `request_id_header` | `"X-Request-Id"` | the header the id is read from and echoed on; must be a non-empty HTTP token |
|
|
269
|
+
| `trust_request_id` | `True` | keep a well-formed incoming id; `False` mints a fresh one every request |
|
|
270
|
+
| `disallow_search_indexing` | `False` | mark every response `X-Robots-Tag: noindex` |
|
|
271
|
+
| `log_channel` | `"request"` | the channel the error listener writes to |
|
|
272
|
+
| `middleware_priority` | `0` | where the lifecycle middleware sits among the contributed factories — highest outermost |
|
|
273
|
+
| `app` | `None` | the `"package.module:app"` string the router commands load the application from |
|
|
274
|
+
|
|
275
|
+
The constructor refuses values the lifecycle would silently misread: a `request_id_header` that
|
|
276
|
+
is not an HTTP token, an empty `log_channel`, or an `app` that is not exactly one module and one
|
|
277
|
+
attribute around a single `:`, each raise `ValueError`.
|
|
278
|
+
|
|
279
|
+
The bundle declares the default `log_channel` on the logging config for you. An application
|
|
280
|
+
renaming it must declare the new channel in its own logging configuration — this bundle's
|
|
281
|
+
config resolves after logging's, so it cannot declare a name it does not yet know.
|
|
282
|
+
|
|
283
|
+
## Kernel / bundle
|
|
284
|
+
|
|
285
|
+
An application using [xtr-dependency-injection](../xtr-dependency-injection) lists
|
|
286
|
+
`HttpKernelBundle` in its `app/bundles.py`:
|
|
287
|
+
|
|
288
|
+
```python
|
|
289
|
+
# app/bundles.py
|
|
290
|
+
from xtr_http_kernel.bundle import HttpKernelBundle
|
|
291
|
+
|
|
292
|
+
BUNDLES = {HttpKernelBundle: {"all": True}}
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
```python
|
|
296
|
+
# app/config/http_kernel.py
|
|
297
|
+
from xtr_dependency_injection import configure
|
|
298
|
+
|
|
299
|
+
from xtr_http_kernel.bundle import HttpKernelConfig
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
@configure
|
|
303
|
+
def http_kernel() -> HttpKernelConfig:
|
|
304
|
+
return HttpKernelConfig(disallow_search_indexing=True, app="app.web:app")
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
The bundle registers the lifecycle middleware factory, the listeners, and — when a console
|
|
308
|
+
bundle is active — the router commands. It requires the event dispatcher bundle outright, and
|
|
309
|
+
the logging and console bundles when installed. Its zero-config path builds and boots with no
|
|
310
|
+
application configuration and touches no I/O until a request arrives.
|
|
311
|
+
|
|
312
|
+
## Middleware from a bundle
|
|
313
|
+
|
|
314
|
+
The lifecycle middleware is one entry in an ordered chain that any bundle can add to. A bundle
|
|
315
|
+
tags a service that builds a middleware — a callable taking the downstream ASGI app and
|
|
316
|
+
returning it wrapped — with `MIDDLEWARE_TAG` (`"http_kernel.middleware"`) and an integer
|
|
317
|
+
`priority`:
|
|
318
|
+
|
|
319
|
+
```python
|
|
320
|
+
from xtr_http_kernel import MIDDLEWARE_TAG
|
|
321
|
+
|
|
322
|
+
services.set(compression_middleware).add_tag(MIDDLEWARE_TAG, priority=10)
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
When the kernel is built, the http_kernel bundle orders every tagged factory into a
|
|
326
|
+
`MiddlewareStack` — highest `priority` outermost, so it sees the request first; a missing
|
|
327
|
+
`priority` counts as `0`, and ties keep registration order. `setup` fetches the stack once per
|
|
328
|
+
application life and composes it over the application, inside the request scope. A `priority`
|
|
329
|
+
that is not an integer fails the build with `InvalidMiddlewarePriorityError`, naming the
|
|
330
|
+
offending definition.
|
|
331
|
+
|
|
332
|
+
## Router commands
|
|
333
|
+
|
|
334
|
+
With a console bundle active, two commands read the application without serving it:
|
|
335
|
+
|
|
336
|
+
- **`debug:router`** lists every route in the order routing tries them, prefixes and mounts
|
|
337
|
+
applied.
|
|
338
|
+
- **`router:match PATH [--method GET]`** names the route a path reaches, reports a route that
|
|
339
|
+
matches the path but refuses the method as the near miss it is, and fails on a path no route
|
|
340
|
+
answers.
|
|
341
|
+
|
|
342
|
+
Both take the application from `--app "package.module:app"`, or from `HttpKernelConfig.app`
|
|
343
|
+
when the option is left out, and read it either side of FastAPI's move to lazily included
|
|
344
|
+
routers.
|
|
345
|
+
|
|
346
|
+
## Testing
|
|
347
|
+
|
|
348
|
+
FastAPI's `TestClient` is unusable here: it leans on a deprecated framework path, and this
|
|
349
|
+
package's test suite turns warnings into errors. Drive the application through an ASGI transport
|
|
350
|
+
instead, inside its own lifespan so `setup`'s wrapper builds and boots the kernel:
|
|
351
|
+
|
|
352
|
+
```python
|
|
353
|
+
import httpx
|
|
354
|
+
import pytest
|
|
355
|
+
|
|
356
|
+
from app import app
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
@pytest.mark.anyio
|
|
360
|
+
async def test_a_book_carries_a_request_id() -> None:
|
|
361
|
+
async with app.router.lifespan_context(app):
|
|
362
|
+
transport = httpx.ASGITransport(app, raise_app_exceptions=False)
|
|
363
|
+
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
|
364
|
+
response = await client.get("/books/0262510871")
|
|
365
|
+
|
|
366
|
+
assert response.status_code == 200
|
|
367
|
+
assert response.headers["x-request-id"]
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
To swap a service for the span of a test, park the replacements on the application with
|
|
371
|
+
`override_services` **before** entering the lifespan, so every kernel built while the block is
|
|
372
|
+
open — every boot hook, every route — sees them:
|
|
373
|
+
|
|
374
|
+
```python
|
|
375
|
+
from xtr_http_kernel.testing import override_services
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
@pytest.mark.anyio
|
|
379
|
+
async def test_it_uses_the_fake_catalogue() -> None:
|
|
380
|
+
with override_services(app, {Catalogue: FakeCatalogue()}):
|
|
381
|
+
async with app.router.lifespan_context(app):
|
|
382
|
+
... # requests here resolve the fake
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
A key is a type, or a `(type, qualifier)` pair for a qualified service.
|
|
386
|
+
|
|
387
|
+
## Errors
|
|
388
|
+
|
|
389
|
+
Everything this library raises derives from `HttpKernelError`, and carries what went wrong as
|
|
390
|
+
typed attributes rather than only a message.
|
|
391
|
+
|
|
392
|
+
| Error | Raised when |
|
|
393
|
+
|---|---|
|
|
394
|
+
| `InvalidMiddlewarePriorityError` | a `http_kernel.middleware` tag's `priority` is not an integer |
|
|
395
|
+
|
|
396
|
+
## Layout
|
|
397
|
+
|
|
398
|
+
```
|
|
399
|
+
xtr_http_kernel/
|
|
400
|
+
├── setup.py setup(app, kernel), the one call an application makes
|
|
401
|
+
├── testing.py override_services, for a served application under test
|
|
402
|
+
├── event/ the five lifecycle events, one class per file
|
|
403
|
+
├── kernel_events.py KernelEvents, the name each of them is dispatched under
|
|
404
|
+
├── event_listener/ the listeners the bundle registers
|
|
405
|
+
├── request_lifecycle_middleware.py the middleware that dispatches the events
|
|
406
|
+
├── middleware_stack.py the ordered chain a bundle contributes to
|
|
407
|
+
├── middleware_tag.py MIDDLEWARE_TAG, the tag bundles agree on
|
|
408
|
+
├── command/ debug:router and router:match
|
|
409
|
+
├── exception/ HttpKernelError, the root of everything this library raises
|
|
410
|
+
└── bundle/ HttpKernelBundle and HttpKernelConfig
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
## Development
|
|
414
|
+
|
|
415
|
+
Developed in the [python-xtr](https://github.com/xterr/python-xtr) monorepo, under
|
|
416
|
+
`packages/xtr-http-kernel`; run the commands below from there. The `python-xtr-http-kernel`
|
|
417
|
+
repository is a read-only copy, so send issues and pull requests to the monorepo.
|
|
418
|
+
|
|
419
|
+
```sh
|
|
420
|
+
uv sync --all-extras
|
|
421
|
+
uv run ruff check
|
|
422
|
+
uv run ruff format --check
|
|
423
|
+
uv run basedpyright
|
|
424
|
+
uv run ty check
|
|
425
|
+
uv run pytest
|
|
426
|
+
```
|
|
427
|
+
|
|
428
|
+
## License
|
|
429
|
+
|
|
430
|
+
MIT — see [LICENSE](LICENSE).
|
|
431
|
+
</content>
|
|
432
|
+
</invoke>
|