From 382a4f12cce73889c078295634a9953d19aa5eec Mon Sep 17 00:00:00 2001 From: Greg Mooney Date: Thu, 2 Apr 2026 17:55:13 +0200 Subject: [PATCH 1/9] Support awareness over Comm --- src/ypywidgets/comm.py | 24 +++++++++++++++++++++++- tests/conftest.py | 2 ++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/ypywidgets/comm.py b/src/ypywidgets/comm.py index 10f004c..c6b5155 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,10 +51,15 @@ 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]) match message[0]: @@ -86,7 +94,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 ede26f5..6222232 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,12 +9,14 @@ from anyio.abc import TaskGroup from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream 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 From f53d0a08068ffee72c5401b624e5c75bbd5099f4 Mon Sep 17 00:00:00 2001 From: Greg Mooney Date: Tue, 7 Apr 2026 12:07:40 +0200 Subject: [PATCH 2/9] 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 6222232..0ef8e7f 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() context.add_task(self.receive) 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 087482dec61cc8e980c5aabfc344a4fa6c046034 Mon Sep 17 00:00:00 2001 From: Greg Mooney Date: Tue, 7 Apr 2026 12:34:07 +0200 Subject: [PATCH 3/9] 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 75b7f0e79d4f59bdfc67e99bfbff30819a50de9e Mon Sep 17 00:00:00 2001 From: Greg Mooney Date: Tue, 7 Apr 2026 12:50:23 +0200 Subject: [PATCH 4/9] 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 ab7b3f5d6e6a9839f6b7e76d2eab6ab71c0a3888 Mon Sep 17 00:00:00 2001 From: Greg Mooney Date: Wed, 8 Apr 2026 11:25:49 +0200 Subject: [PATCH 5/9] 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 c6b5155..85b89e0 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 ( @@ -100,16 +99,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 0ef8e7f..6222232 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() context.add_task(self.receive) 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 a178fc666469e05f5c6c1a5babf0308081bf9fb6 Mon Sep 17 00:00:00 2001 From: Greg Mooney Date: Thu, 9 Apr 2026 18:21:37 +0200 Subject: [PATCH 6/9] Remove test --- tests/test_comm_awareness.py | 25 ------------------------- 1 file changed, 25 deletions(-) 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" From 121e201762a8a00bcb619f949241fca8922ad741 Mon Sep 17 00:00:00 2001 From: David Brochart Date: Mon, 13 Apr 2026 16:31:47 +0200 Subject: [PATCH 7/9] Apply suggestions --- tests/test_comm_awareness.py | 33 ++++++++++++++++----------------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/tests/test_comm_awareness.py b/tests/test_comm_awareness.py index 4211eac..59369cc 100644 --- a/tests/test_comm_awareness.py +++ b/tests/test_comm_awareness.py @@ -2,6 +2,7 @@ import pytest from pycrdt import Awareness, Doc, YMessageType, create_awareness_message +from ypywidgets.comm import CommWidget pytestmark = pytest.mark.anyio @@ -12,34 +13,32 @@ async def test_comm_provider_applies_awareness_frame(synced_widgets, context): 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) + message = create_awareness_message(payload) - assert frame[0] == YMessageType.AWARENESS + assert message[0] == YMessageType.AWARENESS - local_widget._comm_provider._receive({"buffers": [frame]}) + local_widget._comm_provider._receive({"buffers": [message]}) remote_state = local_widget.awareness.states.get(remote_awareness.client_id) assert remote_state is not None assert remote_state.get("role") == "remote" -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 +async def test_comm_widget_exposes_provider_awareness(): + widget = CommWidget() + assert widget.awareness is widget._comm_provider.awareness async def test_comm_widget_awareness_observe_and_unobserve(synced_widgets, context): - async with context: - widget = await synced_widgets.get_local_widget() + widget = CommWidget() - events: list[str] = [] - sub_id = widget.awareness.observe(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.awareness.unobserve(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 == [] From 8121994e771b17837fa3de8bc8c78dbaa3734a39 Mon Sep 17 00:00:00 2001 From: Greg Mooney Date: Mon, 13 Apr 2026 16:41:13 +0200 Subject: [PATCH 8/9] Lint --- src/ypywidgets/comm.py | 1 - tests/conftest.py | 2 -- 2 files changed, 3 deletions(-) diff --git a/src/ypywidgets/comm.py b/src/ypywidgets/comm.py index 85b89e0..85fd871 100644 --- a/src/ypywidgets/comm.py +++ b/src/ypywidgets/comm.py @@ -11,7 +11,6 @@ create_sync_message, create_update_message, handle_sync_message, - read_message, ) from .widget import Widget diff --git a/tests/conftest.py b/tests/conftest.py index 6222232..ede26f5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,14 +9,12 @@ from anyio.abc import TaskGroup from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream 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 From bd130d1fefa77294c38be06999c4a0a97e23031f Mon Sep 17 00:00:00 2001 From: Greg Mooney Date: Mon, 13 Apr 2026 17:25:14 +0200 Subject: [PATCH 9/9] Me good at rebase >_> --- src/ypywidgets/comm.py | 5 +++++ tests/test_comm_awareness.py | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/ypywidgets/comm.py b/src/ypywidgets/comm.py index 85fd871..b2d1d91 100644 --- a/src/ypywidgets/comm.py +++ b/src/ypywidgets/comm.py @@ -11,6 +11,7 @@ create_sync_message, create_update_message, handle_sync_message, + read_message, ) from .widget import Widget @@ -67,6 +68,10 @@ def _receive(self, msg): self._comm.send(buffers=[reply]) if message[1] == YSyncMessageType.SYNC_STEP2: self._ydoc.observe(self._send) + case YMessageType.AWARENESS: + # Same as pycrdt.websocket.yroom: strip Y message kind, decode body. + update = read_message(message[1:]) + self._awareness.apply_awareness_update(update, None) def _send(self, event: TransactionEvent): update = event.update diff --git a/tests/test_comm_awareness.py b/tests/test_comm_awareness.py index 59369cc..5f9ca0f 100644 --- a/tests/test_comm_awareness.py +++ b/tests/test_comm_awareness.py @@ -7,7 +7,7 @@ pytestmark = pytest.mark.anyio -async def test_comm_provider_applies_awareness_frame(synced_widgets, context): +async def test_comm_provider_applies_awareness_message(synced_widgets, context): async with context: local_widget = await synced_widgets.get_local_widget() remote_awareness = Awareness(Doc()) @@ -29,7 +29,7 @@ async def test_comm_widget_exposes_provider_awareness(): assert widget.awareness is widget._comm_provider.awareness -async def test_comm_widget_awareness_observe_and_unobserve(synced_widgets, context): +async def test_comm_widget_awareness_observe_and_unobserve(): widget = CommWidget() events: list[str] = []