Skip to content

Changelog

0.0.10

Added

  • without-asgi: the compressor factories take their codec's own constructor arguments, under the codec's own names: gzip_compressor takes zlib.compressobj's wbits, mem_level, and strategy, zstd_compressor takes zstd.ZstdCompressor's options and zstd_dict, and brotli_compressor takes brotli.Compressor's mode, lgwin, and lgblock. padded_gzip_compressor, padded_zstd_compressor, and without-http's gzip_compress, zstd_compress, and brotli_compress pass the same arguments through. Every default is unchanged, so existing tables encode the same bytes.

The window is the one that prompted it. A held-open stream whose messages repeat something larger than the default window (2 MiB for zstd, 4 MiB for brotli), such as an event stream re-sending a whole page, compresses every message from scratch. With the window widened the repeat costs almost nothing: on a 5.9 MB page, zstd's second copy fell from 704 KB to 0.6 KB and brotli's from 540 KB to 0.1 KB. The window is memory on both ends for the life of the connection, which is why the defaults stay small.

Two arguments a coding cannot allow are refused. A gzip wbits outside the gzip range raises ValueError, since zlib would otherwise write a zlib or raw DEFLATE stream labelled gzip, and zdict is absent because zlib refuses it in the gzip container. MAX_ZSTD_WINDOW_LOG names the 8 MiB ceiling RFC 9659 sets for a zstd window in HTTP.

Changed

  • without-asgi: zstd_compressor raises ValueError for any configuration whose window exceeds the 8 MiB RFC 9659 allows, including zstd_compressor(20) and up, which used to write 32 to 128 MiB windows that the stdlib decodes and a conforming browser may refuse. Long distance matching without a window_log to cap it is refused for the same reason. The check reads the window zstd resolved, once per configuration.
  • without-http: gzip_compress, zstd_compress, and brotli_compress build one compressor when called, so an argument the codec refuses raises when the middleware is built rather than on the first request with a body.

0.0.9

Added

  • without-durability: a claim now carries two deadlines instead of one, so a slow pass is no longer fenced for being slow. Checkpointer.claim takes a budget and an alive window and lapses at whichever comes first: one alive past its holder's last sign of life, or its budget running out. The worker renews on a tick (Checkpointer.renew alongside the new Scheduler.extend, both on the scheduler's lease), so a pass that is still running keeps its workflow however short that window is, and work(..., budget=...) caps how long any one pass may hold a workflow however alive it looks. Scheduler.extend returns the delivery to use from then on: three of the four schedulers make the visibility a delivery was taken under be its receipt, so renewing renames it, and a worker still holding the old name would find its own done silently declined. The stream scheduler's checks that the entry is still this consumer's before resetting its idle clock, so a delivery another worker has already reclaimed is left with that worker rather than taken back.

One number was answering two questions that pull opposite ways. A single lease had to exceed the longest a pass could honestly take, or a healthy-but-slow pass was fenced mid-flight; and it had to be short, or a crashed worker's workflow waited that long before anyone could touch it. Being fenced was not merely a re-run, either: the step in flight had already performed its effect, so the pass that took over performed it again, on a system where nothing had gone wrong. Splitting the deadlines makes each one answerable, since the liveness window measures how fast a death is noticed and has nothing to do with how long the work takes.

Run.step, Run.transact, and Run.perform take a within, which is the budget that step says it needs, granted before its effect runs. That moves the guess from one number covering every workflow a worker runs to a statement by the code that knows, and leaves the deployment-wide default covering only the steps nobody annotated. Annotating a cheap step costs nothing: the extension is skipped whenever the outstanding budget already covers the request, and a step that is already recorded never reaches it at all. How a pass buys that time is an injected Extend (extending builds the ordinary one), so a test hands in a function rather than a store.

A write counts as a sign of life, folded into the statement or script that already fences it, so a workflow of ordinary short steps renews itself with no extra round trip and the tick is left with the case it is really for: one step long enough that no write falls inside a whole window. Only the winning write renews, so a superseded pass's stray writes cannot keep a dead holder's claim alive. Every renewal is capped at the budget, and once the budget is spent renew reports the claim gone, which is what ends a hung pass: the worker cancels it and hands the workflow back rather than renewing a claim anybody may take. A window shorter than what is left never shortens the budget. A missed tick (the store briefly unreachable) is logged and the next tick tried, and a renewal in flight when the pass ends is waited out, so the delivery is answered for under the name the store gave it.

A caller driving resume without a worker has no tick, so the window a step buys there counts as a sign of life for the whole of that step, and the default Extend assumes nothing about what the claim was taken for: the first window a pass names is always bought.

without_durability.testing.passing builds a Run wired as resume wires one, for a test driving a single method rather than a body. - without-async: settled, which awaits a future shielded and, when the caller is cancelled, waits for the future to finish before the cancellation propagates. It is the shape every durable write already had: the effect has happened by the time the record is written, so cancelling the write would lose the record and not the effect.

Changed

  • without-durability: Run takes an extend, and Checkpointer.claim takes two durations where it took one. A store or a hand-built Run from 0.0.8 needs updating; the SQL claim tables gain two columns and a Redis pass hash two fields, so an existing database has to be recreated.
  • without-durability-postgres: transact no longer holds the claim row lock across the effect. The fence is read plainly before the effect and re-read under the lock after it, so a renewal or a claim from another connection lands during a long effect rather than queueing behind it, and a pass superseded mid-effect is still refused with its effect rolled back.

0.0.8

Added

  • without-durability: every checkpoint record now carries the moment it was written, read back with Checkpointer.history. It returns the records load returns, in the same order, each as a Written carrying the decoded value and an aware at. The clock is the store's, read at the winning write, so a workflow's records are comparable with each other across the machines that wrote them and are not comparable with a local datetime.now(); a losing write moves the value, the position, and the time equally not at all, so a replayed step still reads as having run when it first ran.

A second method rather than a richer load, because the two have different readers. Every pass calls load at its top and wants what a step recorded; nothing inside a pass has any use for when a step recorded it, so a mapping of wrappers there would cost a construction per key per pass to carry a field the runners immediately drop. What wants the times is a status view, an operator asking how long a settlement actually took, or a sweep deciding which workflows are old enough to forget, all of them outside a pass and reading one workflow at a time.

  • without-durability: a workflow can be deleted outright. Durable.delete cancels its wakeups and forgets its records, Checkpointer.discard and Scheduler.cancel are the two halves for a deployment holding the stores separately, and delete returns how many records went, so a caller sweeping ids it is unsure about reads zero rather than an error. PostgresDurable and SqliteDurable do the whole thing in one commit; SplitDurable cancels before it discards, which is the reverse of arrive's order for arrive's reason, since the survivable failure belongs in the crash window: records left with nothing to wake them are finished by asking again, where a wakeup left for a workflow with nothing recorded runs it from the top and performs every effect a second time.

The hard half is not removing the records but doing it under a pass that is still running, and there are two doors. discard takes the fencing token up and keeps the claim row rather than deleting it, so the pass still holding one is refused at its next write: deleting the row instead hands the next claim token 1 on the stores whose tokens are a counter, and a pass holding 7 then writes its remaining steps back into a deleted workflow one at a time with nothing to show for it. And Scheduler.wake_at now declines to reschedule a workflow whose delivery has been cancelled underneath it, because a worker answers for its delivery after the pass: written unconditionally, the deadline that pass chose queues the workflow whose records have just been discarded, and the next worker runs it from nothing. What is left behind is one claim row per deleted workflow, which Redis expires on its own ttl and the SQL stores leave for the same sweep everything else there waits for.

Changed

  • without-durability-redis: a checkpoint hash field now holds <position>:<written at>:<encoding> where it held <position>:<encoding>, since a hash field has no metadata to hang a timestamp off. A checkpoint written by an earlier version does not parse, so a workflow in flight across the upgrade fails on its next load. Let workflows drain before upgrading, or delete their checkpoints.

  • without-durability-postgres and without-durability-sqlite: the workflow_checkpoint table takes a written_at column. CREATE TABLE IF NOT EXISTS leaves an existing table alone, so an existing database needs the column added before it will serve this version:

ALTER TABLE workflow_checkpoint ADD COLUMN written_at timestamptz NOT NULL DEFAULT clock_timestamp();

and, on SQLite, ... ADD COLUMN written_at REAL NOT NULL DEFAULT (unixepoch('now', 'subsec')). Rows that predate the column take the moment of the migration rather than the moment they were written, which is the honest answer: nothing recorded the real one. migrate is still not a migration tool and still owns no versioning; a deployment that needs one uses the ordinary tool.

  • without-durability: MemoryScheduler.wake_at now declines a delivery the store is no longer holding, which every shipped store already did by comparing its receipt. A test driving the double with a hand-written Delivery was relying on the double being more permissive than the stores it stands in for, and now gets what a real queue would give it: nothing scheduled. Take deliveries from next_ready.

  • without-durability: MemoryCheckpointer.hashes holds Stored (the encoding and when it landed) rather than the encoding alone, and the store takes a now argument, so a test can stamp records from a clock it moves rather than waiting out an interval.

0.0.7

Added

  • without-durability: an append-only inbox, so a workflow can take input it did not name in advance. Checkpointer.append is supply's sibling and the only difference between them is who picks the key: supply writes under a name the caller brought, and append writes under one the store assigns, returning the Entry that says where it went. Durable.deliver is arrive's sibling in the same way, appending and making the workflow ready in one commit where the two stores are one datastore, since a caller that appends and then separately schedules can die in between and leave a message nobody will wake for. Inside a pass, Run.receive reads the inbox past a cursor and suspends when there is nothing new, Run.pending reads without suspending, and both take a limit so a consumer can take one entry and leave the rest. Run.awaiting is untouched and stays the primitive for one named value from outside.

What this replaces is a queue hand-rolled over a key-value store. A consumer that needed a stream of input had to invent a key per message and allocate out of that space by trying, which is a loop that writes at n, discovers it lost, and tries n + 1, plus a compare-and-set marker to close a slot against a racing writer. The store already sees every write and already decides where each one sits, so it is the only party that can hand out a name nobody else is about to take.

An entry is an ordinary checkpoint record, which is what makes it appear in load, sort into place among the workflow's other records, and copy into a forked workflow like anything else. Nothing is ever consumed, so nothing is ever moved: what a pass records is a reference to the last entry it took, and that replays correctly because first-writer-wins means the entry still holds what it held. The cost is that a workflow's inbox is part of its checkpoint forever, so a long-lived one loads its whole history on every pass.

