Wingfoil is a blazingly fast, highly scalable stream processing framework designed for latency-critical use cases such as electronic trading and real-time AI systems. You define a graph of transformations over streams; Wingfoil drives their execution in a tightly scheduled DAG, either against live data or replayed history.
Wingfoil is the Op-pattern engine that replaces the original wingfoil
engine, and wingfoil is its Python binding. The Rust engine does the
heavy lifting; this package exposes the same graph model, the combinator
surface, all fifteen production I/O adapters and the latency-tracing surface —
plus a plugin seam that lets you author ops, sub-graphs and adapters in Rust
and compose them from Python.
Coming from the original
wingfoilpackage? This one supersedes it — see the migration guide, which lists every renamed entry point and every place wingfoil deliberately behaves differently.
- Features
- Installation
- Quick Start
- Core Concepts
- Stream Operators
- Python-defined nodes
- Backtesting with historical mode
- Pandas integration
- Latency tracing
- I/O adapters
- Authoring components in Rust
- Build from source
- Testing
- Documentation
- Release status and feedback
- Fast — ultra-low latency and high throughput from an efficient DAG execution engine written in Rust. The Python edge boxes values only at the boundary; wiring is done once, not per tick.
- Simple and obvious — build a graph with fluent combinators; Wingfoil manages scheduling and propagation.
- Backtesting out of the box — the same graph runs against wall-clock time or replays history deterministically; historical is the default.
- Lossless — same-instant values ride a single burst, never coalesced to latest-wins and never dropped, identically in both run modes.
- Fails loudly — a missing field, a wrong-typed value or a raising callable aborts the run naming the offender, rather than defaulting its way through.
- Fifteen production I/O adapters — PostgreSQL, Kafka, Redis, etcd, Fluvio, CSV, KDB+, ZeroMQ, FIX 4.4, augurs, Prometheus, OTLP, WebSocket, Aeron and iceoryx2.
- Latency tracing — per-hop wall-clock stamps that survive a hop to a Rust peer, aggregated into the engine's own report.
- A plugin seam — author an op, a sub-graph or an adapter in Rust and call it from Python alongside the built-ins, with a compiled hot core under dynamic Python wiring.
wingfoil is not on PyPI yet — the published wingfoil package is still
the legacy engine. Until the cutover, install from a source checkout:
git clone https://github.com/wingfoil-io/wingfoil
cd wingfoil/crates/wingfoil-python
pip install maturin
maturin developThat builds the wheel's full adapter set (PostgreSQL, Kafka, Redis, etcd, Fluvio, CSV, ZeroMQ, OTLP, augurs, KDB+, FIX, Prometheus, web). Two adapters are opt-in because they would make the build platform-specific:
maturin develop -F iceoryx2 # Linux/POSIX shared memory
maturin develop -F aeron # needs clang, libuuid, CMake >= 3.30protoc is needed at build time (etcd compiles its protos):
sudo apt-get install -y protobuf-compiler.
Adapter clients are compiled into the extension, so no extra Python packages
are needed for them — only the backing service. pandas is needed at run time
for stream.dataframe(), and nothing else.
Because the adapters are cargo features, the module surface reflects what you
built. hasattr(wingfoil, "kafka_sub") is the check.
import wingfoil as wf
g = wf.Graph()
(
g.counter(period_nanos=1_000_000_000) # tick every second: 1, 2, 3, …
.map(lambda n: f"hello, world {n}")
.print() # print each value, pass it through
)
g.run(cycles=3)hello, world 1
hello, world 2
hello, world 3
Graph.run() blocks until the bound is reached, and defaults to deterministic
historical replay from t=0 — so the run above finishes instantly rather than
taking three seconds. Pass any of:
| Argument | Type | Meaning |
|---|---|---|
realtime |
bool |
True uses wall-clock time; False (the default) is historical replay. |
start_nanos |
int |
Historical start, in nanoseconds since the epoch. |
duration_nanos |
int |
Stop after this much graph time. |
cycles |
int |
Stop after this many engine cycles. Takes precedence over duration_nanos. |
With neither cycles nor duration_nanos, the graph runs forever.
- Graph — the builder you hold. Every source is created on it
(
g.counter(...),wf.csv_read(g, ...)), andg.run(...)executes everything wired onto it. There is no ambient graph and no per-streamrun. - Stream — a time-stamped channel of values. Combinators
(
.map,.filter_value,.distinct, …) each return a newStream, so pipelines chain. Sinks return a terminalStreamwhose value isNone; keep hold of it or not, it is wired either way. - Value — after the run,
stream.value()reads back the last value the stream carried. - Tick — a cycle in which a stream produced a value. A combinator that drops a value simply does not tick, and its downstreams do not run.
realtime=True— the engine follows the wall clock. Use it with live inputs (sockets, brokers, shared memory).realtime=False— historical replay, driven by event timestamps. The graph runs as fast as the CPU allows and time advances purely from source events, so it is deterministic: the right mode for tests and backtests.
A Python Graph does not know its mode until run() is called, but several
adapters need it at wiring time — a live subscriber has no timeline to
replay, and a sliced historical read must know its window to build its queries.
Those adapters take the fact as an argument (realtime=, or start_nanos=
/ duration_nanos=), and it must match the eventual run(...). Passing
realtime=False to a live-only source raises there and then, rather than
producing an empty run.
A burst is the group of values sharing one instant — the messages that
arrived between two graph cycles, or the rows of a replay carrying the same
timestamp. The engine never collapses a burst to "latest wins" and never drops
a member, so on the Python edge a burst erases to a list: one tick, one
list, in arrival order.
That is why every I/O source yields a list per tick, even when the list usually has one element. Index it for the single-value case:
rows = wf.csv_read(g, "prices.csv", "time")
first = rows.map(lambda batch: batch[0])Sinks accept the same shape in reverse: a list/tuple writes a multi-value
burst, anything else writes a single-element one.
Every combinator below is a method on Stream unless marked otherwise.
Sources are built on the Graph.
| Source | Description |
|---|---|
g.counter(period_nanos) |
Emit the running tick count 1, 2, 3, … every period_nanos. |
g.constant(value) |
Emit value once, on the first cycle. |
g.values(values, period_nanos) |
Replay a finite list, one value per tick, period_nanos apart. The straightforward way to feed real data in from Python. A graph containing it is single-run. |
g.custom_node(upstreams, obj) |
Wire a Python object as a node — see Python-defined nodes. |
I/O sources are module-level functions taking the graph first — see I/O adapters.
| Operator | Description |
|---|---|
.map(f) |
Apply f(value) to each tick. A raised exception aborts the run. |
.filter_map(f) |
Map and filter in one: f(value) returning None drops the tick. |
.fold(init, f) |
Fold into an accumulator seeded from init, emitting it after each fold. |
.reduce(f) |
Like fold, but the first value seeds the accumulator. |
.difference() |
Emit value - previous (quiet on the first). |
getattr(s, 'not')() |
Arithmetic negation (5 -> -5). The method name is the Python keyword not, so reach it with getattr. |
.bimap(other, f) |
Combine two streams through f(this, other), whenever either ticks. |
| Operator | Description |
|---|---|
.filter_value(pred) |
Keep a value only when pred(value) is truthy. This is legacy wingfoil's filter. |
.filter(condition) |
Emit only while another stream's current value is truthy. Takes a Stream, not a callable. |
.filter_none() |
Drop values that are None. |
.distinct() |
Suppress consecutive duplicates — emit on change only. |
.drop_small_change(is_small) |
Suppress ticks while is_small(current, last_emitted) is truthy. Compares against the last value emitted, so a slow drift still eventually ticks. |
.limit(n) |
Pass the first n values through, then stay quiet. |
.throttle(interval_nanos) |
Emit at most once per interval. |
.sample(trigger) |
Re-emit the current value whenever trigger ticks. |
.delay(delay_nanos) |
Re-emit each value that many nanoseconds later. |
| Operator | Description |
|---|---|
.merge(other) |
Merge two streams; on a same-cycle tie the earliest-supplied input wins. |
.merge_all([...]) |
Merge several at once. |
.split() |
Decompose a stream of 2-tuples into two streams. |
| Operator | Description |
|---|---|
.count() |
Emit the running tick count, ignoring values. |
.sum() / .mean() |
Cumulative running sum / mean. .average() is an alias of .mean(). |
.accumulate() |
Collect every value into a growing list, re-emitted each tick. |
.buffer(capacity) |
Flush a list once capacity values accumulate (and on the last cycle). |
.window(interval_nanos) |
Flush a list on each time boundary (and on the last cycle). |
.collect() |
Collect every (nanos, value) pair into a growing list of tuples. |
.with_time() |
Pair each value with engine time as (nanos, value). |
.dataframe() |
Build a pandas DataFrame — see Pandas integration. |
| Operator | Description |
|---|---|
.inspect(f) |
Call f(value) and pass the value through. The pass-through tap (legacy's for_each). |
.print() |
Print each value to stdout, passing it through. |
.logged(label, level="info") |
Log "{time} {label} {value}" and pass through. level is "trace"/"debug"/"info"/"warn"/"error". |
.value() |
After the run, the stream's last value (legacy's peek_value). |
Cumulative .sum() and .mean() are Stream methods. The full windowed
moment surface — rolling variance, std, median, min/max, time- and
count-weighted windows, and ewma — lives in the engine's Rust stats layer
and reaches Python through the plugin seam,
as a #[pyop] exposing exactly the window you want. That keeps the
parameterisation where it can be type-checked rather than in extra Python
classes. This is the one place the binding is currently narrower than legacy's;
the engine already has the whole surface.
import wingfoil as wf
g = wf.Graph()
avg_of_odds = (
g.counter(period_nanos=100_000_000) # 1, 2, 3, … every 100ms of graph time
.filter_value(lambda n: n % 2 == 1) # 1, 3, 5, …
.map(float)
.mean() # cumulative running mean
.logged("avg")
)
g.run(cycles=10)
print("last:", avg_of_odds.value()) # 5.0A Python object can be a graph node. There are two forms over the same machinery.
Composition — pass any object implementing cycle(values) -> bool (given
the upstreams' current values, return whether it ticked) and peek() (its
output when it did):
class RunningMax:
def __init__(self):
self.best = None
def cycle(self, values):
for value in values:
if value is not None and (self.best is None or value > self.best):
self.best = value
return self.best is not None
def peek(self):
return self.best
g = wf.Graph()
source = g.counter(period_nanos=100)
peak = g.custom_node([source], RunningMax()).print()
g.run(cycles=5)Inheritance — legacy wingfoil's shape. The constructor call is the wiring
step and returns the wired Stream, so it chains:
class Polynomial(wf.CustomStream):
"""Sum of upstream[i] * 10**i."""
def cycle(self):
value = sum(
(u.peek_value() or 0) * 10**i for i, u in enumerate(self.upstreams())
)
self.set_value(value)
return True
g = wf.Graph()
source = g.counter(period_nanos=100)
Polynomial(g, [source] * 3).map(lambda x: x * 0.01).logged("poly")
g.run(cycles=5)Two deviations from legacy, both forced by the engine rather than chosen:
- The graph is explicit —
MyStream(graph, upstreams). Wingfoil has no ambient graph, and aStreamcarries no reference back to its builder. upstreams()yields value snapshots, not the upstreamStreamobjects. During a run the engine holds its runner mutably borrowed, so a Pythoncyclecannot call back into the graph to read a sibling. The current values are handed in, wrapped in objects exposingpeek_value()— the only stream method legacycyclebodies use. A not-yet-ticked upstream reads asNone.
A graph containing a Python-defined node is single-run: the instance's
state is caller-owned and the engine has no hook to reset it, so a second
run() raises rather than replaying from dirty state.
Historical replay is the default, and it is deterministic — the right mode for unit tests and strategy backtests. Time advances from source events rather than the wall clock, so a graph over a day of data finishes as fast as the CPU allows.
import wingfoil as wf
MINUTE_NANOS = 60_000_000_000
JAN_2025 = 1_735_689_600_000_000_000 # 2025-01-01T00:00:00Z
g = wf.Graph()
average = g.counter(period_nanos=1_000_000_000).map(float).mean()
g.run(start_nanos=JAN_2025, duration_nanos=MINUTE_NANOS)
print(average.value()) # 31.5 — a minute of ticks, replayed instantlyKeep an eye on what the graph retains: .accumulate() and .collect() grow a
list per tick, so over a long replay prefer a running aggregate (as above) or a
windowed flush (.buffer(n) / .window(interval_nanos)).
Sources that read a bounded slice of history (postgres_read, kdb_read) take
the same window at wiring, and it must match the run(...) call:
rows = wf.postgres_read(
g, CONN_STR, "SELECT time, sym, price FROM trades", "time",
start_nanos=JAN_2025, duration_nanos=DAY_NANOS,
)
g.run(start_nanos=JAN_2025, duration_nanos=DAY_NANOS)stream.dataframe() accumulates each value with its engine time and, on the
final cycle, produces a pandas.DataFrame (columns time, value) as the
stream's value. The frame is built in Rust, so there is no Python-side
assembly step:
import wingfoil as wf
g = wf.Graph()
frame = g.counter(period_nanos=10_000_000).map(lambda n: n * n).dataframe()
g.run(cycles=5)
print(frame.value())
# time value
# 0 0 1
# 1 10000000 4
# 2 20000000 9
# ...For a frame of several streams, map them into one dict-valued stream before
framing it, or collect each with .collect() and join in pandas. (Legacy's
multi-stream build_dataframe has no direct equivalent yet.)
Stamp wall-clock timestamps onto messages as they hop through a graph — and across processes — then aggregate the per-stage deltas at the end of the pipeline. This surface is always present; it is not an adapter, so there is no feature to enable.
import wingfoil as wf
stages = ["ingest", "decode", "publish"]
g = wf.Graph()
messages = source.map(lambda payload: wf.TracedBytes(payload, wf.Latency(stages)))
stamped = wf.stamp(wf.stamp(wf.stamp(messages, "ingest"), "decode"), "publish")
sink, stats = wf.latency_report(stamped, stages, print_on_teardown=False)
g.run(cycles=1000)
print(stats["decode"]["p99_ns"])
print(stats.report())stampreads the cycle-start clock;stamp_precisetakes a fresh clock read per tick, for intra-cycle resolution.- Every entry point has an
_if(..., enabled)variant that wires nothing when disabled — and returns the same shape, so call sites do not branch. latency_reportreturns a tuple(sink, LatencyStats); the stats handle is live, so the numbers are readable after the run.- Bursts are stamped element-wise: a value reaching
stampmay be a list ofTracedBytes, and every member is stamped under one GIL attach. Latency.to_bytes()/Latency.from_bytes(data, stages)are the little-endian header a Rust peer reads straight back as itslatency_stages!record — andiceoryx2_sub/iceoryx2_pubwill carry it for you, given astages=list.- Aggregation and report formatting are the engine's own, so a Python report is byte-identical to a Rust one.
Every adapter is a module-level function (or, where it is stateful, a class)
taking the graph or the stream as its first argument — never a Stream method.
That is deliberate: a binding authored in a third-party crate cannot add a
method to a #[pyclass] defined here, so making the built-ins free functions is
what makes a third-party adapter indistinguishable from a built-in one.
Two conventions run through all of them:
Selectors are strings, not enum classes. A poll mode, a service variant, an
etcd event kind, an augurs model — each is a plain str, and a wrong value
raises listing the accepted set.
Conversion fails loudly. A missing required field, a wrong-typed value, or a
non-dict where a record was expected aborts the run naming the offending
field, rather than defaulting to empty bytes or an empty burst.
Every entry point carries a full docstring — help(wf.kafka_sub) — and the
API reference tabulates the whole surface.
postgres_read (sliced historical replay), postgres_sub (a live
LISTEN/NOTIFY tail), postgres_source (one wiring call for either mode),
postgres_write, and postgres_notify_trigger_sql() for the trigger DDL. Rows
are dicts; a tick is a list of rows.
import wingfoil as wf
CONN_STR = "host=localhost user=postgres password=postgres dbname=postgres"
DAY_NANOS = 86_400_000_000_000
g = wf.Graph()
# Historical, time-sliced replay.
rows = wf.postgres_read(
g, CONN_STR, "SELECT time, sym, price FROM trades", "time",
start_nanos=START, duration_nanos=DAY_NANOS, chunk_secs=86400,
)
rows.map(lambda batch: [r["price"] for r in batch]).print()
g.run(start_nanos=START, duration_nanos=DAY_NANOS)# Streaming insert sink — declare the target columns in table order.
wf.postgres_write(
trades, CONN_STR, "trades", [("sym", "text"), ("price", "float"), ("qty", "long")]
)Events are {topic, partition, offset, key, value} dicts. topic is optional
on the sink — a record naming its own target lets one sink write to many topics.
BROKERS = "localhost:9092"
produce = wf.Graph()
records = produce.values(
[{"topic": "trades", "key": b"k", "value": b"alpha"}], period_nanos=1_000_000_000
)
wf.kafka_pub(records, BROKERS)
produce.run(cycles=1)
consume = wf.Graph()
wf.kafka_sub(consume, BROKERS, "trades", "my-group").inspect(print)
consume.run(realtime=True, duration_nanos=10_000_000_000)Pub/Sub (redis_sub / redis_pub, events {channel, payload}) and persistent
Streams (redis_stream_read / redis_stream_write, events {key, id, fields}).
channel / key are optional fallbacks on the sinks; both sinks take a
buffer_size back-pressure bound.
URL = "redis://127.0.0.1:6379"
# Subscribe, uppercase, republish — Redis Pub/Sub is fire-and-forget, so the
# subscriber must be live before anything is published.
g = wf.Graph()
inbound = wf.redis_sub(g, URL, "source")
outbound = inbound.map(
lambda batch: [{"channel": "dest", "payload": e["payload"].upper()} for e in batch]
)
wf.redis_pub(outbound, URL)
g.run(realtime=True, duration_nanos=5_000_000_000)Events are {kind, key, value, revision} with kind a string ("put" /
"delete"). endpoints accepts a single str or a list, so a cluster is
addressable.
ENDPOINT = "http://localhost:2379"
g = wf.Graph()
# Publish: each dict is {"key": str, "value": bytes}, or a list of them per tick.
entries = g.values(
[{"key": "/wf/item/1", "value": b"1"}], period_nanos=1_000_000_000
)
wf.etcd_pub(entries, ENDPOINT, lease_ttl_secs=30.0, force=True)
# Subscribe: snapshot + watch under a prefix; each tick is a list of events.
wf.etcd_sub(g, ENDPOINT, "/wf/").inspect(print)
g.run(realtime=True, duration_nanos=2_000_000_000)Events are {key, value, offset}. The key is asymmetric — a read yields
bytes | None, a write expects str | None — because that is the adapter's own
shape, so a round trip needs an explicit .decode().
g = wf.Graph()
wf.fluvio_sub(g, "127.0.0.1:9003", "source", partition=0).inspect(print)
g.run(realtime=True, duration_nanos=5_000_000_000)Deterministic historical replay. Values are str on both sides (CSV has no
types), column order follows the file header, and buffer_size bounds the
replay look-ahead so a huge file is not read up front.
g = wf.Graph()
# Read: `time` holds integer nanoseconds since the epoch. Each tick is a list
# of the rows sharing that timestamp.
rows = wf.csv_read(g, "prices.csv", "time")
rows.inspect(print)
g.run(start_nanos=0, duration_nanos=5_000_000_000)# Write: the header is explicit — a dynamic row has no field names to derive
# one from. The graph timestamp is written as a leading `time` column.
g = wf.Graph()
quotes = g.values(
[{"sym": "AAPL", "price": "101.0"}, {"sym": "AAPL", "price": "102.0"}],
period_nanos=100_000_000,
)
wf.csv_write(quotes, "out.csv", ["sym", "price"])
g.run(cycles=2)kdb_read (a time-sliced historical query), kdb_sub (the tickerplant tail —
new against legacy) and kdb_write. Rows are dicts dispatched on each value's
actual KDB type; temporal columns decode to their raw int (nanoseconds
from the KDB epoch, 2000-01-01) rather than a datetime that would have to
guess a timezone.
Start a q process (q -p 5000) and create the table:
test_trades:([]time:`timestamp$();sym:`symbol$();price:`float$();qty:`long$())
HOST, PORT, TABLE = "localhost", 5000, "test_trades"
KDB_EPOCH_NANOS = 946_684_800_000_000_000
DAY_NANOS = 86_400_000_000_000
# Write: each dict is one row; `columns` names them in table order.
g = wf.Graph()
trades = g.values(
[{"sym": "AAPL", "price": 100.0, "qty": 10}], period_nanos=1_000_000_000
)
wf.kdb_write(
trades, HOST, PORT, TABLE,
[("sym", "symbol"), ("price", "float"), ("qty", "long")],
)
g.run(cycles=1, start_nanos=KDB_EPOCH_NANOS)
# Read: a time-sliced query over the same window the run declares.
g = wf.Graph()
rows = wf.kdb_read(
g, HOST, PORT, f"select from {TABLE}", "time",
KDB_EPOCH_NANOS, DAY_NANOS, chunk_secs=3600,
)
rows.inspect(print)
g.run(start_nanos=KDB_EPOCH_NANOS, duration_nanos=DAY_NANOS)zmq_sub returns a tuple (data, status); status ticks only on a
transition, carrying "connected" / "disconnected". Payloads cross as
bytes. The _etcd pair adds service discovery and needs the etcd feature
too.
g = wf.Graph()
counter = g.counter(period_nanos=20_000_000)
wf.zmq_pub(counter.map(lambda n: str(n).encode()), 7779, bind_address="127.0.0.1")
data, status = wf.zmq_sub(g, "tcp://127.0.0.1:7779")
data.inspect(lambda messages: [print("msg:", m) for m in messages])
status.inspect(lambda s: print("status:", s))
g.run(realtime=True, duration_nanos=2_000_000_000)A SUB socket is a slow joiner — messages published before it finishes
connecting are simply lost — so assert on what arrived, never on the first
message.
With etcd discovery, publishers register under a service name and subscribers look it up; the lookup happens at wiring, so an unreachable registry raises there:
wf.zmq_pub_etcd(payloads, 7779, "quotes", "http://127.0.0.1:2379",
bind_address="127.0.0.1")
data, status = wf.zmq_sub_etcd(g, "quotes", "http://127.0.0.1:2379")fix_connect (initiator), fix_accept (acceptor), fix_connect_tls (TLS
initiator, handing back a FixConnection) and fix_send. Each source returns
(data, status). Messages are
{"msg_type": str, "seq_num": int, "fields": [(tag, value)]} with str tag
values — FIX is a text protocol, so spell a number str(price). Session
status is always a dict.
g = wf.Graph()
data, status = wf.fix_connect(g, "fix.example.com", 9876, "MYCOMP", "BROKER")
data.inspect(lambda messages: [print("fix:", m) for m in messages])
status.inspect(print)
g.run(realtime=True, duration_nanos=10_000_000_000)# TLS initiator (e.g. LMAX). This one hands back a single `FixConnection`
# exposing `.data`, `.status`, `.send(msg)` and `.fix_sub(symbols)`.
g = wf.Graph()
connection = wf.fix_connect_tls(
g, "fix-marketdata.london-digital.lmax.com", 443, "USERNAME", "LMXBL",
password="secret",
)
connection.data.inspect(print)
connection.send({"msg_type": "V", "fields": [(262, "req1"), (263, "1"), (264, "0")]})Six on-graph time-series operators, no external service. augurs_forecast,
augurs_changepoint and augurs_seasons analyse one series (a stream of
floats); augurs_outlier, augurs_dtw and augurs_cluster compare several
(a stream of lists of floats). Results are dicts carrying the full model
output, not just the headline number, and model / detector / metric are
strings.
g = wf.Graph()
prices = g.values([float(i) for i in range(1, 41)], period_nanos=1_000_000_000)
wf.augurs_forecast(prices, window=32, horizon=3, level=0.95).inspect(
lambda f: print(f["point"], f["lower"], f["upper"])
)
wf.augurs_changepoint(prices, window=32).inspect(print)
g.run(cycles=40)A stateful handle exposing a gauge per stream on a scrape endpoint. Historical runs are a no-op — a backtest never publishes fast-forwarded values to a live endpoint.
g = wf.Graph()
exporter = wf.PrometheusExporter("0.0.0.0:9091")
port = exporter.serve() # bind and start the HTTP server
exporter.gauge("wingfoil_ticks", g.counter(period_nanos=1_000_000_000))
g.run(realtime=True, duration_nanos=5_000_000_000)Scrape with curl http://localhost:9091/metrics.
Push a stream's value to an OTLP HTTP collector as a gauge, stringified via
Python's str(). Historical runs are a no-op, as above.
g = wf.Graph()
wf.otlp_push(
g.counter(period_nanos=1_000_000_000), "wingfoil_ticks",
"http://localhost:4318", "demo",
)
g.run(realtime=True, duration_nanos=10_000_000_000)A stateful WebServer handle. Publishing is a server method, not a stream
method, because the handle owns the topic registry. Values marshal through the
selected codec ("bincode" or "json"); bytes become an array of ints —
wire-compatible with a Rust Vec<u8> peer, and therefore decoded back as a
list, not bytes.
g = wf.Graph()
server = wf.WebServer("127.0.0.1:0", codec="json")
print("listening on", server.port(), "codec", server.codec_name())
server.pub(g.counter(period_nanos=50_000_000), "ticks")
server.pub_bursts(g.constant([1.0, 2.0]), "book") # a whole burst as one frame
events = server.sub(g, "ui").accumulate()
g.run(realtime=True, duration_nanos=5_000_000_000)
server.stop()static_dir= serves a directory alongside the socket, and cert_path= /
key_path= (given together) switch it to TLS.
aeron_sub, aeron_sub_with_status, aeron_pub, aeron_pub_with_status.
Not in the default build — rusteron-client builds the Aeron C library from
source. Connecting is eager: an unreachable media driver raises at wiring.
mode is "spin" or "threaded".
g = wf.Graph()
messages = wf.aeron_sub(g, "aeron:ipc", 1001, mode="spin")
wf.aeron_pub(messages, "aeron:ipc", 1002)
g.run(realtime=True, duration_nanos=5_000_000_000)Zero-copy pub/sub over shared memory. Not in the default build —
Linux/POSIX-only. Payloads cross as bytes; variant ("ipc" / "local") and
mode ("spin" / "threaded" / "signaled") are strings.
g = wf.Graph()
received = wf.iceoryx2_sub(g, "wingfoil/demo", variant="local", mode="signaled")
received.inspect(lambda messages: print("received:", messages))
wf.iceoryx2_pub(
g.counter(period_nanos=100_000_000).map(lambda n: f"tick {n}".encode()),
"wingfoil/demo", variant="local",
)
g.run(realtime=True, duration_nanos=500_000_000)Both entry points take an optional stages= list. With it, a sample is a
[u64; len(stages)] little-endian stamp header followed by the payload, and the
value is a TracedBytes rather than bytes — the same layout a Rust peer's
latency_stages! record has, so a Python subscriber reads a Rust publisher's
stamps.
The point of the binding is not just to expose the built-ins: you can author an op, a whole sub-graph, or an I/O adapter in Rust and call it from Python alongside the built-in vocabulary. Three macros do it, and a binding written in a third-party crate is indistinguishable from a built-in one:
| Macro | Exposes |
|---|---|
#[pyop] / pyop_fn! |
An Op implementation as module.my_op(stream, …). One to four inputs, tuple Cfg (each element its own named Python parameter), and stateful ops. |
#[pygraph] |
A Rust wiring function (fn(&Stream<T>) -> Stream<U>) as one callable that splices its nodes into the caller's graph. Multi-input and tuple-returning forms supported. |
#[pyadapter] |
A source or sink adapter trait on GraphBuilder or Stream<T>. A tuple return gives the (data, status) shape live sources use. |
The interior of any of these stays natively typed — only the Python-facing
edge erases to the boxed PyElement. Combined with compiled_island, that
gives "compiled interiors, dynamic wiring": Python composes the graph at run
time while the hot sub-graphs run as monomorphized straight-line code.
This crate ships demo components proving the seam end to end — scale,
square, running_total, weighted_add, blend3, blend4, clamped_scale,
doubled_running_total, spread_and_mid, ramp_source, pair_source,
split_source, list_sink, burst_list_sink, compiled_island and
interpreted_twin:
import wingfoil as wf
g = wf.Graph()
ramp = wf.ramp_source(g, 10.0, 2.0) # a Rust source adapter
squared = wf.square(ramp) # a Rust op
totals = wf.doubled_running_total(ramp) # a Rust sub-graph
collected = []
wf.list_sink(squared, collected) # a Rust sink adapter
g.run(cycles=3)
print(collected) # [100.0, 144.0, 196.0]See docs/python-interop.md at the repository root for the design, and
examples/plugin_sdk.py for the runnable version.
cd crates/wingfoil-python
maturin develop # build + install into the active environment
maturin build --out dist # or build a wheel
pip install --force-reinstall dist/*.whlmaturin develop -F <feature> replaces the wheel's feature list rather
than adding to it, so a lone -F aeron build carries only that adapter.
pytest # Python round-trip tests
# the Rust object form and boundary type have their own unit tests
# (`--features all-adapters` also covers the per-adapter marshaling tests):
cargo test --manifest-path crates/wingfoil-python/Cargo.toml --features all-adaptersAdapter integration tests are marked (@pytest.mark.requires_postgres) and
deselected by default, so a plain pytest is never silently green against a
service that is not up. Opt in explicitly with a service running:
docker run --rm -p 5432:5432 -e POSTGRES_PASSWORD=postgres postgres:16-alpine
pytest -m requires_postgres tests/test_postgres.pyRunnable examples live in examples/ and are smoke-tested by
tests/test_examples.py, so they stay working as the binding evolves.
docs/— the Sphinx source for the published module documentation: this guide, the API reference, and the migration guide for the legacywingfoilpackage. Build it withmaturin developfollowed bymake htmlindocs/; seedocs/README.md.- Every adapter's module docs (
src/adapters/<name>.rs) carry its entry-point table, its argument semantics, and how its surface differs from the legacy binding. Those doc comments are the Python docstrings —help(wf.csv_read). docs/python-interop.mdat the repository root — the design of the boundary and the plugin seam.
Wingfoil is pre-release: it is the engine that replaces the shipping
wingfoil, and the Python binding tracks it. APIs are stabilising and we would
love your input — especially if you:
- are interested in contributing,
- know of a project Wingfoil is a good fit for,
- want to request a feature, or
- have any feedback.
Email us at hello@wingfoil.io, ping us on discord, open a GitHub discussion, or browse the issue tracker.