From 1d8e8a212fa2ea3c1f2c6eaed3bc9517ea93e317 Mon Sep 17 00:00:00 2001 From: Luis Date: Sun, 5 Apr 2026 13:35:05 +0200 Subject: [PATCH 01/54] feat(python-sdk): queue-backed TypedSubscriber/RawSubscriber with optional timeout --- python-sdk/bubbaloop_sdk/subscriber.py | 211 ++++++++++++++++++++++--- python-sdk/tests/test_context.py | 91 +++++++++++ 2 files changed, 279 insertions(+), 23 deletions(-) diff --git a/python-sdk/bubbaloop_sdk/subscriber.py b/python-sdk/bubbaloop_sdk/subscriber.py index 9ba8ec9..ad11f29 100644 --- a/python-sdk/bubbaloop_sdk/subscriber.py +++ b/python-sdk/bubbaloop_sdk/subscriber.py @@ -1,54 +1,219 @@ -"""Blocking Zenoh subscribers.""" +"""Zenoh subscribers — blocking and callback-based.""" + +import queue import zenoh class TypedSubscriber: - """Blocking subscriber. Iterates with ``for msg in sub`` (blocks on each recv).""" + """Blocking subscriber with optional timeout. Iterates with ``for msg in sub``. + + Internally queue-backed: Zenoh delivers samples via a callback into a + ``queue.Queue``. ``recv()`` drains the queue with an optional timeout, + allowing clean shutdown integration:: + + while not ctx.is_shutdown(): + msg = sub.recv(timeout=5.0) + if msg is None: + continue + process(msg) + + Without a timeout, ``recv()`` blocks indefinitely (backward-compatible). + """ def __init__(self, session: zenoh.Session, topic: str, msg_class=None): - self._sub = session.declare_subscriber(topic) + self._queue: queue.Queue = queue.Queue() self._msg_class = msg_class - def recv(self): - """Block until the next sample arrives and return the decoded message.""" - sample = self._sub.recv() - payload = bytes(sample.payload.to_bytes()) - if self._msg_class is not None and hasattr(self._msg_class, "FromString"): - return self._msg_class.FromString(payload) - return payload + def _on_sample(sample: zenoh.Sample) -> None: + payload = bytes(sample.payload.to_bytes()) + if self._msg_class is not None and hasattr(self._msg_class, "FromString"): + self._queue.put(self._msg_class.FromString(payload)) + else: + self._queue.put(payload) + + self._sub = session.declare_subscriber(topic, _on_sample) + + def recv(self, timeout: float | None = None): + """Block until the next message arrives. Returns ``None`` on timeout.""" + try: + return self._queue.get(timeout=timeout) + except queue.Empty: + return None def __iter__(self): return self def __next__(self): - try: - return self.recv() - except Exception as exc: - raise StopIteration from exc + msg = self.recv() + if msg is None: + raise StopIteration + return msg def undeclare(self) -> None: + """Undeclare the subscriber and stop receiving samples.""" self._sub.undeclare() class RawSubscriber: - """Blocking subscriber that yields raw zenoh ``Sample`` objects.""" + """Blocking subscriber that yields raw ``zenoh.Sample`` objects, with optional timeout. + + Internally queue-backed — same pattern as ``TypedSubscriber``. + ``recv()`` also exposes an optional timeout for shutdown-aware loops. + """ def __init__(self, session: zenoh.Session, key_expr: str): - self._sub = session.declare_subscriber(key_expr) + self._queue: queue.Queue = queue.Queue() + + def _on_sample(sample: zenoh.Sample) -> None: + self._queue.put(sample) - def recv(self): - """Block until the next sample and return it.""" - return self._sub.recv() + self._sub = session.declare_subscriber(key_expr, _on_sample) + + def recv(self, timeout: float | None = None): + """Block until the next sample arrives. Returns ``None`` on timeout.""" + try: + return self._queue.get(timeout=timeout) + except queue.Empty: + return None def __iter__(self): return self def __next__(self): - try: - return self.recv() - except Exception as exc: - raise StopIteration from exc + sample = self.recv() + if sample is None: + raise StopIteration + return sample + + def undeclare(self) -> None: + """Undeclare the subscriber and stop receiving samples.""" + self._sub.undeclare() + + +class CallbackSubscriber: + """Callback-based subscriber — Zenoh calls ``handler`` from its internal thread. + + No loop required from the caller. All declared ``CallbackSubscriber`` instances + on the same session receive concurrently and independently. + + ``handler`` receives a decoded message (``msg_class.FromString(payload)``) if + ``msg_class`` is provided, or raw ``bytes`` otherwise. + + **Threading contract:** ``handler`` is called from Zenoh's internal thread. + If you share state with a main loop, protect it with a lock:: + + lock = threading.Lock() + last_value = None + + def on_msg(msg): + nonlocal last_value + with lock: + last_value = msg + + sub = ctx.subscriber_callback("sensor/data", on_msg, SensorData) + + For slow handlers (database writes, hardware I/O), use + ``ctx.subscriber_callback_async()`` instead to avoid blocking Zenoh's thread. + + Keep the returned object alive — garbage-collecting it undeclares the subscriber. + """ + + def __init__(self, session: zenoh.Session, topic: str, handler, msg_class=None): + def _wrap(sample: zenoh.Sample) -> None: + payload = bytes(sample.payload.to_bytes()) + if msg_class is not None and hasattr(msg_class, "FromString"): + handler(msg_class.FromString(payload)) + else: + handler(payload) + + self._sub = session.declare_subscriber(topic, _wrap) + + def undeclare(self) -> None: + """Undeclare the subscriber and stop receiving samples.""" + self._sub.undeclare() + + +class RawCallbackSubscriber: + """Callback-based subscriber that passes raw ``zenoh.Sample`` to the handler. + + Use when you need access to the full sample metadata (key_expr, encoding, + timestamp). Handler is called from Zenoh's internal thread. + + For slow handlers, use ``ctx.subscriber_raw_callback_async()`` instead. + + Keep the returned object alive — garbage-collecting it undeclares the subscriber. + """ + + def __init__(self, session: zenoh.Session, key_expr: str, handler): + self._sub = session.declare_subscriber(key_expr, handler) + + def undeclare(self) -> None: + """Undeclare the subscriber and stop receiving samples.""" + self._sub.undeclare() + + +class CallbackSubscriberAsync: + """Callback subscriber that runs ``handler`` in a ``ThreadPoolExecutor``. + + **Use this when your handler does slow work** (database writes, hardware reads, + HTTP calls). Zenoh uses a single internal thread for all callbacks — if a handler + blocks, ALL other subscribers and queryables on the same session are delayed + until it returns. ``CallbackSubscriberAsync`` fixes this by submitting the + handler to a thread pool immediately and returning, freeing Zenoh's thread:: + + # PROBLEM: on_insert blocks Zenoh's thread for 200ms per message + sub = ctx.subscriber_callback("data", on_insert) + + # SOLUTION: handler runs in thread pool, Zenoh thread is free instantly + sub = ctx.subscriber_callback_async("data", on_insert) + + **Threading contract:** multiple invocations of ``handler`` may run concurrently + if messages arrive faster than the handler processes them. Protect shared state + with locks. + + Keep the returned object alive — garbage-collecting it undeclares the subscriber. + """ + + def __init__(self, session: zenoh.Session, topic: str, handler, msg_class=None, max_workers: int = 4): + import concurrent.futures + self._executor = concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) + + def _wrap(sample: zenoh.Sample) -> None: + payload = bytes(sample.payload.to_bytes()) + if msg_class is not None and hasattr(msg_class, "FromString"): + msg = msg_class.FromString(payload) + else: + msg = payload + self._executor.submit(handler, msg) + + self._sub = session.declare_subscriber(topic, _wrap) + + def undeclare(self) -> None: + """Shutdown the thread pool and undeclare the subscriber.""" + self._executor.shutdown(wait=False) + self._sub.undeclare() + + +class RawCallbackSubscriberAsync: + """Raw callback subscriber that runs ``handler`` in a ``ThreadPoolExecutor``. + + Same as ``CallbackSubscriberAsync`` but passes raw ``zenoh.Sample`` objects. + Use when you need sample metadata AND your handler does slow work. + + Keep the returned object alive — garbage-collecting it undeclares the subscriber. + """ + + def __init__(self, session: zenoh.Session, key_expr: str, handler, max_workers: int = 4): + import concurrent.futures + self._executor = concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) + + def _wrap(sample: zenoh.Sample) -> None: + self._executor.submit(handler, sample) + + self._sub = session.declare_subscriber(key_expr, _wrap) def undeclare(self) -> None: + """Shutdown the thread pool and undeclare the subscriber.""" + self._executor.shutdown(wait=False) self._sub.undeclare() diff --git a/python-sdk/tests/test_context.py b/python-sdk/tests/test_context.py index 9568153..03da1d6 100644 --- a/python-sdk/tests/test_context.py +++ b/python-sdk/tests/test_context.py @@ -141,6 +141,97 @@ def test_json_publisher_passthrough_str(): mock_pub.put.assert_called_once_with(b"hello") +# --------------------------------------------------------------------------- +# TypedSubscriber — queue-backed with timeout +# --------------------------------------------------------------------------- + +def test_typed_subscriber_recv_returns_none_on_timeout(): + """recv(timeout) returns None when queue is empty within timeout.""" + from bubbaloop_sdk.subscriber import TypedSubscriber + mock_session = MagicMock() + mock_session.declare_subscriber.return_value = MagicMock() + sub = TypedSubscriber(mock_session, "test/topic") + result = sub.recv(timeout=0.05) + assert result is None + + +def test_typed_subscriber_recv_returns_message_when_available(): + """recv() returns the message put into the queue by the callback.""" + from bubbaloop_sdk.subscriber import TypedSubscriber + mock_session = MagicMock() + captured_handler = [] + + def fake_declare(topic, handler): + captured_handler.append(handler) + return MagicMock() + + mock_session.declare_subscriber.side_effect = fake_declare + sub = TypedSubscriber(mock_session, "test/topic") + + # Simulate Zenoh delivering a sample + fake_sample = MagicMock() + fake_sample.payload.to_bytes.return_value = b"\x01\x02" + captured_handler[0](fake_sample) + + result = sub.recv(timeout=1.0) + assert result == b"\x01\x02" + + +def test_typed_subscriber_recv_decodes_proto(): + """recv() decodes with FromString when msg_class provided.""" + from bubbaloop_sdk.subscriber import TypedSubscriber + mock_session = MagicMock() + captured_handler = [] + + def fake_declare(topic, handler): + captured_handler.append(handler) + return MagicMock() + + mock_session.declare_subscriber.side_effect = fake_declare + + fake_msg_class = MagicMock() + fake_msg_class.FromString.return_value = "decoded" + sub = TypedSubscriber(mock_session, "test/topic", msg_class=fake_msg_class) + + fake_sample = MagicMock() + fake_sample.payload.to_bytes.return_value = b"\x01" + captured_handler[0](fake_sample) + + result = sub.recv(timeout=1.0) + assert result == "decoded" + fake_msg_class.FromString.assert_called_once_with(b"\x01") + + +def test_raw_subscriber_recv_returns_none_on_timeout(): + """RawSubscriber.recv(timeout) returns None when queue is empty.""" + from bubbaloop_sdk.subscriber import RawSubscriber + mock_session = MagicMock() + mock_session.declare_subscriber.return_value = MagicMock() + sub = RawSubscriber(mock_session, "test/topic") + result = sub.recv(timeout=0.05) + assert result is None + + +def test_raw_subscriber_recv_returns_sample(): + """RawSubscriber.recv() returns the raw zenoh.Sample.""" + from bubbaloop_sdk.subscriber import RawSubscriber + mock_session = MagicMock() + captured_handler = [] + + def fake_declare(topic, handler): + captured_handler.append(handler) + return MagicMock() + + mock_session.declare_subscriber.side_effect = fake_declare + sub = RawSubscriber(mock_session, "test/topic") + + fake_sample = MagicMock() + captured_handler[0](fake_sample) + + result = sub.recv(timeout=1.0) + assert result is fake_sample + + # --------------------------------------------------------------------------- # Helper # --------------------------------------------------------------------------- From afadda27349eb68b83468880fb5ee7ab05e3c7c5 Mon Sep 17 00:00:00 2001 From: Luis Date: Sun, 5 Apr 2026 14:17:24 +0200 Subject: [PATCH 02/54] test(python-sdk): tests for callback subscriber variants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add 8 tests covering CallbackSubscriber, RawCallbackSubscriber, CallbackSubscriberAsync, and RawCallbackSubscriberAsync — bytes/proto decode, undeclare, and thread-pool dispatch paths. Co-Authored-By: Claude Sonnet 4.6 --- python-sdk/tests/test_context.py | 193 +++++++++++++++++++++++++++++++ 1 file changed, 193 insertions(+) diff --git a/python-sdk/tests/test_context.py b/python-sdk/tests/test_context.py index 03da1d6..f594e41 100644 --- a/python-sdk/tests/test_context.py +++ b/python-sdk/tests/test_context.py @@ -232,6 +232,199 @@ def fake_declare(topic, handler): assert result is fake_sample +# --------------------------------------------------------------------------- +# CallbackSubscriber +# --------------------------------------------------------------------------- + +def test_callback_subscriber_calls_handler_with_bytes(): + """Handler receives raw bytes when no msg_class provided.""" + from bubbaloop_sdk.subscriber import CallbackSubscriber + mock_session = MagicMock() + captured_handler = [] + + def fake_declare(topic, handler): + captured_handler.append(handler) + return MagicMock() + + mock_session.declare_subscriber.side_effect = fake_declare + received = [] + sub = CallbackSubscriber(mock_session, "test/topic", lambda msg: received.append(msg)) + + fake_sample = MagicMock() + fake_sample.payload.to_bytes.return_value = b"\xde\xad" + captured_handler[0](fake_sample) + + assert received == [b"\xde\xad"] + + +def test_callback_subscriber_decodes_proto(): + """Handler receives decoded proto when msg_class provided.""" + from bubbaloop_sdk.subscriber import CallbackSubscriber + mock_session = MagicMock() + captured_handler = [] + + def fake_declare(topic, handler): + captured_handler.append(handler) + return MagicMock() + + mock_session.declare_subscriber.side_effect = fake_declare + + fake_msg_class = MagicMock() + fake_msg_class.FromString.return_value = "decoded_proto" + received = [] + sub = CallbackSubscriber(mock_session, "test/topic", + lambda msg: received.append(msg), msg_class=fake_msg_class) + + fake_sample = MagicMock() + fake_sample.payload.to_bytes.return_value = b"\x01" + captured_handler[0](fake_sample) + + assert received == ["decoded_proto"] + fake_msg_class.FromString.assert_called_once_with(b"\x01") + + +def test_callback_subscriber_undeclare(): + """undeclare() calls undeclare on the underlying zenoh subscriber.""" + from bubbaloop_sdk.subscriber import CallbackSubscriber + mock_session = MagicMock() + mock_sub = MagicMock() + mock_session.declare_subscriber.return_value = mock_sub + sub = CallbackSubscriber(mock_session, "test/topic", lambda msg: None) + sub.undeclare() + mock_sub.undeclare.assert_called_once() + + +# --------------------------------------------------------------------------- +# RawCallbackSubscriber +# --------------------------------------------------------------------------- + +def test_raw_callback_subscriber_passes_sample(): + """Handler receives the raw zenoh.Sample object.""" + from bubbaloop_sdk.subscriber import RawCallbackSubscriber + mock_session = MagicMock() + captured_handler = [] + + def fake_declare(key_expr, handler): + captured_handler.append(handler) + return MagicMock() + + mock_session.declare_subscriber.side_effect = fake_declare + received = [] + sub = RawCallbackSubscriber(mock_session, "test/**", lambda s: received.append(s)) + + fake_sample = MagicMock() + captured_handler[0](fake_sample) + + assert received == [fake_sample] + + +def test_raw_callback_subscriber_undeclare(): + """undeclare() calls undeclare on the underlying zenoh subscriber.""" + from bubbaloop_sdk.subscriber import RawCallbackSubscriber + mock_session = MagicMock() + mock_sub = MagicMock() + mock_session.declare_subscriber.return_value = mock_sub + sub = RawCallbackSubscriber(mock_session, "test/**", lambda s: None) + sub.undeclare() + mock_sub.undeclare.assert_called_once() + + +# --------------------------------------------------------------------------- +# CallbackSubscriberAsync +# --------------------------------------------------------------------------- + +def test_callback_subscriber_async_calls_handler_in_thread_pool(): + """Handler is called asynchronously via thread pool.""" + import threading + from bubbaloop_sdk.subscriber import CallbackSubscriberAsync + mock_session = MagicMock() + captured_handler = [] + + def fake_declare(topic, handler): + captured_handler.append(handler) + return MagicMock() + + mock_session.declare_subscriber.side_effect = fake_declare + received = [] + event = threading.Event() + + def slow_handler(msg): + received.append(msg) + event.set() + + sub = CallbackSubscriberAsync(mock_session, "test/topic", slow_handler) + + fake_sample = MagicMock() + fake_sample.payload.to_bytes.return_value = b"\xca\xfe" + captured_handler[0](fake_sample) + + assert event.wait(timeout=2.0), "handler was not called within 2s" + assert received == [b"\xca\xfe"] + sub.undeclare() + + +def test_callback_subscriber_async_decodes_proto(): + """Handler receives decoded proto when msg_class provided.""" + import threading + from bubbaloop_sdk.subscriber import CallbackSubscriberAsync + mock_session = MagicMock() + captured_handler = [] + + def fake_declare(topic, handler): + captured_handler.append(handler) + return MagicMock() + + mock_session.declare_subscriber.side_effect = fake_declare + + fake_msg_class = MagicMock() + fake_msg_class.FromString.return_value = "decoded" + received = [] + event = threading.Event() + + def handler(msg): + received.append(msg) + event.set() + + sub = CallbackSubscriberAsync(mock_session, "test/topic", handler, msg_class=fake_msg_class) + + fake_sample = MagicMock() + fake_sample.payload.to_bytes.return_value = b"\x01" + captured_handler[0](fake_sample) + + assert event.wait(timeout=2.0) + assert received == ["decoded"] + sub.undeclare() + + +def test_raw_callback_subscriber_async_passes_sample(): + """RawCallbackSubscriberAsync handler receives raw zenoh.Sample.""" + import threading + from bubbaloop_sdk.subscriber import RawCallbackSubscriberAsync + mock_session = MagicMock() + captured_handler = [] + + def fake_declare(key_expr, handler): + captured_handler.append(handler) + return MagicMock() + + mock_session.declare_subscriber.side_effect = fake_declare + received = [] + event = threading.Event() + + def handler(sample): + received.append(sample) + event.set() + + sub = RawCallbackSubscriberAsync(mock_session, "test/**", handler) + + fake_sample = MagicMock() + captured_handler[0](fake_sample) + + assert event.wait(timeout=2.0) + assert received == [fake_sample] + sub.undeclare() + + # --------------------------------------------------------------------------- # Helper # --------------------------------------------------------------------------- From d7f4de1d5456f13dcf67ea80278393bfbc086875 Mon Sep 17 00:00:00 2001 From: Luis Date: Sun, 5 Apr 2026 14:43:42 +0200 Subject: [PATCH 03/54] test(python-sdk): tests for callback subscriber variants and async undeclare --- python-sdk/tests/test_context.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/python-sdk/tests/test_context.py b/python-sdk/tests/test_context.py index f594e41..5c2dd70 100644 --- a/python-sdk/tests/test_context.py +++ b/python-sdk/tests/test_context.py @@ -425,6 +425,28 @@ def handler(sample): sub.undeclare() +def test_callback_subscriber_async_undeclare(): + """undeclare() shuts down executor and undeclares underlying subscriber.""" + from bubbaloop_sdk.subscriber import CallbackSubscriberAsync + mock_session = MagicMock() + mock_sub = MagicMock() + mock_session.declare_subscriber.return_value = mock_sub + sub = CallbackSubscriberAsync(mock_session, "test/topic", lambda msg: None) + sub.undeclare() + mock_sub.undeclare.assert_called_once() + + +def test_raw_callback_subscriber_async_undeclare(): + """undeclare() shuts down executor and undeclares underlying subscriber.""" + from bubbaloop_sdk.subscriber import RawCallbackSubscriberAsync + mock_session = MagicMock() + mock_sub = MagicMock() + mock_session.declare_subscriber.return_value = mock_sub + sub = RawCallbackSubscriberAsync(mock_session, "test/**", lambda s: None) + sub.undeclare() + mock_sub.undeclare.assert_called_once() + + # --------------------------------------------------------------------------- # Helper # --------------------------------------------------------------------------- From 9e2e9d43669b3d1bd5ff981371f84559c49b2903 Mon Sep 17 00:00:00 2001 From: Luis Date: Sun, 5 Apr 2026 14:46:21 +0200 Subject: [PATCH 04/54] fix(python-sdk): move concurrent.futures import to module level, fix undeclare order in async subscribers --- python-sdk/bubbaloop_sdk/subscriber.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/python-sdk/bubbaloop_sdk/subscriber.py b/python-sdk/bubbaloop_sdk/subscriber.py index ad11f29..5f8ab20 100644 --- a/python-sdk/bubbaloop_sdk/subscriber.py +++ b/python-sdk/bubbaloop_sdk/subscriber.py @@ -1,5 +1,6 @@ """Zenoh subscribers — blocking and callback-based.""" +import concurrent.futures import queue import zenoh @@ -176,7 +177,6 @@ class CallbackSubscriberAsync: """ def __init__(self, session: zenoh.Session, topic: str, handler, msg_class=None, max_workers: int = 4): - import concurrent.futures self._executor = concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) def _wrap(sample: zenoh.Sample) -> None: @@ -190,9 +190,9 @@ def _wrap(sample: zenoh.Sample) -> None: self._sub = session.declare_subscriber(topic, _wrap) def undeclare(self) -> None: - """Shutdown the thread pool and undeclare the subscriber.""" + """Undeclare the subscriber and shutdown the thread pool.""" + self._sub.undeclare() # stop Zenoh callbacks first self._executor.shutdown(wait=False) - self._sub.undeclare() class RawCallbackSubscriberAsync: @@ -205,7 +205,6 @@ class RawCallbackSubscriberAsync: """ def __init__(self, session: zenoh.Session, key_expr: str, handler, max_workers: int = 4): - import concurrent.futures self._executor = concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) def _wrap(sample: zenoh.Sample) -> None: @@ -214,6 +213,6 @@ def _wrap(sample: zenoh.Sample) -> None: self._sub = session.declare_subscriber(key_expr, _wrap) def undeclare(self) -> None: - """Shutdown the thread pool and undeclare the subscriber.""" + """Undeclare the subscriber and shutdown the thread pool.""" + self._sub.undeclare() # stop Zenoh callbacks first self._executor.shutdown(wait=False) - self._sub.undeclare() From 2e0f26374a899cf7e328d84d75d7de103707d660 Mon Sep 17 00:00:00 2001 From: Luis Date: Sun, 5 Apr 2026 14:50:28 +0200 Subject: [PATCH 05/54] feat(python-sdk): add queryable() and subscriber_callback() methods to NodeContext Adds subscriber_callback(), subscriber_raw_callback(), subscriber_callback_async(), subscriber_raw_callback_async(), queryable(), queryable_raw(), queryable_async(), and queryable_raw_async() factory methods to NodeContext. Includes 9 new tests (41 total). Co-Authored-By: Claude Sonnet 4.6 --- python-sdk/bubbaloop_sdk/context.py | 103 ++++++++++++++++++++++++++ python-sdk/tests/test_context.py | 110 ++++++++++++++++++++++++++++ 2 files changed, 213 insertions(+) diff --git a/python-sdk/bubbaloop_sdk/context.py b/python-sdk/bubbaloop_sdk/context.py index 48d44d3..633e916 100644 --- a/python-sdk/bubbaloop_sdk/context.py +++ b/python-sdk/bubbaloop_sdk/context.py @@ -119,6 +119,109 @@ def subscriber_raw(self, key_expr: str) -> "RawSubscriber": from .subscriber import RawSubscriber return RawSubscriber(self.session, key_expr) + # ------------------------------------------------------------------ + # Callback Subscribers + # ------------------------------------------------------------------ + + def subscriber_callback(self, suffix: str, handler, msg_class=None) -> "CallbackSubscriber": + """Callback subscriber at ``topic(suffix)``. + + ``handler`` is called from Zenoh's internal thread each time a sample + arrives. For slow handlers (I/O, DB), use ``subscriber_callback_async()``. + """ + from .subscriber import CallbackSubscriber + return CallbackSubscriber(self.session, self.topic(suffix), handler, msg_class) + + def subscriber_raw_callback(self, key_expr: str, handler) -> "RawCallbackSubscriber": + """Callback subscriber at a literal key expression. + + ``handler`` receives raw ``zenoh.Sample`` objects from Zenoh's internal thread. + """ + from .subscriber import RawCallbackSubscriber + return RawCallbackSubscriber(self.session, key_expr, handler) + + def subscriber_callback_async(self, suffix: str, handler, msg_class=None, max_workers: int = 4) -> "CallbackSubscriberAsync": + """Callback subscriber at ``topic(suffix)`` with handler in a thread pool. + + Use when ``handler`` does slow work (database writes, hardware I/O, network + calls). Zenoh's internal thread is freed immediately; the handler runs in a + ``ThreadPoolExecutor`` with ``max_workers`` threads. + """ + from .subscriber import CallbackSubscriberAsync + return CallbackSubscriberAsync(self.session, self.topic(suffix), handler, msg_class, max_workers) + + def subscriber_raw_callback_async(self, key_expr: str, handler, max_workers: int = 4) -> "RawCallbackSubscriberAsync": + """Raw callback subscriber at a literal key expression with handler in a thread pool.""" + from .subscriber import RawCallbackSubscriberAsync + return RawCallbackSubscriberAsync(self.session, key_expr, handler, max_workers) + + # ------------------------------------------------------------------ + # Queryables + # ------------------------------------------------------------------ + + def queryable(self, suffix: str, handler) -> "zenoh.Queryable": + """Declare a queryable at ``topic(suffix)``. + + ``handler`` receives a ``zenoh.Query``. Use the standard zenoh API to reply:: + + def on_command(query: zenoh.Query) -> None: + result = process(query.payload.to_string()) + query.reply(query.key_expr, json.dumps(result).encode()) + + qbl = ctx.queryable("command", on_command) + + **Important:** do NOT pass ``complete=True`` — it blocks wildcard queries + like ``bubbaloop/**/schema`` used by the dashboard. + + For slow handlers, use ``queryable_async()``. + + Keep the returned object alive — garbage-collecting it undeclares the queryable. + """ + return self.session.declare_queryable(self.topic(suffix), handler) + + def queryable_raw(self, key_expr: str, handler) -> "zenoh.Queryable": + """Declare a queryable at a literal key expression (no topic prefix). + + Use for wildcard queryables or when the ``bubbaloop/{scope}/{machine_id}/`` + prefix does not apply (e.g. ``bubbaloop/**/schema`` for multi-schema serving). + + Keep the returned object alive — garbage-collecting it undeclares the queryable. + """ + return self.session.declare_queryable(key_expr, handler) + + def queryable_async(self, suffix: str, handler, max_workers: int = 4) -> "zenoh.Queryable": + """Declare a queryable at ``topic(suffix)`` with handler in a thread pool. + + Use when the handler does slow work. Zenoh's internal thread is freed + immediately; the handler runs in a ``ThreadPoolExecutor``:: + + def on_db_query(query: zenoh.Query) -> None: + rows = db.fetch(query.payload.to_string()) # slow + query.reply(query.key_expr, json.dumps(rows).encode()) + + qbl = ctx.queryable_async("device_data", on_db_query) + + **Threading contract:** multiple invocations may run concurrently. + Protect shared state with locks. + """ + import concurrent.futures + executor = concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) + + def _wrap(query) -> None: + executor.submit(handler, query) + + return self.session.declare_queryable(self.topic(suffix), _wrap) + + def queryable_raw_async(self, key_expr: str, handler, max_workers: int = 4) -> "zenoh.Queryable": + """Declare a queryable at a literal key expression with handler in a thread pool.""" + import concurrent.futures + executor = concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) + + def _wrap(query) -> None: + executor.submit(handler, query) + + return self.session.declare_queryable(key_expr, _wrap) + # ------------------------------------------------------------------ # Cleanup # ------------------------------------------------------------------ diff --git a/python-sdk/tests/test_context.py b/python-sdk/tests/test_context.py index 5c2dd70..93e880d 100644 --- a/python-sdk/tests/test_context.py +++ b/python-sdk/tests/test_context.py @@ -447,6 +447,116 @@ def test_raw_callback_subscriber_async_undeclare(): mock_sub.undeclare.assert_called_once() +# --------------------------------------------------------------------------- +# NodeContext.queryable() and queryable_raw() +# --------------------------------------------------------------------------- + +def test_queryable_uses_topic_prefix(): + """queryable() declares at bubbaloop/{scope}/{machine_id}/{suffix}.""" + ctx = _make_context("local", "bot") + handler = lambda q: None + ctx.queryable("command", handler) + ctx.session.declare_queryable.assert_called_once_with( + "bubbaloop/local/bot/command", handler + ) + + +def test_queryable_raw_uses_literal_key_expr(): + """queryable_raw() declares at the literal key expression provided.""" + ctx = _make_context("local", "bot") + handler = lambda q: None + ctx.queryable_raw("bubbaloop/**/schema", handler) + ctx.session.declare_queryable.assert_called_once_with( + "bubbaloop/**/schema", handler + ) + + +def test_queryable_returns_zenoh_queryable(): + """queryable() returns whatever session.declare_queryable returns.""" + ctx = _make_context("local", "bot") + mock_qbl = MagicMock() + ctx.session.declare_queryable.return_value = mock_qbl + result = ctx.queryable("command", lambda q: None) + assert result is mock_qbl + + +# --------------------------------------------------------------------------- +# NodeContext.queryable_async() and queryable_raw_async() +# --------------------------------------------------------------------------- + +def test_queryable_async_uses_topic_prefix(): + """queryable_async() declares at bubbaloop/{scope}/{machine_id}/{suffix}.""" + ctx = _make_context("local", "bot") + handler = lambda q: None + ctx.queryable_async("command", handler) + called_topic = ctx.session.declare_queryable.call_args[0][0] + assert called_topic == "bubbaloop/local/bot/command" + + +def test_queryable_async_wraps_handler_in_executor(): + """queryable_async() wraps handler so Zenoh thread is freed immediately.""" + import threading + ctx = _make_context("local", "bot") + captured_wrapper = [] + + def fake_declare(topic, wrapper): + captured_wrapper.append(wrapper) + return MagicMock() + + ctx.session.declare_queryable.side_effect = fake_declare + + received = [] + event = threading.Event() + + def slow_handler(query): + received.append(query) + event.set() + + ctx.queryable_async("command", slow_handler) + + fake_query = MagicMock() + captured_wrapper[0](fake_query) # Zenoh calls the wrapper + + assert event.wait(timeout=2.0), "handler not called within 2s" + assert received == [fake_query] + + +# --------------------------------------------------------------------------- +# NodeContext.subscriber_callback() +# --------------------------------------------------------------------------- + +def test_subscriber_callback_uses_topic_prefix(): + """subscriber_callback() declares at bubbaloop/{scope}/{machine_id}/{suffix}.""" + ctx = _make_context("local", "bot") + ctx.subscriber_callback("sensor/data", lambda msg: None) + called_topic = ctx.session.declare_subscriber.call_args[0][0] + assert called_topic == "bubbaloop/local/bot/sensor/data" + + +def test_subscriber_raw_callback_uses_literal_key_expr(): + """subscriber_raw_callback() declares at the literal key expression.""" + ctx = _make_context("local", "bot") + ctx.subscriber_raw_callback("bubbaloop/**/health", lambda s: None) + called_topic = ctx.session.declare_subscriber.call_args[0][0] + assert called_topic == "bubbaloop/**/health" + + +def test_subscriber_callback_async_uses_topic_prefix(): + """subscriber_callback_async() declares at bubbaloop/{scope}/{machine_id}/{suffix}.""" + ctx = _make_context("local", "bot") + ctx.subscriber_callback_async("sensor/data", lambda msg: None) + called_topic = ctx.session.declare_subscriber.call_args[0][0] + assert called_topic == "bubbaloop/local/bot/sensor/data" + + +def test_subscriber_raw_callback_async_uses_literal_key_expr(): + """subscriber_raw_callback_async() declares at the literal key expression.""" + ctx = _make_context("local", "bot") + ctx.subscriber_raw_callback_async("bubbaloop/**/health", lambda s: None) + called_topic = ctx.session.declare_subscriber.call_args[0][0] + assert called_topic == "bubbaloop/**/health" + + # --------------------------------------------------------------------------- # Helper # --------------------------------------------------------------------------- From 2846535e2a162d3fbdf260d6a357e51f42c40e90 Mon Sep 17 00:00:00 2001 From: Luis Date: Sun, 5 Apr 2026 14:59:36 +0200 Subject: [PATCH 06/54] fix(python-sdk): AsyncQueryable wrapper prevents executor leak in queryable_async --- python-sdk/bubbaloop_sdk/context.py | 28 ++++++------- python-sdk/bubbaloop_sdk/subscriber.py | 37 +++++++++++++++++ python-sdk/tests/test_context.py | 55 ++++++++++++++++++++++++++ 3 files changed, 104 insertions(+), 16 deletions(-) diff --git a/python-sdk/bubbaloop_sdk/context.py b/python-sdk/bubbaloop_sdk/context.py index 633e916..9d702e7 100644 --- a/python-sdk/bubbaloop_sdk/context.py +++ b/python-sdk/bubbaloop_sdk/context.py @@ -189,7 +189,7 @@ def queryable_raw(self, key_expr: str, handler) -> "zenoh.Queryable": """ return self.session.declare_queryable(key_expr, handler) - def queryable_async(self, suffix: str, handler, max_workers: int = 4) -> "zenoh.Queryable": + def queryable_async(self, suffix: str, handler, max_workers: int = 4) -> "AsyncQueryable": """Declare a queryable at ``topic(suffix)`` with handler in a thread pool. Use when the handler does slow work. Zenoh's internal thread is freed @@ -200,27 +200,23 @@ def on_db_query(query: zenoh.Query) -> None: query.reply(query.key_expr, json.dumps(rows).encode()) qbl = ctx.queryable_async("device_data", on_db_query) + # call qbl.undeclare() when done to release threads **Threading contract:** multiple invocations may run concurrently. Protect shared state with locks. """ - import concurrent.futures - executor = concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) + from .subscriber import AsyncQueryable + return AsyncQueryable(self.session, self.topic(suffix), handler, max_workers) - def _wrap(query) -> None: - executor.submit(handler, query) + def queryable_raw_async(self, key_expr: str, handler, max_workers: int = 4) -> "AsyncQueryable": + """Declare a queryable at a literal key expression with handler in a thread pool. - return self.session.declare_queryable(self.topic(suffix), _wrap) - - def queryable_raw_async(self, key_expr: str, handler, max_workers: int = 4) -> "zenoh.Queryable": - """Declare a queryable at a literal key expression with handler in a thread pool.""" - import concurrent.futures - executor = concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) - - def _wrap(query) -> None: - executor.submit(handler, query) - - return self.session.declare_queryable(key_expr, _wrap) + Same as ``queryable_async()`` but uses a literal key expression without the + ``bubbaloop/{scope}/{machine_id}/`` prefix. Use for wildcard queryables. + Call ``undeclare()`` on the returned object when done to release threads. + """ + from .subscriber import AsyncQueryable + return AsyncQueryable(self.session, key_expr, handler, max_workers) # ------------------------------------------------------------------ # Cleanup diff --git a/python-sdk/bubbaloop_sdk/subscriber.py b/python-sdk/bubbaloop_sdk/subscriber.py index 5f8ab20..2bd3baa 100644 --- a/python-sdk/bubbaloop_sdk/subscriber.py +++ b/python-sdk/bubbaloop_sdk/subscriber.py @@ -216,3 +216,40 @@ def undeclare(self) -> None: """Undeclare the subscriber and shutdown the thread pool.""" self._sub.undeclare() # stop Zenoh callbacks first self._executor.shutdown(wait=False) + + +class AsyncQueryable: + """Wrapper around ``zenoh.Queryable`` that runs the handler in a ``ThreadPoolExecutor``. + + **Use this (via ``ctx.queryable_async()``) when your queryable handler does slow work** + (database reads, hardware access, network calls). Zenoh uses a single internal thread + for all callbacks — a slow handler blocks ALL other subscribers and queryables on the + same session. ``AsyncQueryable`` fixes this by submitting the handler to a thread pool + immediately and returning, freeing Zenoh's thread:: + + def on_db_query(query: zenoh.Query) -> None: + rows = db.fetch(query.payload.to_string()) # slow + query.reply(query.key_expr, json.dumps(rows).encode()) + + qbl = ctx.queryable_async("device_data", on_db_query) + # qbl.undeclare() when done — shuts down Zenoh queryable AND thread pool + + **Threading contract:** multiple invocations of ``handler`` may run concurrently + if queries arrive faster than the handler processes them. Protect shared state + with locks. + + Keep the returned object alive — garbage-collecting it undeclares the queryable. + """ + + def __init__(self, session: zenoh.Session, key_expr: str, handler, max_workers: int = 4): + self._executor = concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) + + def _wrap(query) -> None: + self._executor.submit(handler, query) + + self._qbl = session.declare_queryable(key_expr, _wrap) + + def undeclare(self) -> None: + """Undeclare the queryable and shutdown the thread pool.""" + self._qbl.undeclare() # stop Zenoh callbacks first + self._executor.shutdown(wait=False) diff --git a/python-sdk/tests/test_context.py b/python-sdk/tests/test_context.py index 93e880d..7c9101d 100644 --- a/python-sdk/tests/test_context.py +++ b/python-sdk/tests/test_context.py @@ -521,6 +521,61 @@ def slow_handler(query): assert received == [fake_query] +def test_queryable_async_returns_async_queryable(): + """queryable_async() returns an AsyncQueryable (not a bare zenoh.Queryable).""" + from bubbaloop_sdk.subscriber import AsyncQueryable + ctx = _make_context("local", "bot") + qbl = ctx.queryable_async("command", lambda q: None) + assert isinstance(qbl, AsyncQueryable) + + +def test_queryable_raw_async_uses_literal_key_expr(): + """queryable_raw_async() declares at the literal key expression provided.""" + ctx = _make_context("local", "bot") + ctx.queryable_raw_async("bubbaloop/**/schema", lambda q: None) + called_topic = ctx.session.declare_queryable.call_args[0][0] + assert called_topic == "bubbaloop/**/schema" + + +def test_queryable_raw_async_wraps_handler_in_executor(): + """queryable_raw_async() wraps handler in thread pool.""" + import threading + ctx = _make_context("local", "bot") + captured_wrapper = [] + + def fake_declare(key_expr, wrapper): + captured_wrapper.append(wrapper) + return MagicMock() + + ctx.session.declare_queryable.side_effect = fake_declare + + received = [] + event = threading.Event() + + def handler(query): + received.append(query) + event.set() + + ctx.queryable_raw_async("bubbaloop/**/schema", handler) + + fake_query = MagicMock() + captured_wrapper[0](fake_query) + + assert event.wait(timeout=2.0), "handler not called within 2s" + assert received == [fake_query] + + +def test_async_queryable_undeclare(): + """AsyncQueryable.undeclare() undeclares the queryable then shuts down executor.""" + from bubbaloop_sdk.subscriber import AsyncQueryable + mock_session = MagicMock() + mock_qbl = MagicMock() + mock_session.declare_queryable.return_value = mock_qbl + aq = AsyncQueryable(mock_session, "test/topic", lambda q: None) + aq.undeclare() + mock_qbl.undeclare.assert_called_once() + + # --------------------------------------------------------------------------- # NodeContext.subscriber_callback() # --------------------------------------------------------------------------- From d8f18969d6045b3f8ab89f7f494b78d0bdf00cfa Mon Sep 17 00:00:00 2001 From: Luis Date: Sun, 5 Apr 2026 15:10:11 +0200 Subject: [PATCH 07/54] feat(python-sdk): export new subscriber/queryable classes; add linting config Add AsyncQueryable, CallbackSubscriber, CallbackSubscriberAsync, RawCallbackSubscriber, RawCallbackSubscriberAsync to __init__.py public API. Add import-surface tests for all new exports. Set max-line-length=120 in .flake8 (project root and python-sdk/). Co-Authored-By: Claude Sonnet 4.6 --- .flake8 | 5 ++ python-sdk/.flake8 | 2 + python-sdk/bubbaloop_sdk/__init__.py | 15 +++++- python-sdk/tests/test_context.py | 72 +++++++++++++++++++++------- 4 files changed, 75 insertions(+), 19 deletions(-) create mode 100644 .flake8 create mode 100644 python-sdk/.flake8 diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..8026b79 --- /dev/null +++ b/.flake8 @@ -0,0 +1,5 @@ +[flake8] +max-line-length = 120 +per-file-ignores = + # forward-reference string annotations with lazy imports + python-sdk/bubbaloop_sdk/context.py: F821 diff --git a/python-sdk/.flake8 b/python-sdk/.flake8 new file mode 100644 index 0000000..6deafc2 --- /dev/null +++ b/python-sdk/.flake8 @@ -0,0 +1,2 @@ +[flake8] +max-line-length = 120 diff --git a/python-sdk/bubbaloop_sdk/__init__.py b/python-sdk/bubbaloop_sdk/__init__.py index 30a00b7..d1efe19 100644 --- a/python-sdk/bubbaloop_sdk/__init__.py +++ b/python-sdk/bubbaloop_sdk/__init__.py @@ -11,16 +11,29 @@ from .discover import NodeInfo, discover_nodes from .get_sample import GetSampleTimeout, get_sample from .publisher import JsonPublisher, ProtoPublisher -from .subscriber import RawSubscriber, TypedSubscriber +from .subscriber import ( + AsyncQueryable, + CallbackSubscriber, + CallbackSubscriberAsync, + RawCallbackSubscriber, + RawCallbackSubscriberAsync, + RawSubscriber, + TypedSubscriber, +) from .node import run_node __all__ = [ + "AsyncQueryable", + "CallbackSubscriber", + "CallbackSubscriberAsync", "GetSampleTimeout", "JsonPublisher", "NodeContext", "NodeInfo", "ProtoDecoder", "ProtoPublisher", + "RawCallbackSubscriber", + "RawCallbackSubscriberAsync", "RawSubscriber", "TypedSubscriber", "discover_nodes", diff --git a/python-sdk/tests/test_context.py b/python-sdk/tests/test_context.py index 7c9101d..d529d40 100644 --- a/python-sdk/tests/test_context.py +++ b/python-sdk/tests/test_context.py @@ -69,6 +69,25 @@ def test_import_subscribers(): assert RawSubscriber is not None +def test_import_callback_subscribers(): + from bubbaloop_sdk import CallbackSubscriber, RawCallbackSubscriber + assert CallbackSubscriber is not None + assert RawCallbackSubscriber is not None + + +def test_import_callback_subscribers_async(): + from bubbaloop_sdk import ( + CallbackSubscriberAsync, RawCallbackSubscriberAsync + ) + assert CallbackSubscriberAsync is not None + assert RawCallbackSubscriberAsync is not None + + +def test_import_async_queryable(): + from bubbaloop_sdk import AsyncQueryable + assert AsyncQueryable is not None + + def test_import_run_node(): from bubbaloop_sdk import run_node assert callable(run_node) @@ -248,7 +267,9 @@ def fake_declare(topic, handler): mock_session.declare_subscriber.side_effect = fake_declare received = [] - sub = CallbackSubscriber(mock_session, "test/topic", lambda msg: received.append(msg)) + CallbackSubscriber( + mock_session, "test/topic", lambda msg: received.append(msg) + ) fake_sample = MagicMock() fake_sample.payload.to_bytes.return_value = b"\xde\xad" @@ -272,8 +293,10 @@ def fake_declare(topic, handler): fake_msg_class = MagicMock() fake_msg_class.FromString.return_value = "decoded_proto" received = [] - sub = CallbackSubscriber(mock_session, "test/topic", - lambda msg: received.append(msg), msg_class=fake_msg_class) + CallbackSubscriber( + mock_session, "test/topic", + lambda msg: received.append(msg), msg_class=fake_msg_class + ) fake_sample = MagicMock() fake_sample.payload.to_bytes.return_value = b"\x01" @@ -310,7 +333,9 @@ def fake_declare(key_expr, handler): mock_session.declare_subscriber.side_effect = fake_declare received = [] - sub = RawCallbackSubscriber(mock_session, "test/**", lambda s: received.append(s)) + RawCallbackSubscriber( + mock_session, "test/**", lambda s: received.append(s) + ) fake_sample = MagicMock() captured_handler[0](fake_sample) @@ -385,7 +410,9 @@ def handler(msg): received.append(msg) event.set() - sub = CallbackSubscriberAsync(mock_session, "test/topic", handler, msg_class=fake_msg_class) + sub = CallbackSubscriberAsync( + mock_session, "test/topic", handler, msg_class=fake_msg_class + ) fake_sample = MagicMock() fake_sample.payload.to_bytes.return_value = b"\x01" @@ -426,7 +453,7 @@ def handler(sample): def test_callback_subscriber_async_undeclare(): - """undeclare() shuts down executor and undeclares underlying subscriber.""" + """undeclare() shuts down executor and undeclares underlying sub.""" from bubbaloop_sdk.subscriber import CallbackSubscriberAsync mock_session = MagicMock() mock_sub = MagicMock() @@ -437,7 +464,7 @@ def test_callback_subscriber_async_undeclare(): def test_raw_callback_subscriber_async_undeclare(): - """undeclare() shuts down executor and undeclares underlying subscriber.""" + """undeclare() shuts down executor and undeclares underlying sub.""" from bubbaloop_sdk.subscriber import RawCallbackSubscriberAsync mock_session = MagicMock() mock_sub = MagicMock() @@ -454,7 +481,10 @@ def test_raw_callback_subscriber_async_undeclare(): def test_queryable_uses_topic_prefix(): """queryable() declares at bubbaloop/{scope}/{machine_id}/{suffix}.""" ctx = _make_context("local", "bot") - handler = lambda q: None + + def handler(q): + pass + ctx.queryable("command", handler) ctx.session.declare_queryable.assert_called_once_with( "bubbaloop/local/bot/command", handler @@ -464,7 +494,10 @@ def test_queryable_uses_topic_prefix(): def test_queryable_raw_uses_literal_key_expr(): """queryable_raw() declares at the literal key expression provided.""" ctx = _make_context("local", "bot") - handler = lambda q: None + + def handler(q): + pass + ctx.queryable_raw("bubbaloop/**/schema", handler) ctx.session.declare_queryable.assert_called_once_with( "bubbaloop/**/schema", handler @@ -485,16 +518,19 @@ def test_queryable_returns_zenoh_queryable(): # --------------------------------------------------------------------------- def test_queryable_async_uses_topic_prefix(): - """queryable_async() declares at bubbaloop/{scope}/{machine_id}/{suffix}.""" + """queryable_async() declares at topic(suffix).""" ctx = _make_context("local", "bot") - handler = lambda q: None + + def handler(q): + pass + ctx.queryable_async("command", handler) called_topic = ctx.session.declare_queryable.call_args[0][0] assert called_topic == "bubbaloop/local/bot/command" def test_queryable_async_wraps_handler_in_executor(): - """queryable_async() wraps handler so Zenoh thread is freed immediately.""" + """queryable_async() wraps handler so Zenoh thread is freed.""" import threading ctx = _make_context("local", "bot") captured_wrapper = [] @@ -522,7 +558,7 @@ def slow_handler(query): def test_queryable_async_returns_async_queryable(): - """queryable_async() returns an AsyncQueryable (not a bare zenoh.Queryable).""" + """queryable_async() returns AsyncQueryable (not a bare zenoh.Queryable).""" from bubbaloop_sdk.subscriber import AsyncQueryable ctx = _make_context("local", "bot") qbl = ctx.queryable_async("command", lambda q: None) @@ -530,7 +566,7 @@ def test_queryable_async_returns_async_queryable(): def test_queryable_raw_async_uses_literal_key_expr(): - """queryable_raw_async() declares at the literal key expression provided.""" + """queryable_raw_async() declares at the literal key expression.""" ctx = _make_context("local", "bot") ctx.queryable_raw_async("bubbaloop/**/schema", lambda q: None) called_topic = ctx.session.declare_queryable.call_args[0][0] @@ -566,7 +602,7 @@ def handler(query): def test_async_queryable_undeclare(): - """AsyncQueryable.undeclare() undeclares the queryable then shuts down executor.""" + """AsyncQueryable.undeclare() undeclares queryable then shuts executor.""" from bubbaloop_sdk.subscriber import AsyncQueryable mock_session = MagicMock() mock_qbl = MagicMock() @@ -581,7 +617,7 @@ def test_async_queryable_undeclare(): # --------------------------------------------------------------------------- def test_subscriber_callback_uses_topic_prefix(): - """subscriber_callback() declares at bubbaloop/{scope}/{machine_id}/{suffix}.""" + """subscriber_callback() declares at topic(suffix).""" ctx = _make_context("local", "bot") ctx.subscriber_callback("sensor/data", lambda msg: None) called_topic = ctx.session.declare_subscriber.call_args[0][0] @@ -597,7 +633,7 @@ def test_subscriber_raw_callback_uses_literal_key_expr(): def test_subscriber_callback_async_uses_topic_prefix(): - """subscriber_callback_async() declares at bubbaloop/{scope}/{machine_id}/{suffix}.""" + """subscriber_callback_async() declares at topic(suffix).""" ctx = _make_context("local", "bot") ctx.subscriber_callback_async("sensor/data", lambda msg: None) called_topic = ctx.session.declare_subscriber.call_args[0][0] @@ -605,7 +641,7 @@ def test_subscriber_callback_async_uses_topic_prefix(): def test_subscriber_raw_callback_async_uses_literal_key_expr(): - """subscriber_raw_callback_async() declares at the literal key expression.""" + """subscriber_raw_callback_async() declares at literal key expression.""" ctx = _make_context("local", "bot") ctx.subscriber_raw_callback_async("bubbaloop/**/health", lambda s: None) called_topic = ctx.session.declare_subscriber.call_args[0][0] From 2c18ead81545a173a8b128014bd1db33be0c8cf8 Mon Sep 17 00:00:00 2001 From: Luis Date: Sun, 5 Apr 2026 15:12:33 +0200 Subject: [PATCH 08/54] docs(python-sdk): rewrite README for synchronous API with new subscribers/queryables Replace stale async-API examples with the actual synchronous API. Document all new callback subscribers (_callback, _callback_async, _raw_callback, _raw_callback_async) and queryable methods (queryable, queryable_raw, queryable_async, queryable_raw_async). All original methods remain unchanged. Co-Authored-By: Claude Sonnet 4.6 --- python-sdk/README.md | 196 +++++++++++++++++++++++++++++-------------- 1 file changed, 131 insertions(+), 65 deletions(-) diff --git a/python-sdk/README.md b/python-sdk/README.md index a8d884c..48db196 100644 --- a/python-sdk/README.md +++ b/python-sdk/README.md @@ -1,7 +1,7 @@ # bubbaloop-sdk (Python) -Pure Python wrapper over `zenoh-python` with the same API surface as the Rust Node SDK. -No compilation required — installable directly from the git repository. +Pure Python wrapper over `zenoh-python`. Synchronous API — no asyncio required. +No compilation needed; install directly from the git repository. ## Install @@ -18,77 +18,102 @@ pip install -e ".[dev]" ## Quick start -### Protobuf node +### Publish JSON ```python -import asyncio +import time from bubbaloop_sdk import NodeContext -from my_protos_pb2 import SensorData - -async def run(): - ctx = await NodeContext.connect() - # Publisher — encoding set once at declaration - pub = await ctx.publisher_proto("sensor/data", SensorData) +ctx = NodeContext.connect() +pub = ctx.publisher_json("weather/current") - while not ctx.is_shutdown(): - msg = SensorData(value=42.0) - await pub.put(msg) - await asyncio.sleep(0.1) +while not ctx.is_shutdown(): + pub.put({"temperature": 22.5, "humidity": 60}) + time.sleep(1.0) - pub.undeclare() - ctx.close() - -asyncio.run(run()) +ctx.close() ``` -### JSON node +### Publish protobuf ```python -import asyncio +import time from bubbaloop_sdk import NodeContext +from my_protos_pb2 import SensorData -async def run(): - ctx = await NodeContext.connect() - pub = await ctx.publisher_json("weather/current") - - while not ctx.is_shutdown(): - await pub.put({"temperature": 22.5, "humidity": 60}) - await asyncio.sleep(1.0) +ctx = NodeContext.connect() +pub = ctx.publisher_proto("sensor/data", SensorData) - pub.undeclare() - ctx.close() +while not ctx.is_shutdown(): + pub.put(SensorData(value=42.0)) + time.sleep(0.1) -asyncio.run(run()) +ctx.close() ``` -### Typed subscriber +### Blocking subscriber (poll loop) ```python -import asyncio from bubbaloop_sdk import NodeContext from my_protos_pb2 import SensorData -async def run(): - ctx = await NodeContext.connect() - sub = await ctx.subscriber("sensor/data", SensorData) +ctx = NodeContext.connect() +sub = ctx.subscriber("sensor/data", SensorData) - async for msg in sub: +while not ctx.is_shutdown(): + msg = sub.recv(timeout=5.0) # returns None on timeout + if msg is not None: print(f"value: {msg.value}") -asyncio.run(run()) +ctx.close() ``` -### Schema queryable (protobuf nodes) +### Callback subscriber (event-driven, no loop needed) ```python -from bubbaloop_sdk.schema import declare_schema_queryable +from bubbaloop_sdk import NodeContext from my_protos_pb2 import SensorData -# Declare once — keeps the queryable alive while the reference is held -schema_qbl = declare_schema_queryable( - ctx.session, ctx.scope, ctx.machine_id, "my-node", SensorData -) +ctx = NodeContext.connect() + +def on_sensor(msg: SensorData): + print(f"received: {msg.value}") + +sub = ctx.subscriber_callback("sensor/data", on_sensor, SensorData) +ctx.wait_shutdown() # block until SIGINT/SIGTERM +sub.undeclare() +ctx.close() +``` + +Use `subscriber_callback_async` when the handler does slow work (DB writes, +HTTP calls) — it runs the handler in a thread pool and returns immediately, +freeing Zenoh's internal thread: + +```python +sub = ctx.subscriber_callback_async("sensor/data", on_sensor, SensorData) +``` + +### Queryable (respond to get requests) + +```python +import json +from bubbaloop_sdk import NodeContext + +ctx = NodeContext.connect() + +def on_query(query): + query.reply(query.key_expr, json.dumps({"status": "ok"}).encode()) + +qbl = ctx.queryable("status", on_query) +ctx.wait_shutdown() +ctx.close() +``` + +Use `queryable_async` when the handler does slow work: + +```python +qbl = ctx.queryable_async("status", on_query) +qbl.undeclare() # call when done to release the thread pool ``` ## Configuration @@ -99,36 +124,77 @@ schema_qbl = declare_schema_queryable( | `BUBBALOOP_SCOPE` | `local` | Topic scope | | `BUBBALOOP_MACHINE_ID` | hostname (sanitized) | Machine identifier | -## Requirements +## API reference -- Python 3.9+ -- `eclipse-zenoh >= 1.7, < 2` -- `protobuf >= 4.0` +### `NodeContext` -## API +| Method | Returns | Description | +|---|---|---| +| `NodeContext.connect(endpoint=None, instance_name=None)` | `NodeContext` | Connect to Zenoh router | +| `ctx.topic(suffix)` | `str` | Build `bubbaloop/{scope}/{machine_id}/{suffix}` | +| `ctx.is_shutdown()` | `bool` | True after SIGINT/SIGTERM | +| `ctx.wait_shutdown()` | — | Block until SIGINT/SIGTERM | +| `ctx.close()` | — | Close the Zenoh session | -### `NodeContext` +#### Publishers + +| Method | Returns | Description | +|---|---|---| +| `ctx.publisher_json(suffix)` | `JsonPublisher` | JSON publisher at `topic(suffix)` | +| `ctx.publisher_proto(suffix, msg_class=None)` | `ProtoPublisher` | Protobuf publisher at `topic(suffix)` | + +#### Blocking subscribers (poll with `recv`) + +| Method | Returns | Description | +|---|---|---| +| `ctx.subscriber(suffix, msg_class=None)` | `TypedSubscriber` | Queue-backed subscriber; `recv(timeout)` returns `None` on timeout | +| `ctx.subscriber_raw(key_expr)` | `RawSubscriber` | Same but yields raw `zenoh.Sample`; uses literal key expression | + +#### Callback subscribers (event-driven) + +Handler is called from Zenoh's internal thread. Keep handlers fast; use `_async` +variants for slow work. + +| Method | Returns | Description | +|---|---|---| +| `ctx.subscriber_callback(suffix, handler, msg_class=None)` | `CallbackSubscriber` | Decoded message passed to handler | +| `ctx.subscriber_raw_callback(key_expr, handler)` | `RawCallbackSubscriber` | Raw `zenoh.Sample` passed to handler; literal key expression | +| `ctx.subscriber_callback_async(suffix, handler, msg_class=None, max_workers=4)` | `CallbackSubscriberAsync` | Handler runs in thread pool | +| `ctx.subscriber_raw_callback_async(key_expr, handler, max_workers=4)` | `RawCallbackSubscriberAsync` | Raw sample; handler in thread pool | + +#### Queryables + +Do **not** pass `complete=True` — it blocks wildcard queries used by the dashboard. + +| Method | Returns | Description | +|---|---|---| +| `ctx.queryable(suffix, handler)` | `zenoh.Queryable` | Handler at `topic(suffix)`; called from Zenoh thread | +| `ctx.queryable_raw(key_expr, handler)` | `zenoh.Queryable` | Handler at literal key expression | +| `ctx.queryable_async(suffix, handler, max_workers=4)` | `AsyncQueryable` | Handler in thread pool; call `undeclare()` to release | +| `ctx.queryable_raw_async(key_expr, handler, max_workers=4)` | `AsyncQueryable` | Raw key; handler in thread pool | + +### Publishers | Method | Description | |---|---| -| `await NodeContext.connect(endpoint=None)` | Connect to Zenoh router | -| `ctx.topic(suffix)` | Build `bubbaloop/{scope}/{machine_id}/{suffix}` | -| `await ctx.publisher_proto(suffix, msg_class)` | Declared protobuf publisher | -| `await ctx.publisher_json(suffix)` | Declared JSON publisher | -| `await ctx.subscriber(suffix, msg_class=None)` | Typed async-iterable subscriber | -| `await ctx.subscriber_raw(key_expr)` | Raw sample subscriber (no topic prefix) | -| `ctx.is_shutdown()` | True after SIGINT/SIGTERM | -| `await ctx.wait_shutdown()` | Suspend until shutdown | -| `ctx.close()` | Close the Zenoh session | - -### `ProtoPublisher` / `JsonPublisher` +| `pub.put(msg)` | Publish a message (bytes, proto message, or dict for JSON) | + +### Blocking subscribers | Method | Description | |---|---| -| `await pub.put(msg)` | Publish a message | -| `pub.undeclare()` | Release the Zenoh publisher | +| `sub.recv(timeout=None)` | Return next message or `None` on timeout | +| `sub.undeclare()` | Stop receiving samples | +| `for msg in sub` | Iterate (blocks indefinitely) | -### `TypedSubscriber` / `RawSubscriber` +### Callback subscribers / AsyncQueryable -Both support `async for` iteration. `RawSubscriber` also exposes `recv()` for -synchronous use and yields `zenoh.Sample` objects directly. +| Method | Description | +|---|---| +| `sub.undeclare()` | Undeclare subscriber and shut down thread pool (async variants) | + +## Requirements + +- Python 3.9+ +- `eclipse-zenoh >= 1.7, < 2` +- `protobuf >= 4.0` From 16c5208d60a63b577e4844578a458baa0507d1ff Mon Sep 17 00:00:00 2001 From: Luis Date: Sun, 5 Apr 2026 18:12:52 +0200 Subject: [PATCH 09/54] chore(python-sdk): replace .flake8 with ruff in pyproject.toml; fix lint Move all Python lint config into pyproject.toml [tool.ruff] sections (line-length=120, select E/W/F/I/B/UP, per-file-ignores for pre-existing issues in upstream files). Remove .flake8 files from repo root and python-sdk/. Auto-fix isort ordering in __init__.py, test_context.py, decode_sample.py via `ruff check --fix`. Co-Authored-By: Claude Sonnet 4.6 --- .flake8 | 5 ---- python-sdk/.flake8 | 2 -- python-sdk/bubbaloop_sdk/__init__.py | 2 +- python-sdk/bubbaloop_sdk/context.py | 8 ++++-- python-sdk/bubbaloop_sdk/decode_sample.py | 3 ++- python-sdk/pyproject.toml | 33 ++++++++++++++++++++++- python-sdk/tests/test_context.py | 12 ++++----- 7 files changed, 47 insertions(+), 18 deletions(-) delete mode 100644 .flake8 delete mode 100644 python-sdk/.flake8 diff --git a/.flake8 b/.flake8 deleted file mode 100644 index 8026b79..0000000 --- a/.flake8 +++ /dev/null @@ -1,5 +0,0 @@ -[flake8] -max-line-length = 120 -per-file-ignores = - # forward-reference string annotations with lazy imports - python-sdk/bubbaloop_sdk/context.py: F821 diff --git a/python-sdk/.flake8 b/python-sdk/.flake8 deleted file mode 100644 index 6deafc2..0000000 --- a/python-sdk/.flake8 +++ /dev/null @@ -1,2 +0,0 @@ -[flake8] -max-line-length = 120 diff --git a/python-sdk/bubbaloop_sdk/__init__.py b/python-sdk/bubbaloop_sdk/__init__.py index d1efe19..45dbba1 100644 --- a/python-sdk/bubbaloop_sdk/__init__.py +++ b/python-sdk/bubbaloop_sdk/__init__.py @@ -10,6 +10,7 @@ from .decode_sample import ProtoDecoder from .discover import NodeInfo, discover_nodes from .get_sample import GetSampleTimeout, get_sample +from .node import run_node from .publisher import JsonPublisher, ProtoPublisher from .subscriber import ( AsyncQueryable, @@ -20,7 +21,6 @@ RawSubscriber, TypedSubscriber, ) -from .node import run_node __all__ = [ "AsyncQueryable", diff --git a/python-sdk/bubbaloop_sdk/context.py b/python-sdk/bubbaloop_sdk/context.py index 9d702e7..e2c2ae3 100644 --- a/python-sdk/bubbaloop_sdk/context.py +++ b/python-sdk/bubbaloop_sdk/context.py @@ -140,7 +140,9 @@ def subscriber_raw_callback(self, key_expr: str, handler) -> "RawCallbackSubscri from .subscriber import RawCallbackSubscriber return RawCallbackSubscriber(self.session, key_expr, handler) - def subscriber_callback_async(self, suffix: str, handler, msg_class=None, max_workers: int = 4) -> "CallbackSubscriberAsync": + def subscriber_callback_async( + self, suffix: str, handler, msg_class=None, max_workers: int = 4 + ) -> "CallbackSubscriberAsync": """Callback subscriber at ``topic(suffix)`` with handler in a thread pool. Use when ``handler`` does slow work (database writes, hardware I/O, network @@ -150,7 +152,9 @@ def subscriber_callback_async(self, suffix: str, handler, msg_class=None, max_wo from .subscriber import CallbackSubscriberAsync return CallbackSubscriberAsync(self.session, self.topic(suffix), handler, msg_class, max_workers) - def subscriber_raw_callback_async(self, key_expr: str, handler, max_workers: int = 4) -> "RawCallbackSubscriberAsync": + def subscriber_raw_callback_async( + self, key_expr: str, handler, max_workers: int = 4 + ) -> "RawCallbackSubscriberAsync": """Raw callback subscriber at a literal key expression with handler in a thread pool.""" from .subscriber import RawCallbackSubscriberAsync return RawCallbackSubscriberAsync(self.session, key_expr, handler, max_workers) diff --git a/python-sdk/bubbaloop_sdk/decode_sample.py b/python-sdk/bubbaloop_sdk/decode_sample.py index c91121f..3f2f194 100644 --- a/python-sdk/bubbaloop_sdk/decode_sample.py +++ b/python-sdk/bubbaloop_sdk/decode_sample.py @@ -32,7 +32,8 @@ def _get_proto_class(factory, descriptor): # type: ignore[no-untyped-def] return factory.GetPrototype(descriptor) -from google.protobuf import descriptor_pb2, descriptor_pool, message_factory as _message_factory +from google.protobuf import descriptor_pb2, descriptor_pool +from google.protobuf import message_factory as _message_factory from google.protobuf.json_format import MessageToDict diff --git a/python-sdk/pyproject.toml b/python-sdk/pyproject.toml index c391506..76b02f8 100644 --- a/python-sdk/pyproject.toml +++ b/python-sdk/pyproject.toml @@ -13,8 +13,39 @@ dependencies = [ ] [project.optional-dependencies] -dev = ["pytest", "pytest-asyncio"] +dev = ["pytest", "pytest-asyncio", "ruff"] [tool.setuptools.packages.find] where = ["."] include = ["bubbaloop_sdk*"] + +[tool.pytest.ini_options] +testpaths = ["tests"] + +# --------------------------------------------------------------------------- +# Ruff — linter + formatter (replaces flake8, isort, pycodestyle) +# --------------------------------------------------------------------------- + +[tool.ruff] +line-length = 120 +target-version = "py39" + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # Pyflakes + "I", # isort + "B", # flake8-bugbear + "UP", # pyupgrade +] +ignore = [ + # forward-reference string annotations with lazy imports (by design in context.py) + "F821", +] + +[tool.ruff.lint.per-file-ignores] +# pre-existing unused imports in upstream files (health.py, node.py) +"bubbaloop_sdk/health.py" = ["F401"] +"bubbaloop_sdk/node.py" = ["F401"] +"bubbaloop_sdk/get_sample.py" = ["B904"] diff --git a/python-sdk/tests/test_context.py b/python-sdk/tests/test_context.py index d529d40..7099a14 100644 --- a/python-sdk/tests/test_context.py +++ b/python-sdk/tests/test_context.py @@ -10,7 +10,6 @@ import pytest - # --------------------------------------------------------------------------- # topic() # --------------------------------------------------------------------------- @@ -58,13 +57,13 @@ def test_import_node_context(): def test_import_publishers(): - from bubbaloop_sdk import ProtoPublisher, JsonPublisher + from bubbaloop_sdk import JsonPublisher, ProtoPublisher assert ProtoPublisher is not None assert JsonPublisher is not None def test_import_subscribers(): - from bubbaloop_sdk import TypedSubscriber, RawSubscriber + from bubbaloop_sdk import RawSubscriber, TypedSubscriber assert TypedSubscriber is not None assert RawSubscriber is not None @@ -76,9 +75,7 @@ def test_import_callback_subscribers(): def test_import_callback_subscribers_async(): - from bubbaloop_sdk import ( - CallbackSubscriberAsync, RawCallbackSubscriberAsync - ) + from bubbaloop_sdk import CallbackSubscriberAsync, RawCallbackSubscriberAsync assert CallbackSubscriberAsync is not None assert RawCallbackSubscriberAsync is not None @@ -361,6 +358,7 @@ def test_raw_callback_subscriber_undeclare(): def test_callback_subscriber_async_calls_handler_in_thread_pool(): """Handler is called asynchronously via thread pool.""" import threading + from bubbaloop_sdk.subscriber import CallbackSubscriberAsync mock_session = MagicMock() captured_handler = [] @@ -391,6 +389,7 @@ def slow_handler(msg): def test_callback_subscriber_async_decodes_proto(): """Handler receives decoded proto when msg_class provided.""" import threading + from bubbaloop_sdk.subscriber import CallbackSubscriberAsync mock_session = MagicMock() captured_handler = [] @@ -426,6 +425,7 @@ def handler(msg): def test_raw_callback_subscriber_async_passes_sample(): """RawCallbackSubscriberAsync handler receives raw zenoh.Sample.""" import threading + from bubbaloop_sdk.subscriber import RawCallbackSubscriberAsync mock_session = MagicMock() captured_handler = [] From f0bd66ef261d2c649dc07bfba0e0a8ba80e72702 Mon Sep 17 00:00:00 2001 From: Luis Date: Sun, 5 Apr 2026 18:15:03 +0200 Subject: [PATCH 10/54] docs(python-sdk): add CONTRIBUTING.md with dev setup, lint config, testing notes Co-Authored-By: Claude Sonnet 4.6 --- python-sdk/CONTRIBUTING.md | 67 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 python-sdk/CONTRIBUTING.md diff --git a/python-sdk/CONTRIBUTING.md b/python-sdk/CONTRIBUTING.md new file mode 100644 index 0000000..07e0e54 --- /dev/null +++ b/python-sdk/CONTRIBUTING.md @@ -0,0 +1,67 @@ +# Contributing to bubbaloop-sdk (Python) + +## Dev environment setup + +```bash +cd python-sdk +python3 -m venv .venv +.venv/bin/pip install -e ".[dev]" +``` + +Dev deps installed: `pytest`, `pytest-asyncio`, `ruff`. + +## Running tests + +```bash +cd python-sdk +.venv/bin/python -m pytest tests/ -v +``` + +## Linting (ruff) + +Config lives in `python-sdk/pyproject.toml` under `[tool.ruff]`. Same pattern as [Kornia](https://github.com/kornia/kornia). + +```bash +cd python-sdk +.venv/bin/python -m ruff check bubbaloop_sdk/ tests/ # check +.venv/bin/python -m ruff check --fix bubbaloop_sdk/ tests/ # auto-fix +.venv/bin/python -m ruff format bubbaloop_sdk/ tests/ # format +``` + +Key settings: +- `line-length = 120` +- Rules: E/W (pycodestyle), F (Pyflakes), I (isort), B (bugbear), UP (pyupgrade) +- `F821` globally ignored — forward-reference string annotations in `context.py` use lazy imports by design +- Per-file ignores for pre-existing issues in upstream files (`health.py`, `node.py`, `get_sample.py`) + +## Known pre-existing issues (upstream files, not touched) + +| File | Rule | Reason | +|------|------|--------| +| `health.py` | F401 unused import (`time`) | Pre-existing upstream code | +| `node.py` | F401 unused imports (`os`, `time`) | Pre-existing upstream code | +| `get_sample.py` | B904 raise in except | Pre-existing upstream code | + +## Testing notes + +Tests in `tests/test_context.py` do **not** open a real Zenoh session. +`_make_context()` helper uses `object.__new__(NodeContext)` + `MagicMock()` session — no router needed. + +For async subscriber/queryable tests, `threading.Event` with a 2s timeout is used to verify the handler was dispatched to the thread pool. + +## Project structure + +``` +bubbaloop_sdk/ + __init__.py # Public API surface + context.py # NodeContext — main entry point + subscriber.py # TypedSubscriber, RawSubscriber, Callback*, Async* + publisher.py # JsonPublisher, ProtoPublisher + node.py # run_node() helper + health.py # Health heartbeat (used internally by run_node) + discover.py # discover_nodes() + get_sample.py # get_sample() one-shot helper + decode_sample.py # ProtoDecoder +tests/ + test_context.py # 48 unit tests (no real Zenoh required) +``` From 3c8fcc4c3b80ec4d816348722298465197d96894 Mon Sep 17 00:00:00 2001 From: Luis Date: Sun, 5 Apr 2026 18:22:04 +0200 Subject: [PATCH 11/54] chore(python-sdk): add pixi.toml; expand pyproject.toml; fix unused imports - Add python-sdk/pixi.toml with test/lint/fmt/check tasks (mirrors Kornia) - pyproject.toml: add readme, license, authors, classifiers, project.urls - pyproject.toml: add pytest-cov to dev deps; add [tool.coverage.*] - pyproject.toml: add [tool.ruff.format], [tool.ruff.lint.isort] - pyproject.toml: glob per-file-ignores for */__init__.py and tests/* - pyproject.toml: add C4/RUF rules; move B904 to global ignore - Remove unused `time` from health.py, `os`/`time` from node.py - Update CONTRIBUTING.md with pixi tasks and corrected suppressions table Co-Authored-By: Claude Sonnet 4.6 --- python-sdk/CONTRIBUTING.md | 90 ++++++++++++++++++------------ python-sdk/bubbaloop_sdk/health.py | 1 - python-sdk/bubbaloop_sdk/node.py | 2 - python-sdk/pixi.toml | 22 ++++++++ python-sdk/pyproject.toml | 73 ++++++++++++++++++++++-- 5 files changed, 143 insertions(+), 45 deletions(-) create mode 100644 python-sdk/pixi.toml diff --git a/python-sdk/CONTRIBUTING.md b/python-sdk/CONTRIBUTING.md index 07e0e54..f889fc6 100644 --- a/python-sdk/CONTRIBUTING.md +++ b/python-sdk/CONTRIBUTING.md @@ -1,67 +1,83 @@ # Contributing to bubbaloop-sdk (Python) -## Dev environment setup +## Dev environment + +### With pixi (recommended) ```bash cd python-sdk -python3 -m venv .venv -.venv/bin/pip install -e ".[dev]" +pixi install # creates env, installs all deps including dev extras +pixi run test +pixi run lint +pixi run fmt ``` -Dev deps installed: `pytest`, `pytest-asyncio`, `ruff`. +Available tasks: -## Running tests +| Task | Command | +|---|---| +| `pixi run test` | Run test suite | +| `pixi run test-cov` | Run tests with coverage report | +| `pixi run lint` | Check for lint errors (ruff) | +| `pixi run lint-fix` | Auto-fix lint errors | +| `pixi run fmt` | Format code | +| `pixi run fmt-check` | Check formatting without changing files | +| `pixi run check` | Run fmt-check + lint (CI equivalent) | + +### With plain venv ```bash cd python-sdk -.venv/bin/python -m pytest tests/ -v +python3 -m venv .venv +.venv/bin/pip install -e ".[dev]" +.venv/bin/pytest tests/ -v +.venv/bin/ruff check bubbaloop_sdk/ tests/ ``` ## Linting (ruff) -Config lives in `python-sdk/pyproject.toml` under `[tool.ruff]`. Same pattern as [Kornia](https://github.com/kornia/kornia). +Config lives in `python-sdk/pyproject.toml` under `[tool.ruff]`. +Follows the same pattern as [kornia/kornia](https://github.com/kornia/kornia). -```bash -cd python-sdk -.venv/bin/python -m ruff check bubbaloop_sdk/ tests/ # check -.venv/bin/python -m ruff check --fix bubbaloop_sdk/ tests/ # auto-fix -.venv/bin/python -m ruff format bubbaloop_sdk/ tests/ # format -``` +Rules enabled: E/W (pycodestyle), F (Pyflakes), I (isort), B (bugbear), +UP (pyupgrade), C4 (comprehensions), RUF (ruff-specific). -Key settings: -- `line-length = 120` -- Rules: E/W (pycodestyle), F (Pyflakes), I (isort), B (bugbear), UP (pyupgrade) -- `F821` globally ignored — forward-reference string annotations in `context.py` use lazy imports by design -- Per-file ignores for pre-existing issues in upstream files (`health.py`, `node.py`, `get_sample.py`) +Line length: 120 characters. -## Known pre-existing issues (upstream files, not touched) +## Pre-existing lint suppressions | File | Rule | Reason | |------|------|--------| -| `health.py` | F401 unused import (`time`) | Pre-existing upstream code | -| `node.py` | F401 unused imports (`os`, `time`) | Pre-existing upstream code | -| `get_sample.py` | B904 raise in except | Pre-existing upstream code | +| `context.py` | F821 (globally) | Forward-reference string annotations with lazy imports — by design | +| `get_sample.py` | B904 | `raise` without `from err` in upstream code | +| `*/__init__.py` | F401, F403 | Re-exports allowed | +| `tests/*` | S101, D | Assert and missing docstrings allowed in tests | -## Testing notes +## Testing Tests in `tests/test_context.py` do **not** open a real Zenoh session. -`_make_context()` helper uses `object.__new__(NodeContext)` + `MagicMock()` session — no router needed. +`_make_context()` uses `object.__new__(NodeContext)` + `MagicMock()` — no router needed. -For async subscriber/queryable tests, `threading.Event` with a 2s timeout is used to verify the handler was dispatched to the thread pool. +For async subscriber/queryable tests, `threading.Event` with a 2s timeout +verifies that handlers are dispatched to the thread pool correctly. ## Project structure ``` -bubbaloop_sdk/ - __init__.py # Public API surface - context.py # NodeContext — main entry point - subscriber.py # TypedSubscriber, RawSubscriber, Callback*, Async* - publisher.py # JsonPublisher, ProtoPublisher - node.py # run_node() helper - health.py # Health heartbeat (used internally by run_node) - discover.py # discover_nodes() - get_sample.py # get_sample() one-shot helper - decode_sample.py # ProtoDecoder -tests/ - test_context.py # 48 unit tests (no real Zenoh required) +python-sdk/ + pyproject.toml # Build config, deps, ruff/pytest/coverage config + pixi.toml # Pixi tasks (test, lint, fmt, check) + README.md # User-facing API docs + bubbaloop_sdk/ + __init__.py # Public API surface + context.py # NodeContext — main entry point + subscriber.py # TypedSubscriber, RawSubscriber, Callback*, Async* + publisher.py # JsonPublisher, ProtoPublisher + node.py # run_node() helper + health.py # Health heartbeat (used internally by run_node) + discover.py # discover_nodes() + get_sample.py # get_sample() one-shot helper + decode_sample.py # ProtoDecoder + tests/ + test_context.py # 48 unit tests (no real Zenoh required) ``` diff --git a/python-sdk/bubbaloop_sdk/health.py b/python-sdk/bubbaloop_sdk/health.py index 97fac5c..94668d5 100644 --- a/python-sdk/bubbaloop_sdk/health.py +++ b/python-sdk/bubbaloop_sdk/health.py @@ -1,7 +1,6 @@ """Background health heartbeat thread.""" import threading -import time import zenoh diff --git a/python-sdk/bubbaloop_sdk/node.py b/python-sdk/bubbaloop_sdk/node.py index 9c0774c..4a38de9 100644 --- a/python-sdk/bubbaloop_sdk/node.py +++ b/python-sdk/bubbaloop_sdk/node.py @@ -15,8 +15,6 @@ import argparse import logging -import os -import time import yaml diff --git a/python-sdk/pixi.toml b/python-sdk/pixi.toml new file mode 100644 index 0000000..5d96583 --- /dev/null +++ b/python-sdk/pixi.toml @@ -0,0 +1,22 @@ +[workspace] +name = "bubbaloop-sdk" +version = "0.1.0" +description = "Bubbaloop Node SDK for Python" +channels = ["conda-forge"] +platforms = ["linux-64", "linux-aarch64"] + +[dependencies] +python = ">=3.9,<3.14" + +[pypi-dependencies] +bubbaloop-sdk = { path = ".", extras = ["dev"] } + +[tasks] +install = "pip install -e '.[dev]'" +test = "pytest tests/ -v" +test-cov = "pytest tests/ -v --cov=bubbaloop_sdk --cov-report=term-missing" +lint = "ruff check bubbaloop_sdk/ tests/" +lint-fix = "ruff check --fix bubbaloop_sdk/ tests/" +fmt = "ruff format bubbaloop_sdk/ tests/" +fmt-check = "ruff format --check bubbaloop_sdk/ tests/" +check = { depends-on = ["fmt-check", "lint"] } diff --git a/python-sdk/pyproject.toml b/python-sdk/pyproject.toml index 76b02f8..1aab027 100644 --- a/python-sdk/pyproject.toml +++ b/python-sdk/pyproject.toml @@ -6,30 +6,85 @@ build-backend = "setuptools.build_meta" name = "bubbaloop-sdk" version = "0.1.0" description = "Bubbaloop Node SDK for Python" +readme = "README.md" +license = { text = "Apache-2.0" } +authors = [{ name = "Kornia Team", email = "edgar.riba@gmail.com" }] requires-python = ">=3.9" +keywords = ["robotics", "physical-ai", "zenoh", "pub-sub"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Natural Language :: English", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Software Development :: Libraries", + "Topic :: System :: Distributed Computing", +] dependencies = [ "eclipse-zenoh>=1.7,<2", "protobuf>=4.0", ] +[project.urls] +"Bug Tracker" = "https://github.com/kornia/bubbaloop/issues" +"Source Code" = "https://github.com/kornia/bubbaloop" +Homepage = "https://github.com/kornia/bubbaloop" + [project.optional-dependencies] -dev = ["pytest", "pytest-asyncio", "ruff"] +dev = [ + "pytest", + "pytest-asyncio", + "pytest-cov", + "ruff", +] [tool.setuptools.packages.find] where = ["."] include = ["bubbaloop_sdk*"] +# --------------------------------------------------------------------------- +# pytest +# --------------------------------------------------------------------------- + [tool.pytest.ini_options] +addopts = "--color=yes -v" testpaths = ["tests"] +# --------------------------------------------------------------------------- +# coverage +# --------------------------------------------------------------------------- + +[tool.coverage.run] +branch = true +source = ["bubbaloop_sdk/"] + +[tool.coverage.report] +show_missing = true +skip_covered = true +exclude_lines = [ + "pragma: no cover", + "raise NotImplementedError", + "if TYPE_CHECKING:", + "def __repr__", +] + # --------------------------------------------------------------------------- # Ruff — linter + formatter (replaces flake8, isort, pycodestyle) +# Follows the same pattern as kornia/kornia # --------------------------------------------------------------------------- [tool.ruff] line-length = 120 target-version = "py39" +[tool.ruff.format] +skip-magic-trailing-comma = false + [tool.ruff.lint] select = [ "E", # pycodestyle errors @@ -38,14 +93,22 @@ select = [ "I", # isort "B", # flake8-bugbear "UP", # pyupgrade + "C4", # flake8-comprehensions + "RUF", # Ruff-specific rules ] ignore = [ # forward-reference string annotations with lazy imports (by design in context.py) "F821", + # pre-existing: raise without `from err` in get_sample.py (upstream code) + "B904", ] +[tool.ruff.lint.isort] +known-first-party = ["bubbaloop_sdk"] +split-on-trailing-comma = true + [tool.ruff.lint.per-file-ignores] -# pre-existing unused imports in upstream files (health.py, node.py) -"bubbaloop_sdk/health.py" = ["F401"] -"bubbaloop_sdk/node.py" = ["F401"] -"bubbaloop_sdk/get_sample.py" = ["B904"] +# __init__.py files may re-export names (star imports allowed) +"*/__init__.py" = ["F401", "F403"] +# tests don't need docstrings and use assert freely +"tests/*" = ["S101", "D"] From f28631099d63adc59328752e2a01d696128b5a98 Mon Sep 17 00:00:00 2001 From: Luis Date: Sun, 5 Apr 2026 18:29:20 +0200 Subject: [PATCH 12/54] chore(python-sdk): scope F821 suppression to context.py only Move F821 from global ignore to per-file-ignores for context.py. The rule is only needed there (lazy imports + forward-reference string annotations), not project-wide. Co-Authored-By: Claude Sonnet 4.6 --- python-sdk/pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/python-sdk/pyproject.toml b/python-sdk/pyproject.toml index 1aab027..31cbb1e 100644 --- a/python-sdk/pyproject.toml +++ b/python-sdk/pyproject.toml @@ -97,8 +97,6 @@ select = [ "RUF", # Ruff-specific rules ] ignore = [ - # forward-reference string annotations with lazy imports (by design in context.py) - "F821", # pre-existing: raise without `from err` in get_sample.py (upstream code) "B904", ] @@ -112,3 +110,5 @@ split-on-trailing-comma = true "*/__init__.py" = ["F401", "F403"] # tests don't need docstrings and use assert freely "tests/*" = ["S101", "D"] +# forward-reference string annotations with lazy imports — by design +"bubbaloop_sdk/context.py" = ["F821"] From a19d90d6f062c13659f02d2077aca75200ba6f4b Mon Sep 17 00:00:00 2001 From: Luis Date: Sun, 5 Apr 2026 18:30:08 +0200 Subject: [PATCH 13/54] fix(python-sdk): raise GetSampleTimeout from err; remove global B904 ignore Co-Authored-By: Claude Sonnet 4.6 --- python-sdk/bubbaloop_sdk/get_sample.py | 4 ++-- python-sdk/pyproject.toml | 5 +---- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/python-sdk/bubbaloop_sdk/get_sample.py b/python-sdk/bubbaloop_sdk/get_sample.py index 43c06e3..aef5125 100644 --- a/python-sdk/bubbaloop_sdk/get_sample.py +++ b/python-sdk/bubbaloop_sdk/get_sample.py @@ -44,7 +44,7 @@ def _handler(sample: zenoh.Sample) -> None: sub = session.declare_subscriber(key_expr, _handler) try: return await asyncio.wait_for(future, timeout=timeout) - except asyncio.TimeoutError: - raise GetSampleTimeout(key_expr, timeout) + except asyncio.TimeoutError as err: + raise GetSampleTimeout(key_expr, timeout) from err finally: sub.undeclare() diff --git a/python-sdk/pyproject.toml b/python-sdk/pyproject.toml index 31cbb1e..a8f89a7 100644 --- a/python-sdk/pyproject.toml +++ b/python-sdk/pyproject.toml @@ -96,10 +96,7 @@ select = [ "C4", # flake8-comprehensions "RUF", # Ruff-specific rules ] -ignore = [ - # pre-existing: raise without `from err` in get_sample.py (upstream code) - "B904", -] +ignore = [] [tool.ruff.lint.isort] known-first-party = ["bubbaloop_sdk"] From ce0b18d042391a18a3b8b0da8cf7e8629ddb9987 Mon Sep 17 00:00:00 2001 From: Luis Date: Sun, 5 Apr 2026 18:37:48 +0200 Subject: [PATCH 14/54] chore: add markdownlint config; clean up README and CONTRIBUTING MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Disable MD013 (line-length) and MD060 (table-column-style) — both are stylistic and conflict with wide API tables. Fix fenced code block language tag in CONTRIBUTING.md (text). Update lint suppressions table to remove resolved B904 entry. Co-Authored-By: Claude Sonnet 4.6 --- .markdownlint.json | 4 ++ python-sdk/CONTRIBUTING.md | 11 +++-- python-sdk/README.md | 86 +++++++++++++++++++------------------- 3 files changed, 52 insertions(+), 49 deletions(-) create mode 100644 .markdownlint.json diff --git a/.markdownlint.json b/.markdownlint.json new file mode 100644 index 0000000..c44ef7c --- /dev/null +++ b/.markdownlint.json @@ -0,0 +1,4 @@ +{ + "MD013": false, + "MD060": false +} diff --git a/python-sdk/CONTRIBUTING.md b/python-sdk/CONTRIBUTING.md index f889fc6..09ca9bb 100644 --- a/python-sdk/CONTRIBUTING.md +++ b/python-sdk/CONTRIBUTING.md @@ -14,7 +14,7 @@ pixi run fmt Available tasks: -| Task | Command | +| Task | Description | |---|---| | `pixi run test` | Run test suite | | `pixi run test-cov` | Run tests with coverage report | @@ -44,12 +44,11 @@ UP (pyupgrade), C4 (comprehensions), RUF (ruff-specific). Line length: 120 characters. -## Pre-existing lint suppressions +## Lint suppressions | File | Rule | Reason | -|------|------|--------| -| `context.py` | F821 (globally) | Forward-reference string annotations with lazy imports — by design | -| `get_sample.py` | B904 | `raise` without `from err` in upstream code | +|---|---|---| +| `context.py` | F821 | Forward-reference string annotations with lazy imports — by design | | `*/__init__.py` | F401, F403 | Re-exports allowed | | `tests/*` | S101, D | Assert and missing docstrings allowed in tests | @@ -63,7 +62,7 @@ verifies that handlers are dispatched to the thread pool correctly. ## Project structure -``` +```text python-sdk/ pyproject.toml # Build config, deps, ruff/pytest/coverage config pixi.toml # Pixi tasks (test, lint, fmt, check) diff --git a/python-sdk/README.md b/python-sdk/README.md index 48db196..5dbcc57 100644 --- a/python-sdk/README.md +++ b/python-sdk/README.md @@ -118,80 +118,80 @@ qbl.undeclare() # call when done to release the thread pool ## Configuration -| Environment variable | Default | Description | -|---|---|---| -| `BUBBALOOP_ZENOH_ENDPOINT` | `tcp/127.0.0.1:7447` | Zenoh router endpoint | -| `BUBBALOOP_SCOPE` | `local` | Topic scope | -| `BUBBALOOP_MACHINE_ID` | hostname (sanitized) | Machine identifier | +| Environment variable | Default | Description | +| ---------------------------- | --------------------- | ------------------------- | +| `BUBBALOOP_ZENOH_ENDPOINT` | `tcp/127.0.0.1:7447` | Zenoh router endpoint | +| `BUBBALOOP_SCOPE` | `local` | Topic scope | +| `BUBBALOOP_MACHINE_ID` | hostname (sanitized) | Machine identifier | ## API reference ### `NodeContext` -| Method | Returns | Description | -|---|---|---| -| `NodeContext.connect(endpoint=None, instance_name=None)` | `NodeContext` | Connect to Zenoh router | -| `ctx.topic(suffix)` | `str` | Build `bubbaloop/{scope}/{machine_id}/{suffix}` | -| `ctx.is_shutdown()` | `bool` | True after SIGINT/SIGTERM | -| `ctx.wait_shutdown()` | — | Block until SIGINT/SIGTERM | -| `ctx.close()` | — | Close the Zenoh session | +| Method | Returns | Description | +| --------------------------------------------------- | ------------- | ----------------------------------------------- | +| `NodeContext.connect(endpoint=None, instance_name=None)` | `NodeContext` | Connect to Zenoh router | +| `ctx.topic(suffix)` | `str` | Build `bubbaloop/{scope}/{machine_id}/{suffix}` | +| `ctx.is_shutdown()` | `bool` | True after SIGINT/SIGTERM | +| `ctx.wait_shutdown()` | — | Block until SIGINT/SIGTERM | +| `ctx.close()` | — | Close the Zenoh session | #### Publishers -| Method | Returns | Description | -|---|---|---| -| `ctx.publisher_json(suffix)` | `JsonPublisher` | JSON publisher at `topic(suffix)` | -| `ctx.publisher_proto(suffix, msg_class=None)` | `ProtoPublisher` | Protobuf publisher at `topic(suffix)` | +| Method | Returns | Description | +| ----------------------------------------------- | --------------- | ---------------------------------------- | +| `ctx.publisher_json(suffix)` | `JsonPublisher` | JSON publisher at `topic(suffix)` | +| `ctx.publisher_proto(suffix, msg_class=None)` | `ProtoPublisher`| Protobuf publisher at `topic(suffix)` | #### Blocking subscribers (poll with `recv`) -| Method | Returns | Description | -|---|---|---| -| `ctx.subscriber(suffix, msg_class=None)` | `TypedSubscriber` | Queue-backed subscriber; `recv(timeout)` returns `None` on timeout | -| `ctx.subscriber_raw(key_expr)` | `RawSubscriber` | Same but yields raw `zenoh.Sample`; uses literal key expression | +| Method | Returns | Description | +| ----------------------------------------- | ---------------- | ------------------------------------------------------------ | +| `ctx.subscriber(suffix, msg_class=None)` | `TypedSubscriber`| Queue-backed; `recv(timeout)` returns `None` on timeout | +| `ctx.subscriber_raw(key_expr)` | `RawSubscriber` | Same but yields raw `zenoh.Sample`; literal key expression | #### Callback subscribers (event-driven) Handler is called from Zenoh's internal thread. Keep handlers fast; use `_async` variants for slow work. -| Method | Returns | Description | -|---|---|---| -| `ctx.subscriber_callback(suffix, handler, msg_class=None)` | `CallbackSubscriber` | Decoded message passed to handler | -| `ctx.subscriber_raw_callback(key_expr, handler)` | `RawCallbackSubscriber` | Raw `zenoh.Sample` passed to handler; literal key expression | -| `ctx.subscriber_callback_async(suffix, handler, msg_class=None, max_workers=4)` | `CallbackSubscriberAsync` | Handler runs in thread pool | -| `ctx.subscriber_raw_callback_async(key_expr, handler, max_workers=4)` | `RawCallbackSubscriberAsync` | Raw sample; handler in thread pool | +| Method | Returns | Description | +| ----------------------------------------------------------------------------- | ------------------------- | ---------------------------------------------- | +| `ctx.subscriber_callback(suffix, handler, msg_class=None)` | `CallbackSubscriber` | Decoded message passed to handler | +| `ctx.subscriber_raw_callback(key_expr, handler)` | `RawCallbackSubscriber` | Raw `zenoh.Sample` to handler; literal key | +| `ctx.subscriber_callback_async(suffix, handler, msg_class=None, max_workers=4)` | `CallbackSubscriberAsync` | Handler runs in thread pool | +| `ctx.subscriber_raw_callback_async(key_expr, handler, max_workers=4)` | `RawCallbackSubscriberAsync` | Raw sample; handler in thread pool | #### Queryables Do **not** pass `complete=True` — it blocks wildcard queries used by the dashboard. -| Method | Returns | Description | -|---|---|---| -| `ctx.queryable(suffix, handler)` | `zenoh.Queryable` | Handler at `topic(suffix)`; called from Zenoh thread | -| `ctx.queryable_raw(key_expr, handler)` | `zenoh.Queryable` | Handler at literal key expression | -| `ctx.queryable_async(suffix, handler, max_workers=4)` | `AsyncQueryable` | Handler in thread pool; call `undeclare()` to release | -| `ctx.queryable_raw_async(key_expr, handler, max_workers=4)` | `AsyncQueryable` | Raw key; handler in thread pool | +| Method | Returns | Description | +| --------------------------------------------------- | ---------------- | --------------------------------------------------- | +| `ctx.queryable(suffix, handler)` | `zenoh.Queryable`| Handler at `topic(suffix)`; called from Zenoh thread| +| `ctx.queryable_raw(key_expr, handler)` | `zenoh.Queryable`| Handler at literal key expression | +| `ctx.queryable_async(suffix, handler, max_workers=4)` | `AsyncQueryable` | Handler in thread pool; call `undeclare()` to release| +| `ctx.queryable_raw_async(key_expr, handler, max_workers=4)` | `AsyncQueryable` | Raw key; handler in thread pool | ### Publishers -| Method | Description | -|---|---| -| `pub.put(msg)` | Publish a message (bytes, proto message, or dict for JSON) | +| Method | Description | +| ------------- | -------------------------------------------------------------- | +| `pub.put(msg)`| Publish a message (bytes, proto message, or dict for JSON) | ### Blocking subscribers -| Method | Description | -|---|---| -| `sub.recv(timeout=None)` | Return next message or `None` on timeout | -| `sub.undeclare()` | Stop receiving samples | -| `for msg in sub` | Iterate (blocks indefinitely) | +| Method | Description | +| ----------------------- | ----------------------------------------- | +| `sub.recv(timeout=None)`| Return next message or `None` on timeout | +| `sub.undeclare()` | Stop receiving samples | +| `for msg in sub` | Iterate (blocks indefinitely) | ### Callback subscribers / AsyncQueryable -| Method | Description | -|---|---| -| `sub.undeclare()` | Undeclare subscriber and shut down thread pool (async variants) | +| Method | Description | +| ---------------- | -------------------------------------------------------------------- | +| `sub.undeclare()`| Undeclare subscriber and shut down thread pool (async variants) | ## Requirements From 2d3ce6486744c41ca32b3e99a651ef3192fcf9dd Mon Sep 17 00:00:00 2001 From: Luis Date: Sun, 5 Apr 2026 18:40:15 +0200 Subject: [PATCH 15/54] refactor(python-sdk): use TYPE_CHECKING for type annotations in context.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add TYPE_CHECKING guard at module level to import publisher/subscriber types for annotations only. Replace forward-reference strings ("Foo") with direct type names (Foo) in all method signatures. Remove F821 per-file suppression from pyproject.toml and the corresponding entry from CONTRIBUTING.md — no suppressions needed now. Co-Authored-By: Claude Sonnet 4.6 --- python-sdk/CONTRIBUTING.md | 1 - python-sdk/bubbaloop_sdk/context.py | 37 +++++++++++++++++++---------- python-sdk/pyproject.toml | 2 -- 3 files changed, 25 insertions(+), 15 deletions(-) diff --git a/python-sdk/CONTRIBUTING.md b/python-sdk/CONTRIBUTING.md index 09ca9bb..482d991 100644 --- a/python-sdk/CONTRIBUTING.md +++ b/python-sdk/CONTRIBUTING.md @@ -48,7 +48,6 @@ Line length: 120 characters. | File | Rule | Reason | |---|---|---| -| `context.py` | F821 | Forward-reference string annotations with lazy imports — by design | | `*/__init__.py` | F401, F403 | Re-exports allowed | | `tests/*` | S101, D | Assert and missing docstrings allowed in tests | diff --git a/python-sdk/bubbaloop_sdk/context.py b/python-sdk/bubbaloop_sdk/context.py index e2c2ae3..e92d9f5 100644 --- a/python-sdk/bubbaloop_sdk/context.py +++ b/python-sdk/bubbaloop_sdk/context.py @@ -17,9 +17,22 @@ import signal import socket import threading +from typing import TYPE_CHECKING import zenoh +if TYPE_CHECKING: + from .publisher import JsonPublisher, ProtoPublisher + from .subscriber import ( + AsyncQueryable, + CallbackSubscriber, + CallbackSubscriberAsync, + RawCallbackSubscriber, + RawCallbackSubscriberAsync, + RawSubscriber, + TypedSubscriber, + ) + def _hostname() -> str: return socket.gethostname().replace("-", "_") @@ -94,12 +107,12 @@ def wait_shutdown(self) -> None: # Publishers # ------------------------------------------------------------------ - def publisher_json(self, suffix: str) -> "JsonPublisher": + def publisher_json(self, suffix: str) -> JsonPublisher: """Declare a JSON publisher at ``topic(suffix)``.""" from .publisher import JsonPublisher return JsonPublisher._declare(self.session, self.topic(suffix)) - def publisher_proto(self, suffix: str, msg_class=None) -> "ProtoPublisher": + def publisher_proto(self, suffix: str, msg_class=None) -> ProtoPublisher: """Declare a protobuf publisher at ``topic(suffix)``.""" from .publisher import ProtoPublisher type_name = msg_class.DESCRIPTOR.full_name if msg_class is not None else None @@ -109,12 +122,12 @@ def publisher_proto(self, suffix: str, msg_class=None) -> "ProtoPublisher": # Subscribers # ------------------------------------------------------------------ - def subscriber(self, suffix: str, msg_class=None) -> "TypedSubscriber": + def subscriber(self, suffix: str, msg_class=None) -> TypedSubscriber: """Declare a typed subscriber. Blocks on ``recv()``.""" from .subscriber import TypedSubscriber return TypedSubscriber(self.session, self.topic(suffix), msg_class) - def subscriber_raw(self, key_expr: str) -> "RawSubscriber": + def subscriber_raw(self, key_expr: str) -> RawSubscriber: """Declare a raw subscriber with a literal key expression.""" from .subscriber import RawSubscriber return RawSubscriber(self.session, key_expr) @@ -123,7 +136,7 @@ def subscriber_raw(self, key_expr: str) -> "RawSubscriber": # Callback Subscribers # ------------------------------------------------------------------ - def subscriber_callback(self, suffix: str, handler, msg_class=None) -> "CallbackSubscriber": + def subscriber_callback(self, suffix: str, handler, msg_class=None) -> CallbackSubscriber: """Callback subscriber at ``topic(suffix)``. ``handler`` is called from Zenoh's internal thread each time a sample @@ -132,7 +145,7 @@ def subscriber_callback(self, suffix: str, handler, msg_class=None) -> "Callback from .subscriber import CallbackSubscriber return CallbackSubscriber(self.session, self.topic(suffix), handler, msg_class) - def subscriber_raw_callback(self, key_expr: str, handler) -> "RawCallbackSubscriber": + def subscriber_raw_callback(self, key_expr: str, handler) -> RawCallbackSubscriber: """Callback subscriber at a literal key expression. ``handler`` receives raw ``zenoh.Sample`` objects from Zenoh's internal thread. @@ -142,7 +155,7 @@ def subscriber_raw_callback(self, key_expr: str, handler) -> "RawCallbackSubscri def subscriber_callback_async( self, suffix: str, handler, msg_class=None, max_workers: int = 4 - ) -> "CallbackSubscriberAsync": + ) -> CallbackSubscriberAsync: """Callback subscriber at ``topic(suffix)`` with handler in a thread pool. Use when ``handler`` does slow work (database writes, hardware I/O, network @@ -154,7 +167,7 @@ def subscriber_callback_async( def subscriber_raw_callback_async( self, key_expr: str, handler, max_workers: int = 4 - ) -> "RawCallbackSubscriberAsync": + ) -> RawCallbackSubscriberAsync: """Raw callback subscriber at a literal key expression with handler in a thread pool.""" from .subscriber import RawCallbackSubscriberAsync return RawCallbackSubscriberAsync(self.session, key_expr, handler, max_workers) @@ -163,7 +176,7 @@ def subscriber_raw_callback_async( # Queryables # ------------------------------------------------------------------ - def queryable(self, suffix: str, handler) -> "zenoh.Queryable": + def queryable(self, suffix: str, handler) -> zenoh.Queryable: """Declare a queryable at ``topic(suffix)``. ``handler`` receives a ``zenoh.Query``. Use the standard zenoh API to reply:: @@ -183,7 +196,7 @@ def on_command(query: zenoh.Query) -> None: """ return self.session.declare_queryable(self.topic(suffix), handler) - def queryable_raw(self, key_expr: str, handler) -> "zenoh.Queryable": + def queryable_raw(self, key_expr: str, handler) -> zenoh.Queryable: """Declare a queryable at a literal key expression (no topic prefix). Use for wildcard queryables or when the ``bubbaloop/{scope}/{machine_id}/`` @@ -193,7 +206,7 @@ def queryable_raw(self, key_expr: str, handler) -> "zenoh.Queryable": """ return self.session.declare_queryable(key_expr, handler) - def queryable_async(self, suffix: str, handler, max_workers: int = 4) -> "AsyncQueryable": + def queryable_async(self, suffix: str, handler, max_workers: int = 4) -> AsyncQueryable: """Declare a queryable at ``topic(suffix)`` with handler in a thread pool. Use when the handler does slow work. Zenoh's internal thread is freed @@ -212,7 +225,7 @@ def on_db_query(query: zenoh.Query) -> None: from .subscriber import AsyncQueryable return AsyncQueryable(self.session, self.topic(suffix), handler, max_workers) - def queryable_raw_async(self, key_expr: str, handler, max_workers: int = 4) -> "AsyncQueryable": + def queryable_raw_async(self, key_expr: str, handler, max_workers: int = 4) -> AsyncQueryable: """Declare a queryable at a literal key expression with handler in a thread pool. Same as ``queryable_async()`` but uses a literal key expression without the diff --git a/python-sdk/pyproject.toml b/python-sdk/pyproject.toml index a8f89a7..0b10848 100644 --- a/python-sdk/pyproject.toml +++ b/python-sdk/pyproject.toml @@ -107,5 +107,3 @@ split-on-trailing-comma = true "*/__init__.py" = ["F401", "F403"] # tests don't need docstrings and use assert freely "tests/*" = ["S101", "D"] -# forward-reference string annotations with lazy imports — by design -"bubbaloop_sdk/context.py" = ["F821"] From 9c06330dc688b3241cc186bac2b157c96c030742 Mon Sep 17 00:00:00 2001 From: Luis Date: Sun, 5 Apr 2026 18:41:29 +0200 Subject: [PATCH 16/54] ci: add Python SDK lint and test steps Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/ci.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d5666ca..589f384 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,3 +49,14 @@ jobs: - name: Test run: pixi run cargo test --lib -p bubbaloop + + - name: Python SDK — lint + run: | + cd python-sdk + pip install -e ".[dev]" -q + ruff check bubbaloop_sdk/ tests/ + + - name: Python SDK — test + run: | + cd python-sdk + pytest tests/ -v From fcbac7a22d64b7f0d670293b898182dd9605e37e Mon Sep 17 00:00:00 2001 From: Luis Date: Sun, 5 Apr 2026 18:52:56 +0200 Subject: [PATCH 17/54] docs(python-sdk): add CLAUDE.md with conventions, threading model, pitfalls Co-Authored-By: Claude Sonnet 4.6 --- python-sdk/CLAUDE.md | 110 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 python-sdk/CLAUDE.md diff --git a/python-sdk/CLAUDE.md b/python-sdk/CLAUDE.md new file mode 100644 index 0000000..656bdda --- /dev/null +++ b/python-sdk/CLAUDE.md @@ -0,0 +1,110 @@ +# bubbaloop-sdk (Python) + +Pure Python wrapper over `zenoh-python`. Synchronous API — no asyncio required. +Mirrors the Rust `bubbaloop-node` SDK surface; nodes written with either SDK are interoperable. + +## Structure + +``` +python-sdk/ + bubbaloop_sdk/ + __init__.py # Public API — edit when adding new public names + context.py # NodeContext: connect(), topic(), publishers, subscribers, queryables + publisher.py # JsonPublisher, ProtoPublisher (wraps session.declare_publisher) + subscriber.py # TypedSubscriber, RawSubscriber, Callback*, Async*, AsyncQueryable + node.py # run_node() — CLI arg parsing + health heartbeat + lifecycle + health.py # start_health_heartbeat() — publishes 'ok' every 5s + discover.py # discover_nodes() — GET bubbaloop/**/health + get_sample.py # get_sample() — one-shot async subscribe-and-wait + decode_sample.py # ProtoDecoder — decode zenoh.Sample to protobuf + tests/ + test_context.py # 48 unit tests — NO real Zenoh session needed + pyproject.toml # Build config, deps, ruff/pytest/coverage + pixi.toml # Dev tasks: test, lint, fmt, check +``` + +## Build & verify + +```bash +# With pixi (recommended) +cd python-sdk +pixi run check # fmt-check + lint (run before every commit) +pixi run test # 48 unit tests +pixi run test-cov # tests + coverage report + +# With venv (alternative) +cd python-sdk +.venv/bin/python -m ruff check bubbaloop_sdk/ tests/ +.venv/bin/python -m pytest tests/ -v +``` + +## Conventions — MUST follow + +**Tooling:** +- `ruff` for lint + format — NOT flake8, black, or isort directly +- Config in `pyproject.toml` under `[tool.ruff]` — do NOT add `.flake8` or `setup.cfg` +- Line length: 120 characters +- `TYPE_CHECKING` guard for cross-module type annotations — NEVER string-quoted forward refs (`"Foo"`) + +**Imports:** +- Cross-module type-only imports go under `if TYPE_CHECKING:` at the top of the file +- Lazy runtime imports (inside method bodies) are kept to avoid circular import issues +- `__init__.py` must be updated whenever a new public class is added to `subscriber.py` or `publisher.py` + +**Zenoh session:** +- ALWAYS use `mode: "client"` — peer mode does not route through zenohd +- NEVER use `.complete(True)` on queryables — blocks wildcard queries like `bubbaloop/**/schema` +- `query.key_expr` is a **property**, NOT a method — NEVER write `query.key_expr()` +- `query.reply(query.key_expr, payload_bytes)` — correct reply pattern + +**Threading — critical:** +- Zenoh uses **one internal thread** for ALL callbacks and queryables on a session +- A slow handler blocks every other subscriber/queryable until it returns +- Use `_async` variants (`subscriber_callback_async`, `queryable_async`) for any handler that does I/O, DB access, or hardware calls +- Shutdown order for `_async` variants: undeclare Zenoh subscriber FIRST, then `executor.shutdown()` — reversing this causes `RuntimeError: cannot schedule new futures after shutdown` + +**`undeclare()` discipline:** +- Every subscriber, callback subscriber, and queryable must be undeclared when done +- `AsyncQueryable` and `*Async` subscribers own a `ThreadPoolExecutor` — GC alone is not enough, always call `undeclare()` +- Blocking subscribers (`TypedSubscriber`, `RawSubscriber`) are undeclared via `undeclare()` too + +## Testing + +Tests do NOT open a real Zenoh session. Use `_make_context()`: + +```python +def _make_context(scope, machine_id): + from bubbaloop_sdk.context import NodeContext + ctx = object.__new__(NodeContext) + ctx.session = MagicMock() + ctx.scope = scope + ctx.machine_id = machine_id + ctx.instance_name = machine_id + ctx._shutdown = threading.Event() + return ctx +``` + +For async/threaded tests use `threading.Event` with a 2s timeout — do NOT use `time.sleep`: + +```python +event = threading.Event() +def handler(msg): + received.append(msg) + event.set() +assert event.wait(timeout=2.0), "handler not called within 2s" +``` + +## DO / DON'T + +**DO:** `pixi run check` before every commit | add tests when adding public methods | update `__init__.py` and its `__all__` for every new public class | call `undeclare()` in tests that create async subscribers or queryables + +**DON'T:** use `asyncio` — the SDK is synchronous by design | use `query.key_expr()` with parentheses | use `.complete(True)` on queryables | add string forward references (`"Foo"`) — use `TYPE_CHECKING` instead | suppress lint rules globally when a per-file or code-level fix is possible + +## Pitfalls + +- `B904` — always `raise Foo from err` inside `except` blocks, never bare `raise Foo(...)` +- `F401` in `__init__.py` is suppressed by ruff config (re-exports are intentional) — do NOT add `# noqa` comments there +- `CallbackSubscriber` and `RawCallbackSubscriber` do NOT own an executor — `undeclare()` only calls `_sub.undeclare()`; the `_async` variants do own an executor and shut it down in `undeclare()` +- `TypedSubscriber` and `RawSubscriber` are iterable (`for msg in sub`) but iteration blocks forever — always prefer `recv(timeout=...)` in shutdown-aware loops +- `run_node()` reads `config.yaml` by default; override with `-c path/config.yaml`. The `name` field in config sets `instance_name` for health/schema topics — collisions happen if two instances share the same name +- Health topic format: `bubbaloop/{scope}/{machine_id}/{instance_name}/health` — ensure consumer patterns match exactly From f87ea16c86a8beec8b95ea8ab0b576ba7412526b Mon Sep 17 00:00:00 2001 From: Luis Date: Sun, 5 Apr 2026 19:03:14 +0200 Subject: [PATCH 18/54] test(python-sdk): add missing tests for publisher/subscriber context methods Cover previously untested NodeContext surface: - publisher_json/proto: verify topic() prefix is applied - subscriber/subscriber_raw: verify topic() prefix / literal key expr - close(): calls session.close() - context manager: __exit__ calls close() - connect(): BUBBALOOP_SCOPE, BUBBALOOP_MACHINE_ID env vars + defaults + instance_name override - TypedSubscriber/RawSubscriber: undeclare() and iteration 61 tests total (up from 48). Co-Authored-By: Claude Sonnet 4.6 --- python-sdk/tests/test_context.py | 171 +++++++++++++++++++++++++++++++ 1 file changed, 171 insertions(+) diff --git a/python-sdk/tests/test_context.py b/python-sdk/tests/test_context.py index 7099a14..913e3ed 100644 --- a/python-sdk/tests/test_context.py +++ b/python-sdk/tests/test_context.py @@ -648,6 +648,177 @@ def test_subscriber_raw_callback_async_uses_literal_key_expr(): assert called_topic == "bubbaloop/**/health" +# --------------------------------------------------------------------------- +# NodeContext.publisher_json() / publisher_proto() via context +# --------------------------------------------------------------------------- + +def test_publisher_json_uses_topic_prefix(): + """publisher_json() declares at topic(suffix).""" + ctx = _make_context("local", "bot") + ctx.publisher_json("weather/current") + called_topic = ctx.session.declare_publisher.call_args[0][0] + assert called_topic == "bubbaloop/local/bot/weather/current" + + +def test_publisher_proto_uses_topic_prefix(): + """publisher_proto() declares at topic(suffix).""" + ctx = _make_context("local", "bot") + fake_class = MagicMock() + fake_class.DESCRIPTOR.full_name = "my.SensorData" + ctx.publisher_proto("sensor/data", fake_class) + called_topic = ctx.session.declare_publisher.call_args[0][0] + assert called_topic == "bubbaloop/local/bot/sensor/data" + + +# --------------------------------------------------------------------------- +# NodeContext.subscriber() / subscriber_raw() via context +# --------------------------------------------------------------------------- + +def test_subscriber_uses_topic_prefix(): + """subscriber() declares at topic(suffix).""" + ctx = _make_context("local", "bot") + ctx.subscriber("sensor/data") + called_topic = ctx.session.declare_subscriber.call_args[0][0] + assert called_topic == "bubbaloop/local/bot/sensor/data" + + +def test_subscriber_raw_uses_literal_key_expr(): + """subscriber_raw() declares at the literal key expression.""" + ctx = _make_context("local", "bot") + ctx.subscriber_raw("bubbaloop/**/health") + called_topic = ctx.session.declare_subscriber.call_args[0][0] + assert called_topic == "bubbaloop/**/health" + + +# --------------------------------------------------------------------------- +# NodeContext.close() and context manager +# --------------------------------------------------------------------------- + +def test_close_calls_session_close(): + """close() calls session.close().""" + ctx = _make_context("local", "bot") + ctx.close() + ctx.session.close.assert_called_once() + + +def test_context_manager_calls_close(): + """__exit__ calls close() so the session is always cleaned up.""" + ctx = _make_context("local", "bot") + with ctx: + pass + ctx.session.close.assert_called_once() + + +# --------------------------------------------------------------------------- +# NodeContext.connect() — env var resolution +# --------------------------------------------------------------------------- + +def test_connect_reads_scope_from_env(monkeypatch): + """BUBBALOOP_SCOPE env var sets ctx.scope.""" + import zenoh + monkeypatch.setenv("BUBBALOOP_SCOPE", "prod") + monkeypatch.delenv("BUBBALOOP_MACHINE_ID", raising=False) + monkeypatch.delenv("BUBBALOOP_ZENOH_ENDPOINT", raising=False) + monkeypatch.setattr(zenoh, "open", lambda cfg: MagicMock()) + monkeypatch.setattr(zenoh, "Config", MagicMock) + from bubbaloop_sdk.context import NodeContext + ctx = NodeContext.connect() + assert ctx.scope == "prod" + + +def test_connect_reads_machine_id_from_env(monkeypatch): + """BUBBALOOP_MACHINE_ID env var sets ctx.machine_id.""" + import zenoh + monkeypatch.setenv("BUBBALOOP_MACHINE_ID", "jetson_orin") + monkeypatch.delenv("BUBBALOOP_SCOPE", raising=False) + monkeypatch.delenv("BUBBALOOP_ZENOH_ENDPOINT", raising=False) + monkeypatch.setattr(zenoh, "open", lambda cfg: MagicMock()) + monkeypatch.setattr(zenoh, "Config", MagicMock) + from bubbaloop_sdk.context import NodeContext + ctx = NodeContext.connect() + assert ctx.machine_id == "jetson_orin" + + +def test_connect_defaults_scope_to_local(monkeypatch): + """scope defaults to 'local' when env var is absent.""" + import zenoh + monkeypatch.delenv("BUBBALOOP_SCOPE", raising=False) + monkeypatch.delenv("BUBBALOOP_MACHINE_ID", raising=False) + monkeypatch.delenv("BUBBALOOP_ZENOH_ENDPOINT", raising=False) + monkeypatch.setattr(zenoh, "open", lambda cfg: MagicMock()) + monkeypatch.setattr(zenoh, "Config", MagicMock) + from bubbaloop_sdk.context import NodeContext + ctx = NodeContext.connect() + assert ctx.scope == "local" + + +def test_connect_instance_name_override(monkeypatch): + """instance_name kwarg overrides hostname fallback.""" + import zenoh + monkeypatch.delenv("BUBBALOOP_MACHINE_ID", raising=False) + monkeypatch.delenv("BUBBALOOP_SCOPE", raising=False) + monkeypatch.delenv("BUBBALOOP_ZENOH_ENDPOINT", raising=False) + monkeypatch.setattr(zenoh, "open", lambda cfg: MagicMock()) + monkeypatch.setattr(zenoh, "Config", MagicMock) + from bubbaloop_sdk.context import NodeContext + ctx = NodeContext.connect(instance_name="tapo_entrance") + assert ctx.instance_name == "tapo_entrance" + + +# --------------------------------------------------------------------------- +# TypedSubscriber / RawSubscriber — undeclare() and iteration +# --------------------------------------------------------------------------- + +def test_typed_subscriber_undeclare(): + """undeclare() calls undeclare on the underlying zenoh subscriber.""" + from bubbaloop_sdk.subscriber import TypedSubscriber + mock_session = MagicMock() + mock_sub = MagicMock() + mock_session.declare_subscriber.return_value = mock_sub + sub = TypedSubscriber(mock_session, "test/topic") + sub.undeclare() + mock_sub.undeclare.assert_called_once() + + +def test_raw_subscriber_undeclare(): + """undeclare() calls undeclare on the underlying zenoh subscriber.""" + from bubbaloop_sdk.subscriber import RawSubscriber + mock_session = MagicMock() + mock_sub = MagicMock() + mock_session.declare_subscriber.return_value = mock_sub + sub = RawSubscriber(mock_session, "test/topic") + sub.undeclare() + mock_sub.undeclare.assert_called_once() + + +def test_typed_subscriber_iteration(): + """Iterating over TypedSubscriber yields decoded messages.""" + from bubbaloop_sdk.subscriber import TypedSubscriber + mock_session = MagicMock() + captured_handler = [] + + def fake_declare(topic, handler): + captured_handler.append(handler) + return MagicMock() + + mock_session.declare_subscriber.side_effect = fake_declare + sub = TypedSubscriber(mock_session, "test/topic") + + # Feed two samples then stop iteration by checking queue empty + for payload in [b"\x01", b"\x02"]: + fake_sample = MagicMock() + fake_sample.payload.to_bytes.return_value = payload + captured_handler[0](fake_sample) + + results = [] + for msg in sub: + results.append(msg) + if len(results) == 2: + break + + assert results == [b"\x01", b"\x02"] + + # --------------------------------------------------------------------------- # Helper # --------------------------------------------------------------------------- From bdbf7c0fc9f445903c23ef66decd79df6b4a8487 Mon Sep 17 00:00:00 2001 From: Luis Date: Sun, 5 Apr 2026 19:05:16 +0200 Subject: [PATCH 19/54] fix(python-sdk): add from __future__ import annotations to context.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without this, TYPE_CHECKING-only imports (JsonPublisher, TypedSubscriber, etc.) are evaluated at class definition time on Python ≤3.13, causing NameError. Python 3.14 made annotations lazy by default so tests passed locally but failed on CI (Python 3.12). from __future__ import annotations defers all annotation evaluation to string form on all supported versions. Co-Authored-By: Claude Sonnet 4.6 --- python-sdk/bubbaloop_sdk/context.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/python-sdk/bubbaloop_sdk/context.py b/python-sdk/bubbaloop_sdk/context.py index e92d9f5..3fd364a 100644 --- a/python-sdk/bubbaloop_sdk/context.py +++ b/python-sdk/bubbaloop_sdk/context.py @@ -13,6 +13,8 @@ ctx.close() """ +from __future__ import annotations + import os import signal import socket @@ -59,7 +61,7 @@ def connect( cls, endpoint: str | None = None, instance_name: str | None = None, - ) -> "NodeContext": + ) -> NodeContext: """Connect to a Zenoh router and return a ready NodeContext. Endpoint resolution: ``endpoint`` arg → ``BUBBALOOP_ZENOH_ENDPOINT`` env From 0105a09ff76d5772fd3715e09ae616cfe061cfb0 Mon Sep 17 00:00:00 2001 From: Luis Date: Mon, 6 Apr 2026 00:00:51 +0200 Subject: [PATCH 20/54] fix(python-sdk): address PR review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bump requires-python to >=3.10 (PEP 604 union syntax used throughout) - Remove upper bound from pixi.toml python constraint (was arbitrarily <3.14) - Update ruff target-version to py310; drop py3.9 classifier - subscriber.py: move proto decode from Zenoh callback into recv() so FromString() runs on the consumer thread, not Zenoh's internal thread - subscriber.py: push _CLOSED sentinel on undeclare() to unblock recv(timeout=None) - subscriber.py: fix docstring — callbacks are serial not concurrent; recommend _async - subscriber.py: add _closing flag + try/except RuntimeError in _wrap() to handle race between submit() and executor.shutdown() in *Async classes and AsyncQueryable - subscriber.py: use shutdown(cancel_futures=True) (Python 3.9+) - ci.yml: invoke ruff/pytest via python -m to stay within allowlisted command prefixes Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/ci.yml | 4 +- python-sdk/bubbaloop_sdk/subscriber.py | 93 +++++++++++++++++--------- python-sdk/pixi.toml | 2 +- python-sdk/pyproject.toml | 5 +- 4 files changed, 68 insertions(+), 36 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 589f384..f28a861 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,9 +54,9 @@ jobs: run: | cd python-sdk pip install -e ".[dev]" -q - ruff check bubbaloop_sdk/ tests/ + python -m ruff check bubbaloop_sdk/ tests/ - name: Python SDK — test run: | cd python-sdk - pytest tests/ -v + python -m pytest tests/ -v diff --git a/python-sdk/bubbaloop_sdk/subscriber.py b/python-sdk/bubbaloop_sdk/subscriber.py index 2bd3baa..ee33357 100644 --- a/python-sdk/bubbaloop_sdk/subscriber.py +++ b/python-sdk/bubbaloop_sdk/subscriber.py @@ -1,17 +1,23 @@ """Zenoh subscribers — blocking and callback-based.""" +from __future__ import annotations + import concurrent.futures import queue +import threading import zenoh +# Sentinel object pushed into a queue to unblock recv() on undeclare(). +_CLOSED = object() + class TypedSubscriber: """Blocking subscriber with optional timeout. Iterates with ``for msg in sub``. - Internally queue-backed: Zenoh delivers samples via a callback into a - ``queue.Queue``. ``recv()`` drains the queue with an optional timeout, - allowing clean shutdown integration:: + Internally queue-backed: Zenoh delivers raw payload bytes via a callback into a + ``queue.Queue``. Decoding (``msg_class.FromString``) happens in ``recv()`` on the + consumer thread, not in Zenoh's internal thread:: while not ctx.is_shutdown(): msg = sub.recv(timeout=5.0) @@ -19,7 +25,8 @@ class TypedSubscriber: continue process(msg) - Without a timeout, ``recv()`` blocks indefinitely (backward-compatible). + Without a timeout, ``recv()`` blocks until a message arrives or ``undeclare()`` + is called (which pushes a sentinel to unblock any waiting ``recv()``). """ def __init__(self, session: zenoh.Session, topic: str, msg_class=None): @@ -27,20 +34,21 @@ def __init__(self, session: zenoh.Session, topic: str, msg_class=None): self._msg_class = msg_class def _on_sample(sample: zenoh.Sample) -> None: - payload = bytes(sample.payload.to_bytes()) - if self._msg_class is not None and hasattr(self._msg_class, "FromString"): - self._queue.put(self._msg_class.FromString(payload)) - else: - self._queue.put(payload) + self._queue.put(bytes(sample.payload.to_bytes())) self._sub = session.declare_subscriber(topic, _on_sample) def recv(self, timeout: float | None = None): - """Block until the next message arrives. Returns ``None`` on timeout.""" + """Block until the next message arrives. Returns ``None`` on timeout or close.""" try: - return self._queue.get(timeout=timeout) + payload = self._queue.get(timeout=timeout) except queue.Empty: return None + if payload is _CLOSED: + return None + if self._msg_class is not None and hasattr(self._msg_class, "FromString"): + return self._msg_class.FromString(payload) + return payload def __iter__(self): return self @@ -52,15 +60,16 @@ def __next__(self): return msg def undeclare(self) -> None: - """Undeclare the subscriber and stop receiving samples.""" + """Undeclare the subscriber and unblock any waiting ``recv()``.""" self._sub.undeclare() + self._queue.put(_CLOSED) class RawSubscriber: """Blocking subscriber that yields raw ``zenoh.Sample`` objects, with optional timeout. Internally queue-backed — same pattern as ``TypedSubscriber``. - ``recv()`` also exposes an optional timeout for shutdown-aware loops. + ``undeclare()`` pushes a sentinel to unblock any waiting ``recv()``. """ def __init__(self, session: zenoh.Session, key_expr: str): @@ -72,11 +81,14 @@ def _on_sample(sample: zenoh.Sample) -> None: self._sub = session.declare_subscriber(key_expr, _on_sample) def recv(self, timeout: float | None = None): - """Block until the next sample arrives. Returns ``None`` on timeout.""" + """Block until the next sample arrives. Returns ``None`` on timeout or close.""" try: - return self._queue.get(timeout=timeout) + sample = self._queue.get(timeout=timeout) except queue.Empty: return None + if sample is _CLOSED: + return None + return sample def __iter__(self): return self @@ -88,21 +100,24 @@ def __next__(self): return sample def undeclare(self) -> None: - """Undeclare the subscriber and stop receiving samples.""" + """Undeclare the subscriber and unblock any waiting ``recv()``.""" self._sub.undeclare() + self._queue.put(_CLOSED) class CallbackSubscriber: """Callback-based subscriber — Zenoh calls ``handler`` from its internal thread. - No loop required from the caller. All declared ``CallbackSubscriber`` instances - on the same session receive concurrently and independently. + No loop required from the caller. Callbacks are invoked **serially** on Zenoh's + single internal thread per session — if your handler is slow it will delay every + other subscriber and queryable on the same session. Use ``subscriber_callback_async()`` + for slow work. ``handler`` receives a decoded message (``msg_class.FromString(payload)``) if ``msg_class`` is provided, or raw ``bytes`` otherwise. - **Threading contract:** ``handler`` is called from Zenoh's internal thread. - If you share state with a main loop, protect it with a lock:: + **Threading contract:** ``handler`` runs on Zenoh's internal thread. + Protect shared state with a lock if accessed from other threads:: lock = threading.Lock() last_value = None @@ -114,9 +129,6 @@ def on_msg(msg): sub = ctx.subscriber_callback("sensor/data", on_msg, SensorData) - For slow handlers (database writes, hardware I/O), use - ``ctx.subscriber_callback_async()`` instead to avoid blocking Zenoh's thread. - Keep the returned object alive — garbage-collecting it undeclares the subscriber. """ @@ -139,7 +151,7 @@ class RawCallbackSubscriber: """Callback-based subscriber that passes raw ``zenoh.Sample`` to the handler. Use when you need access to the full sample metadata (key_expr, encoding, - timestamp). Handler is called from Zenoh's internal thread. + timestamp). Handler is called **serially** on Zenoh's internal thread. For slow handlers, use ``ctx.subscriber_raw_callback_async()`` instead. @@ -178,21 +190,28 @@ class CallbackSubscriberAsync: def __init__(self, session: zenoh.Session, topic: str, handler, msg_class=None, max_workers: int = 4): self._executor = concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) + self._closing = threading.Event() def _wrap(sample: zenoh.Sample) -> None: + if self._closing.is_set(): + return payload = bytes(sample.payload.to_bytes()) if msg_class is not None and hasattr(msg_class, "FromString"): msg = msg_class.FromString(payload) else: msg = payload - self._executor.submit(handler, msg) + try: + self._executor.submit(handler, msg) + except RuntimeError: + pass # executor already shut down — drop the message self._sub = session.declare_subscriber(topic, _wrap) def undeclare(self) -> None: """Undeclare the subscriber and shutdown the thread pool.""" + self._closing.set() self._sub.undeclare() # stop Zenoh callbacks first - self._executor.shutdown(wait=False) + self._executor.shutdown(wait=False, cancel_futures=True) class RawCallbackSubscriberAsync: @@ -206,16 +225,23 @@ class RawCallbackSubscriberAsync: def __init__(self, session: zenoh.Session, key_expr: str, handler, max_workers: int = 4): self._executor = concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) + self._closing = threading.Event() def _wrap(sample: zenoh.Sample) -> None: - self._executor.submit(handler, sample) + if self._closing.is_set(): + return + try: + self._executor.submit(handler, sample) + except RuntimeError: + pass # executor already shut down — drop the message self._sub = session.declare_subscriber(key_expr, _wrap) def undeclare(self) -> None: """Undeclare the subscriber and shutdown the thread pool.""" + self._closing.set() self._sub.undeclare() # stop Zenoh callbacks first - self._executor.shutdown(wait=False) + self._executor.shutdown(wait=False, cancel_futures=True) class AsyncQueryable: @@ -243,13 +269,20 @@ def on_db_query(query: zenoh.Query) -> None: def __init__(self, session: zenoh.Session, key_expr: str, handler, max_workers: int = 4): self._executor = concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) + self._closing = threading.Event() def _wrap(query) -> None: - self._executor.submit(handler, query) + if self._closing.is_set(): + return + try: + self._executor.submit(handler, query) + except RuntimeError: + pass # executor already shut down — drop the query self._qbl = session.declare_queryable(key_expr, _wrap) def undeclare(self) -> None: """Undeclare the queryable and shutdown the thread pool.""" + self._closing.set() self._qbl.undeclare() # stop Zenoh callbacks first - self._executor.shutdown(wait=False) + self._executor.shutdown(wait=False, cancel_futures=True) diff --git a/python-sdk/pixi.toml b/python-sdk/pixi.toml index 5d96583..9261b8a 100644 --- a/python-sdk/pixi.toml +++ b/python-sdk/pixi.toml @@ -6,7 +6,7 @@ channels = ["conda-forge"] platforms = ["linux-64", "linux-aarch64"] [dependencies] -python = ">=3.9,<3.14" +python = ">=3.10" [pypi-dependencies] bubbaloop-sdk = { path = ".", extras = ["dev"] } diff --git a/python-sdk/pyproject.toml b/python-sdk/pyproject.toml index 0b10848..677fa61 100644 --- a/python-sdk/pyproject.toml +++ b/python-sdk/pyproject.toml @@ -9,7 +9,7 @@ description = "Bubbaloop Node SDK for Python" readme = "README.md" license = { text = "Apache-2.0" } authors = [{ name = "Kornia Team", email = "edgar.riba@gmail.com" }] -requires-python = ">=3.9" +requires-python = ">=3.10" keywords = ["robotics", "physical-ai", "zenoh", "pub-sub"] classifiers = [ "Development Status :: 3 - Alpha", @@ -18,7 +18,6 @@ classifiers = [ "Natural Language :: English", "Operating System :: OS Independent", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", @@ -80,7 +79,7 @@ exclude_lines = [ [tool.ruff] line-length = 120 -target-version = "py39" +target-version = "py310" [tool.ruff.format] skip-magic-trailing-comma = false From 485d54e9ef4bdaf4fbb45eb3fe2f310b91d6162c Mon Sep 17 00:00:00 2001 From: Luis Date: Mon, 6 Apr 2026 00:19:12 +0200 Subject: [PATCH 21/54] =?UTF-8?q?test(python-sdk):=20cover=20PR=20review?= =?UTF-8?q?=20fixes=20=E2=80=94=20sentinel,=20=5Fclosing=20flag,=20decode?= =?UTF-8?q?=20thread?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - test_typed/raw_subscriber_undeclare_unblocks_recv: verify undeclare() pushes _CLOSED sentinel so recv(timeout=None) returns None immediately - test_typed_subscriber_decode_happens_in_recv_not_callback: verify FromString runs on the consumer thread, not the Zenoh callback thread - test_*_async_drops_after_undeclare (x3): verify _closing flag prevents handler from being called after undeclare() for CallbackSubscriberAsync, RawCallbackSubscriberAsync, and AsyncQueryable 67 tests total. Co-Authored-By: Claude Sonnet 4.6 --- python-sdk/tests/test_context.py | 172 +++++++++++++++++++++++++++++++ 1 file changed, 172 insertions(+) diff --git a/python-sdk/tests/test_context.py b/python-sdk/tests/test_context.py index 913e3ed..42e331a 100644 --- a/python-sdk/tests/test_context.py +++ b/python-sdk/tests/test_context.py @@ -248,6 +248,178 @@ def fake_declare(topic, handler): assert result is fake_sample +# --------------------------------------------------------------------------- +# TypedSubscriber / RawSubscriber — undeclare unblocks recv() +# --------------------------------------------------------------------------- + +def test_typed_subscriber_undeclare_unblocks_recv(): + """undeclare() unblocks a thread waiting in recv(timeout=None).""" + from bubbaloop_sdk.subscriber import TypedSubscriber + mock_session = MagicMock() + mock_session.declare_subscriber.return_value = MagicMock() + sub = TypedSubscriber(mock_session, "test/topic") + + result_holder = [] + + def blocking_recv(): + result_holder.append(sub.recv(timeout=None)) + + t = threading.Thread(target=blocking_recv) + t.start() + sub.undeclare() + t.join(timeout=2.0) + + assert not t.is_alive(), "recv() did not unblock after undeclare()" + assert result_holder == [None] + + +def test_raw_subscriber_undeclare_unblocks_recv(): + """undeclare() unblocks a thread waiting in recv(timeout=None).""" + from bubbaloop_sdk.subscriber import RawSubscriber + mock_session = MagicMock() + mock_session.declare_subscriber.return_value = MagicMock() + sub = RawSubscriber(mock_session, "test/topic") + + result_holder = [] + + def blocking_recv(): + result_holder.append(sub.recv(timeout=None)) + + t = threading.Thread(target=blocking_recv) + t.start() + sub.undeclare() + t.join(timeout=2.0) + + assert not t.is_alive(), "recv() did not unblock after undeclare()" + assert result_holder == [None] + + +def test_typed_subscriber_decode_happens_in_recv_not_callback(): + """FromString is called in recv(), not inside the Zenoh callback.""" + from bubbaloop_sdk.subscriber import TypedSubscriber + mock_session = MagicMock() + captured_handler = [] + + def fake_declare(topic, handler): + captured_handler.append(handler) + return MagicMock() + + mock_session.declare_subscriber.side_effect = fake_declare + decode_thread_ids = [] + callback_thread_id = [] + + class FakeMsgClass: + @staticmethod + def FromString(data): + decode_thread_ids.append(threading.current_thread().ident) + return f"decoded:{data}" + + sub = TypedSubscriber(mock_session, "test/topic", msg_class=FakeMsgClass) + + def zenoh_callback(): + callback_thread_id.append(threading.current_thread().ident) + fake_sample = MagicMock() + fake_sample.payload.to_bytes.return_value = b"\x01" + captured_handler[0](fake_sample) + + t = threading.Thread(target=zenoh_callback) + t.start() + t.join() + + result = sub.recv(timeout=1.0) + + assert result == "decoded:b'\\x01'" + # Decode must NOT have happened on the Zenoh (callback) thread + assert decode_thread_ids[0] != callback_thread_id[0] + + +# --------------------------------------------------------------------------- +# CallbackSubscriberAsync / RawCallbackSubscriberAsync — _closing flag +# --------------------------------------------------------------------------- + +def test_callback_subscriber_async_drops_after_undeclare(): + """Callbacks arriving after undeclare() are silently dropped.""" + from bubbaloop_sdk.subscriber import CallbackSubscriberAsync + mock_session = MagicMock() + captured_handler = [] + + def fake_declare(topic, handler): + captured_handler.append(handler) + return MagicMock() + + mock_session.declare_subscriber.side_effect = fake_declare + received = [] + + def handler(msg): + received.append(msg) + + sub = CallbackSubscriberAsync(mock_session, "test/topic", handler) + sub.undeclare() + + # Simulate a late-arriving Zenoh callback after undeclare + fake_sample = MagicMock() + fake_sample.payload.to_bytes.return_value = b"\xff" + captured_handler[0](fake_sample) # must not raise + + import time + time.sleep(0.05) + assert received == [], "handler should not be called after undeclare()" + + +def test_raw_callback_subscriber_async_drops_after_undeclare(): + """Callbacks arriving after undeclare() are silently dropped.""" + from bubbaloop_sdk.subscriber import RawCallbackSubscriberAsync + mock_session = MagicMock() + captured_handler = [] + + def fake_declare(key_expr, handler): + captured_handler.append(handler) + return MagicMock() + + mock_session.declare_subscriber.side_effect = fake_declare + received = [] + + def handler(sample): + received.append(sample) + + sub = RawCallbackSubscriberAsync(mock_session, "test/**", handler) + sub.undeclare() + + fake_sample = MagicMock() + captured_handler[0](fake_sample) # must not raise + + import time + time.sleep(0.05) + assert received == [], "handler should not be called after undeclare()" + + +def test_async_queryable_drops_after_undeclare(): + """Queries arriving after undeclare() are silently dropped.""" + from bubbaloop_sdk.subscriber import AsyncQueryable + mock_session = MagicMock() + captured_wrapper = [] + + def fake_declare(key_expr, wrapper): + captured_wrapper.append(wrapper) + return MagicMock() + + mock_session.declare_queryable.side_effect = fake_declare + received = [] + + def handler(query): + received.append(query) + + aq = AsyncQueryable(mock_session, "test/topic", handler) + aq.undeclare() + + fake_query = MagicMock() + captured_wrapper[0](fake_query) # must not raise + + import time + time.sleep(0.05) + assert received == [], "handler should not be called after undeclare()" + + # --------------------------------------------------------------------------- # CallbackSubscriber # --------------------------------------------------------------------------- From 254ada7e49b1aa1dc05f5b0bb6a64d6a35b60b3e Mon Sep 17 00:00:00 2001 From: Luis Date: Mon, 6 Apr 2026 00:47:06 +0200 Subject: [PATCH 22/54] fix(python-sdk): address new Copilot review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tests: assign CallbackSubscriber/RawCallbackSubscriber to local vars to prevent premature GC making tests flaky - README: update Python requirement to 3.10+ (matches pyproject.toml) - ci.yml: add ruff format --check step before ruff check - ruff format: apply formatter to context.py, node.py, test_context.py - README: rename duplicate headings (Publishers→Publisher methods, etc.) Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/ci.yml | 1 + python-sdk/README.md | 8 +-- python-sdk/bubbaloop_sdk/context.py | 14 +++- python-sdk/bubbaloop_sdk/node.py | 4 +- python-sdk/tests/test_context.py | 108 ++++++++++++++++++++++------ 5 files changed, 103 insertions(+), 32 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f28a861..f3d1297 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,6 +54,7 @@ jobs: run: | cd python-sdk pip install -e ".[dev]" -q + python -m ruff format --check bubbaloop_sdk/ tests/ python -m ruff check bubbaloop_sdk/ tests/ - name: Python SDK — test diff --git a/python-sdk/README.md b/python-sdk/README.md index 5dbcc57..2822bb5 100644 --- a/python-sdk/README.md +++ b/python-sdk/README.md @@ -173,13 +173,13 @@ Do **not** pass `complete=True` — it blocks wildcard queries used by the dashb | `ctx.queryable_async(suffix, handler, max_workers=4)` | `AsyncQueryable` | Handler in thread pool; call `undeclare()` to release| | `ctx.queryable_raw_async(key_expr, handler, max_workers=4)` | `AsyncQueryable` | Raw key; handler in thread pool | -### Publishers +### Publisher methods | Method | Description | | ------------- | -------------------------------------------------------------- | | `pub.put(msg)`| Publish a message (bytes, proto message, or dict for JSON) | -### Blocking subscribers +### Blocking subscriber methods | Method | Description | | ----------------------- | ----------------------------------------- | @@ -187,7 +187,7 @@ Do **not** pass `complete=True` — it blocks wildcard queries used by the dashb | `sub.undeclare()` | Stop receiving samples | | `for msg in sub` | Iterate (blocks indefinitely) | -### Callback subscribers / AsyncQueryable +### Callback subscriber / AsyncQueryable methods | Method | Description | | ---------------- | -------------------------------------------------------------------- | @@ -195,6 +195,6 @@ Do **not** pass `complete=True` — it blocks wildcard queries used by the dashb ## Requirements -- Python 3.9+ +- Python 3.10+ - `eclipse-zenoh >= 1.7, < 2` - `protobuf >= 4.0` diff --git a/python-sdk/bubbaloop_sdk/context.py b/python-sdk/bubbaloop_sdk/context.py index 3fd364a..d2a6085 100644 --- a/python-sdk/bubbaloop_sdk/context.py +++ b/python-sdk/bubbaloop_sdk/context.py @@ -112,11 +112,13 @@ def wait_shutdown(self) -> None: def publisher_json(self, suffix: str) -> JsonPublisher: """Declare a JSON publisher at ``topic(suffix)``.""" from .publisher import JsonPublisher + return JsonPublisher._declare(self.session, self.topic(suffix)) def publisher_proto(self, suffix: str, msg_class=None) -> ProtoPublisher: """Declare a protobuf publisher at ``topic(suffix)``.""" from .publisher import ProtoPublisher + type_name = msg_class.DESCRIPTOR.full_name if msg_class is not None else None return ProtoPublisher._declare(self.session, self.topic(suffix), type_name) @@ -127,11 +129,13 @@ def publisher_proto(self, suffix: str, msg_class=None) -> ProtoPublisher: def subscriber(self, suffix: str, msg_class=None) -> TypedSubscriber: """Declare a typed subscriber. Blocks on ``recv()``.""" from .subscriber import TypedSubscriber + return TypedSubscriber(self.session, self.topic(suffix), msg_class) def subscriber_raw(self, key_expr: str) -> RawSubscriber: """Declare a raw subscriber with a literal key expression.""" from .subscriber import RawSubscriber + return RawSubscriber(self.session, key_expr) # ------------------------------------------------------------------ @@ -145,6 +149,7 @@ def subscriber_callback(self, suffix: str, handler, msg_class=None) -> CallbackS arrives. For slow handlers (I/O, DB), use ``subscriber_callback_async()``. """ from .subscriber import CallbackSubscriber + return CallbackSubscriber(self.session, self.topic(suffix), handler, msg_class) def subscriber_raw_callback(self, key_expr: str, handler) -> RawCallbackSubscriber: @@ -153,6 +158,7 @@ def subscriber_raw_callback(self, key_expr: str, handler) -> RawCallbackSubscrib ``handler`` receives raw ``zenoh.Sample`` objects from Zenoh's internal thread. """ from .subscriber import RawCallbackSubscriber + return RawCallbackSubscriber(self.session, key_expr, handler) def subscriber_callback_async( @@ -165,13 +171,13 @@ def subscriber_callback_async( ``ThreadPoolExecutor`` with ``max_workers`` threads. """ from .subscriber import CallbackSubscriberAsync + return CallbackSubscriberAsync(self.session, self.topic(suffix), handler, msg_class, max_workers) - def subscriber_raw_callback_async( - self, key_expr: str, handler, max_workers: int = 4 - ) -> RawCallbackSubscriberAsync: + def subscriber_raw_callback_async(self, key_expr: str, handler, max_workers: int = 4) -> RawCallbackSubscriberAsync: """Raw callback subscriber at a literal key expression with handler in a thread pool.""" from .subscriber import RawCallbackSubscriberAsync + return RawCallbackSubscriberAsync(self.session, key_expr, handler, max_workers) # ------------------------------------------------------------------ @@ -225,6 +231,7 @@ def on_db_query(query: zenoh.Query) -> None: Protect shared state with locks. """ from .subscriber import AsyncQueryable + return AsyncQueryable(self.session, self.topic(suffix), handler, max_workers) def queryable_raw_async(self, key_expr: str, handler, max_workers: int = 4) -> AsyncQueryable: @@ -235,6 +242,7 @@ def queryable_raw_async(self, key_expr: str, handler, max_workers: int = 4) -> A Call ``undeclare()`` on the returned object when done to release threads. """ from .subscriber import AsyncQueryable + return AsyncQueryable(self.session, key_expr, handler, max_workers) # ------------------------------------------------------------------ diff --git a/python-sdk/bubbaloop_sdk/node.py b/python-sdk/bubbaloop_sdk/node.py index 4a38de9..a19dc42 100644 --- a/python-sdk/bubbaloop_sdk/node.py +++ b/python-sdk/bubbaloop_sdk/node.py @@ -51,9 +51,7 @@ def run_node(node_class) -> None: ctx = NodeContext.connect(endpoint=args.endpoint, instance_name=instance_name) - start_health_heartbeat( - ctx.session, ctx.scope, ctx.machine_id, instance_name, ctx._shutdown - ) + start_health_heartbeat(ctx.session, ctx.scope, ctx.machine_id, instance_name, ctx._shutdown) log.info("Health heartbeat: bubbaloop/%s/%s/%s/health", ctx.scope, ctx.machine_id, instance_name) node = node_class(ctx, config) diff --git a/python-sdk/tests/test_context.py b/python-sdk/tests/test_context.py index 42e331a..4b0ea09 100644 --- a/python-sdk/tests/test_context.py +++ b/python-sdk/tests/test_context.py @@ -14,11 +14,10 @@ # topic() # --------------------------------------------------------------------------- + def test_topic_formatting(): ctx = _make_context("staging", "jetson_orin") - assert ctx.topic("camera/front/compressed") == ( - "bubbaloop/staging/jetson_orin/camera/front/compressed" - ) + assert ctx.topic("camera/front/compressed") == ("bubbaloop/staging/jetson_orin/camera/front/compressed") def test_topic_default_scope(): @@ -35,14 +34,17 @@ def test_topic_wildcard_suffix(): # _hostname() sanitization # --------------------------------------------------------------------------- + def test_hostname_sanitization_hyphens(monkeypatch): from bubbaloop_sdk.context import _hostname + monkeypatch.setattr(socket, "gethostname", lambda: "my-robot-01") assert _hostname() == "my_robot_01" def test_hostname_no_hyphens(monkeypatch): from bubbaloop_sdk.context import _hostname + monkeypatch.setattr(socket, "gethostname", lambda: "myrobot") assert _hostname() == "myrobot" @@ -51,42 +53,50 @@ def test_hostname_no_hyphens(monkeypatch): # Import surface # --------------------------------------------------------------------------- + def test_import_node_context(): from bubbaloop_sdk import NodeContext + assert NodeContext is not None def test_import_publishers(): from bubbaloop_sdk import JsonPublisher, ProtoPublisher + assert ProtoPublisher is not None assert JsonPublisher is not None def test_import_subscribers(): from bubbaloop_sdk import RawSubscriber, TypedSubscriber + assert TypedSubscriber is not None assert RawSubscriber is not None def test_import_callback_subscribers(): from bubbaloop_sdk import CallbackSubscriber, RawCallbackSubscriber + assert CallbackSubscriber is not None assert RawCallbackSubscriber is not None def test_import_callback_subscribers_async(): from bubbaloop_sdk import CallbackSubscriberAsync, RawCallbackSubscriberAsync + assert CallbackSubscriberAsync is not None assert RawCallbackSubscriberAsync is not None def test_import_async_queryable(): from bubbaloop_sdk import AsyncQueryable + assert AsyncQueryable is not None def test_import_run_node(): from bubbaloop_sdk import run_node + assert callable(run_node) @@ -94,6 +104,7 @@ def test_import_run_node(): # Shutdown # --------------------------------------------------------------------------- + def test_shutdown_not_set_initially(): ctx = _make_context("local", "bot") assert not ctx.is_shutdown() @@ -109,8 +120,10 @@ def test_shutdown_set_manually(): # ProtoPublisher.put() # --------------------------------------------------------------------------- + def test_proto_publisher_rejects_invalid_type(): from bubbaloop_sdk.publisher import ProtoPublisher + pub = ProtoPublisher(MagicMock(), None) with pytest.raises(TypeError): pub.put(12345) @@ -118,6 +131,7 @@ def test_proto_publisher_rejects_invalid_type(): def test_proto_publisher_accepts_bytes(): from bubbaloop_sdk.publisher import ProtoPublisher + mock_pub = MagicMock() ProtoPublisher(mock_pub, None).put(b"\x01\x02\x03") mock_pub.put.assert_called_once_with(b"\x01\x02\x03") @@ -125,6 +139,7 @@ def test_proto_publisher_accepts_bytes(): def test_proto_publisher_calls_serialize(): from bubbaloop_sdk.publisher import ProtoPublisher + fake_msg = MagicMock() fake_msg.SerializeToString.return_value = b"\xde\xad\xbe\xef" mock_pub = MagicMock() @@ -136,8 +151,10 @@ def test_proto_publisher_calls_serialize(): # JsonPublisher.put() # --------------------------------------------------------------------------- + def test_json_publisher_serializes_dict(): from bubbaloop_sdk.publisher import JsonPublisher + mock_pub = MagicMock() JsonPublisher(mock_pub).put({"temperature": 22.5}) assert json.loads(mock_pub.put.call_args[0][0]) == {"temperature": 22.5} @@ -145,6 +162,7 @@ def test_json_publisher_serializes_dict(): def test_json_publisher_passthrough_bytes(): from bubbaloop_sdk.publisher import JsonPublisher + mock_pub = MagicMock() JsonPublisher(mock_pub).put(b"raw") mock_pub.put.assert_called_once_with(b"raw") @@ -152,6 +170,7 @@ def test_json_publisher_passthrough_bytes(): def test_json_publisher_passthrough_str(): from bubbaloop_sdk.publisher import JsonPublisher + mock_pub = MagicMock() JsonPublisher(mock_pub).put("hello") mock_pub.put.assert_called_once_with(b"hello") @@ -161,9 +180,11 @@ def test_json_publisher_passthrough_str(): # TypedSubscriber — queue-backed with timeout # --------------------------------------------------------------------------- + def test_typed_subscriber_recv_returns_none_on_timeout(): """recv(timeout) returns None when queue is empty within timeout.""" from bubbaloop_sdk.subscriber import TypedSubscriber + mock_session = MagicMock() mock_session.declare_subscriber.return_value = MagicMock() sub = TypedSubscriber(mock_session, "test/topic") @@ -174,6 +195,7 @@ def test_typed_subscriber_recv_returns_none_on_timeout(): def test_typed_subscriber_recv_returns_message_when_available(): """recv() returns the message put into the queue by the callback.""" from bubbaloop_sdk.subscriber import TypedSubscriber + mock_session = MagicMock() captured_handler = [] @@ -196,6 +218,7 @@ def fake_declare(topic, handler): def test_typed_subscriber_recv_decodes_proto(): """recv() decodes with FromString when msg_class provided.""" from bubbaloop_sdk.subscriber import TypedSubscriber + mock_session = MagicMock() captured_handler = [] @@ -221,6 +244,7 @@ def fake_declare(topic, handler): def test_raw_subscriber_recv_returns_none_on_timeout(): """RawSubscriber.recv(timeout) returns None when queue is empty.""" from bubbaloop_sdk.subscriber import RawSubscriber + mock_session = MagicMock() mock_session.declare_subscriber.return_value = MagicMock() sub = RawSubscriber(mock_session, "test/topic") @@ -231,6 +255,7 @@ def test_raw_subscriber_recv_returns_none_on_timeout(): def test_raw_subscriber_recv_returns_sample(): """RawSubscriber.recv() returns the raw zenoh.Sample.""" from bubbaloop_sdk.subscriber import RawSubscriber + mock_session = MagicMock() captured_handler = [] @@ -252,9 +277,11 @@ def fake_declare(topic, handler): # TypedSubscriber / RawSubscriber — undeclare unblocks recv() # --------------------------------------------------------------------------- + def test_typed_subscriber_undeclare_unblocks_recv(): """undeclare() unblocks a thread waiting in recv(timeout=None).""" from bubbaloop_sdk.subscriber import TypedSubscriber + mock_session = MagicMock() mock_session.declare_subscriber.return_value = MagicMock() sub = TypedSubscriber(mock_session, "test/topic") @@ -276,6 +303,7 @@ def blocking_recv(): def test_raw_subscriber_undeclare_unblocks_recv(): """undeclare() unblocks a thread waiting in recv(timeout=None).""" from bubbaloop_sdk.subscriber import RawSubscriber + mock_session = MagicMock() mock_session.declare_subscriber.return_value = MagicMock() sub = RawSubscriber(mock_session, "test/topic") @@ -297,6 +325,7 @@ def blocking_recv(): def test_typed_subscriber_decode_happens_in_recv_not_callback(): """FromString is called in recv(), not inside the Zenoh callback.""" from bubbaloop_sdk.subscriber import TypedSubscriber + mock_session = MagicMock() captured_handler = [] @@ -337,9 +366,11 @@ def zenoh_callback(): # CallbackSubscriberAsync / RawCallbackSubscriberAsync — _closing flag # --------------------------------------------------------------------------- + def test_callback_subscriber_async_drops_after_undeclare(): """Callbacks arriving after undeclare() are silently dropped.""" from bubbaloop_sdk.subscriber import CallbackSubscriberAsync + mock_session = MagicMock() captured_handler = [] @@ -362,6 +393,7 @@ def handler(msg): captured_handler[0](fake_sample) # must not raise import time + time.sleep(0.05) assert received == [], "handler should not be called after undeclare()" @@ -369,6 +401,7 @@ def handler(msg): def test_raw_callback_subscriber_async_drops_after_undeclare(): """Callbacks arriving after undeclare() are silently dropped.""" from bubbaloop_sdk.subscriber import RawCallbackSubscriberAsync + mock_session = MagicMock() captured_handler = [] @@ -389,6 +422,7 @@ def handler(sample): captured_handler[0](fake_sample) # must not raise import time + time.sleep(0.05) assert received == [], "handler should not be called after undeclare()" @@ -396,6 +430,7 @@ def handler(sample): def test_async_queryable_drops_after_undeclare(): """Queries arriving after undeclare() are silently dropped.""" from bubbaloop_sdk.subscriber import AsyncQueryable + mock_session = MagicMock() captured_wrapper = [] @@ -416,6 +451,7 @@ def handler(query): captured_wrapper[0](fake_query) # must not raise import time + time.sleep(0.05) assert received == [], "handler should not be called after undeclare()" @@ -424,9 +460,11 @@ def handler(query): # CallbackSubscriber # --------------------------------------------------------------------------- + def test_callback_subscriber_calls_handler_with_bytes(): """Handler receives raw bytes when no msg_class provided.""" from bubbaloop_sdk.subscriber import CallbackSubscriber + mock_session = MagicMock() captured_handler = [] @@ -436,20 +474,20 @@ def fake_declare(topic, handler): mock_session.declare_subscriber.side_effect = fake_declare received = [] - CallbackSubscriber( - mock_session, "test/topic", lambda msg: received.append(msg) - ) + sub = CallbackSubscriber(mock_session, "test/topic", lambda msg: received.append(msg)) fake_sample = MagicMock() fake_sample.payload.to_bytes.return_value = b"\xde\xad" captured_handler[0](fake_sample) assert received == [b"\xde\xad"] + sub.undeclare() def test_callback_subscriber_decodes_proto(): """Handler receives decoded proto when msg_class provided.""" from bubbaloop_sdk.subscriber import CallbackSubscriber + mock_session = MagicMock() captured_handler = [] @@ -462,10 +500,7 @@ def fake_declare(topic, handler): fake_msg_class = MagicMock() fake_msg_class.FromString.return_value = "decoded_proto" received = [] - CallbackSubscriber( - mock_session, "test/topic", - lambda msg: received.append(msg), msg_class=fake_msg_class - ) + sub = CallbackSubscriber(mock_session, "test/topic", lambda msg: received.append(msg), msg_class=fake_msg_class) fake_sample = MagicMock() fake_sample.payload.to_bytes.return_value = b"\x01" @@ -473,11 +508,13 @@ def fake_declare(topic, handler): assert received == ["decoded_proto"] fake_msg_class.FromString.assert_called_once_with(b"\x01") + sub.undeclare() def test_callback_subscriber_undeclare(): """undeclare() calls undeclare on the underlying zenoh subscriber.""" from bubbaloop_sdk.subscriber import CallbackSubscriber + mock_session = MagicMock() mock_sub = MagicMock() mock_session.declare_subscriber.return_value = mock_sub @@ -490,9 +527,11 @@ def test_callback_subscriber_undeclare(): # RawCallbackSubscriber # --------------------------------------------------------------------------- + def test_raw_callback_subscriber_passes_sample(): """Handler receives the raw zenoh.Sample object.""" from bubbaloop_sdk.subscriber import RawCallbackSubscriber + mock_session = MagicMock() captured_handler = [] @@ -502,19 +541,19 @@ def fake_declare(key_expr, handler): mock_session.declare_subscriber.side_effect = fake_declare received = [] - RawCallbackSubscriber( - mock_session, "test/**", lambda s: received.append(s) - ) + sub = RawCallbackSubscriber(mock_session, "test/**", lambda s: received.append(s)) fake_sample = MagicMock() captured_handler[0](fake_sample) assert received == [fake_sample] + sub.undeclare() def test_raw_callback_subscriber_undeclare(): """undeclare() calls undeclare on the underlying zenoh subscriber.""" from bubbaloop_sdk.subscriber import RawCallbackSubscriber + mock_session = MagicMock() mock_sub = MagicMock() mock_session.declare_subscriber.return_value = mock_sub @@ -527,11 +566,13 @@ def test_raw_callback_subscriber_undeclare(): # CallbackSubscriberAsync # --------------------------------------------------------------------------- + def test_callback_subscriber_async_calls_handler_in_thread_pool(): """Handler is called asynchronously via thread pool.""" import threading from bubbaloop_sdk.subscriber import CallbackSubscriberAsync + mock_session = MagicMock() captured_handler = [] @@ -563,6 +604,7 @@ def test_callback_subscriber_async_decodes_proto(): import threading from bubbaloop_sdk.subscriber import CallbackSubscriberAsync + mock_session = MagicMock() captured_handler = [] @@ -581,9 +623,7 @@ def handler(msg): received.append(msg) event.set() - sub = CallbackSubscriberAsync( - mock_session, "test/topic", handler, msg_class=fake_msg_class - ) + sub = CallbackSubscriberAsync(mock_session, "test/topic", handler, msg_class=fake_msg_class) fake_sample = MagicMock() fake_sample.payload.to_bytes.return_value = b"\x01" @@ -599,6 +639,7 @@ def test_raw_callback_subscriber_async_passes_sample(): import threading from bubbaloop_sdk.subscriber import RawCallbackSubscriberAsync + mock_session = MagicMock() captured_handler = [] @@ -627,6 +668,7 @@ def handler(sample): def test_callback_subscriber_async_undeclare(): """undeclare() shuts down executor and undeclares underlying sub.""" from bubbaloop_sdk.subscriber import CallbackSubscriberAsync + mock_session = MagicMock() mock_sub = MagicMock() mock_session.declare_subscriber.return_value = mock_sub @@ -638,6 +680,7 @@ def test_callback_subscriber_async_undeclare(): def test_raw_callback_subscriber_async_undeclare(): """undeclare() shuts down executor and undeclares underlying sub.""" from bubbaloop_sdk.subscriber import RawCallbackSubscriberAsync + mock_session = MagicMock() mock_sub = MagicMock() mock_session.declare_subscriber.return_value = mock_sub @@ -650,6 +693,7 @@ def test_raw_callback_subscriber_async_undeclare(): # NodeContext.queryable() and queryable_raw() # --------------------------------------------------------------------------- + def test_queryable_uses_topic_prefix(): """queryable() declares at bubbaloop/{scope}/{machine_id}/{suffix}.""" ctx = _make_context("local", "bot") @@ -658,9 +702,7 @@ def handler(q): pass ctx.queryable("command", handler) - ctx.session.declare_queryable.assert_called_once_with( - "bubbaloop/local/bot/command", handler - ) + ctx.session.declare_queryable.assert_called_once_with("bubbaloop/local/bot/command", handler) def test_queryable_raw_uses_literal_key_expr(): @@ -671,9 +713,7 @@ def handler(q): pass ctx.queryable_raw("bubbaloop/**/schema", handler) - ctx.session.declare_queryable.assert_called_once_with( - "bubbaloop/**/schema", handler - ) + ctx.session.declare_queryable.assert_called_once_with("bubbaloop/**/schema", handler) def test_queryable_returns_zenoh_queryable(): @@ -689,6 +729,7 @@ def test_queryable_returns_zenoh_queryable(): # NodeContext.queryable_async() and queryable_raw_async() # --------------------------------------------------------------------------- + def test_queryable_async_uses_topic_prefix(): """queryable_async() declares at topic(suffix).""" ctx = _make_context("local", "bot") @@ -704,6 +745,7 @@ def handler(q): def test_queryable_async_wraps_handler_in_executor(): """queryable_async() wraps handler so Zenoh thread is freed.""" import threading + ctx = _make_context("local", "bot") captured_wrapper = [] @@ -732,6 +774,7 @@ def slow_handler(query): def test_queryable_async_returns_async_queryable(): """queryable_async() returns AsyncQueryable (not a bare zenoh.Queryable).""" from bubbaloop_sdk.subscriber import AsyncQueryable + ctx = _make_context("local", "bot") qbl = ctx.queryable_async("command", lambda q: None) assert isinstance(qbl, AsyncQueryable) @@ -748,6 +791,7 @@ def test_queryable_raw_async_uses_literal_key_expr(): def test_queryable_raw_async_wraps_handler_in_executor(): """queryable_raw_async() wraps handler in thread pool.""" import threading + ctx = _make_context("local", "bot") captured_wrapper = [] @@ -776,6 +820,7 @@ def handler(query): def test_async_queryable_undeclare(): """AsyncQueryable.undeclare() undeclares queryable then shuts executor.""" from bubbaloop_sdk.subscriber import AsyncQueryable + mock_session = MagicMock() mock_qbl = MagicMock() mock_session.declare_queryable.return_value = mock_qbl @@ -788,6 +833,7 @@ def test_async_queryable_undeclare(): # NodeContext.subscriber_callback() # --------------------------------------------------------------------------- + def test_subscriber_callback_uses_topic_prefix(): """subscriber_callback() declares at topic(suffix).""" ctx = _make_context("local", "bot") @@ -824,6 +870,7 @@ def test_subscriber_raw_callback_async_uses_literal_key_expr(): # NodeContext.publisher_json() / publisher_proto() via context # --------------------------------------------------------------------------- + def test_publisher_json_uses_topic_prefix(): """publisher_json() declares at topic(suffix).""" ctx = _make_context("local", "bot") @@ -846,6 +893,7 @@ def test_publisher_proto_uses_topic_prefix(): # NodeContext.subscriber() / subscriber_raw() via context # --------------------------------------------------------------------------- + def test_subscriber_uses_topic_prefix(): """subscriber() declares at topic(suffix).""" ctx = _make_context("local", "bot") @@ -866,6 +914,7 @@ def test_subscriber_raw_uses_literal_key_expr(): # NodeContext.close() and context manager # --------------------------------------------------------------------------- + def test_close_calls_session_close(): """close() calls session.close().""" ctx = _make_context("local", "bot") @@ -885,15 +934,18 @@ def test_context_manager_calls_close(): # NodeContext.connect() — env var resolution # --------------------------------------------------------------------------- + def test_connect_reads_scope_from_env(monkeypatch): """BUBBALOOP_SCOPE env var sets ctx.scope.""" import zenoh + monkeypatch.setenv("BUBBALOOP_SCOPE", "prod") monkeypatch.delenv("BUBBALOOP_MACHINE_ID", raising=False) monkeypatch.delenv("BUBBALOOP_ZENOH_ENDPOINT", raising=False) monkeypatch.setattr(zenoh, "open", lambda cfg: MagicMock()) monkeypatch.setattr(zenoh, "Config", MagicMock) from bubbaloop_sdk.context import NodeContext + ctx = NodeContext.connect() assert ctx.scope == "prod" @@ -901,12 +953,14 @@ def test_connect_reads_scope_from_env(monkeypatch): def test_connect_reads_machine_id_from_env(monkeypatch): """BUBBALOOP_MACHINE_ID env var sets ctx.machine_id.""" import zenoh + monkeypatch.setenv("BUBBALOOP_MACHINE_ID", "jetson_orin") monkeypatch.delenv("BUBBALOOP_SCOPE", raising=False) monkeypatch.delenv("BUBBALOOP_ZENOH_ENDPOINT", raising=False) monkeypatch.setattr(zenoh, "open", lambda cfg: MagicMock()) monkeypatch.setattr(zenoh, "Config", MagicMock) from bubbaloop_sdk.context import NodeContext + ctx = NodeContext.connect() assert ctx.machine_id == "jetson_orin" @@ -914,12 +968,14 @@ def test_connect_reads_machine_id_from_env(monkeypatch): def test_connect_defaults_scope_to_local(monkeypatch): """scope defaults to 'local' when env var is absent.""" import zenoh + monkeypatch.delenv("BUBBALOOP_SCOPE", raising=False) monkeypatch.delenv("BUBBALOOP_MACHINE_ID", raising=False) monkeypatch.delenv("BUBBALOOP_ZENOH_ENDPOINT", raising=False) monkeypatch.setattr(zenoh, "open", lambda cfg: MagicMock()) monkeypatch.setattr(zenoh, "Config", MagicMock) from bubbaloop_sdk.context import NodeContext + ctx = NodeContext.connect() assert ctx.scope == "local" @@ -927,12 +983,14 @@ def test_connect_defaults_scope_to_local(monkeypatch): def test_connect_instance_name_override(monkeypatch): """instance_name kwarg overrides hostname fallback.""" import zenoh + monkeypatch.delenv("BUBBALOOP_MACHINE_ID", raising=False) monkeypatch.delenv("BUBBALOOP_SCOPE", raising=False) monkeypatch.delenv("BUBBALOOP_ZENOH_ENDPOINT", raising=False) monkeypatch.setattr(zenoh, "open", lambda cfg: MagicMock()) monkeypatch.setattr(zenoh, "Config", MagicMock) from bubbaloop_sdk.context import NodeContext + ctx = NodeContext.connect(instance_name="tapo_entrance") assert ctx.instance_name == "tapo_entrance" @@ -941,9 +999,11 @@ def test_connect_instance_name_override(monkeypatch): # TypedSubscriber / RawSubscriber — undeclare() and iteration # --------------------------------------------------------------------------- + def test_typed_subscriber_undeclare(): """undeclare() calls undeclare on the underlying zenoh subscriber.""" from bubbaloop_sdk.subscriber import TypedSubscriber + mock_session = MagicMock() mock_sub = MagicMock() mock_session.declare_subscriber.return_value = mock_sub @@ -955,6 +1015,7 @@ def test_typed_subscriber_undeclare(): def test_raw_subscriber_undeclare(): """undeclare() calls undeclare on the underlying zenoh subscriber.""" from bubbaloop_sdk.subscriber import RawSubscriber + mock_session = MagicMock() mock_sub = MagicMock() mock_session.declare_subscriber.return_value = mock_sub @@ -966,6 +1027,7 @@ def test_raw_subscriber_undeclare(): def test_typed_subscriber_iteration(): """Iterating over TypedSubscriber yields decoded messages.""" from bubbaloop_sdk.subscriber import TypedSubscriber + mock_session = MagicMock() captured_handler = [] @@ -995,8 +1057,10 @@ def fake_declare(topic, handler): # Helper # --------------------------------------------------------------------------- + def _make_context(scope: str, machine_id: str): from bubbaloop_sdk.context import NodeContext + ctx = object.__new__(NodeContext) ctx.session = MagicMock() ctx.scope = scope From afb5ac6f0659a3df985960298cea827c6b86ac0d Mon Sep 17 00:00:00 2001 From: Luis Date: Mon, 6 Apr 2026 00:53:39 +0200 Subject: [PATCH 23/54] fix(python-sdk): move FromString decode off Zenoh thread; replace time.sleep with Event in tests; add pyyaml dep - CallbackSubscriberAsync._wrap() now only captures payload bytes on Zenoh's internal thread; FromString decode runs inside the submitted thread-pool task (_decode_and_call closure), keeping the callback fast - 3 async undeclare tests replaced time.sleep(0.05) with threading.Event + called.wait(timeout=0.1) for reliable, race-free assertions - Added pyyaml>=6.0 to core dependencies (node.py imports yaml at runtime) Co-Authored-By: Claude Sonnet 4.6 --- python-sdk/bubbaloop_sdk/subscriber.py | 13 +++++++----- python-sdk/pyproject.toml | 1 + python-sdk/tests/test_context.py | 28 ++++++++++++++------------ 3 files changed, 24 insertions(+), 18 deletions(-) diff --git a/python-sdk/bubbaloop_sdk/subscriber.py b/python-sdk/bubbaloop_sdk/subscriber.py index ee33357..e512f9f 100644 --- a/python-sdk/bubbaloop_sdk/subscriber.py +++ b/python-sdk/bubbaloop_sdk/subscriber.py @@ -196,12 +196,15 @@ def _wrap(sample: zenoh.Sample) -> None: if self._closing.is_set(): return payload = bytes(sample.payload.to_bytes()) - if msg_class is not None and hasattr(msg_class, "FromString"): - msg = msg_class.FromString(payload) - else: - msg = payload + + def _decode_and_call(): + if msg_class is not None and hasattr(msg_class, "FromString"): + handler(msg_class.FromString(payload)) + else: + handler(payload) + try: - self._executor.submit(handler, msg) + self._executor.submit(_decode_and_call) except RuntimeError: pass # executor already shut down — drop the message diff --git a/python-sdk/pyproject.toml b/python-sdk/pyproject.toml index 677fa61..267e990 100644 --- a/python-sdk/pyproject.toml +++ b/python-sdk/pyproject.toml @@ -27,6 +27,7 @@ classifiers = [ dependencies = [ "eclipse-zenoh>=1.7,<2", "protobuf>=4.0", + "pyyaml>=6.0", ] [project.urls] diff --git a/python-sdk/tests/test_context.py b/python-sdk/tests/test_context.py index 4b0ea09..dd89878 100644 --- a/python-sdk/tests/test_context.py +++ b/python-sdk/tests/test_context.py @@ -380,22 +380,24 @@ def fake_declare(topic, handler): mock_session.declare_subscriber.side_effect = fake_declare received = [] + called = threading.Event() def handler(msg): received.append(msg) + called.set() sub = CallbackSubscriberAsync(mock_session, "test/topic", handler) sub.undeclare() - # Simulate a late-arriving Zenoh callback after undeclare + # Simulate a late-arriving Zenoh callback after undeclare. + # _closing is already set so _wrap returns early — handler is never submitted. fake_sample = MagicMock() fake_sample.payload.to_bytes.return_value = b"\xff" captured_handler[0](fake_sample) # must not raise - import time - - time.sleep(0.05) - assert received == [], "handler should not be called after undeclare()" + # Give the executor no chance to run (it's shut down); assert immediately. + assert not called.wait(timeout=0.1), "handler should not be called after undeclare()" + assert received == [] def test_raw_callback_subscriber_async_drops_after_undeclare(): @@ -411,9 +413,11 @@ def fake_declare(key_expr, handler): mock_session.declare_subscriber.side_effect = fake_declare received = [] + called = threading.Event() def handler(sample): received.append(sample) + called.set() sub = RawCallbackSubscriberAsync(mock_session, "test/**", handler) sub.undeclare() @@ -421,10 +425,8 @@ def handler(sample): fake_sample = MagicMock() captured_handler[0](fake_sample) # must not raise - import time - - time.sleep(0.05) - assert received == [], "handler should not be called after undeclare()" + assert not called.wait(timeout=0.1), "handler should not be called after undeclare()" + assert received == [] def test_async_queryable_drops_after_undeclare(): @@ -440,9 +442,11 @@ def fake_declare(key_expr, wrapper): mock_session.declare_queryable.side_effect = fake_declare received = [] + called = threading.Event() def handler(query): received.append(query) + called.set() aq = AsyncQueryable(mock_session, "test/topic", handler) aq.undeclare() @@ -450,10 +454,8 @@ def handler(query): fake_query = MagicMock() captured_wrapper[0](fake_query) # must not raise - import time - - time.sleep(0.05) - assert received == [], "handler should not be called after undeclare()" + assert not called.wait(timeout=0.1), "handler should not be called after undeclare()" + assert received == [] # --------------------------------------------------------------------------- From 1a543e27702779f42e973c47735c869a15759392 Mon Sep 17 00:00:00 2001 From: Luis Date: Mon, 6 Apr 2026 01:09:36 +0200 Subject: [PATCH 24/54] docs(python-sdk): add undeclare() calls to README quick-start examples MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pub.undeclare() / sub.undeclare() / qbl.undeclare() added before ctx.close() in all examples where the object has a declared Zenoh resource — consistent with CLAUDE.md conventions. Co-Authored-By: Claude Sonnet 4.6 --- python-sdk/README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/python-sdk/README.md b/python-sdk/README.md index 2822bb5..63733e5 100644 --- a/python-sdk/README.md +++ b/python-sdk/README.md @@ -31,6 +31,7 @@ while not ctx.is_shutdown(): pub.put({"temperature": 22.5, "humidity": 60}) time.sleep(1.0) +pub.undeclare() ctx.close() ``` @@ -48,6 +49,7 @@ while not ctx.is_shutdown(): pub.put(SensorData(value=42.0)) time.sleep(0.1) +pub.undeclare() ctx.close() ``` @@ -65,6 +67,7 @@ while not ctx.is_shutdown(): if msg is not None: print(f"value: {msg.value}") +sub.undeclare() ctx.close() ``` @@ -106,6 +109,7 @@ def on_query(query): qbl = ctx.queryable("status", on_query) ctx.wait_shutdown() +qbl.undeclare() ctx.close() ``` From b59f51082ef323961bf3631592e68601e531e72e Mon Sep 17 00:00:00 2001 From: Luis Date: Mon, 6 Apr 2026 01:25:16 +0200 Subject: [PATCH 25/54] test(python-sdk): undeclare async objects in tests; use try/finally for cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All 7 tests that created CallbackSubscriberAsync, RawCallbackSubscriberAsync, or AsyncQueryable now capture the return value and call undeclare() in a finally block — prevents thread pool leaks across the test suite. Co-Authored-By: Claude Sonnet 4.6 --- python-sdk/tests/test_context.py | 67 +++++++++++++++++++++----------- 1 file changed, 44 insertions(+), 23 deletions(-) diff --git a/python-sdk/tests/test_context.py b/python-sdk/tests/test_context.py index dd89878..4ed1ce4 100644 --- a/python-sdk/tests/test_context.py +++ b/python-sdk/tests/test_context.py @@ -739,9 +739,12 @@ def test_queryable_async_uses_topic_prefix(): def handler(q): pass - ctx.queryable_async("command", handler) - called_topic = ctx.session.declare_queryable.call_args[0][0] - assert called_topic == "bubbaloop/local/bot/command" + qbl = ctx.queryable_async("command", handler) + try: + called_topic = ctx.session.declare_queryable.call_args[0][0] + assert called_topic == "bubbaloop/local/bot/command" + finally: + qbl.undeclare() def test_queryable_async_wraps_handler_in_executor(): @@ -764,13 +767,16 @@ def slow_handler(query): received.append(query) event.set() - ctx.queryable_async("command", slow_handler) + qbl = ctx.queryable_async("command", slow_handler) - fake_query = MagicMock() - captured_wrapper[0](fake_query) # Zenoh calls the wrapper + try: + fake_query = MagicMock() + captured_wrapper[0](fake_query) # Zenoh calls the wrapper - assert event.wait(timeout=2.0), "handler not called within 2s" - assert received == [fake_query] + assert event.wait(timeout=2.0), "handler not called within 2s" + assert received == [fake_query] + finally: + qbl.undeclare() def test_queryable_async_returns_async_queryable(): @@ -779,15 +785,21 @@ def test_queryable_async_returns_async_queryable(): ctx = _make_context("local", "bot") qbl = ctx.queryable_async("command", lambda q: None) - assert isinstance(qbl, AsyncQueryable) + try: + assert isinstance(qbl, AsyncQueryable) + finally: + qbl.undeclare() def test_queryable_raw_async_uses_literal_key_expr(): """queryable_raw_async() declares at the literal key expression.""" ctx = _make_context("local", "bot") - ctx.queryable_raw_async("bubbaloop/**/schema", lambda q: None) - called_topic = ctx.session.declare_queryable.call_args[0][0] - assert called_topic == "bubbaloop/**/schema" + qbl = ctx.queryable_raw_async("bubbaloop/**/schema", lambda q: None) + try: + called_topic = ctx.session.declare_queryable.call_args[0][0] + assert called_topic == "bubbaloop/**/schema" + finally: + qbl.undeclare() def test_queryable_raw_async_wraps_handler_in_executor(): @@ -810,13 +822,16 @@ def handler(query): received.append(query) event.set() - ctx.queryable_raw_async("bubbaloop/**/schema", handler) + qbl = ctx.queryable_raw_async("bubbaloop/**/schema", handler) - fake_query = MagicMock() - captured_wrapper[0](fake_query) + try: + fake_query = MagicMock() + captured_wrapper[0](fake_query) - assert event.wait(timeout=2.0), "handler not called within 2s" - assert received == [fake_query] + assert event.wait(timeout=2.0), "handler not called within 2s" + assert received == [fake_query] + finally: + qbl.undeclare() def test_async_queryable_undeclare(): @@ -855,17 +870,23 @@ def test_subscriber_raw_callback_uses_literal_key_expr(): def test_subscriber_callback_async_uses_topic_prefix(): """subscriber_callback_async() declares at topic(suffix).""" ctx = _make_context("local", "bot") - ctx.subscriber_callback_async("sensor/data", lambda msg: None) - called_topic = ctx.session.declare_subscriber.call_args[0][0] - assert called_topic == "bubbaloop/local/bot/sensor/data" + sub = ctx.subscriber_callback_async("sensor/data", lambda msg: None) + try: + called_topic = ctx.session.declare_subscriber.call_args[0][0] + assert called_topic == "bubbaloop/local/bot/sensor/data" + finally: + sub.undeclare() def test_subscriber_raw_callback_async_uses_literal_key_expr(): """subscriber_raw_callback_async() declares at literal key expression.""" ctx = _make_context("local", "bot") - ctx.subscriber_raw_callback_async("bubbaloop/**/health", lambda s: None) - called_topic = ctx.session.declare_subscriber.call_args[0][0] - assert called_topic == "bubbaloop/**/health" + sub = ctx.subscriber_raw_callback_async("bubbaloop/**/health", lambda s: None) + try: + called_topic = ctx.session.declare_subscriber.call_args[0][0] + assert called_topic == "bubbaloop/**/health" + finally: + sub.undeclare() # --------------------------------------------------------------------------- From e854706d4620b753ce764bfffb6746747462fcda Mon Sep 17 00:00:00 2001 From: Luis Date: Mon, 6 Apr 2026 01:50:04 +0200 Subject: [PATCH 26/54] fix(python-sdk): add persistent _closed flag to TypedSubscriber and RawSubscriber Previously a single _CLOSED sentinel was consumed by the first recv() after undeclare(), causing subsequent recv(timeout=None) calls to block forever. Now both classes hold a threading.Event (_closed) that: - is set in undeclare() before the sentinel is pushed - is checked at the top of recv() so all future calls return None fast - is set when the sentinel is consumed (covers the recv() that dequeues it) - is checked in the callback to drop samples arriving after close Added 4 tests: recv() returns None on 2nd call after undeclare, and late-arriving samples are dropped for both TypedSubscriber and RawSubscriber. Co-Authored-By: Claude Sonnet 4.6 --- python-sdk/bubbaloop_sdk/subscriber.py | 16 +++++- python-sdk/tests/test_context.py | 68 ++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 2 deletions(-) diff --git a/python-sdk/bubbaloop_sdk/subscriber.py b/python-sdk/bubbaloop_sdk/subscriber.py index e512f9f..1e96ecf 100644 --- a/python-sdk/bubbaloop_sdk/subscriber.py +++ b/python-sdk/bubbaloop_sdk/subscriber.py @@ -32,19 +32,24 @@ class TypedSubscriber: def __init__(self, session: zenoh.Session, topic: str, msg_class=None): self._queue: queue.Queue = queue.Queue() self._msg_class = msg_class + self._closed = threading.Event() def _on_sample(sample: zenoh.Sample) -> None: - self._queue.put(bytes(sample.payload.to_bytes())) + if not self._closed.is_set(): + self._queue.put(bytes(sample.payload.to_bytes())) self._sub = session.declare_subscriber(topic, _on_sample) def recv(self, timeout: float | None = None): """Block until the next message arrives. Returns ``None`` on timeout or close.""" + if self._closed.is_set(): + return None try: payload = self._queue.get(timeout=timeout) except queue.Empty: return None if payload is _CLOSED: + self._closed.set() return None if self._msg_class is not None and hasattr(self._msg_class, "FromString"): return self._msg_class.FromString(payload) @@ -61,6 +66,7 @@ def __next__(self): def undeclare(self) -> None: """Undeclare the subscriber and unblock any waiting ``recv()``.""" + self._closed.set() self._sub.undeclare() self._queue.put(_CLOSED) @@ -74,19 +80,24 @@ class RawSubscriber: def __init__(self, session: zenoh.Session, key_expr: str): self._queue: queue.Queue = queue.Queue() + self._closed = threading.Event() def _on_sample(sample: zenoh.Sample) -> None: - self._queue.put(sample) + if not self._closed.is_set(): + self._queue.put(sample) self._sub = session.declare_subscriber(key_expr, _on_sample) def recv(self, timeout: float | None = None): """Block until the next sample arrives. Returns ``None`` on timeout or close.""" + if self._closed.is_set(): + return None try: sample = self._queue.get(timeout=timeout) except queue.Empty: return None if sample is _CLOSED: + self._closed.set() return None return sample @@ -101,6 +112,7 @@ def __next__(self): def undeclare(self) -> None: """Undeclare the subscriber and unblock any waiting ``recv()``.""" + self._closed.set() self._sub.undeclare() self._queue.put(_CLOSED) diff --git a/python-sdk/tests/test_context.py b/python-sdk/tests/test_context.py index 4ed1ce4..7d10d81 100644 --- a/python-sdk/tests/test_context.py +++ b/python-sdk/tests/test_context.py @@ -1076,6 +1076,74 @@ def fake_declare(topic, handler): assert results == [b"\x01", b"\x02"] +def test_typed_subscriber_recv_returns_none_after_undeclare(): + """recv() returns None immediately on all calls after undeclare().""" + from bubbaloop_sdk.subscriber import TypedSubscriber + + mock_session = MagicMock() + mock_session.declare_subscriber.return_value = MagicMock() + sub = TypedSubscriber(mock_session, "test/topic") + sub.undeclare() + # First call consumes the sentinel; second must not block. + assert sub.recv(timeout=1.0) is None + assert sub.recv(timeout=1.0) is None + + +def test_raw_subscriber_recv_returns_none_after_undeclare(): + """recv() returns None immediately on all calls after undeclare().""" + from bubbaloop_sdk.subscriber import RawSubscriber + + mock_session = MagicMock() + mock_session.declare_subscriber.return_value = MagicMock() + sub = RawSubscriber(mock_session, "test/topic") + sub.undeclare() + assert sub.recv(timeout=1.0) is None + assert sub.recv(timeout=1.0) is None + + +def test_typed_subscriber_drops_samples_after_undeclare(): + """Samples arriving after undeclare() are not enqueued.""" + from bubbaloop_sdk.subscriber import TypedSubscriber + + mock_session = MagicMock() + captured_handler = [] + + def fake_declare(topic, handler): + captured_handler.append(handler) + return MagicMock() + + mock_session.declare_subscriber.side_effect = fake_declare + sub = TypedSubscriber(mock_session, "test/topic") + sub.undeclare() + + fake_sample = MagicMock() + fake_sample.payload.to_bytes.return_value = b"\xff" + captured_handler[0](fake_sample) # arrives after undeclare + + assert sub.recv(timeout=0.1) is None # no message — only closed state + + +def test_raw_subscriber_drops_samples_after_undeclare(): + """Samples arriving after undeclare() are not enqueued.""" + from bubbaloop_sdk.subscriber import RawSubscriber + + mock_session = MagicMock() + captured_handler = [] + + def fake_declare(key_expr, handler): + captured_handler.append(handler) + return MagicMock() + + mock_session.declare_subscriber.side_effect = fake_declare + sub = RawSubscriber(mock_session, "test/topic") + sub.undeclare() + + fake_sample = MagicMock() + captured_handler[0](fake_sample) # arrives after undeclare + + assert sub.recv(timeout=0.1) is None + + # --------------------------------------------------------------------------- # Helper # --------------------------------------------------------------------------- From 4ccad57fd315e4011e7e93e790c411e4c69eac97 Mon Sep 17 00:00:00 2001 From: Luis Date: Mon, 6 Apr 2026 01:58:27 +0200 Subject: [PATCH 27/54] fix(python-sdk): polling recv() for multi-consumer safety; fix GC docstrings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit recv() now loops in _POLL_INTERVAL (50ms) slices instead of blocking indefinitely on queue.get(). This ensures all concurrent callers of recv(timeout=None) observe _closed within 50ms of undeclare() — previously only one blocked caller was guaranteed to wake up via the _CLOSED sentinel. Docstrings in subscriber.py and context.py that claimed GC undeclares subscribers/queryables have been replaced with explicit undeclare() guidance. GC is not reliable for resources owning ThreadPoolExecutors. Co-Authored-By: Claude Sonnet 4.6 --- python-sdk/bubbaloop_sdk/context.py | 4 +- python-sdk/bubbaloop_sdk/subscriber.py | 78 ++++++++++++++++---------- 2 files changed, 51 insertions(+), 31 deletions(-) diff --git a/python-sdk/bubbaloop_sdk/context.py b/python-sdk/bubbaloop_sdk/context.py index d2a6085..73ac289 100644 --- a/python-sdk/bubbaloop_sdk/context.py +++ b/python-sdk/bubbaloop_sdk/context.py @@ -200,7 +200,7 @@ def on_command(query: zenoh.Query) -> None: For slow handlers, use ``queryable_async()``. - Keep the returned object alive — garbage-collecting it undeclares the queryable. + Call ``undeclare()`` on the returned queryable when done. """ return self.session.declare_queryable(self.topic(suffix), handler) @@ -210,7 +210,7 @@ def queryable_raw(self, key_expr: str, handler) -> zenoh.Queryable: Use for wildcard queryables or when the ``bubbaloop/{scope}/{machine_id}/`` prefix does not apply (e.g. ``bubbaloop/**/schema`` for multi-schema serving). - Keep the returned object alive — garbage-collecting it undeclares the queryable. + Call ``undeclare()`` on the returned queryable when done. """ return self.session.declare_queryable(key_expr, handler) diff --git a/python-sdk/bubbaloop_sdk/subscriber.py b/python-sdk/bubbaloop_sdk/subscriber.py index 1e96ecf..b63cbdf 100644 --- a/python-sdk/bubbaloop_sdk/subscriber.py +++ b/python-sdk/bubbaloop_sdk/subscriber.py @@ -5,11 +5,15 @@ import concurrent.futures import queue import threading +import time import zenoh # Sentinel object pushed into a queue to unblock recv() on undeclare(). _CLOSED = object() +# Max time a blocked recv() waits before re-checking _closed. Keeps all +# concurrent callers responsive to undeclare() within this many seconds. +_POLL_INTERVAL = 0.05 class TypedSubscriber: @@ -41,19 +45,27 @@ def _on_sample(sample: zenoh.Sample) -> None: self._sub = session.declare_subscriber(topic, _on_sample) def recv(self, timeout: float | None = None): - """Block until the next message arrives. Returns ``None`` on timeout or close.""" - if self._closed.is_set(): - return None - try: - payload = self._queue.get(timeout=timeout) - except queue.Empty: - return None - if payload is _CLOSED: - self._closed.set() - return None - if self._msg_class is not None and hasattr(self._msg_class, "FromString"): - return self._msg_class.FromString(payload) - return payload + """Block until the next message arrives. Returns ``None`` on timeout or close. + + Polls in ``_POLL_INTERVAL``-second slices so that all concurrent callers + observe ``_closed`` within that window after ``undeclare()`` is called. + """ + deadline = None if timeout is None else time.monotonic() + timeout + while not self._closed.is_set(): + wait = _POLL_INTERVAL if deadline is None else min(_POLL_INTERVAL, deadline - time.monotonic()) + if wait <= 0: + return None + try: + payload = self._queue.get(timeout=wait) + except queue.Empty: + continue + if payload is _CLOSED: + self._closed.set() + return None + if self._msg_class is not None and hasattr(self._msg_class, "FromString"): + return self._msg_class.FromString(payload) + return payload + return None def __iter__(self): return self @@ -89,17 +101,25 @@ def _on_sample(sample: zenoh.Sample) -> None: self._sub = session.declare_subscriber(key_expr, _on_sample) def recv(self, timeout: float | None = None): - """Block until the next sample arrives. Returns ``None`` on timeout or close.""" - if self._closed.is_set(): - return None - try: - sample = self._queue.get(timeout=timeout) - except queue.Empty: - return None - if sample is _CLOSED: - self._closed.set() - return None - return sample + """Block until the next sample arrives. Returns ``None`` on timeout or close. + + Polls in ``_POLL_INTERVAL``-second slices so that all concurrent callers + observe ``_closed`` within that window after ``undeclare()`` is called. + """ + deadline = None if timeout is None else time.monotonic() + timeout + while not self._closed.is_set(): + wait = _POLL_INTERVAL if deadline is None else min(_POLL_INTERVAL, deadline - time.monotonic()) + if wait <= 0: + return None + try: + sample = self._queue.get(timeout=wait) + except queue.Empty: + continue + if sample is _CLOSED: + self._closed.set() + return None + return sample + return None def __iter__(self): return self @@ -141,7 +161,7 @@ def on_msg(msg): sub = ctx.subscriber_callback("sensor/data", on_msg, SensorData) - Keep the returned object alive — garbage-collecting it undeclares the subscriber. + Call ``undeclare()`` when done to stop receiving samples. """ def __init__(self, session: zenoh.Session, topic: str, handler, msg_class=None): @@ -167,7 +187,7 @@ class RawCallbackSubscriber: For slow handlers, use ``ctx.subscriber_raw_callback_async()`` instead. - Keep the returned object alive — garbage-collecting it undeclares the subscriber. + Call ``undeclare()`` when done to stop receiving samples. """ def __init__(self, session: zenoh.Session, key_expr: str, handler): @@ -197,7 +217,7 @@ class CallbackSubscriberAsync: if messages arrive faster than the handler processes them. Protect shared state with locks. - Keep the returned object alive — garbage-collecting it undeclares the subscriber. + Call ``undeclare()`` when done to stop receiving samples. """ def __init__(self, session: zenoh.Session, topic: str, handler, msg_class=None, max_workers: int = 4): @@ -235,7 +255,7 @@ class RawCallbackSubscriberAsync: Same as ``CallbackSubscriberAsync`` but passes raw ``zenoh.Sample`` objects. Use when you need sample metadata AND your handler does slow work. - Keep the returned object alive — garbage-collecting it undeclares the subscriber. + Call ``undeclare()`` when done to stop receiving samples. """ def __init__(self, session: zenoh.Session, key_expr: str, handler, max_workers: int = 4): @@ -279,7 +299,7 @@ def on_db_query(query: zenoh.Query) -> None: if queries arrive faster than the handler processes them. Protect shared state with locks. - Keep the returned object alive — garbage-collecting it undeclares the queryable. + Call ``undeclare()`` when done to stop receiving queries and release the thread pool. """ def __init__(self, session: zenoh.Session, key_expr: str, handler, max_workers: int = 4): From e857a4f2a41d666353c2af94e3ecfcf6abef91d9 Mon Sep 17 00:00:00 2001 From: Luis Date: Mon, 6 Apr 2026 08:55:50 +0200 Subject: [PATCH 28/54] fix(python-sdk): set _shutdown before ctx.close() in run_node(); join heartbeat If node.run() returns without setting _shutdown (normal exit), the heartbeat thread could publish 'ok' to a closed Zenoh session. The fix: - Move start_health_heartbeat() after initialization so its handle is captured - Set ctx._shutdown.set() in the finally block before ctx.close() - Join the heartbeat thread (timeout=1s) to ensure it stops first Co-Authored-By: Claude Sonnet 4.6 --- python-sdk/bubbaloop_sdk/node.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/python-sdk/bubbaloop_sdk/node.py b/python-sdk/bubbaloop_sdk/node.py index a19dc42..a9eee6b 100644 --- a/python-sdk/bubbaloop_sdk/node.py +++ b/python-sdk/bubbaloop_sdk/node.py @@ -50,16 +50,17 @@ def run_node(node_class) -> None: log.info("Starting (type=%s, config=%s)", node_class.name, args.config) ctx = NodeContext.connect(endpoint=args.endpoint, instance_name=instance_name) - - start_health_heartbeat(ctx.session, ctx.scope, ctx.machine_id, instance_name, ctx._shutdown) log.info("Health heartbeat: bubbaloop/%s/%s/%s/health", ctx.scope, ctx.machine_id, instance_name) node = node_class(ctx, config) log.info("Initialized. Running…") + heartbeat = start_health_heartbeat(ctx.session, ctx.scope, ctx.machine_id, instance_name, ctx._shutdown) try: node.run() except KeyboardInterrupt: pass finally: + ctx._shutdown.set() # stop heartbeat before closing session + heartbeat.join(timeout=1.0) ctx.close() log.info("Shutdown complete") From e1203c0cdfdc69bcc8571a563376b957566ed160 Mon Sep 17 00:00:00 2001 From: Luis Date: Mon, 6 Apr 2026 09:04:57 +0200 Subject: [PATCH 29/54] fix(python-sdk): broaden try/finally in run_node(); pin Python 3.12 in CI - Moved node construction and heartbeat start inside try/finally so ctx.close() always runs even if node.__init__() or the heartbeat raises. heartbeat is initialised to None so the join is guarded correctly. - Added actions/setup-python@v5 (python-version: '3.12') before the Python SDK steps in CI to guarantee a 3.10+ interpreter regardless of runner defaults. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/ci.yml | 5 +++++ python-sdk/bubbaloop_sdk/node.py | 10 ++++++---- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f3d1297..af1526d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,6 +50,11 @@ jobs: - name: Test run: pixi run cargo test --lib -p bubbaloop + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + - name: Python SDK — lint run: | cd python-sdk diff --git a/python-sdk/bubbaloop_sdk/node.py b/python-sdk/bubbaloop_sdk/node.py index a9eee6b..9ecf6fb 100644 --- a/python-sdk/bubbaloop_sdk/node.py +++ b/python-sdk/bubbaloop_sdk/node.py @@ -52,15 +52,17 @@ def run_node(node_class) -> None: ctx = NodeContext.connect(endpoint=args.endpoint, instance_name=instance_name) log.info("Health heartbeat: bubbaloop/%s/%s/%s/health", ctx.scope, ctx.machine_id, instance_name) - node = node_class(ctx, config) - log.info("Initialized. Running…") - heartbeat = start_health_heartbeat(ctx.session, ctx.scope, ctx.machine_id, instance_name, ctx._shutdown) + heartbeat = None try: + node = node_class(ctx, config) + log.info("Initialized. Running…") + heartbeat = start_health_heartbeat(ctx.session, ctx.scope, ctx.machine_id, instance_name, ctx._shutdown) node.run() except KeyboardInterrupt: pass finally: ctx._shutdown.set() # stop heartbeat before closing session - heartbeat.join(timeout=1.0) + if heartbeat is not None: + heartbeat.join(timeout=1.0) ctx.close() log.info("Shutdown complete") From 39ceb2d2dc1c019d273af24ca6f2cd246fa8cd82 Mon Sep 17 00:00:00 2001 From: Luis Date: Tue, 7 Apr 2026 23:34:56 +0200 Subject: [PATCH 30/54] chore: merge upstream/main into feat/python-sdk-callback-subscriber-queryable Co-Authored-By: Claude Sonnet 4.6 --- python-sdk/bubbaloop_sdk/context.py | 11 +++++++---- python-sdk/bubbaloop_sdk/subscriber.py | 2 ++ 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/python-sdk/bubbaloop_sdk/context.py b/python-sdk/bubbaloop_sdk/context.py index 05f6cf5..ce9733c 100644 --- a/python-sdk/bubbaloop_sdk/context.py +++ b/python-sdk/bubbaloop_sdk/context.py @@ -24,16 +24,16 @@ import zenoh if TYPE_CHECKING: - from .publisher import JsonPublisher, ProtoPublisher + from .publisher import JsonPublisher, ProtoPublisher, RawPublisher from .subscriber import ( AsyncQueryable, CallbackSubscriber, CallbackSubscriberAsync, + ProtoSubscriber, RawCallbackSubscriber, RawCallbackSubscriberAsync, RawSubscriber, TypedSubscriber, - ProtoSubscriber, ) @@ -135,7 +135,7 @@ def publisher_proto(self, suffix: str, msg_class=None) -> ProtoPublisher: type_name = msg_class.DESCRIPTOR.full_name if msg_class is not None else None return ProtoPublisher._declare(self.session, self.topic(suffix), type_name) - def publisher_raw(self, suffix: str, local: bool = False) -> "RawPublisher": + def publisher_raw(self, suffix: str, local: bool = False) -> RawPublisher: """Declare a raw publisher with no encoding. When ``local=True``, publishes to ``local/{machine_id}/{suffix}`` with @@ -143,6 +143,7 @@ def publisher_raw(self, suffix: str, local: bool = False) -> "RawPublisher": SHM buffer instead of dropping frames. Never crosses the bridge. """ from .publisher import RawPublisher + key = self.local_topic(suffix) if local else self.topic(suffix) return RawPublisher._declare(self.session, key, local=local) @@ -187,7 +188,8 @@ def subscribe(self, suffix: str, local: bool = False) -> ProtoSubscriber: """ from .schema_registry import SchemaRegistry from .subscriber import ProtoSubscriber - if not hasattr(self, '_schema_registry'): + + if not hasattr(self, "_schema_registry"): self._schema_registry = SchemaRegistry(self.session) key = self.local_topic(suffix) if local else self.topic(suffix) return ProtoSubscriber(self.session, key, self._schema_registry) @@ -201,6 +203,7 @@ def subscribe_raw(self, suffix: str, local: bool = False) -> RawSubscriber: When ``local=True``, subscribes to the SHM-only local topic. """ from .subscriber import RawSubscriber + key = self.local_topic(suffix) if local else self.topic(suffix) return RawSubscriber(self.session, key) diff --git a/python-sdk/bubbaloop_sdk/subscriber.py b/python-sdk/bubbaloop_sdk/subscriber.py index 80af2a6..a2709f5 100644 --- a/python-sdk/bubbaloop_sdk/subscriber.py +++ b/python-sdk/bubbaloop_sdk/subscriber.py @@ -66,6 +66,8 @@ def recv(self, timeout: float | None = None): return self._msg_class.FromString(payload) return payload return None + + class ProtoSubscriber: """Blocking subscriber that decodes protobuf automatically from the encoding header. From 6e1fcbeee04f2d12dcc18eff86df5f86af80fc2f Mon Sep 17 00:00:00 2001 From: Luis Date: Tue, 7 Apr 2026 23:36:19 +0200 Subject: [PATCH 31/54] feat(python-sdk): add SchemaRegistry for on-demand protobuf schema resolution Co-Authored-By: Claude Sonnet 4.6 --- python-sdk/bubbaloop_sdk/schema_registry.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python-sdk/bubbaloop_sdk/schema_registry.py b/python-sdk/bubbaloop_sdk/schema_registry.py index 3455a38..5e7fde3 100644 --- a/python-sdk/bubbaloop_sdk/schema_registry.py +++ b/python-sdk/bubbaloop_sdk/schema_registry.py @@ -54,7 +54,7 @@ def decode(self, sample: zenoh.Sample) -> object: if not encoding.startswith(_PROTO_PREFIX): return payload - type_name = encoding[len(_PROTO_PREFIX):] + type_name = encoding[len(_PROTO_PREFIX) :] msg_class = self._resolve(type_name) if msg_class is None: log.debug("SchemaRegistry: no class for %s, returning raw bytes", type_name) From 0b5f46a62821c0620a07e98d922ae4cdad41bceb Mon Sep 17 00:00:00 2001 From: Luis Date: Tue, 7 Apr 2026 23:48:11 +0200 Subject: [PATCH 32/54] fix(python-sdk): correct merge artifacts in TypedSubscriber and ProtoSubscriber - Move __iter__, __next__, undeclare() back to TypedSubscriber (lost during merge) - Fix ProtoSubscriber.undeclare() to only call _sub.undeclare() (no _queue/_closed) - Use try/except in ProtoSubscriber.__next__() since zenoh raises on close (not None) - Add RawPublisher to TYPE_CHECKING imports in context.py; sort ProtoSubscriber import Co-Authored-By: Claude Sonnet 4.6 --- python-sdk/bubbaloop_sdk/subscriber.py | 27 +++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/python-sdk/bubbaloop_sdk/subscriber.py b/python-sdk/bubbaloop_sdk/subscriber.py index a2709f5..e136355 100644 --- a/python-sdk/bubbaloop_sdk/subscriber.py +++ b/python-sdk/bubbaloop_sdk/subscriber.py @@ -67,6 +67,21 @@ def recv(self, timeout: float | None = None): return payload return None + def __iter__(self): + return self + + def __next__(self): + msg = self.recv() + if msg is None: + raise StopIteration + return msg + + def undeclare(self) -> None: + """Undeclare the subscriber and unblock any waiting ``recv()``.""" + self._closed.set() + self._sub.undeclare() + self._queue.put(_CLOSED) + class ProtoSubscriber: """Blocking subscriber that decodes protobuf automatically from the encoding header. @@ -101,16 +116,14 @@ def __iter__(self): return self def __next__(self): - msg = self.recv() - if msg is None: - raise StopIteration - return msg + try: + return self.recv() + except Exception as exc: + raise StopIteration from exc def undeclare(self) -> None: - """Undeclare the subscriber and unblock any waiting ``recv()``.""" - self._closed.set() + """Undeclare the subscriber.""" self._sub.undeclare() - self._queue.put(_CLOSED) class RawSubscriber: From d670b4225beb42923fbf2bf97f8d677a6e9f552d Mon Sep 17 00:00:00 2001 From: Luis Date: Tue, 7 Apr 2026 23:59:40 +0200 Subject: [PATCH 33/54] docs(python-sdk): update CLAUDE.md with typing, docstring, and formatting conventions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add strict typing section: modern X|Y syntax, annotation hierarchy, avoid bare type:ignore - Add Google docstring style guidelines with examples - Add string formatting rule: %-style for log calls, f-strings elsewhere - Fix _make_context() helper: remove ctx.scope (removed from NodeContext) - Fix health topic format: bubbaloop/global/... (no scope segment) - Update test count: 48 → 71 Co-Authored-By: Claude Sonnet 4.6 --- python-sdk/CLAUDE.md | 58 ++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 53 insertions(+), 5 deletions(-) diff --git a/python-sdk/CLAUDE.md b/python-sdk/CLAUDE.md index 656bdda..a6cf7f2 100644 --- a/python-sdk/CLAUDE.md +++ b/python-sdk/CLAUDE.md @@ -18,7 +18,7 @@ python-sdk/ get_sample.py # get_sample() — one-shot async subscribe-and-wait decode_sample.py # ProtoDecoder — decode zenoh.Sample to protobuf tests/ - test_context.py # 48 unit tests — NO real Zenoh session needed + test_context.py # 71 unit tests — NO real Zenoh session needed pyproject.toml # Build config, deps, ruff/pytest/coverage pixi.toml # Dev tasks: test, lint, fmt, check ``` @@ -29,7 +29,7 @@ python-sdk/ # With pixi (recommended) cd python-sdk pixi run check # fmt-check + lint (run before every commit) -pixi run test # 48 unit tests +pixi run test # 71 unit tests pixi run test-cov # tests + coverage report # With venv (alternative) @@ -46,6 +46,55 @@ cd python-sdk - Line length: 120 characters - `TYPE_CHECKING` guard for cross-module type annotations — NEVER string-quoted forward refs (`"Foo"`) +**Type annotations:** + +- Use modern Python 3.11+ union syntax: `X | Y` and `X | None` — NOT `Union[X, Y]` or `Optional[X]` +- Annotate all public method parameters and return types +- Annotate class attributes and instance variables when the type is not obvious from the assignment +- When fixing type errors, follow this hierarchy: + 1. Add proper type annotations + 2. Use `X | Y` union syntax or `cast()` from `typing` + 3. Use `TYPE_CHECKING` for circular imports + 4. Last resort: `# type: ignore[]` with a comment explaining why +- AVOID `# type: ignore` without an error code — always be specific + +**Docstrings:** +- Google docstring style for all public modules, classes, and functions +- Do NOT add a docstring to `__init__()` — document instantiation at the class level instead +- Include `Args:`, `Returns:`, and `Raises:` sections when applicable + +```python +class TypedSubscriber: + """Blocking subscriber with optional timeout. + + Internally queue-backed: Zenoh delivers raw bytes via a callback into a + ``queue.Queue``. Decoding happens in ``recv()`` on the consumer thread. + + Args: + session: Active Zenoh session. + topic: Key expression to subscribe to. + msg_class: Protobuf message class for decoding, or None for raw bytes. + """ + + def __init__(self, session: zenoh.Session, topic: str, msg_class=None): ... + +def recv(self, timeout: float | None = None) -> bytes | None: + """Block until the next message arrives. + + Args: + timeout: Max seconds to wait. None blocks indefinitely. + + Returns: + Decoded message, raw bytes, or None on timeout/close. + """ +``` + +**String formatting:** + +- Use `%`-style formatting for log calls — NOT f-strings: `log.info("Started %s", name)` + - Reason: lazy evaluation — the string is only formatted if the log level is active +- Use f-strings everywhere else: `raise ValueError(f"Unknown topic: {topic}")` + **Imports:** - Cross-module type-only imports go under `if TYPE_CHECKING:` at the top of the file - Lazy runtime imports (inside method bodies) are kept to avoid circular import issues @@ -73,11 +122,10 @@ cd python-sdk Tests do NOT open a real Zenoh session. Use `_make_context()`: ```python -def _make_context(scope, machine_id): +def _make_context(machine_id): from bubbaloop_sdk.context import NodeContext ctx = object.__new__(NodeContext) ctx.session = MagicMock() - ctx.scope = scope ctx.machine_id = machine_id ctx.instance_name = machine_id ctx._shutdown = threading.Event() @@ -107,4 +155,4 @@ assert event.wait(timeout=2.0), "handler not called within 2s" - `CallbackSubscriber` and `RawCallbackSubscriber` do NOT own an executor — `undeclare()` only calls `_sub.undeclare()`; the `_async` variants do own an executor and shut it down in `undeclare()` - `TypedSubscriber` and `RawSubscriber` are iterable (`for msg in sub`) but iteration blocks forever — always prefer `recv(timeout=...)` in shutdown-aware loops - `run_node()` reads `config.yaml` by default; override with `-c path/config.yaml`. The `name` field in config sets `instance_name` for health/schema topics — collisions happen if two instances share the same name -- Health topic format: `bubbaloop/{scope}/{machine_id}/{instance_name}/health` — ensure consumer patterns match exactly +- Health topic format: `bubbaloop/global/{machine_id}/{instance_name}/health` — ensure consumer patterns match exactly From feb3cc614f3671a169fd0862c019c88d59b5126f Mon Sep 17 00:00:00 2001 From: Luis Date: Wed, 8 Apr 2026 00:14:47 +0200 Subject: [PATCH 34/54] test(python-sdk): update tests for scope removal and new global/local key spaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove scope parameter from _make_context() — NodeContext no longer has scope - Update topic assertions: bubbaloop/{scope}/... → bubbaloop/global/... - Remove test_connect_reads_scope_from_env and test_connect_defaults_scope_to_local - Fix test_raw_subscriber_recv_returns_sample: recv() now returns bytes, not Sample - Remove stale BUBBALOOP_SCOPE monkeypatch.delenv() calls from connect tests - Rename test_topic_default_scope → test_topic_uses_global_prefix Co-Authored-By: Claude Sonnet 4.6 --- python-sdk/tests/test_context.py | 110 +++++++++++-------------------- 1 file changed, 40 insertions(+), 70 deletions(-) diff --git a/python-sdk/tests/test_context.py b/python-sdk/tests/test_context.py index 7d10d81..9b54231 100644 --- a/python-sdk/tests/test_context.py +++ b/python-sdk/tests/test_context.py @@ -16,18 +16,18 @@ def test_topic_formatting(): - ctx = _make_context("staging", "jetson_orin") - assert ctx.topic("camera/front/compressed") == ("bubbaloop/staging/jetson_orin/camera/front/compressed") + ctx = _make_context("jetson_orin") + assert ctx.topic("camera/front/compressed") == "bubbaloop/global/jetson_orin/camera/front/compressed" -def test_topic_default_scope(): - ctx = _make_context("local", "my_robot") - assert ctx.topic("sensor/imu") == "bubbaloop/local/my_robot/sensor/imu" +def test_topic_uses_global_prefix(): + ctx = _make_context("my_robot") + assert ctx.topic("sensor/imu") == "bubbaloop/global/my_robot/sensor/imu" def test_topic_wildcard_suffix(): - ctx = _make_context("prod", "edge_01") - assert ctx.topic("**") == "bubbaloop/prod/edge_01/**" + ctx = _make_context("edge_01") + assert ctx.topic("**") == "bubbaloop/global/edge_01/**" # --------------------------------------------------------------------------- @@ -106,12 +106,12 @@ def test_import_run_node(): def test_shutdown_not_set_initially(): - ctx = _make_context("local", "bot") + ctx = _make_context("bot") assert not ctx.is_shutdown() def test_shutdown_set_manually(): - ctx = _make_context("local", "bot") + ctx = _make_context("bot") ctx._shutdown.set() assert ctx.is_shutdown() @@ -267,10 +267,11 @@ def fake_declare(topic, handler): sub = RawSubscriber(mock_session, "test/topic") fake_sample = MagicMock() + fake_sample.payload = b"\xde\xad\xbe\xef" captured_handler[0](fake_sample) result = sub.recv(timeout=1.0) - assert result is fake_sample + assert result == b"\xde\xad\xbe\xef" # --------------------------------------------------------------------------- @@ -697,19 +698,19 @@ def test_raw_callback_subscriber_async_undeclare(): def test_queryable_uses_topic_prefix(): - """queryable() declares at bubbaloop/{scope}/{machine_id}/{suffix}.""" - ctx = _make_context("local", "bot") + """queryable() declares at bubbaloop/global/{machine_id}/{suffix}.""" + ctx = _make_context("bot") def handler(q): pass ctx.queryable("command", handler) - ctx.session.declare_queryable.assert_called_once_with("bubbaloop/local/bot/command", handler) + ctx.session.declare_queryable.assert_called_once_with("bubbaloop/global/bot/command", handler) def test_queryable_raw_uses_literal_key_expr(): """queryable_raw() declares at the literal key expression provided.""" - ctx = _make_context("local", "bot") + ctx = _make_context("bot") def handler(q): pass @@ -720,7 +721,7 @@ def handler(q): def test_queryable_returns_zenoh_queryable(): """queryable() returns whatever session.declare_queryable returns.""" - ctx = _make_context("local", "bot") + ctx = _make_context("bot") mock_qbl = MagicMock() ctx.session.declare_queryable.return_value = mock_qbl result = ctx.queryable("command", lambda q: None) @@ -734,7 +735,7 @@ def test_queryable_returns_zenoh_queryable(): def test_queryable_async_uses_topic_prefix(): """queryable_async() declares at topic(suffix).""" - ctx = _make_context("local", "bot") + ctx = _make_context("bot") def handler(q): pass @@ -742,7 +743,7 @@ def handler(q): qbl = ctx.queryable_async("command", handler) try: called_topic = ctx.session.declare_queryable.call_args[0][0] - assert called_topic == "bubbaloop/local/bot/command" + assert called_topic == "bubbaloop/global/bot/command" finally: qbl.undeclare() @@ -751,7 +752,7 @@ def test_queryable_async_wraps_handler_in_executor(): """queryable_async() wraps handler so Zenoh thread is freed.""" import threading - ctx = _make_context("local", "bot") + ctx = _make_context("bot") captured_wrapper = [] def fake_declare(topic, wrapper): @@ -783,7 +784,7 @@ def test_queryable_async_returns_async_queryable(): """queryable_async() returns AsyncQueryable (not a bare zenoh.Queryable).""" from bubbaloop_sdk.subscriber import AsyncQueryable - ctx = _make_context("local", "bot") + ctx = _make_context("bot") qbl = ctx.queryable_async("command", lambda q: None) try: assert isinstance(qbl, AsyncQueryable) @@ -793,7 +794,7 @@ def test_queryable_async_returns_async_queryable(): def test_queryable_raw_async_uses_literal_key_expr(): """queryable_raw_async() declares at the literal key expression.""" - ctx = _make_context("local", "bot") + ctx = _make_context("bot") qbl = ctx.queryable_raw_async("bubbaloop/**/schema", lambda q: None) try: called_topic = ctx.session.declare_queryable.call_args[0][0] @@ -806,7 +807,7 @@ def test_queryable_raw_async_wraps_handler_in_executor(): """queryable_raw_async() wraps handler in thread pool.""" import threading - ctx = _make_context("local", "bot") + ctx = _make_context("bot") captured_wrapper = [] def fake_declare(key_expr, wrapper): @@ -853,15 +854,15 @@ def test_async_queryable_undeclare(): def test_subscriber_callback_uses_topic_prefix(): """subscriber_callback() declares at topic(suffix).""" - ctx = _make_context("local", "bot") + ctx = _make_context("bot") ctx.subscriber_callback("sensor/data", lambda msg: None) called_topic = ctx.session.declare_subscriber.call_args[0][0] - assert called_topic == "bubbaloop/local/bot/sensor/data" + assert called_topic == "bubbaloop/global/bot/sensor/data" def test_subscriber_raw_callback_uses_literal_key_expr(): """subscriber_raw_callback() declares at the literal key expression.""" - ctx = _make_context("local", "bot") + ctx = _make_context("bot") ctx.subscriber_raw_callback("bubbaloop/**/health", lambda s: None) called_topic = ctx.session.declare_subscriber.call_args[0][0] assert called_topic == "bubbaloop/**/health" @@ -869,18 +870,18 @@ def test_subscriber_raw_callback_uses_literal_key_expr(): def test_subscriber_callback_async_uses_topic_prefix(): """subscriber_callback_async() declares at topic(suffix).""" - ctx = _make_context("local", "bot") + ctx = _make_context("bot") sub = ctx.subscriber_callback_async("sensor/data", lambda msg: None) try: called_topic = ctx.session.declare_subscriber.call_args[0][0] - assert called_topic == "bubbaloop/local/bot/sensor/data" + assert called_topic == "bubbaloop/global/bot/sensor/data" finally: sub.undeclare() def test_subscriber_raw_callback_async_uses_literal_key_expr(): """subscriber_raw_callback_async() declares at literal key expression.""" - ctx = _make_context("local", "bot") + ctx = _make_context("bot") sub = ctx.subscriber_raw_callback_async("bubbaloop/**/health", lambda s: None) try: called_topic = ctx.session.declare_subscriber.call_args[0][0] @@ -896,20 +897,20 @@ def test_subscriber_raw_callback_async_uses_literal_key_expr(): def test_publisher_json_uses_topic_prefix(): """publisher_json() declares at topic(suffix).""" - ctx = _make_context("local", "bot") + ctx = _make_context("bot") ctx.publisher_json("weather/current") called_topic = ctx.session.declare_publisher.call_args[0][0] - assert called_topic == "bubbaloop/local/bot/weather/current" + assert called_topic == "bubbaloop/global/bot/weather/current" def test_publisher_proto_uses_topic_prefix(): """publisher_proto() declares at topic(suffix).""" - ctx = _make_context("local", "bot") + ctx = _make_context("bot") fake_class = MagicMock() fake_class.DESCRIPTOR.full_name = "my.SensorData" ctx.publisher_proto("sensor/data", fake_class) called_topic = ctx.session.declare_publisher.call_args[0][0] - assert called_topic == "bubbaloop/local/bot/sensor/data" + assert called_topic == "bubbaloop/global/bot/sensor/data" # --------------------------------------------------------------------------- @@ -919,15 +920,15 @@ def test_publisher_proto_uses_topic_prefix(): def test_subscriber_uses_topic_prefix(): """subscriber() declares at topic(suffix).""" - ctx = _make_context("local", "bot") + ctx = _make_context("bot") ctx.subscriber("sensor/data") called_topic = ctx.session.declare_subscriber.call_args[0][0] - assert called_topic == "bubbaloop/local/bot/sensor/data" + assert called_topic == "bubbaloop/global/bot/sensor/data" def test_subscriber_raw_uses_literal_key_expr(): """subscriber_raw() declares at the literal key expression.""" - ctx = _make_context("local", "bot") + ctx = _make_context("bot") ctx.subscriber_raw("bubbaloop/**/health") called_topic = ctx.session.declare_subscriber.call_args[0][0] assert called_topic == "bubbaloop/**/health" @@ -940,14 +941,14 @@ def test_subscriber_raw_uses_literal_key_expr(): def test_close_calls_session_close(): """close() calls session.close().""" - ctx = _make_context("local", "bot") + ctx = _make_context("bot") ctx.close() ctx.session.close.assert_called_once() def test_context_manager_calls_close(): """__exit__ calls close() so the session is always cleaned up.""" - ctx = _make_context("local", "bot") + ctx = _make_context("bot") with ctx: pass ctx.session.close.assert_called_once() @@ -958,49 +959,19 @@ def test_context_manager_calls_close(): # --------------------------------------------------------------------------- -def test_connect_reads_scope_from_env(monkeypatch): - """BUBBALOOP_SCOPE env var sets ctx.scope.""" - import zenoh - - monkeypatch.setenv("BUBBALOOP_SCOPE", "prod") - monkeypatch.delenv("BUBBALOOP_MACHINE_ID", raising=False) - monkeypatch.delenv("BUBBALOOP_ZENOH_ENDPOINT", raising=False) - monkeypatch.setattr(zenoh, "open", lambda cfg: MagicMock()) - monkeypatch.setattr(zenoh, "Config", MagicMock) - from bubbaloop_sdk.context import NodeContext - - ctx = NodeContext.connect() - assert ctx.scope == "prod" - - def test_connect_reads_machine_id_from_env(monkeypatch): """BUBBALOOP_MACHINE_ID env var sets ctx.machine_id.""" import zenoh monkeypatch.setenv("BUBBALOOP_MACHINE_ID", "jetson_orin") - monkeypatch.delenv("BUBBALOOP_SCOPE", raising=False) - monkeypatch.delenv("BUBBALOOP_ZENOH_ENDPOINT", raising=False) - monkeypatch.setattr(zenoh, "open", lambda cfg: MagicMock()) - monkeypatch.setattr(zenoh, "Config", MagicMock) - from bubbaloop_sdk.context import NodeContext - - ctx = NodeContext.connect() - assert ctx.machine_id == "jetson_orin" - -def test_connect_defaults_scope_to_local(monkeypatch): - """scope defaults to 'local' when env var is absent.""" - import zenoh - - monkeypatch.delenv("BUBBALOOP_SCOPE", raising=False) - monkeypatch.delenv("BUBBALOOP_MACHINE_ID", raising=False) monkeypatch.delenv("BUBBALOOP_ZENOH_ENDPOINT", raising=False) monkeypatch.setattr(zenoh, "open", lambda cfg: MagicMock()) monkeypatch.setattr(zenoh, "Config", MagicMock) from bubbaloop_sdk.context import NodeContext ctx = NodeContext.connect() - assert ctx.scope == "local" + assert ctx.machine_id == "jetson_orin" def test_connect_instance_name_override(monkeypatch): @@ -1008,7 +979,7 @@ def test_connect_instance_name_override(monkeypatch): import zenoh monkeypatch.delenv("BUBBALOOP_MACHINE_ID", raising=False) - monkeypatch.delenv("BUBBALOOP_SCOPE", raising=False) + monkeypatch.delenv("BUBBALOOP_ZENOH_ENDPOINT", raising=False) monkeypatch.setattr(zenoh, "open", lambda cfg: MagicMock()) monkeypatch.setattr(zenoh, "Config", MagicMock) @@ -1149,12 +1120,11 @@ def fake_declare(key_expr, handler): # --------------------------------------------------------------------------- -def _make_context(scope: str, machine_id: str): +def _make_context(machine_id: str): from bubbaloop_sdk.context import NodeContext ctx = object.__new__(NodeContext) ctx.session = MagicMock() - ctx.scope = scope ctx.machine_id = machine_id ctx.instance_name = machine_id ctx._shutdown = threading.Event() From 8bc9506844340e9e0535dde88196d11f06ca60f9 Mon Sep 17 00:00:00 2001 From: Luis Date: Wed, 15 Apr 2026 23:40:06 +0200 Subject: [PATCH 35/54] refactor(python-sdk): remove TypedSubscriber from subscriber.py Delete the TypedSubscriber class and its dead imports (queue, time, _CLOSED sentinel, _POLL_INTERVAL constant). ProtoSubscriber via SchemaRegistry is the replacement. __init__.py and tests will be updated in follow-up tasks. Co-Authored-By: Claude Sonnet 4.6 --- python-sdk/bubbaloop_sdk/subscriber.py | 76 -------------------------- 1 file changed, 76 deletions(-) diff --git a/python-sdk/bubbaloop_sdk/subscriber.py b/python-sdk/bubbaloop_sdk/subscriber.py index bb3905a..84ec226 100644 --- a/python-sdk/bubbaloop_sdk/subscriber.py +++ b/python-sdk/bubbaloop_sdk/subscriber.py @@ -3,85 +3,10 @@ from __future__ import annotations import concurrent.futures -import queue import threading -import time import zenoh -# Sentinel object pushed into a queue to unblock recv() on undeclare(). -_CLOSED = object() -# Max time a blocked recv() waits before re-checking _closed. Keeps all -# concurrent callers responsive to undeclare() within this many seconds. -_POLL_INTERVAL = 0.05 - - -class TypedSubscriber: - """Blocking subscriber with optional timeout. Iterates with ``for msg in sub``. - - Internally queue-backed: Zenoh delivers raw payload bytes via a callback into a - ``queue.Queue``. Decoding (``msg_class.FromString``) happens in ``recv()`` on the - consumer thread, not in Zenoh's internal thread:: - - while not ctx.is_shutdown(): - msg = sub.recv(timeout=5.0) - if msg is None: - continue - process(msg) - - Without a timeout, ``recv()`` blocks until a message arrives or ``undeclare()`` - is called (which pushes a sentinel to unblock any waiting ``recv()``). - """ - - def __init__(self, session: zenoh.Session, topic: str, msg_class=None): - self._queue: queue.Queue = queue.Queue() - self._msg_class = msg_class - self._closed = threading.Event() - - def _on_sample(sample: zenoh.Sample) -> None: - if not self._closed.is_set(): - self._queue.put(bytes(sample.payload.to_bytes())) - - self._sub = session.declare_subscriber(topic, _on_sample) - - def recv(self, timeout: float | None = None): - """Block until the next message arrives. Returns ``None`` on timeout or close. - - Polls in ``_POLL_INTERVAL``-second slices so that all concurrent callers - observe ``_closed`` within that window after ``undeclare()`` is called. - """ - deadline = None if timeout is None else time.monotonic() + timeout - while not self._closed.is_set(): - wait = _POLL_INTERVAL if deadline is None else min(_POLL_INTERVAL, deadline - time.monotonic()) - if wait <= 0: - return None - try: - payload = self._queue.get(timeout=wait) - except queue.Empty: - continue - if payload is _CLOSED: - self._closed.set() - return None - if self._msg_class is not None and hasattr(self._msg_class, "FromString"): - return self._msg_class.FromString(payload) - return payload - return None - - def __iter__(self): - return self - - def __next__(self): - msg = self.recv() - if msg is None: - raise StopIteration - return msg - - def undeclare(self) -> None: - """Undeclare the subscriber and unblock any waiting ``recv()``.""" - self._closed.set() - self._sub.undeclare() - self._queue.put(_CLOSED) - class _BaseSubscriber: """Shared iterator protocol and cleanup for all subscriber types.""" @@ -141,7 +66,6 @@ def __init__(self, session: zenoh.Session, topic: str, registry): def recv(self): """Block until next message and return the decoded proto object.""" - # NOTE: this Subscriber is new and does not yet implement timeouts or close handling. sample = self._sub.recv() return self._registry.decode(sample) From f0a866062a89155002baeb57c6d25aa5211e150d Mon Sep 17 00:00:00 2001 From: Luis Date: Wed, 15 Apr 2026 23:42:30 +0200 Subject: [PATCH 36/54] refactor(python-sdk): remove TypedSubscriber from public API and context Drop the deprecated TypedSubscriber import from __init__.py, remove it from the TYPE_CHECKING block in context.py, delete the old subscriber() and subscriber_raw() methods, and remove the stale "next 2 functions" comment. subscribe() and subscribe_raw() are now the primary methods. Co-Authored-By: Claude Sonnet 4.6 --- python-sdk/bubbaloop_sdk/__init__.py | 1 - python-sdk/bubbaloop_sdk/context.py | 15 --------------- 2 files changed, 16 deletions(-) diff --git a/python-sdk/bubbaloop_sdk/__init__.py b/python-sdk/bubbaloop_sdk/__init__.py index 36011f8..768ffa3 100644 --- a/python-sdk/bubbaloop_sdk/__init__.py +++ b/python-sdk/bubbaloop_sdk/__init__.py @@ -20,7 +20,6 @@ RawCallbackSubscriber, RawCallbackSubscriberAsync, RawSubscriber, - TypedSubscriber, ) __all__ = [ diff --git a/python-sdk/bubbaloop_sdk/context.py b/python-sdk/bubbaloop_sdk/context.py index f215b83..42251c9 100644 --- a/python-sdk/bubbaloop_sdk/context.py +++ b/python-sdk/bubbaloop_sdk/context.py @@ -33,7 +33,6 @@ RawCallbackSubscriber, RawCallbackSubscriberAsync, RawSubscriber, - TypedSubscriber, ) @@ -153,20 +152,6 @@ def publisher_raw(self, suffix: str, local: bool = False) -> RawPublisher: # Subscribers # ------------------------------------------------------------------ - def subscriber(self, suffix: str, msg_class=None) -> TypedSubscriber: - """Declare a typed subscriber. Blocks on ``recv()``.""" - from .subscriber import TypedSubscriber - - return TypedSubscriber(self.session, self.topic(suffix), msg_class) - - def subscriber_raw(self, key_expr: str) -> RawSubscriber: - """Declare a raw subscriber with a literal key expression.""" - from .subscriber import RawSubscriber - - return RawSubscriber(self.session, key_expr) - - # NOTE: next 2 functions are new. Talk to Edgar to understand them. - def subscribe(self, suffix: str, local: bool = False) -> ProtoSubscriber: """Declare a subscriber that auto-decodes every message by its encoding. From 1d400efa33708bef10edc76029eb9b874525b255 Mon Sep 17 00:00:00 2001 From: Luis Date: Wed, 15 Apr 2026 23:50:47 +0200 Subject: [PATCH 37/54] refactor(python-sdk): all callback classes inherit _BaseSubscriber, idempotent undeclare CallbackSubscriber, RawCallbackSubscriber, CallbackSubscriberAsync, and RawCallbackSubscriberAsync now inherit _BaseSubscriber for a unified idempotent undeclare() pattern. AsyncQueryable gains its own _undeclared guard. Two new tests verify the double-undeclare no-op for callback classes. Co-Authored-By: Claude Sonnet 4.6 --- python-sdk/bubbaloop_sdk/subscriber.py | 38 ++++++++++++++------------ python-sdk/tests/test_context.py | 25 +++++++++++++++++ 2 files changed, 46 insertions(+), 17 deletions(-) diff --git a/python-sdk/bubbaloop_sdk/subscriber.py b/python-sdk/bubbaloop_sdk/subscriber.py index 84ec226..102a571 100644 --- a/python-sdk/bubbaloop_sdk/subscriber.py +++ b/python-sdk/bubbaloop_sdk/subscriber.py @@ -91,7 +91,7 @@ def recv(self) -> bytes: return bytes(sample.payload) -class CallbackSubscriber: +class CallbackSubscriber(_BaseSubscriber): """Callback-based subscriber — Zenoh calls ``handler`` from its internal thread. No loop required from the caller. Callbacks are invoked **serially** on Zenoh's @@ -127,13 +127,10 @@ def _wrap(sample: zenoh.Sample) -> None: handler(payload) self._sub = session.declare_subscriber(topic, _wrap) - - def undeclare(self) -> None: - """Undeclare the subscriber and stop receiving samples.""" - self._sub.undeclare() + self._undeclared = False -class RawCallbackSubscriber: +class RawCallbackSubscriber(_BaseSubscriber): """Callback-based subscriber that passes raw ``zenoh.Sample`` to the handler. Use when you need access to the full sample metadata (key_expr, encoding, @@ -146,13 +143,10 @@ class RawCallbackSubscriber: def __init__(self, session: zenoh.Session, key_expr: str, handler): self._sub = session.declare_subscriber(key_expr, handler) - - def undeclare(self) -> None: - """Undeclare the subscriber and stop receiving samples.""" - self._sub.undeclare() + self._undeclared = False -class CallbackSubscriberAsync: +class CallbackSubscriberAsync(_BaseSubscriber): """Callback subscriber that runs ``handler`` in a ``ThreadPoolExecutor``. **Use this when your handler does slow work** (database writes, hardware reads, @@ -195,15 +189,18 @@ def _decode_and_call(): pass # executor already shut down — drop the message self._sub = session.declare_subscriber(topic, _wrap) + self._undeclared = False def undeclare(self) -> None: - """Undeclare the subscriber and shutdown the thread pool.""" + """Undeclare the subscriber and shutdown the thread pool. Idempotent.""" + if self._undeclared: + return self._closing.set() - self._sub.undeclare() # stop Zenoh callbacks first + super().undeclare() self._executor.shutdown(wait=False, cancel_futures=True) -class RawCallbackSubscriberAsync: +class RawCallbackSubscriberAsync(_BaseSubscriber): """Raw callback subscriber that runs ``handler`` in a ``ThreadPoolExecutor``. Same as ``CallbackSubscriberAsync`` but passes raw ``zenoh.Sample`` objects. @@ -225,11 +222,14 @@ def _wrap(sample: zenoh.Sample) -> None: pass # executor already shut down — drop the message self._sub = session.declare_subscriber(key_expr, _wrap) + self._undeclared = False def undeclare(self) -> None: - """Undeclare the subscriber and shutdown the thread pool.""" + """Undeclare the subscriber and shutdown the thread pool. Idempotent.""" + if self._undeclared: + return self._closing.set() - self._sub.undeclare() # stop Zenoh callbacks first + super().undeclare() self._executor.shutdown(wait=False, cancel_futures=True) @@ -259,6 +259,7 @@ def on_db_query(query: zenoh.Query) -> None: def __init__(self, session: zenoh.Session, key_expr: str, handler, max_workers: int = 4): self._executor = concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) self._closing = threading.Event() + self._undeclared = False def _wrap(query) -> None: if self._closing.is_set(): @@ -271,7 +272,10 @@ def _wrap(query) -> None: self._qbl = session.declare_queryable(key_expr, _wrap) def undeclare(self) -> None: - """Undeclare the queryable and shutdown the thread pool.""" + """Undeclare the queryable and shutdown the thread pool. Idempotent.""" + if self._undeclared: + return + self._undeclared = True self._closing.set() self._qbl.undeclare() # stop Zenoh callbacks first self._executor.shutdown(wait=False, cancel_futures=True) diff --git a/python-sdk/tests/test_context.py b/python-sdk/tests/test_context.py index 797a6ff..80e2478 100644 --- a/python-sdk/tests/test_context.py +++ b/python-sdk/tests/test_context.py @@ -568,6 +568,31 @@ def test_raw_callback_subscriber_undeclare(): mock_sub.undeclare.assert_called_once() +def test_callback_subscriber_undeclare_is_idempotent(): + """undeclare() can be called twice without error.""" + from bubbaloop_sdk.subscriber import CallbackSubscriber + + mock_session = MagicMock() + mock_session.declare_subscriber.return_value = MagicMock() + sub = CallbackSubscriber(mock_session, "test/topic", lambda msg: None) + sub.undeclare() + sub.undeclare() # second call is a no-op + mock_session.declare_subscriber.return_value.undeclare.assert_called_once() + + +def test_callback_subscriber_async_undeclare_is_idempotent(): + """undeclare() can be called twice without error.""" + from bubbaloop_sdk.subscriber import CallbackSubscriberAsync + + mock_session = MagicMock() + mock_sub = MagicMock() + mock_session.declare_subscriber.return_value = mock_sub + sub = CallbackSubscriberAsync(mock_session, "test/topic", lambda msg: None) + sub.undeclare() + sub.undeclare() # second call is a no-op + mock_sub.undeclare.assert_called_once() + + # --------------------------------------------------------------------------- # CallbackSubscriberAsync # --------------------------------------------------------------------------- From eb34fbe42dcebd1aade4a7ef17f9e7c529c65d5c Mon Sep 17 00:00:00 2001 From: Luis Date: Wed, 15 Apr 2026 23:53:21 +0200 Subject: [PATCH 38/54] test(python-sdk): remove TypedSubscriber tests Remove all TypedSubscriber tests (9 functions) and two tests for the removed ctx.subscriber()/ctx.subscriber_raw() methods. Update test_import_subscribers to drop the TypedSubscriber import assertion. Rename section headers that previously mentioned both TypedSubscriber and RawSubscriber to mention only RawSubscriber. Co-Authored-By: Claude Sonnet 4.6 --- python-sdk/tests/test_context.py | 231 +------------------------------ 1 file changed, 3 insertions(+), 228 deletions(-) diff --git a/python-sdk/tests/test_context.py b/python-sdk/tests/test_context.py index 80e2478..9ff5443 100644 --- a/python-sdk/tests/test_context.py +++ b/python-sdk/tests/test_context.py @@ -93,9 +93,8 @@ def test_import_publishers(): def test_import_subscribers(): - from bubbaloop_sdk import ProtoSubscriber, RawSubscriber, TypedSubscriber + from bubbaloop_sdk import ProtoSubscriber, RawSubscriber - assert TypedSubscriber is not None assert ProtoSubscriber is not None assert RawSubscriber is not None @@ -202,71 +201,6 @@ def test_json_publisher_passthrough_str(): mock_pub.put.assert_called_once_with(b"hello") -# --------------------------------------------------------------------------- -# TypedSubscriber — queue-backed with timeout -# --------------------------------------------------------------------------- - - -def test_typed_subscriber_recv_returns_none_on_timeout(): - """recv(timeout) returns None when queue is empty within timeout.""" - from bubbaloop_sdk.subscriber import TypedSubscriber - - mock_session = MagicMock() - mock_session.declare_subscriber.return_value = MagicMock() - sub = TypedSubscriber(mock_session, "test/topic") - result = sub.recv(timeout=0.05) - assert result is None - - -def test_typed_subscriber_recv_returns_message_when_available(): - """recv() returns the message put into the queue by the callback.""" - from bubbaloop_sdk.subscriber import TypedSubscriber - - mock_session = MagicMock() - captured_handler = [] - - def fake_declare(topic, handler): - captured_handler.append(handler) - return MagicMock() - - mock_session.declare_subscriber.side_effect = fake_declare - sub = TypedSubscriber(mock_session, "test/topic") - - # Simulate Zenoh delivering a sample - fake_sample = MagicMock() - fake_sample.payload.to_bytes.return_value = b"\x01\x02" - captured_handler[0](fake_sample) - - result = sub.recv(timeout=1.0) - assert result == b"\x01\x02" - - -def test_typed_subscriber_recv_decodes_proto(): - """recv() decodes with FromString when msg_class provided.""" - from bubbaloop_sdk.subscriber import TypedSubscriber - - mock_session = MagicMock() - captured_handler = [] - - def fake_declare(topic, handler): - captured_handler.append(handler) - return MagicMock() - - mock_session.declare_subscriber.side_effect = fake_declare - - fake_msg_class = MagicMock() - fake_msg_class.FromString.return_value = "decoded" - sub = TypedSubscriber(mock_session, "test/topic", msg_class=fake_msg_class) - - fake_sample = MagicMock() - fake_sample.payload.to_bytes.return_value = b"\x01" - captured_handler[0](fake_sample) - - result = sub.recv(timeout=1.0) - assert result == "decoded" - fake_msg_class.FromString.assert_called_once_with(b"\x01") - - def test_raw_subscriber_recv_returns_bytes(): """RawSubscriber.recv() returns bytes from sample payload.""" from bubbaloop_sdk.subscriber import RawSubscriber @@ -286,32 +220,10 @@ def test_raw_subscriber_recv_returns_bytes(): # --------------------------------------------------------------------------- -# TypedSubscriber / RawSubscriber — undeclare unblocks recv() +# RawSubscriber — undeclare unblocks recv() # --------------------------------------------------------------------------- -def test_typed_subscriber_undeclare_unblocks_recv(): - """undeclare() unblocks a thread waiting in recv(timeout=None).""" - from bubbaloop_sdk.subscriber import TypedSubscriber - - mock_session = MagicMock() - mock_session.declare_subscriber.return_value = MagicMock() - sub = TypedSubscriber(mock_session, "test/topic") - - result_holder = [] - - def blocking_recv(): - result_holder.append(sub.recv(timeout=None)) - - t = threading.Thread(target=blocking_recv) - t.start() - sub.undeclare() - t.join(timeout=2.0) - - assert not t.is_alive(), "recv() did not unblock after undeclare()" - assert result_holder == [None] - - def test_raw_subscriber_undeclare_is_idempotent(): """undeclare() can be called twice without error.""" from bubbaloop_sdk.subscriber import RawSubscriber @@ -326,46 +238,6 @@ def test_raw_subscriber_undeclare_is_idempotent(): mock_sub.undeclare.assert_called_once() -def test_typed_subscriber_decode_happens_in_recv_not_callback(): - """FromString is called in recv(), not inside the Zenoh callback.""" - from bubbaloop_sdk.subscriber import TypedSubscriber - - mock_session = MagicMock() - captured_handler = [] - - def fake_declare(topic, handler): - captured_handler.append(handler) - return MagicMock() - - mock_session.declare_subscriber.side_effect = fake_declare - decode_thread_ids = [] - callback_thread_id = [] - - class FakeMsgClass: - @staticmethod - def FromString(data): - decode_thread_ids.append(threading.current_thread().ident) - return f"decoded:{data}" - - sub = TypedSubscriber(mock_session, "test/topic", msg_class=FakeMsgClass) - - def zenoh_callback(): - callback_thread_id.append(threading.current_thread().ident) - fake_sample = MagicMock() - fake_sample.payload.to_bytes.return_value = b"\x01" - captured_handler[0](fake_sample) - - t = threading.Thread(target=zenoh_callback) - t.start() - t.join() - - result = sub.recv(timeout=1.0) - - assert result == "decoded:b'\\x01'" - # Decode must NOT have happened on the Zenoh (callback) thread - assert decode_thread_ids[0] != callback_thread_id[0] - - # --------------------------------------------------------------------------- # CallbackSubscriberAsync / RawCallbackSubscriberAsync — _closing flag # --------------------------------------------------------------------------- @@ -941,27 +813,6 @@ def test_publisher_proto_uses_topic_prefix(): assert called_topic == "bubbaloop/global/bot/sensor/data" -# --------------------------------------------------------------------------- -# NodeContext.subscriber() / subscriber_raw() via context -# --------------------------------------------------------------------------- - - -def test_subscriber_uses_topic_prefix(): - """subscriber() declares at topic(suffix).""" - ctx = _make_context("bot") - ctx.subscriber("sensor/data") - called_topic = ctx.session.declare_subscriber.call_args[0][0] - assert called_topic == "bubbaloop/global/bot/sensor/data" - - -def test_subscriber_raw_uses_literal_key_expr(): - """subscriber_raw() declares at the literal key expression.""" - ctx = _make_context("bot") - ctx.subscriber_raw("bubbaloop/**/health") - called_topic = ctx.session.declare_subscriber.call_args[0][0] - assert called_topic == "bubbaloop/**/health" - - # --------------------------------------------------------------------------- # NodeContext.close() and context manager # --------------------------------------------------------------------------- @@ -1018,22 +869,10 @@ def test_connect_instance_name_override(monkeypatch): # --------------------------------------------------------------------------- -# TypedSubscriber / RawSubscriber — undeclare() and iteration +# RawSubscriber — undeclare() and iteration # --------------------------------------------------------------------------- -def test_typed_subscriber_undeclare(): - """undeclare() calls undeclare on the underlying zenoh subscriber.""" - from bubbaloop_sdk.subscriber import TypedSubscriber - - mock_session = MagicMock() - mock_sub = MagicMock() - mock_session.declare_subscriber.return_value = mock_sub - sub = TypedSubscriber(mock_session, "test/topic") - sub.undeclare() - mock_sub.undeclare.assert_called_once() - - def test_raw_subscriber_undeclare(): """undeclare() calls undeclare on the underlying zenoh subscriber.""" from bubbaloop_sdk.subscriber import RawSubscriber @@ -1046,48 +885,6 @@ def test_raw_subscriber_undeclare(): mock_sub.undeclare.assert_called_once() -def test_typed_subscriber_iteration(): - """Iterating over TypedSubscriber yields decoded messages.""" - from bubbaloop_sdk.subscriber import TypedSubscriber - - mock_session = MagicMock() - captured_handler = [] - - def fake_declare(topic, handler): - captured_handler.append(handler) - return MagicMock() - - mock_session.declare_subscriber.side_effect = fake_declare - sub = TypedSubscriber(mock_session, "test/topic") - - # Feed two samples then stop iteration by checking queue empty - for payload in [b"\x01", b"\x02"]: - fake_sample = MagicMock() - fake_sample.payload.to_bytes.return_value = payload - captured_handler[0](fake_sample) - - results = [] - for msg in sub: - results.append(msg) - if len(results) == 2: - break - - assert results == [b"\x01", b"\x02"] - - -def test_typed_subscriber_recv_returns_none_after_undeclare(): - """recv() returns None immediately on all calls after undeclare().""" - from bubbaloop_sdk.subscriber import TypedSubscriber - - mock_session = MagicMock() - mock_session.declare_subscriber.return_value = MagicMock() - sub = TypedSubscriber(mock_session, "test/topic") - sub.undeclare() - # First call consumes the sentinel; second must not block. - assert sub.recv(timeout=1.0) is None - assert sub.recv(timeout=1.0) is None - - def test_raw_subscriber_declares_on_topic(): """RawSubscriber declares a zenoh subscriber on the given topic.""" from bubbaloop_sdk.subscriber import RawSubscriber @@ -1098,28 +895,6 @@ def test_raw_subscriber_declares_on_topic(): mock_session.declare_subscriber.assert_called_once_with("test/topic") -def test_typed_subscriber_drops_samples_after_undeclare(): - """Samples arriving after undeclare() are not enqueued.""" - from bubbaloop_sdk.subscriber import TypedSubscriber - - mock_session = MagicMock() - captured_handler = [] - - def fake_declare(topic, handler): - captured_handler.append(handler) - return MagicMock() - - mock_session.declare_subscriber.side_effect = fake_declare - sub = TypedSubscriber(mock_session, "test/topic") - sub.undeclare() - - fake_sample = MagicMock() - fake_sample.payload.to_bytes.return_value = b"\xff" - captured_handler[0](fake_sample) # arrives after undeclare - - assert sub.recv(timeout=0.1) is None # no message — only closed state - - def test_raw_subscriber_undeclare_calls_sub_undeclare(): """undeclare() calls undeclare on the underlying zenoh subscriber.""" from bubbaloop_sdk.subscriber import RawSubscriber From e96917bf01d0a83ab16929ef4968156148415e2f Mon Sep 17 00:00:00 2001 From: Luis Date: Wed, 15 Apr 2026 23:56:09 +0200 Subject: [PATCH 39/54] docs(python-sdk): remove TypedSubscriber references, update subscriber docs Replace TypedSubscriber with ProtoSubscriber/CallbackSubscriber in all doc files; remove deprecated ctx.subscriber() and ctx.subscriber_raw() rows from README API table; update CONTRIBUTING test count to 68. Co-Authored-By: Claude Sonnet 4.6 --- python-sdk/CLAUDE.md | 35 ++++++++++++++++++----------------- python-sdk/CONTRIBUTING.md | 4 ++-- python-sdk/README.md | 9 +++------ 3 files changed, 23 insertions(+), 25 deletions(-) diff --git a/python-sdk/CLAUDE.md b/python-sdk/CLAUDE.md index a6cf7f2..d883867 100644 --- a/python-sdk/CLAUDE.md +++ b/python-sdk/CLAUDE.md @@ -11,7 +11,7 @@ python-sdk/ __init__.py # Public API — edit when adding new public names context.py # NodeContext: connect(), topic(), publishers, subscribers, queryables publisher.py # JsonPublisher, ProtoPublisher (wraps session.declare_publisher) - subscriber.py # TypedSubscriber, RawSubscriber, Callback*, Async*, AsyncQueryable + subscriber.py # ProtoSubscriber, RawSubscriber, Callback*, Async*, AsyncQueryable node.py # run_node() — CLI arg parsing + health heartbeat + lifecycle health.py # start_health_heartbeat() — publishes 'ok' every 5s discover.py # discover_nodes() — GET bubbaloop/**/health @@ -64,29 +64,30 @@ cd python-sdk - Include `Args:`, `Returns:`, and `Raises:` sections when applicable ```python -class TypedSubscriber: - """Blocking subscriber with optional timeout. +class CallbackSubscriber: + """Event-driven subscriber that calls a handler on each received message. - Internally queue-backed: Zenoh delivers raw bytes via a callback into a - ``queue.Queue``. Decoding happens in ``recv()`` on the consumer thread. + The handler is invoked from Zenoh's internal callback thread. Keep + handlers fast; use ``subscriber_callback_async`` for slow work (I/O, + DB writes, HTTP calls). Args: session: Active Zenoh session. topic: Key expression to subscribe to. + handler: Callable invoked with each decoded message. msg_class: Protobuf message class for decoding, or None for raw bytes. """ - def __init__(self, session: zenoh.Session, topic: str, msg_class=None): ... + def __init__( + self, + session: zenoh.Session, + topic: str, + handler: Callable, + msg_class=None, + ): ... -def recv(self, timeout: float | None = None) -> bytes | None: - """Block until the next message arrives. - - Args: - timeout: Max seconds to wait. None blocks indefinitely. - - Returns: - Decoded message, raw bytes, or None on timeout/close. - """ + def undeclare(self) -> None: + """Undeclare the Zenoh subscriber and release resources.""" ``` **String formatting:** @@ -115,7 +116,7 @@ def recv(self, timeout: float | None = None) -> bytes | None: **`undeclare()` discipline:** - Every subscriber, callback subscriber, and queryable must be undeclared when done - `AsyncQueryable` and `*Async` subscribers own a `ThreadPoolExecutor` — GC alone is not enough, always call `undeclare()` -- Blocking subscribers (`TypedSubscriber`, `RawSubscriber`) are undeclared via `undeclare()` too +- Blocking subscribers (`RawSubscriber`) are undeclared via `undeclare()` too ## Testing @@ -153,6 +154,6 @@ assert event.wait(timeout=2.0), "handler not called within 2s" - `B904` — always `raise Foo from err` inside `except` blocks, never bare `raise Foo(...)` - `F401` in `__init__.py` is suppressed by ruff config (re-exports are intentional) — do NOT add `# noqa` comments there - `CallbackSubscriber` and `RawCallbackSubscriber` do NOT own an executor — `undeclare()` only calls `_sub.undeclare()`; the `_async` variants do own an executor and shut it down in `undeclare()` -- `TypedSubscriber` and `RawSubscriber` are iterable (`for msg in sub`) but iteration blocks forever — always prefer `recv(timeout=...)` in shutdown-aware loops +- `ProtoSubscriber` and `RawSubscriber` are iterable (`for msg in sub`); iteration raises `StopIteration` on exception via `_BaseSubscriber.__next__` — prefer `recv(timeout=...)` in shutdown-aware loops to avoid blocking indefinitely - `run_node()` reads `config.yaml` by default; override with `-c path/config.yaml`. The `name` field in config sets `instance_name` for health/schema topics — collisions happen if two instances share the same name - Health topic format: `bubbaloop/global/{machine_id}/{instance_name}/health` — ensure consumer patterns match exactly diff --git a/python-sdk/CONTRIBUTING.md b/python-sdk/CONTRIBUTING.md index 482d991..6dd5ea7 100644 --- a/python-sdk/CONTRIBUTING.md +++ b/python-sdk/CONTRIBUTING.md @@ -69,7 +69,7 @@ python-sdk/ bubbaloop_sdk/ __init__.py # Public API surface context.py # NodeContext — main entry point - subscriber.py # TypedSubscriber, RawSubscriber, Callback*, Async* + subscriber.py # ProtoSubscriber, RawSubscriber, Callback*, Async*, AsyncQueryable publisher.py # JsonPublisher, ProtoPublisher node.py # run_node() helper health.py # Health heartbeat (used internally by run_node) @@ -77,5 +77,5 @@ python-sdk/ get_sample.py # get_sample() one-shot helper decode_sample.py # ProtoDecoder tests/ - test_context.py # 48 unit tests (no real Zenoh required) + test_context.py # 68 unit tests (no real Zenoh required) ``` diff --git a/python-sdk/README.md b/python-sdk/README.md index f55017e..d7e6c66 100644 --- a/python-sdk/README.md +++ b/python-sdk/README.md @@ -56,16 +56,15 @@ pub.undeclare() ctx.close() ``` -### Proto subscriber +### Auto-decode subscriber ```python from bubbaloop_sdk import NodeContext -from my_protos_pb2 import SensorData ctx = NodeContext.connect() -sub = ctx.subscriber("sensor/data", SensorData) +sub = ctx.subscribe("sensor/data") -for msg in sub: +for msg in sub: # auto-decoded: proto, dict, or bytes print(f"value: {msg.value}") ``` @@ -139,8 +138,6 @@ qbl.undeclare() # call when done to release the thread pool | `ctx.publisher_raw(suffix, local=False)` | Declared raw publisher (no encoding) | | `ctx.subscribe(suffix, local=False)` | Auto-decode subscriber (proto/json/bytes) | | `ctx.subscribe_raw(suffix, local=False)` | Raw bytes subscriber | -| `ctx.subscriber(suffix, msg_class=None)` | TypedSubscriber (queue-backed, timeout support) | -| `ctx.subscriber_raw(key_expr)` | Raw sample subscriber (no topic prefix) | | `ctx.is_shutdown()` | True after SIGINT/SIGTERM | | `ctx.wait_shutdown()` | Block until shutdown | | `ctx.close()` | Close the Zenoh session | From b240353ad05c341833e64479fe4ffbfc7127cda2 Mon Sep 17 00:00:00 2001 From: Luis Date: Wed, 15 Apr 2026 23:58:40 +0200 Subject: [PATCH 40/54] chore(python-sdk): add pixi.lock Co-Authored-By: Claude Opus 4.6 (1M context) --- python-sdk/pixi.lock | 742 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 742 insertions(+) create mode 100644 python-sdk/pixi.lock diff --git a/python-sdk/pixi.lock b/python-sdk/pixi.lock new file mode 100644 index 0000000..8903e7d --- /dev/null +++ b/python-sdk/pixi.lock @@ -0,0 +1,742 @@ +version: 6 +environments: + default: + channels: + - url: https://conda.anaconda.org/conda-forge/ + indexes: + - https://pypi.org/simple + options: + pypi-prerelease-mode: if-necessary-or-explicit + packages: + linux-64: + - conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-20_gnu.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_9.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.2.25-hbd8a1cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/icu-78.3-h33c6efd_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.45.1-default_hbd61a6d_102.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.7.5-hecca717_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.5.2-h3435931_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-15.2.0-he0feb66_18.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-15.2.0-he0feb66_18.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.2-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libmpdec-4.0.0-hb03c661_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.52.0-hf4e2dac_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-15.2.0-h934c35e_18.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.42-h5347b49_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.2-h25fd6f3_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.5-h2d0b736_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.1-h35e630c_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.14.3-h32b2ec7_101_cp314.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.14-8_cp314.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.3-h853b02a_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_h366c992_103.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda + - pypi: https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/c6/f9/22883e613eb193f8f956e8e96d8f16e39b369dac4ade7aa3b37f344ddc62/eclipse_zenoh-1.8.0.tar.gz + - pypi: https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/53/1b/3b431694a4dc6d37b9f653f0c64b0a0d9ec074ee810710c0c3da21d67ba7/protobuf-7.34.1-cp310-abi3-manylinux2014_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/ff/6b/a1548ac378a78332a4c3dcf4a134c2475a36d2a22ddfa272acd574140b50/ruff-0.15.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + - pypi: ./ + linux-aarch64: + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/_openmp_mutex-4.5-20_gnu.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/bzip2-1.0.8-h4777abc_9.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.2.25-hbd8a1cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/icu-78.3-hcab7f73_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/ld_impl_linux-aarch64-2.45.1-default_h1979696_102.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libexpat-2.7.5-hfae3067_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libffi-3.5.2-h376a255_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libgcc-15.2.0-h8acb6b2_18.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libgomp-15.2.0-h8acb6b2_18.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/liblzma-5.8.2-he30d5cf_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libmpdec-4.0.0-he30d5cf_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libsqlite-3.52.0-h10b116e_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libstdcxx-15.2.0-hef695bb_18.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libuuid-2.42-h1022ec0_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libzlib-1.3.2-hdc9db2a_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/ncurses-6.5-ha32ae93_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/openssl-3.6.1-h546c87b_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/python-3.14.3-hb06a95a_101_cp314.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.14-8_cp314.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/readline-8.3-hb682ff5_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/tk-8.6.13-noxft_h0dc03b3_103.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/zstd-1.5.7-h85ac4a6_6.conda + - pypi: https://files.pythonhosted.org/packages/80/28/2a148a51e5907e504fa7b85490277734e6771d8844ebcc48764a15e28155/coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl + - pypi: https://files.pythonhosted.org/packages/cf/ba/7bb452da75a6c3d40d512112e90aa9942996466051ebfb038c6dc41ed302/eclipse_zenoh-1.8.0-cp39-abi3-manylinux_2_28_aarch64.whl + - pypi: https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/eb/9d/aa69df2724ff63efa6f72307b483ce0827f4347cc6d6df24b59e26659fef/protobuf-7.34.1-cp310-abi3-manylinux2014_aarch64.whl + - pypi: https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl + - pypi: https://files.pythonhosted.org/packages/48/11/690d75f3fd6278fe55fff7c9eb429c92d207e14b25d1cae4064a32677029/ruff-0.15.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl + - pypi: ./ +packages: +- conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-20_gnu.conda + build_number: 20 + sha256: 1dd3fffd892081df9726d7eb7e0dea6198962ba775bd88842135a4ddb4deb3c9 + md5: a9f577daf3de00bca7c3c76c0ecbd1de + depends: + - __glibc >=2.17,<3.0.a0 + - libgomp >=7.5.0 + constrains: + - openmp_impl <0.0a0 + license: BSD-3-Clause + license_family: BSD + purls: [] + size: 28948 + timestamp: 1770939786096 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/_openmp_mutex-4.5-20_gnu.conda + build_number: 20 + sha256: a2527b1d81792a0ccd2c05850960df119c2b6d8f5fdec97f2db7d25dc23b1068 + md5: 468fd3bb9e1f671d36c2cbc677e56f1d + depends: + - libgomp >=7.5.0 + constrains: + - openmp_impl <0.0a0 + license: BSD-3-Clause + license_family: BSD + purls: [] + size: 28926 + timestamp: 1770939656741 +- pypi: ./ + name: bubbaloop-sdk + version: 0.1.0 + sha256: cd379756248058d15ec268b0a35a5292df0f5ec4414162c979070b174874047b + requires_dist: + - eclipse-zenoh>=1.7,<2 + - protobuf>=4.0 + - pyyaml>=6.0 + - pytest ; extra == 'dev' + - pytest-asyncio ; extra == 'dev' + - pytest-cov ; extra == 'dev' + - ruff ; extra == 'dev' + requires_python: '>=3.10' +- conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_9.conda + sha256: 0b75d45f0bba3e95dc693336fa51f40ea28c980131fec438afb7ce6118ed05f6 + md5: d2ffd7602c02f2b316fd921d39876885 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: bzip2-1.0.6 + license_family: BSD + purls: [] + size: 260182 + timestamp: 1771350215188 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/bzip2-1.0.8-h4777abc_9.conda + sha256: b3495077889dde6bb370938e7db82be545c73e8589696ad0843a32221520ad4c + md5: 840d8fc0d7b3209be93080bc20e07f2d + depends: + - libgcc >=14 + license: bzip2-1.0.6 + license_family: BSD + purls: [] + size: 192412 + timestamp: 1771350241232 +- conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.2.25-hbd8a1cb_0.conda + sha256: 67cc7101b36421c5913a1687ef1b99f85b5d6868da3abbf6ec1a4181e79782fc + md5: 4492fd26db29495f0ba23f146cd5638d + depends: + - __unix + license: ISC + purls: [] + size: 147413 + timestamp: 1772006283803 +- pypi: https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl + name: coverage + version: 7.13.5 + sha256: 6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510 + requires_dist: + - tomli ; python_full_version <= '3.11' and extra == 'toml' + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/80/28/2a148a51e5907e504fa7b85490277734e6771d8844ebcc48764a15e28155/coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl + name: coverage + version: 7.13.5 + sha256: 380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247 + requires_dist: + - tomli ; python_full_version <= '3.11' and extra == 'toml' + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/c6/f9/22883e613eb193f8f956e8e96d8f16e39b369dac4ade7aa3b37f344ddc62/eclipse_zenoh-1.8.0.tar.gz + name: eclipse-zenoh + version: 1.8.0 + sha256: 1cb0b8abdc522d58497c0cd7b8c8e7791f39d2c189c5e0bc80da8840af0ce24d + requires_python: '>=3.8' +- pypi: https://files.pythonhosted.org/packages/cf/ba/7bb452da75a6c3d40d512112e90aa9942996466051ebfb038c6dc41ed302/eclipse_zenoh-1.8.0-cp39-abi3-manylinux_2_28_aarch64.whl + name: eclipse-zenoh + version: 1.8.0 + sha256: 1aca875fd5aa38284cf7161964241a73b4e4090a48385c35a6d8e6169cc8e88a + requires_python: '>=3.8' +- conda: https://conda.anaconda.org/conda-forge/linux-64/icu-78.3-h33c6efd_0.conda + sha256: fbf86c4a59c2ed05bbffb2ba25c7ed94f6185ec30ecb691615d42342baa1a16a + md5: c80d8a3b84358cb967fa81e7075fbc8a + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + license: MIT + license_family: MIT + purls: [] + size: 12723451 + timestamp: 1773822285671 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/icu-78.3-hcab7f73_0.conda + sha256: 49ba6aed2c6b482bb0ba41078057555d29764299bc947b990708617712ef6406 + md5: 546da38c2fa9efacf203e2ad3f987c59 + depends: + - libgcc >=14 + - libstdcxx >=14 + license: MIT + license_family: MIT + purls: [] + size: 12837286 + timestamp: 1773822650615 +- pypi: https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl + name: iniconfig + version: 2.3.0 + sha256: f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12 + requires_python: '>=3.10' +- conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.45.1-default_hbd61a6d_102.conda + sha256: 3d584956604909ff5df353767f3a2a2f60e07d070b328d109f30ac40cd62df6c + md5: 18335a698559cdbcd86150a48bf54ba6 + depends: + - __glibc >=2.17,<3.0.a0 + - zstd >=1.5.7,<1.6.0a0 + constrains: + - binutils_impl_linux-64 2.45.1 + license: GPL-3.0-only + license_family: GPL + purls: [] + size: 728002 + timestamp: 1774197446916 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/ld_impl_linux-aarch64-2.45.1-default_h1979696_102.conda + sha256: 7abd913d81a9bf00abb699e8987966baa2065f5132e37e815f92d90fc6bba530 + md5: a21644fc4a83da26452a718dc9468d5f + depends: + - zstd >=1.5.7,<1.6.0a0 + constrains: + - binutils_impl_linux-aarch64 2.45.1 + license: GPL-3.0-only + license_family: GPL + purls: [] + size: 875596 + timestamp: 1774197520746 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.7.5-hecca717_0.conda + sha256: e8c2b57f6aacabdf2f1b0924bd4831ce5071ba080baa4a9e8c0d720588b6794c + md5: 49f570f3bc4c874a06ea69b7225753af + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + constrains: + - expat 2.7.5.* + license: MIT + license_family: MIT + purls: [] + size: 76624 + timestamp: 1774719175983 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libexpat-2.7.5-hfae3067_0.conda + sha256: 6d438fc0bfdb263c24654fe49c09b31f06ec78eb709eb386392d2499af105f85 + md5: 05d1e0b30acd816a192c03dc6e164f4d + depends: + - libgcc >=14 + constrains: + - expat 2.7.5.* + license: MIT + license_family: MIT + purls: [] + size: 76523 + timestamp: 1774719129371 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.5.2-h3435931_0.conda + sha256: 31f19b6a88ce40ebc0d5a992c131f57d919f73c0b92cd1617a5bec83f6e961e6 + md5: a360c33a5abe61c07959e449fa1453eb + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: MIT + license_family: MIT + purls: [] + size: 58592 + timestamp: 1769456073053 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libffi-3.5.2-h376a255_0.conda + sha256: 3df4c539449aabc3443bbe8c492c01d401eea894603087fca2917aa4e1c2dea9 + md5: 2f364feefb6a7c00423e80dcb12db62a + depends: + - libgcc >=14 + license: MIT + license_family: MIT + purls: [] + size: 55952 + timestamp: 1769456078358 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-15.2.0-he0feb66_18.conda + sha256: faf7d2017b4d718951e3a59d081eb09759152f93038479b768e3d612688f83f5 + md5: 0aa00f03f9e39fb9876085dee11a85d4 + depends: + - __glibc >=2.17,<3.0.a0 + - _openmp_mutex >=4.5 + constrains: + - libgcc-ng ==15.2.0=*_18 + - libgomp 15.2.0 he0feb66_18 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + purls: [] + size: 1041788 + timestamp: 1771378212382 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libgcc-15.2.0-h8acb6b2_18.conda + sha256: 43df385bedc1cab11993c4369e1f3b04b4ca5d0ea16cba6a0e7f18dbc129fcc9 + md5: 552567ea2b61e3a3035759b2fdb3f9a6 + depends: + - _openmp_mutex >=4.5 + constrains: + - libgcc-ng ==15.2.0=*_18 + - libgomp 15.2.0 h8acb6b2_18 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + purls: [] + size: 622900 + timestamp: 1771378128706 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-15.2.0-he0feb66_18.conda + sha256: 21337ab58e5e0649d869ab168d4e609b033509de22521de1bfed0c031bfc5110 + md5: 239c5e9546c38a1e884d69effcf4c882 + depends: + - __glibc >=2.17,<3.0.a0 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + purls: [] + size: 603262 + timestamp: 1771378117851 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libgomp-15.2.0-h8acb6b2_18.conda + sha256: fc716f11a6a8525e27a5d332ef6a689210b0d2a4dd1133edc0f530659aa9faa6 + md5: 4faa39bf919939602e594253bd673958 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + purls: [] + size: 588060 + timestamp: 1771378040807 +- conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.2-hb03c661_0.conda + sha256: 755c55ebab181d678c12e49cced893598f2bab22d582fbbf4d8b83c18be207eb + md5: c7c83eecbb72d88b940c249af56c8b17 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + constrains: + - xz 5.8.2.* + license: 0BSD + purls: [] + size: 113207 + timestamp: 1768752626120 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/liblzma-5.8.2-he30d5cf_0.conda + sha256: 843c46e20519651a3e357a8928352b16c5b94f4cd3d5481acc48be2e93e8f6a3 + md5: 96944e3c92386a12755b94619bae0b35 + depends: + - libgcc >=14 + constrains: + - xz 5.8.2.* + license: 0BSD + purls: [] + size: 125916 + timestamp: 1768754941722 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libmpdec-4.0.0-hb03c661_1.conda + sha256: fe171ed5cf5959993d43ff72de7596e8ac2853e9021dec0344e583734f1e0843 + md5: 2c21e66f50753a083cbe6b80f38268fa + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: BSD-2-Clause + license_family: BSD + purls: [] + size: 92400 + timestamp: 1769482286018 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libmpdec-4.0.0-he30d5cf_1.conda + sha256: 57c0dd12d506e84541c4e877898bd2a59cca141df493d34036f18b2751e0a453 + md5: 7b9813e885482e3ccb1fa212b86d7fd0 + depends: + - libgcc >=14 + license: BSD-2-Clause + license_family: BSD + purls: [] + size: 114056 + timestamp: 1769482343003 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.52.0-hf4e2dac_0.conda + sha256: d716847b7deca293d2e49ed1c8ab9e4b9e04b9d780aea49a97c26925b28a7993 + md5: fd893f6a3002a635b5e50ceb9dd2c0f4 + depends: + - __glibc >=2.17,<3.0.a0 + - icu >=78.2,<79.0a0 + - libgcc >=14 + - libzlib >=1.3.1,<2.0a0 + license: blessing + purls: [] + size: 951405 + timestamp: 1772818874251 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libsqlite-3.52.0-h10b116e_0.conda + sha256: 1ddaf91b44fae83856276f4cb7ce544ffe41d4b55c1e346b504c6b45f19098d6 + md5: 77891484f18eca74b8ad83694da9815e + depends: + - icu >=78.2,<79.0a0 + - libgcc >=14 + - libzlib >=1.3.1,<2.0a0 + license: blessing + purls: [] + size: 952296 + timestamp: 1772818881550 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-15.2.0-h934c35e_18.conda + sha256: 78668020064fdaa27e9ab65cd2997e2c837b564ab26ce3bf0e58a2ce1a525c6e + md5: 1b08cd684f34175e4514474793d44bcb + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc 15.2.0 he0feb66_18 + constrains: + - libstdcxx-ng ==15.2.0=*_18 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + purls: [] + size: 5852330 + timestamp: 1771378262446 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libstdcxx-15.2.0-hef695bb_18.conda + sha256: 31fdb9ffafad106a213192d8319b9f810e05abca9c5436b60e507afb35a6bc40 + md5: f56573d05e3b735cb03efeb64a15f388 + depends: + - libgcc 15.2.0 h8acb6b2_18 + constrains: + - libstdcxx-ng ==15.2.0=*_18 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + purls: [] + size: 5541411 + timestamp: 1771378162499 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.42-h5347b49_0.conda + sha256: bc1b08c92626c91500fd9f26f2c797f3eb153b627d53e9c13cd167f1e12b2829 + md5: 38ffe67b78c9d4de527be8315e5ada2c + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: BSD-3-Clause + license_family: BSD + purls: [] + size: 40297 + timestamp: 1775052476770 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libuuid-2.42-h1022ec0_0.conda + sha256: 7d427edf58c702c337bf62bc90f355b7fc374a65fd9f70ea7a490f13bb76b1b9 + md5: a0b5de740d01c390bdbb46d7503c9fab + depends: + - libgcc >=14 + license: BSD-3-Clause + license_family: BSD + purls: [] + size: 43567 + timestamp: 1775052485727 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.2-h25fd6f3_2.conda + sha256: 55044c403570f0dc26e6364de4dc5368e5f3fc7ff103e867c487e2b5ab2bcda9 + md5: d87ff7921124eccd67248aa483c23fec + depends: + - __glibc >=2.17,<3.0.a0 + constrains: + - zlib 1.3.2 *_2 + license: Zlib + license_family: Other + purls: [] + size: 63629 + timestamp: 1774072609062 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libzlib-1.3.2-hdc9db2a_2.conda + sha256: eb111e32e5a7313a5bf799c7fb2419051fa2fe7eff74769fac8d5a448b309f7f + md5: 502006882cf5461adced436e410046d1 + constrains: + - zlib 1.3.2 *_2 + license: Zlib + license_family: Other + purls: [] + size: 69833 + timestamp: 1774072605429 +- conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.5-h2d0b736_3.conda + sha256: 3fde293232fa3fca98635e1167de6b7c7fda83caf24b9d6c91ec9eefb4f4d586 + md5: 47e340acb35de30501a76c7c799c41d7 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + license: X11 AND BSD-3-Clause + purls: [] + size: 891641 + timestamp: 1738195959188 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/ncurses-6.5-ha32ae93_3.conda + sha256: 91cfb655a68b0353b2833521dc919188db3d8a7f4c64bea2c6a7557b24747468 + md5: 182afabe009dc78d8b73100255ee6868 + depends: + - libgcc >=13 + license: X11 AND BSD-3-Clause + purls: [] + size: 926034 + timestamp: 1738196018799 +- conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.1-h35e630c_1.conda + sha256: 44c877f8af015332a5d12f5ff0fb20ca32f896526a7d0cdb30c769df1144fb5c + md5: f61eb8cd60ff9057122a3d338b99c00f + depends: + - __glibc >=2.17,<3.0.a0 + - ca-certificates + - libgcc >=14 + license: Apache-2.0 + license_family: Apache + purls: [] + size: 3164551 + timestamp: 1769555830639 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/openssl-3.6.1-h546c87b_1.conda + sha256: 7f8048c0e75b2620254218d72b4ae7f14136f1981c5eb555ef61645a9344505f + md5: 25f5885f11e8b1f075bccf4a2da91c60 + depends: + - ca-certificates + - libgcc >=14 + license: Apache-2.0 + license_family: Apache + purls: [] + size: 3692030 + timestamp: 1769557678657 +- pypi: https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl + name: packaging + version: '26.0' + sha256: b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529 + requires_python: '>=3.8' +- pypi: https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl + name: pluggy + version: 1.6.0 + sha256: e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746 + requires_dist: + - pre-commit ; extra == 'dev' + - tox ; extra == 'dev' + - pytest ; extra == 'testing' + - pytest-benchmark ; extra == 'testing' + - coverage ; extra == 'testing' + requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/53/1b/3b431694a4dc6d37b9f653f0c64b0a0d9ec074ee810710c0c3da21d67ba7/protobuf-7.34.1-cp310-abi3-manylinux2014_x86_64.whl + name: protobuf + version: 7.34.1 + sha256: 8ff40ce8cd688f7265326b38d5a1bed9bfdf5e6723d49961432f83e21d5713e4 + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/eb/9d/aa69df2724ff63efa6f72307b483ce0827f4347cc6d6df24b59e26659fef/protobuf-7.34.1-cp310-abi3-manylinux2014_aarch64.whl + name: protobuf + version: 7.34.1 + sha256: 5185e0e948d07abe94bb76ec9b8416b604cfe5da6f871d67aad30cbf24c3110b + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl + name: pygments + version: 2.20.0 + sha256: 81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176 + requires_dist: + - colorama>=0.4.6 ; extra == 'windows-terminal' + requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl + name: pytest + version: 9.0.2 + sha256: 711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b + requires_dist: + - colorama>=0.4 ; sys_platform == 'win32' + - exceptiongroup>=1 ; python_full_version < '3.11' + - iniconfig>=1.0.1 + - packaging>=22 + - pluggy>=1.5,<2 + - pygments>=2.7.2 + - tomli>=1 ; python_full_version < '3.11' + - argcomplete ; extra == 'dev' + - attrs>=19.2 ; extra == 'dev' + - hypothesis>=3.56 ; extra == 'dev' + - mock ; extra == 'dev' + - requests ; extra == 'dev' + - setuptools ; extra == 'dev' + - xmlschema ; extra == 'dev' + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl + name: pytest-asyncio + version: 1.3.0 + sha256: 611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5 + requires_dist: + - backports-asyncio-runner>=1.1,<2 ; python_full_version < '3.11' + - pytest>=8.2,<10 + - typing-extensions>=4.12 ; python_full_version < '3.13' + - sphinx>=5.3 ; extra == 'docs' + - sphinx-rtd-theme>=1 ; extra == 'docs' + - coverage>=6.2 ; extra == 'testing' + - hypothesis>=5.7.1 ; extra == 'testing' + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl + name: pytest-cov + version: 7.1.0 + sha256: a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678 + requires_dist: + - coverage[toml]>=7.10.6 + - pluggy>=1.2 + - pytest>=7 + - process-tests ; extra == 'testing' + - pytest-xdist ; extra == 'testing' + - virtualenv ; extra == 'testing' + requires_python: '>=3.9' +- conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.14.3-h32b2ec7_101_cp314.conda + build_number: 101 + sha256: cb0628c5f1732f889f53a877484da98f5a0e0f47326622671396fb4f2b0cd6bd + md5: c014ad06e60441661737121d3eae8a60 + depends: + - __glibc >=2.17,<3.0.a0 + - bzip2 >=1.0.8,<2.0a0 + - ld_impl_linux-64 >=2.36.1 + - libexpat >=2.7.3,<3.0a0 + - libffi >=3.5.2,<3.6.0a0 + - libgcc >=14 + - liblzma >=5.8.2,<6.0a0 + - libmpdec >=4.0.0,<5.0a0 + - libsqlite >=3.51.2,<4.0a0 + - libuuid >=2.41.3,<3.0a0 + - libzlib >=1.3.1,<2.0a0 + - ncurses >=6.5,<7.0a0 + - openssl >=3.5.5,<4.0a0 + - python_abi 3.14.* *_cp314 + - readline >=8.3,<9.0a0 + - tk >=8.6.13,<8.7.0a0 + - tzdata + - zstd >=1.5.7,<1.6.0a0 + license: Python-2.0 + purls: [] + size: 36702440 + timestamp: 1770675584356 + python_site_packages_path: lib/python3.14/site-packages +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/python-3.14.3-hb06a95a_101_cp314.conda + build_number: 101 + sha256: 87e9dff5646aba87cecfbc08789634c855871a7325169299d749040b0923a356 + md5: 205011b36899ff0edf41b3db0eda5a44 + depends: + - bzip2 >=1.0.8,<2.0a0 + - ld_impl_linux-aarch64 >=2.36.1 + - libexpat >=2.7.3,<3.0a0 + - libffi >=3.5.2,<3.6.0a0 + - libgcc >=14 + - liblzma >=5.8.2,<6.0a0 + - libmpdec >=4.0.0,<5.0a0 + - libsqlite >=3.51.2,<4.0a0 + - libuuid >=2.41.3,<3.0a0 + - libzlib >=1.3.1,<2.0a0 + - ncurses >=6.5,<7.0a0 + - openssl >=3.5.5,<4.0a0 + - python_abi 3.14.* *_cp314 + - readline >=8.3,<9.0a0 + - tk >=8.6.13,<8.7.0a0 + - tzdata + - zstd >=1.5.7,<1.6.0a0 + license: Python-2.0 + purls: [] + size: 37305578 + timestamp: 1770674395875 + python_site_packages_path: lib/python3.14/site-packages +- conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.14-8_cp314.conda + build_number: 8 + sha256: ad6d2e9ac39751cc0529dd1566a26751a0bf2542adb0c232533d32e176e21db5 + md5: 0539938c55b6b1a59b560e843ad864a4 + constrains: + - python 3.14.* *_cp314 + license: BSD-3-Clause + license_family: BSD + purls: [] + size: 6989 + timestamp: 1752805904792 +- pypi: https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl + name: pyyaml + version: 6.0.3 + sha256: c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5 + requires_python: '>=3.8' +- pypi: https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl + name: pyyaml + version: 6.0.3 + sha256: 501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7 + requires_python: '>=3.8' +- conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.3-h853b02a_0.conda + sha256: 12ffde5a6f958e285aa22c191ca01bbd3d6e710aa852e00618fa6ddc59149002 + md5: d7d95fc8287ea7bf33e0e7116d2b95ec + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - ncurses >=6.5,<7.0a0 + license: GPL-3.0-only + license_family: GPL + purls: [] + size: 345073 + timestamp: 1765813471974 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/readline-8.3-hb682ff5_0.conda + sha256: fe695f9d215e9a2e3dd0ca7f56435ab4df24f5504b83865e3d295df36e88d216 + md5: 3d49cad61f829f4f0e0611547a9cda12 + depends: + - libgcc >=14 + - ncurses >=6.5,<7.0a0 + license: GPL-3.0-only + license_family: GPL + purls: [] + size: 357597 + timestamp: 1765815673644 +- pypi: https://files.pythonhosted.org/packages/48/11/690d75f3fd6278fe55fff7c9eb429c92d207e14b25d1cae4064a32677029/ruff-0.15.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl + name: ruff + version: 0.15.9 + sha256: 9439a342adb8725f32f92732e2bafb6d5246bd7a5021101166b223d312e8fc59 + requires_python: '>=3.7' +- pypi: https://files.pythonhosted.org/packages/ff/6b/a1548ac378a78332a4c3dcf4a134c2475a36d2a22ddfa272acd574140b50/ruff-0.15.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + name: ruff + version: 0.15.9 + sha256: 2b0c7c341f68adb01c488c3b7d4b49aa8ea97409eae6462d860a79cf55f431b6 + requires_python: '>=3.7' +- conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_h366c992_103.conda + sha256: cafeec44494f842ffeca27e9c8b0c27ed714f93ac77ddadc6aaf726b5554ebac + md5: cffd3bdd58090148f4cfcd831f4b26ab + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libzlib >=1.3.1,<2.0a0 + constrains: + - xorg-libx11 >=1.8.12,<2.0a0 + license: TCL + license_family: BSD + purls: [] + size: 3301196 + timestamp: 1769460227866 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/tk-8.6.13-noxft_h0dc03b3_103.conda + sha256: e25c314b52764219f842b41aea2c98a059f06437392268f09b03561e4f6e5309 + md5: 7fc6affb9b01e567d2ef1d05b84aa6ed + depends: + - libgcc >=14 + - libzlib >=1.3.1,<2.0a0 + constrains: + - xorg-libx11 >=1.8.12,<2.0a0 + license: TCL + license_family: BSD + purls: [] + size: 3368666 + timestamp: 1769464148928 +- conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda + sha256: 1d30098909076af33a35017eed6f2953af1c769e273a0626a04722ac4acaba3c + md5: ad659d0a2b3e47e38d829aa8cad2d610 + license: LicenseRef-Public-Domain + purls: [] + size: 119135 + timestamp: 1767016325805 +- conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda + sha256: 68f0206ca6e98fea941e5717cec780ed2873ffabc0e1ed34428c061e2c6268c7 + md5: 4a13eeac0b5c8e5b8ab496e6c4ddd829 + depends: + - __glibc >=2.17,<3.0.a0 + - libzlib >=1.3.1,<2.0a0 + license: BSD-3-Clause + license_family: BSD + purls: [] + size: 601375 + timestamp: 1764777111296 +- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/zstd-1.5.7-h85ac4a6_6.conda + sha256: 569990cf12e46f9df540275146da567d9c618c1e9c7a0bc9d9cfefadaed20b75 + md5: c3655f82dcea2aa179b291e7099c1fcc + depends: + - libzlib >=1.3.1,<2.0a0 + license: BSD-3-Clause + license_family: BSD + purls: [] + size: 614429 + timestamp: 1764777145593 From 3bc6e64af6b204c6339b4dcb423cddd54dc7a786 Mon Sep 17 00:00:00 2001 From: Luis Date: Thu, 16 Apr 2026 00:14:25 +0200 Subject: [PATCH 41/54] refactor(python-sdk): callback subscribers use registry.decode() instead of msg_class Co-Authored-By: Claude Sonnet 4.6 --- python-sdk/bubbaloop_sdk/subscriber.py | 65 ++++++-------------------- 1 file changed, 14 insertions(+), 51 deletions(-) diff --git a/python-sdk/bubbaloop_sdk/subscriber.py b/python-sdk/bubbaloop_sdk/subscriber.py index 102a571..f706455 100644 --- a/python-sdk/bubbaloop_sdk/subscriber.py +++ b/python-sdk/bubbaloop_sdk/subscriber.py @@ -94,39 +94,20 @@ def recv(self) -> bytes: class CallbackSubscriber(_BaseSubscriber): """Callback-based subscriber — Zenoh calls ``handler`` from its internal thread. - No loop required from the caller. Callbacks are invoked **serially** on Zenoh's - single internal thread per session — if your handler is slow it will delay every - other subscriber and queryable on the same session. Use ``subscriber_callback_async()`` - for slow work. - - ``handler`` receives a decoded message (``msg_class.FromString(payload)``) if - ``msg_class`` is provided, or raw ``bytes`` otherwise. + ``handler`` receives a decoded message: protobuf object, ``dict`` (JSON), + or raw ``bytes`` — determined automatically by the sample's encoding header + via the shared :class:`~bubbaloop_sdk.schema_registry.SchemaRegistry`. **Threading contract:** ``handler`` runs on Zenoh's internal thread. - Protect shared state with a lock if accessed from other threads:: - - lock = threading.Lock() - last_value = None - - def on_msg(msg): - nonlocal last_value - with lock: - last_value = msg - - sub = ctx.subscriber_callback("sensor/data", on_msg, SensorData) + Protect shared state with a lock if accessed from other threads. Call ``undeclare()`` when done to stop receiving samples. """ - def __init__(self, session: zenoh.Session, topic: str, handler, msg_class=None): - def _wrap(sample: zenoh.Sample) -> None: - payload = bytes(sample.payload.to_bytes()) - if msg_class is not None and hasattr(msg_class, "FromString"): - handler(msg_class.FromString(payload)) - else: - handler(payload) - - self._sub = session.declare_subscriber(topic, _wrap) + def __init__(self, session: zenoh.Session, topic: str, handler, registry): + self._sub = session.declare_subscriber( + topic, lambda sample: handler(registry.decode(sample)) + ) self._undeclared = False @@ -149,42 +130,24 @@ def __init__(self, session: zenoh.Session, key_expr: str, handler): class CallbackSubscriberAsync(_BaseSubscriber): """Callback subscriber that runs ``handler`` in a ``ThreadPoolExecutor``. - **Use this when your handler does slow work** (database writes, hardware reads, - HTTP calls). Zenoh uses a single internal thread for all callbacks — if a handler - blocks, ALL other subscribers and queryables on the same session are delayed - until it returns. ``CallbackSubscriberAsync`` fixes this by submitting the - handler to a thread pool immediately and returning, freeing Zenoh's thread:: + ``handler`` receives auto-decoded messages (proto, dict, or bytes). + Zenoh's internal thread is freed instantly; the handler runs in a thread pool. - # PROBLEM: on_insert blocks Zenoh's thread for 200ms per message - sub = ctx.subscriber_callback("data", on_insert) - - # SOLUTION: handler runs in thread pool, Zenoh thread is free instantly - sub = ctx.subscriber_callback_async("data", on_insert) - - **Threading contract:** multiple invocations of ``handler`` may run concurrently - if messages arrive faster than the handler processes them. Protect shared state - with locks. + **Threading contract:** multiple invocations of ``handler`` may run concurrently. + Protect shared state with locks. Call ``undeclare()`` when done to stop receiving samples. """ - def __init__(self, session: zenoh.Session, topic: str, handler, msg_class=None, max_workers: int = 4): + def __init__(self, session: zenoh.Session, topic: str, handler, registry, max_workers: int = 4): self._executor = concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) self._closing = threading.Event() def _wrap(sample: zenoh.Sample) -> None: if self._closing.is_set(): return - payload = bytes(sample.payload.to_bytes()) - - def _decode_and_call(): - if msg_class is not None and hasattr(msg_class, "FromString"): - handler(msg_class.FromString(payload)) - else: - handler(payload) - try: - self._executor.submit(_decode_and_call) + self._executor.submit(handler, registry.decode(sample)) except RuntimeError: pass # executor already shut down — drop the message From 08eff64e1d41f76691ff08021dca4312a7538530 Mon Sep 17 00:00:00 2001 From: Luis Date: Thu, 16 Apr 2026 00:16:54 +0200 Subject: [PATCH 42/54] refactor(python-sdk): subscriber_callback methods drop msg_class, pass schema_registry Co-Authored-By: Claude Sonnet 4.6 --- python-sdk/bubbaloop_sdk/context.py | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/python-sdk/bubbaloop_sdk/context.py b/python-sdk/bubbaloop_sdk/context.py index 42251c9..04cd403 100644 --- a/python-sdk/bubbaloop_sdk/context.py +++ b/python-sdk/bubbaloop_sdk/context.py @@ -196,15 +196,19 @@ def subscribe_raw(self, suffix: str, local: bool = False) -> RawSubscriber: # Callback Subscribers # ------------------------------------------------------------------ - def subscriber_callback(self, suffix: str, handler, msg_class=None) -> CallbackSubscriber: + def subscriber_callback(self, suffix: str, handler) -> CallbackSubscriber: """Callback subscriber at ``topic(suffix)``. - ``handler`` is called from Zenoh's internal thread each time a sample - arrives. For slow handlers (I/O, DB), use ``subscriber_callback_async()``. + ``handler`` receives auto-decoded messages (proto, dict, or bytes). + Called from Zenoh's internal thread. For slow handlers, use + ``subscriber_callback_async()``. """ + from .schema_registry import SchemaRegistry from .subscriber import CallbackSubscriber - return CallbackSubscriber(self.session, self.topic(suffix), handler, msg_class) + if not hasattr(self, "_schema_registry"): + self._schema_registry = SchemaRegistry(self.session) + return CallbackSubscriber(self.session, self.topic(suffix), handler, self._schema_registry) def subscriber_raw_callback(self, key_expr: str, handler) -> RawCallbackSubscriber: """Callback subscriber at a literal key expression. @@ -216,17 +220,22 @@ def subscriber_raw_callback(self, key_expr: str, handler) -> RawCallbackSubscrib return RawCallbackSubscriber(self.session, key_expr, handler) def subscriber_callback_async( - self, suffix: str, handler, msg_class=None, max_workers: int = 4 + self, suffix: str, handler, max_workers: int = 4 ) -> CallbackSubscriberAsync: """Callback subscriber at ``topic(suffix)`` with handler in a thread pool. - Use when ``handler`` does slow work (database writes, hardware I/O, network - calls). Zenoh's internal thread is freed immediately; the handler runs in a + ``handler`` receives auto-decoded messages (proto, dict, or bytes). + Zenoh's internal thread is freed immediately; the handler runs in a ``ThreadPoolExecutor`` with ``max_workers`` threads. """ + from .schema_registry import SchemaRegistry from .subscriber import CallbackSubscriberAsync - return CallbackSubscriberAsync(self.session, self.topic(suffix), handler, msg_class, max_workers) + if not hasattr(self, "_schema_registry"): + self._schema_registry = SchemaRegistry(self.session) + return CallbackSubscriberAsync( + self.session, self.topic(suffix), handler, self._schema_registry, max_workers + ) def subscriber_raw_callback_async(self, key_expr: str, handler, max_workers: int = 4) -> RawCallbackSubscriberAsync: """Raw callback subscriber at a literal key expression with handler in a thread pool.""" From 34b4e80d769130893aa2cc6b97c9dbe77ebbc793 Mon Sep 17 00:00:00 2001 From: Luis Date: Thu, 16 Apr 2026 00:19:10 +0200 Subject: [PATCH 43/54] fix(python-sdk): remove duplicate heartbeat and fix wrong signature in run_node MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The heartbeat was started twice — once with the correct signature (return value discarded) and once with the old signature including ctx.scope, which would crash at runtime. Keep a single call inside the try block so the thread is properly joined on shutdown. Co-Authored-By: Claude Opus 4.6 (1M context) --- python-sdk/bubbaloop_sdk/node.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/python-sdk/bubbaloop_sdk/node.py b/python-sdk/bubbaloop_sdk/node.py index 2d85745..a4f34a5 100644 --- a/python-sdk/bubbaloop_sdk/node.py +++ b/python-sdk/bubbaloop_sdk/node.py @@ -51,14 +51,12 @@ def run_node(node_class) -> None: ctx = NodeContext.connect(endpoint=args.endpoint, instance_name=instance_name) - start_health_heartbeat(ctx.session, ctx.machine_id, instance_name, ctx._shutdown) - log.info("Health heartbeat: bubbaloop/global/%s/%s/health", ctx.machine_id, instance_name) - heartbeat = None try: node = node_class(ctx, config) log.info("Initialized. Running…") - heartbeat = start_health_heartbeat(ctx.session, ctx.scope, ctx.machine_id, instance_name, ctx._shutdown) + heartbeat = start_health_heartbeat(ctx.session, ctx.machine_id, instance_name, ctx._shutdown) + log.info("Health heartbeat: bubbaloop/global/%s/%s/health", ctx.machine_id, instance_name) node.run() except KeyboardInterrupt: pass From fd3b4f81ce2eba70b2307c01b348080dc30ff206 Mon Sep 17 00:00:00 2001 From: Luis Date: Thu, 16 Apr 2026 00:21:29 +0200 Subject: [PATCH 44/54] test(python-sdk): update callback tests to use mock registry instead of msg_class Co-Authored-By: Claude Opus 4.6 (1M context) --- python-sdk/tests/test_context.py | 60 ++++++++++++++++++-------------- 1 file changed, 34 insertions(+), 26 deletions(-) diff --git a/python-sdk/tests/test_context.py b/python-sdk/tests/test_context.py index 9ff5443..4706202 100644 --- a/python-sdk/tests/test_context.py +++ b/python-sdk/tests/test_context.py @@ -262,13 +262,12 @@ def handler(msg): received.append(msg) called.set() - sub = CallbackSubscriberAsync(mock_session, "test/topic", handler) + sub = CallbackSubscriberAsync(mock_session, "test/topic", handler, MagicMock()) sub.undeclare() # Simulate a late-arriving Zenoh callback after undeclare. # _closing is already set so _wrap returns early — handler is never submitted. fake_sample = MagicMock() - fake_sample.payload.to_bytes.return_value = b"\xff" captured_handler[0](fake_sample) # must not raise # Give the executor no chance to run (it's shut down); assert immediately. @@ -339,8 +338,8 @@ def handler(query): # --------------------------------------------------------------------------- -def test_callback_subscriber_calls_handler_with_bytes(): - """Handler receives raw bytes when no msg_class provided.""" +def test_callback_subscriber_calls_handler_with_decoded(): + """Handler receives whatever registry.decode() returns.""" from bubbaloop_sdk.subscriber import CallbackSubscriber mock_session = MagicMock() @@ -351,19 +350,23 @@ def fake_declare(topic, handler): return MagicMock() mock_session.declare_subscriber.side_effect = fake_declare + + mock_registry = MagicMock() + mock_registry.decode.return_value = {"temperature": 22.5} + received = [] - sub = CallbackSubscriber(mock_session, "test/topic", lambda msg: received.append(msg)) + sub = CallbackSubscriber(mock_session, "test/topic", lambda msg: received.append(msg), mock_registry) fake_sample = MagicMock() - fake_sample.payload.to_bytes.return_value = b"\xde\xad" captured_handler[0](fake_sample) - assert received == [b"\xde\xad"] + assert received == [{"temperature": 22.5}] + mock_registry.decode.assert_called_once_with(fake_sample) sub.undeclare() -def test_callback_subscriber_decodes_proto(): - """Handler receives decoded proto when msg_class provided.""" +def test_callback_subscriber_passes_sample_to_registry(): + """CallbackSubscriber passes the zenoh.Sample to registry.decode().""" from bubbaloop_sdk.subscriber import CallbackSubscriber mock_session = MagicMock() @@ -375,17 +378,17 @@ def fake_declare(topic, handler): mock_session.declare_subscriber.side_effect = fake_declare - fake_msg_class = MagicMock() - fake_msg_class.FromString.return_value = "decoded_proto" + mock_registry = MagicMock() + mock_registry.decode.return_value = "decoded_proto" + received = [] - sub = CallbackSubscriber(mock_session, "test/topic", lambda msg: received.append(msg), msg_class=fake_msg_class) + sub = CallbackSubscriber(mock_session, "test/topic", lambda msg: received.append(msg), mock_registry) fake_sample = MagicMock() - fake_sample.payload.to_bytes.return_value = b"\x01" captured_handler[0](fake_sample) assert received == ["decoded_proto"] - fake_msg_class.FromString.assert_called_once_with(b"\x01") + mock_registry.decode.assert_called_once_with(fake_sample) sub.undeclare() @@ -396,7 +399,7 @@ def test_callback_subscriber_undeclare(): mock_session = MagicMock() mock_sub = MagicMock() mock_session.declare_subscriber.return_value = mock_sub - sub = CallbackSubscriber(mock_session, "test/topic", lambda msg: None) + sub = CallbackSubscriber(mock_session, "test/topic", lambda msg: None, MagicMock()) sub.undeclare() mock_sub.undeclare.assert_called_once() @@ -446,7 +449,7 @@ def test_callback_subscriber_undeclare_is_idempotent(): mock_session = MagicMock() mock_session.declare_subscriber.return_value = MagicMock() - sub = CallbackSubscriber(mock_session, "test/topic", lambda msg: None) + sub = CallbackSubscriber(mock_session, "test/topic", lambda msg: None, MagicMock()) sub.undeclare() sub.undeclare() # second call is a no-op mock_session.declare_subscriber.return_value.undeclare.assert_called_once() @@ -459,7 +462,7 @@ def test_callback_subscriber_async_undeclare_is_idempotent(): mock_session = MagicMock() mock_sub = MagicMock() mock_session.declare_subscriber.return_value = mock_sub - sub = CallbackSubscriberAsync(mock_session, "test/topic", lambda msg: None) + sub = CallbackSubscriberAsync(mock_session, "test/topic", lambda msg: None, MagicMock()) sub.undeclare() sub.undeclare() # second call is a no-op mock_sub.undeclare.assert_called_once() @@ -484,6 +487,10 @@ def fake_declare(topic, handler): return MagicMock() mock_session.declare_subscriber.side_effect = fake_declare + + mock_registry = MagicMock() + mock_registry.decode.return_value = b"\xca\xfe" + received = [] event = threading.Event() @@ -491,10 +498,9 @@ def slow_handler(msg): received.append(msg) event.set() - sub = CallbackSubscriberAsync(mock_session, "test/topic", slow_handler) + sub = CallbackSubscriberAsync(mock_session, "test/topic", slow_handler, mock_registry) fake_sample = MagicMock() - fake_sample.payload.to_bytes.return_value = b"\xca\xfe" captured_handler[0](fake_sample) assert event.wait(timeout=2.0), "handler was not called within 2s" @@ -502,8 +508,8 @@ def slow_handler(msg): sub.undeclare() -def test_callback_subscriber_async_decodes_proto(): - """Handler receives decoded proto when msg_class provided.""" +def test_callback_subscriber_async_passes_sample_to_registry(): + """CallbackSubscriberAsync passes the zenoh.Sample to registry.decode().""" import threading from bubbaloop_sdk.subscriber import CallbackSubscriberAsync @@ -517,8 +523,8 @@ def fake_declare(topic, handler): mock_session.declare_subscriber.side_effect = fake_declare - fake_msg_class = MagicMock() - fake_msg_class.FromString.return_value = "decoded" + mock_registry = MagicMock() + mock_registry.decode.return_value = "decoded" received = [] event = threading.Event() @@ -526,14 +532,14 @@ def handler(msg): received.append(msg) event.set() - sub = CallbackSubscriberAsync(mock_session, "test/topic", handler, msg_class=fake_msg_class) + sub = CallbackSubscriberAsync(mock_session, "test/topic", handler, mock_registry) fake_sample = MagicMock() - fake_sample.payload.to_bytes.return_value = b"\x01" captured_handler[0](fake_sample) assert event.wait(timeout=2.0) assert received == ["decoded"] + mock_registry.decode.assert_called_once_with(fake_sample) sub.undeclare() @@ -575,7 +581,7 @@ def test_callback_subscriber_async_undeclare(): mock_session = MagicMock() mock_sub = MagicMock() mock_session.declare_subscriber.return_value = mock_sub - sub = CallbackSubscriberAsync(mock_session, "test/topic", lambda msg: None) + sub = CallbackSubscriberAsync(mock_session, "test/topic", lambda msg: None, MagicMock()) sub.undeclare() mock_sub.undeclare.assert_called_once() @@ -755,6 +761,7 @@ def test_async_queryable_undeclare(): def test_subscriber_callback_uses_topic_prefix(): """subscriber_callback() declares at topic(suffix).""" ctx = _make_context("bot") + ctx._schema_registry = MagicMock() ctx.subscriber_callback("sensor/data", lambda msg: None) called_topic = ctx.session.declare_subscriber.call_args[0][0] assert called_topic == "bubbaloop/global/bot/sensor/data" @@ -771,6 +778,7 @@ def test_subscriber_raw_callback_uses_literal_key_expr(): def test_subscriber_callback_async_uses_topic_prefix(): """subscriber_callback_async() declares at topic(suffix).""" ctx = _make_context("bot") + ctx._schema_registry = MagicMock() sub = ctx.subscriber_callback_async("sensor/data", lambda msg: None) try: called_topic = ctx.session.declare_subscriber.call_args[0][0] From 817d137ec796a2846c1aa699d9192094f3b36b3b Mon Sep 17 00:00:00 2001 From: Luis Date: Thu, 16 Apr 2026 00:23:46 +0200 Subject: [PATCH 45/54] style(python-sdk): ruff format subscriber.py and context.py Co-Authored-By: Claude Opus 4.6 (1M context) --- python-sdk/bubbaloop_sdk/context.py | 8 ++------ python-sdk/bubbaloop_sdk/subscriber.py | 4 +--- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/python-sdk/bubbaloop_sdk/context.py b/python-sdk/bubbaloop_sdk/context.py index 04cd403..768fa06 100644 --- a/python-sdk/bubbaloop_sdk/context.py +++ b/python-sdk/bubbaloop_sdk/context.py @@ -219,9 +219,7 @@ def subscriber_raw_callback(self, key_expr: str, handler) -> RawCallbackSubscrib return RawCallbackSubscriber(self.session, key_expr, handler) - def subscriber_callback_async( - self, suffix: str, handler, max_workers: int = 4 - ) -> CallbackSubscriberAsync: + def subscriber_callback_async(self, suffix: str, handler, max_workers: int = 4) -> CallbackSubscriberAsync: """Callback subscriber at ``topic(suffix)`` with handler in a thread pool. ``handler`` receives auto-decoded messages (proto, dict, or bytes). @@ -233,9 +231,7 @@ def subscriber_callback_async( if not hasattr(self, "_schema_registry"): self._schema_registry = SchemaRegistry(self.session) - return CallbackSubscriberAsync( - self.session, self.topic(suffix), handler, self._schema_registry, max_workers - ) + return CallbackSubscriberAsync(self.session, self.topic(suffix), handler, self._schema_registry, max_workers) def subscriber_raw_callback_async(self, key_expr: str, handler, max_workers: int = 4) -> RawCallbackSubscriberAsync: """Raw callback subscriber at a literal key expression with handler in a thread pool.""" diff --git a/python-sdk/bubbaloop_sdk/subscriber.py b/python-sdk/bubbaloop_sdk/subscriber.py index f706455..875edfe 100644 --- a/python-sdk/bubbaloop_sdk/subscriber.py +++ b/python-sdk/bubbaloop_sdk/subscriber.py @@ -105,9 +105,7 @@ class CallbackSubscriber(_BaseSubscriber): """ def __init__(self, session: zenoh.Session, topic: str, handler, registry): - self._sub = session.declare_subscriber( - topic, lambda sample: handler(registry.decode(sample)) - ) + self._sub = session.declare_subscriber(topic, lambda sample: handler(registry.decode(sample))) self._undeclared = False From 70bb99a1e152f95c39c276208a03fd026428f5f2 Mon Sep 17 00:00:00 2001 From: Luis Date: Thu, 16 Apr 2026 00:24:51 +0200 Subject: [PATCH 46/54] docs(python-sdk): update callback examples to reflect auto-decode via SchemaRegistry Co-Authored-By: Claude Sonnet 4.6 --- python-sdk/CLAUDE.md | 4 ++-- python-sdk/README.md | 11 +++++------ 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/python-sdk/CLAUDE.md b/python-sdk/CLAUDE.md index d883867..a00a42d 100644 --- a/python-sdk/CLAUDE.md +++ b/python-sdk/CLAUDE.md @@ -75,7 +75,7 @@ class CallbackSubscriber: session: Active Zenoh session. topic: Key expression to subscribe to. handler: Callable invoked with each decoded message. - msg_class: Protobuf message class for decoding, or None for raw bytes. + registry: SchemaRegistry for auto-decoding samples by encoding header. """ def __init__( @@ -83,7 +83,7 @@ class CallbackSubscriber: session: zenoh.Session, topic: str, handler: Callable, - msg_class=None, + registry, ): ... def undeclare(self) -> None: diff --git a/python-sdk/README.md b/python-sdk/README.md index d7e6c66..300bb09 100644 --- a/python-sdk/README.md +++ b/python-sdk/README.md @@ -72,14 +72,13 @@ for msg in sub: # auto-decoded: proto, dict, or bytes ```python from bubbaloop_sdk import NodeContext -from my_protos_pb2 import SensorData ctx = NodeContext.connect() -def on_sensor(msg: SensorData): +def on_sensor(msg): print(f"received: {msg.value}") -sub = ctx.subscriber_callback("sensor/data", on_sensor, SensorData) +sub = ctx.subscriber_callback("sensor/data", on_sensor) ctx.wait_shutdown() # block until SIGINT/SIGTERM sub.undeclare() ctx.close() @@ -90,7 +89,7 @@ HTTP calls) — it runs the handler in a thread pool and returns immediately, freeing Zenoh's internal thread: ```python -sub = ctx.subscriber_callback_async("sensor/data", on_sensor, SensorData) +sub = ctx.subscriber_callback_async("sensor/data", on_sensor) ``` ### Queryable (respond to get requests) @@ -149,9 +148,9 @@ variants for slow work. | Method | Description | |---|---| -| `ctx.subscriber_callback(suffix, handler, msg_class=None)` | Decoded message to handler | +| `ctx.subscriber_callback(suffix, handler)` | Decoded message to handler | | `ctx.subscriber_raw_callback(key_expr, handler)` | Raw `zenoh.Sample` to handler | -| `ctx.subscriber_callback_async(suffix, handler, msg_class=None, max_workers=4)` | Handler in thread pool | +| `ctx.subscriber_callback_async(suffix, handler, max_workers=4)` | Handler in thread pool | | `ctx.subscriber_raw_callback_async(key_expr, handler, max_workers=4)` | Raw sample; handler in thread pool | #### Queryables From 48036022cf279f8855e2fc9221452203f58be634 Mon Sep 17 00:00:00 2001 From: Luis Date: Thu, 16 Apr 2026 00:29:36 +0200 Subject: [PATCH 47/54] refactor(python-sdk): type-annotate handler and registry params in subscriber classes Co-Authored-By: Claude Opus 4.6 (1M context) --- python-sdk/bubbaloop_sdk/subscriber.py | 38 +++++++++++++++++++++----- 1 file changed, 31 insertions(+), 7 deletions(-) diff --git a/python-sdk/bubbaloop_sdk/subscriber.py b/python-sdk/bubbaloop_sdk/subscriber.py index 875edfe..51b596d 100644 --- a/python-sdk/bubbaloop_sdk/subscriber.py +++ b/python-sdk/bubbaloop_sdk/subscriber.py @@ -4,9 +4,14 @@ import concurrent.futures import threading +from collections.abc import Callable +from typing import TYPE_CHECKING, Any import zenoh +if TYPE_CHECKING: + from .schema_registry import SchemaRegistry + class _BaseSubscriber: """Shared iterator protocol and cleanup for all subscriber types.""" @@ -60,7 +65,7 @@ class ProtoSubscriber(_BaseSubscriber): tensor = torch.frombuffer(msg.data, dtype=torch.uint8) """ - def __init__(self, session: zenoh.Session, topic: str, registry): + def __init__(self, session: zenoh.Session, topic: str, registry: SchemaRegistry): super().__init__(session, topic) self._registry = registry @@ -104,7 +109,7 @@ class CallbackSubscriber(_BaseSubscriber): Call ``undeclare()`` when done to stop receiving samples. """ - def __init__(self, session: zenoh.Session, topic: str, handler, registry): + def __init__(self, session: zenoh.Session, topic: str, handler: Callable[[Any], None], registry: SchemaRegistry): self._sub = session.declare_subscriber(topic, lambda sample: handler(registry.decode(sample))) self._undeclared = False @@ -120,7 +125,7 @@ class RawCallbackSubscriber(_BaseSubscriber): Call ``undeclare()`` when done to stop receiving samples. """ - def __init__(self, session: zenoh.Session, key_expr: str, handler): + def __init__(self, session: zenoh.Session, key_expr: str, handler: Callable[[zenoh.Sample], None]): self._sub = session.declare_subscriber(key_expr, handler) self._undeclared = False @@ -137,7 +142,14 @@ class CallbackSubscriberAsync(_BaseSubscriber): Call ``undeclare()`` when done to stop receiving samples. """ - def __init__(self, session: zenoh.Session, topic: str, handler, registry, max_workers: int = 4): + def __init__( + self, + session: zenoh.Session, + topic: str, + handler: Callable[[Any], None], + registry: SchemaRegistry, + max_workers: int = 4, + ): self._executor = concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) self._closing = threading.Event() @@ -170,7 +182,13 @@ class RawCallbackSubscriberAsync(_BaseSubscriber): Call ``undeclare()`` when done to stop receiving samples. """ - def __init__(self, session: zenoh.Session, key_expr: str, handler, max_workers: int = 4): + def __init__( + self, + session: zenoh.Session, + key_expr: str, + handler: Callable[[zenoh.Sample], None], + max_workers: int = 4, + ): self._executor = concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) self._closing = threading.Event() @@ -217,12 +235,18 @@ def on_db_query(query: zenoh.Query) -> None: Call ``undeclare()`` when done to stop receiving queries and release the thread pool. """ - def __init__(self, session: zenoh.Session, key_expr: str, handler, max_workers: int = 4): + def __init__( + self, + session: zenoh.Session, + key_expr: str, + handler: Callable[[zenoh.Query], None], + max_workers: int = 4, + ): self._executor = concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) self._closing = threading.Event() self._undeclared = False - def _wrap(query) -> None: + def _wrap(query: zenoh.Query) -> None: if self._closing.is_set(): return try: From 9f489d2f6745e7afaea4f628545680c8a0b8c0fe Mon Sep 17 00:00:00 2001 From: Luis Date: Thu, 16 Apr 2026 00:38:35 +0200 Subject: [PATCH 48/54] test(python-sdk): update tests for merged callback subscriber classes Replace CallbackSubscriberAsync/RawCallbackSubscriberAsync references with CallbackSubscriber/RawCallbackSubscriber using max_workers=4, and update subscriber_callback_async/subscriber_raw_callback_async calls to use the merged max_workers parameter form. Co-Authored-By: Claude Sonnet 4.6 --- python-sdk/tests/test_context.py | 78 ++++++++++++++++---------------- 1 file changed, 39 insertions(+), 39 deletions(-) diff --git a/python-sdk/tests/test_context.py b/python-sdk/tests/test_context.py index 4706202..c8736a8 100644 --- a/python-sdk/tests/test_context.py +++ b/python-sdk/tests/test_context.py @@ -106,11 +106,11 @@ def test_import_callback_subscribers(): assert RawCallbackSubscriber is not None -def test_import_callback_subscribers_async(): - from bubbaloop_sdk import CallbackSubscriberAsync, RawCallbackSubscriberAsync +def test_import_callback_subscribers_with_workers(): + from bubbaloop_sdk import CallbackSubscriber, RawCallbackSubscriber - assert CallbackSubscriberAsync is not None - assert RawCallbackSubscriberAsync is not None + assert CallbackSubscriber is not None + assert RawCallbackSubscriber is not None def test_import_async_queryable(): @@ -239,13 +239,13 @@ def test_raw_subscriber_undeclare_is_idempotent(): # --------------------------------------------------------------------------- -# CallbackSubscriberAsync / RawCallbackSubscriberAsync — _closing flag +# CallbackSubscriber(max_workers=4) / RawCallbackSubscriber(max_workers=4) — _closing flag # --------------------------------------------------------------------------- -def test_callback_subscriber_async_drops_after_undeclare(): +def test_callback_subscriber_with_workers_drops_after_undeclare(): """Callbacks arriving after undeclare() are silently dropped.""" - from bubbaloop_sdk.subscriber import CallbackSubscriberAsync + from bubbaloop_sdk.subscriber import CallbackSubscriber mock_session = MagicMock() captured_handler = [] @@ -262,7 +262,7 @@ def handler(msg): received.append(msg) called.set() - sub = CallbackSubscriberAsync(mock_session, "test/topic", handler, MagicMock()) + sub = CallbackSubscriber(mock_session, "test/topic", handler, MagicMock(), max_workers=4) sub.undeclare() # Simulate a late-arriving Zenoh callback after undeclare. @@ -275,9 +275,9 @@ def handler(msg): assert received == [] -def test_raw_callback_subscriber_async_drops_after_undeclare(): +def test_raw_callback_subscriber_with_workers_drops_after_undeclare(): """Callbacks arriving after undeclare() are silently dropped.""" - from bubbaloop_sdk.subscriber import RawCallbackSubscriberAsync + from bubbaloop_sdk.subscriber import RawCallbackSubscriber mock_session = MagicMock() captured_handler = [] @@ -294,7 +294,7 @@ def handler(sample): received.append(sample) called.set() - sub = RawCallbackSubscriberAsync(mock_session, "test/**", handler) + sub = RawCallbackSubscriber(mock_session, "test/**", handler, max_workers=4) sub.undeclare() fake_sample = MagicMock() @@ -455,29 +455,29 @@ def test_callback_subscriber_undeclare_is_idempotent(): mock_session.declare_subscriber.return_value.undeclare.assert_called_once() -def test_callback_subscriber_async_undeclare_is_idempotent(): +def test_callback_subscriber_with_workers_undeclare_is_idempotent(): """undeclare() can be called twice without error.""" - from bubbaloop_sdk.subscriber import CallbackSubscriberAsync + from bubbaloop_sdk.subscriber import CallbackSubscriber mock_session = MagicMock() mock_sub = MagicMock() mock_session.declare_subscriber.return_value = mock_sub - sub = CallbackSubscriberAsync(mock_session, "test/topic", lambda msg: None, MagicMock()) + sub = CallbackSubscriber(mock_session, "test/topic", lambda msg: None, MagicMock(), max_workers=4) sub.undeclare() sub.undeclare() # second call is a no-op mock_sub.undeclare.assert_called_once() # --------------------------------------------------------------------------- -# CallbackSubscriberAsync +# CallbackSubscriber with max_workers (thread pool mode) # --------------------------------------------------------------------------- -def test_callback_subscriber_async_calls_handler_in_thread_pool(): - """Handler is called asynchronously via thread pool.""" +def test_callback_subscriber_with_workers_calls_handler_in_thread_pool(): + """Handler is called asynchronously via thread pool when max_workers is set.""" import threading - from bubbaloop_sdk.subscriber import CallbackSubscriberAsync + from bubbaloop_sdk.subscriber import CallbackSubscriber mock_session = MagicMock() captured_handler = [] @@ -498,7 +498,7 @@ def slow_handler(msg): received.append(msg) event.set() - sub = CallbackSubscriberAsync(mock_session, "test/topic", slow_handler, mock_registry) + sub = CallbackSubscriber(mock_session, "test/topic", slow_handler, mock_registry, max_workers=4) fake_sample = MagicMock() captured_handler[0](fake_sample) @@ -508,11 +508,11 @@ def slow_handler(msg): sub.undeclare() -def test_callback_subscriber_async_passes_sample_to_registry(): - """CallbackSubscriberAsync passes the zenoh.Sample to registry.decode().""" +def test_callback_subscriber_with_workers_passes_sample_to_registry(): + """CallbackSubscriber with max_workers passes the zenoh.Sample to registry.decode().""" import threading - from bubbaloop_sdk.subscriber import CallbackSubscriberAsync + from bubbaloop_sdk.subscriber import CallbackSubscriber mock_session = MagicMock() captured_handler = [] @@ -532,7 +532,7 @@ def handler(msg): received.append(msg) event.set() - sub = CallbackSubscriberAsync(mock_session, "test/topic", handler, mock_registry) + sub = CallbackSubscriber(mock_session, "test/topic", handler, mock_registry, max_workers=4) fake_sample = MagicMock() captured_handler[0](fake_sample) @@ -543,11 +543,11 @@ def handler(msg): sub.undeclare() -def test_raw_callback_subscriber_async_passes_sample(): - """RawCallbackSubscriberAsync handler receives raw zenoh.Sample.""" +def test_raw_callback_subscriber_with_workers_passes_sample(): + """RawCallbackSubscriber with max_workers handler receives raw zenoh.Sample.""" import threading - from bubbaloop_sdk.subscriber import RawCallbackSubscriberAsync + from bubbaloop_sdk.subscriber import RawCallbackSubscriber mock_session = MagicMock() captured_handler = [] @@ -564,7 +564,7 @@ def handler(sample): received.append(sample) event.set() - sub = RawCallbackSubscriberAsync(mock_session, "test/**", handler) + sub = RawCallbackSubscriber(mock_session, "test/**", handler, max_workers=4) fake_sample = MagicMock() captured_handler[0](fake_sample) @@ -574,26 +574,26 @@ def handler(sample): sub.undeclare() -def test_callback_subscriber_async_undeclare(): +def test_callback_subscriber_with_workers_undeclare(): """undeclare() shuts down executor and undeclares underlying sub.""" - from bubbaloop_sdk.subscriber import CallbackSubscriberAsync + from bubbaloop_sdk.subscriber import CallbackSubscriber mock_session = MagicMock() mock_sub = MagicMock() mock_session.declare_subscriber.return_value = mock_sub - sub = CallbackSubscriberAsync(mock_session, "test/topic", lambda msg: None, MagicMock()) + sub = CallbackSubscriber(mock_session, "test/topic", lambda msg: None, MagicMock(), max_workers=4) sub.undeclare() mock_sub.undeclare.assert_called_once() -def test_raw_callback_subscriber_async_undeclare(): +def test_raw_callback_subscriber_with_workers_undeclare(): """undeclare() shuts down executor and undeclares underlying sub.""" - from bubbaloop_sdk.subscriber import RawCallbackSubscriberAsync + from bubbaloop_sdk.subscriber import RawCallbackSubscriber mock_session = MagicMock() mock_sub = MagicMock() mock_session.declare_subscriber.return_value = mock_sub - sub = RawCallbackSubscriberAsync(mock_session, "test/**", lambda s: None) + sub = RawCallbackSubscriber(mock_session, "test/**", lambda s: None, max_workers=4) sub.undeclare() mock_sub.undeclare.assert_called_once() @@ -775,11 +775,11 @@ def test_subscriber_raw_callback_uses_literal_key_expr(): assert called_topic == "bubbaloop/**/health" -def test_subscriber_callback_async_uses_topic_prefix(): - """subscriber_callback_async() declares at topic(suffix).""" +def test_subscriber_callback_with_workers_uses_topic_prefix(): + """subscriber_callback() with max_workers declares at topic(suffix).""" ctx = _make_context("bot") ctx._schema_registry = MagicMock() - sub = ctx.subscriber_callback_async("sensor/data", lambda msg: None) + sub = ctx.subscriber_callback("sensor/data", lambda msg: None, max_workers=4) try: called_topic = ctx.session.declare_subscriber.call_args[0][0] assert called_topic == "bubbaloop/global/bot/sensor/data" @@ -787,10 +787,10 @@ def test_subscriber_callback_async_uses_topic_prefix(): sub.undeclare() -def test_subscriber_raw_callback_async_uses_literal_key_expr(): - """subscriber_raw_callback_async() declares at literal key expression.""" +def test_subscriber_raw_callback_with_workers_uses_literal_key_expr(): + """subscriber_raw_callback() with max_workers declares at literal key expression.""" ctx = _make_context("bot") - sub = ctx.subscriber_raw_callback_async("bubbaloop/**/health", lambda s: None) + sub = ctx.subscriber_raw_callback("bubbaloop/**/health", lambda s: None, max_workers=4) try: called_topic = ctx.session.declare_subscriber.call_args[0][0] assert called_topic == "bubbaloop/**/health" From 31b6fcc037dbf6ff4d4e6e98989054c6dea5d378 Mon Sep 17 00:00:00 2001 From: Luis Date: Thu, 16 Apr 2026 00:39:02 +0200 Subject: [PATCH 49/54] refactor(python-sdk): merge sync/async callback subscribers into one class each MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CallbackSubscriber(max_workers=None) replaces CallbackSubscriber + CallbackSubscriberAsync. RawCallbackSubscriber(max_workers=None) replaces RawCallbackSubscriber + RawCallbackSubscriberAsync. None = handler runs on Zenoh's thread (fast path). int = handler runs in a ThreadPoolExecutor. 6 subscriber classes → 4. 4 NodeContext methods → 2. Co-Authored-By: Claude Opus 4.6 (1M context) --- python-sdk/bubbaloop_sdk/__init__.py | 4 - python-sdk/bubbaloop_sdk/context.py | 43 +++----- python-sdk/bubbaloop_sdk/subscriber.py | 147 +++++++++++++------------ 3 files changed, 87 insertions(+), 107 deletions(-) diff --git a/python-sdk/bubbaloop_sdk/__init__.py b/python-sdk/bubbaloop_sdk/__init__.py index 768ffa3..a5110b8 100644 --- a/python-sdk/bubbaloop_sdk/__init__.py +++ b/python-sdk/bubbaloop_sdk/__init__.py @@ -15,17 +15,14 @@ from .subscriber import ( AsyncQueryable, CallbackSubscriber, - CallbackSubscriberAsync, ProtoSubscriber, RawCallbackSubscriber, - RawCallbackSubscriberAsync, RawSubscriber, ) __all__ = [ "AsyncQueryable", "CallbackSubscriber", - "CallbackSubscriberAsync", "GetSampleTimeout", "JsonPublisher", "NodeContext", @@ -34,7 +31,6 @@ "ProtoPublisher", "ProtoSubscriber", "RawCallbackSubscriber", - "RawCallbackSubscriberAsync", "RawPublisher", "RawSubscriber", "discover_nodes", diff --git a/python-sdk/bubbaloop_sdk/context.py b/python-sdk/bubbaloop_sdk/context.py index 768fa06..c144f95 100644 --- a/python-sdk/bubbaloop_sdk/context.py +++ b/python-sdk/bubbaloop_sdk/context.py @@ -28,10 +28,8 @@ from .subscriber import ( AsyncQueryable, CallbackSubscriber, - CallbackSubscriberAsync, ProtoSubscriber, RawCallbackSubscriber, - RawCallbackSubscriberAsync, RawSubscriber, ) @@ -196,48 +194,33 @@ def subscribe_raw(self, suffix: str, local: bool = False) -> RawSubscriber: # Callback Subscribers # ------------------------------------------------------------------ - def subscriber_callback(self, suffix: str, handler) -> CallbackSubscriber: - """Callback subscriber at ``topic(suffix)``. + def subscriber_callback(self, suffix: str, handler, max_workers: int | None = None) -> CallbackSubscriber: + """Callback subscriber at ``topic(suffix)`` with auto-decode. ``handler`` receives auto-decoded messages (proto, dict, or bytes). - Called from Zenoh's internal thread. For slow handlers, use - ``subscriber_callback_async()``. + + By default the handler runs on Zenoh's internal thread (fast path). + Pass ``max_workers`` to run the handler in a thread pool instead — + use this when the handler does slow work (DB writes, HTTP calls). """ from .schema_registry import SchemaRegistry from .subscriber import CallbackSubscriber if not hasattr(self, "_schema_registry"): self._schema_registry = SchemaRegistry(self.session) - return CallbackSubscriber(self.session, self.topic(suffix), handler, self._schema_registry) + return CallbackSubscriber(self.session, self.topic(suffix), handler, self._schema_registry, max_workers) - def subscriber_raw_callback(self, key_expr: str, handler) -> RawCallbackSubscriber: + def subscriber_raw_callback(self, key_expr: str, handler, max_workers: int | None = None) -> RawCallbackSubscriber: """Callback subscriber at a literal key expression. - ``handler`` receives raw ``zenoh.Sample`` objects from Zenoh's internal thread. - """ - from .subscriber import RawCallbackSubscriber - - return RawCallbackSubscriber(self.session, key_expr, handler) - - def subscriber_callback_async(self, suffix: str, handler, max_workers: int = 4) -> CallbackSubscriberAsync: - """Callback subscriber at ``topic(suffix)`` with handler in a thread pool. + ``handler`` receives raw ``zenoh.Sample`` objects. - ``handler`` receives auto-decoded messages (proto, dict, or bytes). - Zenoh's internal thread is freed immediately; the handler runs in a - ``ThreadPoolExecutor`` with ``max_workers`` threads. + By default the handler runs on Zenoh's internal thread. Pass + ``max_workers`` to run the handler in a thread pool instead. """ - from .schema_registry import SchemaRegistry - from .subscriber import CallbackSubscriberAsync - - if not hasattr(self, "_schema_registry"): - self._schema_registry = SchemaRegistry(self.session) - return CallbackSubscriberAsync(self.session, self.topic(suffix), handler, self._schema_registry, max_workers) - - def subscriber_raw_callback_async(self, key_expr: str, handler, max_workers: int = 4) -> RawCallbackSubscriberAsync: - """Raw callback subscriber at a literal key expression with handler in a thread pool.""" - from .subscriber import RawCallbackSubscriberAsync + from .subscriber import RawCallbackSubscriber - return RawCallbackSubscriberAsync(self.session, key_expr, handler, max_workers) + return RawCallbackSubscriber(self.session, key_expr, handler, max_workers) # ------------------------------------------------------------------ # Queryables diff --git a/python-sdk/bubbaloop_sdk/subscriber.py b/python-sdk/bubbaloop_sdk/subscriber.py index 51b596d..07a5b96 100644 --- a/python-sdk/bubbaloop_sdk/subscriber.py +++ b/python-sdk/bubbaloop_sdk/subscriber.py @@ -97,47 +97,22 @@ def recv(self) -> bytes: class CallbackSubscriber(_BaseSubscriber): - """Callback-based subscriber — Zenoh calls ``handler`` from its internal thread. + """Callback-based subscriber with auto-decode via SchemaRegistry. ``handler`` receives a decoded message: protobuf object, ``dict`` (JSON), - or raw ``bytes`` — determined automatically by the sample's encoding header - via the shared :class:`~bubbaloop_sdk.schema_registry.SchemaRegistry`. + or raw ``bytes`` — determined automatically by the sample's encoding header. - **Threading contract:** ``handler`` runs on Zenoh's internal thread. - Protect shared state with a lock if accessed from other threads. + By default the handler runs on Zenoh's internal thread (fast path). Pass + ``max_workers`` to run the handler in a ``ThreadPoolExecutor`` instead — + use this when the handler does slow work (DB writes, HTTP calls, hardware I/O). - Call ``undeclare()`` when done to stop receiving samples. - """ - - def __init__(self, session: zenoh.Session, topic: str, handler: Callable[[Any], None], registry: SchemaRegistry): - self._sub = session.declare_subscriber(topic, lambda sample: handler(registry.decode(sample))) - self._undeclared = False - - -class RawCallbackSubscriber(_BaseSubscriber): - """Callback-based subscriber that passes raw ``zenoh.Sample`` to the handler. - - Use when you need access to the full sample metadata (key_expr, encoding, - timestamp). Handler is called **serially** on Zenoh's internal thread. - - For slow handlers, use ``ctx.subscriber_raw_callback_async()`` instead. - - Call ``undeclare()`` when done to stop receiving samples. - """ - - def __init__(self, session: zenoh.Session, key_expr: str, handler: Callable[[zenoh.Sample], None]): - self._sub = session.declare_subscriber(key_expr, handler) - self._undeclared = False - - -class CallbackSubscriberAsync(_BaseSubscriber): - """Callback subscriber that runs ``handler`` in a ``ThreadPoolExecutor``. - - ``handler`` receives auto-decoded messages (proto, dict, or bytes). - Zenoh's internal thread is freed instantly; the handler runs in a thread pool. - - **Threading contract:** multiple invocations of ``handler`` may run concurrently. - Protect shared state with locks. + Args: + session: Active Zenoh session. + topic: Key expression to subscribe to. + handler: Callable invoked with each decoded message. + registry: SchemaRegistry for auto-decoding samples by encoding header. + max_workers: If None (default), handler runs on Zenoh's thread. If int, + handler runs in a ThreadPoolExecutor with that many threads. Call ``undeclare()`` when done to stop receiving samples. """ @@ -148,36 +123,54 @@ def __init__( topic: str, handler: Callable[[Any], None], registry: SchemaRegistry, - max_workers: int = 4, + max_workers: int | None = None, ): - self._executor = concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) - self._closing = threading.Event() - - def _wrap(sample: zenoh.Sample) -> None: - if self._closing.is_set(): - return - try: - self._executor.submit(handler, registry.decode(sample)) - except RuntimeError: - pass # executor already shut down — drop the message - - self._sub = session.declare_subscriber(topic, _wrap) + self._executor: concurrent.futures.ThreadPoolExecutor | None = None + self._closing: threading.Event | None = None + + if max_workers is not None: + self._executor = concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) + self._closing = threading.Event() + + def _wrap(sample: zenoh.Sample) -> None: + if self._closing.is_set(): # type: ignore[union-attr] + return + try: + self._executor.submit(handler, registry.decode(sample)) # type: ignore[union-attr] + except RuntimeError: + pass # executor already shut down — drop the message + + self._sub = session.declare_subscriber(topic, _wrap) + else: + self._sub = session.declare_subscriber(topic, lambda sample: handler(registry.decode(sample))) self._undeclared = False def undeclare(self) -> None: - """Undeclare the subscriber and shutdown the thread pool. Idempotent.""" + """Undeclare the subscriber and shutdown the thread pool (if any). Idempotent.""" if self._undeclared: return - self._closing.set() + if self._closing is not None: + self._closing.set() super().undeclare() - self._executor.shutdown(wait=False, cancel_futures=True) + if self._executor is not None: + self._executor.shutdown(wait=False, cancel_futures=True) + + +class RawCallbackSubscriber(_BaseSubscriber): + """Callback-based subscriber that passes raw ``zenoh.Sample`` to the handler. + Use when you need access to the full sample metadata (key_expr, encoding, + timestamp). -class RawCallbackSubscriberAsync(_BaseSubscriber): - """Raw callback subscriber that runs ``handler`` in a ``ThreadPoolExecutor``. + By default the handler runs on Zenoh's internal thread. Pass ``max_workers`` + to run the handler in a ``ThreadPoolExecutor`` instead. - Same as ``CallbackSubscriberAsync`` but passes raw ``zenoh.Sample`` objects. - Use when you need sample metadata AND your handler does slow work. + Args: + session: Active Zenoh session. + key_expr: Literal key expression to subscribe to. + handler: Callable invoked with each ``zenoh.Sample``. + max_workers: If None (default), handler runs on Zenoh's thread. If int, + handler runs in a ThreadPoolExecutor with that many threads. Call ``undeclare()`` when done to stop receiving samples. """ @@ -187,29 +180,37 @@ def __init__( session: zenoh.Session, key_expr: str, handler: Callable[[zenoh.Sample], None], - max_workers: int = 4, + max_workers: int | None = None, ): - self._executor = concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) - self._closing = threading.Event() - - def _wrap(sample: zenoh.Sample) -> None: - if self._closing.is_set(): - return - try: - self._executor.submit(handler, sample) - except RuntimeError: - pass # executor already shut down — drop the message - - self._sub = session.declare_subscriber(key_expr, _wrap) + self._executor: concurrent.futures.ThreadPoolExecutor | None = None + self._closing: threading.Event | None = None + + if max_workers is not None: + self._executor = concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) + self._closing = threading.Event() + + def _wrap(sample: zenoh.Sample) -> None: + if self._closing.is_set(): # type: ignore[union-attr] + return + try: + self._executor.submit(handler, sample) # type: ignore[union-attr] + except RuntimeError: + pass # executor already shut down — drop the message + + self._sub = session.declare_subscriber(key_expr, _wrap) + else: + self._sub = session.declare_subscriber(key_expr, handler) self._undeclared = False def undeclare(self) -> None: - """Undeclare the subscriber and shutdown the thread pool. Idempotent.""" + """Undeclare the subscriber and shutdown the thread pool (if any). Idempotent.""" if self._undeclared: return - self._closing.set() + if self._closing is not None: + self._closing.set() super().undeclare() - self._executor.shutdown(wait=False, cancel_futures=True) + if self._executor is not None: + self._executor.shutdown(wait=False, cancel_futures=True) class AsyncQueryable: From 5aded5b547a7a631ee43a16c27c91b684828d0f7 Mon Sep 17 00:00:00 2001 From: Luis Date: Thu, 16 Apr 2026 00:41:21 +0200 Subject: [PATCH 50/54] docs(python-sdk): update docs for merged callback subscriber classes Replace _async variant references with max_workers parameter pattern. Co-Authored-By: Claude Opus 4.6 (1M context) --- python-sdk/CLAUDE.md | 12 ++++++------ python-sdk/README.md | 17 +++++++---------- 2 files changed, 13 insertions(+), 16 deletions(-) diff --git a/python-sdk/CLAUDE.md b/python-sdk/CLAUDE.md index a00a42d..245bd88 100644 --- a/python-sdk/CLAUDE.md +++ b/python-sdk/CLAUDE.md @@ -67,9 +67,9 @@ cd python-sdk class CallbackSubscriber: """Event-driven subscriber that calls a handler on each received message. - The handler is invoked from Zenoh's internal callback thread. Keep - handlers fast; use ``subscriber_callback_async`` for slow work (I/O, - DB writes, HTTP calls). + The handler is invoked from Zenoh's internal callback thread by default. + Pass ``max_workers`` to run the handler in a thread pool instead — use + this for slow work (I/O, DB writes, HTTP calls). Args: session: Active Zenoh session. @@ -110,8 +110,8 @@ class CallbackSubscriber: **Threading — critical:** - Zenoh uses **one internal thread** for ALL callbacks and queryables on a session - A slow handler blocks every other subscriber/queryable until it returns -- Use `_async` variants (`subscriber_callback_async`, `queryable_async`) for any handler that does I/O, DB access, or hardware calls -- Shutdown order for `_async` variants: undeclare Zenoh subscriber FIRST, then `executor.shutdown()` — reversing this causes `RuntimeError: cannot schedule new futures after shutdown` +- Pass `max_workers=N` to `subscriber_callback` / `subscriber_raw_callback` or use `queryable_async` for any handler that does I/O, DB access, or hardware calls +- Shutdown order for thread-pool variants: undeclare Zenoh subscriber FIRST, then `executor.shutdown()` — reversing this causes `RuntimeError: cannot schedule new futures after shutdown` **`undeclare()` discipline:** - Every subscriber, callback subscriber, and queryable must be undeclared when done @@ -153,7 +153,7 @@ assert event.wait(timeout=2.0), "handler not called within 2s" - `B904` — always `raise Foo from err` inside `except` blocks, never bare `raise Foo(...)` - `F401` in `__init__.py` is suppressed by ruff config (re-exports are intentional) — do NOT add `# noqa` comments there -- `CallbackSubscriber` and `RawCallbackSubscriber` do NOT own an executor — `undeclare()` only calls `_sub.undeclare()`; the `_async` variants do own an executor and shut it down in `undeclare()` +- `CallbackSubscriber` and `RawCallbackSubscriber` without `max_workers` do NOT own an executor — `undeclare()` only calls `_sub.undeclare()`; with `max_workers` they own a `ThreadPoolExecutor` and shut it down in `undeclare()` - `ProtoSubscriber` and `RawSubscriber` are iterable (`for msg in sub`); iteration raises `StopIteration` on exception via `_BaseSubscriber.__next__` — prefer `recv(timeout=...)` in shutdown-aware loops to avoid blocking indefinitely - `run_node()` reads `config.yaml` by default; override with `-c path/config.yaml`. The `name` field in config sets `instance_name` for health/schema topics — collisions happen if two instances share the same name - Health topic format: `bubbaloop/global/{machine_id}/{instance_name}/health` — ensure consumer patterns match exactly diff --git a/python-sdk/README.md b/python-sdk/README.md index 300bb09..f5128b9 100644 --- a/python-sdk/README.md +++ b/python-sdk/README.md @@ -84,12 +84,11 @@ sub.undeclare() ctx.close() ``` -Use `subscriber_callback_async` when the handler does slow work (DB writes, -HTTP calls) — it runs the handler in a thread pool and returns immediately, -freeing Zenoh's internal thread: +Pass `max_workers` when the handler does slow work (DB writes, HTTP calls) — +the handler runs in a thread pool, freeing Zenoh's internal thread: ```python -sub = ctx.subscriber_callback_async("sensor/data", on_sensor) +sub = ctx.subscriber_callback("sensor/data", on_sensor, max_workers=4) ``` ### Queryable (respond to get requests) @@ -143,15 +142,13 @@ qbl.undeclare() # call when done to release the thread pool #### Callback subscribers (event-driven) -Handler is called from Zenoh's internal thread. Keep handlers fast; use `_async` -variants for slow work. +Handler runs on Zenoh's internal thread by default. Pass `max_workers` to +run the handler in a thread pool instead (for slow work). | Method | Description | |---|---| -| `ctx.subscriber_callback(suffix, handler)` | Decoded message to handler | -| `ctx.subscriber_raw_callback(key_expr, handler)` | Raw `zenoh.Sample` to handler | -| `ctx.subscriber_callback_async(suffix, handler, max_workers=4)` | Handler in thread pool | -| `ctx.subscriber_raw_callback_async(key_expr, handler, max_workers=4)` | Raw sample; handler in thread pool | +| `ctx.subscriber_callback(suffix, handler, max_workers=None)` | Auto-decoded message to handler | +| `ctx.subscriber_raw_callback(key_expr, handler, max_workers=None)` | Raw `zenoh.Sample` to handler | #### Queryables From c07c1242e6b93aac10c43f83b518bc35baa4da0a Mon Sep 17 00:00:00 2001 From: Luis Date: Thu, 16 Apr 2026 00:52:39 +0200 Subject: [PATCH 51/54] refactor(python-sdk): merge sync/async queryable methods into one each queryable(suffix, handler, max_workers=None) replaces queryable + queryable_async. queryable_raw(key_expr, handler, max_workers=None) replaces queryable_raw + queryable_raw_async. AsyncQueryable now supports both modes: None = handler on Zenoh thread, int = thread pool. Co-Authored-By: Claude Opus 4.6 (1M context) --- python-sdk/bubbaloop_sdk/context.py | 48 +++++------------- python-sdk/bubbaloop_sdk/subscriber.py | 67 ++++++++++++++------------ python-sdk/tests/test_context.py | 65 ++++++++++++++----------- 3 files changed, 86 insertions(+), 94 deletions(-) diff --git a/python-sdk/bubbaloop_sdk/context.py b/python-sdk/bubbaloop_sdk/context.py index c144f95..3e6d68c 100644 --- a/python-sdk/bubbaloop_sdk/context.py +++ b/python-sdk/bubbaloop_sdk/context.py @@ -226,7 +226,7 @@ def subscriber_raw_callback(self, key_expr: str, handler, max_workers: int | Non # Queryables # ------------------------------------------------------------------ - def queryable(self, suffix: str, handler) -> zenoh.Queryable: + def queryable(self, suffix: str, handler, max_workers: int | None = None) -> AsyncQueryable: """Declare a queryable at ``topic(suffix)``. ``handler`` receives a ``zenoh.Query``. Use the standard zenoh API to reply:: @@ -237,51 +237,25 @@ def on_command(query: zenoh.Query) -> None: qbl = ctx.queryable("command", on_command) - **Important:** do NOT pass ``complete=True`` — it blocks wildcard queries - like ``bubbaloop/**/schema`` used by the dashboard. - - For slow handlers, use ``queryable_async()``. + By default the handler runs on Zenoh's internal thread. Pass + ``max_workers`` to run the handler in a thread pool instead. Call ``undeclare()`` on the returned queryable when done. """ - return self.session.declare_queryable(self.topic(suffix), handler) + from .subscriber import AsyncQueryable + + return AsyncQueryable(self.session, self.topic(suffix), handler, max_workers) - def queryable_raw(self, key_expr: str, handler) -> zenoh.Queryable: + def queryable_raw(self, key_expr: str, handler, max_workers: int | None = None) -> AsyncQueryable: """Declare a queryable at a literal key expression (no topic prefix). - Use for wildcard queryables or when the ``bubbaloop/{scope}/{machine_id}/`` + Use for wildcard queryables or when the ``bubbaloop/global/{machine_id}/`` prefix does not apply (e.g. ``bubbaloop/**/schema`` for multi-schema serving). - Call ``undeclare()`` on the returned queryable when done. - """ - return self.session.declare_queryable(key_expr, handler) - - def queryable_async(self, suffix: str, handler, max_workers: int = 4) -> AsyncQueryable: - """Declare a queryable at ``topic(suffix)`` with handler in a thread pool. - - Use when the handler does slow work. Zenoh's internal thread is freed - immediately; the handler runs in a ``ThreadPoolExecutor``:: - - def on_db_query(query: zenoh.Query) -> None: - rows = db.fetch(query.payload.to_string()) # slow - query.reply(query.key_expr, json.dumps(rows).encode()) - - qbl = ctx.queryable_async("device_data", on_db_query) - # call qbl.undeclare() when done to release threads - - **Threading contract:** multiple invocations may run concurrently. - Protect shared state with locks. - """ - from .subscriber import AsyncQueryable - - return AsyncQueryable(self.session, self.topic(suffix), handler, max_workers) - - def queryable_raw_async(self, key_expr: str, handler, max_workers: int = 4) -> AsyncQueryable: - """Declare a queryable at a literal key expression with handler in a thread pool. + By default the handler runs on Zenoh's internal thread. Pass + ``max_workers`` to run the handler in a thread pool instead. - Same as ``queryable_async()`` but uses a literal key expression without the - ``bubbaloop/{scope}/{machine_id}/`` prefix. Use for wildcard queryables. - Call ``undeclare()`` on the returned object when done to release threads. + Call ``undeclare()`` on the returned queryable when done. """ from .subscriber import AsyncQueryable diff --git a/python-sdk/bubbaloop_sdk/subscriber.py b/python-sdk/bubbaloop_sdk/subscriber.py index 07a5b96..223461c 100644 --- a/python-sdk/bubbaloop_sdk/subscriber.py +++ b/python-sdk/bubbaloop_sdk/subscriber.py @@ -214,26 +214,25 @@ def undeclare(self) -> None: class AsyncQueryable: - """Wrapper around ``zenoh.Queryable`` that runs the handler in a ``ThreadPoolExecutor``. + """Queryable that responds to Zenoh GET requests. - **Use this (via ``ctx.queryable_async()``) when your queryable handler does slow work** - (database reads, hardware access, network calls). Zenoh uses a single internal thread - for all callbacks — a slow handler blocks ALL other subscribers and queryables on the - same session. ``AsyncQueryable`` fixes this by submitting the handler to a thread pool - immediately and returning, freeing Zenoh's thread:: + ``handler`` receives a ``zenoh.Query`` and must call ``query.reply()`` to respond. - def on_db_query(query: zenoh.Query) -> None: - rows = db.fetch(query.payload.to_string()) # slow - query.reply(query.key_expr, json.dumps(rows).encode()) + By default the handler runs on Zenoh's internal thread (fast path). Pass + ``max_workers`` to run the handler in a ``ThreadPoolExecutor`` instead — + use this when the handler does slow work (DB reads, hardware access, HTTP calls). - qbl = ctx.queryable_async("device_data", on_db_query) - # qbl.undeclare() when done — shuts down Zenoh queryable AND thread pool + **Important:** do NOT pass ``complete=True`` to the underlying queryable — + it blocks wildcard queries like ``bubbaloop/**/schema`` used by the dashboard. - **Threading contract:** multiple invocations of ``handler`` may run concurrently - if queries arrive faster than the handler processes them. Protect shared state - with locks. + Args: + session: Active Zenoh session. + key_expr: Key expression to declare the queryable on. + handler: Callable invoked with each ``zenoh.Query``. + max_workers: If None (default), handler runs on Zenoh's thread. If int, + handler runs in a ThreadPoolExecutor with that many threads. - Call ``undeclare()`` when done to stop receiving queries and release the thread pool. + Call ``undeclare()`` when done to stop receiving queries. """ def __init__( @@ -241,27 +240,35 @@ def __init__( session: zenoh.Session, key_expr: str, handler: Callable[[zenoh.Query], None], - max_workers: int = 4, + max_workers: int | None = None, ): - self._executor = concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) - self._closing = threading.Event() + self._executor: concurrent.futures.ThreadPoolExecutor | None = None + self._closing: threading.Event | None = None self._undeclared = False - def _wrap(query: zenoh.Query) -> None: - if self._closing.is_set(): - return - try: - self._executor.submit(handler, query) - except RuntimeError: - pass # executor already shut down — drop the query + if max_workers is not None: + self._executor = concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) + self._closing = threading.Event() + + def _wrap(query: zenoh.Query) -> None: + if self._closing.is_set(): # type: ignore[union-attr] + return + try: + self._executor.submit(handler, query) # type: ignore[union-attr] + except RuntimeError: + pass # executor already shut down — drop the query - self._qbl = session.declare_queryable(key_expr, _wrap) + self._qbl = session.declare_queryable(key_expr, _wrap) + else: + self._qbl = session.declare_queryable(key_expr, handler) def undeclare(self) -> None: - """Undeclare the queryable and shutdown the thread pool. Idempotent.""" + """Undeclare the queryable and shutdown the thread pool (if any). Idempotent.""" if self._undeclared: return self._undeclared = True - self._closing.set() - self._qbl.undeclare() # stop Zenoh callbacks first - self._executor.shutdown(wait=False, cancel_futures=True) + if self._closing is not None: + self._closing.set() + self._qbl.undeclare() + if self._executor is not None: + self._executor.shutdown(wait=False, cancel_futures=True) diff --git a/python-sdk/tests/test_context.py b/python-sdk/tests/test_context.py index c8736a8..dd4291a 100644 --- a/python-sdk/tests/test_context.py +++ b/python-sdk/tests/test_context.py @@ -305,7 +305,7 @@ def handler(sample): def test_async_queryable_drops_after_undeclare(): - """Queries arriving after undeclare() are silently dropped.""" + """Queries arriving after undeclare() are silently dropped (thread pool mode).""" from bubbaloop_sdk.subscriber import AsyncQueryable mock_session = MagicMock() @@ -323,7 +323,7 @@ def handler(query): received.append(query) called.set() - aq = AsyncQueryable(mock_session, "test/topic", handler) + aq = AsyncQueryable(mock_session, "test/topic", handler, max_workers=4) aq.undeclare() fake_query = MagicMock() @@ -610,8 +610,12 @@ def test_queryable_uses_topic_prefix(): def handler(q): pass - ctx.queryable("command", handler) - ctx.session.declare_queryable.assert_called_once_with("bubbaloop/global/bot/command", handler) + qbl = ctx.queryable("command", handler) + try: + called_topic = ctx.session.declare_queryable.call_args[0][0] + assert called_topic == "bubbaloop/global/bot/command" + finally: + qbl.undeclare() def test_queryable_raw_uses_literal_key_expr(): @@ -621,32 +625,39 @@ def test_queryable_raw_uses_literal_key_expr(): def handler(q): pass - ctx.queryable_raw("bubbaloop/**/schema", handler) - ctx.session.declare_queryable.assert_called_once_with("bubbaloop/**/schema", handler) + qbl = ctx.queryable_raw("bubbaloop/**/schema", handler) + try: + called_topic = ctx.session.declare_queryable.call_args[0][0] + assert called_topic == "bubbaloop/**/schema" + finally: + qbl.undeclare() -def test_queryable_returns_zenoh_queryable(): - """queryable() returns whatever session.declare_queryable returns.""" +def test_queryable_returns_async_queryable(): + """queryable() returns AsyncQueryable.""" + from bubbaloop_sdk.subscriber import AsyncQueryable + ctx = _make_context("bot") - mock_qbl = MagicMock() - ctx.session.declare_queryable.return_value = mock_qbl result = ctx.queryable("command", lambda q: None) - assert result is mock_qbl + try: + assert isinstance(result, AsyncQueryable) + finally: + result.undeclare() # --------------------------------------------------------------------------- -# NodeContext.queryable_async() and queryable_raw_async() +# NodeContext.queryable(max_workers) and queryable_raw(max_workers) # --------------------------------------------------------------------------- -def test_queryable_async_uses_topic_prefix(): - """queryable_async() declares at topic(suffix).""" +def test_queryable_with_workers_uses_topic_prefix(): + """queryable(max_workers=4) declares at topic(suffix).""" ctx = _make_context("bot") def handler(q): pass - qbl = ctx.queryable_async("command", handler) + qbl = ctx.queryable("command", handler, max_workers=4) try: called_topic = ctx.session.declare_queryable.call_args[0][0] assert called_topic == "bubbaloop/global/bot/command" @@ -654,8 +665,8 @@ def handler(q): qbl.undeclare() -def test_queryable_async_wraps_handler_in_executor(): - """queryable_async() wraps handler so Zenoh thread is freed.""" +def test_queryable_with_workers_wraps_handler_in_executor(): + """queryable(max_workers=4) wraps handler so Zenoh thread is freed.""" import threading ctx = _make_context("bot") @@ -674,7 +685,7 @@ def slow_handler(query): received.append(query) event.set() - qbl = ctx.queryable_async("command", slow_handler) + qbl = ctx.queryable("command", slow_handler, max_workers=4) try: fake_query = MagicMock() @@ -686,22 +697,22 @@ def slow_handler(query): qbl.undeclare() -def test_queryable_async_returns_async_queryable(): - """queryable_async() returns AsyncQueryable (not a bare zenoh.Queryable).""" +def test_queryable_with_workers_returns_async_queryable(): + """queryable(max_workers=4) returns AsyncQueryable.""" from bubbaloop_sdk.subscriber import AsyncQueryable ctx = _make_context("bot") - qbl = ctx.queryable_async("command", lambda q: None) + qbl = ctx.queryable("command", lambda q: None, max_workers=4) try: assert isinstance(qbl, AsyncQueryable) finally: qbl.undeclare() -def test_queryable_raw_async_uses_literal_key_expr(): - """queryable_raw_async() declares at the literal key expression.""" +def test_queryable_raw_with_workers_uses_literal_key_expr(): + """queryable_raw(max_workers=4) declares at the literal key expression.""" ctx = _make_context("bot") - qbl = ctx.queryable_raw_async("bubbaloop/**/schema", lambda q: None) + qbl = ctx.queryable_raw("bubbaloop/**/schema", lambda q: None, max_workers=4) try: called_topic = ctx.session.declare_queryable.call_args[0][0] assert called_topic == "bubbaloop/**/schema" @@ -709,8 +720,8 @@ def test_queryable_raw_async_uses_literal_key_expr(): qbl.undeclare() -def test_queryable_raw_async_wraps_handler_in_executor(): - """queryable_raw_async() wraps handler in thread pool.""" +def test_queryable_raw_with_workers_wraps_handler_in_executor(): + """queryable_raw(max_workers=4) wraps handler in thread pool.""" import threading ctx = _make_context("bot") @@ -729,7 +740,7 @@ def handler(query): received.append(query) event.set() - qbl = ctx.queryable_raw_async("bubbaloop/**/schema", handler) + qbl = ctx.queryable_raw("bubbaloop/**/schema", handler, max_workers=4) try: fake_query = MagicMock() From e173b06ec81eff91b942dae65fe98589ddf1859c Mon Sep 17 00:00:00 2001 From: Luis Date: Thu, 16 Apr 2026 00:53:49 +0200 Subject: [PATCH 52/54] =?UTF-8?q?refactor(python-sdk):=20rename=20AsyncQue?= =?UTF-8?q?ryable=20=E2=86=92=20Queryable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The class now supports both sync and thread-pool modes via max_workers, so the Async prefix no longer applies. Co-Authored-By: Claude Opus 4.6 (1M context) --- python-sdk/bubbaloop_sdk/__init__.py | 4 ++-- python-sdk/bubbaloop_sdk/context.py | 14 +++++++------- python-sdk/bubbaloop_sdk/subscriber.py | 2 +- python-sdk/tests/test_context.py | 26 +++++++++++++------------- 4 files changed, 23 insertions(+), 23 deletions(-) diff --git a/python-sdk/bubbaloop_sdk/__init__.py b/python-sdk/bubbaloop_sdk/__init__.py index a5110b8..f495fde 100644 --- a/python-sdk/bubbaloop_sdk/__init__.py +++ b/python-sdk/bubbaloop_sdk/__init__.py @@ -13,15 +13,14 @@ from .node import run_node from .publisher import JsonPublisher, ProtoPublisher, RawPublisher from .subscriber import ( - AsyncQueryable, CallbackSubscriber, ProtoSubscriber, + Queryable, RawCallbackSubscriber, RawSubscriber, ) __all__ = [ - "AsyncQueryable", "CallbackSubscriber", "GetSampleTimeout", "JsonPublisher", @@ -30,6 +29,7 @@ "ProtoDecoder", "ProtoPublisher", "ProtoSubscriber", + "Queryable", "RawCallbackSubscriber", "RawPublisher", "RawSubscriber", diff --git a/python-sdk/bubbaloop_sdk/context.py b/python-sdk/bubbaloop_sdk/context.py index 3e6d68c..4bad345 100644 --- a/python-sdk/bubbaloop_sdk/context.py +++ b/python-sdk/bubbaloop_sdk/context.py @@ -26,9 +26,9 @@ if TYPE_CHECKING: from .publisher import JsonPublisher, ProtoPublisher, RawPublisher from .subscriber import ( - AsyncQueryable, CallbackSubscriber, ProtoSubscriber, + Queryable, RawCallbackSubscriber, RawSubscriber, ) @@ -226,7 +226,7 @@ def subscriber_raw_callback(self, key_expr: str, handler, max_workers: int | Non # Queryables # ------------------------------------------------------------------ - def queryable(self, suffix: str, handler, max_workers: int | None = None) -> AsyncQueryable: + def queryable(self, suffix: str, handler, max_workers: int | None = None) -> Queryable: """Declare a queryable at ``topic(suffix)``. ``handler`` receives a ``zenoh.Query``. Use the standard zenoh API to reply:: @@ -242,11 +242,11 @@ def on_command(query: zenoh.Query) -> None: Call ``undeclare()`` on the returned queryable when done. """ - from .subscriber import AsyncQueryable + from .subscriber import Queryable - return AsyncQueryable(self.session, self.topic(suffix), handler, max_workers) + return Queryable(self.session, self.topic(suffix), handler, max_workers) - def queryable_raw(self, key_expr: str, handler, max_workers: int | None = None) -> AsyncQueryable: + def queryable_raw(self, key_expr: str, handler, max_workers: int | None = None) -> Queryable: """Declare a queryable at a literal key expression (no topic prefix). Use for wildcard queryables or when the ``bubbaloop/global/{machine_id}/`` @@ -257,9 +257,9 @@ def queryable_raw(self, key_expr: str, handler, max_workers: int | None = None) Call ``undeclare()`` on the returned queryable when done. """ - from .subscriber import AsyncQueryable + from .subscriber import Queryable - return AsyncQueryable(self.session, key_expr, handler, max_workers) + return Queryable(self.session, key_expr, handler, max_workers) # ------------------------------------------------------------------ # Cleanup diff --git a/python-sdk/bubbaloop_sdk/subscriber.py b/python-sdk/bubbaloop_sdk/subscriber.py index 223461c..d3bfee5 100644 --- a/python-sdk/bubbaloop_sdk/subscriber.py +++ b/python-sdk/bubbaloop_sdk/subscriber.py @@ -213,7 +213,7 @@ def undeclare(self) -> None: self._executor.shutdown(wait=False, cancel_futures=True) -class AsyncQueryable: +class Queryable: """Queryable that responds to Zenoh GET requests. ``handler`` receives a ``zenoh.Query`` and must call ``query.reply()`` to respond. diff --git a/python-sdk/tests/test_context.py b/python-sdk/tests/test_context.py index dd4291a..9fdaa64 100644 --- a/python-sdk/tests/test_context.py +++ b/python-sdk/tests/test_context.py @@ -114,9 +114,9 @@ def test_import_callback_subscribers_with_workers(): def test_import_async_queryable(): - from bubbaloop_sdk import AsyncQueryable + from bubbaloop_sdk import Queryable - assert AsyncQueryable is not None + assert Queryable is not None def test_import_run_node(): @@ -306,7 +306,7 @@ def handler(sample): def test_async_queryable_drops_after_undeclare(): """Queries arriving after undeclare() are silently dropped (thread pool mode).""" - from bubbaloop_sdk.subscriber import AsyncQueryable + from bubbaloop_sdk.subscriber import Queryable mock_session = MagicMock() captured_wrapper = [] @@ -323,7 +323,7 @@ def handler(query): received.append(query) called.set() - aq = AsyncQueryable(mock_session, "test/topic", handler, max_workers=4) + aq = Queryable(mock_session, "test/topic", handler, max_workers=4) aq.undeclare() fake_query = MagicMock() @@ -634,13 +634,13 @@ def handler(q): def test_queryable_returns_async_queryable(): - """queryable() returns AsyncQueryable.""" - from bubbaloop_sdk.subscriber import AsyncQueryable + """queryable() returns Queryable.""" + from bubbaloop_sdk.subscriber import Queryable ctx = _make_context("bot") result = ctx.queryable("command", lambda q: None) try: - assert isinstance(result, AsyncQueryable) + assert isinstance(result, Queryable) finally: result.undeclare() @@ -698,13 +698,13 @@ def slow_handler(query): def test_queryable_with_workers_returns_async_queryable(): - """queryable(max_workers=4) returns AsyncQueryable.""" - from bubbaloop_sdk.subscriber import AsyncQueryable + """queryable(max_workers=4) returns Queryable.""" + from bubbaloop_sdk.subscriber import Queryable ctx = _make_context("bot") qbl = ctx.queryable("command", lambda q: None, max_workers=4) try: - assert isinstance(qbl, AsyncQueryable) + assert isinstance(qbl, Queryable) finally: qbl.undeclare() @@ -753,13 +753,13 @@ def handler(query): def test_async_queryable_undeclare(): - """AsyncQueryable.undeclare() undeclares queryable then shuts executor.""" - from bubbaloop_sdk.subscriber import AsyncQueryable + """Queryable.undeclare() undeclares queryable then shuts executor.""" + from bubbaloop_sdk.subscriber import Queryable mock_session = MagicMock() mock_qbl = MagicMock() mock_session.declare_queryable.return_value = mock_qbl - aq = AsyncQueryable(mock_session, "test/topic", lambda q: None) + aq = Queryable(mock_session, "test/topic", lambda q: None) aq.undeclare() mock_qbl.undeclare.assert_called_once() From 42311bf9086f1d96b9deeb15d0ff94dd617231e0 Mon Sep 17 00:00:00 2001 From: Luis Date: Thu, 16 Apr 2026 01:05:39 +0200 Subject: [PATCH 53/54] =?UTF-8?q?fix(python-sdk):=20address=20Copilot=20re?= =?UTF-8?q?view=20=E2=80=94=20decode=20in=20worker=20thread,=20type=20anno?= =?UTF-8?q?tations,=20cleanup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move registry.decode() into executor.submit() lambda so decode runs in the worker thread, not on Zenoh's callback thread (avoids blocking on first schema fetch) - Add Callable type annotations to handler params in context.py - Remove duplicate test_import_callback_subscribers_with_workers Co-Authored-By: Claude Opus 4.6 (1M context) --- python-sdk/bubbaloop_sdk/context.py | 19 ++++++++++++++----- python-sdk/bubbaloop_sdk/subscriber.py | 2 +- python-sdk/tests/test_context.py | 7 ------- 3 files changed, 15 insertions(+), 13 deletions(-) diff --git a/python-sdk/bubbaloop_sdk/context.py b/python-sdk/bubbaloop_sdk/context.py index 4bad345..14b20ff 100644 --- a/python-sdk/bubbaloop_sdk/context.py +++ b/python-sdk/bubbaloop_sdk/context.py @@ -19,7 +19,8 @@ import signal import socket import threading -from typing import TYPE_CHECKING +from collections.abc import Callable +from typing import TYPE_CHECKING, Any import zenoh @@ -194,7 +195,9 @@ def subscribe_raw(self, suffix: str, local: bool = False) -> RawSubscriber: # Callback Subscribers # ------------------------------------------------------------------ - def subscriber_callback(self, suffix: str, handler, max_workers: int | None = None) -> CallbackSubscriber: + def subscriber_callback( + self, suffix: str, handler: Callable[[Any], None], max_workers: int | None = None + ) -> CallbackSubscriber: """Callback subscriber at ``topic(suffix)`` with auto-decode. ``handler`` receives auto-decoded messages (proto, dict, or bytes). @@ -210,7 +213,9 @@ def subscriber_callback(self, suffix: str, handler, max_workers: int | None = No self._schema_registry = SchemaRegistry(self.session) return CallbackSubscriber(self.session, self.topic(suffix), handler, self._schema_registry, max_workers) - def subscriber_raw_callback(self, key_expr: str, handler, max_workers: int | None = None) -> RawCallbackSubscriber: + def subscriber_raw_callback( + self, key_expr: str, handler: Callable[[zenoh.Sample], None], max_workers: int | None = None + ) -> RawCallbackSubscriber: """Callback subscriber at a literal key expression. ``handler`` receives raw ``zenoh.Sample`` objects. @@ -226,7 +231,9 @@ def subscriber_raw_callback(self, key_expr: str, handler, max_workers: int | Non # Queryables # ------------------------------------------------------------------ - def queryable(self, suffix: str, handler, max_workers: int | None = None) -> Queryable: + def queryable( + self, suffix: str, handler: Callable[[zenoh.Query], None], max_workers: int | None = None + ) -> Queryable: """Declare a queryable at ``topic(suffix)``. ``handler`` receives a ``zenoh.Query``. Use the standard zenoh API to reply:: @@ -246,7 +253,9 @@ def on_command(query: zenoh.Query) -> None: return Queryable(self.session, self.topic(suffix), handler, max_workers) - def queryable_raw(self, key_expr: str, handler, max_workers: int | None = None) -> Queryable: + def queryable_raw( + self, key_expr: str, handler: Callable[[zenoh.Query], None], max_workers: int | None = None + ) -> Queryable: """Declare a queryable at a literal key expression (no topic prefix). Use for wildcard queryables or when the ``bubbaloop/global/{machine_id}/`` diff --git a/python-sdk/bubbaloop_sdk/subscriber.py b/python-sdk/bubbaloop_sdk/subscriber.py index d3bfee5..22875fa 100644 --- a/python-sdk/bubbaloop_sdk/subscriber.py +++ b/python-sdk/bubbaloop_sdk/subscriber.py @@ -136,7 +136,7 @@ def _wrap(sample: zenoh.Sample) -> None: if self._closing.is_set(): # type: ignore[union-attr] return try: - self._executor.submit(handler, registry.decode(sample)) # type: ignore[union-attr] + self._executor.submit(lambda s=sample: handler(registry.decode(s))) # type: ignore[union-attr] except RuntimeError: pass # executor already shut down — drop the message diff --git a/python-sdk/tests/test_context.py b/python-sdk/tests/test_context.py index 9fdaa64..ade41ad 100644 --- a/python-sdk/tests/test_context.py +++ b/python-sdk/tests/test_context.py @@ -106,13 +106,6 @@ def test_import_callback_subscribers(): assert RawCallbackSubscriber is not None -def test_import_callback_subscribers_with_workers(): - from bubbaloop_sdk import CallbackSubscriber, RawCallbackSubscriber - - assert CallbackSubscriber is not None - assert RawCallbackSubscriber is not None - - def test_import_async_queryable(): from bubbaloop_sdk import Queryable From 6463f04a887dd21a632039a5d55a7f498f8064c4 Mon Sep 17 00:00:00 2001 From: Luis Date: Fri, 17 Apr 2026 08:57:01 +0200 Subject: [PATCH 54/54] fix(python-sdk): address Copilot review round 2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix stale queryable_async/AsyncQueryable refs in README, CLAUDE.md, CONTRIBUTING.md - Fix README examples: msg.value → msg (auto-decode returns proto/dict/bytes) - Rename test_import_async_queryable → test_import_queryable - Remove duplicate test_raw_subscriber_undeclare_calls_sub_undeclare - Type-annotate msg_class in publisher_proto() Co-Authored-By: Claude Opus 4.6 (1M context) --- python-sdk/CLAUDE.md | 6 +++--- python-sdk/CONTRIBUTING.md | 2 +- python-sdk/README.md | 14 ++++++-------- python-sdk/bubbaloop_sdk/context.py | 2 +- python-sdk/tests/test_context.py | 14 +------------- 5 files changed, 12 insertions(+), 26 deletions(-) diff --git a/python-sdk/CLAUDE.md b/python-sdk/CLAUDE.md index 245bd88..8a892ee 100644 --- a/python-sdk/CLAUDE.md +++ b/python-sdk/CLAUDE.md @@ -11,7 +11,7 @@ python-sdk/ __init__.py # Public API — edit when adding new public names context.py # NodeContext: connect(), topic(), publishers, subscribers, queryables publisher.py # JsonPublisher, ProtoPublisher (wraps session.declare_publisher) - subscriber.py # ProtoSubscriber, RawSubscriber, Callback*, Async*, AsyncQueryable + subscriber.py # ProtoSubscriber, RawSubscriber, CallbackSubscriber, RawCallbackSubscriber, Queryable node.py # run_node() — CLI arg parsing + health heartbeat + lifecycle health.py # start_health_heartbeat() — publishes 'ok' every 5s discover.py # discover_nodes() — GET bubbaloop/**/health @@ -110,12 +110,12 @@ class CallbackSubscriber: **Threading — critical:** - Zenoh uses **one internal thread** for ALL callbacks and queryables on a session - A slow handler blocks every other subscriber/queryable until it returns -- Pass `max_workers=N` to `subscriber_callback` / `subscriber_raw_callback` or use `queryable_async` for any handler that does I/O, DB access, or hardware calls +- Pass `max_workers=N` to `subscriber_callback` / `subscriber_raw_callback` / `queryable` / `queryable_raw` for any handler that does I/O, DB access, or hardware calls - Shutdown order for thread-pool variants: undeclare Zenoh subscriber FIRST, then `executor.shutdown()` — reversing this causes `RuntimeError: cannot schedule new futures after shutdown` **`undeclare()` discipline:** - Every subscriber, callback subscriber, and queryable must be undeclared when done -- `AsyncQueryable` and `*Async` subscribers own a `ThreadPoolExecutor` — GC alone is not enough, always call `undeclare()` +- `Queryable`, `CallbackSubscriber`, and `RawCallbackSubscriber` with `max_workers` own a `ThreadPoolExecutor` — GC alone is not enough, always call `undeclare()` - Blocking subscribers (`RawSubscriber`) are undeclared via `undeclare()` too ## Testing diff --git a/python-sdk/CONTRIBUTING.md b/python-sdk/CONTRIBUTING.md index 6dd5ea7..110608d 100644 --- a/python-sdk/CONTRIBUTING.md +++ b/python-sdk/CONTRIBUTING.md @@ -69,7 +69,7 @@ python-sdk/ bubbaloop_sdk/ __init__.py # Public API surface context.py # NodeContext — main entry point - subscriber.py # ProtoSubscriber, RawSubscriber, Callback*, Async*, AsyncQueryable + subscriber.py # ProtoSubscriber, RawSubscriber, CallbackSubscriber, RawCallbackSubscriber, Queryable publisher.py # JsonPublisher, ProtoPublisher node.py # run_node() helper health.py # Health heartbeat (used internally by run_node) diff --git a/python-sdk/README.md b/python-sdk/README.md index f5128b9..698d478 100644 --- a/python-sdk/README.md +++ b/python-sdk/README.md @@ -65,7 +65,7 @@ ctx = NodeContext.connect() sub = ctx.subscribe("sensor/data") for msg in sub: # auto-decoded: proto, dict, or bytes - print(f"value: {msg.value}") + print(msg) ``` ### Callback subscriber (event-driven, no loop needed) @@ -76,7 +76,7 @@ from bubbaloop_sdk import NodeContext ctx = NodeContext.connect() def on_sensor(msg): - print(f"received: {msg.value}") + print(f"received: {msg}") # proto, dict, or bytes depending on encoding sub = ctx.subscriber_callback("sensor/data", on_sensor) ctx.wait_shutdown() # block until SIGINT/SIGTERM @@ -108,10 +108,10 @@ qbl.undeclare() ctx.close() ``` -Use `queryable_async` when the handler does slow work: +Pass `max_workers` when the handler does slow work: ```python -qbl = ctx.queryable_async("status", on_query) +qbl = ctx.queryable("status", on_query, max_workers=4) qbl.undeclare() # call when done to release the thread pool ``` @@ -156,10 +156,8 @@ Do **not** pass `complete=True` — it blocks wildcard queries used by the dashb | Method | Description | |---|---| -| `ctx.queryable(suffix, handler)` | Handler at `topic(suffix)` | -| `ctx.queryable_raw(key_expr, handler)` | Handler at literal key expression | -| `ctx.queryable_async(suffix, handler, max_workers=4)` | Handler in thread pool | -| `ctx.queryable_raw_async(key_expr, handler, max_workers=4)` | Raw key; handler in thread pool | +| `ctx.queryable(suffix, handler, max_workers=None)` | Queryable at `topic(suffix)` | +| `ctx.queryable_raw(key_expr, handler, max_workers=None)` | Queryable at literal key expression | #### Publishers diff --git a/python-sdk/bubbaloop_sdk/context.py b/python-sdk/bubbaloop_sdk/context.py index 14b20ff..f330801 100644 --- a/python-sdk/bubbaloop_sdk/context.py +++ b/python-sdk/bubbaloop_sdk/context.py @@ -129,7 +129,7 @@ def publisher_json(self, suffix: str) -> JsonPublisher: return JsonPublisher._declare(self.session, self.topic(suffix)) - def publisher_proto(self, suffix: str, msg_class=None) -> ProtoPublisher: + def publisher_proto(self, suffix: str, msg_class: type | None = None) -> ProtoPublisher: """Declare a protobuf publisher at ``topic(suffix)``.""" from .publisher import ProtoPublisher diff --git a/python-sdk/tests/test_context.py b/python-sdk/tests/test_context.py index ade41ad..715b569 100644 --- a/python-sdk/tests/test_context.py +++ b/python-sdk/tests/test_context.py @@ -106,7 +106,7 @@ def test_import_callback_subscribers(): assert RawCallbackSubscriber is not None -def test_import_async_queryable(): +def test_import_queryable(): from bubbaloop_sdk import Queryable assert Queryable is not None @@ -907,18 +907,6 @@ def test_raw_subscriber_declares_on_topic(): mock_session.declare_subscriber.assert_called_once_with("test/topic") -def test_raw_subscriber_undeclare_calls_sub_undeclare(): - """undeclare() calls undeclare on the underlying zenoh subscriber.""" - from bubbaloop_sdk.subscriber import RawSubscriber - - mock_sub = MagicMock() - mock_session = MagicMock() - mock_session.declare_subscriber.return_value = mock_sub - sub = RawSubscriber(mock_session, "test/topic") - sub.undeclare() - mock_sub.undeclare.assert_called_once() - - # --------------------------------------------------------------------------- # RawPublisher.put() # ---------------------------------------------------------------------------