Skip to content

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

IMMUTABLE_CACHE_CONTROL = (
    b"public, max-age=31536000, immutable"
)

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,
)

REVALIDATE_CACHE_CONTROL module-attribute

REVALIDATE_CACHE_CONTROL = b'public, no-cache'

STATIC_ASSET_HEADERS module-attribute

STATIC_ASSET_HEADERS: RawHeaders = (
    (_CACHE_CONTROL, REVALIDATE_CACHE_CONTROL),
    (b"x-content-type-options", b"nosniff"),
)

DEFAULT_CHUNK_SIZE module-attribute

DEFAULT_CHUNK_SIZE = 65536

DEFAULT_EVENT_TYPE module-attribute

DEFAULT_EVENT_TYPE = 'message'

DEFAULT_HEARTBEAT_INTERVAL module-attribute

DEFAULT_HEARTBEAT_INTERVAL = timedelta(seconds=15)

EVENT_STREAM_HEADERS module-attribute

EVENT_STREAM_HEADERS: RawHeaders = (
    (b"content-type", EVENT_STREAM_MEDIA_TYPE),
    (b"cache-control", b"no-store"),
)

EVENT_STREAM_MEDIA_TYPE module-attribute

EVENT_STREAM_MEDIA_TYPE = b'text/event-stream'

HEARTBEAT module-attribute

HEARTBEAT = Comment()

HttpHandler

HttpHandler = Processor[Inbound, Outbound]

HttpRouter

HttpRouter = Callable[[T, HttpScope], HttpHandler]

Lifespan

WebsocketHandler

WebsocketRouter

WebsocketRouter = Callable[
    [T, WebsocketScope], WebsocketHandler
]

Inbound

Inbound = RequestBody | Disconnect

LifespanEvent

LifespanEvent = Startup | Shutdown

WebsocketInbound

LifespanReply

ConnectionScope

ConnectionScope = HttpScope | WebsocketScope

Scope

Selection

Selection = (
    Whole | Head | Span | NotModified | Unsatisfiable
)

Received

ServerSentEvent

ServerSentEvent = Event | Comment | Retry | Checkpoint

ASGIApp

ASGIApp = Callable[
    [RawScope, Receive, Send], Awaitable[None]
]

RawHeaders

RawHeaders = tuple[tuple[bytes, bytes], ...]

RawMessage

RawMessage = Mapping[str, object]

RawScope

RawScope = Mapping[str, object]

Receive

Receive = Callable[[], Awaitable[RawMessage]]

Send

Send = Callable[[RawMessage], Awaitable[None]]

WebsocketData

WebsocketData = WebsocketText | WebsocketBinary

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.

path instance-attribute

path: Path

last_modified instance-attribute

last_modified: datetime

identity instance-attribute

identity: Representation

encodings class-attribute instance-attribute

encodings: Mapping[bytes, Representation] = _NO_ENCODINGS

codings class-attribute instance-attribute

codings: tuple[bytes, ...] = ()

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.

needs_trailing_slash class-attribute instance-attribute

needs_trailing_slash: bool = False

Whether this key reaches a directory's index under a URL that is missing the trailing slash, and so should be redirected rather than answered. See _slash_redirect.

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

Inventory(assets: Mapping[str, Asset])

A mapping of request key to Asset, built once by inventory.

assets instance-attribute

assets: Mapping[str, Asset]

get

get(key: str) -> Asset | None

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.

size instance-attribute

size: int

etag instance-attribute

etag: bytes

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.

body class-attribute instance-attribute

body: bytes | None = None

The encoded bytes, held in memory; None for the identity form, read from disk.

Disconnect dataclass

Disconnect()

The client went away before the request finished.

RequestBody dataclass

RequestBody(body: bytes, more_body: bool)

body instance-attribute

body: bytes

more_body instance-attribute

more_body: bool

Shutdown dataclass

Shutdown()

Startup dataclass

Startup()

WebsocketConnect dataclass

WebsocketConnect()

The client is opening a websocket and awaiting an accept or a close.

WebsocketDisconnect dataclass

WebsocketDisconnect(code: int, reason: str)

code instance-attribute

code: int

reason instance-attribute

reason: str

WebsocketReceive dataclass

WebsocketReceive(data: WebsocketData)

data instance-attribute

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.

body instance-attribute

body: bytes

headers class-attribute instance-attribute

