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.
- {jupyter_server_ydoc-1.0.0rc1 → jupyter_server_ydoc-1.1.0a0}/PKG-INFO +4 -3
- jupyter_server_ydoc-1.1.0a0/jupyter_server_ydoc/_version.py +1 -0
- {jupyter_server_ydoc-1.0.0rc1 → jupyter_server_ydoc-1.1.0a0}/jupyter_server_ydoc/app.py +10 -0
- jupyter_server_ydoc-1.1.0a0/jupyter_server_ydoc/events/fork.yaml +56 -0
- {jupyter_server_ydoc-1.0.0rc1 → jupyter_server_ydoc-1.1.0a0}/jupyter_server_ydoc/handlers.py +100 -0
- {jupyter_server_ydoc-1.0.0rc1 → jupyter_server_ydoc-1.1.0a0}/jupyter_server_ydoc/pytest_plugin.py +76 -9
- {jupyter_server_ydoc-1.0.0rc1 → jupyter_server_ydoc-1.1.0a0}/jupyter_server_ydoc/test_utils.py +30 -0
- {jupyter_server_ydoc-1.0.0rc1 → jupyter_server_ydoc-1.1.0a0}/jupyter_server_ydoc/utils.py +2 -0
- {jupyter_server_ydoc-1.0.0rc1 → jupyter_server_ydoc-1.1.0a0}/pyproject.toml +3 -1
- {jupyter_server_ydoc-1.0.0rc1 → jupyter_server_ydoc-1.1.0a0}/tests/test_documents.py +11 -8
- {jupyter_server_ydoc-1.0.0rc1 → jupyter_server_ydoc-1.1.0a0}/tests/test_handlers.py +157 -15
- jupyter_server_ydoc-1.0.0rc1/jupyter_server_ydoc/_version.py +0 -1
- {jupyter_server_ydoc-1.0.0rc1 → jupyter_server_ydoc-1.1.0a0}/.gitignore +0 -0
- {jupyter_server_ydoc-1.0.0rc1 → jupyter_server_ydoc-1.1.0a0}/LICENSE +0 -0
- {jupyter_server_ydoc-1.0.0rc1 → jupyter_server_ydoc-1.1.0a0}/README.md +0 -0
- {jupyter_server_ydoc-1.0.0rc1 → jupyter_server_ydoc-1.1.0a0}/jupyter-config/jupyter_server_ydoc.json +0 -0
- {jupyter_server_ydoc-1.0.0rc1 → jupyter_server_ydoc-1.1.0a0}/jupyter_server_ydoc/__init__.py +0 -0
- {jupyter_server_ydoc-1.0.0rc1 → jupyter_server_ydoc-1.1.0a0}/jupyter_server_ydoc/events/awareness.yaml +0 -0
- {jupyter_server_ydoc-1.0.0rc1 → jupyter_server_ydoc-1.1.0a0}/jupyter_server_ydoc/events/session.yaml +0 -0
- {jupyter_server_ydoc-1.0.0rc1 → jupyter_server_ydoc-1.1.0a0}/jupyter_server_ydoc/loaders.py +0 -0
- {jupyter_server_ydoc-1.0.0rc1 → jupyter_server_ydoc-1.1.0a0}/jupyter_server_ydoc/rooms.py +0 -0
- {jupyter_server_ydoc-1.0.0rc1 → jupyter_server_ydoc-1.1.0a0}/jupyter_server_ydoc/stores.py +0 -0
- {jupyter_server_ydoc-1.0.0rc1 → jupyter_server_ydoc-1.1.0a0}/jupyter_server_ydoc/websocketserver.py +0 -0
- {jupyter_server_ydoc-1.0.0rc1 → jupyter_server_ydoc-1.1.0a0}/setup.py +0 -0
- {jupyter_server_ydoc-1.0.0rc1 → jupyter_server_ydoc-1.1.0a0}/tests/__init__.py +0 -0
- {jupyter_server_ydoc-1.0.0rc1 → jupyter_server_ydoc-1.1.0a0}/tests/conftest.py +0 -0
- {jupyter_server_ydoc-1.0.0rc1 → jupyter_server_ydoc-1.1.0a0}/tests/test_app.py +0 -0
- {jupyter_server_ydoc-1.0.0rc1 → jupyter_server_ydoc-1.1.0a0}/tests/test_loaders.py +0 -0
- {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.
|
|
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
|
{jupyter_server_ydoc-1.0.0rc1 → jupyter_server_ydoc-1.1.0a0}/jupyter_server_ydoc/handlers.py
RENAMED
|
@@ -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)
|
{jupyter_server_ydoc-1.0.0rc1 → jupyter_server_ydoc-1.1.0a0}/jupyter_server_ydoc/pytest_plugin.py
RENAMED
|
@@ -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
|
|
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
|
|
130
|
-
f"
|
|
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
|
-
|
|
142
|
-
|
|
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
|
-
|
|
166
|
-
|
|
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
|
|
{jupyter_server_ydoc-1.0.0rc1 → jupyter_server_ydoc-1.1.0a0}/jupyter_server_ydoc/test_utils.py
RENAMED
|
@@ -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
|
-
"
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
81
|
-
|
|
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
|
-
|
|
118
|
-
|
|
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
|
-
|
|
151
|
-
|
|
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
|
-
|
|
177
|
-
|
|
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
|
-
|
|
186
|
-
|
|
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"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{jupyter_server_ydoc-1.0.0rc1 → jupyter_server_ydoc-1.1.0a0}/jupyter-config/jupyter_server_ydoc.json
RENAMED
|
File without changes
|
{jupyter_server_ydoc-1.0.0rc1 → jupyter_server_ydoc-1.1.0a0}/jupyter_server_ydoc/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{jupyter_server_ydoc-1.0.0rc1 → jupyter_server_ydoc-1.1.0a0}/jupyter_server_ydoc/events/session.yaml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{jupyter_server_ydoc-1.0.0rc1 → jupyter_server_ydoc-1.1.0a0}/jupyter_server_ydoc/websocketserver.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|