A Stream[A, E, R] emits any number of As, then ends or fails with E. Streams are pulled:
nothing runs until a consumer asks for items, so a slow consumer slows the producer down
(backpressure) and memory stays bounded.
import pyfect as fx
from pyfect import stream
# A stream is a description too: nothing is pulled until it runs.
evens = stream.range(0, 100).filter(lambda n: n % 2 == 0).map(lambda n: n * n).take(4)
squares = fx.run_sync(evens.run_collect())
print(squares)
assert squares == [0, 4, 16, 36]
def lookup(n: int) -> fx.Effect[str]:
return fx.sleep(0.01).as_(f"item {n}")
# map_effect runs an effect per item, here three at a time, keeping the order.
items = stream.from_iterable([1, 2, 3, 4]).map_effect(lookup, concurrency=3)
pairs = fx.run_sync(items.grouped(2).run_collect())
print(pairs)
assert pairs == [("item 1", "item 2"), ("item 3", "item 4")]Build streams with stream.make, from_iterable, range, unfold, paginate,
from_queue, from_async_iterable, tick, ... and transform them with methods: map,
map_effect, filter, take, scan, grouped, zip, merge, buffer, debounce,
throttle, retry, timeout. The ones that remove an error or a service (stream.catch_tag,
stream.provide) are functions for .pipe, as for effects.
Sinks#
A Sink consumes a stream and produces a result; run_collect, run_fold and
run_for_each are shortcuts for common ones.
import pyfect as fx
from pyfect import sink, stream
numbers = stream.range(1, 11)
# A sink consumes a stream and produces one value.
assert fx.run_sync(numbers.run(sink.sum())) == 55
assert fx.run_sync(numbers.run(sink.take(3))) == [1, 2, 3]
# Streams of bytes or text: decode, split into lines, fold.
body = stream.from_iterable([b"alpha\nbe", b"ta\ngamma\n"])
lines = body.decode_text().split_lines()
assert fx.run_sync(lines.run_fold(lambda: 0, lambda count, _line: count + 1)) == 3A pipeline#
NDJSON records arriving in byte chunks, priced by a flaky service four at a time with retries,
buffered so memory stays bounded, batched and written by a sink. examples/stream_pipeline.py
in the repository is a longer version.
"""NDJSON in, a flaky service called four at a time, batches out."""
from dataclasses import dataclass
import pyfect as fx
from pyfect import schedule, sink, stream
from pyfect.encoding import Json, ndjson
@dataclass
class Unavailable(fx.TaggedError):
order_id: int
calls: dict[int, int] = {}
def price(order: Json) -> fx.Effect[int, Unavailable]:
"""Fails the first call for order 3, as a busy service would."""
assert isinstance(order, dict) and isinstance(order["id"], int)
order_id = order["id"]
def call() -> fx.Effect[int, Unavailable]:
calls[order_id] = calls.get(order_id, 0) + 1
if order_id == 3 and calls[order_id] == 1:
return fx.fail(Unavailable(order_id))
return fx.sleep(0.001).as_(order_id * 100)
# suspend: each retry calls the service again, instead of reusing one result
return fx.suspend(call)
written: list[tuple[int, ...]] = []
# Byte chunks that split records anywhere, as an HTTP body would.
body = stream.from_iterable([b'{"id": 1}\n{"id"', b': 2}\n{"id": 3}\n{"id": 4}\n{"id": 5}\n'])
pipeline = (
body.pipe_through_channel(ndjson.decode())
.map_effect(
lambda o: price(o).retry(schedule=schedule.exponential(0.001), times=3), concurrency=4
)
.buffer(capacity=8) # pricing runs at most 8 ahead of the writer: bounded memory
.grouped(2)
.run(sink.for_each(lambda batch: fx.sync(lambda: written.append(batch))))
)
assert fx.run_sync_exit(pipeline) == fx.Success(None)
assert written == [(100, 200), (300, 400), (500,)]
assert calls[3] == 2 # retried onceStreams are pulled: nothing upstream runs until the sink asks for more. map_effect keeps
at most four calls in flight and buffer lets pricing run at most eight items ahead of the
writer, so a slow sink slows the whole pipeline instead of filling memory.
fx.encoding has framers for NDJSON, JSON arrays and Server-Sent Events.
Reference: pyfect.stream, pyfect.sink,
pyfect.channel.