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