Python 3.8 or newer is required. Generate bindings with
./scripts/build_python_bindings.sh --test before building a wheel; the generated
module and native library must come from the same build. Missing bindings produce
an import error. Wheels contain a platform-specific native library and are tagged
accordingly.
Python bindings for FlowSDK, providing both low-level FFI access and high-level async MQTT client.
pip install flowsdkThe high-level async client provides a simple asyncio-based API:
import asyncio
from flowsdk import FlowMqttClient
async def main():
# Create client with message callback
client = FlowMqttClient(
"my_client_id",
on_message=lambda topic, payload, qos: print(f"{topic}: {payload}")
)
# Connect and use
await client.connect("broker.emqx.io", 1883)
subscription = await client.subscribe("test/topic", qos=1)
subscription.raise_for_status()
await client.publish("test/topic", b"Hello World!", qos=1)
await asyncio.sleep(2) # Wait for messages
await client.disconnect()
asyncio.run(main())For advanced use cases requiring manual control:
import flowsdk
# Create engine
engine = flowsdk.MqttEngineFfi("client_id", mqtt_version=5)
engine.connect()
# Manual network I/O required (see examples/)- ✅ MQTT 3.1.1 and 5.0 support
- ✅ Async/await API with asyncio
- ✅ TLS/SSL support
- ✅ QUIC transport support
- ✅ QoS 0, 1, 2 support
- ✅ Clean session and persistent sessions
- ✅ Last Will and Testament
- ✅ Automatic reconnection
See the examples directory for more usage patterns:
simple_async_usage.py- Minimal async client exampleasyncio_tcp_client_example.py- Async client lifecycleselect_example.py- Select-based non-blocking I/Oasync_example.py- Manual asyncio with low-level APItest_binding.py- FFI binding testsmqtt5_options.py- Properties, retained publishing, Will, and session options
High-level async MQTT client.
Constructor:
FlowMqttClient(
client_id, transport=TransportType.TCP, mqtt_version=5,
clean_start=True, keep_alive=30, username=None, password=None,
reconnect_base_delay_ms=1000, reconnect_max_delay_ms=30000,
max_reconnect_attempts=0, on_message=None, ca_cert_file=None,
client_cert_file=None, client_key_file=None, insecure_skip_verify=False,
alpn_protocols=None, server_name=None, enable_key_log=False,
*, on_message_full=None, on_event=None, connect_properties=None,
will=None, engine_options=None, auto_reconnect=False, quic_zero_rtt=None,
)Methods:
| Method | Result / options |
|---|---|
await connect(host, port, server_name=None, timeout=10, *, return_result=False) |
Optional ConnectionResult |
await publish(topic, payload, qos=0, retain=None, *, timeout=10, properties=None, priority=None, return_result=False, stream_id=None, early_data=False) |
Packet ID or PublishResult |
await subscribe(topic, qos=0, *, timeout=10, no_local=False, retain_as_published=False, retain_handling=0, properties=None) |
SubscribeResult |
await subscribe_many(subscriptions, *, properties=None, timeout=10, stream_id=None, early_data=False) |
SubscribeResult in input order |
await unsubscribe(topic, *, timeout=10, properties=None) |
UnsubscribeResult |
await unsubscribe_many(topics, *, properties=None, timeout=10, stream_id=None, early_data=False) |
UnsubscribeResult in input order |
await auth(reason_code=0x18, *, properties=None) |
Submit an enhanced-authentication response |
await ping(*, timeout=10) |
Await MQTT PINGRESP |
await disconnect(reason_code=0, *, properties=None, timeout=5) |
Flush DISCONNECT and close |
await acknowledge(packet_id, qos, *, stream_id=None, reason_code=0, properties=None) |
Manual PUBACK / PUBREC |
await complete_qos2(packet_id, *, stream_id=None, reason_code=0, properties=None) |
Manual PUBCOMP |
set_parse_level(level) |
Configure receive parsing after connecting |
pump() |
Drive outgoing I/O and dispatch queued events |
is_connected, mqtt_version, connection_result, reconnect_error, and quic
provide connection state and advanced QUIC controls. Callbacks run synchronously;
use asyncio.create_task(...) to start an async response from on_event.
SubscribeResult and UnsubscribeResult are exported from flowsdk. Both contain
packet_id: int and reason_codes: List[int], preserving broker reason codes in
topic order, including failures. They expose is_success and is_failure boolean
properties. A result with any failed topic has is_failure=True.
Broker rejection returns normally from these methods. Applications can inspect
the result or explicitly call raise_for_status(), which raises MqttAckError
on failure. The exception's result attribute contains the complete result,
including successful entries in a mixed acknowledgement.
from flowsdk import MqttAckError
subscription = await client.subscribe("test/topic", qos=1)
if subscription.is_failure:
print(f"Subscription rejected: {subscription.reason_codes}")
else:
print(f"Subscribed with packet ID {subscription.packet_id}")
unsubscription = await client.unsubscribe("test/topic")
try:
unsubscription.raise_for_status() # Opt in to exceptions for broker rejection.
except MqttAckError as exc:
print(exc.result.packet_id, exc.result.reason_codes)For subscriptions, granted QoS codes 0, 1, and 2 are successful. For
unsubscriptions, 0 and 0x11 ("No subscription existed") are successful;
MQTT 3.1.1's empty UNSUBACK reason-code list also indicates success.
raise_for_status() returns None for successful results.
Migration: subscribe and unsubscribe previously returned an integer packet
ID. Callers that use the return value must now read result.packet_id:
result = await client.subscribe("test/topic", qos=1)
packet_id = result.packet_idCalling publish, subscribe, or unsubscribe without an active MQTT connection
raises ConnectionError.
Local request failures raise an exception immediately, since no acknowledgement
can arrive. Broker-rejected connections raise ConnectionError;
rejected publishes raise RuntimeError. Successful publish calls return their
packet IDs, including "No matching subscribers" outcomes.
Subscribe, unsubscribe, and QoS 1/2 publish wait up to 10 seconds for their
acknowledgement. Set timeout per call, or use timeout=None to disable this
deadline. Expiry raises asyncio.TimeoutError; task cancellation propagates
asyncio.CancelledError. Both remove the Python waiter, and late acknowledgements
are ignored. The queued MQTT operation may still complete in the engine or at
the broker. QoS 0 returns after local submission and does not wait for an ACK.
Transport loss, engine errors, and disconnect finish pending calls with
ConnectionError. Failed, timed-out, or cancelled connection attempts close the
transport and cancel its timer. Calling connect() while already connected or
connecting raises ConnectionError.
Disconnect drives TLS/QUIC output before closing, with a five-second flushing deadline. It closes the socket on timeout or cancellation too. Outstanding calls fail immediately; disconnect does not wait for broker acknowledgements.
Retain and MQTT 5 publish properties work on TCP, TLS, and QUIC. Properties may be
an ordered list of MqttPropertyFfi variants or a PublishProperties record:
from flowsdk import PublishProperties
await client.publish("sensors/temperature", b"23.5", qos=1, retain=True,
properties=PublishProperties(content_type="text/plain",
correlation_data=b"\x00\xff", user_properties=[("source", "sensor"), ("source", "gateway")]))All transports accept priority=0..255; higher values have higher priority.
TCP/TLS schedule queued MQTT packets. QUIC uses a separate automatic publish
stream for each priority and prioritizes both local flushing and QUIC transmission.
FIFO order is preserved within each priority. The MQTT control stream has the
highest transport priority. Stream limits can reject opening another priority
stream. On an explicitly selected stream, publish priority changes that stream
and all its queued packets. client.quic.set_stream_priority(id, priority) changes
the priority directly. Priorities survive rejected 0-RTT replay.
Invalid properties, duplicate singleton properties, invalid QoS/topic values,
and MQTT 5 properties used with MQTT 3.1.1 raise MqttErrorFfi before queueing.
connect(return_result=True) returns ConnectionResult with reason code,
session-present flag, and properties; the result is also in connection_result.
publish(return_result=True) returns PublishResult, including broker rejections,
with is_success, is_failure, and raise_for_status(). Its properties and all
subscription-result properties are preserved. The default publish API still
returns a packet ID and raises on rejection. QoS 0 details describe local submission.
Use on_message_full(message) to receive retain, DUP, packet ID, and properties
alongside topic, payload, and QoS. on_event(event) receives every exposed native
event. The existing three-argument on_message callback remains supported.
Callback exceptions are logged without interrupting acknowledgement processing.
client.is_connected and client.mqtt_version expose current connection state
and the configured protocol version.
subscribe() also accepts no_local, retain_as_published, retain_handling,
and properties. Use subscribe_many() and unsubscribe_many() to send batches:
from flowsdk import Subscription, MqttPropertyFfi
result = await client.subscribe_many([
Subscription("sensors/+", qos=1, no_local=True),
Subscription("alerts/#", qos=2, retain_handling=2),
], properties=[MqttPropertyFfi.SUBSCRIPTION_IDENTIFIER(value=7)])
await client.unsubscribe_many(["sensors/+", "alerts/#"])Batch results preserve topic order and mixed reason codes. Subscription flags and properties require MQTT 5; default batches also work with MQTT 3.1.1. Empty batches, invalid filters, and No Local on a shared subscription are rejected.
Connection properties and Will messages are configured at client construction:
from flowsdk import ConnectProperties, Will
client = FlowMqttClient("sensor", username="device", password=b"binary-password",
clean_start=False,
connect_properties=ConnectProperties(session_expiry_interval=3600,
receive_maximum=32, maximum_packet_size=65536),
will=Will("sensor/status", b"offline", qos=1, retain=True))connect_properties also accepts ordered native properties, including enhanced
authentication fields. Will.properties accepts MQTT 5 Will properties. String
passwords remain supported; bytes preserve arbitrary binary credentials. MQTT
3.1.1 supports Will messages and binary passwords, but rejects MQTT 5 properties.
Enhanced authentication is supported on all three transports. Configure
ConnectProperties(authentication_method=..., authentication_data=...), handle
event.is_auth_received() through on_event, and send a response with
await client.auth(0x18, properties=[...]). AUTH can run before CONNACK or during
reauthentication; the authentication method must remain the one used in CONNECT.
Client reason codes are 0x18 (continue) and 0x19 (reauthenticate after
CONNACK). Responses automatically include the configured authentication method;
server challenges must retain that method.
auth() reports local submission, while subsequent challenges/results arrive as
events. Application-specific authentication computations belong in the callback
or in a task it schedules.
await client.ping(timeout=10.0) sends MQTT PINGREQ on any transport and returns
True on PINGRESP. Only one explicit ping can wait at a time. Ping uses the same
timeout, cancellation, and connection-loss cleanup as other operations.
disconnect(reason_code=0, properties=[...]) supports MQTT 5 reason codes and
properties. Incoming disconnect events retain the broker's properties. Outgoing
codes are checked against the client entries in the
MQTT 5 DISCONNECT table.
MQTT 3.1.1 accepts the default disconnect only. Rust event consumers should handle
MqttEvent::DisconnectReceived for complete MQTT 5 broker-disconnect metadata;
transport-loss notifications remain MqttEvent::Disconnected.
Run the broker-free asyncio wrapper regression tests from the repository root:
python3 -m unittest discover -s python/tests -vSet auto_reconnect=True to reopen a lost connection using exponential backoff
from reconnect_base_delay_ms, capped by reconnect_max_delay_ms.
max_reconnect_attempts=0 means unlimited retries. Automatic retry starts after
a successful connection; initial connection errors are returned to the caller.
The default is disabled. on_event receives retry scheduling events, and
reconnect_error retains the last failed attempt if retries are exhausted.
Pending operations fail on loss. The engine preserves MQTT inflight state for
broker-confirmed session recovery; Python does not resubmit requests. Use a
stable client ID, clean_start=False, and an appropriate MQTT 5 session expiry
for persistent sessions. disconnect() cancels retries and connection setup,
including DNS resolution. A later connect() starts a fresh transport and TLS
handshake using the same engine clock.
QUIC resolves IPv4 and IPv6 addresses and uses an unconnected UDP socket. Each outgoing datagram uses the destination selected by the QUIC engine, including IPv6 scope IDs; receiving a packet does not change the configured peer.
Pass engine_options=EngineOptions(...) for retransmission timeout, ping timeout
multiplier, outgoing queue/event limits, initial parser capacity, max_inflight,
automatic keepalive, automatic acknowledgements, or configured subscriptions.
Omitted values retain the native defaults; numeric limits must be positive.
max_inflight limits outgoing QoS 1/2 messages and also respects the broker limit.
The CONNECT receive_maximum property separately advertises the incoming limit.
EngineOptions(subscriptions=[Subscription("status/#", qos=1)]) subscribes after
a successful CONNACK when the broker has no previous session. SUBACK results are
available through on_event. sessionless=True discards local session state on
transport replacement and requires clean_start=True with zero session expiry.
A broker response indicating a new session also clears obsolete inflight state.
Use EngineOptions(auto_ack=False) to acknowledge received QoS 1/2 messages
yourself. Full messages include packet_id and stream_id. Call
await client.acknowledge(message.packet_id, message.qos, stream_id=message.stream_id)
to send PUBACK or PUBREC. For QoS 2, wait for event.is_pub_rel_received() and
then call await client.complete_qos2(event.packet_id, stream_id=event.stream_id).
Both methods accept MQTT 5 reason codes and properties. Outgoing publish
handshakes remain managed by the engine. These methods report local submission.
PublishReceived metadata precedes each full message; PubRelReceived retains
the packet and stream ID. QUIC manual acknowledgements use that same stream and
respect its send-buffer limit. TCP/TLS reject explicit stream IDs.
After connecting with no outstanding operations, client.set_parse_level(...)
accepts MqttParseLevelFfi.FULL, .HEADERS_PARSED, or .TYPE_ONLY. Reduced modes
report receive metadata without full messages. They are intended for receive
counting; outgoing operations that require MQTT acknowledgements are rejected.
Type-only mode discards packet IDs and therefore only supports incoming QoS 0.
Reconnect resets the parser to Full so CONNECT can complete.
client.quic exposes open_stream(), finish_stream(id),
reset_stream(id, error_code=0), stop_stream(id, error_code=0),
control_stream_id, and data_stream_count. Use
await client.quic.publish(id, topic, payload, qos=1, properties=...),
await client.quic.subscribe(id, [Subscription(...)]), or
await client.quic.unsubscribe(id, [topic]) to select a data stream.
Normal client methods continue to use the engine's default streams.
client.quic.ping() queues a QUIC PING. notify_local_address_changed() notifies
the engine after an external address change; it does not rebind the UDP socket.
close_transport(error_code=0, reason=b"", silent=False) closes QUIC directly
and disables retries. Use await client.disconnect() for MQTT DISCONNECT.
Transport close details and stream close/reset/stop events reach on_event.
Enable the in-memory TLS ticket cache with
quic_zero_rtt=QuicZeroRttOptions(session_cache_size=256, replay_on_reject=True).
Read client.quic.zero_rtt_status, observe ZeroRttStatusChanged events, or
call client.quic.clear_session_cache(). The first connection normally reports
Unavailable; subsequent connections may report Attempted, Accepted, or Rejected.
To submit before CONNACK, explicitly use early_data=True on publish or batch
subscription methods while the status is Attempted. Early data can be replayed,
so applications must choose operations that tolerate duplicates. With
replay_on_reject=True, the engine resends rejected early bytes after the QUIC
handshake; with False, it leaves recovery to the caller.
Run ./scripts/build_python_bindings.sh --test from any directory. The suite
checks generated bindings as well as the asyncio wrapper. Live-broker tests are
opt-in; their TCP/TLS/QUIC setup is documented in the examples guide.
Regenerate Python, Swift, and Kotlin bindings whenever the native FFI changes.
Existing C layouts and signatures are retained; UniFFI records and events have
expanded and require matching generated bindings. Python package and native crate
versions are currently 0.7.0. Release wheels build their native library inside
the target build environment and run the regression suite against the installed
wheel before upload.
Apache License 2.0
Disk checkpoint/restore is disabled by default. Build bindings with
bash scripts/build_python_bindings.sh --durable-session --test to enable it.
Without it, session_state= and checkpoint_for_restart() raise
MqttErrorFfi.Unsupported; the generated native checkpoint APIs are absent.
Ordinary reconnect and in-memory session resumption work with either build.
RuntimeOptions adds protocol OperationTimeouts, incoming receive quota and
incoming/outgoing byte limits. These deadlines are separate from Python's
timeout= argument; OperationTimeouts.cloud() selects 30 seconds for CONNECT
and 10 seconds for publish/subscribe/unsubscribe. Defaults remain disabled.
OperationFailedError identifies the affected operation and packet ID without
closing unrelated healthy work. A late ACK is still delivered through on_event.
For planned restarts, construct FlowMqttClient with clean_start=False, a
nonzero MQTT 5 ConnectProperties(session_expiry_interval=...), and
RuntimeOptions(peer="tcp://broker.example:1883"). The peer must match the
transport and the original hostname/port passed to connect (lowercase host,
bracketed IPv6). Low-level FFI engines allow application-defined peer identities.
Call await client.checkpoint_for_restart() to stop commands, timers and retries,
retire the transport abruptly, and return stable opaque bytes. This may trigger
the Will. The old client cannot connect again. Pending Python futures fail; they
are not serialized. Persist the bytes before constructing a fresh client with
session_state=saved_bytes and the same options. Supply credentials and Will again.
An empty client ID can be recovered from saved metadata; explicit mismatches fail.
ClientSessionStore defines application-owned create/resume/update/delete.
SqliteSessionStore implements transactional commits with SQLite FULL synchronous
mode and propagates native storage errors. Its methods are synchronous and must
run outside engine callbacks. Use one writer per key and include account identity
in keys as needed. python/examples/durable_session.py shows the lifecycle.
A snapshot is not automatic crash persistence: persist-before-send/event ordering
and a transactional application inbox remain the application's responsibility.
Python owns one opt-in reconnect scheduler (auto_reconnect=False by default).
During an actual retry it keeps ACK waiters and lets the engine replay existing
exchanges; disabling/exhausting retries fails them. set_auto_reconnect(False)
cancels retries. Offline publishing remains unsupported by the high-level client;
there is no additional Python replay queue. Canceling an await removes only its
waiter. disconnect() waits for protocol output and the socket close, bounded by
five seconds by default (also when timeout=None); cancellation aborts the socket.
client.quic.subscribe_on_control(...) and unsubscribe_on_control(...) expose
explicit control-stream commands. Default subscriptions still use a data stream.
Keep generated bindings and their native library from the same build together.