jupyter-server-ydoc 1.0.0rc1__tar.gz → 1.1.0a0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. {jupyter_server_ydoc-1.0.0rc1 → jupyter_server_ydoc-1.1.0a0}/PKG-INFO +4 -3
  2. jupyter_server_ydoc-1.1.0a0/jupyter_server_ydoc/_version.py +1 -0
  3. {jupyter_server_ydoc-1.0.0rc1 → jupyter_server_ydoc-1.1.0a0}/jupyter_server_ydoc/app.py +10 -0
  4. jupyter_server_ydoc-1.1.0a0/jupyter_server_ydoc/events/fork.yaml +56 -0
  5. {jupyter_server_ydoc-1.0.0rc1 → jupyter_server_ydoc-1.1.0a0}/jupyter_server_ydoc/handlers.py +100 -0
  6. {jupyter_server_ydoc-1.0.0rc1 → jupyter_server_ydoc-1.1.0a0}/jupyter_server_ydoc/pytest_plugin.py +76 -9
  7. {jupyter_server_ydoc-1.0.0rc1 → jupyter_server_ydoc-1.1.0a0}/jupyter_server_ydoc/test_utils.py +30 -0
  8. {jupyter_server_ydoc-1.0.0rc1 → jupyter_server_ydoc-1.1.0a0}/jupyter_server_ydoc/utils.py +2 -0
  9. {jupyter_server_ydoc-1.0.0rc1 → jupyter_server_ydoc-1.1.0a0}/pyproject.toml +3 -1
  10. {jupyter_server_ydoc-1.0.0rc1 → jupyter_server_ydoc-1.1.0a0}/tests/test_documents.py +11 -8
  11. {jupyter_server_ydoc-1.0.0rc1 → jupyter_server_ydoc-1.1.0a0}/tests/test_handlers.py +157 -15
  12. jupyter_server_ydoc-1.0.0rc1/jupyter_server_ydoc/_version.py +0 -1
  13. {jupyter_server_ydoc-1.0.0rc1 → jupyter_server_ydoc-1.1.0a0}/.gitignore +0 -0
  14. {jupyter_server_ydoc-1.0.0rc1 → jupyter_server_ydoc-1.1.0a0}/LICENSE +0 -0
  15. {jupyter_server_ydoc-1.0.0rc1 → jupyter_server_ydoc-1.1.0a0}/README.md +0 -0
  16. {jupyter_server_ydoc-1.0.0rc1 → jupyter_server_ydoc-1.1.0a0}/jupyter-config/jupyter_server_ydoc.json +0 -0
  17. {jupyter_server_ydoc-1.0.0rc1 → jupyter_server_ydoc-1.1.0a0}/jupyter_server_ydoc/__init__.py +0 -0
  18. {jupyter_server_ydoc-1.0.0rc1 → jupyter_server_ydoc-1.1.0a0}/jupyter_server_ydoc/events/awareness.yaml +0 -0
  19. {jupyter_server_ydoc-1.0.0rc1 → jupyter_server_ydoc-1.1.0a0}/jupyter_server_ydoc/events/session.yaml +0 -0
  20. {jupyter_server_ydoc-1.0.0rc1 → jupyter_server_ydoc-1.1.0a0}/jupyter_server_ydoc/loaders.py +0 -0
  21. {jupyter_server_ydoc-1.0.0rc1 → jupyter_server_ydoc-1.1.0a0}/jupyter_server_ydoc/rooms.py +0 -0
  22. {jupyter_server_ydoc-1.0.0rc1 → jupyter_server_ydoc-1.1.0a0}/jupyter_server_ydoc/stores.py +0 -0
  23. {jupyter_server_ydoc-1.0.0rc1 → jupyter_server_ydoc-1.1.0a0}/jupyter_server_ydoc/websocketserver.py +0 -0
  24. {jupyter_server_ydoc-1.0.0rc1 → jupyter_server_ydoc-1.1.0a0}/setup.py +0 -0
  25. {jupyter_server_ydoc-1.0.0rc1 → jupyter_server_ydoc-1.1.0a0}/tests/__init__.py +0 -0
  26. {jupyter_server_ydoc-1.0.0rc1 → jupyter_server_ydoc-1.1.0a0}/tests/conftest.py +0 -0
  27. {jupyter_server_ydoc-1.0.0rc1 → jupyter_server_ydoc-1.1.0a0}/tests/test_app.py +0 -0
  28. {jupyter_server_ydoc-1.0.0rc1 → jupyter_server_ydoc-1.1.0a0}/tests/test_loaders.py +0 -0
  29. {jupyter_server_ydoc-1.0.0rc1 → jupyter_server_ydoc-1.1.0a0}/tests/test_rooms.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: jupyter-server-ydoc
