From cf774a4dcf3a19c41c4ca2b95c5f5421ce9953a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Krassowski?= <5832902+krassowski@users.noreply.github.com> Date: Fri, 12 Apr 2024 10:12:19 +0100 Subject: [PATCH] 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 --------- Co-authored-by: David Brochart --- docs/source/developer/python_api.rst | 19 +++++++++++ jupyter_collaboration/app.py | 51 ++++++++++++++++++++++++++-- jupyter_collaboration/handlers.py | 3 +- jupyter_collaboration/utils.py | 5 +++ tests/test_app.py | 29 ++++++++++++++++ 5 files changed, 104 insertions(+), 3 deletions(-) diff --git a/docs/source/developer/python_api.rst b/docs/source/developer/python_api.rst index bc634f3b..0cf352c5 100644 --- a/docs/source/developer/python_api.rst +++ b/docs/source/developer/python_api.rst @@ -4,6 +4,25 @@ Python API ========== +``jupyter_collaboration`` instantiates :any:`YDocExtension` and stores it under ``serverapp.settings`` dictionary, under the ``"jupyter_collaboration"`` key. +This instance can be used in other extensions to access the public API methods. + +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: + +.. code-block:: + + collaboration = serverapp.settings["jupyter_collaboration"] + document = collaboration.get_document( + path='Untitled.ipynb', + content_type="notebook", + file_format="json" + ) + content = document.get() + + +API Reference +------------- + .. automodule:: jupyter_collaboration.app :members: :inherited-members: diff --git a/jupyter_collaboration/app.py b/jupyter_collaboration/app.py index 72c327b1..a187a8bb 100644 --- a/jupyter_collaboration/app.py +++ b/jupyter_collaboration/app.py @@ -3,16 +3,26 @@ from __future__ import annotations import asyncio +from typing import Literal from jupyter_server.extension.application import ExtensionApp +from jupyter_ydoc import ydocs as YDOCS +from jupyter_ydoc.ybasedoc import YBaseDoc +from pycrdt import Doc from pycrdt_websocket.ystore import BaseYStore from traitlets import Bool, Float, Type from .handlers import DocSessionHandler, YDocWebSocketHandler from .loaders import FileLoaderMapping +from .rooms import DocumentRoom from .stores import SQLiteYStore -from .utils import AWARENESS_EVENTS_SCHEMA_PATH, EVENTS_SCHEMA_PATH -from .websocketserver import JupyterWebsocketServer +from .utils import ( + AWARENESS_EVENTS_SCHEMA_PATH, + EVENTS_SCHEMA_PATH, + encode_file_path, + room_id_from_encoded_path, +) +from .websocketserver import JupyterWebsocketServer, RoomNotFound class YDocExtension(ExtensionApp): @@ -124,6 +134,43 @@ def initialize_handlers(self): ] ) + async def get_document( + self: YDocExtension, + *, + path: str, + content_type: Literal["notebook", "file"], + file_format: Literal["json", "text"], + copy: bool = True, + ) -> YBaseDoc | None: + """Get a view of the shared model for the matching document. + + If `copy=True`, the returned shared model is a fork, meaning that any changes + made to it will not be propagated to the shared model used by the application. + """ + file_id_manager = self.serverapp.web_app.settings["file_id_manager"] + file_id = file_id_manager.index(path) + + encoded_path = encode_file_path(file_format, content_type, file_id) + room_id = room_id_from_encoded_path(encoded_path) + + try: + room = await self.ywebsocket_server.get_room(room_id) + except RoomNotFound: + return None + + if isinstance(room, DocumentRoom): + if copy: + update = room.ydoc.get_update() + + fork_ydoc = Doc() + fork_ydoc.apply_update(update) + + return YDOCS.get(content_type, YDOCS["file"])(fork_ydoc) + else: + return room._document + + return None + async def stop_extension(self): # Cancel tasks and clean up await asyncio.wait( diff --git a/jupyter_collaboration/handlers.py b/jupyter_collaboration/handlers.py index 81b64b3d..59849058 100644 --- a/jupyter_collaboration/handlers.py +++ b/jupyter_collaboration/handlers.py @@ -26,6 +26,7 @@ LogLevel, MessageType, decode_file_path, + room_id_from_encoded_path, ) from .websocketserver import JupyterWebsocketServer @@ -74,7 +75,7 @@ async def prepare(self): await self._websocket_server.started.wait() # Get room - self._room_id: str = self.request.path.split("/")[-1] + self._room_id: str = room_id_from_encoded_path(self.request.path) async with self._room_lock(self._room_id): if self._websocket_server.room_exists(self._room_id): diff --git a/jupyter_collaboration/utils.py b/jupyter_collaboration/utils.py index 0f8446a5..d1c74ce4 100644 --- a/jupyter_collaboration/utils.py +++ b/jupyter_collaboration/utils.py @@ -70,3 +70,8 @@ def encode_file_path(format: str, file_type: str, file_id: str) -> str: path (str): File path. """ return f"{format}:{file_type}:{file_id}" + + +def room_id_from_encoded_path(encoded_path: str) -> str: + """Transforms the encoded path into a stable room identifier.""" + return encoded_path.split("/")[-1] diff --git a/tests/test_app.py b/tests/test_app.py index becc278c..0f6d5c49 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -3,6 +3,8 @@ from __future__ import annotations +import pytest + from jupyter_collaboration.stores import SQLiteYStore, TempFileYStore @@ -59,3 +61,30 @@ def test_settings_should_change_ystore_class(jp_configurable_serverapp): settings = app.web_app.settings["jupyter_collaboration_config"] assert settings["ystore_class"] == TempFileYStore + + +@pytest.mark.parametrize("copy", [True, False]) +async def test_get_document_file(rtc_create_file, jp_serverapp, copy): + path, content = await rtc_create_file("test.txt", "test", store=True) + collaboration = jp_serverapp.web_app.settings["jupyter_collaboration"] + document = await collaboration.get_document( + path=path, content_type="file", file_format="text", copy=copy + ) + assert document.get() == content == "test" + await collaboration.stop_extension() + + +async def test_get_document_file_copy_is_independent( + rtc_create_file, jp_serverapp, rtc_fetch_session +): + path, content = await rtc_create_file("test.txt", "test", store=True) + collaboration = jp_serverapp.web_app.settings["jupyter_collaboration"] + document = await collaboration.get_document( + path=path, content_type="file", file_format="text", copy=True + ) + document.set("other") + fresh_copy = await collaboration.get_document( + path=path, content_type="file", file_format="text" + ) + assert fresh_copy.get() == "test" + await collaboration.stop_extension()