without_asgi¶
without adapters that turn an ASGI app's receive/send into typed event streams and back.
without_asgi
¶
IMMUTABLE_CACHE_CONTROL
module-attribute
¶
NOT_FOUND
module-attribute
¶
NOT_FOUND = Response(
status=404,
headers=(
(_CONTENT_TYPE, b"text/plain; charset=utf-8"),
(_CONTENT_LENGTH, b"%d" % len(_NOT_FOUND_BODY)),
),
body=_NOT_FOUND_BODY,
)
STATIC_ASSET_HEADERS
module-attribute
¶
STATIC_ASSET_HEADERS: RawHeaders = (
(_CACHE_CONTROL, REVALIDATE_CACHE_CONTROL),
(b"x-content-type-options", b"nosniff"),
)
EVENT_STREAM_HEADERS
module-attribute
¶
EVENT_STREAM_HEADERS: RawHeaders = (
(b"content-type", EVENT_STREAM_MEDIA_TYPE),
(b"cache-control", b"no-store"),
)
LifespanReply
¶
LifespanReply = (
StartupComplete
| ShutdownComplete
| StartupFailed
| ShutdownFailed
)
Outbound
¶
Outbound = (
ResponseStart
| ResponseBody
| ServerPush
| ZeroCopySend
| PathSend
| EarlyHint
| ResponseTrailers
| ResponseDebug
)
WebsocketOutbound
¶
WebsocketOutbound = (
WebsocketAccept
| WebsocketSend
| WebsocketClose
| WebsocketResponseStart
| WebsocketResponseBody
)
Asset
dataclass
¶
Asset(
path: Path,
last_modified: datetime,
identity: Representation,
encodings: Mapping[
bytes, Representation
] = _NO_ENCODINGS,
codings: tuple[bytes, ...] = (),
needs_trailing_slash: bool = False,
)
One file in an Inventory, with every response header already computed.
encodings
class-attribute
instance-attribute
¶
encodings: Mapping[bytes, Representation] = _NO_ENCODINGS
codings
class-attribute
instance-attribute
¶
encodings' keys in the server's preference order, which is what
negotiate_coding takes. Held rather than derived per request: it is a constant of
the asset, and rebuilding it on every request for every compressible asset is an
allocation on the hot path buying nothing.
AssetChanged
¶
Bases: Exception
An asset's bytes changed after the inventory was built.
The inventory's contract is that nothing writes into the tree while the app runs.
This is raised when that is observably false, before any ResponseStart, rather
than framing a body whose length and validator describe different bytes.
Inventory
dataclass
¶
Representation
dataclass
¶
Representation(
size: int,
etag: bytes,
described: RawHeaders,
revalidation: RawHeaders,
body: bytes | None = None,
)
One selectable form of an asset: the identity bytes on disk, or a content coding.
Each carries its own strong etag. Sharing one tag across codings is a real
bug rather than an untidiness: a client holding the gzip copy would send
If-None-Match, receive a 304, and go on using bytes that are a different
representation entirely.
described
instance-attribute
¶
described: RawHeaders
What a 200 or 206 says about this representation.
revalidation
instance-attribute
¶
revalidation: RawHeaders
What a 304 repeats, as describing assembles it: RFC 9110 §15.4.5's required
fields, plus the content-type and content-encoding naming which stored variant
is being revalidated. The coding settles that for an already-encoded variant; the
type settles it for a representation with no variants at all, a PNG or a font or a
video, which carries no coding to read.
RequestBody
dataclass
¶
WebsocketConnect
dataclass
¶
The client is opening a websocket and awaiting an accept or a close.
WebsocketDisconnect
dataclass
¶
Content
dataclass
¶
Content(body: bytes, headers: RawHeaders = ())
A body and the headers that describe it: what a caller holds when it has a value rather than bytes.
Encoding a value produces two things that must travel together, the bytes and the
content-type naming what they are, and every caller that separates them gets to
make the same mistake. This pairs them without deciding either: Content carries no
policy, so json_content is one producer of it and a form, text, or msgpack encoder
is another, all with equal standing.
The body is bytes because a Content is a value the caller already holds whole,
which is what makes it comparable, shareable, and replayable. A body produced as
chunks is the same pairing with a different lifetime: StreamingContent, below.
EarlyHint
dataclass
¶
FilePart
dataclass
¶
FilePart(
name: str,
filename: str,
body: bytes | Stream[bytes],
content_type: bytes = b"application/octet-stream",
)
One file part of a multipart_content body: a named file carrying its own type.
The body is bytes when the file is held whole, or a Stream[bytes] to stream a
file too large to hold; a streaming part makes the whole multipart_content
one-shot in practice as well as in type.
Response
dataclass
¶
Response(
status: int, headers: RawHeaders = (), body: bytes = b""
)
A whole response as one value, the common case behind the event pair.
from_content
classmethod
¶
from_content(
status: int,
content: Content,
*,
headers: RawHeaders = (),
) -> Response
A response carrying content, with headers layered over the ones it describes itself with.
The caller wins on any name the content also sets, so a handler that wants
content-type: application/problem+json over a JSON body says so here rather
than rebuilding the body.
ResponseBody
dataclass
¶
ResponseDebug
dataclass
¶
ResponseStart
dataclass
¶
ResponseStart(
status: int,
headers: RawHeaders = (),
trailers: bool = False,
)
ResponseTrailers
dataclass
¶
ResponseTrailers(
headers: RawHeaders, more_trailers: bool = False
)
Trailing headers sent after the final body (http.response.trailers extension).
ServerPush
dataclass
¶
ServerPush(path: str, headers: RawHeaders)
An HTTP/2 server push (http.response.push extension).
StreamingContent
dataclass
¶
StreamingContent(
body: Stream[bytes], headers: RawHeaders = ()
)
A streaming body and the headers that describe it: Content's one-shot sibling.
The same pairing (chunks plus the content-type naming them travel together),
but the body is a Stream[bytes] consumed exactly once, so unlike a Content
this is not a value to compare, cache, or replay; re-sending one means rebuilding
it. without-http's request takes either at body=; a streaming body whose
length is unknown is framed as transfer-encoding: chunked where a buffered one
gets a content-length.
buffered() collapses one into the Content it would have been, the
request-side mirror of reading a response body whole: produce as a stream
first-class, buffer as the convenience.
SupportsFileno
¶
WebsocketAccept
dataclass
¶
WebsocketAccept(
subprotocol: str | None = None, headers: RawHeaders = ()
)
WebsocketClose
dataclass
¶
Close a websocket connection, or reject it when sent before WebsocketAccept.
code is a
WebSocket close code;
1000 is a normal closure. If sent before the handshake is accepted, the
server discards code/reason and returns an HTTP 403 instead, so these
only reach the client on a close after accept.
WebsocketResponseBody
dataclass
¶
WebsocketResponseStart
dataclass
¶
WebsocketResponseStart(
status: int, headers: RawHeaders = ()
)
The start of an HTTP denial response (websocket.http.response extension).
ZeroCopySend
dataclass
¶
ZeroCopySend(
file: SupportsFileno,
offset: int | None = None,
count: int | None = None,
more_body: bool = False,
)
A zero-copy file-descriptor send (http.response.zerocopysend extension).
The application is responsible for closing file afterwards.
Asgi
dataclass
¶
HttpScope
dataclass
¶
HttpScope(
asgi: Asgi,
http_version: str,
method: str,
scheme: str,
path: str,
raw_path: bytes | None,
query_string: bytes,
root_path: str,
headers: RawHeaders,
client: tuple[str, int] | None,
server: tuple[str, int | None] | None,
extensions: Mapping[str, Mapping[str, object]] | None,
)
The per-request connection facts, known once when the request opens.
Field descriptions are taken from the ASGI HTTP connection scope: https://asgi.readthedocs.io/en/latest/specs/www.html#http-connection-scope
The ASGI state namespace is intentionally not surfaced: without threads
lifespan-derived state to handlers explicitly through make_asgi_app, rather
than reading it from the scope.
scheme
instance-attribute
¶
scheme: str
URL scheme portion (likely "http" or "https"); defaults to "http".
path
instance-attribute
¶
path: str
HTTP request target excluding any query string, with percent-encoded sequences and UTF-8 byte sequences decoded into characters.
raw_path
instance-attribute
¶
raw_path: bytes | None
The original HTTP path component as the bytes the web server received,
excluding any query string; None if the server cannot provide it.
root_path
instance-attribute
¶
root_path: str
The root path this application is mounted at (WSGI SCRIPT_NAME);
defaults to "".
headers
instance-attribute
¶
headers: RawHeaders
[name, value] byte-string header pairs, in the order received;
duplicates are preserved.
client
instance-attribute
¶
Remote [host, port]; None if not provided.
server
instance-attribute
¶
Server [host, port], or [path, None] for a unix socket; None if not
provided.
LifespanScope
dataclass
¶
LifespanScope(asgi: Asgi)
The server lifecycle scope, shared across the whole event loop.
Field descriptions are taken from the ASGI lifespan scope: https://asgi.readthedocs.io/en/latest/specs/lifespan.html#scope
The ASGI state namespace is intentionally not surfaced: without threads
lifespan-derived state to handlers explicitly through make_asgi_app (which
holds it in a _Cell and passes it per request), rather than via the scope.
Tls
dataclass
¶
Tls(
server_cert: str | None,
client_cert_chain: tuple[str, ...],
client_cert_name: str | None,
client_cert_error: str | None,
tls_version: int | None,
cipher_suite: int | None,
)
The tls extension's connection info, present only on TLS connections.
Field descriptions are taken from the ASGI TLS extension: https://asgi.readthedocs.io/en/latest/specs/tls.html
server_cert
instance-attribute
¶
server_cert: str | None
PEM-encoded server certificate; None if the server cannot provide it.
client_cert_chain
instance-attribute
¶
PEM-encoded client certificate chain (client cert first); empty if none.
client_cert_name
instance-attribute
¶
client_cert_name: str | None
RFC4514 Distinguished Name of the client certificate subject; None if
no client certificate.
client_cert_error
instance-attribute
¶
client_cert_error: str | None
Verification error message if a client certificate failed validation;
None if it verified or none was provided.
WebsocketScope
dataclass
¶
WebsocketScope(
asgi: Asgi,
http_version: str,
scheme: str,
path: str,
raw_path: bytes | None,
query_string: bytes,
root_path: str,
headers: RawHeaders,
client: tuple[str, int] | None,
server: tuple[str, int | None] | None,
subprotocols: tuple[str, ...],
extensions: Mapping[str, Mapping[str, object]] | None,
)
The handshake facts of a websocket connection, known when it opens.
Field descriptions are taken from the ASGI WebSocket connection scope: https://asgi.readthedocs.io/en/latest/specs/www.html#websocket-connection-scope
The ASGI state namespace is intentionally not surfaced: without threads
lifespan-derived state to handlers explicitly through make_asgi_app, rather
than reading it from the scope.
scheme
instance-attribute
¶
scheme: str
URL scheme portion (likely "ws" or "wss"); defaults to "ws".
path
instance-attribute
¶
path: str
HTTP request target excluding any query string, with percent-encoded sequences and UTF-8 byte sequences decoded into characters.
raw_path
instance-attribute
¶
raw_path: bytes | None
The original HTTP path component as the bytes the web server received,
excluding any query string; None if the server cannot provide it.
root_path
instance-attribute
¶
root_path: str
The root path this application is mounted at (WSGI SCRIPT_NAME);
defaults to "".
headers
instance-attribute
¶
headers: RawHeaders
[name, value] byte-string header pairs, in the order received;
duplicates are preserved.
client
instance-attribute
¶
Remote [host, port]; None if not provided.
server
instance-attribute
¶
Server [host, port], or [path, None] for a unix socket; None if not
provided.
subprotocols
instance-attribute
¶
Subprotocols the client advertised; defaults to empty.
Head
dataclass
¶
The head a 200 would carry, and no body: the answer to a HEAD.
Distinct from Whole so the bytes are never produced. A HEAD answered as Whole
reads the entire representation off disk and streams it only for the transport to
drop each frame, which makes curl -I and every uptime check cost a full read of
the largest file they name.
NotModified
dataclass
¶
The client's cached copy is still current: a 304, carrying no body.
Span
dataclass
¶
One byte range, inclusive at both ends per RFC 9110 §14.1.2: a 206.
last is the index of the final byte, so a Span(0, 0) is one byte and
Content-Length is length, not the size of the representation.
Unsatisfiable
dataclass
¶
The requested range lies outside the representation: a 416.
ClientDisconnect
¶
Bases: Exception
The client disconnected before its request body was fully received.
Checkpoint
dataclass
¶
Checkpoint(id: str)
An id: frame carrying no data: advance the resumption point, deliver nothing.
The spec's dispatch sets the last event ID string before it returns early on an empty data buffer, so this moves where a reconnecting client resumes from without delivering an event. That is what a producer sends after skipping work a consumer asked not to see: a filtered batch, a compacted range, a heartbeat that is also a position. Without it the consumer would replay from before the skip.
Comment
dataclass
¶
Comment(text: str = '')
A : line, which every parser ignores: the conventional heartbeat.
It keeps intermediaries from reaping an idle connection without dispatching an
event, and costs a consumer no memory, since a comment is discarded as its line
ends. A newline in text is carried by splitting it across as many : lines as it
needs, because a raw one would leave the remainder on a line the peer reads as a
field.
Event
dataclass
¶
Event(
data: str,
type: str = DEFAULT_EVENT_TYPE,
id: str | None = None,
)
A frame that dispatches an event: the only kind that delivers anything.
data is what makes it an event, so it is required here rather than optional the
way it would be on a type covering every frame. type names the event, defaulting
to the same message a peer assumes when no name is given. id makes the event a
resumption point, echoed back in Last-Event-ID after a reconnect.
id is the one field whose shape differs from ReceivedEvent's, and the asymmetry
is real rather than an oversight: None sends no id: line, which leaves the
peer's resumption point where it was, while "" sends an empty one, which clears
it. There is no inbound counterpart to that choice, since a parser only ever
reports the point the stream currently sits on. type needs no such option because
the format cannot tell the two apart: an absent event: line, event: message,
and event: all arrive as message, so the encoder writes no line for the
default and the distinction never reaches the wire.
A newline in data is carried, by splitting the value across as many data: lines
as it needs and letting the peer rejoin them. The break survives; which break
does not, because the format spells all three terminators the same way and the peer
rejoins with a line feed, so a \r\n or a lone \r inside data arrives as \n.
Normalize before sending if the distinction matters, or send a format that can
carry it (a JSON string in data).
ReceivedEvent
dataclass
¶
One event as parsed off a stream: the inbound counterpart to Event.
No defaults, because the parser always supplies every field, so a field it forgot
fails loudly instead of arriving as a plausible blank. The fields line up with
Event's except that id is a plain str here: the spec's dispatch substitutes
message for an unnamed type, and the last event id persists across events, so
an event whose own frame carried no id: still reports whichever id the stream
last set ("" until it sets one). Only a sender gets to choose between leaving
that point alone and clearing it, which is why only Event.id is optional.
id is always one a reconnect can carry unchanged: a carriage return or line feed is
what ended the field, and the parser ignores an id: that a Last-Event-ID header
could not spell or would not preserve. That is what makes reflecting it into one safe.
Retry
dataclass
¶
Retry(after: Milliseconds)
A retry: directive: how long a client should wait before reconnecting.
Its own frame rather than a field on Event, because it is a property of the
stream: a frame carrying only retry: dispatches no event, so a value hung on an
event would have nowhere to live when a producer sent one on its own.
without-http's subscribe is what acts on it.
after is a count of Milliseconds because that is what the line carries, and the
type says so where a caller writes the value rather than where it reaches the wire.
Truncating a finer duration instead is at its worst at the bottom of the range: half
a millisecond would render retry: 0, which does not mean "almost no wait" but
"reconnect immediately". Milliseconds(0) stays legal, because zero is a wait a
sender chose rather than one that fell out of a conversion.
make_asgi_app
¶
make_asgi_app(
lifespan: Lifespan[T],
http: HttpRouter[T] = refuse_http,
websocket: WebsocketRouter[T] = refuse_websocket,
) -> ASGIApp
Build the ASGI app that drives lifespan and runs a per-connection
Processor over each connection's event stream.
This is the ASGI entrypoint: it parses each raw scope into its typed value
and owns all the receive/send wiring. The lifespan scope is set up once on
startup and torn down on shutdown, with boot failures reported as
lifespan.startup.failed / lifespan.shutdown.failed. For a connection
scope it calls the matching handler with the state threaded in, wraps
receive into the inbound event stream, runs the returned Processor, and
drains its outbound stream into send: the handler only ever sees streams.
The inbound stream is closed when the handler exits, so a handler that
abandons the request body early does not leave it dangling for GC.
Each protocol's router defaults to one that refuses the connection, so an app
serves a protocol only by passing its own router (an HTTP-only app passes
http, a WebSocket-only app passes websocket). The default refusal never
reaches app code: an HTTP scope gets a 501 Not Implemented response, a
WebSocket scope is closed before accept (which the server turns into a
403). Drilling under this driver, e.g. to build a handler that needs the raw
receive/send, is parse_scope plus the http_inbound / http_outbound
(and websocket) shell functions this wires together.
refuse_http
¶
refuse_http(state: object, head: HttpScope) -> HttpHandler
An HttpRouter that refuses every request with 501 Not Implemented.
refuse_websocket
¶
refuse_websocket(
state: object, head: WebsocketScope
) -> WebsocketHandler
A WebsocketRouter that refuses every connection by closing before accept (a 403).
content_hash
¶
content_hash(
key: str, path: Path, stat: stat_result
) -> bytes
A digest of the file's bytes: the default, and a validator strong on its own merits.
Unlike a timestamp-derived tag it does not change when a rebuild rewrites an unchanged file, so clients do not refetch a bundle that did not change, and it is identical across replicas and machines.
inventory
¶
inventory(
root: Path,
*,
etag_for: EtagFor = content_hash,
index: str | None = None,
headers: RawHeaders = STATIC_ASSET_HEADERS,
charset: str | None = "utf-8",
encodings: Mapping[
bytes, Callable[[], Compressor]
] = DEFAULT_COMPRESSORS,
compressible: Callable[
[bytes | None], bool
] = is_compressible,
) -> Inventory
Walk root once and build the mapping serve_asset answers from.
Every decision that could involve an attacker in a traversal design is made here instead, once, over a tree the operator assembled:
- Only regular files are admitted, so a directory, fifo, or device is absent rather than a failure discovered mid-response.
- A directory that cannot be read raises, rather than contributing nothing and leaving the inventory silently short every asset beneath it.
- Each entry is resolved and confirmed to be inside
root; one that escapes raises, naming both ends. No flag relaxes this, because that flag is precisely aiohttp's CVE-2024-23334. - A symlinked directory raises. Descending it could cycle and hang the walk,
and skipping it is the silent shortfall again, since
Path.walkreports one among the filenames rather than the directories. - Content type, validators, and response headers are computed now, so serving hands an immutable tuple through rather than rebuilding one per request.
Keys are relative POSIX paths with no leading slash ("css/app.css"). index
installs an alias from a directory's key to the index file inside it, under both
"guide" and "guide/" so the keyspace does not depend on whether the shell above
strips a trailing slash; /guide/ reaches guide/index.html, and the slash-less
/guide gets a 302 to it rather than the document itself (see _slash_redirect).
Every other directory key is simply absent, which is a 404 by omission. There is
no directory listing, and none behind a flag.
A file whose suffixes name a content coding as well as a media type (logo.svgz,
bundle.tar.gz) is served with that content-encoding and is not encoded again.
etag_for returns the opaque token; the quoting is added here, and a token
holding characters illegal in an entity-tag is rejected, so a caller cannot emit a
malformed validator.
headers are prepended to every asset's response, on both what a 200 announces
and what a 304 repeats, since a policy header is needed by the browser reading the
response back out of cache too. They default to STATIC_ASSET_HEADERS:
REVALIDATE_CACHE_CONTROL plus x-content-type-options: nosniff. That caching
policy is correct whatever the tree's filenames look like, and cheap here, since a
revalidation is answered from memory with no syscall at all.
Where the tree holds fingerprinted filenames, ones carrying a content hash
(app.a1b2c3d4.css), a new build writes a new URL and the old entry is never
requested again, so the round trip buys nothing and IMMUTABLE_CACHE_CONTROL is
worth opting into. Do not reach for it otherwise: on stable names it pins a stale
copy in every browser that saw it, for a year, with no way to reach those clients.
Amend or extend with the headers module's ordinary helpers, rather than retyping:
inventory(root, headers=headers.replace(
STATIC_ASSET_HEADERS, b"cache-control", IMMUTABLE_CACHE_CONTROL))
inventory(root, headers=headers.add(
STATIC_ASSET_HEADERS, b"cross-origin-resource-policy", b"same-origin"))
Pre-compression. For each asset whose media type compressible allows, a
variant is built per coding in encodings, preferring a sidecar file the build
system already produced (app.css.br, app.css.gz, app.css.zst, the convention
nginx's brotli_static and WhiteNoise use) and compressing in memory only when one
is missing or older than the asset it encodes. A sidecar is recognized as one only
beside an asset that is itself encoded, so a data.tar.gz published alongside its
own data.tar keeps its URL rather than disappearing into a variant that a
non-compressible media type never builds. A missing sidecar is logged, because
the level worth using for bytes compressed once and served forever is far slower
than one worth paying per process start: brotli quality 11 runs at roughly a
megabyte per second, so it belongs in the build, not in every replica's startup.
Encoded bytes are held in memory, which also makes a Range over a compressed
asset work correctly, something on-the-fly compression cannot do at all.
Static assets are safe to compress: BREACH needs a response that both reflects
attacker-controlled input and carries a secret, and a stylesheet does neither. That
is why this uses DEFAULT_COMPRESSORS rather than the padded table this package
ships for credential-bearing responses.
This does blocking I/O, deliberately: it is assembly, not request handling. From an
async lifespan, await asyncio.to_thread(inventory, root). The result is a value,
so a development loop that wants to pick up edits rebuilds one and swaps it, on a
timer or a filesystem watch, rather than putting the walk on the request path.
serve_asset
async
¶
serve_asset(
scope: HttpScope,
assets: Inventory,
key: str,
*,
not_found: Response = NOT_FOUND,
chunk_size: int = DEFAULT_CHUNK_SIZE,
) -> AsyncIterator[Outbound]
Answer a request for key out of assets: 200, 206, 302, 304, 416, or 404.
key is used only to look up an entry, never to build a path, so every traversal
payload (.., a decoded %2F or %00, an absolute path, a Windows drive letter,
a reserved device name) is simply a key that is not present.
The content coding is negotiated against the variants the inventory holds, and the
conditional and range rules are then applied to that representation: its size, its
own strong validator. Every answer that owes no bytes (a 302, a 304, a 416,
and any HEAD) is settled from the inventory alone and touches no file at all,
which is what makes revalidation, the common request for a cached asset, cost a
dictionary lookup and a byte comparison, and a curl -I cost nothing at all.
When identity bytes are owed the stat runs before any ResponseStart, and its
size is checked against the inventory's. A disagreement means the tree was written
to while the app was running, which the inventory's contract forbids, and raises
AssetChanged while nothing is committed rather than framing a response whose
length and validator describe different bytes.
size_and_mtime
¶
size_and_mtime(
key: str, path: Path, stat: stat_result
) -> bytes
A validator from the stat alone, for a tree too large to read at startup.
It is published as a strong tag, which rests entirely on the inventory's
no-writes contract rather than on the bytes: a filesystem's timestamp granularity
can be coarser than the interval between two writes, so this could not distinguish
two versions of a file that the contract says cannot exist. content_hash needs no
such assumption. Never st_ino, which would leak a filesystem internal into every
response (Apache's FileETag default, CVE-2003-1418).
describing
¶
describing(
*,
headers: RawHeaders,
content_type: bytes,
coding: bytes | None,
etag: bytes,
modified: datetime,
varies: bool = False,
) -> tuple[RawHeaders, RawHeaders]
What a representation is announced under, and what a 304 about it repeats.
The first tuple is what a 200 or 206 says; the second is RFC 9110 §15.4.5's
required fields plus the content-type and content-encoding naming which stored
variant is being revalidated. §15.4.5 states a floor rather than a ceiling, and both
of those earn their place there: without them a downstream compress() cannot tell a
304 for a representation it would never have encoded from one it would have encoded
itself, so it weakens a validator that is still exactly true of the stored bytes, and
the client's next If-Range, which requires strong comparison, refetches the whole
asset.
accept-ranges is on the first alone: a bodyless answer offers nothing to range over.
varies adds vary: accept-encoding to both, for a representation reached by
negotiation, since a cache keys a stored 304 too. Stamping it where the body does
not depend on the request fragments every downstream cache key for nothing, which is
the bug filed against ngx_brotli as #97, so a file stored in a coding, which
negotiates nothing, does not vary either.
file_response
async
¶
file_response(
path: Path,
*,
status: int = 200,
content_type: str | None = None,
charset: str | None = "utf-8",
headers: RawHeaders = (),
chunk_size: int = DEFAULT_CHUNK_SIZE,
) -> AsyncIterator[Outbound]
Stream a file as the ResponseStart + ResponseBody event stream a handler
yields, with Content-Type and Content-Length filled in: guess the content
type, compute the length, and chunk the bytes off the event loop into the
Outbound events the framework already streams to send, so a large file is
never slurped into one bytes.
This is the helper for content with no cacheable identity: a report you just
rendered, a temp file zipped for this one response. Its validator would change on
every request, so conditional requests would buy nothing and advertising
Accept-Ranges would invite a follow-up for a file that may already be gone. For a
file that persists, reach for serve_file, which answers Range and conditional
requests; for a tree of assets, build an Inventory and use serve_asset.
file_response is a coroutine, not an async generator: awaiting it does the
stat up front, so a missing file raises FileNotFoundError (or stat's
other OSErrors) before any ResponseStart is emitted. Nothing has been
committed to the wire yet, so a handler can still turn the miss into a clean
404 (the parse-don't-validate move). Hand the returned stream back from a
handler:
async def download(state, match) -> Reply:
try:
return await file_response(Path("/srv/report.pdf"))
except FileNotFoundError:
return Response(status=404, ...)
Content-Type is guessed from the file suffix with mimetypes.guess_file_type,
falling back to application/octet-stream; pass content_type to override it, and
charset to name the encoding appended to a textual type (utf-8, as in
inventory; None states none).
The bytes are handed over whole, so a file whose suffixes name a content coding
is described by the coding's own media type (report.tar.gz is
application/gzip) and never by content-encoding. Declaring the coding is right
for a resource stored encoded, which is what serve_file and inventory do, and
wrong for a download: a conformant client decodes content-encoding transparently,
so the user asks for report.tar.gz and saves raw tar bytes under that name. This
is the Apache AddEncoding .gz problem, and it is why this helper, whose whole job
is handing a file over, does not do it.
Any headers given are prepended, for things like content-disposition. The
body is read in chunk_size pieces via asyncio.to_thread, so neither the open
nor the reads block the event loop. The file is opened only once streaming begins
and is closed when the stream is exhausted, errored, or closed early (make_asgi_app
closes an abandoned outbound stream, e.g. on a client disconnect mid-download).
no_body
async
¶
no_body(start: ResponseStart) -> AsyncIterator[Outbound]
The event stream for an answer that carries no content: a HEAD, a 304, or a 416.
serve_file
async
¶
serve_file(
scope: HttpScope,
path: Path,
*,
etag: bytes | None = None,
content_type: str | None = None,
charset: str | None = "utf-8",
headers: RawHeaders = (),
chunk_size: int = DEFAULT_CHUNK_SIZE,
) -> AsyncIterator[Outbound]
Serve one named file as a response to scope, answering Range and conditional
requests: 200, 206 with a Content-Range, 304, or 416.
The path is named by the handler, never derived from the request, so nothing here
confines a key to a directory; to serve a tree, build an Inventory and use
serve_asset, which never derives a path from request input either.
Like file_response this is a coroutine, and the ordering is the point: the stat
runs on await, so a missing file raises FileNotFoundError and a directory
raises IsADirectoryError while nothing is on the wire, and 304 and 416 are
decided before a status is committed.
The derived validator is weak (W/"<size>-<mtime>"), because a filesystem's
timestamp granularity can be coarser than the interval between two writes, so two
different bodies can share a size and an st_mtime_ns. A weak validator fails the
strong comparison If-Range requires (RFC 9110 §13.1.5), so a resumed download
correctly restarts rather than splicing bytes from two versions. Pass etag when
you hold something better, such as a content hash you already store; it is emitted
verbatim, so quote it yourself and mark it W/ only if it is genuinely weak.
The media type is guessed from the file's suffixes, with charset naming the
encoding appended to a textual one (utf-8, as in inventory; None states none).
A file whose suffixes name a content coding as well (logo.svgz) is served with
that content-encoding, since this serves a resource the client is to decode;
file_response, whose job is handing a file over, deliberately does not.
headers are prepended to both what a 200 announces and what a 304 repeats, so
a policy header (cache-control, x-content-type-options, a CORP or CSP value) is
applied to a revalidated response as well, which is where a browser reading it from
cache needs it. Compose them with the headers module's helpers.
The 304 repeats the content-type and any content-encoding alongside the
validators, which is what lets a compress() above this tell a representation it
would never have encoded from one it would have. See Representation.revalidation
for why that matters to the next If-Range.
start_for
¶
start_for(
selection: Selection,
size: int,
described: RawHeaders,
revalidation: RawHeaders,
) -> ResponseStart
Turn a Selection into the ResponseStart that announces it.
described carries what a 200 says about the representation (content type,
validators, Accept-Ranges); revalidation carries what its caller decided a 304
should repeat, which is RFC 9110 §15.4.5's required fields and whatever else
identifies the stored variant, so a bodyless answer does not describe content it is
not sending.
Head announces exactly what Whole does, including the content-length of the
body a GET would carry (§9.3.2), and differs only in that no body follows.
encode_inbound
¶
encode_inbound(event: Inbound) -> RawMessage
Render one inbound http event as the raw dict an ASGI receive returns.
The server-direction dual of parse_inbound: a transport that owns the wire
(without-http) builds typed Inbound events and hands them to the app as the
dicts ASGI receive yields.
encode_lifespan_event
¶
encode_lifespan_event(event: LifespanEvent) -> RawMessage
Render one lifespan event as the raw dict an ASGI receive returns.
encode_websocket_inbound
¶
encode_websocket_inbound(
event: WebsocketInbound,
) -> RawMessage
Render one inbound websocket event as the raw dict an ASGI receive returns.
parse_inbound
¶
parse_inbound(message: RawMessage) -> Inbound
Classify one inbound http event. An unknown event is a protocol fault, so it raises.
parse_lifespan_event
¶
parse_lifespan_event(message: RawMessage) -> LifespanEvent
Classify one lifespan event. An unknown event is a protocol fault, so it raises.
parse_websocket_inbound
¶
parse_websocket_inbound(
message: RawMessage,
) -> WebsocketInbound
Classify one inbound websocket event. An unknown event is a protocol fault, so it raises.
encode_lifespan_reply
¶
encode_lifespan_reply(reply: LifespanReply) -> RawMessage
Render one lifespan reply as the raw dict an ASGI send expects.
encode_outbound
¶
encode_outbound(event: Outbound) -> RawMessage
Render one outbound http event as the raw dict an ASGI send expects.
encode_response
¶
Split a whole Response into its ResponseStart then final ResponseBody.
encode_websocket_outbound
¶
encode_websocket_outbound(
event: WebsocketOutbound,
) -> RawMessage
Render one outbound websocket event as the raw dict an ASGI send expects.
form_content
¶
Encode fields as an application/x-www-form-urlencoded Content.
The encoding HTML forms POST and OAuth2 token endpoints require
(grant_type=client_credentials&scope=...). Names and values are percent-encoded
as UTF-8 by the stdlib's urlencode. A mapping carries one value per name; pass
pairs ([("tag", "a"), ("tag", "b")]) when a name repeats.
html_content
¶
Encode already-rendered markup as an HTML Content: UTF-8 bytes plus content-type: text/html.
Takes a string rather than any kind of node or template, because how the markup was
produced is the application's business and none of this layer's: a without-html
tree passed through render, a template engine's output, or a literal all arrive
here identically. That is what lets this package name the content type without
taking on a renderer, and what leaves the choice of renderer with the app.
The charset is stated rather than left to the recipient's guess, since a bare
text/html sends a browser to its sniffing and locale-default rules for a document
this side already knows is UTF-8.
json_content
¶
Encode payload as a JSON Content: the bytes plus content-type: application/json.
dumps is the whole encoding policy, injected rather than fixed, so an app that
needs sorted keys, a faster encoder, or one that knows its domain types passes its
own (json_content(order, dumps=orjson_dumps)). The default is the stdlib's, because
a default should add no dependency; what it costs is that a payload must be
JSON-native, and a value the stdlib encoder has never heard of raises here rather
than reaching the wire half-written.
JSON ships as a function here, where the library otherwise leaves encoding to the app, because it is the one encoding both sides of this stack kept re-deriving: the same three lines (serializer, encode, content type) appeared in every app, helper, and test that answered or sent a JSON body.
multipart_content
¶
multipart_content(
fields: Mapping[str, str]
| Iterable[tuple[str, str]] = (),
files: Iterable[FilePart] = (),
*,
boundary: bytes | None = None,
) -> StreamingContent
Encode form fields and files as a multipart/form-data StreamingContent (RFC 7578).
The encoding file-upload APIs take: each field becomes a text part, each FilePart
a file part with its own content-type, and the chunks travel with the
multipart/form-data header naming the boundary, so the pair cannot be separated.
fields follows form_content (a mapping, or pairs when a name repeats).
boundary defaults to a random token; inject one for a byte-reproducible body.
Streaming is the one shape, so a large file part never has to be held whole: a
FilePart whose body is a Stream[bytes] is re-yielded chunk by chunk between
its framing. When the payload is small and a replayable value with a
content-length is worth more than the streaming, collapse it:
await multipart_content(...).buffered().
parse_lifespan_reply
¶
parse_lifespan_reply(message: RawMessage) -> LifespanReply
Classify one lifespan reply from the raw dict an app passed to send.
parse_outbound
¶
parse_outbound(message: RawMessage) -> Outbound
Classify one outbound http event from the raw dict an app passed to send.
The server-direction dual of encode_outbound: a transport that owns the wire
(without-http) reads the dicts the app sends and parses them into typed
Outbound events at the boundary. An unknown event is a protocol fault, so it
raises.
parse_websocket_outbound
¶
parse_websocket_outbound(
message: RawMessage,
) -> WebsocketOutbound
Classify one outbound websocket event from the raw dict an app passed to send.
encode_http_scope
¶
Render a typed HttpScope as the raw http scope dict an ASGI app expects.
The server-direction dual of parse_http_scope: a transport that owns the
wire (without-http) builds the typed scope from the request line and renders
it back to the dict the ASGI interface hands an app.
encode_scope
¶
Render any typed scope as its raw ASGI dict, the dual of parse_scope.
encode_websocket_scope
¶
encode_websocket_scope(scope: WebsocketScope) -> RawScope
Render a typed WebsocketScope as the raw websocket scope dict an ASGI app expects.
extension
¶
extension(
extensions: Mapping[str, Mapping[str, object]] | None,
name: str,
) -> Mapping[str, object] | None
The named extension's advertised options, or None when it is absent.
Parse, don't validate: this returns the options mapping itself (often empty,
as for http.response.trailers) rather than a bool, so a caller needing the
options has them and one needing only presence checks is not None.
Server-advertised extensions are optional per-connection capabilities; an app
negotiates by looking one up before using it and falling back when it is
None (and parse_tls reads the tls extension through this).
parse_http_scope
¶
Read an http scope into the typed connection facts, validating at the boundary.
parse_scope
¶
Classify any scope by its type discriminator. An unknown type is a protocol fault, so it raises.
parse_tls
¶
Read the tls extension's connection info from a scope's extensions.
Returns None when the connection is not over TLS (the extension is absent),
which is how an application distinguishes TLS from plaintext connections.
parse_websocket_scope
¶
parse_websocket_scope(scope: RawScope) -> WebsocketScope
Read a websocket scope into the typed handshake facts, validating at the boundary.
http_date
¶
Format when as an IMF-fixdate, the preferred form of RFC 9110 §5.6.7.
parse_http_date
¶
Parse any of the three date forms RFC 9110 §5.6.7 requires a recipient to accept,
returning None for a value that is not a date at all.
The obsolete asctime form carries no zone, so parsedate_to_datetime hands back a
naive value; HTTP dates are always UTC, so one is stamped as such rather than being
left to mean whatever the server's local zone happens to be.
selection_for
¶
selection_for(
*,
size: int,
method: str,
request_headers: RawHeaders,
etag: bytes | None,
last_modified: datetime | None,
) -> Selection
Decide what to send for a representation of size bytes carrying these validators.
A HEAD that is not answered 304 selects Head, which announces exactly what a
200 would and sends no body, so nothing reads the representation to produce bytes
the transport is required to discard.
The precedence is RFC 9110 §13.2.2's, not one invented here: If-None-Match is
evaluated before If-Modified-Since and suppresses it entirely when present, and
If-Range gates whether a Range is honored at all. etag is the final header
value, so a weak validator arrives W/-prefixed and the strong comparison
If-Range requires (§13.1.5) fails on it by construction, which is what stops a
client splicing a fresh range onto a stale prefix.
Only single ranges are honored. A multi-range request needs a
multipart/byteranges body, which is most of the implementation cost for a case
almost nothing sends, and is the shape behind both
CVE-2011-3192 (one copy of
the resource per range) and
CVE-2025-62727
(quadratic range merging). §14 permits a server to ignore a Range it does not
want to honor, so answering with the whole representation is conformant, and the
check is a scan for a comma rather than a split, so a header naming a hundred
thousand ranges costs one linear pass and allocates nothing.
http_inbound
async
¶
http_inbound(receive: Receive) -> AsyncGenerator[Inbound]
An http request's inbound events as a stream.
The stream ends when the request is fully received (the last body chunk, or a disconnect), so a downstream processor's input runs dry exactly when the request does: the request's lifecycle is this stream's lifecycle.
A handler that abandons the body early leaves this generator suspended at its
yield; make_asgi_app wraps it in aclosing, so closing it there runs any
finally deterministically rather than deferring to garbage collection.
http_outbound
¶
A sink that writes each outbound event to ASGI send, encoding at the boundary.
lifespan_inbound
async
¶
lifespan_inbound(
receive: Receive,
) -> AsyncGenerator[LifespanEvent]
The lifespan protocol's events as a stream, ending after shutdown.
lifespan_outbound
¶
lifespan_outbound(send: Send) -> Sink[LifespanReply]
A sink that writes each lifespan reply to ASGI send, encoding at the boundary.
read_body
async
¶
Accumulate an http request's body across its RequestBody chunks.
Raises ClientDisconnect if the client goes away before the final chunk,
so a truncated body fails loudly rather than passing for a complete one.
websocket_inbound
async
¶
websocket_inbound(
receive: Receive,
) -> AsyncGenerator[WebsocketInbound]
A websocket connection's inbound events as a stream.
The stream ends on WebsocketDisconnect, so the connection's lifecycle is
this stream's lifecycle, the same shape as http_inbound.
websocket_outbound
¶
websocket_outbound(send: Send) -> Sink[WebsocketOutbound]
A sink that writes each outbound websocket event to ASGI send, encoding at the boundary.
encode_event
¶
encode_event(event: ServerSentEvent) -> bytes
Render one frame as its wire bytes, terminated by the blank line that ends it.
Pure and total: every arm of ServerSentEvent that exists has already been checked
for the values that cannot be spelled (see each __post_init__), so there is
nothing left to reject here. Every frame is terminated, Comment included, so each
one is self-contained and a Checkpoint takes effect on arrival rather than
waiting for whatever a producer sends next.
event_stream
async
¶
event_stream(
events: Stream[ServerSentEvent],
*,
status: int = 200,
headers: RawHeaders = (),
) -> AsyncGenerator[Outbound]
Serve a stream of frames as the ResponseStart + ResponseBody event stream a
handler yields.
The file_response analog for an event stream, and the shape without-web's
Reply already accepts. Each frame becomes one ResponseBody carrying
more_body=True, so the transport writes it as its own chunk and the client sees
it when it happens; the stream ends with the empty final body. One chunk per frame
is the contract rather than an implementation detail: an event that sits in a
buffer has not been delivered, so the chunk boundary is where the flush happens.
async def ticks(state, match) -> Reply:
return event_stream(counter(), headers=((b"x-accel-buffering", b"no"),))
headers is layered over the defaults, so a caller wins on any name they also
set. Those defaults are content-type: text/event-stream and cache-control:
no-store, and deliberately stop there:
- No
connection: keep-alive, which most SSE advice recommends. It is already the HTTP/1.1 default, and it is a forbidden header in HTTP/2 and HTTP/3, so sending it ranges from redundant to a protocol error. - No
x-accel-buffering: no, the header that stops nginx buffering the response into 32 KB lumps and destroying the streaming this whole module exists for. It is real, it is the most common way an event stream breaks in production, and it is also one proxy vendor's deployment policy, which this layer does not hold (the same reason no access log ships). Pass it, as above, when nginx is in front, and read the deployment notes in the guide for the rest of the chain: a CDN, an ETag middleware, or anything else that wants to see a whole body will re-buffer a stream that this header freed.
There is no content-length, and there cannot be. Compression is separately
declined for this media type; see is_compressible.
An AsyncGenerator rather than a bare AsyncIterator, because closing the response
closes events with it. That is what carries with_heartbeat's promise through this
composition: a client that goes away mid-stream ends the response at the send that
fails, and the source's finally runs there rather than at whenever the collector
reaches it.
parse_events
async
¶
parse_events(
chunks: Stream[bytes],
*,
last_event_id: str = "",
max_event_size: int | None = None,
) -> AsyncIterator[ReceivedEvent]
Parse a byte stream into the events it delivers, dropping directives.
The common path, and the one to reach for unless you are deciding when and where to
reconnect: async for event in parse_events(response.body). See
parse_events_with_directives for the decoding rules and for last_event_id and
max_event_size, and without-http's subscribe for a loop that reconnects and
resumes on its own.
parse_events_with_directives
async
¶
parse_events_with_directives(
chunks: Stream[bytes],
*,
last_event_id: str = "",
max_event_size: int | None = None,
) -> AsyncIterator[Received]
Parse a byte stream into events and the directives that carry no event.
The full parse. Take it when you act on where the stream says to resume and how
long to wait first, which in practice means you are writing a reconnecting loop;
subscribe in without-http is that loop, and this is what it consumes. Reach for
parse_events when you only want what was delivered.
A Retry is surfaced where its line was read, and a Checkpoint where a frame
moved the resumption point without dispatching an event. Neither can be a field on
ReceivedEvent, because the frames that carry them deliver no event to hang them
on, and a consumer that missed them would reconnect to the wrong place.
Decoding follows the spec exactly, which matters more than it sounds:
- UTF-8, with replacement. Malformed bytes become U+FFFD rather than raising, because raising would hand a hostile producer a way to kill its consumers. One leading byte order mark is stripped, and only one.
- Chunk boundaries are invisible. A multi-byte character or a CRLF pair split across two chunks parses as if it had arrived whole.
- Unknown fields are ignored, as are comments and malformed values, which is
what lets a producer add a field without breaking a consumer that predates it.
Nothing in the format is a parse error; the only thing raised here is the size
cap below, which is this library's policy rather than the format's. Malformed
covers a
retry:too large to be a duration and anid:carrying a control character, both of which a consumer would otherwise carry into a reconnect that cannot be spelled.
last_event_id seeds the resumption point this stream continues from, which is what
a caller reconnecting a dropped stream passes back in (subscribe does it for you).
The spec initializes a reconnected parser's last event ID buffer from the value the
connection was resumed with, and only an id: field replaces it, so an event that
carries no id of its own reports the id it is resuming from rather than nothing. A
parser that started from empty would hand back "" there, and a consumer storing
that as its next resumption point would reconnect from the beginning of the feed.
max_event_size caps the characters retained toward the event being assembled
(its data, its type, and any partial line), raising ValueError past the bound.
It is None, unbounded, by default: the bound is worth setting exactly when the
producer might be hostile or merely broken, since a stream of data: lines that
never sends the blank line dispatching them makes a conformant parser buffer until
it dies. A cap on retained state rather than on bytes consumed is what lets a
heartbeat run forever without tripping it: a comment is discarded as soon as its
line ends and retains nothing.
with_heartbeat
async
¶
with_heartbeat(
events: Stream[ServerSentEvent],
*,
every: timedelta = DEFAULT_HEARTBEAT_INTERVAL,
beat: ServerSentEvent = HEARTBEAT,
) -> AsyncGenerator[ServerSentEvent]
Re-emit events, inserting beat whenever the stream has been silent for every.
An idle timer, not a metronome: the interval restarts on each frame that goes out, so a busy stream sends no heartbeats at all and a silent one sends exactly as many as it needs. Merging a fixed-rate tick instead would spend a frame every interval no matter how much real traffic there was.
Reach for it whenever a stream can go quiet for longer than an intermediary will tolerate. A proxy, a load balancer, or a NAT table reaps an idle connection after 30 to 60 seconds, and the client learns about it only as a drop; a comment costs three bytes, dispatches no event, and retains nothing in the peer's parser, so the connection stays alive without the consumer seeing anything.
beat is any frame, so a deployment that needs the traffic to carry meaning can
send something else: a Checkpoint to double as a resumption point, or a typed
Event if a browser client wants to observe liveness. It defaults to a bare
comment because that is the only frame a conformant consumer is guaranteed to
ignore.
This is the one thing in this module that reads a clock; the encoder and parser
stay pure. It pulls the source into a task so a lapsed interval leaves that pull
running: bounding anext with a timeout instead would cancel the pull and lose
whatever the source was about to produce. An AsyncGenerator rather than a bare
AsyncIterator, because it owns that task: aclose() cancels the pull and closes
the source there and then, rather than at whenever the collector gets to it.