without_streams¶
The sans-IO stream-processor substrate for without: streams, processors, contexts, and the wiring between them.
without_streams
¶
Context
¶
Bases: Protocol
A stream viewed as its latest value: the "behavior" half of the model.
Where consuming a stream sees every event, current samples the latest
and never blocks. This is how long-lived state (config, a connection pool) is
read: a context is just another processor's output that a reader samples
rather than consumes. current MUST return a value; a context is never
"not ready". The reader only ever gets a value, never a writable place.
Processor
¶
Bases: Protocol
A transformation from a stream of inputs to a stream of outputs.
This is the only thing a user writes, and the only node type: a processor's output stream becomes another processor's input stream, all the way down.
I/O is decoupled, not forbidden. A processor MAY await I/O while
handling an event (a database query, a closed-lifespan sub-request), reading
its dependencies from injected Context values; this is why a scan's
step is async. The point is not to ban I/O but to separate it into the
right abstractions so the parts stay reusable: sources at the edge, behaviors
via sample, effects contained in the step. The one rule: an effect MUST
NOT escape the entrypoint. A processor awaits its I/O to completion and MUST
NOT hand a half-open resource (an open socket, an unfinished task it does not
own) back to the runtime. Testing injects fake Context dependencies.
Stream
¶
Bases: Protocol
An asynchronous sequence of values.
A stream is the single shape every connection has. Sources that touch the outside world (a socket, a file watcher, a clock) are streams too: a stream is just the one shape every connection takes, whoever does the I/O.
Transition
dataclass
¶
The result of folding one event into a scan's state.
A value, never a place: a step returns the next state and the single
output it emits, and mutates nothing the caller can observe. Splitting
one event into several outputs is a wiring-style concern, not a per-step
one, so a transition carries one output rather than a collection.
Sample
dataclass
¶
updated
async
¶
Wait for the drain to publish the next value, then return it.
The deterministic counterpart to current on the behavior edge: where
current reads the latest value and never blocks, updated blocks until
the background drain consumes and publishes the next value from the
source, then returns it. It is the "await next update" signal a reader
waits on (a test asserting on post-reload state, a control loop reacting
to a config change) instead of guessing how long the background task
needs. If the source raises instead of yielding, the wait raises that
error rather than hanging, and the failure is terminal: once the source
has failed, every later call re-raises it rather than registering a
waiter that can never resolve. If the context closes first, the wait is
cancelled.
Each call registers its own one-shot future resolved by the next publish,
so concurrent waiters are independent: cancelling one deregisters it at
once and never disturbs another. Like current, it inherits latest-wins: a waiter sees only
publishes after it starts waiting, and a source that publishes faster
than the reader re-arms collapses the values it missed. So updated is a
"the state has moved on" signal, not a way to observe every value;
consume the stream for that.
from_filter
¶
Build a processor that drops events for which reject is true and keeps the rest.
from_selector with the opposite polarity: where a selector keeps the
matching subset, a filter removes it (filtering those events out). The two
are duals, from_filter(reject) being from_selector of the negated
predicate, and both exist because naming the intent positively at the call
site (from_filter(is_health_check), from_selector(is_error)) reads better
than threading a negation through a predicate. Note this is the opposite
polarity to Python's built-in filter, which keeps matches, as
from_selector does; from_filter is itertools.filterfalse. Like
from_selector, the predicate is async.
from_fold
¶
Build a leaf that folds a stream of events into a single final state.
The stateful terminus, dual to from_scan: where from_scan threads
state and emits an output every step (a scan), from_fold threads state
and yields only the final accumulated value when the stream ends (a true
reduce). The step MAY await contained I/O, so a fold whose result you
ignore is also how you run a stateful consumer for its effects.
from_map
¶
Build a processor from a stateless step: each event maps to one output.
The counterpart to from_scan for a processor that holds no state.
Each event is handled independently of every other, so there is no
initial to seed and no Transition to thread: step maps an event
straight to its single output. Like from_scan the step is async
so it MAY await contained I/O, and the effect MUST complete within each
call (see Processor). Splitting one event into several outputs is a
separate, wiring-style concern, not a per-step one, so the step returns a
single value rather than a collection.
from_scan
¶
from_scan(
initial: S,
step: Callable[
[In, S], Awaitable[Transition[S, Out]]
],
) -> Processor[In, Out]
Build a processor from a stateful step that emits an output every event.
step is the kernel: given an event and the current state it returns the
next state and the output it emits. It is async so it MAY await contained I/O
(reading dependencies from Context values captured by closure), but a
step that does no I/O is just an async def that never awaits.
from_scan supplies the loop that threads state across the input stream,
emitting one output per event: a scan, not a reduce (the collapse-to-one-
value form is from_fold). The effect MUST complete within each call (see
Processor).
from_selector
¶
Build a processor that keeps events for which keep is true and drops the rest.
A Processor is any Stream -> Stream function, so a step is under no
obligation to emit exactly one output per event the way from_map does: it
MAY yield zero. A selector is that zero-or-one case, an async generator that
re-emits an event on a match and skips to the next input otherwise, keeping
the matching subset through unchanged. This is the same sense as Python's
built-in filter (keep the matches).
Like every other builder step, keep is async: a predicate is one color
of function throughout without, so a decision that needs to await I/O (an
async permission check, a lookup) composes without ceremony, and a pure
decision simply never awaits (it MAY read injected Context values, whose
current never blocks). The polarity-opposite builder is from_filter,
which drops the matches. Emitting several outputs per event, by contrast, is
a wiring-style concern rather than a builder one (see without.wiring).
from_sink
¶
Build a leaf that consumes a stream for its effects and emits nothing.
The stateless terminus, dual to from_map: where a map turns each event
into an output, a sink turns each event into an effect and yields no output
stream at all. Awaiting it drains the stream to completion (or runs forever,
for an unbounded source driven inside a background_task). The step
MAY await contained I/O.
close_stream
async
¶
Close source if it is a generator: how a consumer releases a stream it abandons.
Stream is __aiter__-only, so a source may be a generator holding a finally (a
file, a task, a connection) or an object with nothing to release. This is the
difference between the two, in one place, because the alternative is every consumer
that can stop early carrying the same check.
Reach for it wherever a consumer may not drain what it was given, which is the case
whenever it can raise or be closed part-way through: without it the generator's
cleanup waits on garbage collection, so a long-lived source outlives the consumer by
an indeterminate amount. contextlib.aclosing is the same guarantee for a source
already known to be a generator.
collect
async
¶
Drain a Stream into a list: the terminal that materializes every value.
The dual of stream_from_iterable. It runs until the source ends, so it suits bounded
streams (a finished request, a shut-down queue); an endless source never
returns.
compose
¶
Compose two processors on the event edge: first then second.
The join type B may differ from A and C, so this adapts as well as
chains. Pure composition (the only event-edge connector that needs nothing
running); nest for three or more stages. When second is a Sink rather than
a Processor the result is a Sink too: the same wiring, terminated, which is
how a middleware chain (a filter, an enrichment) is prefixed onto a terminal
consumer such as a writer.
offload
async
¶
offload(
work: Callable[[Iterator[list[T]]], None],
) -> AsyncIterator[Sink[T]]
Run a blocking work on a dedicated thread, fed by the yielded async Sink.
The async-to-sync direction, and the exact counterpart of
stream_from_blocking: there a blocking source feeds an async consumer,
here an async producer feeds a blocking consumer. Both put one long-lived
thread on the blocking side and bridge with a queue, because the alternative
(an async wrapper library hopping to a worker thread per operation) pays
that round trip on every item. Here a single thread owns the resource and
does all the I/O: work is plain blocking Python, and the yielded Sink
drops each item onto a thread-safe queue the worker drains. No per-item
thread hop, and the consumer body stays ordinary synchronous code.
The two are not mirror images, and the asymmetry is real rather than an
oversight. A source has to be pulled, so stream_from_blocking bounds its
queue and pushes backpressure into the producer; a sink is pushed, so the
bound here would have to become a decision about what to do when the worker
falls behind (block the async side, or drop). See the note on the queue
below: this first cut takes neither, deliberately.
Items arrive in bursts: each element of the iterator is everything available on the queue at that instant (at least one item, blocking for the first). A burst boundary is therefore exactly the moment the worker has caught up, which is where a writer flushes: under load bursts are large and flushes are few, and when idle each burst is a single item flushed at once. So durability needs no flush-frequency knob; it falls out of the queue's own backlog.
Lifecycle is bounded by the with block: the thread starts on entry, and on
exit the queue is shut down so the worker drains what remains and then ends,
and the thread is joined (so a file is closed before the block returns). If
work raises, that surfaces when the block exits.
Nest this outside the consumer that drives the sink, so the worker outlives
the draining: async with offload(...) as writer: around whatever drives
writer, not the other way round.
The queue is unbounded in this first cut: the async side never blocks or drops,
at the cost of growing memory if the worker cannot keep up with a sustained
burst (a stalled disk). A bounded, drop-counting variant is a deliberate
follow-up. The bidirectional case (a full Processor bridged onto a thread,
with an output queue as well) is intentionally out of scope; this covers the
terminal-sink need (writing) without that extra state.
sample
async
¶
sample(source: Stream[T]) -> AsyncIterator[Sample[T]]
Connect to a stream on the behavior edge: read its latest value, not each.
The first value is sampled eagerly, so the context is never "not ready". A
background task keeps the held value current while the with block is open,
dropping intermediate values (latest-wins, no backpressure). A reader reads
the held value through current (latest, non-blocking) or waits for the next
one through updated (the deterministic "await next update" signal); the held
value is mutated only by the drain. The yielded Sample is a Context, so a
caller that only reads current can treat it as one. When the block exits,
any still-pending updated waits are cancelled, so a task awaiting one is not
left hanging on a context that has closed.
spool
async
¶
spool(source: Stream[T], ahead: int) -> AsyncIterator[T]
Drive a source ahead of its consumer through a bounded queue: read-ahead.
A background task pulls from source as fast as backpressure allows and
drops each value into a queue of at most ahead items; the returned stream
yields from that queue. So the source is driven independently of how fast
the consumer pulls: a pull-based producer (an accept loop, a file's chunks, a
DAG's executed iterator) keeps making progress while a slower consumer
catches up, up to ahead items of slack before put blocks and backpressure
reaches the producer. That overlaps the producer's work with the consumer's,
e.g. reading the next file chunk while the current one is still being written
to a socket.
ahead must be at least 1: the bound is the backpressure, so an unbounded
spool (which could let a fast producer grow memory without limit) is a
ValueError rather than a silent default. When source ends the queue is
shut down and the stream ends once drained; if source raises, the spooled
items still drain and then the error surfaces. Closing the stream early
cancels the background task, so the producer never outlives its consumer.
stack
¶
Compose middleware into one, first argument outermost; stack() is identity.
A middleware is (handler, *context) -> handler: it wraps a handler, given some
fixed context, into a new handler of the same type. The context is whatever the
setting threads through unchanged: nothing for a client exchange (Endo[H]), the
connection state and scope for a server handler. stack threads the same context
into every middleware and chains the handler through them, first outermost, so
stack(f, g)(handler, *context) is f(g(handler, *context), *context).
Generic over the handler H (the value each middleware transforms) and the context
pack *Ctx, which is bound once per call: every middleware in one stack(...) must
therefore share a shape, and mixing shapes is a type error. The pack passes through
untouched (never wrapped element-wise), which is exactly why one variadic generic
covers every arity here where a heterogeneous ladder would be needed.
stream_from_blocking
async
¶
stream_from_blocking(
values: Iterable[T], *, ahead: int = 1
) -> AsyncIterator[T]
Expose a blocking iterable as a Stream, without stalling the event loop.
stream_from_iterable is the right adapter for values already in hand, but
it pulls each one on the loop's own thread, so a source that blocks between
items (a pipe, sys.stdin, a driver with no async client, a Queue.get)
parks every other task while it waits. This runs the whole iteration on one
worker thread instead and hands each value across a bounded queue, so the
loop stays free and the producer runs ahead by at most ahead items before
backpressure reaches it. It is spool's counterpart for a source that is not
async in the first place.
Handing the whole loop to the thread, rather than awaiting one next at a
time, is what makes it pipeline: the producer fetches the next value while
the consumer is still working on the last, which per-item offloading cannot
do. The cost is the thread being held for the source's whole lifetime rather
than one read, which is the right trade for a long-lived source and the wrong
one for many short ones.
Two consequences worth knowing, both from the fact that a blocked thread cannot be cancelled:
- Abandoning the stream stops the handover immediately, but the thread itself
lives until its source produces one more item or ends. For
sys.stdinthat can be forever, so the worker is a daemon thread: the process exits without waiting for it, where a pooled thread would hangasyncio.run's shutdown. - The producer's own cleanup runs when that thread unwinds, which may be
after the consumer has moved on. A generator source is closed explicitly
there rather than left to garbage collection, so its
finallyis as prompt as it can be, but a source that must release a resource on a deadline wants an async adapter rather than this one. Anything else is left open, because it belongs to whoever passed it in and may well be read again.
stream_from_iterable
async
¶
stream_from_iterable(
values: Iterable[T],
) -> AsyncIterator[T]
Expose a fixed iterable as a Stream: the simplest source.
Turns already-in-hand values into the pull-based Stream the rest of
without consumes, e.g. to emit a fixed reply or to feed a processor under
test. stream_from_queue is the push-source counterpart.
stream_from_queue
async
¶
stream_from_queue(queue: Queue[T]) -> AsyncIterator[T]
Expose a queue as a Stream: the bridge from a push source to a pull stream.
A source that pushes (a server's accept loop, a callback-based client, a
pub/sub subscriber) drops values into a queue; this turns that queue into the
pull-based Stream the rest of without consumes. It ends gracefully when
the queue is shut down (queue.shutdown()): remaining items still drain, then
get raises QueueShutDown and the stream ends, letting a downstream fold
return its final value. Shutting the queue down is thus the closable-stream
signal; without it the stream never ends on its own and must be driven inside
a background_task or otherwise cancelled by its consumer.
tee
¶
Fan one stream out to every sink: the terminal counterpart to compose.
Where compose chains a processor onto a single sink, tee splits the stream
across several, after the Unix tool that writes one input to many destinations.
Every sink sees every event, in order, and the input is consumed exactly once.
The caller controls the split point purely by placement, since each argument is
itself a Sink: whatever is composed before the tee is the shared prefix
(parsed and enriched once), and each branch is its own Sink, carrying its own
filtering, rendering, and terminal. A branch MAY itself be another tee, so
"one input, several sink groups" nests without new machinery.
One pump reads the source once and pushes each value onto every branch's bounded
queue; each sink drains its own queue concurrently, and when the source ends the
queues are shut so each branch's stream ends and its sink returns. Every branch
MUST be consumed to completion and concurrently: a sink that stops early leaves
its queue to fill and stalls the pump (real sinks, a filter that drops events
included, drain every input). A sink failure tears the whole tee down and
surfaces as an ExceptionGroup, so a broken branch fails loud rather than
silently starving the rest.
buffer is how far a branch may run ahead: the queues are bounded to it, so the
slowest branch gates the pump (and thus backpressure onto the source), while a
larger value trades memory for slack so a fast branch need not wait on a slow
one. The default 1 keeps memory O(sinks). At least one sink is REQUIRED, and
buffer MUST be at least 1 (an unbounded branch could grow memory without limit).
ticks
async
¶
ticks(
every: timedelta,
*,
now: Callable[[], datetime] = utc_now,
) -> AsyncGenerator[datetime]
A Stream of moments, one now and one every every after: the clock as a source.
The source periodic work runs off, so that when something happens is a stream a
caller supplies rather than a loop inside the thing being done. A cache sweep, a
config refresh, a queue's housekeeping: each becomes a Sink that says only what
happens per event, and composing it with this says how often. A while True with a
sleep in it can only ever be a timer, and it buries the schedule inside the work;
a sink over a stream runs off this, off stream_from_queue when an operator pokes
it, or off stream_from_iterable in a test that chooses the instants.
Each tick carries its moment, so a consumer needs no clock of its own and a test controls time by choosing values rather than by patching one.
It yields before it sleeps, so the first event lands at once rather than one interval
later, and it never ends on its own: drive it inside a background_task, a task
group, or anything else that will cancel it.
The sleep goes after the yield rather than being measured from it, so the period is
every plus however long the consumer took, and the moments drift later by that much
each time. That is the right trade for the work this drives: a sweep that runs on a
fixed period instead of a fixed cadence can never overlap itself, where a scheduler
that chased a wall-clock grid would fire back-to-back to catch up after one slow pass,
which is exactly the wrong response to a dependency that has gone slow. What it means
for a caller is that every is a floor on the gap between events rather than a
promise about when each one lands, so a consumer that needs the true elapsed time
reads the moment it is handed rather than counting ticks.
An interval that is not positive is refused rather than run, for the same reason
drive refuses a limit below one: it has no sensible reading, and taken literally
it is a loop that yields as fast as the sink can consume, which pins a core to do
housekeeping. A zero arrives from a configured duration whose setting was never set,
so it is worth one comparison here.