without-asgi¶
without adapters that turn an ASGI application's
receive/send into typed event streams and back. This package is only the
boundary: it parses raw ASGI
event dicts into typed values, encodes typed values back into the dicts a server
expects, and exposes receive as a Stream and send as a Sink. Routing,
middleware, and handlers are left to the application, which hooks processors
together in its own code. The one piece of protocol the adapter does drive is
lifespan (see make_asgi_app below), because that is boundary work, not app
policy. See the without_asgi API reference for
the full surface.
An ASGI app is async def app(scope, receive, send). The adapters let the body
of that callable read as plain without wiring:
from without_asgi import http_inbound, http_outbound, parse_http_scope
async def app(scope, receive, send):
head = parse_http_scope(scope)
handler = select(head) # your routing, your processor
outbound = handler(http_inbound(receive)) # Stream[Inbound] -> Stream[Outbound]
await http_outbound(send)(outbound) # drive ASGI send
Because receive is already pull-based, http_inbound is a plain async
generator (no queue): the request's lifecycle is the stream's lifecycle, so it
ends on the final body chunk or a disconnect. A handler that wants the whole body
folds that stream with read_body, which joins the RequestBody chunks and
raises ClientDisconnect if the client drops before the final one. scope
(method, path) is known once up front, so routing is an ordinary scope ->
Processor choice rather than a per-event stream split.
make_asgi_app(lifespan, http=..., websocket=...) builds the ASGI app: it drives
the lifespan protocol around a portable Lifespan[T] = () ->
AbstractAsyncContextManager[T], setting the value up on startup, tearing it
down on shutdown (reporting setup/teardown errors as lifespan.startup.failed /
lifespan.shutdown.failed). Each connection scope is parsed into a typed
HttpScope / WebsocketScope and passed to that protocol's router with the
value threaded in: an HttpRouter[T] = (T, HttpScope) -> Processor[Inbound,
Outbound] (and the websocket equivalent) selects the Processor that serves the
connection. make_asgi_app then owns the receive/send wiring around it: it wraps
receive into the inbound stream, runs the returned Processor, and drains its
outbound stream into send, so a router and its handler only ever see streams,
never the raw callables or the lifespan scope. It closes the inbound stream when
the handler exits (via aclosing), so a handler that abandons the request body
early has its finally run deterministically rather than leaving the generator
dangling for GC: the server-side mirror of the client folding connection release
into its response-body generator. Each protocol's router defaults to
one that refuses the connection, so an app serves a protocol only by passing its
own router to override the default; an unserved HTTP scope gets a 501 Not
Implemented and an unserved WebSocket scope is closed before accept (a 403).
The manual wiring shown above is the drill-under path for a handler that needs the
raw receive/send.
The Lifespan names no ASGI types on purpose, so the same value drives a non-ASGI
shell (a queue processor, a CLI, a test) unchanged; only the wrapper differs.
Interdependent resources compose inside the lifespan with nested async with,
which also orders teardown.
Writing a router is opinionated work (what a route matches on, how dispatch falls
back), so this package ships no router. The optional without_asgi.routing
submodule provides only the unopinionated tools you assemble one from: a
Middleware vocabulary, generic over the connection state T, the protocol's
handler, and scope (with HttpMiddleware[T] / WebsocketMiddleware[T] aliases),
so a middleware wraps a handler with the lifespan state and scope in hand
((T, handler, scope) -> handler); state leads so a cross-cutting middleware can
read the same T the handler sees, while one that does not need it ignores the
argument; stack, which composes a sequence of middleware into one
(first outermost), so a stack of middleware is itself a Middleware; wrap, which
builds a middleware from scope-aware inbound and/or outbound stream transformers
(composing them around the handler, so a logging or header middleware is a
one-liner; wrap is the scope-only end, so its product ignores T); and
buffered, which adapts a (state, scope, body) -> Response
function into the HttpRouter shape for the common request/response case (it reads
as a decorator).
The integration package's transform.router shows a small
protocol-generic Router built from these, dispatching both an HTTP and a
WebSocket route.
For a full, opinionated router you don't have to hand-roll, the sibling
without-web package provides trie matching with typed path
parameters, 405-vs-404, mounting, scoped middleware, exception handlers, and
OpenAPI. It snaps onto this boundary through nothing but the HttpRouter type
(Router.dispatch is one), so adopting it is opt-in and bring-your-own stays
first-class. The integration package's todos example is
built on it.
A Middleware wraps the whole handler, a Processor[Inbound, Outbound], so it can
transform the inbound stream, the outbound stream, or both. The body is not a
special thing to reach for; it is the RequestBody events on the inbound stream
(and ResponseBody events on the way out), so a middleware that touches the body
just transforms those events before or after the inner handler. Two shapes:
- Per-chunk, which stays streaming: wrap
inputsand re-yieldeachRequestBodywith itsbodytransformed and itsmore_bodypreserved, passingDisconnectthrough. The inner handler still receives the body incrementally. - Whole-body, which buffers:
await read_body(inputs)to join the chunks (it raisesClientDisconnecton a truncated body), do the work, thenyieldoneRequestBody(body=..., more_body=False). The inner handler sees a complete body in a single event and cannot tell it was re-synthesized; the tradeoff is that buffering forecloses streaming in the handler. The response body is symmetric: wrap the outbound stream and transform itsResponseBodyevents, the way thetransformexample's header middleware rewritesResponseStart.
The pure half (parse_http_scope, parse_inbound, encode_outbound,
encode_response, and the lifespan equivalents) is sans-IO and tested without a
socket: build a scope, a scripted receive, and a capturing send, then call
app directly. See the integration package for a worked
text-transform service that reads the request body and dynamic config from a
without-configmap Context.
Streaming a file¶
file_response(path) builds the outbound stream that serves a file: a
ResponseStart with Content-Type and Content-Length filled in, then the
ResponseBody chunks. It returns a Stream[Outbound], so it drops straight into
a handler's output (or a without-web Reply), and Response stays the pure
buffered value it is; streaming is the other, already-existing arm rather than an
iterator smuggled into that value.
It exists because the offloaded-file ASGI extensions
(http.response.pathsend, http.response.zerocopysend) only pay off when the
transport can push bytes below Python (a native sendfile), which a pure-Python
asyncio server cannot do; rather than advertise an offload it can't honor, the
reusable-but-fiddly work moves here. That work is: guess the content type, compute
the length, and chunk the bytes off the event loop into the ResponseBody events
the framework already streams to send, so a large file is never slurped into one
bytes.
file_response is a coroutine, not an async generator, and the ordering is the
point. Awaiting it runs the stat up front, so a missing file raises
FileNotFoundError before any ResponseStart is emitted, while nothing is on
the wire yet and a handler can still answer a clean 404. This is the
parse-don't-validate move, and precisely the wart http.response.pathsend cannot
avoid: its path is opened only after the status and headers have already been
sent, so a missing file there can only truncate a response that already claimed
200.
from pathlib import Path
from without_asgi import Response, file_response
async def download(state, match) -> Reply:
try:
return await file_response(Path("/srv/report.pdf"))
except FileNotFoundError:
return Response(status=404, body=b"not found\n")
Content-Type is guessed from the suffix with mimetypes.guess_file_type
(falling back to application/octet-stream) and overridable with content_type;
any headers given are prepended, for a content-disposition say. The body is
read in chunk_size pieces via asyncio.to_thread, matching the package's
pathlib.Path + thread-offload file-I/O discipline, and the file is closed when
the stream ends or is closed early (make_asgi_app closes an abandoned outbound
stream, e.g. on a client disconnect mid-download). A HEAD request needs nothing
special: the transport drops the body chunks on the wire. Range requests (206)
and conditional requests (ETag, Last-Modified) are not modeled yet; a caller
that wants them sets the headers and status itself.
Reads and writes are lockstep by default: the next chunk is read only once the
consumer has drained the current one, so disk and socket never overlap. Because
the chunks are an ordinary Stream, read-ahead is opt-in composition rather than
a built-in, spool from the core:
spool drives the file reads up to ahead chunks ahead of the socket writes
through a bounded queue on a background task, so the next read overlaps the
current chunk's send; ahead still bounds the memory held and applies
backpressure.
The codec runs both directions¶
Everything above is the app side of the boundary: parse the dicts an ASGI
server hands an app (parse_*), encode the typed values the app sends back
(encode_outbound, encode_lifespan_reply). The vocabulary is also complete in
the server direction, which is what a transport provider needs to drive an app:
encode_scope(andencode_http_scope/encode_websocket_scope) renders a typed scope into the dict an app expects, the dual ofparse_scope.encode_inbound/encode_websocket_inbound/encode_lifespan_eventbuild the dicts an app'sreceivereturns, the duals of theparse_*events.parse_outbound/parse_websocket_outbound/parse_lifespan_replyclassify the dicts an app passes tosend, the duals of theencode_*reply encoders.
So the same typed vocabulary parses and encodes in both directions, and a server
that owns the wire can work in typed values at the boundary rather than raw dicts.
The sibling without-http package is exactly that: an ASGI
server built on h11/h2/wsproto that uses these server-direction codecs to
talk ASGI to any app, make_asgi_app-built or third-party.