Skip to content

Commit da62498

Browse files
committed
Update CI and tests
1 parent 0c4c141 commit da62498

5 files changed

Lines changed: 186 additions & 127 deletions

File tree

.github/workflows/test.yml

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ jobs:
2020
fail-fast: false
2121
matrix:
2222
os: [ubuntu-latest, macos-latest, windows-latest]
23-
python-version: [ '3.8', '3.9', '3.10', '3.11', '3.12' ]
23+
python-version: [ '3.10', '3.11', '3.12', '3.13', '3.14' ]
2424

2525
steps:
2626
- name: Checkout
@@ -30,20 +30,17 @@ jobs:
3030
with:
3131
python-version: ${{ matrix.python-version }}
3232

33-
- name: Upgrade pip
34-
run: python3 -m pip install --upgrade pip
35-
3633
- name: Install ypywidgets in dev mode
37-
run: pip install -e ".[dev]"
34+
run: pip install -e . --group dev
3835

3936
- name: Check types
40-
run: mypy src
37+
run: mypy src tests
4138

4239
- name: Run tests
4340
run: pytest ./tests -v --color=yes
4441

4542
- name: Run code coverage
46-
if: ${{ (matrix.python-version == '3.12') && (matrix.os == 'ubuntu-latest') }}
43+
if: ${{ (matrix.python-version == '3.14') && (matrix.os == 'ubuntu-latest') }}
4744
run: |
4845
coverage run -m pytest tests
4946
coverage report --fail-under=100

pyproject.toml

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ version = "0.9.7"
88
description = "Y-based Jupyter widgets for Python"
99
readme = "README.md"
1010
license = "MIT"
11-
requires-python = ">=3.8"
11+
requires-python = ">=3.10"
1212
authors = [
1313
{ name = "David Brochart", email = "david.brochart@gmail.com" },
1414
]
@@ -24,14 +24,15 @@ dependencies = [
2424
]
2525

2626
[project.urls]
27-
Homepage = "https://github.com/davidbrochart/ypywidgets"
27+
Homepage = "https://github.com/QuantStack/ypywidgets"
2828

29-
[project.optional-dependencies]
29+
[dependency-groups]
3030
dev = [
3131
"coverage >=7.0.0,<8.0.0",
3232
"mypy",
3333
"pytest",
34-
"pytest-asyncio",
34+
"anyio",
35+
"trio",
3536
]
3637

3738
[tool.hatch.build.targets.wheel]

src/ypywidgets/comm.py

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -62,16 +62,16 @@ def awareness(self) -> Awareness:
6262

6363
def _receive(self, msg):
6464
message = bytes(msg["buffers"][0])
65-
mtype = message[0]
66-
if mtype == YMessageType.SYNC:
67-
reply = handle_sync_message(message[1:], self._ydoc)
68-
if reply is not None:
69-
self._comm.send(buffers=[reply])
70-
if message[1] == YSyncMessageType.SYNC_STEP2:
71-
self._ydoc.observe(self._send)
72-
elif mtype == YMessageType.AWARENESS:
73-
payload = read_message(message[1:])
74-
self._awareness.apply_awareness_update(payload, None)
65+
match message[0]:
66+
case YMessageType.SYNC:
67+
reply = handle_sync_message(message[1:], self._ydoc)
68+
if reply is not None:
69+
self._comm.send(buffers=[reply])
70+
if message[1] == YSyncMessageType.SYNC_STEP2:
71+
self._ydoc.observe(self._send)
72+
case YMessageType.AWARENESS:
73+
payload = read_message(message[1:])
74+
self._awareness.apply_awareness_update(payload, None)
7575

7676
def _send(self, event: TransactionEvent):
7777
update = event.update

tests/conftest.py

Lines changed: 117 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
1-
import asyncio
1+
import math
22
import time
3-
from typing import Optional
3+
from contextlib import AsyncExitStack
4+
from functools import partial
5+
from typing import Any, cast
46

