Python
Subscribe to a stream, publish to one, and read what a stream announced about itself.
from ranvier import schema_id_of, subscribe
with subscribe("127.0.0.1", 7000, "pointer") as pointer:
print(schema_id_of(pointer.announcement()))
for sample in pointer:
handle(sample.values)
sample.values is bytes this package never opens. What they mean is the
schema the stream announced, and decoding them belongs to whatever compiled
that schema — np.frombuffer(sample.values, "<f4") if the payload is a packed
block, a generated protobuf class if it is a message.
Install
pip install ranvier
There is no runtime dependency. Wheels are built against CPython's stable ABI from 3.11, so one wheel per platform covers every Python version from 3.11 onward rather than needing one per version.
What you can do
Read a stream another process publishes
from ranvier import subscribe
pointer = subscribe("127.0.0.1", 7000, "pointer", connect_timeout=5.0)
subscribe dials immediately and raises OSError if nothing is listening —
a subscription that will never yield anything is worth being told about rather
than discovering later. Constructing Subscription(host, port, name) directly
does the opposite: it dials on the first next, retrying until your timeout,
which is what you want when a publisher and a subscriber start in no particular
order.
Timeouts are in seconds. next(timeout=0.3) gives up after 300
milliseconds and answers None; next() waits indefinitely. None is an
answer rather than an error, and it means one of three things told apart by
what you asked for: the wait ran out, the subscription was closed, or the
publisher is not there and reconnect is off.
Ctrl-C works. Every wait is a quarter-second poll with the interpreter released, so an interrupt is felt within 250 milliseconds and other Python threads keep running while you block.
For a frame loop or a GUI callback that cannot block at all, try_next()
answers whatever is already waiting and None otherwise, without waiting.
Ask what the stream said about itself
info = pointer.announcement()
if info is not None and info.has_schema:
identifier = info.schema.schema_id # "ranvier.example.v1.PointerSample"
descriptor = info.schema.descriptor # a serialized FileDescriptorSet
announcement() is None until a publisher has spoken, which is a real state
rather than an error. Several streams may publish under one name, so
announcements() gives all of them and announcement(stream_ref) asks about
one — and sample.stream_ref is the only thing that says which stream a sample
came from.
An announcement also carries who was operating and on what authority, when the
publisher stated them: info.operator.reference, info.operator.asserted_by,
info.authorisation.reference and its validity window. has_operator and
has_authorisation separate unstated from empty, which matters because
Ranvier records the identity it was given and never verifies it.
Count what went missing
counts = pointer.counts()
print(counts.received, counts.missing, counts.breaks, counts.dropped)
missing is what the sequence numbers say never arrived. breaks is how many
separate runs those were, and it is the number that separates two very
different problems sharing one total: a hundred consecutive losses is a
disconnection, a hundred scattered singles is a subscriber marginally too slow.
dropped is what this consumer's own queue discarded because it was full, and
undecodable above zero means a publisher and this subscriber disagree about
the contract.
Publish
from ranvier import Origin, Runtime
runtime = Runtime()
stream = runtime.node("analysis").declare_stream(
"band-power",
schema_id="lab.v1.BandPower",
descriptor=descriptor_set_bytes,
origin=Origin.DERIVED,
source_clock_domain="device.eeg",
)
serving = runtime.serve("127.0.0.1:0")
print(f"listening on {serving.address}")
stream.publish(encoded, source_timestamp_ns=instrument_time)
Announce a descriptor if you have one. It is what lets something never compiled against your schema still read the stream — a viewer, a converter, a tool written years from now.
source_timestamp_ns=None is an answer. A synthetic event, a device with no
clock, or a handshake that has not produced one are all honestly undated, and
saying so is different from claiming a time of zero.
Declare source_clock_domain if your samples carry instrument times. A
stream that named no clock cannot carry one, and a timestamp handed to such a
stream is dropped rather than sent — a time on a clock nobody named is a number
nothing can compare against. It is dropped quietly, which is the sharpest edge
on this surface, so stream.source_timestamp_is_carried exists to be asked
rather than discovered.
A port of 0 lets the operating system choose, and serving.address reports
what it chose in the exact form subscribe takes.
Publish and subscribe in one process
runtime = Runtime()
node = runtime.node("analysis")
stream = node.declare_stream("band-power")
local = node.subscribe("band-power")
No socket is involved, and a subscriber cannot tell the difference from a remote one.
What this package does not do
No asyncio. Every call blocks the calling thread, and the omission is
deliberate rather than pending. A native binding cannot make a socket read into
an awaitable without either running the wait on a thread pool — which is what
asyncio.to_thread already is, in your code where you can see it — or
reimplementing the transport against an event loop, which would be a second
implementation of the thing this package exists to have only one of. If you
want a coroutine:
sample = await asyncio.to_thread(pointer.next, 1.0)
No redialling a connection that dropped. reconnect and reconnect_delay
cover a publisher that is not up yet, which is the ordinary case at startup:
the first next keeps dialling until your timeout. They cannot cover a
connection that was established and then broke, because nothing in the C ABI
reports that it broke — a link exposes counters that stop moving, which a quiet
publisher also does. Redialling on a stalled counter was considered and
rejected: a merely slow stream would be redialled underneath you, and samples
lost across the swap would be counted nowhere. A silent loss is worse than an
absent feature.
No hard real-time. Delivery goes through a bounded queue with a background receive thread. If you need microsecond-class latency guarantees, this is not the shape of tool that provides them.
No decoding. By design. See the top of this file.
Types
The package ships py.typed and a complete stub, so mypy --strict sees real
signatures rather than Any.
Migrating from the package that spoke the wire in Python
The subscribing surface is unchanged: subscribe, Subscription and its
methods, Sample, Counts, StreamInfo and schema_id_of all keep their
names and signatures. Four things moved.
counts() answers eight numbers rather than four. The four you had keep
their names. Read dropped first: a slow consumer used to apply backpressure
through the kernel receive buffer, and its loss surfaced as missing,
attributed to the publisher. It now surfaces as dropped, attributed to you.
Monitoring written against missing alone will read a slow consumer as a quiet
one.
missing is credited back when a late sample turns up, where the old
counter never credited back. Ranvier's transports do not reorder — TCP does
not, and in-process delivery is ordered under one lock — so the two definitions
produce the same number today, and reordered says when that stops being true.
The framing primitives are gone. FrameReader, write_frame,
MAX_PAYLOAD, PREFIX and FramingError are not here. The wire belongs to
the ABI now, and a second copy of it in Python is exactly what this package was
rewritten to remove. Nothing raises FramingError any more: a frame that
arrives whole and cannot be read is counted in counts().undecodable, and a
stream that misaligns closes its connection.
announcement() answers this package's own StreamInfo, not a generated
protobuf message. The field paths are the same — info.name,
info.schema.schema_id, info.schema.descriptor, info.host_id — so most code
does not notice, and the protobuf runtime is not a dependency of this package. If you were calling protobuf methods on the result (SerializeToString,
CopyFrom, HasField) those are gone; has_schema and friends replace
HasField.
What it is underneath
A PyO3 binding over Ranvier's C ABI, which is the one boundary every non-Rust binding sits on. The framing, the protobuf decoding, the sequence accounting and the loss policy are the same code the Rust runtime uses and the same code the other language bindings use.
That is the whole argument for the rewrite. The package this replaces was 740
lines of Python implementing a contract that Rust also implemented, and two
implementations of one contract disagree eventually — which is how two
hand-written runtimes came to declare four of six refusal reasons with nothing
positioned to notice. This binding declares none of them: it loops the ABI's own
enumeration functions at import time, so list(RefuseReason) cannot be a
subset of what the contract says.