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. 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.
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.