jupyter-server-ydoc 1.0.0b2__tar.gz → 1.0.0b4__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 (28) hide show
  1. {jupyter_server_ydoc-1.0.0b2 → jupyter_server_ydoc-1.0.0b4}/PKG-INFO +3 -3
  2. jupyter_server_ydoc-1.0.0b4/jupyter_server_ydoc/_version.py +1 -0
  3. {jupyter_server_ydoc-1.0.0b2 → jupyter_server_ydoc-1.0.0b4}/jupyter_server_ydoc/app.py +21 -1
  4. {jupyter_server_ydoc-1.0.0b2 → jupyter_server_ydoc-1.0.0b4}/jupyter_server_ydoc/handlers.py +139 -2
  5. {jupyter_server_ydoc-1.0.0b2 → jupyter_server_ydoc-1.0.0b4}/jupyter_server_ydoc/loaders.py +17 -3
  6. {jupyter_server_ydoc-1.0.0b2 → jupyter_server_ydoc-1.0.0b4}/jupyter_server_ydoc/rooms.py +3 -1
  7. {jupyter_server_ydoc-1.0.0b2 → jupyter_server_ydoc-1.0.0b4}/pyproject.toml +2 -2
  8. jupyter_server_ydoc-1.0.0b2/jupyter_server_ydoc/_version.py +0 -1
  9. {jupyter_server_ydoc-1.0.0b2 → jupyter_server_ydoc-1.0.0b4}/.gitignore +0 -0
  10. {jupyter_server_ydoc-1.0.0b2 → jupyter_server_ydoc-1.0.0b4}/LICENSE +0 -0
  11. {jupyter_server_ydoc-1.0.0b2 → jupyter_server_ydoc-1.0.0b4}/README.md +0 -0
  12. {jupyter_server_ydoc-1.0.0b2 → jupyter_server_ydoc-1.0.0b4}/jupyter-config/jupyter_server_ydoc.json +0 -0
  13. {jupyter_server_ydoc-1.0.0b2 → jupyter_server_ydoc-1.0.0b4}/jupyter_server_ydoc/__init__.py +0 -0
  14. {jupyter_server_ydoc-1.0.0b2 → jupyter_server_ydoc-1.0.0b4}/jupyter_server_ydoc/events/awareness.yaml +0 -0
  15. {jupyter_server_ydoc-1.0.0b2 → jupyter_server_ydoc-1.0.0b4}/jupyter_server_ydoc/events/session.yaml +0 -0
  16. {jupyter_server_ydoc-1.0.0b2 → jupyter_server_ydoc-1.0.0b4}/jupyter_server_ydoc/pytest_plugin.py +0 -0
  17. {jupyter_server_ydoc-1.0.0b2 → jupyter_server_ydoc-1.0.0b4}/jupyter_server_ydoc/stores.py +0 -0
  18. {jupyter_server_ydoc-1.0.0b2 → jupyter_server_ydoc-1.0.0b4}/jupyter_server_ydoc/test_utils.py +0 -0
  19. {jupyter_server_ydoc-1.0.0b2 → jupyter_server_ydoc-1.0.0b4}/jupyter_server_ydoc/utils.py +0 -0
  20. {jupyter_server_ydoc-1.0.0b2 → jupyter_server_ydoc-1.0.0b4}/jupyter_server_ydoc/websocketserver.py +0 -0
  21. {jupyter_server_ydoc-1.0.0b2 → jupyter_server_ydoc-1.0.0b4}/setup.py +0 -0
  22. {jupyter_server_ydoc-1.0.0b2 → jupyter_server_ydoc-1.0.0b4}/tests/__init__.py +0 -0
  23. {jupyter_server_ydoc-1.0.0b2 → jupyter_server_ydoc-1.0.0b4}/tests/conftest.py +0 -0
  24. {jupyter_server_ydoc-1.0.0b2 → jupyter_server_ydoc-1.0.0b4}/tests/test_app.py +0 -0
  25. {jupyter_server_ydoc-1.0.0b2 → jupyter_server_ydoc-1.0.0b4}/tests/test_documents.py +0 -0
  26. {jupyter_server_ydoc-1.0.0b2 → jupyter_server_ydoc-1.0.0b4}/tests/test_handlers.py +0 -0
  27. {jupyter_server_ydoc-1.0.0b2 → jupyter_server_ydoc-1.0.0b4}/tests/test_loaders.py +0 -0
  28. {jupyter_server_ydoc-1.0.0b2 → jupyter_server_ydoc-1.0.0b4}/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.0b2