3
- Version: 1.0.0rc1
3
+ Version: 1.1.0a0
4
4
  Summary: jupyter-server extension integrating collaborative shared models.
5
5
  Author-email: Jupyter Development Team <jupyter@googlegroups.com>
6
6
  License: # Licensing terms
@@ -62,7 +62,6 @@ License: # Licensing terms
62
62
 
63
63
  # Copyright (c) Jupyter Development Team.
64
64
  # Distributed under the terms of the Modified BSD License.
65
- License-File: LICENSE
66
65
  Classifier: Framework :: Jupyter
67
66
  Classifier: Intended Audience :: Developers
68
67
  Classifier: Intended Audience :: Science/Research
@@ -83,13 +82,15 @@ Requires-Dist: jupyter-ydoc<4.0.0,>=2.1.2
83
82
  Requires-Dist: pycrdt
84
83
  Requires-Dist: pycrdt-websocket<0.16.0,>=0.15.0
85
84
  Provides-Extra: test
85
+ Requires-Dist: anyio; extra == 'test'
86
86
  Requires-Dist: coverage; extra == 'test'
87
+ Requires-Dist: dirty-equals; extra == 'test'
88
+ Requires-Dist: httpx-ws>=0.5.2; extra == 'test'
87
89
  Requires-Dist: importlib-metadata>=4.8.3; (python_version < '3.10') and extra == 'test'
88
90
  Requires-Dist: jupyter-server-fileid[test]; extra == 'test'
89
91
  Requires-Dist: jupyter-server[test]>=2.4.0; extra == 'test'
90
92
  Requires-Dist: pytest-cov; extra == 'test'
91
93
  Requires-Dist: pytest>=7.0; extra == 'test'
92
- Requires-Dist: websockets; extra == 'test'
93
94
  Description-Content-Type: text/markdown
94
95
 
95
96
  # jupyter-server-ydoc
@@ -0,0 +1 @@
1
+ __version__ = "1.1.0.a0"
@@ -14,6 +14,7 @@ from pycrdt_websocket.ystore import BaseYStore
14
14
  from traitlets import Bool, Float, Type
15
15
 
