From 0c4c141225da46f07fd4fa8292694abd48171457 Mon Sep 17 00:00:00 2001 From: Greg Mooney Date: Thu, 2 Apr 2026 17:55:13 +0200 Subject: [PATCH 1/6] Support awareness over Comm --- src/ypywidgets/comm.py | 42 ++++++++++++++++++++++++++++++++++-------- tests/conftest.py | 8 ++++++++ 2 files changed, 42 insertions(+), 8 deletions(-) diff --git a/src/ypywidgets/comm.py b/src/ypywidgets/comm.py index 89a5a89..d33bfe5 100644 --- a/src/ypywidgets/comm.py +++ b/src/ypywidgets/comm.py @@ -1,7 +1,9 @@ from __future__ import annotations +from typing import Any, Callable import comm from pycrdt import ( + Awareness, Doc, Text, TransactionEvent, @@ -10,6 +12,7 @@ create_sync_message, create_update_message, handle_sync_message, + read_message, ) from .widget import Widget @@ -48,18 +51,27 @@ def __init__( ) -> None: self._ydoc = ydoc self._comm = comm + self._awareness = Awareness(ydoc) msg = create_sync_message(ydoc) self._comm.send(buffers=[msg]) self._comm.on_msg(self._receive) + @property + def awareness(self) -> Awareness: + return self._awareness + def _receive(self, msg): message = bytes(msg["buffers"][0]) - if message[0] == YMessageType.SYNC: + mtype = message[0] + if mtype == YMessageType.SYNC: reply = handle_sync_message(message[1:], self._ydoc) if reply is not None: self._comm.send(buffers=[reply]) if message[1] == YSyncMessageType.SYNC_STEP2: self._ydoc.observe(self._send) + elif mtype == YMessageType.AWARENESS: + payload = read_message(message[1:]) + self._awareness.apply_awareness_update(payload, None) def _send(self, event: TransactionEvent): update = event.update @@ -69,12 +81,12 @@ def _send(self, event: TransactionEvent): class CommWidget(Widget): def __init__( - self, - ydoc: Doc | None = None, - comm_data: dict | None = None, - comm_metadata: dict | None = None, - comm_id: str | None = None, - ): + self, + ydoc: Doc | None = None, + comm_data: dict | None = None, + comm_metadata: dict | None = None, + comm_id: str | None = None, + ): super().__init__(ydoc) model_name = self.__class__.__name__ _model_name = self.ydoc["_model_name"] = Text() @@ -85,7 +97,21 @@ def __init__( create_ydoc=not ydoc, ) self._comm = create_widget_comm(comm_data, comm_metadata, comm_id) - CommProvider(self.ydoc, self._comm) + self._comm_provider = CommProvider(self.ydoc, self._comm) + + @property + def awareness(self) -> Awareness: + return self._comm_provider.awareness + + def on_awareness_change( + self, + callback: Callable[[str, tuple[dict[str, Any], Any]], None], + ) -> str: + """Subscribe to pycrdt Awareness updates; returns subscription id for unobserve.""" + return self.awareness.observe(callback) + + def unobserve_awareness(self, subscription_id: str) -> None: + self.awareness.unobserve(subscription_id) def _repr_mimebundle_(self, *args, **kwargs): # pragma: nocover plaintext = repr(self) diff --git a/tests/conftest.py b/tests/conftest.py index b7a0ea5..3282fde 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,12 +5,14 @@ import comm import pytest from pycrdt import ( + Awareness, YMessageType, YSyncMessageType, TransactionEvent, create_sync_message, create_update_message, handle_sync_message, + read_message, ) from ypywidgets import Widget from ypywidgets.comm import CommWidget @@ -67,6 +69,7 @@ def __init__(self, widget_factory, comm): self.widget_factory = widget_factory self.comm = comm self.widget = None + self._remote_awareness: Optional[Awareness] = None self.receive_task = asyncio.create_task(self.receive()) def send(self, event: TransactionEvent): @@ -89,6 +92,11 @@ async def receive(self): self.comm.handle_msg({"buffers": [reply]}) if message[1] == YSyncMessageType.SYNC_STEP2: self.widget.ydoc.observe(self.send) + elif message[0] == YMessageType.AWARENESS: + if self._remote_awareness is None: + self._remote_awareness = Awareness(self.widget.ydoc) + payload = read_message(bytes(message[1:])) + self._remote_awareness.apply_awareness_update(payload, None) async def get_widget(self, timeout=0.1): t = time.monotonic() From 065df6f06d5e256da51e3eee313ec3ebcb4f28cf Mon Sep 17 00:00:00 2001 From: Greg Mooney Date: Tue, 7 Apr 2026 12:07:40 +0200 Subject: [PATCH 2/6] Tests --- tests/conftest.py | 1 + tests/test_comm_awareness.py | 70 ++++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+) create mode 100644 tests/test_comm_awareness.py diff --git a/tests/conftest.py b/tests/conftest.py index b614dba..f81a9b1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -112,6 +112,7 @@ def __init__( self.local_widget_factory, self.remote_widget_factory = widget_factories self.local_widget: CommWidget | None = None self.remote_widget: Widget | None = None + self.remote_awareness: Awareness | None = None self.local_widget_created = Event() self.remote_widget_created = Event() self._remote_awareness: Awareness | None = None diff --git a/tests/test_comm_awareness.py b/tests/test_comm_awareness.py new file mode 100644 index 0000000..ea09ba0 --- /dev/null +++ b/tests/test_comm_awareness.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +from unittest.mock import patch + +import pytest +from anyio import sleep +from pycrdt import Doc, Text, YMessageType, create_awareness_message + +from ypywidgets.comm import CommProvider, CommWidget + +pytestmark = pytest.mark.anyio + + +class DummyComm: + def __init__(self): + self.sent: list[bytes] = [] + self._handler = None + + def send(self, *, buffers=None, **kwargs): + if buffers: + self.sent.append(bytes(memoryview(buffers[0]))) + + def on_msg(self, handler): + self._handler = handler + + +def test_comm_provider_applies_awareness_frame(): + doc = Doc() + comm = DummyComm() + provider = CommProvider(doc, comm) + + awareness = provider.awareness + awareness.set_local_state({"role": "tester"}) + payload = awareness.encode_awareness_update([awareness.client_id]) + frame = create_awareness_message(payload) + + assert frame[0] == YMessageType.AWARENESS + + provider._receive({"buffers": [frame]}) + + state = awareness.get_local_state() + assert state is not None + assert state.get("role") == "tester" + + +@patch("ypywidgets.comm.create_widget_comm") +def test_comm_widget_exposes_provider_awareness(mock_create_comm): + comm = DummyComm() + mock_create_comm.return_value = comm + + widget = CommWidget() + assert widget.awareness is widget._comm_provider.awareness + + +@patch("ypywidgets.comm.create_widget_comm") +def test_comm_widget_awareness_observe_and_unobserve(mock_create_comm): + comm = DummyComm() + mock_create_comm.return_value = comm + widget = CommWidget() + + events: list[str] = [] + sub_id = widget.on_awareness_change(lambda topic, _: events.append(topic)) + + widget.awareness.set_local_state({"ping": 1}) + assert events + + widget.unobserve_awareness(sub_id) + events.clear() + widget.awareness.set_local_state({"ping": 2}) + assert events == [] \ No newline at end of file From 7964b05917f585c02a80376bdace7b3ce3238cff Mon Sep 17 00:00:00 2001 From: Greg Mooney Date: Tue, 7 Apr 2026 12:34:07 +0200 Subject: [PATCH 3/6] Lint --- tests/test_comm_awareness.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/test_comm_awareness.py b/tests/test_comm_awareness.py index ea09ba0..6d06a34 100644 --- a/tests/test_comm_awareness.py +++ b/tests/test_comm_awareness.py @@ -3,8 +3,7 @@ from unittest.mock import patch import pytest -from anyio import sleep -from pycrdt import Doc, Text, YMessageType, create_awareness_message +from pycrdt import Doc, YMessageType, create_awareness_message from ypywidgets.comm import CommProvider, CommWidget @@ -67,4 +66,4 @@ def test_comm_widget_awareness_observe_and_unobserve(mock_create_comm): widget.unobserve_awareness(sub_id) events.clear() widget.awareness.set_local_state({"ping": 2}) - assert events == [] \ No newline at end of file + assert events == [] From 1cac4969e022a3ed441e770cc7e8b2c56ab0271c Mon Sep 17 00:00:00 2001 From: Greg Mooney Date: Tue, 7 Apr 2026 12:50:23 +0200 Subject: [PATCH 4/6] Coverage --- tests/test_comm_awareness.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/test_comm_awareness.py b/tests/test_comm_awareness.py index 6d06a34..32b7132 100644 --- a/tests/test_comm_awareness.py +++ b/tests/test_comm_awareness.py @@ -3,6 +3,7 @@ from unittest.mock import patch import pytest +from anyio import sleep from pycrdt import Doc, YMessageType, create_awareness_message from ypywidgets.comm import CommProvider, CommWidget @@ -67,3 +68,27 @@ def test_comm_widget_awareness_observe_and_unobserve(mock_create_comm): events.clear() widget.awareness.set_local_state({"ping": 2}) assert events == [] + + +async def test_remote_manager_applies_awareness_messages(synced_widgets, context): + async with context: + local_widget = await synced_widgets.get_local_widget() + await synced_widgets.get_remote_widget() + + local_widget.awareness.set_local_state({"role": "local"}) + payload = local_widget.awareness.encode_awareness_update( + [local_widget.awareness.client_id] + ) + frame = create_awareness_message(payload) + + synced_widgets.comm.send_send_stream.send_nowait( + ("comm_msg", {}, None, [frame], None, None) + ) + await sleep(0.01) + + assert synced_widgets._remote_awareness is not None + remote_state = synced_widgets._remote_awareness.states.get( + local_widget.awareness.client_id + ) + assert remote_state is not None + assert remote_state.get("role") == "local" From 2d8622709a2070ccaf425f17809605f9ac032ea8 Mon Sep 17 00:00:00 2001 From: Greg Mooney Date: Wed, 8 Apr 2026 11:25:49 +0200 Subject: [PATCH 5/6] Apply suggestions and update test --- src/ypywidgets/comm.py | 11 ----- tests/conftest.py | 1 - tests/test_comm_awareness.py | 80 +++++++++++++----------------------- 3 files changed, 28 insertions(+), 64 deletions(-) diff --git a/src/ypywidgets/comm.py b/src/ypywidgets/comm.py index d632d8c..4982d2a 100644 --- a/src/ypywidgets/comm.py +++ b/src/ypywidgets/comm.py @@ -1,5 +1,4 @@ from __future__ import annotations -from typing import Any, Callable import comm from pycrdt import ( @@ -103,16 +102,6 @@ def __init__( def awareness(self) -> Awareness: return self._comm_provider.awareness - def on_awareness_change( - self, - callback: Callable[[str, tuple[dict[str, Any], Any]], None], - ) -> str: - """Subscribe to pycrdt Awareness updates; returns subscription id for unobserve.""" - return self.awareness.observe(callback) - - def unobserve_awareness(self, subscription_id: str) -> None: - self.awareness.unobserve(subscription_id) - def _repr_mimebundle_(self, *args, **kwargs): # pragma: nocover plaintext = repr(self) if len(plaintext) > 110: diff --git a/tests/conftest.py b/tests/conftest.py index f81a9b1..b614dba 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -112,7 +112,6 @@ def __init__( self.local_widget_factory, self.remote_widget_factory = widget_factories self.local_widget: CommWidget | None = None self.remote_widget: Widget | None = None - self.remote_awareness: Awareness | None = None self.local_widget_created = Event() self.remote_widget_created = Event() self._remote_awareness: Awareness | None = None diff --git a/tests/test_comm_awareness.py b/tests/test_comm_awareness.py index 32b7132..7bf0fd0 100644 --- a/tests/test_comm_awareness.py +++ b/tests/test_comm_awareness.py @@ -1,73 +1,49 @@ from __future__ import annotations -from unittest.mock import patch - import pytest from anyio import sleep -from pycrdt import Doc, YMessageType, create_awareness_message - -from ypywidgets.comm import CommProvider, CommWidget +from pycrdt import Awareness, Doc, YMessageType, create_awareness_message pytestmark = pytest.mark.anyio -class DummyComm: - def __init__(self): - self.sent: list[bytes] = [] - self._handler = None - - def send(self, *, buffers=None, **kwargs): - if buffers: - self.sent.append(bytes(memoryview(buffers[0]))) - - def on_msg(self, handler): - self._handler = handler - - -def test_comm_provider_applies_awareness_frame(): - doc = Doc() - comm = DummyComm() - provider = CommProvider(doc, comm) - - awareness = provider.awareness - awareness.set_local_state({"role": "tester"}) - payload = awareness.encode_awareness_update([awareness.client_id]) - frame = create_awareness_message(payload) - - assert frame[0] == YMessageType.AWARENESS +async def test_comm_provider_applies_awareness_frame(synced_widgets, context): + async with context: + local_widget = await synced_widgets.get_local_widget() + remote_awareness = Awareness(Doc()) + remote_awareness.set_local_state({"role": "remote"}) + payload = remote_awareness.encode_awareness_update([remote_awareness.client_id]) + frame = create_awareness_message(payload) - provider._receive({"buffers": [frame]}) + assert frame[0] == YMessageType.AWARENESS - state = awareness.get_local_state() - assert state is not None - assert state.get("role") == "tester" + local_widget._comm_provider._receive({"buffers": [frame]}) + remote_state = local_widget.awareness.states.get(remote_awareness.client_id) + assert remote_state is not None + assert remote_state.get("role") == "remote" -@patch("ypywidgets.comm.create_widget_comm") -def test_comm_widget_exposes_provider_awareness(mock_create_comm): - comm = DummyComm() - mock_create_comm.return_value = comm - widget = CommWidget() - assert widget.awareness is widget._comm_provider.awareness +async def test_comm_widget_exposes_provider_awareness(synced_widgets, context): + async with context: + widget = await synced_widgets.get_local_widget() + assert widget.awareness is widget._comm_provider.awareness -@patch("ypywidgets.comm.create_widget_comm") -def test_comm_widget_awareness_observe_and_unobserve(mock_create_comm): - comm = DummyComm() - mock_create_comm.return_value = comm - widget = CommWidget() +async def test_comm_widget_awareness_observe_and_unobserve(synced_widgets, context): + async with context: + widget = await synced_widgets.get_local_widget() - events: list[str] = [] - sub_id = widget.on_awareness_change(lambda topic, _: events.append(topic)) + events: list[str] = [] + sub_id = widget.awareness.observe(lambda topic, _: events.append(topic)) - widget.awareness.set_local_state({"ping": 1}) - assert events + widget.awareness.set_local_state({"ping": 1}) + assert events - widget.unobserve_awareness(sub_id) - events.clear() - widget.awareness.set_local_state({"ping": 2}) - assert events == [] + widget.awareness.unobserve(sub_id) + events.clear() + widget.awareness.set_local_state({"ping": 2}) + assert events == [] async def test_remote_manager_applies_awareness_messages(synced_widgets, context): From 61782e404e619178b464cd00f4d09e20e4fe47a1 Mon Sep 17 00:00:00 2001 From: Greg Mooney Date: Thu, 9 Apr 2026 18:21:37 +0200 Subject: [PATCH 6/6] Remove test --- tests/conftest.py | 4 ++-- tests/test_comm_awareness.py | 25 ------------------------- 2 files changed, 2 insertions(+), 27 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index b614dba..b1e117c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -153,10 +153,10 @@ async def receive(self) -> None: if message[1] == YSyncMessageType.SYNC_STEP2: self.sub = self.remote_widget.ydoc.observe(self.send) self.remote_widget_created.set() - case YMessageType.AWARENESS: + case YMessageType.AWARENESS: # pragma: nocover if self._remote_awareness is None: self._remote_awareness = Awareness( - self.remote_widget.ydoc + self.remote_widget.ydoc # pragma: no ) payload = read_message(bytes(message[1:])) self._remote_awareness.apply_awareness_update(payload, None) diff --git a/tests/test_comm_awareness.py b/tests/test_comm_awareness.py index 7bf0fd0..4211eac 100644 --- a/tests/test_comm_awareness.py +++ b/tests/test_comm_awareness.py @@ -1,7 +1,6 @@ from __future__ import annotations import pytest -from anyio import sleep from pycrdt import Awareness, Doc, YMessageType, create_awareness_message pytestmark = pytest.mark.anyio @@ -44,27 +43,3 @@ async def test_comm_widget_awareness_observe_and_unobserve(synced_widgets, conte events.clear() widget.awareness.set_local_state({"ping": 2}) assert events == [] - - -async def test_remote_manager_applies_awareness_messages(synced_widgets, context): - async with context: - local_widget = await synced_widgets.get_local_widget() - await synced_widgets.get_remote_widget() - - local_widget.awareness.set_local_state({"role": "local"}) - payload = local_widget.awareness.encode_awareness_update( - [local_widget.awareness.client_id] - ) - frame = create_awareness_message(payload) - - synced_widgets.comm.send_send_stream.send_nowait( - ("comm_msg", {}, None, [frame], None, None) - ) - await sleep(0.01) - - assert synced_widgets._remote_awareness is not None - remote_state = synced_widgets._remote_awareness.states.get( - local_widget.awareness.client_id - ) - assert remote_state is not None - assert remote_state.get("role") == "local"