3
+ Version: 1.0.0b4
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
@@ -78,10 +78,10 @@ Requires-Python: >=3.8
78
78
  Requires-Dist: jsonschema>=4.18.0
79
79
  Requires-Dist: jupyter-events>=0.10.0
80
80
  Requires-Dist: jupyter-server-fileid<1,>=0.7.0
81
- Requires-Dist: jupyter-server<3.0.0,>=2.4.0
81
+ Requires-Dist: jupyter-server<3.0.0,>=2.11.1
82
82
  Requires-Dist: jupyter-ydoc<4.0.0,>=2.0.0
83
83
  Requires-Dist: pycrdt
84
- Requires-Dist: pycrdt-websocket<0.15.0,>=0.14.0
84
+ Requires-Dist: pycrdt-websocket<0.15.0,>=0.14.2
85
85
  Provides-Extra: test
86
86
  Requires-Dist: coverage; extra == 'test'
87
87
  Requires-Dist: importlib-metadata>=4.8.3; (python_version < '3.10') and extra == 'test'
@@ -0,0 +1 @@
1
+ __version__ = "1.0.0b4"
@@ -13,7 +13,12 @@ from pycrdt import Doc
13
13
  from pycrdt_websocket.ystore import BaseYStore
14
14
  from traitlets import Bool, Float, Type
15
15
 
16
- from .handlers import DocSessionHandler, YDocWebSocketHandler
16
+ from .handlers import (
17
+ DocSessionHandler,
18
+ TimelineHandler,
19
+ UndoRedoHandler,
20
+ YDocWebSocketHandler,
21
+ )
17
22
  from .loaders import FileLoaderMapping
18
23
  from .rooms import DocumentRoom
19
24
  from .stores import SQLiteYStore
@@ -130,6 +135,21 @@ class YDocExtension(ExtensionApp):
130
135
  },
131
136
  ),
132
137
  (r"/api/collaboration/session/(.*)", DocSessionHandler),
138
+ (
139
+ r"/api/collaboration/timeline/(.*)",
140
+ TimelineHandler,
141
+ {
142
+ "ystore_class": self.ystore_class,
143
+ "ywebsocket_server": self.ywebsocket_server,
144
+ },
145
+ ),
146
+ (
147
+ r"/api/collaboration/undo_redo/(.*)",
148
+ UndoRedoHandler,
149
+ {
150
+ "ywebsocket_server": self.ywebsocket_server,
151
+ },
152
+ ),
133
153
  ]
134
154
  )
135
155
 
@@ -9,12 +9,13 @@ import time
9
9
  import uuid
10
10
  from logging import Logger
11
11
  from typing import Any
12
+ from uuid import uuid4
12
13
 
13
14
  from jupyter_server.auth import authorized
14
15
  from jupyter_server.base.handlers import APIHandler, JupyterHandler
15
16
  from jupyter_server.utils import ensure_async
16
17
  from jupyter_ydoc import ydocs as YDOCS
17
- from pycrdt import YMessageType, write_var_uint
18
+ from pycrdt import Doc, UndoManager, YMessageType, write_var_uint
18
19
  from pycrdt_websocket.websocket_server import YRoom
19
20
  from pycrdt_websocket.ystore import BaseYStore
20
21
  from tornado import web
@@ -28,13 +29,16 @@ from .utils import (
28
29
  LogLevel,
29
30
  MessageType,
30
31
  decode_file_path,
32
+ encode_file_path,
31
33
  room_id_from_encoded_path,
32
34
  )
33
- from .websocketserver import JupyterWebsocketServer
35
+ from .websocketserver import JupyterWebsocketServer, RoomNotFound
34
36
 
35
37
  YFILE = YDOCS["file"]
36
38
 
39
+
37
40
  SERVER_SESSION = str(uuid.uuid4())
41
+ FORK_DOCUMENTS = {}
38
42
 
39
43
 
40
44
  class YDocWebSocketHandler(WebSocketHandler, JupyterHandler):
@@ -459,3 +463,136 @@ class DocSessionHandler(APIHandler):
459
463
  )
460
464
  self.set_status(201)
461
465
  return self.finish(data)