16
16
  from .handlers import (
17
+ DocForkHandler,
17
18
  DocSessionHandler,
18
19
  TimelineHandler,
19
20
  UndoRedoHandler,
@@ -25,6 +26,7 @@ from .stores import SQLiteYStore
25
26
  from .utils import (
26
27
  AWARENESS_EVENTS_SCHEMA_PATH,
27
28
  EVENTS_SCHEMA_PATH,
29
+ FORK_EVENTS_SCHEMA_PATH,
28
30
  encode_file_path,
29
31
  room_id_from_encoded_path,
30
32
  )
@@ -85,6 +87,7 @@ class YDocExtension(ExtensionApp):
85
87
  super().initialize()
86
88
  self.serverapp.event_logger.register_event_schema(EVENTS_SCHEMA_PATH)
87
89
  self.serverapp.event_logger.register_event_schema(AWARENESS_EVENTS_SCHEMA_PATH)
90
+ self.serverapp.event_logger.register_event_schema(FORK_EVENTS_SCHEMA_PATH)
88
91
 
89
92
  def initialize_settings(self):
90
93
  self.settings.update(
@@ -123,6 +126,13 @@ class YDocExtension(ExtensionApp):
123
126
 
124
127
  self.handlers.extend(
125
128
  [
129
+ (
130
+ r"/api/collaboration/fork/(.*)",
131
+ DocForkHandler,
132
+ {
133
+ "ywebsocket_server": self.ywebsocket_server,
134
+ },
135
+ ),
126
136
  (
127
137
  r"/api/collaboration/room/(.*)",
128
138
  YDocWebSocketHandler,
@@ -0,0 +1,56 @@
1
+ "$id": https://schema.jupyter.org/jupyter_collaboration/fork/v1
2
+ "$schema": "http://json-schema.org/draft-07/schema"
3
+ version: 1
4
+ title: Collaborative fork events
5
+ personal-data: true
6
+ description: |
7
+ Fork events emitted from server-side during a collaborative session.
8
+ type: object
9
+ required:
10
+ - fork_roomid
11
+ - fork_info
12
+ - username
13
+ - action
14
+ properties:
15
+ fork_roomid:
16
+ type: string
17
+ description: |
18
+ Fork root room ID.
19
+ fork_info:
20
+ type: object
21
+ description: |
22
+ Fork root room information.
23
+ required:
24
+ - root_roomid
25
+ - synchronize
26
+ - title
27
+ - description
28
+ properties:
29
+ root_roomid:
30
+ type: string
31
+ description: |
32
+ Root room ID. Usually composed by the file type, format and ID.
33
+ synchronize:
34
+ type: boolean
35
+ description: |
36
+ Whether the fork is kept in sync with the root.
37
+ title:
38
+ type: string
39
+ description: |
40
+ The title of the fork.
41
+ description:
42
+ type: string
43
+ description: |
44
+ The description of the fork.
45
+ username:
46
+ type: string
47
+ description: |
48
+ The name of the user who created or deleted the fork.
49
+ action:
50
+ enum:
51
+ - create
52
+ - delete
53
+ description: |
54
+ Possible values:
55
+ 1. create
56
+ 2. delete
@@ -26,6 +26,7 @@ from .rooms import DocumentRoom, TransientRoom
26
26
  from .utils import (
27
27
  JUPYTER_COLLABORATION_AWARENESS_EVENTS_URI,
28
28
  JUPYTER_COLLABORATION_EVENTS_URI,
29
+ JUPYTER_COLLABORATION_FORK_EVENTS_URI,
29
30
  LogLevel,
30
31
  MessageType,
31
32
  decode_file_path,
@@ -39,6 +40,7 @@ YFILE = YDOCS["file"]
39
40
 
40
41
  SERVER_SESSION = str(uuid.uuid4())
41
42
  FORK_DOCUMENTS = {}
43
+ FORK_ROOMS: dict[str, dict[str, str]] = {}
42
44
 
43
45
 
44
46
  class YDocWebSocketHandler(WebSocketHandler, JupyterHandler):
@@ -600,3 +602,101 @@ class UndoRedoHandler(APIHandler):
600
602
  if room_id in FORK_DOCUMENTS:
601
603
  del FORK_DOCUMENTS[room_id]
602
604
  self.log.info(f"Fork Document for {room_id} has been removed.")
605
+
606
+
607
+ class DocForkHandler(APIHandler):
608
+ """
609
+ Jupyter Server handler to:
610
+ - create a fork of a root document (optionally synchronizing with the root document),
611
+ - delete a fork of a root document (optionally merging back in the root document).
612
+ - get fork IDs of a root document.
613
+ """
614
+
615
+ auth_resource = "contents"
616
+
617
+ def initialize(
618
+ self,
619
+ ywebsocket_server: JupyterWebsocketServer,
620
+ ) -> None:
621
+ self._websocket_server = ywebsocket_server
622
+
623
+ @web.authenticated
624
+ @authorized
625
+ async def get(self, root_roomid):
626
+ """
627
+ Returns a dictionary of fork room ID to fork room information for the given root room ID.
628
+ """
629
+ self.write(
630
+ {
631
+ fork_roomid: fork_info
632
+ for fork_roomid, fork_info in FORK_ROOMS.items()
633
+ if fork_info["root_roomid"] == root_roomid
634
+ }
635
+ )
636
+
637
+ @web.authenticated
638
+ @authorized
639
+ async def put(self, root_roomid):
640
+ """
641
+ Creates a fork of a root document and returns its ID.
642
+ Optionally keeps the fork in sync with the root.
643
+ """
644
+ fork_roomid = uuid4().hex
645
+ root_room = await self._websocket_server.get_room(root_roomid)
646
+ update = root_room.ydoc.get_update()
647
+ fork_ydoc = Doc()
648
+ fork_ydoc.apply_update(update)
649
+ model = self.get_json_body()
650
+ synchronize = model.get("synchronize", False)
651
+ if synchronize:
652
+ root_room.ydoc.observe(lambda event: fork_ydoc.apply_update(event.update))
653
+ FORK_ROOMS[fork_roomid] = fork_info = {
654
+ "root_roomid": root_roomid,
655
+ "synchronize": synchronize,
656
+ "title": model.get("title", ""),
657
+ "description": model.get("description", ""),
658
+ }
659
+ fork_room = YRoom(ydoc=fork_ydoc)
660
+ self._websocket_server.rooms[fork_roomid] = fork_room
661
+ await self._websocket_server.start_room(fork_room)
662
+ self._emit_fork_event(self.current_user.username, fork_roomid, fork_info, "create")
663
+ data = json.dumps(
664
+ {
665
+ "sessionId": SERVER_SESSION,
666
+ "fork_roomid": fork_roomid,
667
+ "fork_info": fork_info,
668
+ }
669
+ )
670
+ self.set_status(201)
671
+ return self.finish(data)
672
+
673
+ @web.authenticated
674
+ @authorized
675
+ async def delete(self, fork_roomid):
676
+ """
677
+ Deletes a forked document, and optionally merges it back in the root document.
678
+ """
679
+ fork_info = FORK_ROOMS[fork_roomid]
680
+ root_roomid = fork_info["root_roomid"]
681
+ del FORK_ROOMS[fork_roomid]
682
+ if self.get_query_argument("merge") == "true":
683
+ root_room = await self._websocket_server.get_room(root_roomid)
684
+ root_ydoc = root_room.ydoc
685
+ fork_room = await self._websocket_server.get_room(fork_roomid)
686
+ fork_ydoc = fork_room.ydoc
687
+ fork_update = fork_ydoc.get_update()
688
+ root_ydoc.apply_update(fork_update)
689
+ await self._websocket_server.delete_room(name=fork_roomid)
690
+ self._emit_fork_event(self.current_user.username, fork_roomid, fork_info, "delete")
691
+ self.set_status(200)
692
+
693
+ def _emit_fork_event(
694
+ self, username: str, fork_roomid: str, fork_info: dict[str, str], action: str
695
+ ) -> None:
696
+ data = {
697
+ "username": username,
698
+ "fork_roomid": fork_roomid,
699
+ "fork_info": fork_info,
700
+ "action": action,
701
+ }
702
+ self.event_logger.emit(schema_id=JUPYTER_COLLABORATION_FORK_EVENTS_URI, data=data)
@@ -9,14 +9,19 @@ from typing import Any
9
9
 
10
10
  import nbformat
11
11
  import pytest
12
+ from httpx_ws import aconnect_ws
12
13
  from jupyter_server_ydoc.loaders import FileLoader
13
14
  from jupyter_server_ydoc.rooms import DocumentRoom
14
15
  from jupyter_server_ydoc.stores import SQLiteYStore
15
16
  from jupyter_ydoc import YNotebook, YUnicode
16
17
  from pycrdt_websocket import WebsocketProvider
17
- from websockets import connect
18
18
 
19
- from .test_utils import FakeContentsManager, FakeEventLogger, FakeFileIDManager
19
+ from .test_utils import (
20
+ FakeContentsManager,
21
+ FakeEventLogger,
22
+ FakeFileIDManager,
23
+ Websocket,
24
+ )
20
25
 
21
26
 
22
27
  @pytest.fixture
@@ -126,8 +131,8 @@ def rtc_fetch_session(jp_fetch):
126
131
  @pytest.fixture
127
132
  def rtc_connect_awareness_client(jp_http_port, jp_base_url):
128
133
  async def _inner(room_id: str) -> Any:
129
- return connect(
130
- f"ws://127.0.0.1:{jp_http_port}{jp_base_url}api/collaboration/room/{room_id}"
134
+ return aconnect_ws(
135
+ f"http://127.0.0.1:{jp_http_port}{jp_base_url}api/collaboration/room/{room_id}"
131
136
  )
132
137
 
133
138
  return _inner
@@ -138,8 +143,71 @@ def rtc_connect_doc_client(jp_http_port, jp_base_url, rtc_fetch_session):
138
143
  async def _inner(format: str, type: str, path: str) -> Any:
139
144
  resp = await rtc_fetch_session(format, type, path)
140
145
  data = json.loads(resp.body.decode("utf-8"))
141
- return connect(
142
- f"ws://127.0.0.1:{jp_http_port}{jp_base_url}api/collaboration/room/{data['format']}:{data['type']}:{data['fileId']}?sessionId={data['sessionId']}"
146
+ room_name = f"{data['format']}:{data['type']}:{data['fileId']}"
147
+ return (
148
+ aconnect_ws(
149
+ f"http://127.0.0.1:{jp_http_port}{jp_base_url}api/collaboration/room/{room_name}?sessionId={data['sessionId']}"
150
+ ),
151
+ room_name,
152
+ )
153
+
154
+ return _inner
155
+
156
+
157
+ @pytest.fixture
158
+ def rtc_connect_fork_client(jp_http_port, jp_base_url, rtc_fetch_session):
159
+ async def _inner(room_id: str) -> Any:
160
+ return aconnect_ws(
161
+ f"http://127.0.0.1:{jp_http_port}{jp_base_url}api/collaboration/room/{room_id}"
162
+ )
163
+
164
+ return _inner
165
+
166
+
167
+ @pytest.fixture
168
+ def rtc_get_forks_client(jp_fetch):
169
+ async def _inner(root_roomid: str) -> Any:
170
+ return await jp_fetch(
171
+ "/api/collaboration/fork",
172
+ root_roomid,
173
+ method="GET",
174
+ )
175
+
176
+ return _inner
177
+
178
+
179
+ @pytest.fixture
180
+ def rtc_create_fork_client(jp_fetch):
181
+ async def _inner(
182
+ root_roomid: str,
183
+ synchronize: bool,
184
+ title: str | None = None,
185
+ description: str | None = None,
186
+ ) -> Any:
187
+ return await jp_fetch(
188
+ "/api/collaboration/fork",
189
+ root_roomid,
190
+ method="PUT",
191
+ body=json.dumps(
192
+ {
193
+ "synchronize": synchronize,
194
+ "title": title,
195
+ "description": description,
196
+ }
197
+ ),
198
+ )
199
+
200
+ return _inner
201
+
202
+
203
+ @pytest.fixture
204
+ def rtc_delete_fork_client(jp_fetch):
205
+ async def _inner(fork_roomid: str, merge: bool) -> Any:
206
+ return await jp_fetch(
207
+ "/api/collaboration/fork",
208
+ fork_roomid,
209
+ method="DELETE",
210
+ params={"merge": str(merge).lower()},
143
211
  )
144
212
 
145
213
  return _inner
@@ -162,9 +230,8 @@ def rtc_add_doc_to_store(rtc_connect_doc_client):
162
230
 
163
231
  doc.observe(_on_document_change)
164
232
 
165
- async with await rtc_connect_doc_client(format, type, path) as ws, WebsocketProvider(
166
- doc.ydoc, ws
167
- ):
233
+ websocket, room_name = await rtc_connect_doc_client(format, type, path)
234
+ async with websocket as ws, WebsocketProvider(doc.ydoc, Websocket(ws, room_name)):
168
235
  await event.wait()
169
236
  await sleep(0.1)
170
237
 
@@ -6,6 +6,7 @@ from __future__ import annotations
6
6
  from datetime import datetime
7
7
  from typing import Any
8
8
 
9
+ from anyio import Lock
9
10
  from jupyter_server import _tz as tz
10
11
 
11
12
 
@@ -55,3 +56,32 @@ class FakeContentsManager:
55
56
  class FakeEventLogger:
56
57
  def emit(self, schema_id: str, data: dict) -> None:
57
58
  print(data)
59
+
60
+
61
+ class Websocket:
62
+ def __init__(self, websocket: Any, path: str):
63
+ self._websocket = websocket
64
+ self._path = path
65
+ self._send_lock = Lock()
66
+
67
+ @property
68
+ def path(self) -> str:
69
+ return self._path
70
+
71
+ def __aiter__(self):
72
+ return self
73
+
74
+ async def __anext__(self) -> bytes:
75
+ try:
76
+ message = await self.recv()
77
+ except Exception:
78
+ raise StopAsyncIteration()
79
+ return message
80
+
81
+ async def send(self, message: bytes) -> None:
82
+ async with self._send_lock:
83
+ await self._websocket.send_bytes(message)
84
+
85
+ async def recv(self) -> bytes:
86
+ b = await self._websocket.receive_bytes()
87
+ return bytes(b)
@@ -11,7 +11,9 @@ EVENTS_SCHEMA_PATH = EVENTS_FOLDER_PATH / "session.yaml"
11
11
  JUPYTER_COLLABORATION_AWARENESS_EVENTS_URI = (
12
12
  "https://schema.jupyter.org/jupyter_collaboration/awareness/v1"
13
13
  )
14
+ JUPYTER_COLLABORATION_FORK_EVENTS_URI = "https://schema.jupyter.org/jupyter_collaboration/fork/v1"
14
15
  AWARENESS_EVENTS_SCHEMA_PATH = EVENTS_FOLDER_PATH / "awareness.yaml"
16
+ FORK_EVENTS_SCHEMA_PATH = EVENTS_FOLDER_PATH / "fork.yaml"
15
17
 
16
18
 
17
19
  class MessageType(IntEnum):
@@ -41,11 +41,13 @@ dynamic = ["version"]
41
41
  [project.optional-dependencies]
42
42
  test = [
43
43
  "coverage",
44
+ "dirty-equals",
44
45
  "jupyter_server[test]>=2.4.0",
45
46
  "jupyter_server_fileid[test]",
46
47
  "pytest>=7.0",
47
48
  "pytest-cov",
48
- "websockets",
49
+ "anyio",
50
+ "httpx-ws >=0.5.2",
49
51
  "importlib_metadata >=4.8.3; python_version<'3.10'",
50
52
  ]
51
53
 
@@ -11,6 +11,7 @@ else:
11
11
 
12
12
  import pytest
13
13
  from anyio import create_task_group, sleep
14
+ from jupyter_server_ydoc.test_utils import Websocket
14
15
  from pycrdt_websocket import WebsocketProvider
15
16
 
16
17
  jupyter_ydocs = {ep.name: ep.load() for ep in entry_points(group="jupyter_ydoc")}
@@ -32,12 +33,12 @@ async def test_dirty(
32
33
  await rtc_create_file(file_path)
33
34
  jupyter_ydoc = jupyter_ydocs[file_type]()
34
35
 
35
- async with await rtc_connect_doc_client(file_format, file_type, file_path) as ws:
36
- async with WebsocketProvider(jupyter_ydoc.ydoc, ws):
37
- for _ in range(2):
38
- jupyter_ydoc.dirty = True
39
- await sleep(rtc_document_save_delay * 1.5)
40
- assert not jupyter_ydoc.dirty
36
+ websocket, room_name = await rtc_connect_doc_client(file_format, file_type, file_path)
37
+ async with websocket as ws, WebsocketProvider(jupyter_ydoc.ydoc, Websocket(ws, room_name)):
38
+ for _ in range(2):
39
+ jupyter_ydoc.dirty = True
40
+ await sleep(rtc_document_save_delay * 1.5)
41
+ assert not jupyter_ydoc.dirty
41
42
 
42
43
 
43
44
  async def cleanup(jp_serverapp):
@@ -59,7 +60,8 @@ async def test_room_concurrent_initialization(
59
60
  await rtc_create_file(file_path)
60
61
 
61
62
  async def connect(file_format, file_type, file_path):
62
- async with await rtc_connect_doc_client(file_format, file_type, file_path) as ws:
63
+ websocket, room_name = await rtc_connect_doc_client(file_format, file_type, file_path)
64
+ async with websocket:
63
65
  pass
64
66
 
65
67
  t0 = time()
@@ -84,7 +86,8 @@ async def test_room_sequential_opening(
84
86
 
85
87
  async def connect(file_format, file_type, file_path):
86
88
  t0 = time()
87
- async with await rtc_connect_doc_client(file_format, file_type, file_path) as ws:
89
+ websocket, room_name = await rtc_connect_doc_client(file_format, file_type, file_path)
90
+ async with websocket:
88
91
  pass
89
92
  t1 = time()
90
93
  return t1 - t0
@@ -7,8 +7,11 @@ import json
7
7
  from asyncio import Event, sleep
8
8
  from typing import Any
9
9
 
10
+ from dirty_equals import IsStr
10
11
  from jupyter_events.logger import EventLogger
12
+ from jupyter_server_ydoc.test_utils import Websocket
11
13
  from jupyter_ydoc import YUnicode
14
+ from pycrdt import Text
12
15
  from pycrdt_websocket import WebsocketProvider
13
16
 
14
17
 
@@ -77,9 +80,8 @@ async def test_room_handler_doc_client_should_connect(rtc_create_file, rtc_conne
77
80
  doc = YUnicode()
78
81
  doc.observe(_on_document_change)
79
82
 
80
- async with await rtc_connect_doc_client("text", "file", path) as ws, WebsocketProvider(
81
- doc.ydoc, ws
82
- ):
83
+ websocket, room_name = await rtc_connect_doc_client("text", "file", path)
84
+ async with websocket as ws, WebsocketProvider(doc.ydoc, Websocket(ws, room_name)):
83
85
  await event.wait()
84
86
  await sleep(0.1)
85
87
 
@@ -114,9 +116,8 @@ async def test_room_handler_doc_client_should_emit_awareness_event(
114
116
  listener=my_listener,
115
117
  )
116
118
 
117
- async with await rtc_connect_doc_client("text", "file", path) as ws, WebsocketProvider(
118
- doc.ydoc, ws
119
- ):
119
+ websocket, room_name = await rtc_connect_doc_client("text", "file", path)
120
+ async with websocket as ws, WebsocketProvider(doc.ydoc, Websocket(ws, room_name)):
120
121
  await event.wait()
121
122
  await sleep(0.1)
122
123
 
@@ -147,9 +148,8 @@ async def test_room_handler_doc_client_should_cleanup_room_file(
147
148
  doc = YUnicode()
148
149
  doc.observe(_on_document_change)
149
150
 
150
- async with await rtc_connect_doc_client("text", "file", path) as ws, WebsocketProvider(
151
- doc.ydoc, ws
152
- ):
151
+ websocket, room_name = await rtc_connect_doc_client("text", "file", path)
152
+ async with websocket as ws, WebsocketProvider(doc.ydoc, Websocket(ws, room_name)):
153
153
  await event.wait()
154
154
  await sleep(0.1)
155
155
 
@@ -173,18 +173,16 @@ async def test_room_handler_doc_client_should_cleanup_room_file(
173
173
  path2, _ = await rtc_create_file("test2.txt", "test2")
174
174
 
175
175
  try:
176
- async with await rtc_connect_doc_client("text2", "file2", path2) as ws, WebsocketProvider(
177
- doc.ydoc, ws
178
- ):
176
+ websocket, room_name = await rtc_connect_doc_client("text2", "file2", path2)
177
+ async with websocket as ws, WebsocketProvider(doc.ydoc, Websocket(ws, room_name)):
179
178
  await event.wait()
180
179
  await sleep(0.1)
181
180
  except Exception:
182
181
  pass
183
182
 
184
183
  try:
185
- async with await rtc_connect_doc_client("text2", "file2", path2) as ws, WebsocketProvider(
186
- doc.ydoc, ws
187
- ):
184
+ websocket, room_name = await rtc_connect_doc_client("text2", "file2", path2)
185
+ async with websocket as ws, WebsocketProvider(doc.ydoc, Websocket(ws, room_name)):
188
186
  await event.wait()
189
187
  await sleep(0.1)
190
188
  except Exception:
@@ -215,3 +213,147 @@ async def test_room_handler_doc_client_should_cleanup_room_file(
215
213
 
216
214
  await jp_serverapp.web_app.settings["jupyter_server_ydoc"].stop_extension()
217
215
  del jp_serverapp.web_app.settings["file_id_manager"]
216
+
217
+
218
+ async def test_fork_handler(
219
+ jp_serverapp,
220
+ rtc_create_file,
221
+ rtc_connect_doc_client,
222
+ rtc_connect_fork_client,
223
+ rtc_get_forks_client,
224
+ rtc_create_fork_client,
225
+ rtc_delete_fork_client,
226
+ rtc_fetch_session,
227
+ ):
228
+ collected_data = []
229
+
230
+ async def my_listener(logger: EventLogger, schema_id: str, data: dict) -> None:
231
+ collected_data.append(data)
232
+
233
+ event_logger = jp_serverapp.event_logger
234
+ event_logger.add_listener(
235
+ schema_id="https://schema.jupyter.org/jupyter_collaboration/fork/v1",
236
+ listener=my_listener,
237
+ )
238
+
239
+ path, _ = await rtc_create_file("test.txt", "Hello")
240
+
241
+ root_connect_event = Event()
242
+
243
+ def _on_root_change(topic: str, event: Any) -> None:
244
+ if topic == "source":
245
+ root_connect_event.set()
246
+
247
+ root_ydoc = YUnicode()
248
+ root_ydoc.observe(_on_root_change)
249
+
250
+ resp = await rtc_fetch_session("text", "file", path)
251
+ data = json.loads(resp.body.decode("utf-8"))
252
+ file_id = data["fileId"]
253
+ root_roomid = f"text:file:{file_id}"
254
+
255
+ websocket, room_name = await rtc_connect_doc_client("text", "file", path)
256
+ async with websocket as ws, WebsocketProvider(root_ydoc.ydoc, Websocket(ws, room_name)):
257
+ await root_connect_event.wait()
258
+
259
+ resp = await rtc_create_fork_client(root_roomid, False, "my fork0", "is awesome0")
260
+ data = json.loads(resp.body.decode("utf-8"))
261
+ fork_roomid0 = data["fork_roomid"]
262
+
263
+ resp = await rtc_get_forks_client(root_roomid)
264
+ data = json.loads(resp.body.decode("utf-8"))
265
+ expected_data0 = {
266
+ fork_roomid0: {
267
+ "root_roomid": root_roomid,
268
+ "synchronize": False,
269
+ "title": "my fork0",
270
+ "description": "is awesome0",
271
+ }
272
+ }
273
+ assert data == expected_data0
274
+
275
+ assert collected_data == [
276
+ {
277
+ "username": IsStr(),
278
+ "fork_roomid": fork_roomid0,
279
+ "fork_info": expected_data0[fork_roomid0],
280
+ "action": "create",
281
+ }
282
+ ]
283
+
284
+ resp = await rtc_create_fork_client(root_roomid, True, "my fork1", "is awesome1")
285
+ data = json.loads(resp.body.decode("utf-8"))
286
+ fork_roomid1 = data["fork_roomid"]
287
+
288
+ resp = await rtc_get_forks_client(root_roomid)
289
+ data = json.loads(resp.body.decode("utf-8"))
290
+ expected_data1 = {
291
+ fork_roomid1: {
292
+ "root_roomid": root_roomid,
293
+ "synchronize": True,
294
+ "title": "my fork1",
295
+ "description": "is awesome1",
296
+ }
297
+ }
298
+ expected_data = dict(**expected_data0, **expected_data1)
299
+ assert data == expected_data
300
+
301
+ assert len(collected_data) == 2
302
+ assert collected_data[1] == {
303
+ "username": IsStr(),
304
+ "fork_roomid": fork_roomid1,
305
+ "fork_info": expected_data[fork_roomid1],
306
+ "action": "create",
307
+ }
308
+
309
+ fork_ydoc = YUnicode()
310
+ fork_connect_event = Event()
311
+
312
+ def _on_fork_change(topic: str, event: Any) -> None:
313
+ if topic == "source":
314
+ fork_connect_event.set()
315
+
316
+ fork_ydoc.observe(_on_fork_change)
317
+ fork_text = fork_ydoc.ydoc.get("source", type=Text)
318
+
319
+ async with await rtc_connect_fork_client(fork_roomid1) as ws, WebsocketProvider(
320
+ fork_ydoc.ydoc, Websocket(ws, fork_roomid1)
321
+ ):
322
+ await fork_connect_event.wait()
323
+ root_text = root_ydoc.ydoc.get("source", type=Text)
324
+ root_text += ", World!"
325
+ await sleep(0.1)
326
+ assert str(fork_text) == "Hello, World!"
327
+ fork_text += " Hi!"
328
+ await sleep(0.1)
329
+
330
+ await sleep(0.1)
331
+ assert str(root_text) == "Hello, World!"
332
+
333
+ await rtc_delete_fork_client(fork_roomid0, True)
334
+ await sleep(0.1)
335
+ assert str(root_text) == "Hello, World!"
336
+ resp = await rtc_get_forks_client(root_roomid)
337
+ data = json.loads(resp.body.decode("utf-8"))
338
+ assert data == expected_data1
339
+ assert len(collected_data) == 3
340
+ assert collected_data[2] == {
341
+ "username": IsStr(),
342
+ "fork_roomid": fork_roomid0,
343
+ "fork_info": expected_data[fork_roomid0],
344
+ "action": "delete",
345
+ }
346
+
347
+ await rtc_delete_fork_client(fork_roomid1, True)
348
+ await sleep(0.1)
349
+ assert str(root_text) == "Hello, World! Hi!"
350
+ resp = await rtc_get_forks_client(root_roomid)
351
+ data = json.loads(resp.body.decode("utf-8"))
352
+ assert data == {}
353
+ assert len(collected_data) == 4
354
+ assert collected_data[3] == {
355
+ "username": IsStr(),
356
+ "fork_roomid": fork_roomid1,
357
+ "fork_info": expected_data[fork_roomid1],
358
+ "action": "delete",
359
+ }
@@ -1 +0,0 @@
1
- __version__ = "1.0.0rc1"