Changelog¶
0.0.10¶
Added¶
without-asgi: the compressor factories take their codec's own constructor arguments, under the codec's own names:gzip_compressortakeszlib.compressobj'swbits,mem_level, andstrategy,zstd_compressortakeszstd.ZstdCompressor'soptionsandzstd_dict, andbrotli_compressortakesbrotli.Compressor'smode,lgwin, andlgblock.padded_gzip_compressor,padded_zstd_compressor, andwithout-http'sgzip_compress,zstd_compress, andbrotli_compresspass 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_compressorraisesValueErrorfor any configuration whose window exceeds the 8 MiB RFC 9659 allows, includingzstd_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 awindow_logto cap it is refused for the same reason. The check reads the window zstd resolved, once per configuration.without-http:gzip_compress,zstd_compress, andbrotli_compressbuild 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.claimtakes abudgetand analivewindow and lapses at whichever comes first: onealivepast its holder's last sign of life, or its budget running out. The worker renews on a tick (Checkpointer.renewalongside the newScheduler.extend, both on the scheduler'slease), so a pass that is still running keeps its workflow however short that window is, andwork(..., budget=...)caps how long any one pass may hold a workflow however alive it looks.Scheduler.extendreturns 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 owndonesilently 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:Runtakes anextend, andCheckpointer.claimtakes two durations where it took one. A store or a hand-builtRunfrom 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:transactno 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 aclaimfrom 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 withCheckpointer.history. It returns the recordsloadreturns, in the same order, each as aWrittencarrying the decoded value and an awareat. 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 localdatetime.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.deletecancels its wakeups and forgets its records,Checkpointer.discardandScheduler.cancelare the two halves for a deployment holding the stores separately, anddeletereturns how many records went, so a caller sweeping ids it is unsure about reads zero rather than an error.PostgresDurableandSqliteDurabledo the whole thing in one commit;SplitDurablecancels before it discards, which is the reverse ofarrive's order forarrive'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 nextload. Let workflows drain before upgrading, or delete their checkpoints. -
without-durability-postgresandwithout-durability-sqlite: theworkflow_checkpointtable takes awritten_atcolumn.CREATE TABLE IF NOT EXISTSleaves 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_atnow 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-writtenDeliverywas 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 fromnext_ready. -
without-durability:MemoryCheckpointer.hashesholdsStored(the encoding and when it landed) rather than the encoding alone, and the store takes anowargument, 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.appendissupply's sibling and the only difference between them is who picks the key:supplywrites under a name the caller brought, andappendwrites under one the store assigns, returning theEntrythat says where it went.Durable.deliverisarrive'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.receivereads the inbox past a cursor and suspends when there is nothing new,Run.pendingreads without suspending, and both take alimitso a consumer can take one entry and leave the rest.Run.awaitingis 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 ofwithout-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;@commandreturns anArmvalue and registers nothing, so a package can ship one and a consumer place it anywhere in a single edit. An@overloadladder ties each token's type to the handler parameter it fills, with no runtime introspection of a function signature, which is the mechanism that givestyperits ceiling.grouptakes an async-context-managerstateentered 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 whatclick's ambientContext.objis for. There is no separate root concept: a tree's top level is an ordinary group whose parent is the shell, which supplies theStreamsit derives from, so a CLI with no shared resource declares nostateand its commands receive thatStreamsdirectly rather than an ignored parameter. A state that writes output carries the streams onward by extendingStreamsor holding one, and forgetting is a static error. An option names where else its value may come from withsources=(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_argvis a total, pure function of argv, environment, and file contents returningBound | Answered | Rejected: it never exits, never prints, and extracts every value up front, so aBoundproves 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:answeredis the caller's list of spellings that stop the scan, andrunis the only place that names-h,--help, and--versionor decides what they mean, so a program wanting-?, ahelpsubcommand, or none of it writes its own shell and changes nothing below. Help is aUsagevalue 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 passingStreams.captured()rather than by capturing a process.without-streams:offloadmoves here fromwithout-logging, whose signature named onlySink,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 fromwithout_streams;without_loggingno 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_iterablepulls 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 mostaheaditems before backpressure reaches it. Handing over the whole loop rather than awaiting onenextat 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 hangasyncio.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 itsfinallyruns 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.stdinbeing the one this exists to wrap). It andoffloadshare 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 boundingoffloadwould 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 ownenumfor 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, sourl_forrenders/deploy/prodfor a plainEnumas much as for aStrEnum, wherestr(member)would have spelled the Python identifier. That isConverter's newrender,parse's inverse: it defaults tostr, 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:Waitingis replaced byBlocked, which reports every branch a pass stopped on rather than one of them, in two sets:waitingholds the addresses fromRun.awaiting, answered witharrive(workflow, key, value), andlisteningholds the read steps fromRun.receive, answered withdeliver(workflow, value).Outcomeis nowCompleted | Sleeping | Blocked, so a driver matching over it has a case to rename, whichassert_neverreports 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 theRunbefore raising, andRun.wakingis gone, folded into the oneRun.reachedthat all three waits use.stopped_atandunwoundtake that list in its place. Agatherof twoawaitingcalls now reports both keys, where it previously reported one:asyncio.gatherpropagates only the first exception, so the siblings never reachedresumeat all. -
without-durability: a workflow may no longer catch aSuspendedat all. A body that returns normally having reached a suspension raisesSwallowed, naming the keys, instead of reportingCompleted.asyncio.waitandgather(return_exceptions=True)capture exceptions as values rather than raising them, soSuspendeddescending fromBaseExceptionnever 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.loadnow 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 throughrecordand anything outside it throughsupply, 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:loadreturns adict, 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-partyCheckpointernow 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.migrateisCREATE TABLE IF NOT EXISTS, so an existing database keeps its old shape and everyloadagainst it then fails on the missingseqcolumn. Dropworkflow_checkpoint(or the whole database) before running 0.0.7. Both stores now carry an explicitseqforloadto order by: SQLite gives upWITHOUT ROWIDand names the rowid it already assigns asseq INTEGER PRIMARY KEY, moving(workflow, step)to aUNIQUEconstraint, and Postgres takes the number from aworkflow_seqsequence as the column'sDEFAULT. Declaring the column rather than ordering by the implicitrowidis what makes the guarantee survive aVACUUM, which SQLite documents as free to renumber the rowids of any table that has no explicitINTEGER 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. Postgresappendmints its inbox key from that same sequence, in onenextvalwritten as both the key and the row'sseq: 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 whileloadrendered them the other. One number is both, which is also why the column carries aDEFAULTrather 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 pasthash-max-listpack-entriesorhash-max-listpack-value, soHGETALLorder 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, sorecord,supply, andtransactare unchanged for callers and aLuaEffectneither writes a prefix nor sees one.appendtakes its key from the sameHLEN, 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 ownttl.without-web: two routes that differ only in what they name a path parameter are now the build-timeduplicate routeerror 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 bynamealone, so two converters sharing a name but not aparsemerged 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 nownameandparsetogether;schemastays out of it, along withrender, since neither takes part in matching and OpenAPI andurl_forread them off the route's own segments. A converter built by a factory should be@cached on its inputs, aschoiceis, 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_eventrenders one frame to bytes andparse_eventsisStream[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_streamis thefile_response-shaped server side, yielding oneResponseBodyper event withmore_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 justdata:: 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, becausestr.splitlinesalso 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 isServerSentEvent, a union of the only four frames that mean anything:Event(data, type, id)dispatches,Commentis the heartbeat,Retrysets the reconnection time, andCheckpointmoves 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 (anevent: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 narrowerReceived = ReceivedEvent | Retry | Checkpoint;Commentis outbound-only because the spec says to ignore comments, andparse_eventsdrops the directives whereparse_events_with_directiveskeeps them, the same split asResponseBody's trailers.EventandReceivedEventare separate types for the usual inbound/outbound reason, and differ in exactly one field:idis 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.typeneeds no such option, since the format cannot tell an absentevent:line fromevent: message, so it is a plainstron both and the encoder writes no line for the default.RetryandCheckpointare 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 bareCommentby 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 boundinganextwith 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 noid:still reports the stream's current one, and across connections, since both parsers take alast_event_idseeding the point a reconnect resumed from;retry:belongs to the stream; and anid: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 indatais carried by splitting it acrossdata:lines, which is what makes event injection structurally impossible where a hand-rolledf"data: {payload}\n\n"forges a whole event; a newline intypeoridhas no such spelling and raises at construction, before anything is committed to the wire. Both sides are stricter than the format about anid, which comes back on reconnect as aLast-Event-IDheader, 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 onlyNUL. 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. Aretry:too large to name a duration is bounded on both sides on the same terms, dropped by the parser and refused byRetry, since a frame encoding to bytes this library would not read is not one worth writing. The line carries whole milliseconds, soRetrytakes a count ofMillisecondsrather than atimedeltaand 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 rendersretry: 0, which does not mean "almost no wait" but "reconnect immediately".max_event_sizeis 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 onedata: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 carryingLast-Event-ID, so a caller sees one uninterrupted stream of events across however many connections it took.attemptis a function that opens one connection (lambda headers: client(ClientRequest("GET", url, headers))) rather than aClientRequest, because a request is not replayable: its body is aStream[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 aPOST(the shape MCP's Streamable HTTP uses) rather than only the bodylessGETa reused request survives. The resumption point advances on an event carrying anid:and on aCheckpoint, 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 asretry:, the resumption token asid:, and the terminal condition is in the protocol. A non-200or a content type other thantext/event-streamraisesNotAnEventStreamand 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'sretry:is clamped betweenminimum_reconnect(100ms) andmaximum_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 firstanext, by which point a request has already gone out.sleepis injected so a test drives the loop without waiting.without:close_stream(source), how a consumer releases a stream it abandons. AStreamis__aiter__-only, so a source may be a generator holding afinally(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:SecondsandMilliseconds, for the parameters whose duration has to cross a boundary carrying whole units of one. Atimedeltanames 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 SSEretry:line and SQLite'sbusy_timeoutcarry integer milliseconds, and each truncates whatever it is handed. Truncation is worst where it is least visible, half a millisecond ofretry:becomingretry: 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.durationis thetimedeltaback out andofis the one way in from one, which is the only place the question of whether it divides is ever asked.
Changed¶
without-coreis renamedwithout-streams, and its import name moves fromwithouttowithout_streams. Installwithout-streamsinstead ofwithout-core, and rewritefrom without import ...asfrom without_streams import ...(likewise for the.interfacesand.wiringsubmodules;.tasksand.testingmove further, see below).corenamed 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, andwithout-htmlis already a member of the family that depends on none of this.streamsnames 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 aContextis defined as a stream sampled for its latest value, soStreamis the primitive the rest is derived from. With the rename everywithout*distribution name now matches its import name, so no package needs a[tool.uv.build-backend] module-nameoverride and no distribution claims the barewithoutname, 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, andlimit_concurrencyare imported fromwithout_asyncrather thanwithout_streams, as areyield_onceandresolved_next_turnfromwithout_async.testing; the names, signatures, and behavior are unchanged, andwithout-streamsno longer re-exports them. Applying the same test that produced the rename finds these do not belong under a name that saysstreams: not one of their signatures mentions aStream, aProcessor, or aContext, andwithout-streamsitself only reaches forbackground_taskto runsample's drain. Keeping them together made the dependency graph say things that were not true, most visiblywithout-durability-sqlitedepending on the whole substrate to reach abusy_timeoutcount and a test helper; it now depends onwithout-asyncalone. 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_keepalivetakesidleandintervalas counts ofSecondsrather thantimedeltas. 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:connecttakestimeoutas a count ofMilliseconds, the unitPRAGMA busy_timeoutcarries, so a duration finer than the pragma can express is no longer silently truncated into it.
Fixed¶
without-asgi:make_asgi_appcloses 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 thesendthat fails, and a streaming handler's ownfinallynow 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_foris the whole of RFC 9110 §13 and §14 as one pure function of a size, two validators, and the request's headers, returningWhole | 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_forturns that decision into theResponseStartannouncing it,describingassembles the header pair a200states and a304repeats, andno_bodyis 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.Headis its own arm so aHEADnever reads the representation to produce bytes the transport is required to drop, which is what keepscurl -Iand an uptime check from costing a full read of whatever they name; it announces exactly what a200would,Content-Lengthincluded.serve_file(scope, path)isfile_response's request-aware sibling for one named file, answering200,206,304, and416, with thestatstill on theawaitso 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; passetagwhen you hold something better. Only single ranges are honored:multipart/byterangesis most of the cost for a case almost nothing sends, and §14 permits ignoring aRange, 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_responsekeeps its job, content with no cacheable identity, where a validator that changes every request buys nothing.serve_fileandinventoryserve a resource, so they use the content codingmimetypes.guess_file_typereports alongside the media type, and alogo.svgzgoes out asimage/svg+xmlwithContent-Encoding: gziprather than as gzip bytes a browser tries to render as an image.file_responsehands 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.gzisapplication/gzip): asking for an archive and saving raw tar bytes under its name is the ApacheAddEncoding .gzfailure, and not one a download helper should have. A suffix naming only a coding (archive.gz) is an opaque download either way, and an explicitcontent_typesuppresses the coding entirely. Both helpers take acharset, defaulting toutf-8asinventorydoes, 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 toAsset, andserve_assetanswers 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 relative302to/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 thestatbefore anyResponseStartraisesAssetChangedrather than framing a body whose length and validator describe different bytes. The payoff is a shorter request path too: a304is answered from memory with no syscall at all. Validators default tocontent_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_mtimecosts nothing for a tree too large to read at startup and rests on the no-writes contract instead. Neither carriesst_ino, which is what Apache'sFileETagdefault leaked in CVE-2003-1418. Response policy headers are oneheadersargument, prepended to what a200announces and to what a304repeats alike, since a browser reading a response back out of cache needs the policy too. They default toSTATIC_ASSET_HEADERS,REVALIDATE_CACHE_CONTROL(public, no-cache) plusnosniff, 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_CONTROLis exported to opt into with the ordinaryheadershelpers 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 a304and keep bytes from a different representation, and a304repeats both that coding and the media type, which is what lets a downstreamcompresstell 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 nextIf-Rangerefetches the whole thing.Vary: Accept-Encodinggoes 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: anapp.css.bris never published as an asset of its own (brotli bytes labelledtext/css) merely because brotli is not among them, while adata.tar.gzbeside its owndata.tarkeeps its URL, since a media type that is never compressed has no variant for it to become and dropping it would be a silent404for 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 aRangeover a compressed asset work, which on-the-fly compression cannot do at all, since it has no way to restate aContent-Rangecomputed over identity bytes.without-web:static_files(prefix, assets), aGET/HEADroute serving anInventory. The catch-all remainder is the inventory key, and the returnedRouteis an ordinary value carrying its complete segments, so it reverses throughurl_forwith no router involved andmountrebases it like any other route. The split follows the placement rule: deciding between200,206,302,304, and416needs 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'sfallbackinstead, 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 purerender(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 separateVoidElementtype with no children field at all, and a raw-text element takesMarkup | 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;noscriptis 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'sMarkupis what renders verbatim, kept under its own name so a fragment from Jinja or tdom already is one, alongside anything else carrying__html__(Django'sSafeStringincluded). Attribute names pass through a mapping verbatim, sohx-get,data-*,aria-*, and SVG'sviewBoxneed no mangling convention;classis the one nameattrsrejects, since classes have theclsargument 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 readsClassandclassas one attribute. An element is changed withwith_attributesandwith_children, which take the arguments the constructors take, so a transform goes through the same escaping the tree was built with;dataclasses.replaceon 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)andvoid_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 thanIterableis what keeps aMapping(which would render only its keys) and aset(which would render in an order that varies between processes) from type-checking there.clsnames the same two, and dropsNoneand empty entries socls=("card", "card-active" if active else None)needs no filtering around it. That spelling is the only one: aMappingjoins its keys, socls={"card": True, "active": False}, the shapeclassnamesandclsxmade 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 ofwithout-htmlagainst 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 fourthContentproducer alongsidejson_content,form_content, andmultipart_content. It takes astr, 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-sidedecompressandcompressingopened.without_asgi.compression.compress()is anHttpMiddlewarethat reads a request'saccept-encodingand 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. TheCompressorprotocols and thegzip_compressor/zstd_compressor/brotli_compressorfactories now live here rather than inwithout-http, which re-exports them, so one codec serves a coding in both directions.brotli_compressordefaults toDYNAMIC_BROTLI_QUALITY(5) rather than the bindings' 11, since a table entry encodes a response per request; the request-sidebrotli_compresskeeps 11, where a client compressing one upload makes the ratio worth its cost. This addsbrotlitowithout-asgi's dependencies, whichPHILOSOPHY.mdnow 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 readsgzip;q=0, which refuses gzip, as asking for it. Hereq=0excludes, 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 noaccept-encodingis answered unencoded although rule 1 would permit any coding, and one that refuses identity while accepting nothing available still gets identity rather than a406.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 toMAX_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, sobris 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 keepDEFAULT_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 whatis_compressibleexcludestext/event-streamfor, and the property is the streaming rather than the media type, so a streamedtext/htmlpage mixing a secret with reflected input belongs off the middleware or behind acompressiblethat rejects its type.without-asgi:Vary: Accept-Encodingon every responsecompresscould 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 the304that revalidates one, which RFC 9110 §15.4.5 asks to carry the fields its200would have and namesVaryamong them because that is how a shared cache picks the stored variant to update. A strongetagis weakened toW/when the body is encoded, per RFC 9110 §8.8.1: weak comparison still matches the two representations,Rangecorrectly stops matching. The304inherits that weakening for a client whoseaccept-encodingnegotiates a coding, since the stored entry it updates is then the encoded variant and RFC 9111 §4.3.4 has the cache copy the304's fields onto it; a strong tag landing there would let a laterIf-Rangematch under strong comparison and stitch identity range bytes into an encoded body. Which stored200a304updates is usually unknowable, since §15.4.5's field list omitscontent-typeand most304s carry none, so the candidate is assumed; a304that names a type no coding applies to, or acontent-encodingthe 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 a304may 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 the304'scontent-lengthgoes with it, since §8.6 permits one only where it equals what a200to the same request would have carried, which for that client is the encoded variant. A206is never encoded: itscontent-rangenames 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 declaredcontent-lengthanswers 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 exactcontent-lengthfor 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 aHEADresponse comes to answer with the size of an empty encoded stream in place of the size of the body aGETwould carry.weigh_undeclared_bodiesdecides 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 untilminimum_sizeof 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 acontent-length, which answers the floor for nothing, asfile_responsedoes. An offloaded body is the one shape that cannot follow a commitment, sincehttp.response.zerocopysendandhttp.response.pathsendboth send bytes the middleware never sees and the former carriesmore_bodyso it can follow body events already sent: arriving before any body event an offload passes through unencoded, and arriving after the head has declaredcontent-encodingit raisesOffloadedBodyAfterEncodingrather 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, theCompressorthat can be flushed without being ended, and whatcompressneeds to encode a response that is still streaming. What acompresscall 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 ownflush); a coding whose factory produces a plainCompressorstill encodes responses that arrive whole, and its streaming ones go out unencoded, which costs bytes rather than delivery.gzip_compressorandzstd_compressorare public alongsidebrotli_compressorfor the same reason:zlib.compressobjandzstd.ZstdCompressorspell a block flush as a mode argument rather than a method, so a table built from them directly satisfiesCompressoralone and would take the buffered path for every stream.
Fixed¶
without-http:compressingno 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 fromwithout-asgi'sStreamingCompressorfactories (gzip_compressor,zstd_compressor,brotli_compressor) andcompressingflushes 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. Amake_compressorproducing a plainCompressorstill 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()offersaccept-encodingoutbound 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 theaccept-encodingoffer 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-encodingandcontent-lengthdescribed 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 raisesConnectionErrorrather 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 offeringaccept-encodingat all.without-http: request compression, the same mechanism pointed the other way.compressingis the middleware over any coding and aCompressorfactory, withgzip_compress,zstd_compress, andbrotli_compressas 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 toadd_headersfor a field RFC 9110 allows only once.add_headerscopies its headers onto every request whatever it already carries, which is right for a field that may repeat and wrong forauthorizationoruser-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_headersadds 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 positiondeadlinetakes on a time budget.without-http:basic_auth(username, password)andbearer_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 aredefault_headersunderneath, so a request carrying its ownauthorizationkeeps 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), andUSER_AGENTas the library's ownwithout-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 isdefault_headersunderneath too: a request carrying its ownuser-agentkeeps it.without-http: Happy Eyeballs on by default, and resolution as an injectable step.tcp_connect(resolve=..., happy_eyeballs_delay=...)builds the pool's defaultConnect: 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. SplittingResolveout 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 plainloop.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 ASGItlsextension 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, andparse_tlsfinally 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_certandcipher_suiteareNone, which the spec permits and which is a CPython limit rather than a shortcut: anssl.SSLContextnever exposes the certificate it loaded, andSSLObject.cipher()reports a suite by name with no IANA identifier.client_cert_errorisNonebecause 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_bytesis 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 a431, andmax_header_list_bytesis advertised over HTTP/2 asMAX_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 onserving,served_pipe, andloopback_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_hinton HTTP scopes,websocket.http.responseon WebSocket scopes, andtlson 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; awithout-asgiapp speaks the typed vocabulary directly and never had to check. An HTTP/1.0 request is the exception: RFC 8297 §2 forbids a103to 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-memoryasgi_clientalready advertisedhttp.response.trailers, so the wire scopes are what changed.without-asgi:form_contentandmultipart_content, joiningjson_contentas producers of the sameContentvalue, plusFilePartandStreamingContent. A multipart body streams its file parts rather than buffering them, which is why it is aStreamingContent: the shape follows the size of what it carries rather than being uniform for its own sake. Both work as a request body throughwithout-http'srequestand as a response body, sinceContentis the shared vocabulary of the package both sides depend on.- Documentation: Alternatives, a
feature-by-feature register of
without-httpagainst 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:servingno 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, whichasyncio.start_servercalls 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 unraisableResourceWarnings 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, soclose()alone left the descriptor with the transport until the process ended, and the wait for it blockedserving's shutdown indefinitely. The wait is now bounded byclose_timeout(5 seconds, a newservingargument) 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 thepy.typedmarker, 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(...)andrun.stream(...)take acheckpointof{node key: result}, the same mappingstreamemits, 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, sonodenow takes one as its first argument (graph.node("charged", charge, order)) andNodeKeyis astr. A name chosen in the source means the same thing on the other side of a crash, where anobject()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.streambeing 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_durablydrives awithout-dagCompiledGraph, 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 anexcept Exceptionaround 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).stepwiseneeds 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.resumereports that as anOutcome(Completed,Sleeping, orWaiting) rather than raising, so a driver matches over three values and closes withassert_neverinstead of writing anexceptno type checker can call incomplete; the worker does exactly that. Inside a workflow a suspension is still an exception (Suspended, and itsScheduledWakeup/InputNeededcases), because that is the only way to stop in the middle of straight-line code, and it descends fromBaseExceptionso anexcept Exceptionaround a step cannot swallow it.without-durability: theCheckpointer,Scheduler, andDurableinterfaces, which are where the guarantee lives. A protocol ofloadandrecordis 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.claimtakes the right to run a pass and every write carries thePassit 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).recordnever overwrites a recorded step and returns aRecorded, 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.supplyis 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.Durablebundles the two stores and names the transitions crossing them, soarrive(workflow, key, value)is one call rather than two writes in an order the caller has to get right:SplitDurablecomposes 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.stepruns an effect and then writes the record, so a crash between them repeats it;transacthands 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.Checkpointeris therefore generic over the effect type a store can commit, defaulting toNeverso a store with nothing to offer makestransactuncallable 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, andpasses,ready, andwakingas theSink-over-Streampieces it composes. A worker runs up toPOOLpasses at once throughwithout'slimit_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'sOutcome, closed withassert_never: aSleepingis scheduled, aWaitingis left for whoever owes the value to queue, aCompletedneeds 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=...)):workreads 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 towork(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 reasonwake_duealready 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, andLuaEffectis what this store can commit alongside a record. The fencing token ismax(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.RedisStreamScheduleris 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.RedisSetScheduleris 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.trimbounds the stream withXTRIM ... MAXLEN 0 ACKED(Redis 8.2+), so the server decides what every group has finished with; it refuses a stream with no groups, whereACKEDhas 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, withSqlEffectas the effect typetransacttakes 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 whoseDO UPDATEcarries aWHEREon the lease;recordis aFOR UPDATECTE 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 withFOR 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.PostgresDurablemakesarriveone 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, thatnext_readystill polls (LISTEN/NOTIFYwould close that and does not yet), and thatmigrateis threeCREATE TABLE IF NOT EXISTSunder 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 IMMEDIATEis the exclusion, so it needs neither Postgres'sFOR UPDATEnor 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 isasync, because the whole transaction runs on one worker thread.connectopens withsynchronous=FULLrather than the usualNORMAL, 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, whichrequires-pythoncannot express: on Linuxsqlite3links whateverlibsqlite3the distribution ships.without-durability:CheckpointCodec, the interface deciding what a step's result becomes in a store, withJsonCodecover 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 valuejson.dumpshas 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 equalx, or a resumed pass reads something the first pass never wrote, andencodeMUST be deterministic, becauserecorddecides who won a race by comparing encodings.PostgresCheckpointernarrows the choice to codecs producing JSON text, since that is what ajsonbcolumn takes; keeping the column buys the indexing and the operators, and the codec still owns the value mapping.MemoryCheckpointerapplies 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 meansload.without-durability: every durable read names its parser.Run.step,Run.transact, andRun.awaitingtake aparse: Callable[[object], T]and return aTa 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.sleeprecords an ISO string and reads back adatetime, which is the ordinary case and not the exception.without-durability:run_durablyrefuses 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 wherestepwisehas 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-dagis untouched, and the split is the general rule rather than a convenience: verifying beats parsing whenever the caller still holds what it sent, andRun.awaitingis exactly the case that does not, since it reads a value another process wrote.without-durability:Interruption, aBaseExceptionbase forFenced,Contended, andSuspended, for the reasonasyncio.CancelledErrorhas one. Each says something about whether this pass may continue rather than about the work, so anexcept Exceptionwritten to handle a declined gateway must not absorb one. The case that forced it is a saga, whoseexcept Exceptioncompensates on failure: aFencedforward 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 theexceptit 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 ofresume. Inside a pass,Suspendedis the base of aScheduledWakeupwhosedueis always present and anInputNeededthat carries none; coming back out, they are aSleepingand aWaiting. 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 whatwithout-durabilitydeliberately 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 samearrivecall and the workflow id is the request'sIdempotency-Key.tests/durable/stores.pybuilds oneDurableper 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: thetestrecipe starts the newcompose.yamlwith docker or podman, whichever it finds, hands pytest each published address, and takes the stack down from an exit trap. They carry acomposemark and skip where neither is installed.without:ticks(every), aStreamof moments, one now and one every interval after. It is the clock as a source, so periodic work stops being awhile Truewith asleepburied in it and becomes aSinkthat says only what happens per event, composed with a stream that says when.wakingandtrimmingare 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, asdriverefuses alimitbelow 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). Becausemountbakes 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. ATimeoutvalue bounds each phase independently (connect,read,write,pool), each atimedeltaand 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 onTimeoutrather than at every call site. A timeout raises a typedConnectTimeout/ReadTimeout/WriteTimeout/PoolTimeoutunderHTTPTimeout(itself aTimeoutError), 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'sSETTINGS_MAX_CONCURRENT_STREAMS.max_connections_per_hostbounds concurrent HTTP/1.1 connections to one origin (the acquire-wait thepoolaxis guards);max_keepalive_per_hostbounds 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>= 1when set.without-http: socket options on the client pool and onserving, as(level, option, value)triples built by pure producers and combined by concatenation, the way headers are:tcp_keepalive,send_buffer_size, andreceive_buffer_sizeeach describe one concern and know nothing of each other, soConnectionPool(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 aFINthe 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 theResponseStart+ResponseBodyevent stream a handler yields, withContent-Typeguessed from the suffix (mimetypes.guess_file_type, overridable) andContent-Lengthfromstat, the body read inchunk_sizepieces 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 thestatup front, so a missing file raisesFileNotFoundErrorbefore anyResponseStartis emitted and a handler can still answer a clean404. Reads and writes are lockstep by default; wrap the result inspoolfor read-ahead.without-asgi:headers, a module of pure functions over the raw ASGI header pairs (RawHeaders) rather than a wrapper type.get_allreturns every value under a name as an immutable tuple andfirstthe first (for singleton fields, where a duplicate is a protocol violation);add,replace,remove,subset, andmergeareRawHeaders -> RawHeaderstransforms. All match field names case-insensitively (RFC 9110) and preserve duplicates, so a multi-valuedSet-Cookiesurvives intact.RawHeadersis 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:onceandoptional, parse adapters for singleton request fields. Each lifts a one-valueparseinto the tuple-taking formquery_param/header_paramfeed:oncerequires the value exactly once (returningV),optionalallows zero or one (returningV | None,Nonewhen absent). A duplicated value raisesValueErrorin 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, aValueErrorsubtype marking a request rejected while one of its typed values was being extracted. Thequery_param/header_param/bodyextractors raise it directly when theirparserejects (aonce/optionalcardinality check, a converter, a pydanticValidationError), gathering at the raise site what arecoverpolicy needs:fieldnames the request part that failed (the parameter name, orNonefor the body) andcausecarries the underlying error as a first-class value, so a policy matchescase ExtractionError(cause=ValidationError())for a 422 versuscase ExtractionError()for a 400 naming thefield, without reaching into__cause__. The router wraps any stray, unattributedValueError(from a custom extractor or anintofactory) as a backstop. Making the boundary a single matchable type is what lets a plainValueErrorraised 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, plusjson_contentandResponse.from_content. Encoding a value produces two things that must travel together, the bytes and thecontent-typenaming 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 privatejson_response.Contentcarries no policy, sojson_contentis 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 aNaNfails 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, andwithout-http'srequesttakes the same value as a request body, which is why it lives in the package both sides already depend on. This walks backwithout-web's "ships nojson_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 moreClients 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, withrespond(...)building the canned response.asgi_client(app)builds anHttpScopefrom each request and drivesapp(scope, receive, send)directly, streaming: the head returns the moment the app sendshttp.response.startand body chunks cross a one-slot queue, so duplex handlers are testable, and the app's lifespan runs for the block through the samerun_lifespana server uses (whichhttpx.ASGITransportleaves to the caller). Its scope advertiseshttp.response.trailers, the one extension in-memory delivery can honestly offer, since aClientResponsecarries trailing blocks through toread_with_trailers, so an app that negotiates trailers takes that path here.loopback_client(app)isservingminusasyncio.start_server: the realConnectionPooland the real server, wired to each other overpipe(), two cross-wiredStreamReaders with genuine backpressure, so framing, keep-alive, HTTP/2 by prior knowledge, and the server's crash-to-500isolation 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 awithoutone, andbase_url(...)composes on when a test would rather write"/items". Below the clients,served_pipe(app, ...)hands over the client end of apipe()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 asservingdoes, and the server presents asSERVER_ADDRESS(withAUTHORITYspelling thehost:portbytes such a test writes into:authorityorHost).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 calledClientExchangeis nowClient,ConnectionPoolsatisfies it by being callable (await pool(request)), and the caller-facing surface is a freerequest(client, method, url, ...)context manager rather than a method on the pool. Everything the pool held that was not about connections has left it:middlewareis gone, because a decorated client is juststack(add_headers(...), cookies(jar))(pool), andtimeoutis gone, because a deadline belongs to the caller rather than to the connection and now rides onClientRequest.timeout(set it per call withrequest(..., timeout=...), or across a client with the newdeadline(...)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 injectableconnect, which is the one step that touches the network. Migration is mechanical:pool.request(m, u, ...)becomesrequest(pool, m, u, ...),ConnectionPool(middleware=mw)becomes composingmw(pool)where the client is built, andConnectionPool(timeout=t)becomesdeadline(t)(pool).without-asgi: a scope whoseasgikey (orasgi["version"]) is missing parses as version"2.0"rather than raisingKeyError, which is what the spec tells applications to assume. Real producers omit it: starlette'sTestClientsends a lifespan scope with noasgikey at all, and awithoutapp driven through it previously crashed on the first request.without: the module holding the substrate iswithout.interfacesrather thanwithout.contracts. Every name is re-exported from the package's top-level__init__, sofrom without import Processoris 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.nodetakes the node's key as its first argument (graph.node("charged", charge, order)), andNodeKeyis astrrather than anyHashable. A key was previously anobject()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 acheckpoint, 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 pernodecall; nothing else about the builder changes. -
without-web: the extractor context typeRequestis renamedRequestHeadand no longer carries the request body.RequestHeadis exactly the parsed head an extractor reads (scope, path params, query params), mirroringwithout-http'sResponseHead. It is now the top of a small context lattice each route builds concretely:HttpRequestHead(scope narrowed toHttpScope) for HTTP routes,WebsocketRequestHead(WebsocketScope) for websocket routes, andBufferedRequest(anHttpRequestHeadplus the bufferedbody) for the buffered-HTTP path. Custom extractors typed onRequestbecomeRequestHead(or a narrower context if they read the concrete scope or body). without-web:Extractorgains a request-context type parameter,Extractor[C, V](wasExtractor[V]), contravariant inC. This makes the wrong extractor on the wrong route a static type error rather than a runtime guard: abodytoken (Extractor[BufferedRequest, V]) on a streaming or websocket route, or anhttp_scope/websocket_scopeon the wrong protocol, no longer type-checks, so the former runtimeTypeError/ValueErrorguards inbody/http_scope/websocket_scope/handle_stream/wsare removed. Permissive tokens (path_param/query_param/header_param/catch_all) areExtractor[RequestHead, V]and still serve any route. A custom extractor annotatedExtractor[V]must add its context:Extractor[RequestHead, V]for a scope/path/query read.without-web: query and header extractorparsecallbacks now receive an immutabletupleof values rather than alist(query_param,header_param, and theonce/optionaladapters), andRequestHead.query_paramsvalues are tuples. The parsed head is a value no consumer can mutate out from under another (values over places); aparsetyped onlistmust widen totuple.-
without-core(imported aswithout): thebufferwiring connector is renamedspool, and itsmaxsizeargument renamedahead, sospool(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)andws_mount(...)are transforms that bake the prefix (and per-route middleware) into routes, reusable and usable as decorators;delegate(prefix, app)andws_delegate(...)mount an opaque BYO app as a black box with the prefix-trimmed scope. This replaces the formerMount/WebsocketMountwrapper (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 freeurl_forfunction rather than aRouter.url_formethod plus aurl_for()extractor injected throughMatch. 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 (a413, 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-closeFINplus 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, andDatabase.runmakes 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.aclosetakes the same guardrunreleases from the thread, so the close waits the statement out; the guard is released afterwards, so arunarriving 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, withsynchronous=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.h11advances the client's state only as events are pulled, and an ASGI app may ignorereceiveentirely, so a body-lessGETleft itsEndOfMessageunread 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 fromh11's buffer once it responds. Only buffered bytes count: aNEED_DATAmeans 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_appnow 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'sfinallyrun 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 inaclosing, covering both the HTTP and WebSocket paths.
0.0.1¶
Added¶
without-core(imported aswithout): the narrow-waist core. TheStream/Processor/Contextcontracts, the builders (from_map,from_scan,from_sink,from_fold, and the polarity-dual predicate filtersfrom_selector/from_filter), the wiring connectors (compose, which also composes a processor onto a terminalSink;tee, its terminal fan-out counterpart, splitting a stream across severalSinkbranches so a shared prefix runs once;sample,stream_from_iterable,stream_from_queue,collect,buffer,stack), and thewith-scoped task helpers (background_task,limit_concurrency,sleep_forever,cancel_futures,as_async_iterator).without-env: a staticContextloaded once from environment variables withpydantic-settings.without-configmap: a behavior source backed by a Kubernetes ConfigMap mount, reloaded withwatchfiles(watches the mount directory to catch the atomic..datasymlink swap).without-asgi: adapters between an ASGI app'sreceive/sendand typed event streams, complete in both the app and server directions, plusmake_asgi_appand 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: anasyncioASGI server and connection-pooling HTTP client built on the sans-IOh11/h2/wsprotostate 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 typedGraphbuilder, and a single-inputCompiledGraphthat lifts straight into aProcessorviafrom_map.without-logging: a logging pipeline. Stdlib log records parsed into immutableRecordvalues at acaptureboundary (stdlib as a one-way source), the message resolved and any exception captured as a structuredTracebackExceptionat that edge (no live traceback carried downstream, and its formatting left to the app), filtered with the corefrom_selector(plus theat_leastlevel predicate) and enriched withadd_fields, drained to a sink the app owns (or several at once, each with its own tail, through the coretee). Per-call-site context binds at the edge with the scopedbind(**fields)context manager and themerge_contextRecord -> Recordenrichment composed into the default parser (the structlog-stylebind_contextvarsequivalent), since the pipeline runs off the caller's task and cannot recover it. Optional opt-in renderersrender_json(fields flat) andrender_console(human line) cover the common encodings without the core forcing one, with the timestamp and exception encodings injected:exception_to_dict(structured frames) orexception_to_text(flat traceback), andiso_timestampby default.offloadbridges 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 aRecordto text with afrom_map(Record -> str)in front) and own the newline framing:to_rotating_fileowns the byte count and clock, rotating on any combination ofmax_bytes(size),max_age(relative interval), andschedule(absolute wall-clock boundaries, built from times of day withat_times);to_streamwrites 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.tomlfiles.