466
+
467
+
468
+ class TimelineHandler(APIHandler):
469
+ def initialize(
470
+ self, ystore_class: type[BaseYStore], ywebsocket_server: JupyterWebsocketServer
471
+ ) -> None:
472
+ self.ystore_class = ystore_class
473
+ self.ywebsocket_server = ywebsocket_server
474
+
475
+ async def get(self, path: str) -> None:
476
+ idx = uuid4().hex
477
+ file_id_manager = self.settings["file_id_manager"]
478
+ file_id = file_id_manager.get_id(path)
479
+
480
+ format = str(self.request.query_arguments.get("format")[0].decode("utf-8"))
481
+ content_type = str(self.request.query_arguments.get("type")[0].decode("utf-8"))
482
+ encoded_path = encode_file_path(format, content_type, file_id)
483
+ try:
484
+ room_id = room_id_from_encoded_path(encoded_path)
485
+ room: YRoom = await self.ywebsocket_server.get_room(room_id)
486
+ fork_ydoc = Doc()
487
+
488
+ ydoc_factory = YDOCS.get(content_type)
489
+ if ydoc_factory is None:
490
+ self.set_status(404)
491
+ self.finish(
492
+ {
493
+ "code": 404,
494
+ "error": f"No document factory found for content type: {content_type}",
495
+ }
496
+ )
497
+ return
498
+
499
+ FORK_DOCUMENTS[idx] = ydoc_factory(fork_ydoc)
500
+ undo_manager: UndoManager = FORK_DOCUMENTS[idx].undo_manager
501
+
502
+ updates_and_timestamps = [(item[0], item[-1]) async for item in room.ystore.read()]
503
+
504
+ result_timestamps = []
505
+
506
+ for update, timestamp in updates_and_timestamps:
507
+ undo_stack_len = len(undo_manager.undo_stack)
508
+ fork_ydoc.apply_update(update)
509
+ if len(undo_manager.undo_stack) > undo_stack_len:
510
+ result_timestamps.append(timestamp)
511
+
512
+ fork_room = YRoom(ydoc=fork_ydoc)
513
+ self.ywebsocket_server.add_room(idx, fork_room)
514
+
515
+ data = {
516
+ "roomId": room_id,
517
+ "timestamps": result_timestamps,
518
+ "forkRoom": idx,
519
+ "sessionId": SERVER_SESSION,
520
+ }
521
+ self.set_status(200)
522
+ self.finish(json.dumps(data))
523
+
524
+ except RoomNotFound:
525
+ self.set_status(404)
526
+ self.finish({"code": 404, "error": "Room not found"})
527
+
528
+
529
+ class UndoRedoHandler(APIHandler):
530
+ def initialize(self, ywebsocket_server: JupyterWebsocketServer) -> None:
531
+ self._websocket_server = ywebsocket_server
532
+
533
+ async def put(self, room_id):
534
+ try:
535
+ action = str(self.request.query_arguments.get("action")[0].decode("utf-8"))
536
+ steps = int(self.request.query_arguments.get("steps")[0].decode("utf-8"))
537
+ fork_room_id = str(self.request.query_arguments.get("forkRoom")[0].decode("utf-8"))
538
+
539
+ fork_document = FORK_DOCUMENTS.get(fork_room_id)
540
+ if not fork_document:
541
+ self.set_status(404)
542
+ self.log.warning(f"Fork document not found for room ID {fork_room_id}")
543
+ return self.finish({"code": 404, "error": "Fork document not found"})
544
+
545
+ undo_manager = fork_document.undo_manager
546
+
547
+ if action == "undo":
548
+ if undo_manager.can_undo():
549
+ await self._perform_undo_or_redo(undo_manager, "undo", steps)
550
+ self.set_status(200)
551
+ self.log.info(f"Undo operation performed in room {room_id} for {steps} steps")
552
+ return self.finish({"status": "undone"})
553
+ else:
554
+ self.log.info(f"No more undo operations available in room {room_id}")
555
+ return self.finish({"error": "No more undo operations available"})
556
+ elif action == "redo":
557
+ if undo_manager.can_redo():
558
+ await self._perform_undo_or_redo(undo_manager, "redo", steps)
559
+ self.set_status(200)
560
+ self.log.info(f"Redo operation performed in room {room_id} for {steps} steps")
561
+ return self.finish({"status": "redone"})
562
+ else:
563
+ self.log.info(f"No more redo operations available in room {room_id}")
564
+ return self.finish({"error": "No more redo operations available"})
565
+ elif action == "restore":
566
+ try:
567
+ # Cleanup undo manager after restoration
568
+ await self._cleanup_undo_manager(fork_room_id)
569
+
570
+ self.set_status(200)
571
+ self.log.info(f"Document in room {room_id} restored successfully")
572
+ return self.finish({"code": 200, "status": "Document restored successfully"})
573
+ except Exception as e:
574
+ self.log.error(f"Error during document restore in room {room_id}: {str(e)}")
575
+ self.set_status(500)
576
+ return self.finish(
577
+ {"code": 500, "error": "Internal server error", "message": str(e)}
578
+ )
579
+
580
+ except Exception as e:
581
+ self.log.error(f"Error during undo/redo/restore operation in room {room_id}: {str(e)}")
582
+
583
+ async def _perform_undo_or_redo(
584
+ self, undo_manager: UndoManager, action: str, steps: int
585
+ ) -> None:
586
+ for _ in range(steps):
587
+ if action == "undo" and len(undo_manager.undo_stack) > 1:
588
+ undo_manager.undo()
589
+
590
+ elif action == "redo" and undo_manager.can_redo():
591
+ undo_manager.redo()
592
+ else:
593
+ break
594
+
595
+ async def _cleanup_undo_manager(self, room_id: str) -> None:
596
+ if room_id in FORK_DOCUMENTS:
597
+ del FORK_DOCUMENTS[room_id]
598
+ self.log.info(f"Fork Document for {room_id} has been removed.")
@@ -117,7 +117,7 @@ class FileLoader:
117
117
  self.last_modified = model["last_modified"]