headers: RawHeaders = ()

EarlyHint dataclass

EarlyHint(links: tuple[bytes, ...])

A 103 Early Hints informational response (http.response.early_hint extension).

links: tuple[bytes, ...]

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.

name instance-attribute

name: str

filename instance-attribute

filename: str

body instance-attribute

body: bytes | Stream[bytes]

content_type class-attribute instance-attribute

content_type: bytes = b'application/octet-stream'

PathSend dataclass

PathSend(path: str)

Offload sending a file by absolute path (http.response.pathsend extension).

path instance-attribute

path: str

Response dataclass

Response(
    status: int, headers: RawHeaders = (), body: bytes = b""
)

A whole response as one value, the common case behind the event pair.

status instance-attribute

status: int

headers class-attribute instance-attribute

headers: RawHeaders = ()

body class-attribute instance-attribute

body: bytes = b''

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

ResponseBody(body: bytes = b'', more_body: bool = False)

body class-attribute instance-attribute

body: bytes = b''

more_body class-attribute instance-attribute

more_body: bool = False

ResponseDebug dataclass

ResponseDebug(info: Mapping[str, object])

Debug information sent before the response start (http.response.debug extension).

info instance-attribute

info: Mapping[str, object]

ResponseStart dataclass

ResponseStart(
    status: int,
    headers: RawHeaders = (),
    trailers: bool = False,
)

status instance-attribute

status: int

headers class-attribute instance-attribute

headers: RawHeaders = ()

trailers class-attribute instance-attribute

trailers: bool = False

Whether the app will send a ResponseTrailers after the body; requires the http.response.trailers extension.

ResponseTrailers dataclass

ResponseTrailers(
    headers: RawHeaders, more_trailers: bool = False
)

Trailing headers sent after the final body (http.response.trailers extension).

headers instance-attribute

headers: RawHeaders

more_trailers class-attribute instance-attribute

more_trailers: bool = False

ServerPush dataclass

ServerPush(path: str, headers: RawHeaders)

An HTTP/2 server push (http.response.push extension).

path instance-attribute

path: str

headers instance-attribute

headers: RawHeaders

ShutdownComplete dataclass

ShutdownComplete()

ShutdownFailed dataclass

ShutdownFailed(message: str = '')

message class-attribute instance-attribute

message: str = ''

StartupComplete dataclass

StartupComplete()

StartupFailed dataclass

StartupFailed(message: str = '')

message class-attribute instance-attribute

message: str = ''

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.

body instance-attribute

body: Stream[bytes]

headers class-attribute instance-attribute

headers: RawHeaders = ()

buffered async

buffered() -> Content

Consume the chunks into a Content: same headers, body held whole.

SupportsFileno

Bases: Protocol

An opened file object backed by a real OS file descriptor (for os.sendfile).

fileno

fileno() -> int

WebsocketAccept dataclass

WebsocketAccept(
    subprotocol: str | None = None, headers: RawHeaders = ()
)

subprotocol class-attribute instance-attribute

subprotocol: str | None = None

headers class-attribute instance-attribute

headers: RawHeaders = ()

WebsocketClose dataclass

WebsocketClose(code: int = 1000, reason: str = '')

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.

code class-attribute instance-attribute

code: int = 1000

reason class-attribute instance-attribute

reason: str = ''

WebsocketResponseBody dataclass

WebsocketResponseBody(
    body: bytes = b"", more_body: bool = False
)

A body chunk of an HTTP denial response (websocket.http.response extension).

body class-attribute instance-attribute

body: bytes = b''

more_body class-attribute instance-attribute

more_body: bool = False

WebsocketResponseStart dataclass

WebsocketResponseStart(
    status: int, headers: RawHeaders = ()
)

The start of an HTTP denial response (websocket.http.response extension).

status instance-attribute

status: int

headers class-attribute instance-attribute

headers: RawHeaders = ()

WebsocketSend dataclass

WebsocketSend(data: WebsocketData)

data instance-attribute

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.

file instance-attribute

offset class-attribute instance-attribute

offset: int | None = None

count class-attribute instance-attribute

count: int | None = None

more_body class-attribute instance-attribute

more_body: bool = False

Asgi dataclass

Asgi(version: str, spec_version: str)

The ASGI version metadata carried on every scope's asgi key.

version instance-attribute

version: str

Version of the ASGI spec.

spec_version instance-attribute

