Skip to content

Commit 4a7b983

Browse files
committed
Support YRoom forking
1 parent fc46172 commit 4a7b983

File tree

14 files changed

+201
-32
lines changed

14 files changed

+201
-32
lines changed

jupyverse_api/jupyverse_api/yjs/__init__.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,13 @@ async def create_roomid(
3535
):
3636
return await self.create_roomid(path, request, response, user)
3737

38+
@router.put("/api/collaboration/room/{roomid}", status_code=201)
39+
async def fork_room(
40+
roomid,
41+
user: User = Depends(auth.current_user(permissions={"contents": ["read"]})),
42+
):
43+
return await self.fork_room(roomid, user)
44+
3845
self.include_router(router)
3946

4047
@abstractmethod
@@ -55,6 +62,14 @@ async def create_roomid(
5562
):
5663
...
5764

65+
@abstractmethod
66+
async def fork_room(
67+
self,
68+
roomid,
69+
user: User,
70+
):
71+
...
72+
5873
@abstractmethod
5974
def get_document(
6075
self,

plugins/contents/fps_contents/fileid.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,6 @@
77
from anyio import Path
88
from watchfiles import Change, awatch
99

10-
from jupyverse_api import Singleton
11-
1210
logger = logging.getLogger("contents")
1311

1412

@@ -30,7 +28,7 @@ def notify(self, change):
3028
self._event.set()
3129

3230

33-
class FileIdManager(metaclass=Singleton):
31+
class FileIdManager:
3432
db_path: str
3533
initialized: asyncio.Event
3634
watchers: Dict[str, List[Watcher]]

plugins/contents/fps_contents/routes.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@
2525

2626

2727
class _Contents(Contents):
28+
_file_id_manager: FileIdManager | None = None
29+
2830
async def create_checkpoint(
2931
self,
3032
path,
@@ -245,7 +247,9 @@ async def write_content(self, content: Union[SaveContent, Dict]) -> None:
245247

246248
@property
247249
def file_id_manager(self):
248-
return FileIdManager()
250+
if self._file_id_manager is None:
251+
self._file_id_manager = FileIdManager()
252+
return self._file_id_manager
249253

250254

251255
def get_available_path(path: Path, sep: str = "") -> Path:

plugins/yjs/fps_yjs/routes.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,26 @@ async def create_roomid(
9595
res["fileId"] = idx
9696
return res
9797

98+
async def fork_room(
99+
self,
100+
roomid: str,
101+
user: User,
102+
):
103+
idx = uuid4().hex
104+
105+
root_room = await self.room_manager.websocket_server.get_room(roomid)
106+
update = root_room.ydoc.get_update()
107+
new_ydoc = Doc()
108+
new_ydoc.apply_update(update)
109+
new_room = await self.room_manager.websocket_server.get_room(idx, new_ydoc)
110+
root_room.local_clients.add(new_room)
111+
112+
res = {
113+
"sessionId": SERVER_SESSION,
114+
"roomId": idx,
115+
}
116+
return res
117+
98118
def get_document(self, document_id: str) -> YBaseDoc:
99119
return self.room_manager.documents[document_id]
100120

@@ -223,7 +243,7 @@ async def serve(self, websocket: YWebsocket, permissions) -> None:
223243
await self.websocket_server.started.wait()
224244
await self.websocket_server.serve(websocket)
225245

226-
if is_stored_document and not room.clients:
246+
if is_stored_document and not room.remote_clients:
227247
# no client in this room after we disconnect
228248
self.cleaners[room] = asyncio.create_task(self.maybe_clean_room(room, websocket.path))
229249

plugins/yjs/fps_yjs/ywebsocket/websocket_server.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ async def _serve(self, websocket: Websocket, tg: TaskGroup):
151151
await self.start_room(room)
152152
await room.serve(websocket)
153153

154-
if self.auto_clean_rooms and not room.clients:
154+
if self.auto_clean_rooms and not room.remote_clients:
155155
self.delete_room(room=room)
156156
tg.cancel_scope.cancel()
157157

plugins/yjs/fps_yjs/ywebsocket/yroom.py

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@
2929

3030

3131
class YRoom:
32-
clients: list
32+
remote_clients: set
33+
local_clients: set[YRoom]
3334
ydoc: Doc
3435
ystore: BaseYStore | None
3536
_on_message: Callable[[bytes], Awaitable[bool] | bool] | None
@@ -76,7 +77,8 @@ def __init__(
7677
self.ready = ready
7778
self.ystore = ystore
7879
self.log = log or getLogger(__name__)
79-
self.clients = []
80+
self.remote_clients = set()
81+
self.local_clients = set()
8082
self._on_message = None
8183
self._started = None
8284
self._starting = False
@@ -133,10 +135,15 @@ async def _broadcast_updates(self):
133135
return
134136
# broadcast internal ydoc's update to all clients, that includes changes from the
135137
# clients and changes from the backend (out-of-band changes)
136-
for client in self.clients:
137-
self.log.debug("Sending Y update to client with endpoint: %s", client.path)
138+
for client in self.local_clients:
139+
client.ydoc.apply_update(update)
140+
if self.remote_clients:
138141
message = create_update_message(update)
139-
self._task_group.start_soon(client.send, message)
142+
for client in self.remote_clients:
143+
self.log.debug(
144+
"Sending Y update to remote client with endpoint: %s", client.path
145+
)
146+
self._task_group.start_soon(client.send, message)
140147
if self.ystore:
141148
self.log.debug("Writing Y update to YStore")
142149
self._task_group.start_soon(self.ystore.write, update)
@@ -197,7 +204,7 @@ async def serve(self, websocket: Websocket):
197204
websocket: The WebSocket through which to serve the client.
198205
"""
199206
async with create_task_group() as tg:
200-
self.clients.append(websocket)
207+
self.remote_clients.add(websocket)
201208
await sync(self.ydoc, websocket, self.log)
202209
try:
203210
async for message in websocket:
@@ -224,7 +231,7 @@ async def serve(self, websocket: Websocket):
224231
YMessageType.AWARENESS.name,
225232
websocket.path,
226233
)
227-
for client in self.clients:
234+
for client in self.remote_clients:
228235
self.log.debug(
229236
"Sending Y awareness from client with endpoint "
230237
"%s to client with endpoint: %s",
@@ -236,4 +243,4 @@ async def serve(self, websocket: Websocket):
236243
self.log.debug("Error serving endpoint: %s", websocket.path, exc_info=e)
237244

238245
# remove this client
239-
self.clients = [c for c in self.clients if c != websocket]
246+
self.remote_clients.remove(websocket)

tests/test_app.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import os
2+
13
import pytest
24
from asphalt.core import Context
35
from fastapi import APIRouter
@@ -17,7 +19,8 @@
1719
"/foo",
1820
),
1921
)
20-
async def test_mount_path(mount_path, unused_tcp_port):
22+
async def test_mount_path(mount_path, unused_tcp_port, tmp_path):
23+
os.chdir(tmp_path)
2124
components = configure({"app": {"type": "app"}}, {"app": {"mount_path": mount_path}})
2225

2326
async with Context() as ctx, AsyncClient() as http:

tests/test_auth.py

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import os
2+
13
import pytest
24
from asphalt.core import Context
35
from httpx import AsyncClient
@@ -20,7 +22,8 @@
2022

2123

2224
@pytest.mark.asyncio
23-
async def test_kernel_channels_unauthenticated(unused_tcp_port):
25+
async def test_kernel_channels_unauthenticated(unused_tcp_port, tmp_path):
26+
os.chdir(tmp_path)
2427
async with Context() as ctx:
2528
await JupyverseComponent(
2629
components=COMPONENTS,
@@ -35,7 +38,8 @@ async def test_kernel_channels_unauthenticated(unused_tcp_port):
3538

3639

3740
@pytest.mark.asyncio
38-
async def test_kernel_channels_authenticated(unused_tcp_port):
41+
async def test_kernel_channels_authenticated(unused_tcp_port, tmp_path):
42+
os.chdir(tmp_path)
3943
async with Context() as ctx, AsyncClient() as http:
4044
await JupyverseComponent(
4145
components=COMPONENTS,
@@ -52,7 +56,8 @@ async def test_kernel_channels_authenticated(unused_tcp_port):
5256

5357
@pytest.mark.asyncio
5458
@pytest.mark.parametrize("auth_mode", ("noauth", "token", "user"))
55-
async def test_root_auth(auth_mode, unused_tcp_port):
59+
async def test_root_auth(auth_mode, unused_tcp_port, tmp_path):
60+
os.chdir(tmp_path)
5661
components = configure(COMPONENTS, {"auth": {"mode": auth_mode}})
5762
async with Context() as ctx, AsyncClient() as http:
5863
await JupyverseComponent(
@@ -72,7 +77,8 @@ async def test_root_auth(auth_mode, unused_tcp_port):
7277

7378
@pytest.mark.asyncio
7479
@pytest.mark.parametrize("auth_mode", ("noauth",))
75-
async def test_no_auth(auth_mode, unused_tcp_port):
80+
async def test_no_auth(auth_mode, unused_tcp_port, tmp_path):
81+
os.chdir(tmp_path)
7682
components = configure(COMPONENTS, {"auth": {"mode": auth_mode}})
7783
async with Context() as ctx, AsyncClient() as http:
7884
await JupyverseComponent(
@@ -86,7 +92,8 @@ async def test_no_auth(auth_mode, unused_tcp_port):
8692

8793
@pytest.mark.asyncio
8894
@pytest.mark.parametrize("auth_mode", ("token",))
89-
async def test_token_auth(auth_mode, unused_tcp_port):
95+
async def test_token_auth(auth_mode, unused_tcp_port, tmp_path):
96+
os.chdir(tmp_path)
9097
components = configure(COMPONENTS, {"auth": {"mode": auth_mode}})
9198
async with Context() as ctx, AsyncClient() as http:
9299
await JupyverseComponent(
@@ -113,7 +120,8 @@ async def test_token_auth(auth_mode, unused_tcp_port):
113120
{"admin": ["read"], "foo": ["bar", "baz"]},
114121
),
115122
)
116-
async def test_permissions(auth_mode, permissions, unused_tcp_port):
123+
async def test_permissions(auth_mode, permissions, unused_tcp_port, tmp_path):
124+
os.chdir(tmp_path)
117125
components = configure(COMPONENTS, {"auth": {"mode": auth_mode}})
118126
async with Context() as ctx, AsyncClient() as http:
119127
await JupyverseComponent(

tests/test_contents.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@
1919
@pytest.mark.asyncio
2020
@pytest.mark.parametrize("auth_mode", ("noauth",))
2121
async def test_tree(auth_mode, tmp_path, unused_tcp_port):
22-
prev_dir = os.getcwd()
2322
os.chdir(tmp_path)
2423
dname = Path(".")
2524
expected = []
@@ -81,4 +80,3 @@ async def test_tree(auth_mode, tmp_path, unused_tcp_port):
8180
sort_content_by_name(actual)
8281
sort_content_by_name(expected)
8382
assert actual == expected
84-
os.chdir(prev_dir)

tests/test_execute.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import os
33
from functools import partial
44
from pathlib import Path
5+
from shutil import copytree, rmtree
56

67
import pytest
78
from asphalt.core import Context
@@ -27,6 +28,8 @@
2728
"yjs": {"type": "yjs"},
2829
}
2930

31+
HERE = Path(__file__).parent
32+
3033

3134
class Websocket:
3235
def __init__(self, websocket, roomid: str):
@@ -57,7 +60,11 @@ async def recv(self) -> bytes:
5760

5861
@pytest.mark.asyncio
5962
@pytest.mark.parametrize("auth_mode", ("noauth",))
60-
async def test_execute(auth_mode, unused_tcp_port):
63+
async def test_execute(auth_mode, unused_tcp_port, tmp_path):
64+
os.chdir(tmp_path)
65+
if Path("data").exists():
66+
rmtree("data")
67+
copytree(HERE / "data", "data")
6168
url = f"http://127.0.0.1:{unused_tcp_port}"
6269
components = configure(COMPONENTS, {
6370
"auth": {"mode": auth_mode},
@@ -71,7 +78,7 @@ async def test_execute(auth_mode, unused_tcp_port):
7178

7279
ws_url = url.replace("http", "ws", 1)
7380
name = "notebook1.ipynb"
74-
path = (Path("tests") / "data" / name).as_posix()
81+
path = (Path("data") / name).as_posix()
7582
# create a session to launch a kernel
7683
response = await http.post(
7784
f"{url}/api/sessions",
@@ -92,7 +99,8 @@ async def test_execute(auth_mode, unused_tcp_port):
9299
"type": "notebook",
93100
}
94101
)
95-
file_id = response.json()["fileId"]
102+
r = response.json()
103+
file_id = r["fileId"]
96104
document_id = f"json:notebook:{file_id}"
97105
ynb = ydocs["notebook"]()
98106
def callback(aevent, events, event):

0 commit comments

Comments
 (0)