118
118
  return model
119
119
 
120
- async def maybe_save_content(self, model: dict[str, Any]) -> None:
120
+ async def maybe_save_content(self, model: dict[str, Any]) -> dict[str, Any] | None:
121
121
  """
122
122
  Save the content of the file.
123
123
 
@@ -149,20 +149,34 @@ class FileLoader:
149
149
  # otherwise it could corrupt the file
150
150
  done_saving = asyncio.Event()
151
151
  task = asyncio.create_task(self._save_content(model, done_saving))
152
+ saved_model = None
152
153
  try:
153
- await asyncio.shield(task)
154
+ saved_model = await asyncio.shield(task)
154
155
  except asyncio.CancelledError:
155
156
  pass
156
157
  await done_saving.wait()
158
+ return saved_model
157
159
  else:
158
160
  # file changed on disk, raise an error
159
161
  self.last_modified = m["last_modified"]
160
162
  raise OutOfBandChanges
161
163
 
162
- async def _save_content(self, model: dict[str, Any], done_saving: asyncio.Event) -> None:
164
+ async def _save_content(
165
+ self, model: dict[str, Any], done_saving: asyncio.Event
166
+ ) -> dict[str, Any]:
163
167
  try:
164
168
  m = await ensure_async(self._contents_manager.save(model, self.path))
165
169
  self.last_modified = m["last_modified"]
170
+ # TODO, get rid of the extra `get` here once upstream issue:
171
+ # https://github.com/jupyter-server/jupyter_server/issues/1453 is resolved
172
+ model_with_hash = await ensure_async(
173
+ self._contents_manager.get(
174
+ self.path,
175
+ content=False,
176
+ require_hash=True,
177
+ )
178
+ )
179
+ return {**m, "hash": model_with_hash["hash"]}
166
180
  finally:
167
181
  done_saving.set()
168
182
 
@@ -271,7 +271,7 @@ class DocumentRoom(YRoom):
271
271
  await asyncio.sleep(self._save_delay)
272
272
 
273
273
  self.log.info("Saving the content from room %s", self._room_id)
274
- await self._file.maybe_save_content(
274
+ saved_model = await self._file.maybe_save_content(
275
275
  {
276
276
  "format": self._file_format,
277
277
  "type": self._file_type,
@@ -280,6 +280,8 @@ class DocumentRoom(YRoom):
280
280
  )
281
281
  async with self._update_lock:
282
282
  self._document.dirty = False
283
+ if saved_model:
284
+ self._document.hash = saved_model["hash"]
283
285
 
284
286
  self._emit(LogLevel.INFO, "save", "Content saved.")
285
287
 
@@ -28,10 +28,10 @@ authors = [
28
28
  { name = "Jupyter Development Team", email = "jupyter@googlegroups.com" },
29
29
  ]
30
30
  dependencies = [
31
- "jupyter_server>=2.4.0,<3.0.0",
31
+ "jupyter_server>=2.11.1,<3.0.0",
32
32
  "jupyter_ydoc>=2.0.0,<4.0.0",
33
33
  "pycrdt",
34
- "pycrdt-websocket>=0.14.0,<0.15.0",
34
+ "pycrdt-websocket>=0.14.2,<0.15.0",
35
35
  "jupyter_events>=0.10.0",
36
36
  "jupyter_server_fileid>=0.7.0,<1",
37
37
  "jsonschema>=4.18.0"
@@ -1 +0,0 @@
1
- __version__ = "1.0.0b2"