Skip to content

Commit

Permalink
Add forking API (#394)
Browse files Browse the repository at this point in the history
* Add forking API

* Add GET forks of root

* Add fork Jupyter events

* Replace query parameter merge=1 with merge=true

* Add fork title and description
  • Loading branch information
davidbrochart committed Nov 8, 2024
1 parent 3f9b0db commit 47811fd
Show file tree
Hide file tree
Showing 7 changed files with 374 additions and 0 deletions.
10 changes: 10 additions & 0 deletions projects/jupyter-server-ydoc/jupyter_server_ydoc/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from traitlets import Bool, Float, Type

from .handlers import (
DocForkHandler,
DocSessionHandler,
TimelineHandler,
UndoRedoHandler,
Expand All @@ -25,6 +26,7 @@
from .utils import (
AWARENESS_EVENTS_SCHEMA_PATH,
EVENTS_SCHEMA_PATH,
FORK_EVENTS_SCHEMA_PATH,
encode_file_path,
room_id_from_encoded_path,
)
Expand Down Expand Up @@ -85,6 +87,7 @@ def initialize(self):
super().initialize()
self.serverapp.event_logger.register_event_schema(EVENTS_SCHEMA_PATH)
self.serverapp.event_logger.register_event_schema(AWARENESS_EVENTS_SCHEMA_PATH)
self.serverapp.event_logger.register_event_schema(FORK_EVENTS_SCHEMA_PATH)

def initialize_settings(self):
self.settings.update(
Expand Down Expand Up @@ -123,6 +126,13 @@ def initialize_handlers(self):

self.handlers.extend(
[
(
r"/api/collaboration/fork/(.*)",
DocForkHandler,
{
"ywebsocket_server": self.ywebsocket_server,
},
),
(
r"/api/collaboration/room/(.*)",
YDocWebSocketHandler,
Expand Down
56 changes: 56 additions & 0 deletions projects/jupyter-server-ydoc/jupyter_server_ydoc/events/fork.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
"$id": https://schema.jupyter.org/jupyter_collaboration/fork/v1
"$schema": "http://json-schema.org/draft-07/schema"
version: 1
title: Collaborative fork events
personal-data: true
description: |
Fork events emitted from server-side during a collaborative session.
type: object
required:
- fork_roomid
- fork_info
- username
- action
properties:
fork_roomid:
type: string
description: |
Fork root room ID.
fork_info:
type: object
description: |
Fork root room information.
required:
- root_roomid
- synchronize
- title
- description
properties:
root_roomid:
type: string
description: |
Root room ID. Usually composed by the file type, format and ID.
synchronize:
type: boolean
description: |
Whether the fork is kept in sync with the root.
title:
type: string
description: |
The title of the fork.
description:
type: string
description: |
The description of the fork.
username:
type: string
description: |
The name of the user who created or deleted the fork.
action:
enum:
- create
- delete
description: |
Possible values:
1. create
2. delete
100 changes: 100 additions & 0 deletions projects/jupyter-server-ydoc/jupyter_server_ydoc/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
from .utils import (
JUPYTER_COLLABORATION_AWARENESS_EVENTS_URI,
JUPYTER_COLLABORATION_EVENTS_URI,
JUPYTER_COLLABORATION_FORK_EVENTS_URI,
LogLevel,
MessageType,
decode_file_path,
Expand All @@ -39,6 +40,7 @@

SERVER_SESSION = str(uuid.uuid4())
FORK_DOCUMENTS = {}
FORK_ROOMS: dict[str, dict[str, str]] = {}


class YDocWebSocketHandler(WebSocketHandler, JupyterHandler):
Expand Down Expand Up @@ -600,3 +602,101 @@ async def _cleanup_undo_manager(self, room_id: str) -> None:
if room_id in FORK_DOCUMENTS:
del FORK_DOCUMENTS[room_id]
self.log.info(f"Fork Document for {room_id} has been removed.")


class DocForkHandler(APIHandler):
"""
Jupyter Server handler to:
- create a fork of a root document (optionally synchronizing with the root document),
- delete a fork of a root document (optionally merging back in the root document).
- get fork IDs of a root document.
"""

auth_resource = "contents"

def initialize(
self,
ywebsocket_server: JupyterWebsocketServer,
) -> None:
self._websocket_server = ywebsocket_server

@web.authenticated
@authorized
async def get(self, root_roomid):
"""
Returns a dictionary of fork room ID to fork room information for the given root room ID.
"""
self.write(
{
fork_roomid: fork_info
for fork_roomid, fork_info in FORK_ROOMS.items()
if fork_info["root_roomid"] == root_roomid
}
)

@web.authenticated
@authorized
async def put(self, root_roomid):
"""
Creates a fork of a root document and returns its ID.
Optionally keeps the fork in sync with the root.
"""
fork_roomid = uuid4().hex
root_room = await self._websocket_server.get_room(root_roomid)
update = root_room.ydoc.get_update()
fork_ydoc = Doc()
fork_ydoc.apply_update(update)
model = self.get_json_body()
synchronize = model.get("synchronize", False)
if synchronize:
root_room.ydoc.observe(lambda event: fork_ydoc.apply_update(event.update))
FORK_ROOMS[fork_roomid] = fork_info = {
"root_roomid": root_roomid,
"synchronize": synchronize,
"title": model.get("title", ""),
"description": model.get("description", ""),
}
fork_room = YRoom(ydoc=fork_ydoc)
self._websocket_server.rooms[fork_roomid] = fork_room
await self._websocket_server.start_room(fork_room)
self._emit_fork_event(self.current_user.username, fork_roomid, fork_info, "create")
data = json.dumps(
{
"sessionId": SERVER_SESSION,
"fork_roomid": fork_roomid,
"fork_info": fork_info,
}
)
self.set_status(201)
return self.finish(data)

@web.authenticated
@authorized
async def delete(self, fork_roomid):
"""
Deletes a forked document, and optionally merges it back in the root document.
"""
fork_info = FORK_ROOMS[fork_roomid]
root_roomid = fork_info["root_roomid"]
del FORK_ROOMS[fork_roomid]
if self.get_query_argument("merge") == "true":
root_room = await self._websocket_server.get_room(root_roomid)
root_ydoc = root_room.ydoc
fork_room = await self._websocket_server.get_room(fork_roomid)
fork_ydoc = fork_room.ydoc
fork_update = fork_ydoc.get_update()
root_ydoc.apply_update(fork_update)
await self._websocket_server.delete_room(name=fork_roomid)
self._emit_fork_event(self.current_user.username, fork_roomid, fork_info, "delete")
self.set_status(200)

def _emit_fork_event(
self, username: str, fork_roomid: str, fork_info: dict[str, str], action: str
) -> None:
data = {
"username": username,
"fork_roomid": fork_roomid,
"fork_info": fork_info,
"action": action,
}
self.event_logger.emit(schema_id=JUPYTER_COLLABORATION_FORK_EVENTS_URI, data=data)
59 changes: 59 additions & 0 deletions projects/jupyter-server-ydoc/jupyter_server_ydoc/pytest_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,65 @@ async def _inner(format: str, type: str, path: str) -> Any:
return _inner


@pytest.fixture
def rtc_connect_fork_client(jp_http_port, jp_base_url, rtc_fetch_session):
async def _inner(room_id: str) -> Any:
return connect(
f"ws://127.0.0.1:{jp_http_port}{jp_base_url}api/collaboration/room/{room_id}"
)

return _inner


@pytest.fixture
def rtc_get_forks_client(jp_fetch):
async def _inner(root_roomid: str) -> Any:
return await jp_fetch(
"/api/collaboration/fork",
root_roomid,
method="GET",
)

return _inner


@pytest.fixture
def rtc_create_fork_client(jp_fetch):
async def _inner(
root_roomid: str,
synchronize: bool,
title: str | None = None,
description: str | None = None,
) -> Any:
return await jp_fetch(
"/api/collaboration/fork",
root_roomid,
method="PUT",
body=json.dumps(
{
"synchronize": synchronize,
"title": title,
"description": description,
}
),
)

return _inner


@pytest.fixture
def rtc_delete_fork_client(jp_fetch):
async def _inner(fork_roomid: str, merge: bool) -> Any:
return await jp_fetch(
"/api/collaboration/fork",
fork_roomid,
method="DELETE",
params={"merge": str(merge).lower()},
)

return _inner


@pytest.fixture
def rtc_add_doc_to_store(rtc_connect_doc_client):
event = Event()
Expand Down
2 changes: 2 additions & 0 deletions projects/jupyter-server-ydoc/jupyter_server_ydoc/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@
JUPYTER_COLLABORATION_AWARENESS_EVENTS_URI = (
"https://schema.jupyter.org/jupyter_collaboration/awareness/v1"
)
JUPYTER_COLLABORATION_FORK_EVENTS_URI = "https://schema.jupyter.org/jupyter_collaboration/fork/v1"
AWARENESS_EVENTS_SCHEMA_PATH = EVENTS_FOLDER_PATH / "awareness.yaml"
FORK_EVENTS_SCHEMA_PATH = EVENTS_FOLDER_PATH / "fork.yaml"


class MessageType(IntEnum):
Expand Down
1 change: 1 addition & 0 deletions projects/jupyter-server-ydoc/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ dynamic = ["version"]
[project.optional-dependencies]
test = [
"coverage",
"dirty-equals",
"jupyter_server[test]>=2.4.0",
"jupyter_server_fileid[test]",
"pytest>=7.0",
Expand Down
Loading

0 comments on commit 47811fd

Please sign in to comment.