Keys are inbox: followed by a zero-padded 20-digit number, which is past what a signed 64-bit counter reaches, so they sort lexically. Run.claim now refuses a step key in that space: a step named inbox:3 would be read back as a message somebody delivered, and no amount of re-running would reveal it.

  • without-cli: command-line parsing as values, the CLI sibling of without-web's extractors. A token (argument, option, flag, count) is one declaration that is the parse, the usage entry, and the typed read at once, so a command's help, its arity table, and the value its handler receives cannot disagree; positionals and options take the same cardinality vocabulary (once, optional, default, many), so an optional positional and a variadic one need no vocabulary of their own; @command returns an Arm value and registers nothing, so a package can ship one and a consumer place it anywhere in a single edit. An @overload ladder ties each token's type to the handler parameter it fills, with no runtime introspection of a function signature, which is the mechanism that gives typer its ceiling. group takes an async-context-manager state entered only when something beneath it is selected, so a command receives a live client it did not open and does not close, typed and checked in both directions (a command wanting one state cannot sit under a group building another, and two commands wanting different states cannot be siblings), which is what click's ambient Context.obj is for. There is no separate root concept: a tree's top level is an ordinary group whose parent is the shell, which supplies the Streams it derives from, so a CLI with no shared resource declares no state and its commands receive that Streams directly rather than an ignored parameter. A state that writes output carries the streams onward by extending Streams or holding one, and forgetting is a static error. An option names where else its value may come from with sources=(FromFile(...), FromEnv(...)), covering Kubernetes and Docker secret mounts through the same validation the command line goes through, with the files read by the shell and handed to the parser as a value. parse_argv is a total, pure function of argv, environment, and file contents returning Bound | Answered | Rejected: it never exits, never prints, and extracts every value up front, so a Bound proves the invocation is valid and nothing is opened for a command line that was never going to run. It holds no opinion about which flags are magic either: answered is the caller's list of spellings that stop the scan, and run is the only place that names -h, --help, and --version or decides what they mean, so a program wanting -?, a help subcommand, or none of it writes its own shell and changes nothing below. Help is a Usage value that plain text is merely one rendering of, so no styling library sits on the path every program crosses and the package depends on nothing. The streams are injected, so output is asserted on by passing Streams.captured() rather than by capturing a process.
  • without-streams: offload moves here from without-logging, whose signature named only Sink, Stream, and standard library types, so it belonged in the substrate rather than in a leaf package. It now sits beside its new counterpart and the pair covers both directions across the sync/async boundary. Import it from without_streams; without_logging no longer re-exports it, so there is one home rather than two paths to the same function.
  • without-streams: stream_from_blocking, read-ahead for a source that is not async at all. stream_from_iterable pulls each value on the loop's own thread, so a source that blocks between items (a pipe, sys.stdin, a driver with no async client) parks every other task; this runs the whole iteration on one worker thread and hands values across a bounded queue, so the loop stays free and the producer pipelines ahead by at most ahead items before backpressure reaches it. Handing over the whole loop rather than awaiting one next at a time is what makes it pipeline, at the cost of holding a thread for the source's lifetime. Because a blocked thread cannot be cancelled, the worker is a daemon thread (a pooled one would hang asyncio.run's shutdown) and abandoning the stream releases the producer's backpressure so it wakes to notice, rather than parking on a semaphore nobody will post to. A generator source is closed as that thread unwinds, so its finally runs promptly instead of waiting on garbage collection; anything else is left open, since a file is its own iterator and closing it would dispose of a source the caller still owns (sys.stdin being the one this exists to wrap). It and offload share a mechanism and deliberately differ in interface: a source has to be pulled, so this one bounds its queue and pushes backpressure into the producer, while a sink is pushed, so bounding offload would mean choosing between blocking the async side and dropping, which it does not yet do.
  • without-web: choice(SomeEnum), a converter matching a path segment against an enum's values and parsing it to the member (path_param("profile", choice(Profile))). A segment outside the set rejects, so the trie branch simply fails to match and the walk backtracks rather than the request reaching a handler and 400-ing, and the enum's values become OpenAPI's own enum for that parameter, declared once on the enum itself. It is @cached per enum so two call sites share one converter and therefore one trie branch, which is the pattern any converter built by a function should follow. Both directions read the member's value, so url_for renders /deploy/prod for a plain Enum as much as for a StrEnum, where str(member) would have spelled the Python identifier. That is Converter's new render, parse's inverse: it defaults to str, which is right wherever a value's text form is its URL form, and is the converter's own business wherever it is not.

Changed

  • without-durability: Waiting is replaced by Blocked, which reports every branch a pass stopped on rather than one of them, in two sets: waiting holds the addresses from Run.awaiting, answered with arrive(workflow, key, value), and listening holds the read steps from Run.receive, answered with deliver(workflow, value). Outcome is now Completed | Sleeping | Blocked, so a driver matching over it has a case to rename, which assert_never reports as a type error rather than as a workflow that quietly stops being woken.

A fan-out suspends in every branch that cannot finish, and the old shape carried one key, so a pass blocked on an approval and an empty inbox reported the approval and discarded the inbox: a client was told one way to unblock the workflow when there were two. It was also unstable, which was worse and is what prompted this. Nothing sorted the candidates, so the reported key was whichever branch reached its raise first, and two passes at one suspended workflow could name different keys on scheduling alone. A set has no order to leak, so both problems go away together.

Two fields rather than two arms because a driver's response to either is identical (acknowledge, schedule nothing) and a pass can be stopped on both at once, while the distinction that is load-bearing (an address to write to, versus a step name nobody writes to) survives as the field a key sits in. InputNeeded and MessageNeeded are unchanged and are what decide which field a key lands in.

Sleeping still wins when a pass has both a deadline and blocked branches, since it alone carries something the driver must act on. That is the one place an outcome still drops information, and it is bounded: the pass the wakeup produces reaches those branches again and reports them then.

  • without-durability: a pass now reports what it reached rather than what propagated out of it, so the outcome no longer depends on which combinator a workflow wrapped its waits in. Each wait writes itself onto the Run before raising, and Run.waking is gone, folded into the one Run.reached that all three waits use. stopped_at and unwound take that list in its place. A gather of two awaiting calls now reports both keys, where it previously reported one: asyncio.gather propagates only the first exception, so the siblings never reached resume at all.

  • without-durability: a workflow may no longer catch a Suspended at all. A body that returns normally having reached a suspension raises Swallowed, naming the keys, instead of reporting Completed. asyncio.wait and gather(return_exceptions=True) capture exceptions as values rather than raising them, so Suspended descending from BaseException never protected against them: a pass could report a finished workflow that was still waiting on the world, having in the inbox case already consumed entries it never acted on. Nothing wakes a finished workflow and no record says a wait went unanswered, so this was silent and unrecoverable.

The cost is that "carry on if it is not there yet" can no longer be written by catching one, and there is no awaiting that returns a default; for the inbox, Run.pending is that shape. Fenced and Contended are unaffected and may still be caught by name.

  • without-durability: Checkpointer.load now MUST return a workflow's records in the order they were first recorded, and all four stores do. A workflow's records have two independent writers, the pass through record and anything outside it through supply, and neither can order itself against the other: a counter either side keeps is read from a stale snapshot or observed from the store and then raced, so both reach for the same next number and the tie has to be invented. The store sees every write, so the store is the only thing that can say. First-writer-wins already decided what a key holds; this says the same writer decides where it sits, so a losing write moves neither the value nor the position. Nothing changes in the signature: load returns a dict, which preserves insertion order, so every existing caller gets the order by iterating and no store owes a sequence number anyone can see. A third-party Checkpointer now owes a guarantee it did not before, and the requirement is invisible to the type checker, so an implementation that ignores it still satisfies the protocol; the cross-store conformance suite is what holds the shipped four to it.
  • without-durability-sqlite, without-durability-postgres: the checkpoint schema changed and there is no migration. migrate is CREATE TABLE IF NOT EXISTS, so an existing database keeps its old shape and every load against it then fails on the missing seq column. Drop workflow_checkpoint (or the whole database) before running 0.0.7. Both stores now carry an explicit seq for load to order by: SQLite gives up WITHOUT ROWID and names the rowid it already assigns as seq INTEGER PRIMARY KEY, moving (workflow, step) to a UNIQUE constraint, and Postgres takes the number from a workflow_seq sequence as the column's DEFAULT. Declaring the column rather than ordering by the implicit rowid is what makes the guarantee survive a VACUUM, which SQLite documents as free to renumber the rowids of any table that has no explicit INTEGER PRIMARY KEY. Neither store's existing write statements changed, since both counters are assigned on insert and left alone by the conflict update that implements first-writer-wins. Postgres append mints its inbox key from that same sequence, in one nextval written as both the key and the row's seq: it is the one store with genuinely concurrent writers, so a maximum read inside the insert would be a race two callers could both win, and two separate counters would let the keys sort one way while load rendered them the other. One number is both, which is also why the column carries a DEFAULT rather than being an identity: an identity is a number no statement may supply.
  • without-durability-redis: a checkpoint hash field now holds <position>:<encoding> rather than the encoding alone, which is how this store meets the ordering guarantee. A Redis hash preserves insertion order only while it is listpack-encoded and stops once it grows past hash-max-listpack-entries or hash-max-listpack-value, so HGETALL order could never carry it. Keeping the position in the field keeps it to one key: there is no second structure to expire in step with the first, and no branch that could allocate a position for a write that turned out to lose. The prefix is added and stripped inside the scripts, so record, supply, and transact are unchanged for callers and a LuaEffect neither writes a prefix nor sees one. append takes its key from the same HLEN, so the field's position and the number its name is built from cannot drift apart. Existing checkpoint hashes are not readable by this version; they expire on their own ttl.
  • without-web: two routes that differ only in what they name a path parameter are now the build-time duplicate route error they always were in substance. No request can tell /u/{id:int} from /u/{other:int}, and both used to be reachable, with whichever was registered first silently winning.

Fixed

  • without-web: a route is no longer handed a value some other route's converter produced. A converter is the routing trie's branch key, and it compared by name alone, so two converters sharing a name but not a parse merged onto one branch and whichever was registered first supplied the parse for both. That was invisible while every converter was a module-level singleton (equal names meant the same object) and became reachable as soon as one was built by a function. Equality is now name and parse together; schema stays out of it, along with render, since neither takes part in matching and OpenAPI and url_for read them off the route's own segments. A converter built by a factory should be @cached on its inputs, as choice is, so that two call sites for the same thing still share one branch.
  • without-web: a path segment is converted once per branch rather than once per route passing through it. Trie branches are keyed on the converter alone, with each route's parameter names carried to its own leaf.

0.0.6

Added

  • without-asgi: Server-Sent Events, as the two pure transforms the format actually is. encode_event renders one frame to bytes and parse_events is Stream[bytes] -> Stream[ReceivedEvent]; neither touches a socket, which is what puts the format at this layer rather than beside a transport, so an app under uvicorn emits events with no transport dependency and a caller parses them out of any byte stream. event_stream is the file_response-shaped server side, yielding one ResponseBody per event with more_body=True, since an event sitting in a buffer has not been delivered, and closing the source with the response so a client that goes away mid-stream releases whatever the handler held there and then. The whole WHATWG format is implemented, not just data:: comments, the multi-line join, the dispatch-on-blank-line rule, one leading BOM, UTF-8 with replacement rather than raising (raising would let a hostile producer kill its consumers), and unknown fields ignored so a producer can add one without breaking an older consumer. Lines split on the format's three terminators and only those, because str.splitlines also splits on U+2028 and five others, which would make a value two lines here and one at a conformant peer. What a handler sends is ServerSentEvent, a union of the only four frames that mean anything: Event(data, type, id) dispatches, Comment is the heartbeat, Retry sets the reconnection time, and Checkpoint moves the resumption point without delivering anything. A single type with five optional fields was the obvious shape and the wrong one, since most of what it permitted was a no-op on the wire or silently discarded on arrival (an event: naming a frame that dispatches nothing), and splitting costs nothing because a frame carrying several at once is equivalent to sending them one after another. Parsing yields the narrower Received = ReceivedEvent | Retry | Checkpoint; Comment is outbound-only because the spec says to ignore comments, and parse_events drops the directives where parse_events_with_directives keeps them, the same split as ResponseBody's trailers. Event and ReceivedEvent are separate types for the usual inbound/outbound reason, and differ in exactly one field: id is optional outbound because omitting the line leaves the peer's resumption point alone where an empty one clears it, a choice only a sender has. type needs no such option, since the format cannot tell an absent event: line from event: message, so it is a plain str on both and the encoder writes no line for the default. Retry and Checkpoint are shared across directions, since a type with one required field has no default that could mask a parser bug. with_heartbeat(events) keeps an idle stream from being reaped by a proxy, inserting a frame (a bare Comment by default, the only one a conformant consumer must ignore) after a silent interval. An idle timer rather than a metronome, so a busy stream sends none at all; it holds the source pull in a task across a lapsed interval, because bounding anext with a timeout would cancel the pull and lose whatever was arriving. Three of the format's own asymmetries are modeled rather than smoothed over: the last event id persists across events, so a frame carrying no id: still reports the stream's current one, and across connections, since both parsers take a last_event_id seeding the point a reconnect resumed from; retry: belongs to the stream; and an id: with no data still moves the resumption point, because the spec's dispatch sets the last event ID string before it returns early on an empty data buffer. A newline in data is carried by splitting it across data: lines, which is what makes event injection structurally impossible where a hand-rolled f"data: {payload}\n\n" forges a whole event; a newline in type or id has no such spelling and raises at construction, before anything is committed to the wire. Both sides are stricter than the format about an id, which comes back on reconnect as a Last-Event-ID header, so what it has to survive is a field value rather than just a line: a control character (not a legal field value at all) and a leading or trailing space (legal, but stripped by a field parser, so the peer would resume from a point neither side chose) both raise on the way out and are ignored on the way in, where the spec singles out only NUL. Interior spaces survive intact and are left alone. Ignoring rather than raising inbound costs a replay from an older id, where failing to spell the header would end a subscription outright: nothing in the format is a parse error, so nothing in it hands a hostile producer a way to kill its consumers. A retry: too large to name a duration is bounded on both sides on the same terms, dropped by the parser and refused by Retry, since a frame encoding to bytes this library would not read is not one worth writing. The line carries whole milliseconds, so Retry takes a count of Milliseconds rather than a timedelta and a finer duration is not a value it can be built from: truncating one is at its worst at the bottom of the range, where half a millisecond renders retry: 0, which does not mean "almost no wait" but "reconnect immediately". max_event_size is unbounded by default and caps state retained toward the pending event, for a producer that might be hostile or merely broken. A line spanning many chunks is assembled from its fragments and joined once, so a megabyte of JSON in one data: line costs time linear in its length rather than quadratic.
  • without-http: subscribe(attempt), the half of Server-Sent Events that needs a transport: it parses the response body, and when the stream ends waits and opens another connection carrying Last-Event-ID, so a caller sees one uninterrupted stream of events across however many connections it took. attempt is a function that opens one connection (lambda headers: client(ClientRequest("GET", url, headers))) rather than a ClientRequest, because a request is not replayable: its body is a Stream[bytes], which the interface allows to be iterated exactly once, so re-sending one value would put a full body on the wire for the first attempt and an empty one for every attempt after it. Building the request per attempt makes that unrepresentable, and is what lets an event stream ride a POST (the shape MCP's Streamable HTTP uses) rather than only the bodyless GET a reused request survives. The resumption point advances on an event carrying an id: and on a Checkpoint, so a producer that skips work the consumer filtered out is not replayed from before the skip. This is the only retry loop the client ships, and the no-retry-middleware position is what makes it possible rather than an exception to it: that position rejects policy the library would have to invent, and here the backoff arrives on the wire as retry:, the resumption token as id:, and the terminal condition is in the protocol. A non-200 or a content type other than text/event-stream raises NotAnEventStream and never reconnects, the first connection's errors propagate rather than starting a silent loop, and a drop after a stream is established reconnects. How far to trust the peer supplying that backoff is the caller's to set: a producer's retry: is clamped between minimum_reconnect (100ms) and maximum_reconnect (five minutes), since at zero it would spin a consumer into a hot reconnect loop and a few orders of magnitude too large it would park one on a subscription that goes silent forever with nothing raised to notice. A window that runs backwards raises where the caller wrote it rather than at the first anext, by which point a request has already gone out. sleep is injected so a test drives the loop without waiting.
  • without: close_stream(source), how a consumer releases a stream it abandons. A Stream is __aiter__-only, so a source may be a generator holding a finally (a file, a task, a connection) or an object with nothing to release; this is that difference in one place rather than in every consumer that can stop early, and without it the cleanup waits on garbage collection, so a long-lived source outlives its consumer by an indeterminate amount.
  • without-async: Seconds and Milliseconds, for the parameters whose duration has to cross a boundary carrying whole units of one. A timedelta names its unit, which is why it is the right type everywhere else, but it cannot say that a duration survives the boundary ahead: a TCP keepalive knob carries integer seconds, an SSE retry: line and SQLite's busy_timeout carry integer milliseconds, and each truncates whatever it is handed. Truncation is worst where it is least visible, half a millisecond of retry: becoming retry: 0, which does not mean "almost no wait" but "reconnect immediately". What makes these worth having is what they cannot hold: each is a count, so no argument to either constructor names a finer unit, and a duration too fine to cross is not one the type can be asked to carry. duration is the timedelta back out and of is the one way in from one, which is the only place the question of whether it divides is ever asked.

Changed

  • without-core is renamed without-streams, and its import name moves from without to without_streams. Install without-streams instead of without-core, and rewrite from without import ... as from without_streams import ... (likewise for the .interfaces and .wiring submodules; .tasks and .testing move further, see below). core named the package's position in the dependency graph rather than anything it contains, and that position is not one the project actually claims: the whole point of layers with narrow interfaces is that no layer is privileged, and without-html is already a member of the family that depends on none of this. streams names the contents instead. It is the narrower of the two honest names, since the substrate also carries the behavior half of the model (Context, sample), but a Context is defined as a stream sampled for its latest value, so Stream is the primitive the rest is derived from. With the rename every without* distribution name now matches its import name, so no package needs a [tool.uv.build-backend] module-name override and no distribution claims the bare without name, which is the project rather than a package.
  • The asyncio primitives move out of the substrate into without-async. background_task, timeout, sleep_forever, cancel_futures, as_async_iterator, and limit_concurrency are imported from without_async rather than without_streams, as are yield_once and resolved_next_turn from without_async.testing; the names, signatures, and behavior are unchanged, and without-streams no longer re-exports them. Applying the same test that produced the rename finds these do not belong under a name that says streams: not one of their signatures mentions a Stream, a Processor, or a Context, and without-streams itself only reaches for background_task to run sample's drain. Keeping them together made the dependency graph say things that were not true, most visibly without-durability-sqlite depending on the whole substrate to reach a busy_timeout count and a test helper; it now depends on without-async alone. The membership rule is that a symbol belongs there when its signature mentions only standard library types, which is decidable by reading one line.
  • without-http: tcp_keepalive takes idle and interval as counts of Seconds rather than timedeltas. The values it produces were always integer seconds; what changes is that a finer duration can no longer be written at the call site, in place of the check that used to reject one after the fact.
  • without-durability-sqlite: connect takes timeout as a count of Milliseconds, the unit PRAGMA busy_timeout carries, so a duration finer than the pragma can express is no longer silently truncated into it.

Fixed

  • without-asgi: make_asgi_app closes a handler's outbound stream when the connection ends, as it already did for the inbound one. A client that goes away mid-response ends the exchange at the send that fails, and a streaming handler's own finally now runs there rather than at whenever the garbage collector reaches the abandoned generator, which is what a long-lived response (an event stream, a heartbeat's pull task) depends on to release what it holds.

0.0.5

Added

  • without-asgi: conditional requests, byte ranges, and static assets. selection_for is the whole of RFC 9110 §13 and §14 as one pure function of a size, two validators, and the request's headers, returning Whole | Head | Span | NotModified | Unsatisfiable; nothing in its signature mentions a file, so the matrix tests as a table and the same decision serves bytes from anywhere. start_for turns that decision into the ResponseStart announcing it, describing assembles the header pair a 200 states and a 304 repeats, and no_body is the event stream for an answer owing no bytes, so a shell over an object store or bytes in memory composes those rather than reimplementing §14 and §15.4.5. Head is its own arm so a HEAD never reads the representation to produce bytes the transport is required to drop, which is what keeps curl -I and an uptime check from costing a full read of whatever they name; it announces exactly what a 200 would, Content-Length included. serve_file(scope, path) is file_response's request-aware sibling for one named file, answering 200, 206, 304, and 416, with the stat still on the await so every one of those is decided while nothing is on the wire. Its derived validator is weak, because a filesystem's timestamp granularity can be coarser than the gap between two writes, so a resumed download correctly restarts rather than splicing two versions; pass etag when you hold something better. Only single ranges are honored: multipart/byteranges is most of the cost for a case almost nothing sends, and §14 permits ignoring a Range, so the check is a scan for a comma rather than a split and a header naming a hundred thousand ranges costs one linear pass. file_response keeps its job, content with no cacheable identity, where a validator that changes every request buys nothing. serve_file and inventory serve a resource, so they use the content coding mimetypes.guess_file_type reports alongside the media type, and a logo.svgz goes out as image/svg+xml with Content-Encoding: gzip rather than as gzip bytes a browser tries to render as an image. file_response hands a file over instead, so it never declares a coding a conformant client would decode in transit, and describes an encoded file by that coding's own type (report.tar.gz is application/gzip): asking for an archive and saving raw tar bytes under its name is the Apache AddEncoding .gz failure, and not one a download helper should have. A suffix naming only a coding (archive.gz) is an opaque download either way, and an explicit content_type suppresses the coding entirely. Both helpers take a charset, defaulting to utf-8 as inventory does, since a textual type that states none leaves the encoding to the recipient's guess, which is how a UTF-8 stylesheet renders as mojibake with no <meta charset> to rescue it. For a tree, inventory(root) walks it once at startup into a mapping of key to Asset, and serve_asset answers out of it. This is deliberately not a directory mount: a mount derives a filesystem path from request input and then has to prove the derivation stayed inside the root, which is the construction behind CVE-2023-29159, CVE-2024-23334, Werkzeug's drive-letter escape, and the two Windows device-name advisories. An inventory never derives a path, so there is no proof to get wrong and a traversal payload is simply a key that is not present. Every decision a mount makes per request with an attacker in the loop is made once here over a tree the operator assembled: regular files only, each resolved and confirmed inside the root (one that escapes raises, and no flag relaxes that, because that flag is aiohttp's CVE), a symlinked directory and a directory that cannot be read both raising rather than silently contributing nothing, and no directory listing at all. index= aliases a directory's key to the index inside it under both spellings, "guide" and "guide/", so the keyspace does not depend on whether the shell above strips a trailing slash; only the slash-less key redirects, with a relative 302 to /guide/ rather than the document, since serving it there resolves every relative link in the page one level too high. The request's query is carried across explicitly, because a relative reference stating none does not inherit the base URI's (RFC 3986 §5.3). The cost is the one in the name: nothing may write into the tree while the process runs. That is not enforced by file modes, which change and which root ignores, but it is detected, since the stat before any ResponseStart raises AssetChanged rather than framing a body whose length and validator describe different bytes. The payoff is a shorter request path too: a 304 is answered from memory with no syscall at all. Validators default to content_hash, which unlike a timestamp-derived tag does not change when a rebuild rewrites an unchanged file, so clients do not refetch a bundle that did not change, and is identical across replicas; size_and_mtime costs nothing for a tree too large to read at startup and rests on the no-writes contract instead. Neither carries st_ino, which is what Apache's FileETag default leaked in CVE-2003-1418. Response policy headers are one headers argument, prepended to what a 200 announces and to what a 304 repeats alike, since a browser reading a response back out of cache needs the policy too. They default to STATIC_ASSET_HEADERS, REVALIDATE_CACHE_CONTROL (public, no-cache) plus nosniff, which is correct whatever the tree's filenames look like and costs a round trip rather than a read, since an inventory revalidates from memory. IMMUTABLE_CACHE_CONTROL is exported to opt into with the ordinary headers helpers where filenames are fingerprinted, and is deliberately not the default: on stable names it pins a stale copy in every browser that saw it for a year, with no way to reach those clients, and a default whose failure is a shipped fix nobody receives is the wrong way round. Assets are also pre-compressed, preferring a sidecar the build system produced (app.css.br, the nginx and WhiteNoise convention) and compressing in memory only when one is missing or stale, which is logged: brotli at quality 11 runs at about a megabyte a second, so it belongs in the build rather than in a cost every replica pays at startup. Each coding carries its own strong validator, since one tag shared across codings lets a client holding the gzip copy revalidate into a 304 and keep bytes from a different representation, and a 304 repeats both that coding and the media type, which is what lets a downstream compress tell a revalidation it would never have encoded from one it would have. The type is what carries an asset with no variants at all, a PNG or a font or a video, which has no coding to read: without it that asset's strong validator is weakened on every revalidation and the client's next If-Range refetches the whole thing. Vary: Accept-Encoding goes only on assets that have variants, since stamping it on an image fragments every downstream cache key for nothing. A sidecar is recognized as one only beside an asset that is itself encoded, and then by a fixed suffix set rather than by the configured codings: an app.css.br is never published as an asset of its own (brotli bytes labelled text/css) merely because brotli is not among them, while a data.tar.gz beside its own data.tar keeps its URL, since a media type that is never compressed has no variant for it to become and dropping it would be a silent 404 for a second deliverable. A file already stored in a coding is served with it rather than encoded a second time. Holding encoded bytes in memory also makes a Range over a compressed asset work, which on-the-fly compression cannot do at all, since it has no way to restate a Content-Range computed over identity bytes.
  • without-web: static_files(prefix, assets), a GET/HEAD route serving an Inventory. The catch-all remainder is the inventory key, and the returned Route is an ordinary value carrying its complete segments, so it reverses through url_for with no router involved and mount rebases it like any other route. The split follows the placement rule: deciding between 200, 206, 302, 304, and 416 needs no routing vocabulary, so it lives a layer down; matching a prefix does, so it lives here. A bare prefix does not match, which is correct rather than a gap, since a request for a directory is a listing request. A single-page app's entry point is the router's fallback instead, the one place that also sees the client-side deep links no asset matches.
  • without-html: a new package. HTML as immutable Python values: build a node tree with plain constructors (div(cls=..., attrs=..., children=...)), render it with a pure render(node) -> str. It depends on nothing else in the workspace, so it is usable from any framework, and the interface is the tree rather than the string, which is what leaves room for rendering a component alone as a fragment. HTML's own constraints live in the signatures rather than in checks that can be forgotten: a void element is a separate VoidElement type with no children field at all, and a raw-text element takes Markup | None, since escaping its content would corrupt the script while not escaping it would be an injection hole. That set is HTML's own rather than a shortlist (script, style, iframe, noembed, noframes, xmp), since a tag left out is one whose content renders entity-encoded; noscript is deliberately not one, being raw text only where its content is never displayed. Escaping is a type, not a setting: text and attribute values are escaped when the element is built, and MarkupSafe's Markup is what renders verbatim, kept under its own name so a fragment from Jinja or tdom already is one, alongside anything else carrying __html__ (Django's SafeString included). Attribute names pass through a mapping verbatim, so hx-get, data-*, aria-*, and SVG's viewBox need no mangling convention; class is the one name attrs rejects, since classes have the cls argument and two channels into one attribute would be two sources to keep in sync, and it rejects the name however it is capitalized, since a parser reads Class and class as one attribute. An element is changed with with_attributes and with_children, which take the arguments the constructors take, so a transform goes through the same escaping the tree was built with; dataclasses.replace on the fields would write the output of a parse that never ran. Replacing an attribute puts the new value where the old one stood, because HTML keeps the first of a duplicated attribute and an appended one would be silently inert. Tag and attribute names are checked rather than escaped, since a name is written into the markup verbatim and one assembled from outside input is an injection point that escaping the values around it cannot reach. A tag must also begin with an ASCII letter, all HTML's own tag-name grammar allows there, which is what keeps a leading ! from opening a comment that runs past the element rather than ending the name. Custom elements are first class: element_type(tag) and void_element_type(tag) define constructors equal in standing to the built-in ones, with the tag check paid once at definition rather than on every call, which is the seam for anything the browser must do itself. render_chunks(node) walks the same tree and produces the same bytes a chunk at a time, for a body that should start reaching a client before the tree is finished. A sequence or iterator in a child position flattens one level, so unpacking goes at the call site ([header, *rows]), which is what makes every element a hashable value with flat children and keeps rendering from consuming anything. Naming those two rather than Iterable is what keeps a Mapping (which would render only its keys) and a set (which would render in an order that varies between processes) from type-checking there. cls names the same two, and drops None and empty entries so cls=("card", "card-active" if active else None) needs no filtering around it. That spelling is the only one: a Mapping joins its keys, so cls={"card": True, "active": False}, the shape classnames and clsx made the idiom in JavaScript, would render both names, and it does not type-check for that reason.
  • benchmarks: benchmarks.render (just bench-render), an in-process comparison of without-html against htpy, Jinja2, and hand-written f-strings over four workloads (a wide table, an htmx-sized fragment, an attribute-heavy page, and a deep nest). It shares none of the load benchmark's machinery, because the thing under test is a pure function rather than a server, and it fails unless every renderer produces byte-identical output, which is the way a render benchmark most often lies. It reports the minimum of many batches with the collector left on, since building a tree allocates and collection is part of what the approach costs.
  • without-asgi: html_content(markup), the fourth Content producer alongside json_content, form_content, and multipart_content. It takes a str, so how the markup was produced stays the application's business and this package names the content type without taking on a renderer.
  • without-asgi: negotiated response compression, closing the exchange 0.0.4's client-side decompress and compressing opened. without_asgi.compression.compress() is an HttpMiddleware that reads a request's accept-encoding and encodes the response body with the coding it picks. It is middleware rather than server behavior because the decision needs the response's media type and the request's headers rather than anything about the socket, which is also why no ASGI server implements one; it therefore applies under any transport and any router, and its coverage is decided by where it is mounted. The coding table is the argument (DEFAULT_COMPRESSORS: brotli, zstd, and gzip) and what is negotiated is derived from its keys, with the order of those keys serving as the server's own preference between codings a client weighted equally, best ratio first. The Compressor protocols and the gzip_compressor / zstd_compressor / brotli_compressor factories now live here rather than in without-http, which re-exports them, so one codec serves a coding in both directions. brotli_compressor defaults to DYNAMIC_BROTLI_QUALITY (5) rather than the bindings' 11, since a table entry encodes a response per request; the request-side brotli_compress keeps 11, where a client compressing one upload makes the ratio worth its cost. This adds brotli to without-asgi's dependencies, which PHILOSOPHY.md now states the test for: a dependency that makes no choice for anyone (no stdlib brotli, one implementation) is taken so it just works, where one with live alternatives (a JSON encoder) stays an argument.
  • without-asgi: negotiate_coding(accept_encoding, available), the negotiation as a pure function, implementing RFC 9110 §12.5.3 whole. Weights are the part the ecosystem skips, and skipping them inverts requests: matching on substrings reads gzip;q=0, which refuses gzip, as asking for it. Here q=0 excludes, the highest non-zero weight wins, * matches every coding not named, and identity outranking the alternatives means no coding. Two answers are choices rather than requirements, both documented on the function: a request with no accept-encoding is answered unencoded although rule 1 would permit any coding, and one that refuses identity while accepting nothing available still gets identity rather than a 406.
  • without-asgi: PADDED_COMPRESSORS, a coding table that mitigates BREACH, for the routes whose responses mix a secret with attacker-influenced text. It implements Heal The Breach (Palacios et al., IEEE Access 2022), the mitigation Django adopted in 4.2: each response carries a random-length run of up to MAX_RANDOM_BYTES (100) in a part of the container the decoder must ignore, so the response length stops being a function of the content alone and a length oracle has to average the noise away first. It is a table rather than a flag because the padding is per container: gzip has the optional filename field after its fixed header (RFC 1952 §2.3.1) and zstd has skippable frames (RFC 8878 §3.1.2), placed after the data rather than before it since a decoder may stop at the end of the frame it just read, while brotli's bindings expose no metadata block and reject concatenation, so br is absent by construction. A table that silently left one coding unpadded would promise a guarantee it does not keep, and it would be the coding browsers reach for first. Padding raises the sample count an attack needs rather than removing the leak, so mount it where secrets and reflections meet and keep DEFAULT_COMPRESSORS, with its brotli, everywhere else. Neither table covers streaming: a committed stream ends a block per chunk, so each chunk the app produces carries its own observable length, a cleaner oracle than the buffered case, and the single random run a padded container holds sits ahead of all of them. That is what is_compressible excludes text/event-stream for, and the property is the streaming rather than the media type, so a streamed text/html page mixing a secret with reflected input belongs off the middleware or behind a compressible that rejects its type.
  • without-asgi: Vary: Accept-Encoding on every response compress could have encoded, whether or not this client got an encoded body, since candidacy is a property of the resource and a shared cache has to key on the header that decides the answer, and on the 304 that revalidates one, which RFC 9110 §15.4.5 asks to carry the fields its 200 would have and names Vary among them because that is how a shared cache picks the stored variant to update. A strong etag is weakened to W/ when the body is encoded, per RFC 9110 §8.8.1: weak comparison still matches the two representations, Range correctly stops matching. The 304 inherits that weakening for a client whose accept-encoding negotiates a coding, since the stored entry it updates is then the encoded variant and RFC 9111 §4.3.4 has the cache copy the 304's fields onto it; a strong tag landing there would let a later If-Range match under strong comparison and stitch identity range bytes into an encoded body. Which stored 200 a 304 updates is usually unknowable, since §15.4.5's field list omits content-type and most 304s carry none, so the candidate is assumed; a 304 that names a type no coding applies to, or a content-encoding the app applied itself, is left exactly as it arrived, because weakening a tag for a re-encoding that never happened breaks every later range request into a full response. The size a 304 may state is not read as the same evidence, since a body streamed behind a head that declared no length is encoded however short it turns out to be. Where the tag is weakened the 304's content-length goes with it, since §8.6 permits one only where it equals what a 200 to the same request would have carried, which for that client is the encoded variant. A 206 is never encoded: its content-range names offsets into the identity representation and nothing here can restate them for an encoded one, so a client reassembling ranges would stitch them at the wrong offsets.
  • without-asgi: compress's one size floor. minimum_size (500 bytes) is the whole of it, and what decides how it is answered is what the head said rather than how the body arrives: a declared content-length answers it before a body event is read, a body that ends in the events read so far answers it exactly from its own bytes (and is re-described with an exact content-length for its encoded form instead of falling back to chunked, unless its head announced trailers over HTTP/1.x, which carries them only in the chunked coding; HTTP/2 and HTTP/3 send trailers as a second HEADERS frame that sits beside a length, so the exact length stands there), and a body still being produced behind a head that declared no length is the one case that cannot be answered without holding bytes the app has already made. An empty body is left alone however low the floor, since encoding nothing produces pure framing and the head would then state that length, which is how a HEAD response comes to answer with the size of an empty encoded stream in place of the size of the body a GET would carry. weigh_undeclared_bodies decides that case, and it is a policy rather than a second floor because the only two honest answers are to hold or not to: holding keeps produced bytes until minimum_size of them accumulate, so a feed emitting a line a second delivers nothing for as many seconds as that takes, and how long that is belongs to the app rather than to the middleware. The default does not hold, spending framing bytes bounded by the floor on a body too small to earn them, which is the trade a response the app chose to stream usually wants; an app that wants both declares a content-length, which answers the floor for nothing, as file_response does. An offloaded body is the one shape that cannot follow a commitment, since http.response.zerocopysend and http.response.pathsend both send bytes the middleware never sees and the former carries more_body so it can follow body events already sent: arriving before any body event an offload passes through unencoded, and arriving after the head has declared content-encoding it raises OffloadedBodyAfterEncoding rather than write a body no decoder can read. An app that means to stream a prefix and then offload the rest sends the whole response through the offload.
  • without-asgi: StreamingCompressor, the Compressor that can be flushed without being ended, and what compress needs to encode a response that is still streaming. What a compress call returns is the codec's choice rather than the caller's: fed the small pieces a streaming body arrives in, zlib emits its header and then nothing until the stream ends and zstd emits nothing at all, so encoding a stream without ending a block per chunk holds the whole body inside the codec and delivers it as one burst at the end. That round-trips perfectly, which is why it reads as a working feature while having removed the incremental delivery the response was streamed for. The three shipped codings satisfy the protocol (zlib and zstd through a flush mode, brotli through its own flush); a coding whose factory produces a plain Compressor still encodes responses that arrive whole, and its streaming ones go out unencoded, which costs bytes rather than delivery. gzip_compressor and zstd_compressor are public alongside brotli_compressor for the same reason: zlib.compressobj and zstd.ZstdCompressor spell a block flush as a mode argument rather than a method, so a table built from them directly satisfies Compressor alone and would take the buffered path for every stream.

Fixed

  • without-http: compressing no longer holds a streamed upload inside the codec. Each chunk was fed to the compressor and whatever came back was yielded, which is the shape of streaming without the property: zlib returns its header and then nothing until the stream ends and zstd returns nothing at all, so a large upload accumulated inside the codec and went out as one burst on the final flush. Ending a block per chunk is what releases it, so the three shipped codings are now built from without-asgi's StreamingCompressor factories (gzip_compressor, zstd_compressor, brotli_compressor) and compressing flushes a block per chunk, with one chunk of lookahead so the last one rides out on the flush that ends the stream rather than paying for a block of its own. A body that arrives whole is one chunk either way, so it encodes to exactly the bytes it did before. A make_compressor producing a plain Compressor still encodes correctly and still buffers, since a coding the caller named by hand has no unencoded answer to fall back on the way a negotiated response does.

0.0.4

Added

  • without-http: response decompression as opt-in middleware. decompress() offers accept-encoding outbound and wraps the response body in an incremental decoder inbound, so a streamed body decodes chunk by chunk and trailers pass through untouched. It is middleware rather than pool behavior because the transport must never silently rewrite bytes: a caller that wants the wire encoding reads the undecorated client. The coding table is the argument (DEFAULT_DECOMPRESSORS, gzip and zstd from the stdlib and brotli from the bundled bindings), and the accept-encoding offer is derived from its keys, so what is advertised and what can be decoded cannot disagree; registering a coding this package does not ship is one entry (decompress(DEFAULT_DECOMPRESSORS | {b"lzma": make_lzma})) rather than a fork. The decoded response is self-consistent: content-encoding and content-length described the encoded body, so both leave the head instead of contradicting the bytes the stream now yields, an unknown or stacked coding passes through whole, a body that concatenates streams (multi-member gzip, back-to-back zstd frames) decodes whole rather than stopping at the first, and a truncated compressed stream raises ConnectionError rather than passing a prefix off as the whole body. This is also how the no-unbidden-headers position holds rather than bends: composing the middleware is how a client opts into offering accept-encoding at all.
  • without-http: request compression, the same mechanism pointed the other way. compressing is the middleware over any coding and a Compressor factory, with gzip_compress, zstd_compress, and brotli_compress as the three that ship. Bodies compress as they stream, so a large upload is never buffered whole, and per-call composition means one client can send compressed to a peer that wants it and plain to one that does not.
  • without-http: default_headers(*headers), the counterpart to add_headers for a field RFC 9110 allows only once. add_headers copies its headers onto every request whatever it already carries, which is right for a field that may repeat and wrong for authorization or user-agent, where a second copy leaves the peer to resolve a duplicate the spec says cannot happen and the per-request value silently loses. default_headers adds each header only where the request omits it, deciding each one on its own. It is a default rather than a policy: the call site's value wins, and a caller that must not be overridden composes its own client, the same position deadline takes on a time budget.
  • without-http: basic_auth(username, password) and bearer_auth(token). The challenge-free schemes need no new mechanism, and naming them saves every caller from re-deriving the base64 and the scheme token. Both are default_headers underneath, so a request carrying its own authorization keeps it and one call can authenticate as someone else without composing a second client. Digest is deliberately still absent, because answering a challenge is a looping middleware rather than a header.
  • without-http: user_agent(*segments), and USER_AGENT as the library's own without-http/<version> identity, which is what it sends when given no segments. Requests still say exactly what the caller said; this is how a caller opts into an identity for the peers that vary on one (and the ones, like the GitHub API, that refuse a request without it). It is default_headers underneath too: a request carrying its own user-agent keeps it.
  • without-http: Happy Eyeballs on by default, and resolution as an injectable step. tcp_connect(resolve=..., happy_eyeballs_delay=...) builds the pool's default Connect: it races address families per RFC 8305 through aiohappyeyeballs, so a dual-stack host with one black-holed family costs a 250 ms delay rather than a full connect timeout. Splitting Resolve out is what makes DNS policy the caller's: a cache, DNS-over-HTTPS, or a test's canned addresses swap in without touching how the winning address is connected. The race drives plain loop.sock_connect, so it behaves the same on any event loop, where asyncio's own racing is fused to its own resolution.
  • without-http: the server supplies the ASGI tls extension on every TLS scope, HTTP, HTTP/2, and WebSocket alike, so an mTLS deployment's client certificate reaches the handler as a PEM chain with its subject as an RFC 4514 distinguished name, and parse_tls finally has a producer inside this stack rather than only a parser. The facts are read once per connection off the finished handshake rather than per request, since a completed handshake does not change under the connection. server_cert and cipher_suite are None, which the spec permits and which is a CPython limit rather than a shortcut: an ssl.SSLContext never exposes the certificate it loaded, and SSLObject.cipher() reports a suite by name with no IANA identifier. client_cert_error is None because a certificate that fails verification fails the handshake, so no scope is ever built for it.
  • without-http: two bounds on the request head, which was previously whatever h11 and h2 chose. They are separate knobs because the protocols measure different things: max_incomplete_event_bytes is how much of an unfinished HTTP/1.1 event (a request line and its headers, a chunk header) may accumulate before the parse is abandoned with a 431, and max_header_list_bytes is advertised over HTTP/2 as MAX_HEADER_LIST_SIZE, bounding an uncompressed header list, which is what makes it a defense against an hpack bomb. Each defaults to its protocol library's own default (16 KiB and 64 KiB), so the numbers differ; collapsing them into one knob would have silently retightened or loosened one protocol. Both are on serving, served_pipe, and loopback_client, like every other per-connection bound.
  • without-http: a served scope advertises the extensions its wire layer implements, where it previously carried none at all: http.response.early_hint on HTTP scopes, websocket.http.response on WebSocket scopes, and tls on both over TLS. A third-party ASGI framework that checks the scope before using an extension, as the spec tells it to, now finds them, where before it correctly concluded there were none; a without-asgi app speaks the typed vocabulary directly and never had to check. An HTTP/1.0 request is the exception: RFC 8297 §2 forbids a 103 to a client with no notion of an interim response, so early hints are withheld from that scope rather than advertised for an app to send and mis-frame the exchange with. The in-memory asgi_client already advertised http.response.trailers, so the wire scopes are what changed.
  • without-asgi: form_content and multipart_content, joining json_content as producers of the same Content value, plus FilePart and StreamingContent. A multipart body streams its file parts rather than buffering them, which is why it is a StreamingContent: the shape follows the size of what it carries rather than being uniform for its own sake. Both work as a request body through without-http's request and as a response body, since Content is the shared vocabulary of the package both sides depend on.
  • Documentation: Alternatives, a feature-by-feature register of without-http against httpx, aiohttp, and niquests on the client side, and against uvicorn, hypercorn, and granian on the server side. Every cell cites its source, gaps are marked by how they close (a composition against an interface that already ships, genuinely new mechanism, or a stated position with its cost named), and open gaps link the issue tracking them. It is a roadmap as much as a comparison, and it is what drove most of the additions above.

Fixed

  • without-http: serving no longer leaves behind the socket of a connection it accepted moments before shutdown. Its connection set was populated by each handler once that handler first ran, so a connection accepted late enough was tracked by nobody: the shutdown's cancel never reached it, nothing ran the teardown that closes its socket, and the descriptor outlived the server. The accept callback is now a plain function rather than a coroutine, which asyncio.start_server calls synchronously as each connection's transport comes up; handed a coroutine instead, it builds the task itself, which registers only once it first runs, a tick later, where a shutdown can slip in between. And the task's completion aborts the transport, which is the only closer for one cancelled before it ever ran.
  • without-http: serving's shutdown no longer races the event loop's own accept machinery, which was leaking the socket of a connection caught one step earlier in its life than the fix above reaches. The stdlib loop turns an accepted connection into a transport inside an internal task, one tick after taking it off the listener, and a connection in that gap is invisible: it has no transport, no handler, and no place in any tracking set. Closing the listener under it trips a CPython bug (python/cpython#109564): the transport construction fails an internal assertion against the closed server and asyncio drops the error and the connection without closing its socket, which surfaced as unraisable ResourceWarnings blaming whichever test ran at the next garbage collection. The shutdown now waits for every connection mid-accept to materialize before closing the listener, then aborts any transport no handler ever registered for (over TLS, the handshake can hold that registration off for seconds) and cancels handlers that registered while it was tearing down, so a connection is closed no matter where in its accept the shutdown caught it.
  • without-http: a served connection whose queued response the peer never read no longer holds its file descriptor, or a shutdown, forever. Asyncio releases a socket only once the transport's write buffer drains, which a peer that has stopped reading never lets happen, so close() alone left the descriptor with the transport until the process ended, and the wait for it blocked serving's shutdown indefinitely. The wait is now bounded by close_timeout (5 seconds, a new serving argument) and followed by an abort, so the descriptor comes back whether or not the peer took delivery. Raise it for large responses to slow clients, lower it for a tighter shutdown.
  • without-http: the wheel now ships the py.typed marker, so installed copies are type-checked instead of treated as untyped (PEP 561). It was the one package in the workspace missing the marker; a pre-commit hook now creates the marker for any package missing one.

0.0.3

Added

  • without-dag: resuming a graph from a checkpoint. run(...) and run.stream(...) take a checkpoint of {node key: result}, the same mapping stream emits, and a node named in it is not run: its result is taken as given and fed to its dependents, so a run picks up where an interrupted one stopped and a checkpoint covering the whole graph performs no effects at all. The execution interface already treated a pre-supplied key as done; what was missing was a key worth storing, so node now takes one as its first argument (graph.node("charged", charge, order)) and NodeKey is a str. A name chosen in the source means the same thing on the other side of a crash, where an object() minted at build time does not, and it must be distinct from every other key in the graph (entries are keyed by position, input:0). A checkpoint key that names no node is rejected rather than ignored, since that is the shape of one written by a different version of the graph. stream being pull-driven makes the store write a barrier: nothing downstream of a completed step starts until the consumer asks for the next result.
  • without-durability (new package): durable workflows over a checkpoint any process can read. Two mechanisms spend the one checkpoint. run_durably drives a without-dag CompiledGraph, recording each (node key, result) before pulling the next, so a resumed run re-enters only what had not finished. A saga is not a third mechanism: a rollback is another graph, so compensating is an except Exception around that call and a second call to it under an id the application chose, which leaves the library reserving no name in anyone else's namespace (the guide writes the eight lines out). stepwise needs no graph: a workflow is an ordinary async function whose effects are named (await run.step("charged", charge, as_text)), resuming calls it again, and each step hands back what is recorded. It asks one thing in return, because the code between steps re-runs: effects live in steps, the code around them is pure, which Temporal and DBOS state as workflow determinism. Keying by name rather than by position keeps that mild, since reordering or inserting a step changes nothing, and it buys two shapes a fixed graph cannot express: a fan-out whose width comes from a step's result, one key per item so a crash resumes item by item, and a step that cannot finish now stopping the pass rather than blocking, which is how a settlement window (run.sleep) and a human approval (run.awaiting) become ordinary lines. resume reports that as an Outcome (Completed, Sleeping, or Waiting) rather than raising, so a driver matches over three values and closes with assert_never instead of writing an except no type checker can call incomplete; the worker does exactly that. Inside a workflow a suspension is still an exception (Suspended, and its ScheduledWakeup/InputNeeded cases), because that is the only way to stop in the middle of straight-line code, and it descends from BaseException so an except Exception around a step cannot swallow it.
  • without-durability: the Checkpointer, Scheduler, and Durable interfaces, which are where the guarantee lives. A protocol of load and record is too weak to run a workflow safely at any scale: it cannot say "only if nobody else is running this" or "only if I am still the one who may write", so two wakeups for one workflow (which the submit-then-confirm flow produces every time) run two passes that both find a step unrecorded and both perform its effect. claim takes the right to run a pass and every write carries the Pass it was granted, so "you cannot write without holding the workflow" is structural rather than remembered. The token is a fencing number minted by the store, because a lease alone is not exclusion: a process that stalls past its lease still believes it holds the workflow, and only the store knows better, so a superseded write is refused (Fenced). record never overwrites a recorded step and returns a Recorded, the value stored after the call and whether it is this pass's own, which only the store can say since a result crosses the codec both ways. supply is the unclaimed half, for values arriving from outside a pass, which keeps first-writer-wins without making an approval fail because a worker is mid-pass. Durable bundles the two stores and names the transitions crossing them, so arrive(workflow, key, value) is one call rather than two writes in an order the caller has to get right: SplitDurable composes any two stores and records before it queues, where a store over one datastore commits both at once.
  • without-durability: Run.transact, which performs an effect and records it in one commit, making that step exactly-once rather than at-least-once. step runs an effect and then writes the record, so a crash between them repeats it; transact hands the store an effect it can perform itself, so there is no in-between. That it works on Redis is worth stating, because the usual framing (that exactly-once needs Postgres) is wrong about why: a Lua script is an atomic commit over Redis data, and the real constraint is that you can only transact within one datastore, so Postgres wins only for effects that live in that Postgres. Checkpointer is therefore generic over the effect type a store can commit, defaulting to Never so a store with nothing to offer makes transact uncallable rather than absent. What "one datastore" means was measured rather than recalled: Redis Cluster rejects a script whose declared keys span slots (CROSSSLOT) and kills one reaching an undeclared non-local key partway through, so a cross-node atomic write is unavailable rather than expensive; sharded Postgres instead escalates silently to a two-phase commit under Citus. The escape is one idea on both sides, Redis's hash tag and co-location by workflow id, and sharing a pool is its necessary half rather than its sufficient one.
  • without-durability: work(durable, body), a queue worker over the same interfaces, and passes, ready, and waking as the Sink-over-Stream pieces it composes. A worker runs up to POOL passes at once through without's limit_concurrency, and every pull takes exactly one delivery (a reclaimed one if any workflow was abandoned, otherwise a fresh read), so it holds precisely as many wakeups as it is working on and stops reading at capacity. It matches on the pass's Outcome, closed with assert_never: a Sleeping is scheduled, a Waiting is left for whoever owes the value to queue, a Completed needs nothing, and nothing polls a workflow to ask whether it can proceed. The acknowledgement lands after the pass on every path but cancellation, so a worker that dies mid-pass leaves its delivery to be reclaimed. How long a pass may honestly take is one number rather than two, and it lives on the scheduler (PostgresScheduler(pool=pool, lease=...)): work reads it and claims the workflow for exactly as long, because the two windows disagreeing fails quietly. The rest of the loop's timings are arguments to work (tick, within, contended, limit), and every duration across the stores and the worker is refused at construction unless it is positive.
  • without-durability-redis (new package): both interfaces over Redis, where each guarantee is a small Lua script, for the reason wake_due already was: checking whether a workflow is free and taking it, or checking a token and applying the write it guards, are only correct as a single step. A workflow's two keys are hash-tagged so they land on one slot, and LuaEffect is what this store can commit alongside a record. The fencing token is max(now_ms, previous + 1), a hybrid logical clock rather than a counter: the checkpoint and the claim expire together, so a counter would hand a reused id token 1 while a pass stalled since before the expiry still held token 3. Two queues ship. RedisStreamScheduler is a stream read as a consumer group beside a deadline-scored sorted set, which buys a blocking read; a stream rather than a list because a list loses work, since a delivery stays pending until acknowledged. RedisSetScheduler is one sorted set scored by when each workflow becomes visible, which makes the timer, the consumer group, the pending list, and the trimmer all disappear, and costs the blocking read. Holding each workflow once is its catch, since a wakeup landing mid-pass has nowhere to go but on top of the entry that pass is holding, so the score a pass took is its receipt and finishing is conditional on it being unchanged. trim bounds the stream with XTRIM ... MAXLEN 0 ACKED (Redis 8.2+), so the server decides what every group has finished with; it refuses a stream with no groups, where ACKED has no effect and the trim would degrade to deleting a queue nobody has read yet.
  • without-durability-postgres (new package): both interfaces over three tables in one database, with SqlEffect as the effect type transact takes there. It is the other half of the Redis store's argument, and what it shows is where the atomic unit came from: every write that had to be a Lua script is one statement or one transaction here, because SQL says "check this, then write that, and let nobody in between" by default. The claim is an upsert whose DO UPDATE carries a WHERE on the lease; record is a FOR UPDATE CTE over the claim row feeding an upsert, where the row lock is what makes the fence serialize against a claim in flight rather than read a stale snapshot; the queue takes with FOR UPDATE SKIP LOCKED, so several workers polling one table fan out instead of queueing on its head. Three live Redis questions do not arise: a workflow id is a query parameter rather than key structure, nothing expires so the fencing token can be a plain counter, and a default Postgres commits synchronously. PostgresDurable makes arrive one commit, which is what makes "no second system" a claim this can make. What it costs is that sweeping finished workflows becomes a job somebody writes, that next_ready still polls (LISTEN/NOTIFY would close that and does not yet), and that migrate is three CREATE TABLE IF NOT EXISTS under an advisory lock rather than a migration tool.
  • without-durability-sqlite (new package): the same three tables over one file, and the smallest thing that meets every requirement the interface states, with no server and no third-party driver. BEGIN IMMEDIATE is the exclusion, so it needs neither Postgres's FOR UPDATE nor Redis's Lua, and because the datastore is a file there is nothing to co-locate, which is DBOS's guarantee for an application that never needed Postgres. Its effect type is a synchronous callback where the Postgres one is async, because the whole transaction runs on one worker thread. connect opens with synchronous=FULL rather than the usual NORMAL, since that trades away exactly the property the package exists for. Its scope is one machine, which is the deployment it is for rather than a defect, and it needs SQLite 3.42 or newer, which requires-python cannot express: on Linux sqlite3 links whatever libsqlite3 the distribution ships.
  • without-durability: CheckpointCodec, the interface deciding what a step's result becomes in a store, with JsonCodec over the stdlib as every store's default. What a checkpoint is encoded as is a boundary decision, so it belongs to the application rather than to four stores answering it identically and wrongly for anyone whose steps return a domain value json.dumps has never heard of; swapping one in is now a constructor argument. It is one object rather than a pair of functions because both requirements are about the pair: decode(encode(x)) MUST equal x, or a resumed pass reads something the first pass never wrote, and encode MUST be deterministic, because record decides who won a race by comparing encodings. PostgresCheckpointer narrows the choice to codecs producing JSON text, since that is what a jsonb column takes; keeping the column buys the indexing and the operators, and the codec still owns the value mapping. MemoryCheckpointer applies it too, which is the part that is easy to skip and is exactly what makes a double lie: a dict can hold a value directly, so encoding into it looks like ceremony, but then every property that depends on the round trip passes in the suite and fails in a deployment. It holds encoded values, so reading a checkpoint means load.
  • without-durability: every durable read names its parser. Run.step, Run.transact, and Run.awaiting take a parse: Callable[[object], T] and return a T a function actually produced, where they previously cast. The cast was unsound on every path rather than only after a crash: a step hands back what the store holds, read through a codec, so one returning a tuple was handed a list on the pass that ran it while its signature still promised a tuple. The parsers were already there, wrapped around the call sites (parse_items, parse_approver); moving them inside means a step whose result is used unparsed is no longer expressible. The effect's own return type is deliberately not tied to the parser's, because what goes in and what comes out are related by encode-then-decode rather than by identity: Run.sleep records an ISO string and reads back a datetime, which is the ordinary case and not the exception.
  • without-durability: run_durably refuses a node whose result does not survive its own store, on the pass that wrote it. It needs no per-node parser because it holds both values at once, what the node returned and what the store now has, so it verifies where stepwise has to parse. The check earns more here than a parser would: a graph feeds a node's result straight to its dependents, so without it they see a tuple on the pass that computed it and a list on the one that restored it, with no crash needed for the two to disagree. without-dag is untouched, and the split is the general rule rather than a convenience: verifying beats parsing whenever the caller still holds what it sent, and Run.awaiting is exactly the case that does not, since it reads a value another process wrote.
  • without-durability: Interruption, a BaseException base for Fenced, Contended, and Suspended, for the reason asyncio.CancelledError has one. Each says something about whether this pass may continue rather than about the work, so an except Exception written to handle a declined gateway must not absorb one. The case that forced it is a saga, whose except Exception compensates on failure: a Fenced forward run is not a failure but a lost race, and a loser that unwound would refund a charge the winner is still building on. That the rule is carried by the exceptions' own shape matters more once the saga is application code rather than a shipped runner, since the except it has to survive is one somebody else wrote. The worker has a matching arm, treating a claim lost mid-pass as the deferral it already applies to a claim refused up front, rather than as a workflow that failed.
  • without-durability: the two ways of waiting are separate types rather than one carrying a nullable deadline, on both sides of resume. Inside a pass, Suspended is the base of a ScheduledWakeup whose due is always present and an InputNeeded that carries none; coming back out, they are a Sleeping and a Waiting. It is the difference a driver has to branch on either way, so neither side makes it a field that is sometimes there.
  • integration: durable, the deployment half of the durable-workflow work, which is what without-durability deliberately does not ship. An order fulfilment graph (charge and reserve concurrently, ship, render) and its compensating rollback; a payout workflow written as ordinary code, with a data-dependent fan-out, a settlement window, and a human approval; the body the worker runs; and an HTTP API in front of it whose three endpoints run no workflow, since submitting an order and confirming a payout are the same arrive call and the workflow id is the request's Idempotency-Key. tests/durable/stores.py builds one Durable per store and one suite runs the same saga, the same suspension, and the same API-plus-worker flow against all four, so "a workflow cannot tell which store it got" is a claim the suite makes rather than a page asserts. Those tests drive real servers: the test recipe starts the new compose.yaml with docker or podman, whichever it finds, hands pytest each published address, and takes the stack down from an exit trap. They carry a compose mark and skip where neither is installed.
  • without: ticks(every), a Stream of moments, one now and one every interval after. It is the clock as a source, so periodic work stops being a while True with a sleep buried in it and becomes a Sink that says only what happens per event, composed with a stream that says when. waking and trimming are both sinks over it now, which means the same code runs off a timer, off a queue an operator pokes, or off a fixed list of instants in a test. Each tick carries its own moment, so a consumer needs no clock of its own and a test controls time by choosing values. An interval that is not positive is refused, as drive refuses a limit below one: taken literally it is a loop that yields as fast as its sink can consume, which pins a core to do housekeeping, and a duration read from a setting that was never set is how one arrives.
  • without-web: reverse routing. url_for(route, values) renders a route back to a concrete path from the values for its path parameters, the inverse of the trie walk. It is a plain function of the route value (routes are identified by value, no registry), each value fed back through its converter to prove it round-trips (parse, don't validate, in reverse). Because mount bakes any prefix into the route, a route is a self-contained value whose segments are its full path, so reversing needs no router and holds no hidden prefix: a handler links by referencing a route value (immutable), and a websocket handler reverses an HTTP route to link to its resource with the same call.
  • without-http: granular client request timeouts. A Timeout value bounds each phase independently (connect, read, write, pool), each a timedelta and an inactivity bound that re-arms on progress, disabled by default (a deadline is the caller's policy, not the transport's). Each axis applies through its own bound (connecting(), reading(), writing(), pooling()), so the axis-to-error mapping lives on Timeout rather than at every call site. A timeout raises a typed ConnectTimeout / ReadTimeout / WriteTimeout / PoolTimeout under HTTPTimeout (itself a TimeoutError), so a caller can tell how far the request got and retry the right ones. Also: per-host connection bounds and gating of HTTP/2 stream issuance against the server's SETTINGS_MAX_CONCURRENT_STREAMS. max_connections_per_host bounds concurrent HTTP/1.1 connections to one origin (the acquire-wait the pool axis guards); max_keepalive_per_host bounds how many idle connections are retained per origin once a burst subsides, so the pool ramps up under load but settles back down when quiet. Both unbounded by default, and must be >= 1 when set.
  • without-http: socket options on the client pool and on serving, as (level, option, value) triples built by pure producers and combined by concatenation, the way headers are: tcp_keepalive, send_buffer_size, and receive_buffer_size each describe one concern and know nothing of each other, so ConnectionPool(socket_options=tcp_keepalive() + send_buffer_size(1 << 16)) needs no merge step that understands what any of them mean. serving(socket_options=...) applies them to the listening socket, whose buffer sizes every accepted connection inherits. TCP keepalive is the default (socket_options=tcp_keepalive()), so the kernel probes an otherwise-idle pooled connection and drops it when a peer has vanished silently (a crash, a partition, a NAT dropping the flow), which a clean server-side close does not: that sends a FIN the pool already detects before reuse. This matters most because request timeouts are disabled by default, so nothing else would notice a dead idle socket until a request hung on it. Pass () for the kernel's own defaults.
  • without-asgi: file_response(path) streams a file as the ResponseStart + ResponseBody event stream a handler yields, with Content-Type guessed from the suffix (mimetypes.guess_file_type, overridable) and Content-Length from stat, the body read in chunk_size pieces off the event loop (asyncio.to_thread) so a large file is never buffered whole. It is a coroutine, not an async generator: awaiting it runs the stat up front, so a missing file raises FileNotFoundError before any ResponseStart is emitted and a handler can still answer a clean 404. Reads and writes are lockstep by default; wrap the result in spool for read-ahead.
  • without-asgi: headers, a module of pure functions over the raw ASGI header pairs (RawHeaders) rather than a wrapper type. get_all returns every value under a name as an immutable tuple and first the first (for singleton fields, where a duplicate is a protocol violation); add, replace, remove, subset, and merge are RawHeaders -> RawHeaders transforms. All match field names case-insensitively (RFC 9110) and preserve duplicates, so a multi-valued Set-Cookie survives intact. RawHeaders is the one representation the ASGI spec fixes on both edges, so operating on it directly keeps reads a scan and writes a straight pass-through, no value to wrap or unwrap.
  • without-web: once and optional, parse adapters for singleton request fields. Each lifts a one-value parse into the tuple-taking form query_param/header_param feed: once requires the value exactly once (returning V), optional allows zero or one (returning V | None, None when absent). A duplicated value raises ValueError in both (a duplicated singleton violates RFC 9110 §5.3). Reading a single value stays a policy the call site chooses rather than a second extractor.
  • without-web: ExtractionError, a ValueError subtype marking a request rejected while one of its typed values was being extracted. The query_param/header_param/body extractors raise it directly when their parse rejects (a once/optional cardinality check, a converter, a pydantic ValidationError), gathering at the raise site what a recover policy needs: field names the request part that failed (the parameter name, or None for the body) and cause carries the underlying error as a first-class value, so a policy matches case ExtractionError(cause=ValidationError()) for a 422 versus case ExtractionError() for a 400 naming the field, without reaching into __cause__. The router wraps any stray, unattributed ValueError (from a custom extractor or an into factory) as a backstop. Making the boundary a single matchable type is what lets a plain ValueError raised deeper in a handler surface as a 500 rather than masquerading as a client 400.
  • without-asgi: Content, a body paired with the headers that describe it, plus json_content and Response.from_content. Encoding a value produces two things that must travel together, the bytes and the content-type naming them, and every caller that separated them re-derived the same three lines: the app layer, the router's own tests, and every test that sent a JSON body each carried a private json_response. Content carries no policy, so json_content is one producer of it and a form or msgpack encoder is another, and the serializer stays an argument (json_content(order, dumps=...)) with the stdlib as the default, because a default should add no dependency. It is strict where JSON is (allow_nan=False, so a NaN fails at the sender) and leaves key order alone, since sorting is a policy some callers want and a cost every response would pay. Response.from_content(status, content, headers=...) layers the caller's headers over the content's, and without-http's request takes the same value as a request body, which is why it lives in the package both sides already depend on. This walks back without-web's "ships no json_response-style helper" stance on the narrow point of the shape: what a handler must not have imposed on it is the serializer, and that is still injected.
  • without-http: without_http.testing, three more Clients that reach an app (or nothing) without binding a socket. mock_client(handler) answers from a function, which is the whole of mocking once a client is one, with respond(...) building the canned response. asgi_client(app) builds an HttpScope from each request and drives app(scope, receive, send) directly, streaming: the head returns the moment the app sends http.response.start and body chunks cross a one-slot queue, so duplex handlers are testable, and the app's lifespan runs for the block through the same run_lifespan a server uses (which httpx.ASGITransport leaves to the caller). Its scope advertises http.response.trailers, the one extension in-memory delivery can honestly offer, since a ClientResponse carries trailing blocks through to read_with_trailers, so an app that negotiates trailers takes that path here. loopback_client(app) is serving minus asyncio.start_server: the real ConnectionPool and the real server, wired to each other over pipe(), two cross-wired StreamReaders with genuine backpressure, so framing, keep-alive, HTTP/2 by prior knowledge, and the server's crash-to-500 isolation all run with no port and no file descriptor. All three speak plain ASGI and plain request values, so they drive a FastAPI or Starlette app as readily as a without one, and base_url(...) composes on when a test would rather write "/items". Below the clients, served_pipe(app, ...) hands over the client end of a pipe() with the server on the other, for a conformance test that writes frames rather than requests (a malformed request line, an h2 preface followed by an illegal frame, a reset flood); it runs the lifespan and cancels the connection on exit as serving does, and the server presents as SERVER_ADDRESS (with AUTHORITY spelling the host:port bytes such a test writes into :authority or Host). without-http's own HTTP/1.1 and HTTP/2 server suites run on it, leaving a bound socket to the tests that need what only a kernel provides: TLS, socket options, and a third-party client.

Changed

  • without-http: a client is a function from a request to a response. The type formerly called ClientExchange is now Client, ConnectionPool satisfies it by being callable (await pool(request)), and the caller-facing surface is a free request(client, method, url, ...) context manager rather than a method on the pool. Everything the pool held that was not about connections has left it: middleware is gone, because a decorated client is just stack(add_headers(...), cookies(jar))(pool), and timeout is gone, because a deadline belongs to the caller rather than to the connection and now rides on ClientRequest.timeout (set it per call with request(..., timeout=...), or across a client with the new deadline(...) middleware, which fills in only a request that states no budget of its own). What is left on the pool is connections: TLS, HTTP/2, the per-host bounds, socket options, and the new injectable connect, which is the one step that touches the network. Migration is mechanical: pool.request(m, u, ...) becomes request(pool, m, u, ...), ConnectionPool(middleware=mw) becomes composing mw(pool) where the client is built, and ConnectionPool(timeout=t) becomes deadline(t)(pool).
  • without-asgi: a scope whose asgi key (or asgi["version"]) is missing parses as version "2.0" rather than raising KeyError, which is what the spec tells applications to assume. Real producers omit it: starlette's TestClient sends a lifespan scope with no asgi key at all, and a without app driven through it previously crashed on the first request.
  • without: the module holding the substrate is without.interfaces rather than without.contracts. Every name is re-exported from the package's top-level __init__, so from without import Processor is unaffected and only a direct submodule import has to change. The core called this idea a contract while the prose about it called it an interface, and one word is worth more than the shade of meaning each carried.
  • without-dag: Graph.node takes the node's key as its first argument (graph.node("charged", charge, order)), and NodeKey is a str rather than any Hashable. A key was previously an object() the builder minted, which is unique but means nothing on the other side of a crash; a name chosen in the source is what lets a run's (key, result) pairs be stored and handed back as a checkpoint, so the key had to become something a store can hold and a human can recognise in one. It must be distinct from every other key in the graph, and entries are keyed by position (input:0), which a node may not take. Existing graphs add a name per node call; nothing else about the builder changes.

  • without-web: the extractor context type Request is renamed RequestHead and no longer carries the request body. RequestHead is exactly the parsed head an extractor reads (scope, path params, query params), mirroring without-http's ResponseHead. It is now the top of a small context lattice each route builds concretely: HttpRequestHead (scope narrowed to HttpScope) for HTTP routes, WebsocketRequestHead (WebsocketScope) for websocket routes, and BufferedRequest (an HttpRequestHead plus the buffered body) for the buffered-HTTP path. Custom extractors typed on Request become RequestHead (or a narrower context if they read the concrete scope or body).

  • without-web: Extractor gains a request-context type parameter, Extractor[C, V] (was Extractor[V]), contravariant in C. This makes the wrong extractor on the wrong route a static type error rather than a runtime guard: a body token (Extractor[BufferedRequest, V]) on a streaming or websocket route, or an http_scope/websocket_scope on the wrong protocol, no longer type-checks, so the former runtime TypeError/ValueError guards in body/http_scope/ websocket_scope/handle_stream/ws are removed. Permissive tokens (path_param/query_param/header_param/catch_all) are Extractor[RequestHead, V] and still serve any route. A custom extractor annotated Extractor[V] must add its context: Extractor[RequestHead, V] for a scope/path/query read.
  • without-web: query and header extractor parse callbacks now receive an immutable tuple of values rather than a list (query_param, header_param, and the once/optional adapters), and RequestHead.query_params values are tuples. The parsed head is a value no consumer can mutate out from under another (values over places); a parse typed on list must widen to tuple.
  • without-core (imported as without): the buffer wiring connector is renamed spool, and its maxsize argument renamed ahead, so spool(source, ahead=n) reads as the read-ahead it is (drive a source ahead of its consumer through a bounded queue on a background task). Behavior is unchanged.

  • without-web: routing and mounting reworked around self-contained route values. mount(prefix, *middleware) and ws_mount(...) are transforms that bake the prefix (and per-route middleware) into routes, reusable and usable as decorators; delegate(prefix, app) and ws_delegate(...) mount an opaque BYO app as a black box with the prefix-trimmed scope. This replaces the former Mount/WebsocketMount wrapper (a transparent sub-router is now just its baked routes), so a route carries its own full path — matching, OpenAPI, and reverse routing all read it directly, and a nested opaque app is trimmed by its full accumulated prefix by construction. Reverse routing is now the free url_for function rather than a Router.url_for method plus a url_for() extractor injected through Match.

  • without-http: the client sends the request body concurrently with reading the response (consumer-driven duplex) instead of sending it whole first. A server can now answer early (a 413, a redirect) without deadlocking a large upload, and a caller can drive genuine bidirectional streaming over HTTP/2: the request head is sent before the first body chunk is produced, so both a client-speaks-first duplex (feed a queue-backed body in reaction to the response) and a server-speaks-first one (let the server respond before any body chunk is ready) work. Connection teardown is a single release-exactly-once path shared by the background sender and the response body. Closing an early-answered HTTP/1.1 connection is now a bounded lingering close (a half-close FIN plus a short, fixed drain window, never draining to end-of-input) rather than a reset that could race ahead of and discard the response the server already sent, and the client stops streaming its body the moment the peer half-closes rather than writing on into a closing connection. See the new Security page.

Fixed

  • without-durability-sqlite: Database.aclose(), and closing the connection any other way is now a documented mistake. sqlite3.close() frees the connection and finalizes its statements under any thread still executing one, which segfaults the process rather than raising, and Database.run makes that reachable by design: a cancelled caller unwinds immediately while its thread runs on, precisely so the connection is not handed to the next caller mid-transaction. A shutdown that follows a cancellation therefore closed on top of a statement in flight. It surfaced as an intermittently dying test worker, roughly one run in twenty-five, whenever a workflow's worker task was cancelled just before its store was torn down. aclose takes the same guard run releases from the thread, so the close waits the statement out; the guard is released afterwards, so a run arriving later fails loudly on a closed connection. The close itself runs on a thread like every other driver call, since under WAL it performs the final checkpoint (and, with synchronous=FULL, an fsync), which is blocking disk I/O the event loop should not carry.

  • without-http: an HTTP/1.1 connection is no longer dropped after every request whose app never read the body. h11 advances the client's state only as events are pulled, and an ASGI app may ignore receive entirely, so a body-less GET left its EndOfMessage unread and the request was indistinguishable from a peer still owing a body: it failed the keep-alive check and the connection closed. That hit any app that skips the body (FastAPI, on a request with no body parameter) on every request, and under load surfaced as a small fraction of requests never answered, the pooled-connection race of a client writing into a connection the server was concurrently closing. The events the app left unread are now consumed from h11's buffer once it responds. Only buffered bytes count: a NEED_DATA means the body genuinely has not arrived, so an early response to an in-flight body still correctly declines reuse and takes the lingering close.

  • without-asgi: make_asgi_app now closes the inbound stream when a connection handler exits, so a handler that abandons the request body early (reads part of it, then returns) has the inbound generator's finally run deterministically instead of leaving it suspended for garbage collection. This is the server-side mirror of the client folding connection release into its response-body generator; the handler's inbound stream is wrapped in aclosing, covering both the HTTP and WebSocket paths.

0.0.1

Added

  • without-core (imported as without): the narrow-waist core. The Stream / Processor / Context contracts, the builders (from_map, from_scan, from_sink, from_fold, and the polarity-dual predicate filters from_selector / from_filter), the wiring connectors (compose, which also composes a processor onto a terminal Sink; tee, its terminal fan-out counterpart, splitting a stream across several Sink branches so a shared prefix runs once; sample, stream_from_iterable, stream_from_queue, collect, buffer, stack), and the with-scoped task helpers (background_task, limit_concurrency, sleep_forever, cancel_futures, as_async_iterator).
  • without-env: a static Context loaded once from environment variables with pydantic-settings.
  • without-configmap: a behavior source backed by a Kubernetes ConfigMap mount, reloaded with watchfiles (watches the mount directory to catch the atomic ..data symlink swap).
  • without-asgi: adapters between an ASGI app's receive/send and typed event streams, complete in both the app and server directions, plus make_asgi_app and the unopinionated routing/middleware vocabulary.
  • without-web: an opinionated HTTP/WebSocket router with trie matching, typed path parameters, converters, extractors, 405-vs-404, mounting, scoped middleware, exception handlers, and structure-recovered OpenAPI.
  • without-http: an asyncio ASGI server and connection-pooling HTTP client built on the sans-IO h11/h2/wsproto state machines, serving HTTP/1.1, HTTP/2, and WebSockets (over the HTTP/1.1 upgrade), with TLS, keep-alive, streaming and buffered bodies, trailers, and client middleware.
  • without-dag: bounded-concurrency execution of DAG-shaped async workflows, a typed Graph builder, and a single-input CompiledGraph that lifts straight into a Processor via from_map.
  • without-logging: a logging pipeline. Stdlib log records parsed into immutable Record values at a capture boundary (stdlib as a one-way source), the message resolved and any exception captured as a structured TracebackException at that edge (no live traceback carried downstream, and its formatting left to the app), filtered with the core from_selector (plus the at_least level predicate) and enriched with add_fields, drained to a sink the app owns (or several at once, each with its own tail, through the core tee). Per-call-site context binds at the edge with the scoped bind(**fields) context manager and the merge_context Record -> Record enrichment composed into the default parser (the structlog-style bind_contextvars equivalent), since the pipeline runs off the caller's task and cannot recover it. Optional opt-in renderers render_json (fields flat) and render_console (human line) cover the common encodings without the core forcing one, with the timestamp and exception encodings injected: exception_to_dict (structured frames) or exception_to_text (flat traceback), and iso_timestamp by default. offload bridges a blocking worker onto a dedicated thread (delivering items in bursts, so the worker flushes when it catches up, no per-write thread hop) so file I/O stays off the event loop. Destination-shaped writers take strings (render a Record to text with a from_map(Record -> str) in front) and own the newline framing: to_rotating_file owns the byte count and clock, rotating on any combination of max_bytes (size), max_age (relative interval), and schedule (absolute wall-clock boundaries, built from times of day with at_times); to_stream writes to a caller-owned text stream (sys.stderr, a socket) without closing it.
  • Documentation site (mkdocs-material + mkdocstrings): narrative guides, an API reference recovered from the source docstrings, and a package dependency graph derived from the workspace pyproject.toml files.