Skip to content

Commit ec93b8b

Browse files
authored
Add optional on_reset argument to YRoom get methods (#152)
* add on_reset callback to YRoom.get_{awareness,jupyter_ydoc,ydoc} * add unit test for on_reset() callback * fix mypy errors in yroom.py
1 parent a74b275 commit ec93b8b

File tree

5 files changed

+234
-9
lines changed

5 files changed

+234
-9
lines changed

conftest.py

Lines changed: 78 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,17 @@
1+
from __future__ import annotations
12
import pytest
23

4+
import asyncio
5+
import logging
6+
from traitlets.config import Config, LoggingConfigurable
7+
from jupyter_server.services.contents.filemanager import AsyncFileContentsManager
8+
from typing import TYPE_CHECKING
9+
from jupyter_server_documents.rooms.yroom_manager import YRoomManager
10+
11+
if TYPE_CHECKING:
12+
from jupyter_server.serverapp import ServerApp
13+
14+
315
pytest_plugins = ("pytest_jupyter.jupyter_server", "jupyter_server.pytest_plugin", "pytest_asyncio")
416

517

@@ -10,5 +22,69 @@ def pytest_configure(config):
1022

1123

1224
@pytest.fixture
13-
def jp_server_config(jp_server_config):
14-
return {"ServerApp": {"jpserver_extensions": {"jupyter_server_documents": True}}}
25+
def jp_server_config(jp_server_config, tmp_path):
26+
"""
27+
Fixture that defines the traitlets configuration used in unit tests.
28+
"""
29+
30+
return Config({
31+
"ServerApp": {
32+
"jpserver_extensions": {
33+
"jupyter_server_documents": True,
34+
"jupyter_server_fileid": True
35+
},
36+
"root_dir": str(tmp_path)
37+
},
38+
"ContentsManager": {"root_dir": str(tmp_path)}
39+
})
40+
41+
class MockServerDocsApp(LoggingConfigurable):
42+
"""Mock `ServerDocsApp` class for testing purposes."""
43+
44+
serverapp: ServerApp
45+
46+
def __init__(self, *args, serverapp: ServerApp, **kwargs):
47+
super().__init__(*args, **kwargs)
48+
self.serverapp = serverapp
49+
self._log = None
50+
51+
@property
52+
def log(self) -> logging.Logger:
53+
return self.serverapp.log
54+
55+
@property
56+
def event_loop(self) -> asyncio.AbstractEventLoop:
57+
return self.serverapp.io_loop.asyncio_loop
58+
59+
@property
60+
def contents_manager(self) -> AsyncFileContentsManager:
61+
return self.serverapp.contents_manager
62+
63+
64+
@pytest.fixture
65+
def mock_server_docs_app(jp_server_config, jp_configurable_serverapp) -> MockServerDocsApp:
66+
"""
67+
Returns a mocked `MockServerDocsApp` object that can be passed as the `parent`
68+
argument to objects normally initialized by `ServerDocsApp` in `app.py`.
69+
This should be passed to most of the "manager singletons" like
70+
`YRoomManager`.
71+
72+
See `MockServerDocsApp` in `conftest.py` for a complete description of the
73+
attributes, properties, and methods available. If something is missing,
74+
please feel free to add to it in your PR.
75+
76+
Returns:
77+
A `MockServerDocsApp` instance that can be passed as the `parent` argument
78+
to objects normally initialized by `ServerDocsApp`.
79+
"""
80+
serverapp = jp_configurable_serverapp()
81+
return MockServerDocsApp(config=jp_server_config, serverapp=serverapp)
82+
83+
@pytest.fixture
84+
def mock_yroom_manager(mock_server_docs_app) -> YRoomManager:
85+
"""
86+
Returns a mocked `YRoomManager` which can be passed as the `parent` argument
87+
to `YRoom` for testing purposes.
88+
"""
89+
90+
return YRoomManager(parent=mock_server_docs_app)

jupyter_server_documents/rooms/yroom.py

Lines changed: 59 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,14 @@ class YRoom(LoggingConfigurable):
144144
`unobserve_jupyter_ydoc()`.
145145
"""
146146

147+
# TODO: define a dataclass for this to ensure values are type-safe
148+
_on_reset_callbacks: dict[Literal['awareness', 'ydoc', 'jupyter_ydoc'], list[Callable[[Any], Any]]]
149+
"""
150+
Dictionary that stores all `on_reset` callbacks passed to `get_awareness()`,
151+
`get_jupyter_ydoc()`, or `get_ydoc()`. These are stored in lists under the
152+
'awareness', 'ydoc' and 'jupyter_ydoc' keys respectively.
153+
"""
154+
147155
_ydoc: pycrdt.Doc
148156
"""
149157
The `YDoc` for this room's document. See `get_ydoc()` documentation for more
@@ -197,6 +205,11 @@ def __init__(self, *args, **kwargs):
197205

198206
# Initialize instance attributes
199207
self._jupyter_ydoc_observers = {}
208+
self._on_reset_callbacks = {
209+
"awareness": [],
210+
"jupyter_ydoc": [],
211+
"ydoc": [],
212+
}
200213
self._stopped = False
201214
self._updated = False
202215
self._save_task = None
@@ -336,11 +349,16 @@ def clients(self) -> YjsClientGroup:
336349
return self._client_group
337350

338351

339-
async def get_jupyter_ydoc(self) -> YBaseDoc:
352+
async def get_jupyter_ydoc(self, on_reset: Callable[[YBaseDoc], Any] | None = None) -> YBaseDoc:
340353
"""
341-
Returns a reference to the room's JupyterYDoc
354+
Returns a reference to the room's Jupyter YDoc
342355
(`jupyter_ydoc.ybasedoc.YBaseDoc`) after waiting for its content to be
343356
loaded from the ContentsManager.
357+
358+
This method also accepts an `on_reset` callback, which should take a
359+
Jupyter YDoc as an argument. This callback is run with the new Jupyter
360+
YDoc whenever the YDoc is reset, e.g. in response to an out-of-band
361+
change.
344362
"""
345363
if self.room_id == "JupyterLab:globalAwareness":
346364
message = "There is no Jupyter ydoc for global awareness scenario"
@@ -350,23 +368,39 @@ async def get_jupyter_ydoc(self) -> YBaseDoc:
350368
raise RuntimeError("Jupyter YDoc is not available")
351369
if self.file_api:
352370
await self.file_api.until_content_loaded
371+
if on_reset:
372+
self._on_reset_callbacks['jupyter_ydoc'].append(on_reset)
373+
353374
return self._jupyter_ydoc
354375

355376

356-
async def get_ydoc(self) -> pycrdt.Doc:
377+
async def get_ydoc(self, on_reset: Callable[[pycrdt.Doc], Any] | None = None) -> pycrdt.Doc:
357378
"""
358379
Returns a reference to the room's YDoc (`pycrdt.Doc`) after
359380
waiting for its content to be loaded from the ContentsManager.
381+
382+
This method also accepts an `on_reset` callback, which should take a
383+
YDoc as an argument. This callback is run with the new YDoc object
384+
whenever the YDoc is reset, e.g. in response to an out-of-band change.
360385
"""
361386
if self.file_api:
362387
await self.file_api.until_content_loaded
388+
if on_reset:
389+
self._on_reset_callbacks['ydoc'].append(on_reset)
363390
return self._ydoc
364391

365392

366-
def get_awareness(self) -> pycrdt.Awareness:
393+
def get_awareness(self, on_reset: Callable[[pycrdt.Awareness], Any] | None = None) -> pycrdt.Awareness:
367394
"""
368395
Returns a reference to the room's awareness (`pycrdt.Awareness`).
396+
397+
This method also accepts an `on_reset` callback, which should take an
398+
Awareness object as an argument. This callback is run with the new
399+
Awareness object whenever the YDoc is reset, e.g. in response to an
400+
out-of-band change.
369401
"""
402+
if on_reset:
403+
self._on_reset_callbacks['awareness'].append(on_reset)
370404
return self._awareness
371405

372406
def get_cell_execution_states(self) -> dict:
@@ -914,13 +948,34 @@ def _reset_ydoc(self) -> None:
914948
"""
915949
Deletes and re-initializes the YDoc, awareness, and JupyterYDoc. This
916950
frees the memory occupied by their histories.
951+
952+
This runs all `on_reset` callbacks previously passed to `get_ydoc()`,
953+
`get_jupyter_ydoc()`, or `get_awareness()`.
917954
"""
918955
self._ydoc = self._init_ydoc()
919956
self._awareness = self._init_awareness(ydoc=self._ydoc)
920957
self._jupyter_ydoc = self._init_jupyter_ydoc(
921958
ydoc=self._ydoc,
922959
awareness=self._awareness
923960
)
961+
962+
# Run callbacks stored in `self._on_reset_callbacks`.
963+
objects_by_type = {
964+
"awareness": self._awareness,
965+
"jupyter_ydoc": self._jupyter_ydoc,
966+
"ydoc": self._ydoc,
967+
}
968+
for obj_type, obj in objects_by_type.items():
969+
# This is type-safe, but requires a mypy hint because it cannot
970+
# infer that `obj_type` only takes 3 values.
971+
for on_reset in self._on_reset_callbacks[obj_type]: # type: ignore
972+
try:
973+
result = on_reset(obj)
974+
if asyncio.iscoroutine(result):
975+
asyncio.create_task(result)
976+
except Exception:
977+
self.log.exception(f"Exception raised by '{obj_type}' on_reset() callback:")
978+
continue
924979

925980
@property
926981
def stopped(self) -> bool:
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
from __future__ import annotations
2+
import pytest
3+
import pytest_asyncio
4+
import os
5+
from unittest.mock import Mock
6+
from jupyter_server_documents.rooms.yroom import YRoom
7+
from typing import TYPE_CHECKING
8+
9+
if TYPE_CHECKING:
10+
from pathlib import Path
11+
from jupyter_server_documents.rooms import YRoomManager
12+
13+
@pytest.fixture
14+
def mock_textfile_path(tmp_path: Path):
15+
"""
16+
Returns the path of a mock text file under `/tmp`.
17+
18+
Automatically creates the file before each test & deletes the file after
19+
each test.
20+
"""
21+
# Create file before test and yield the path
22+
path: Path = tmp_path / "test.txt"
23+
path.touch()
24+
yield path
25+
26+
# Cleanup after test
27+
os.remove(path)
28+
29+
30+
@pytest_asyncio.fixture
31+
async def default_yroom(mock_yroom_manager: YRoomManager, mock_textfile_path: Path):
32+
"""
33+
Returns a configured `YRoom` instance that serves an empty text file under
34+
`/tmp`.
35+
36+
Uses the `mock_yroom_manager` fixture defined in `conftest.py`.
37+
"""
38+
# Get room ID
39+
file_id = mock_yroom_manager.fileid_manager.index(str(mock_textfile_path))
40+
room_id = f"text:file:{file_id}"
41+
42+
# Initialize room and wait until its content is loaded
43+
room: YRoom = YRoom(parent=mock_yroom_manager, room_id=room_id)
44+
await room.file_api.until_content_loaded
45+
46+
# Yield configured `YRoom`
47+
yield room
48+
49+
# Cleanup
50+
room.stop(immediately=True)
51+
52+
class TestDefaultYRoom():
53+
"""
54+
Tests that assert against the `default_yroom` fixture defined above.
55+
"""
56+
57+
@pytest.mark.asyncio
58+
async def test_on_reset_callbacks(self, default_yroom: YRoom):
59+
"""
60+
Asserts that the `on_reset()` callback passed to
61+
`YRoom.get_{awareness,jupyter_ydoc,ydoc}()` methods are each called with
62+
the expected object when the YDoc is reset.
63+
"""
64+
yroom = default_yroom
65+
66+
# Create mock callbacks
67+
awareness_reset_mock = Mock()
68+
jupyter_ydoc_reset_mock = Mock()
69+
ydoc_reset_mock = Mock()
70+
71+
# Call get methods while passing `on_reset` callbacks
72+
yroom.get_awareness(on_reset=awareness_reset_mock)
73+
await yroom.get_jupyter_ydoc(on_reset=jupyter_ydoc_reset_mock)
74+
await yroom.get_ydoc(on_reset=ydoc_reset_mock)
75+
76+
# Assert that each callback has not been called yet
77+
awareness_reset_mock.assert_not_called()
78+
jupyter_ydoc_reset_mock.assert_not_called()
79+
ydoc_reset_mock.assert_not_called()
80+
81+
# Reset the ydoc and get the new expected objects
82+
yroom._reset_ydoc()
83+
new_awareness = yroom.get_awareness()
84+
new_jupyter_ydoc = await yroom.get_jupyter_ydoc()
85+
new_ydoc = await yroom.get_ydoc()
86+
87+
# Assert that each callback was called exactly once with the expected
88+
# object
89+
awareness_reset_mock.assert_called_once_with(new_awareness)
90+
jupyter_ydoc_reset_mock.assert_called_once_with(new_jupyter_ydoc)
91+
ydoc_reset_mock.assert_called_once_with(new_ydoc)
92+

jupyter_server_documents/tests/test_yroom_file_api.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,8 @@ def mock_plaintext_file(tmp_path):
5454
os.remove(target_path)
5555

5656

57-
@pytest_asyncio.fixture(loop_scope="module")
58-
async def plaintext_file_api(
57+
@pytest.fixture
58+
def plaintext_file_api(
5959
mock_plaintext_file: str,
6060
jp_contents_manager: AsyncFileContentsManager,
6161
fileid_manager: ArbitraryFileIdManager

pyproject.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,9 @@ dynamic = ["version", "description", "authors", "urls", "keywords"]
3939
test = [
4040
"coverage",
4141
"pytest",
42-
"pytest-asyncio",
42+
# pytest-asyncio <1.1.0 incorrectly resolves async fixtures to async
43+
# generators instead of the yielded object.
44+
"pytest-asyncio>=1.1.0",
4345
"pytest-cov",
4446
"pytest-jupyter[server]>=0.6.0",
4547
]

0 commit comments

Comments
 (0)