57
import comm
68
import pytest
9+
from anyio import Event, create_memory_object_stream, create_task_group, fail_after
10+
from anyio.abc import TaskGroup
11+
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
712
from pycrdt import (
813
Awareness,
914
YMessageType,
@@ -17,92 +22,141 @@
1722
from ypywidgets import Widget
1823
from ypywidgets.comm import CommWidget
1924

25+
pytestmark = pytest.mark.anyio
26+
2027

2128
class MockComm(comm.base_comm.BaseComm):
2229

2330
def __init__(
24-
self,
25-
comm_id=None,
26-
target_name=None,
27-
data=None,
28-
metadata=None,
29-
):
30-
self.send_queue = asyncio.Queue()
31-
self.recv_queue = asyncio.Queue()
31+
self,
32+
task_group: TaskGroup,
33+
send_send_stream: MemoryObjectSendStream,
34+
send_recv_stream: MemoryObjectReceiveStream,
35+
recv_send_stream: MemoryObjectSendStream,
36+
recv_recv_stream: MemoryObjectReceiveStream,
37+
comm_id: str = "",
38+
target_name: str = "",
39+
data=None,
40+
metadata=None,
41+
) -> None:
42+
self.send_send_stream = send_send_stream
43+
self.send_recv_stream = send_recv_stream
44+
self.recv_send_stream = recv_send_stream
45+
self.recv_recv_stream = recv_recv_stream
3246
super().__init__(comm_id=comm_id, target_name=target_name, data=data, metadata=metadata)
33-
self.receive_task = asyncio.create_task(self.receive())
47+
task_group.start_soon(self.receive)
3448

3549
def publish_msg(self, msg_type, data, metadata, buffers, target_name=None, target_module=None):
36-
self.send_queue.put_nowait((msg_type, data, metadata, buffers, target_name, target_module))
50+
self.send_send_stream.send_nowait((msg_type, data, metadata, buffers, target_name, target_module))
3751

3852
def handle_msg(self, msg):
3953
self._msg_callback(msg)
4054

41-
async def receive(self):
55+
async def receive(self) -> None:
4256
while True:
43-
msg = await self.recv_queue.get()
57+
msg = await self.recv_recv_stream.receive()
4458
self.handle_msg(msg)
4559

4660

47-
comm.create_comm = MockComm
61+
class Context:
62+
def __init__(self):
63+
self.tasks = []
4864

65+
def add_task(self, task):
66+
self.tasks.append(task)
4967

50-
@pytest.fixture
51-
def widget_factories():
52-
return CommWidget, Widget
68+
async def __aenter__(self) -> Context:
69+
send_send_stream, send_recv_stream = create_memory_object_stream(max_buffer_size=math.inf)
70+
recv_send_stream, recv_recv_stream = create_memory_object_stream(max_buffer_size=math.inf)
71+
async with AsyncExitStack() as stack:
72+
await stack.enter_async_context(send_send_stream)
73+
await stack.enter_async_context(recv_send_stream)
74+
await stack.enter_async_context(send_recv_stream)
75+
await stack.enter_async_context(recv_recv_stream)
76+
self.task_group = await stack.enter_async_context(create_task_group())
77+
comm.create_comm = partial(MockComm, self.task_group, send_send_stream, send_recv_stream, recv_send_stream, recv_recv_stream)
78+
for task in self.tasks:
79+
self.task_group.start_soon(task)
80+
self.stack = stack.pop_all()
81+
return self
5382

83+
async def __aexit__(self, *exc) -> bool | None:
84+
self.task_group.cancel_scope.cancel()
85+
comm.create_comm = _create_comm
86+
return await self.stack.__aexit__(*exc)
5487

55-
@pytest.fixture
56-
async def synced_widgets(widget_factories):
57-
local_widget = widget_factories[0]()
58-
remote_widget_manager = RemoteWidgetManager(widget_factories[1], local_widget._comm)
59-
remote_widget = await remote_widget_manager.get_widget()
60-
return local_widget, remote_widget
6188

89+
def _create_comm(*args: Any, **kwargs: Any) -> comm.BaseComm:
90+
return comm.DummyComm(*args, **kwargs)
6291

63-
class RemoteWidgetManager:
6492

65-
comm: Optional[MockComm]
66-
widget: Optional[Widget]
93+
class SyncedWidgets:
6794

68-
def __init__(self, widget_factory, comm):
69-
self.widget_factory = widget_factory
70-
self.comm = comm
71-
self.widget = None
72-
self._remote_awareness: Optional[Awareness] = None
73-
self.receive_task = asyncio.create_task(self.receive())
95+
def __init__(self, widget_factories: tuple[type[CommWidget], type[Widget]], context: Context) -> None:
96+
self.local_widget_factory, self.remote_widget_factory = widget_factories
97+
self.local_widget: CommWidget | None = None
98+
self.remote_widget: Widget | None = None
99+
self._remote_awareness: Awareness | None = None
100+
self.local_widget_created = Event()
101+
self.remote_widget_created = Event()
102+
context.add_task(self.receive)
74103

