Skip to content

Commit 4a2a508

Browse files
Update CI, tests and project (#46)
1 parent 5346549 commit 4a2a508

6 files changed

Lines changed: 227 additions & 135 deletions

File tree

.github/workflows/test.yml

Lines changed: 9 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,22 @@ 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
35+
36+
- name: Lint
37+
run: |
38+
ruff format --check src tests
39+
ruff check src tests
3840
3941
- name: Check types
40-
run: mypy src
42+
run: mypy src tests
4143

4244
- name: Run tests
4345
run: pytest ./tests -v --color=yes
4446

4547
- name: Run code coverage
46-
if: ${{ (matrix.python-version == '3.12') && (matrix.os == 'ubuntu-latest') }}
48+
if: ${{ (matrix.python-version == '3.14') && (matrix.os == 'ubuntu-latest') }}
4749
run: |
4850
coverage run -m pytest tests
4951
coverage report --fail-under=100

pyproject.toml

Lines changed: 6 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,16 @@ 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 = [
31+
"anyio",
3132
"coverage >=7.0.0,<8.0.0",
3233
"mypy",
3334
"pytest",
34-
"pytest-asyncio",
35+
"ruff",
36+
"trio",
3537
]
3638

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

src/ypywidgets/comm.py

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -54,12 +54,13 @@ def __init__(
5454

5555
def _receive(self, msg):
5656
message = bytes(msg["buffers"][0])
57-
if message[0] == YMessageType.SYNC:
58-
reply = handle_sync_message(message[1:], self._ydoc)
59-
if reply is not None:
60-
self._comm.send(buffers=[reply])
61-
if message[1] == YSyncMessageType.SYNC_STEP2:
62-
self._ydoc.observe(self._send)
57+
match message[0]:
58+
case YMessageType.SYNC:
59+
reply = handle_sync_message(message[1:], self._ydoc)
60+
if reply is not None:
61+
self._comm.send(buffers=[reply])
62+
if message[1] == YSyncMessageType.SYNC_STEP2:
63+
self._ydoc.observe(self._send)
6364

6465
def _send(self, event: TransactionEvent):
6566
update = event.update
@@ -69,12 +70,12 @@ def _send(self, event: TransactionEvent):
6970

7071
class CommWidget(Widget):
7172
def __init__(
72-
self,
73-
ydoc: Doc | None = None,
74-
comm_data: dict | None = None,
75-
comm_metadata: dict | None = None,
76-
comm_id: str | None = None,
77-
):
73+
self,
74+
ydoc: Doc | None = None,
75+
comm_data: dict | None = None,
76+
comm_metadata: dict | None = None,
77+
comm_id: str | None = None,
78+
):
7879
super().__init__(ydoc)
7980
model_name = self.__class__.__name__
8081
_model_name = self.ydoc["_model_name"] = Text()
@@ -90,13 +91,13 @@ def __init__(
9091
def _repr_mimebundle_(self, *args, **kwargs): # pragma: nocover
9192
plaintext = repr(self)
9293
if len(plaintext) > 110:
93-
plaintext = plaintext[:110] + '…'
94+
plaintext = plaintext[:110] + "…"
9495
data = {
9596
"text/plain": plaintext,
9697
"application/vnd.jupyter.ywidget-view+json": {
9798
"version_major": 2,
9899
"version_minor": 0,
99100
"model_id": self._comm.comm_id,
100-
}
101+
},
101102
}
102103
return data

src/ypywidgets/reactive.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010

1111

1212
class Reactive(_Reactive, Generic[ValueType]):
13-
1413
def __init__(
1514
self,
1615
default: ValueType,

tests/conftest.py

Lines changed: 147 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
1-
import asyncio
2-
import time
3-
from typing import Optional
1+
import math
2+
from contextlib import AsyncExitStack
3+
from functools import partial
4+
from typing import Any, cast
45

56
import comm
67
import pytest
8+
from anyio import Event, create_memory_object_stream, create_task_group, fail_after
9+
from anyio.abc import TaskGroup
10+
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
711
from pycrdt import (
812
YMessageType,
913
YSyncMessageType,
@@ -15,86 +19,163 @@
1519
from ypywidgets import Widget
1620
from ypywidgets.comm import CommWidget
1721

22+
pytestmark = pytest.mark.anyio
1823

19-
class MockComm(comm.base_comm.BaseComm):
2024

25+
class MockComm(comm.base_comm.BaseComm):
2126
def __init__(
22-
self,
23-
comm_id=None,
24-
target_name=None,
25-
data=None,
26-
metadata=None,
27+
self,
28+
task_group: TaskGroup,
29+
send_send_stream: MemoryObjectSendStream,
30+
send_recv_stream: MemoryObjectReceiveStream,
31+
recv_send_stream: MemoryObjectSendStream,
32+
recv_recv_stream: MemoryObjectReceiveStream,
33+
comm_id: str = "",
34+
target_name: str = "",
35+
data=None,
36+
metadata=None,
37+
) -> None:
38+
self.send_send_stream = send_send_stream
39+
self.send_recv_stream = send_recv_stream
40+
self.recv_send_stream = recv_send_stream
41+
self.recv_recv_stream = recv_recv_stream
42+
super().__init__(
43+
comm_id=comm_id, target_name=target_name, data=data, metadata=metadata
44+
)
45+
task_group.start_soon(self.receive)
46+
47+
def publish_msg(
48+
self, msg_type, data, metadata, buffers, target_name=None, target_module=None
2749
):
28-
self.send_queue = asyncio.Queue()
29-
self.recv_queue = asyncio.Queue()
30-
super().__init__(comm_id=comm_id, target_name=target_name, data=data, metadata=metadata)
31-
self.receive_task = asyncio.create_task(self.receive())
32-
33-
def publish_msg(self, msg_type, data, metadata, buffers, target_name=None, target_module=None):
34-
self.send_queue.put_nowait((msg_type, data, metadata, buffers, target_name, target_module))
50+
self.send_send_stream.send_nowait(
51+
(msg_type, data, metadata, buffers, target_name, target_module)
52+
)
3553

3654
def handle_msg(self, msg):
3755
self._msg_callback(msg)
3856

39-
async def receive(self):
57+
async def receive(self) -> None:
4058
while True:
41-
msg = await self.recv_queue.get()
59+
msg = await self.recv_recv_stream.receive()
4260
self.handle_msg(msg)
4361

4462

45-
comm.create_comm = MockComm
46-
63+
class Context:
64+
def __init__(self):
65+
self.tasks = []
66+
67+
def add_task(self, task):
68+
self.tasks.append(task)
69+
70+
async def __aenter__(self) -> "Context":
71+
send_send_stream, send_recv_stream = create_memory_object_stream(
72+
max_buffer_size=math.inf
73+
)
74+
recv_send_stream, recv_recv_stream = create_memory_object_stream(
75+
max_buffer_size=math.inf
76+
)
77+
async with AsyncExitStack() as stack:
78+
await stack.enter_async_context(send_send_stream)
79+
await stack.enter_async_context(recv_send_stream)
80+
await stack.enter_async_context(send_recv_stream)
81+
await stack.enter_async_context(recv_recv_stream)
82+
self.task_group = await stack.enter_async_context(create_task_group())
83+
comm.create_comm = partial(
84+
MockComm,
85+
self.task_group,
86+
send_send_stream,
87+
send_recv_stream,
88+
recv_send_stream,
89+
recv_recv_stream,
90+
)
91+
for task in self.tasks:
92+
self.task_group.start_soon(task)
93+
self.stack = stack.pop_all()
94+
return self
95+
96+
async def __aexit__(self, *exc) -> bool | None:
97+
self.task_group.cancel_scope.cancel()
98+
comm.create_comm = _create_comm
99+
return await self.stack.__aexit__(*exc)
100+
101+
102+
def _create_comm(*args: Any, **kwargs: Any) -> comm.BaseComm:
103+
return comm.DummyComm(*args, **kwargs) # pragma: nocover
104+
105+
106+
class SyncedWidgets:
107+
def __init__(
108+
self, widget_factories: tuple[type[CommWidget], type[Widget]], context: Context
109+
) -> None:
110+
self.local_widget_factory, self.remote_widget_factory = widget_factories
111+
self.local_widget: CommWidget | None = None
112+
self.remote_widget: Widget | None = None
113+
self.local_widget_created = Event()
114+
self.remote_widget_created = Event()
115+
context.add_task(self.receive)
116+
117+
def send(self, event: TransactionEvent) -> None:
118+
update = event.update
119+
message = create_update_message(update)
120+
self.comm.recv_send_stream.send_nowait({"buffers": [message]})
47121

48-
@pytest.fixture
49-
def widget_factories():
50-
return CommWidget, Widget
122+
async def receive(self) -> None:
123+
self.local_widget = self.local_widget_factory()
124+
self.local_widget_created.set()
125+
self.comm = cast(MockComm, self.local_widget._comm)
126+
while True:
127+
(
128+
msg_type,
129+
data,
130+
metadata,
131+
buffers,
132+
target_name,
133+
target_module,
134+
) = await self.comm.send_recv_stream.receive()
135+
match msg_type:
136+
case "comm_open":
137+
self.remote_widget = self.remote_widget_factory()
138+
msg = create_sync_message(self.remote_widget.ydoc)
139+
self.comm.handle_msg({"buffers": [msg]})
140+
case "comm_msg":
141+
assert self.remote_widget is not None
142+
message = buffers[0]
143+
match message[0]:
144+
case YMessageType.SYNC:
145+
reply = handle_sync_message(
146+
message[1:], self.remote_widget.ydoc
147+
)
148+
if reply is not None:
149+
self.comm.handle_msg({"buffers": [reply]})
150+
if message[1] == YSyncMessageType.SYNC_STEP2:
151+
self.sub = self.remote_widget.ydoc.observe(self.send)
152+
self.remote_widget_created.set()
153+
154+
async def get_local_widget(self, timeout: float = 0.2) -> CommWidget:
155+
with fail_after(timeout):
156+
await self.local_widget_created.wait()
157+
assert self.local_widget is not None
158+
return self.local_widget
159+
160+
async def get_remote_widget(self, timeout: float = 0.2) -> Widget:
161+
with fail_after(timeout):
162+
await self.remote_widget_created.wait()
163+
assert self.remote_widget is not None
164+
return self.remote_widget
51165

52166

53167
@pytest.fixture
54-
async def synced_widgets(widget_factories):
55-
local_widget = widget_factories[0]()
56-
remote_widget_manager = RemoteWidgetManager(widget_factories[1], local_widget._comm)
57-
remote_widget = await remote_widget_manager.get_widget()
58-
return local_widget, remote_widget
168+
def context() -> Context:
169+
return Context()
59170

60171

61-
class RemoteWidgetManager:
62-
63-
comm: Optional[MockComm]
64-
widget: Optional[Widget]
172+
@pytest.fixture
173+
def widget_factories() -> tuple[type[CommWidget], type[Widget]]:
174+
return CommWidget, Widget
65175

66-
def __init__(self, widget_factory, comm):
67-
self.widget_factory = widget_factory
68-
self.comm = comm
69-
self.widget = None
70-
self.receive_task = asyncio.create_task(self.receive())
71176

72-
def send(self, event: TransactionEvent):
73-
update = event.update
74-
message = create_update_message(update)
75-
self.comm.recv_queue.put_nowait({"buffers": [message]})
76-
77-
async def receive(self):
78-
while True:
79-
msg_type, data, metadata, buffers, target_name, target_module = await self.comm.send_queue.get()
80-
if msg_type == "comm_open":
81-
self.widget = self.widget_factory()
82-
msg = create_sync_message(self.widget.ydoc)
83-
self.comm.handle_msg({"buffers": [msg]})
84-
elif msg_type == "comm_msg":
85-
message = buffers[0]
86-
if message[0] == YMessageType.SYNC:
87-
reply = handle_sync_message(message[1:], self.widget.ydoc)
88-
if reply is not None:
89-
self.comm.handle_msg({"buffers": [reply]})
90-
if message[1] == YSyncMessageType.SYNC_STEP2:
91-
self.widget.ydoc.observe(self.send)
92-
93-
async def get_widget(self, timeout=0.1):
94-
t = time.monotonic()
95-
while True:
96-
if self.widget:
97-
return self.widget
98-
await asyncio.sleep(0)
99-
if time.monotonic() - t > timeout: # pragma: nocover
100-
raise TimeoutError("Timeout waiting for widget")
177+
@pytest.fixture
178+
def synced_widgets(
179+
widget_factories: tuple[type[CommWidget], type[Widget]], context: Context
180+
) -> SyncedWidgets:
181+
return SyncedWidgets(widget_factories, context)

0 commit comments

Comments
 (0)