spec_version: str

Version of the ASGI sub-spec this server implements.

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.

asgi instance-attribute

asgi: Asgi

Version metadata for the ASGI spec and HTTP sub-spec.

http_version instance-attribute

http_version: str

One of "1.0", "1.1" or "2".

method instance-attribute

method: str

The HTTP method name, uppercased.

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.

query_string instance-attribute

query_string: bytes

URL portion after the ?, percent-encoded.

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

client: tuple[str, int] | None

Remote [host, port]; None if not provided.

server instance-attribute

server: tuple[str, int | None] | None

Server [host, port], or [path, None] for a unix socket; None if not provided.

extensions instance-attribute

extensions: Mapping[str, Mapping[str, object]] | None

Server-advertised optional extensions, keyed by name; None if the server advertised none. See parse_tls for reading the tls extension.

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.

asgi instance-attribute

asgi: Asgi

Version metadata for the ASGI spec and lifespan sub-spec.

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

client_cert_chain: tuple[str, ...]

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.

tls_version instance-attribute

tls_version: int | None

TLS version number (e.g. 0x0304 for TLS 1.3); None if not in use.

cipher_suite instance-attribute

cipher_suite: int | None

16-bit cipher suite identifier in network byte order; None if not 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.

asgi instance-attribute

asgi: Asgi

Version metadata for the ASGI spec and WebSocket sub-spec.

http_version instance-attribute

http_version: str

One of "1.1" or "2"; defaults to "1.1".

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.

query_string instance-attribute

query_string: bytes

URL portion after the ?; defaults to empty.

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

client: tuple[str, int] | None

Remote [host, port]; None if not provided.

server instance-attribute

server: tuple[str, int | None] | None

Server [host, port], or [path, None] for a unix socket; None if not provided.

subprotocols instance-attribute

subprotocols: tuple[str, ...]

Subprotocols the client advertised; defaults to empty.

extensions instance-attribute

extensions: Mapping[str, Mapping[str, object]] | None

Server-advertised optional extensions, keyed by name; None if the server advertised none. See parse_tls for reading the tls extension.

Head dataclass

Head()

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

NotModified()

The client's cached copy is still current: a 304, carrying no body.

Span dataclass

Span(first: int, last: int)

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.

first instance-attribute

first: int

last instance-attribute

last: int

length property

length: int

Unsatisfiable dataclass

Unsatisfiable()

The requested range lies outside the representation: a 416.

Whole dataclass

Whole()

The entire representation: a 200.

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.

id instance-attribute

id: str

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.

text class-attribute instance-attribute

text: str = ''

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

data instance-attribute

data: str

type class-attribute instance-attribute

id class-attribute instance-attribute

id: str | None = None

ReceivedEvent dataclass

ReceivedEvent(data: str, type: str, id: str)

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.

data instance-attribute

data: str

type instance-attribute

type: str

id instance-attribute

id: str

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.

after instance-attribute

after: Milliseconds

WebsocketBinary dataclass

WebsocketBinary(data: bytes)

data instance-attribute

data: bytes

WebsocketText dataclass

WebsocketText(text: str)

text instance-attribute

text: str

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

encode_response(response: Response) -> tuple[Outbound, ...]

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

form_content(
    fields: Mapping[str, str] | Iterable[tuple[str, str]],
) -> 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

html_content(markup: str) -> 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

json_content(
    payload: object,
    *,
    dumps: Callable[[object], str] = _dumps,
) -> 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

encode_http_scope(scope: HttpScope) -> RawScope

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

encode_scope(scope: Scope) -> RawScope

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

parse_http_scope(scope: RawScope) -> HttpScope

Read an http scope into the typed connection facts, validating at the boundary.

parse_scope

parse_scope(scope: RawScope) -> Scope

Classify any scope by its type discriminator. An unknown type is a protocol fault, so it raises.

parse_tls

parse_tls(
    extensions: Mapping[str, Mapping[str, object]] | None,
) -> Tls | None

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

http_date(when: datetime) -> bytes

Format when as an IMF-fixdate, the preferred form of RFC 9110 §5.6.7.

parse_http_date

parse_http_date(raw: bytes) -> datetime | None

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

http_outbound(send: Send) -> Sink[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

read_body(events: Stream[Inbound]) -> bytes

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 an id: 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

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.

return event_stream(with_heartbeat(progress(job)))

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.