75-
def send(self, event: TransactionEvent):
104+
def send(self, event: TransactionEvent) -> None:
76105
update = event.update
77106
message = create_update_message(update)
78-
self.comm.recv_queue.put_nowait({"buffers": [message]})
107+
self.comm.recv_send_stream.send_nowait({"buffers": [message]})
79108

80-
async def receive(self):
109+
async def receive(self) -> None:
110+
self.local_widget = self.local_widget_factory()
111+
self.local_widget_created.set()
112+
self.comm = cast(MockComm, self.local_widget._comm)
81113
while True:
82-
msg_type, data, metadata, buffers, target_name, target_module = await self.comm.send_queue.get()
83-
if msg_type == "comm_open":
84-
self.widget = self.widget_factory()
85-
msg = create_sync_message(self.widget.ydoc)
86-
self.comm.handle_msg({"buffers": [msg]})
87-
elif msg_type == "comm_msg":
88-
message = buffers[0]
89-
if message[0] == YMessageType.SYNC:
90-
reply = handle_sync_message(message[1:], self.widget.ydoc)
91-
if reply is not None:
92-
self.comm.handle_msg({"buffers": [reply]})
93-
if message[1] == YSyncMessageType.SYNC_STEP2:
94-
self.widget.ydoc.observe(self.send)
95-
elif message[0] == YMessageType.AWARENESS:
96-
if self._remote_awareness is None:
97-
self._remote_awareness = Awareness(self.widget.ydoc)
98-
payload = read_message(bytes(message[1:]))
99-
self._remote_awareness.apply_awareness_update(payload, None)
100-
101-
async def get_widget(self, timeout=0.1):
102-
t = time.monotonic()
103-
while True:
104-
if self.widget:
105-
return self.widget
106-
await asyncio.sleep(0)
107-
if time.monotonic() - t > timeout: # pragma: nocover
108-
raise TimeoutError("Timeout waiting for widget")
114+
msg_type, data, metadata, buffers, target_name, target_module = await self.comm.send_recv_stream.receive()
115+
match msg_type:
116+
case "comm_open":
117+
self.remote_widget = self.remote_widget_factory()
118+
msg = create_sync_message(self.remote_widget.ydoc)
119+
self.comm.handle_msg({"buffers": [msg]})
120+
case "comm_msg":
121+
assert self.remote_widget is not None
122+
message = buffers[0]
123+
match message[0]:
124+
case YMessageType.SYNC:
125+
reply = handle_sync_message(message[1:], self.remote_widget.ydoc)
126+
if reply is not None:
127+
self.comm.handle_msg({"buffers": [reply]})
128+
if message[1] == YSyncMessageType.SYNC_STEP2:
129+
self.sub = self.remote_widget.ydoc.observe(self.send)
130+
self.remote_widget_created.set()
131+
case YMessageType.AWARENESS:
132+
if self._remote_awareness is None:
133+
self._remote_awareness = Awareness(self.remote_widget.ydoc)
134+
payload = read_message(bytes(message[1:]))
135+
self._remote_awareness.apply_awareness_update(payload, None)
136+
137+
async def get_local_widget(self, timeout: float =0.2) -> CommWidget:
138+
with fail_after(timeout):
139+
await self.local_widget_created.wait()
140+
assert self.local_widget is not None
141+
return self.local_widget
142+
143+
async def get_remote_widget(self, timeout: float =0.2) -> Widget:
144+
with fail_after(timeout):
145+
await self.remote_widget_created.wait()
146+
assert self.remote_widget is not None
147+
return self.remote_widget
148+
149+
150+
@pytest.fixture
151+
def context() -> Context:
152+
return Context()
153+
154+
155+
@pytest.fixture
156+
def widget_factories() -> tuple[type[CommWidget], type[Widget]]:
157+
return CommWidget, Widget
158+
159+
160+
@pytest.fixture
161+
def synced_widgets(widget_factories: tuple[type[CommWidget], type[Widget]], context: Context) -> SyncedWidgets:
162+
return SyncedWidgets(widget_factories, context)

0 commit comments

Comments
 (0)