Skip to content

Commit cf774a4

Browse files
Add a public API for getting a read-only view of the shared model (#275)
* Implement `get_document()` API * Return a live copy * Add `copy` argument * Update the docstring to reflect the `copy` arg Co-authored-by: David Brochart <[email protected]> --------- Co-authored-by: David Brochart <[email protected]>
1 parent 3ccda21 commit cf774a4

File tree

5 files changed

+104
-3
lines changed

5 files changed

+104
-3
lines changed

docs/source/developer/python_api.rst

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,25 @@
44
Python API
55
==========
66

7+
``jupyter_collaboration`` instantiates :any:`YDocExtension` and stores it under ``serverapp.settings`` dictionary, under the ``"jupyter_collaboration"`` key.
8+
This instance can be used in other extensions to access the public API methods.
9+
10+
For example, to access a read-only view of the shared notebook model in your jupyter-server extension, you can use the :any:`get_document` method:
11+
12+
.. code-block::
13+
14+
collaboration = serverapp.settings["jupyter_collaboration"]
15+
document = collaboration.get_document(
16+
path='Untitled.ipynb',
17+
content_type="notebook",
18+
file_format="json"
19+
)
20+
content = document.get()
21+
22+
23+
API Reference
24+
-------------
25+
726
.. automodule:: jupyter_collaboration.app
827
:members:
928
:inherited-members:

jupyter_collaboration/app.py

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,26 @@
33
from __future__ import annotations
44

55
import asyncio
6+
from typing import Literal
67

78
from jupyter_server.extension.application import ExtensionApp
9+
from jupyter_ydoc import ydocs as YDOCS
10+
from jupyter_ydoc.ybasedoc import YBaseDoc
11+
from pycrdt import Doc
812
from pycrdt_websocket.ystore import BaseYStore
913
from traitlets import Bool, Float, Type
1014

1115
from .handlers import DocSessionHandler, YDocWebSocketHandler
1216
from .loaders import FileLoaderMapping
17+
from .rooms import DocumentRoom
1318
from .stores import SQLiteYStore
14-
from .utils import AWARENESS_EVENTS_SCHEMA_PATH, EVENTS_SCHEMA_PATH
15-
from .websocketserver import JupyterWebsocketServer
19+
from .utils import (
20+
AWARENESS_EVENTS_SCHEMA_PATH,
21+
EVENTS_SCHEMA_PATH,
22+
encode_file_path,
23+
room_id_from_encoded_path,
24+
)
25+
from .websocketserver import JupyterWebsocketServer, RoomNotFound
1626

1727

1828
class YDocExtension(ExtensionApp):
@@ -124,6 +134,43 @@ def initialize_handlers(self):
124134
]
125135
)
126136

137+
async def get_document(
138+
self: YDocExtension,
139+
*,
140+
path: str,
141+
content_type: Literal["notebook", "file"],
142+
file_format: Literal["json", "text"],
143+
copy: bool = True,
144+
) -> YBaseDoc | None:
145+
"""Get a view of the shared model for the matching document.
146+
147+
If `copy=True`, the returned shared model is a fork, meaning that any changes
148+
made to it will not be propagated to the shared model used by the application.
149+
"""
150+
file_id_manager = self.serverapp.web_app.settings["file_id_manager"]
151+
file_id = file_id_manager.index(path)
152+
153+
encoded_path = encode_file_path(file_format, content_type, file_id)
154+
room_id = room_id_from_encoded_path(encoded_path)
155+
156+
try:
157+
room = await self.ywebsocket_server.get_room(room_id)
158+
except RoomNotFound:
159+
return None
160+
161+
if isinstance(room, DocumentRoom):
162+
if copy:
163+
update = room.ydoc.get_update()
164+
165+
fork_ydoc = Doc()
166+
fork_ydoc.apply_update(update)
167+
168+
return YDOCS.get(content_type, YDOCS["file"])(fork_ydoc)
169+
else:
170+
return room._document
171+
172+
return None
173+
127174
async def stop_extension(self):
128175
# Cancel tasks and clean up
129176
await asyncio.wait(

jupyter_collaboration/handlers.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
LogLevel,
2727
MessageType,
2828
decode_file_path,
29+
room_id_from_encoded_path,
2930
)
3031
from .websocketserver import JupyterWebsocketServer
3132

@@ -74,7 +75,7 @@ async def prepare(self):
7475
await self._websocket_server.started.wait()
7576

7677
# Get room
77-
self._room_id: str = self.request.path.split("/")[-1]
78+
self._room_id: str = room_id_from_encoded_path(self.request.path)
7879

7980
async with self._room_lock(self._room_id):
8081
if self._websocket_server.room_exists(self._room_id):

jupyter_collaboration/utils.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,3 +70,8 @@ def encode_file_path(format: str, file_type: str, file_id: str) -> str:
7070
path (str): File path.
7171
"""
7272
return f"{format}:{file_type}:{file_id}"
73+
74+
75+
def room_id_from_encoded_path(encoded_path: str) -> str:
76+
"""Transforms the encoded path into a stable room identifier."""
77+
return encoded_path.split("/")[-1]

tests/test_app.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33

44
from __future__ import annotations
55

6+
import pytest
7+
68
from jupyter_collaboration.stores import SQLiteYStore, TempFileYStore
79

810

@@ -59,3 +61,30 @@ def test_settings_should_change_ystore_class(jp_configurable_serverapp):
5961
settings = app.web_app.settings["jupyter_collaboration_config"]
6062

6163
assert settings["ystore_class"] == TempFileYStore
64+
65+
66+
@pytest.mark.parametrize("copy", [True, False])
67+
async def test_get_document_file(rtc_create_file, jp_serverapp, copy):
68+
path, content = await rtc_create_file("test.txt", "test", store=True)
69+
collaboration = jp_serverapp.web_app.settings["jupyter_collaboration"]
70+
document = await collaboration.get_document(
71+
path=path, content_type="file", file_format="text", copy=copy
72+
)
73+
assert document.get() == content == "test"
74+
await collaboration.stop_extension()
75+
76+
77+
async def test_get_document_file_copy_is_independent(
78+
rtc_create_file, jp_serverapp, rtc_fetch_session
79+
):
80+
path, content = await rtc_create_file("test.txt", "test", store=True)
81+
collaboration = jp_serverapp.web_app.settings["jupyter_collaboration"]
82+
document = await collaboration.get_document(
83+
path=path, content_type="file", file_format="text", copy=True
84+
)
85+
document.set("other")
86+
fresh_copy = await collaboration.get_document(
87+
path=path, content_type="file", file_format="text"
88+
)
89+
assert fresh_copy.get() == "test"
90+
await collaboration.stop_extension()

0 commit comments

Comments
 (0)