Python node library for a flow-based, language-agnostic runtime — Node-RED
without the UI. The reference runtime lives in
ruby_zmq_framework; this
library lets a Python process join that flow as a full citizen, or run
entirely standalone with no Ruby involved.
- Nodes are independent OS processes. Each one does one job, lives in one file, and knows nothing about any other node — not their ports, not their names, not their language.
- Wires are pub/sub topics carrying JSON, over ZeroMQ.
- The graph is data:
flow.yml(in the Ruby repo) is the only artifact that knows the topology.bin/flowctlreads it, computes the wiring, and runs every node — Python or Ruby — with that wiring in its environment. - The contract is one page:
PROTOCOL.mdis everything a node in any language needs to join.
Only dependency is pyzmq. Nothing here requires Ruby to install or run —
boot() also works standalone with no environment set, for local iteration
or running a single node on its own.
pip install -e .
# or, without installing the package:
pip install -r requirements.txtfrom python_zmq_framework import FrameworkNode, boot
class RpmSmoother(FrameworkNode):
def __init__(self, bus):
super().__init__(bus)
self.window = []
def handle_message(self, topic, payload):
self.window = (self.window + [payload["rpm"]])[-5:]
self.broadcast("engine_data_smooth", {"rpm": sum(self.window) / len(self.window)})
RpmSmoother_instance = boot(RpmSmoother)Note what's absent: no ports, no peers, no .subscribe() calls. Wiring
comes entirely from environment variables — BUS_PORT, BUS_PEERS,
BUS_SUBSCRIBES, NODE_NAME — set by whatever launches the process (see
PROTOCOL.md). A flow manifest entry in the Ruby repo's
flow.yml computes and injects them:
rpm_smoother:
cmd: python3 ../python_zmq_framework/nodes/rpm_smoother.py
subscribes: [engine_data]
publishes: [engine_data_smooth]Run standalone (no environment needed — it binds an ephemeral port) to poke
at a node in isolation: python3 nodes/dbc_decoder.py.
Every node automatically heartbeats every 5 seconds.
FrameworkNode.handle_message is @abstractmethod, so Python's ABC
machinery raises TypeError the moment you try to instantiate a subclass
that forgot it — before __init__ runs at all, so no heartbeat thread can
leak from a failed construction. That's exactly the fast, specific feedback
an iterating LLM agent needs.
ZeroMQBusbinds a PUB socket (pass0for an OS-assigned ephemeral port, readable back via.port) and connects a SUB socket to every peer. Peers may be ints (loopback ports),"host:port"strings, or full ZeroMQ endpoints; passbind_host="0.0.0.0"to accept peers from other machines. There is no dynamic discovery — every node's address has to be listed in every peer'speer_portsup front (or computed for you bybin/flowctl). Messages published on a bus are also delivered synchronously to subscribers on that same bus.FrameworkNodeis the base class for a node on the bus. Subclass it and implementhandle_message(topic, payload). You get an automaticheartbeatbroadcast (node_name,status,timestamp) every 5 seconds for free.node_namedefaults to the class name; setself.node_name = "..."before callingsuper().__init__()to give distinct instances distinct identities.boot(NodeClass)builds a node entirely from the environment contract inPROTOCOL.md— the same helper the Ruby gem exposes asRubyZmqFramework.boot.- Resilience: the listener survives anything the network throws at
it — non-framework frame layouts and malformed JSON are dropped with a
warning, and each subscriber's
handle_messageis caught individually so one raising handler can't starve the others or kill the listener. Handlers on one bus never run concurrently. - Clean shutdown:
bus.close()stops the listener and releases the sockets;node.stop_heartbeat()ends the heartbeat thread. Stop your node first, then close the bus — publishing on a closed bus raisesZeroMQBusError. - Thread safety: a ZeroMQ PUB socket must not be written to
concurrently from multiple threads. The heartbeat thread and whatever
thread calls
broadcast()/publish()both use the same socket, soZeroMQBus.publishtakes a lock around every send.
pip install -r requirements.txt
python3 nodes/dbc_decoder.py # broadcasts engine_data
python3 nodes/telemetry_logger.py # logs whatever BUS_SUBSCRIBES namesnodes/dbc_decoder.py is a stand-in for a real cantools-based DBC
decoder — a pure producer. nodes/telemetry_logger.py is a consumer: it
calls no .subscribe() itself, so wiring it to engine_data is purely an
environment-variable exercise:
BUS_PORT=5561 NODE_NAME=dbc_decoder python3 nodes/dbc_decoder.py &
BUS_PORT=5562 BUS_PEERS=127.0.0.1:5561 BUS_SUBSCRIBES=engine_data,heartbeat \
NODE_NAME=logger python3 nodes/telemetry_logger.pypython -m unittest discover -s testsThe wire protocol and environment contract are shared — see
PROTOCOL.md — so a Python node and a Ruby node
interoperate automatically whenever both are wired into the same
flow.yml (in ruby_zmq_framework),
or by hand:
bus = ZeroMQBus(5561, peer_ports=[5558]) # 5558 = a Ruby StateRegistry nodeThis is entirely optional — nothing here requires the Ruby gem to be
installed or running, and boot() works with only Python processes present.