jupyter-server-ydoc 2.0.1__tar.gz → 2.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-2.0.1 → jupyter_server_ydoc-2.1.0a0}/PKG-INFO +1 -1
  2. jupyter_server_ydoc-2.1.0a0/jupyter_server_ydoc/_version.py +1 -0
  3. {jupyter_server_ydoc-2.0.1 → jupyter_server_ydoc-2.1.0a0}/jupyter_server_ydoc/app.py +2 -2
  4. {jupyter_server_ydoc-2.0.1 → jupyter_server_ydoc-2.1.0a0}/jupyter_server_ydoc/handlers.py +31 -37
  5. {jupyter_server_ydoc-2.0.1 → jupyter_server_ydoc-2.1.0a0}/jupyter_server_ydoc/loaders.py +16 -1
  6. {jupyter_server_ydoc-2.0.1 → jupyter_server_ydoc-2.1.0a0}/jupyter_server_ydoc/pytest_plugin.py +7 -2
  7. {jupyter_server_ydoc-2.0.1 → jupyter_server_ydoc-2.1.0a0}/jupyter_server_ydoc/rooms.py +32 -6
  8. {jupyter_server_ydoc-2.0.1 → jupyter_server_ydoc-2.1.0a0}/jupyter_server_ydoc/utils.py +1 -0
  9. {jupyter_server_ydoc-2.0.1 → jupyter_server_ydoc-2.1.0a0}/jupyter_server_ydoc/websocketserver.py +5 -4
  10. {jupyter_server_ydoc-2.0.1 → jupyter_server_ydoc-2.1.0a0}/tests/test_rooms.py +45 -3
  11. jupyter_server_ydoc-2.0.1/jupyter_server_ydoc/_version.py +0 -1
  12. {jupyter_server_ydoc-2.0.1 → jupyter_server_ydoc-2.1.0a0}/.gitignore +0 -0
  13. {jupyter_server_ydoc-2.0.1 → jupyter_server_ydoc-2.1.0a0}/LICENSE +0 -0
  14. {jupyter_server_ydoc-2.0.1 → jupyter_server_ydoc-2.1.0a0}/README.md +0 -0
  15. {jupyter_server_ydoc-2.0.1 → jupyter_server_ydoc-2.1.0a0}/jupyter-config/jupyter_server_ydoc.json +0 -0
  16. {jupyter_server_ydoc-2.0.1 → jupyter_server_ydoc-2.1.0a0}/jupyter_server_ydoc/__init__.py +0 -0
  17. {jupyter_server_ydoc-2.0.1 → jupyter_server_ydoc-2.1.0a0}/jupyter_server_ydoc/events/awareness.yaml +0 -0
  18. {jupyter_server_ydoc-2.0.1 → jupyter_server_ydoc-2.1.0a0}/jupyter_server_ydoc/events/fork.yaml +0 -0
  19. {jupyter_server_ydoc-2.0.1 → jupyter_server_ydoc-2.1.0a0}/jupyter_server_ydoc/events/session.yaml +0 -0
  20. {jupyter_server_ydoc-2.0.1 → jupyter_server_ydoc-2.1.0a0}/jupyter_server_ydoc/stores.py +0 -0
  21. {jupyter_server_ydoc-2.0.1 → jupyter_server_ydoc-2.1.0a0}/jupyter_server_ydoc/test_utils.py +0 -0
  22. {jupyter_server_ydoc-2.0.1 → jupyter_server_ydoc-2.1.0a0}/pyproject.toml +0 -0
  23. {jupyter_server_ydoc-2.0.1 → jupyter_server_ydoc-2.1.0a0}/setup.py +0 -0
  24. {jupyter_server_ydoc-2.0.1 → jupyter_server_ydoc-2.1.0a0}/tests/__init__.py +0 -0
  25. {jupyter_server_ydoc-2.0.1 → jupyter_server_ydoc-2.1.0a0}/tests/conftest.py +0 -0
  26. {jupyter_server_ydoc-2.0.1 → jupyter_server_ydoc-2.1.0a0}/tests/test_app.py +0 -0
  27. {jupyter_server_ydoc-2.0.1 → jupyter_server_ydoc-2.1.0a0}/tests/test_documents.py +0 -0
  28. {jupyter_server_ydoc-2.0.1 → jupyter_server_ydoc-2.1.0a0}/tests/test_handlers.py +0 -0
  29. {jupyter_server_ydoc-2.0.1 → jupyter_server_ydoc-2.1.0a0}/tests/test_loaders.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: jupyter-server-ydoc
3
- Version: 2.0.1
3
+ Version: 2.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
@@ -0,0 +1 @@
1
+ __version__ = "2.1.0.a0"
@@ -105,7 +105,7 @@ class YDocExtension(ExtensionApp):
105
105
  page_config.setdefault("serverSideExecution", self.server_side_execution)
106
106
 
107
107
  # Set configurable parameters to YStore class
108
- ystore_class = partial(self.ystore_class, config=self.config)
108
+ ystore_class: type[BaseYStore] = partial(self.ystore_class, config=self.config) # type:ignore[assignment]
109
109
 
110
110
  self.ywebsocket_server = JupyterWebsocketServer(
111
111
  rooms_ready=False,
@@ -205,7 +205,7 @@ class YDocExtension(ExtensionApp):
205
205
  if copy:
206
206
  update = room.ydoc.get_update()
207
207
 
208
- fork_ydoc = Doc()
208
+ fork_ydoc: Doc = Doc()
209
209
  fork_ydoc.apply_update(update)
210
210
 
211
211
  return YDOCS.get(room.file_type, YDOCS["file"])(fork_ydoc)
@@ -5,18 +5,18 @@ from __future__ import annotations
5
5
 
6
6
  import asyncio
7
7
  import json
8
- import time
9
8
  import uuid
10
9
  from logging import Logger
11
- from typing import Any, Literal
10
+ from typing import Any
12
11
  from uuid import uuid4
12
+ from typing import cast
13
13
 
14
14
  from jupyter_server.auth import authorized
15
15
  from jupyter_server.base.handlers import APIHandler, JupyterHandler
16
16
  from jupyter_server.utils import ensure_async
17
17
  from jupyter_ydoc import ydocs as YDOCS
18
- from pycrdt import Doc, UndoManager, write_var_uint
19
- from pycrdt_websocket.websocket_server import YRoom
18
+ from pycrdt import Doc, UndoManager
19
+ from pycrdt_websocket.yroom import YRoom
20
20
  from pycrdt_websocket.ystore import BaseYStore
21
21
  from tornado import web
22
22
  from tornado.websocket import WebSocketHandler
@@ -28,12 +28,13 @@ from .utils import (
28
28
  JUPYTER_COLLABORATION_EVENTS_URI,
29
29
  JUPYTER_COLLABORATION_FORK_EVENTS_URI,
30
30
  LogLevel,
31
- MessageType,
32
31
  decode_file_path,
33
32
  encode_file_path,
34
33
  room_id_from_encoded_path,
35
34
  )
36
35
  from .websocketserver import JupyterWebsocketServer, RoomNotFound
36
+ from .utils import MessageType
37
+ from pycrdt import Decoder
37
38
 
38
39
  YFILE = YDOCS["file"]
39
40
 
@@ -117,7 +118,10 @@ class YDocWebSocketHandler(WebSocketHandler, JupyterHandler):
117
118
 
118
119
  file = self._file_loaders[file_id]
119
120
  updates_file_path = f".{file_type}:{file_id}.y"
120
- ystore = self._ystore_class(path=updates_file_path, log=self.log)
121
+ ystore = self._ystore_class(
122
+ path=updates_file_path,
123
+ log=self.log, # type:ignore[call-arg]
124
+ )
121
125
  self.room = DocumentRoom(
122
126
  self._room_id,
123
127
  file_format,
@@ -182,7 +186,7 @@ class YDocWebSocketHandler(WebSocketHandler, JupyterHandler):
182
186
  self._websocket_server = ywebsocket_server
183
187
  self._message_queue = asyncio.Queue()
184
188
  self._room_id = ""
185
- self.room = None
189
+ self.room = None # type:ignore
186
190
 
187
191
  @property
188
192
  def path(self):
@@ -219,7 +223,7 @@ class YDocWebSocketHandler(WebSocketHandler, JupyterHandler):
219
223
  raise web.HTTPError(403)
220
224
  return await super().get(*args, **kwargs)
221
225
 
222
- async def open(self, room_id):
226
+ async def open(self, room_id: str) -> None: # type:ignore[override]
223
227
  """
224
228
  On connection open.
225
229
  """
@@ -259,7 +263,7 @@ class YDocWebSocketHandler(WebSocketHandler, JupyterHandler):
259
263
  )
260
264
 
261
265
  # Clean up the room and delete the file loader
262
- if len(self.room.clients) == 0 or self.room.clients == [self]:
266
+ if len(self.room.clients) == 0 or self.room.clients == {self}:
263
267
  self._message_queue.put_nowait(b"")
264
268
  self._cleanup_delay = 0
265
269
  await self._clean_room()
@@ -290,27 +294,17 @@ class YDocWebSocketHandler(WebSocketHandler, JupyterHandler):
290
294
  """
291
295
  On message receive.
292
296
  """
293
- message_type = message[0]
294
-
295
- if message_type == MessageType.CHAT:
296
- msg = message[2:].decode("utf-8")
297
-
298
- user = self.current_user
299
- data = json.dumps(
300
- {
301
- "sender": user.username,
302
- "timestamp": time.time(),
303
- "content": json.loads(msg),
304
- }
305
- ).encode("utf8")
306
-
307
- for client in self.room.clients:
308
- if client != self:
309
- task = asyncio.create_task(
310
- client.send(bytes([MessageType.CHAT]) + write_var_uint(len(data)) + data)
311
- )
312
- self._websocket_server.background_tasks.add(task)
313
- task.add_done_callback(self._websocket_server.background_tasks.discard)
297
+ decoder = Decoder(message)
298
+ header = decoder.read_var_uint()
299
+ if header == MessageType.RAW:
300
+ msg = decoder.read_var_string()
301
+ if msg == "save":
302
+ try:
303
+ room = cast(DocumentRoom, self.room)
304
+ room._save_to_disc()
305
+ except Exception:
306
+ self.log.error("Couldn't save content from room: %s", self._room_id)
307
+ return
314
308
 
315
309
  self._message_queue.put_nowait(message)
316
310
  self._websocket_server.ypatch_nb += 1
@@ -321,7 +315,7 @@ class YDocWebSocketHandler(WebSocketHandler, JupyterHandler):
321
315
  """
322
316
  # stop serving this client
323
317
  self._message_queue.put_nowait(b"")
324
- if isinstance(self.room, DocumentRoom) and self.room.clients == [self]:
318
+ if isinstance(self.room, DocumentRoom) and self.room.clients == {self}:
325
319
  # no client in this room after we disconnect
326
320
  # keep the document for a while in case someone reconnects
327
321
  self.log.info("Cleaning room: %s", self._room_id)
@@ -386,9 +380,7 @@ class YDocWebSocketHandler(WebSocketHandler, JupyterHandler):
386
380
  self._emit(LogLevel.INFO, "clean", "Loader deleted.")
387
381
  del self._room_locks[self._room_id]
388
382
 
389
- def _on_global_awareness_event(
390
- self, topic: Literal["change", "update"], changes: tuple[dict[str, Any], Any]
391
- ) -> None:
383
+ def _on_global_awareness_event(self, topic: str, changes: tuple[dict[str, Any], Any]) -> None:
392
384
  """
393
385
  Update the users when the global awareness changes.
394
386
 
@@ -489,7 +481,7 @@ class TimelineHandler(APIHandler):
489
481
  try:
490
482
  room_id = room_id_from_encoded_path(encoded_path)
491
483
  room: YRoom = await self.ywebsocket_server.get_room(room_id)
492
- fork_ydoc = Doc()
484
+ fork_ydoc: Doc = Doc()
493
485
 
494
486
  ydoc_factory = YDOCS.get(content_type)
495
487
  if ydoc_factory is None:
@@ -505,7 +497,9 @@ class TimelineHandler(APIHandler):
505
497
  FORK_DOCUMENTS[idx] = ydoc_factory(fork_ydoc)
506
498
  undo_manager: UndoManager = FORK_DOCUMENTS[idx].undo_manager
507
499
 
508
- updates_and_timestamps = [(item[0], item[-1]) async for item in room.ystore.read()]
500
+ ystore = room.ystore
501
+ assert ystore
502
+ updates_and_timestamps = [(item[0], item[-1]) async for item in ystore.read()]
509
503
 
510
504
  result_timestamps = []
511
505
 
@@ -649,7 +643,7 @@ class DocForkHandler(APIHandler):
649
643
  return self.finish({"code": 404, "error": "Root room not found"})
650
644
 
651
645
  update = root_room.ydoc.get_update()
652
- fork_ydoc = Doc()
646
+ fork_ydoc: Doc = Doc()
653
647
  fork_ydoc.apply_update(update)
654
648
  model = self.get_json_body()
655
649
  synchronize = model.get("synchronize", False)
@@ -203,13 +203,28 @@ class FileLoader:
203
203
  if self._poll_interval is None:
204
204
  return
205
205
 
206
+ consecutive_error_logs = 0
207
+ max_consecutive_logs = 3
208
+ suppression_logged = False
209
+
206
210
  while True:
207
211
  try:
208
212
  await asyncio.sleep(self._poll_interval)
209
213
  try:
210
214
  await self.maybe_notify()
215
+ consecutive_error_logs = 0
216
+ suppression_logged = False
211
217
  except Exception as e:
212
- self._log.error(f"Error watching file: {self.path}\n{e!r}", exc_info=e)
218
+ if consecutive_error_logs < max_consecutive_logs:
219
+ self._log.error(f"Error watching file: {self.path}\n{e!r}", exc_info=e)
220
+ consecutive_error_logs += 1
221
+ elif not suppression_logged:
222
+ self._log.warning(
223
+ "Too many errors while watching %s — suppressing further logs.",
224
+ self.path,
225
+ )
226
+ suppression_logged = True
227
+
213
228
  except asyncio.CancelledError:
214
229
  break
215
230
 
@@ -240,7 +240,12 @@ def rtc_add_doc_to_store(rtc_connect_doc_client):
240
240
 
241
241
  def rtc_create_SQLite_store_factory(jp_serverapp):
242
242
  async def _inner(type: str, path: str, content: str) -> DocumentRoom:
243
- db = SQLiteYStore(path=f"{type}:{path}", config=jp_serverapp.config)
243
+ db = SQLiteYStore(
244
+ path=f"{type}:{path}",
245
+ # `SQLiteYStore` here is a subclass of booth `LoggingConfigurable`
246
+ # and `pycrdt_websocket.ystore.SQLiteYStore`, but mypy gets lost:
247
+ config=jp_serverapp.config, # type:ignore[call-arg]
248
+ )
244
249
  _ = create_task(db.start())
245
250
  await db.started.wait()
246
251
 
@@ -271,7 +276,7 @@ def rtc_create_mock_document_room():
271
276
  last_modified: datetime | None = None,
272
277
  save_delay: float | None = None,
273
278
  store: SQLiteYStore | None = None,
274
- writable: bool = False,
279
+ writable: bool = True,
275
280
  ) -> tuple[FakeContentsManager, FileLoader, DocumentRoom]:
276
281
  paths = {id: path}
277
282
 
@@ -9,7 +9,7 @@ from typing import Any, Callable
9
9
 
10
10
  from jupyter_events import EventLogger
11
11
  from jupyter_ydoc import ydocs as YDOCS
12
- from pycrdt_websocket.websocket_server import YRoom
12
+ from pycrdt_websocket.yroom import YRoom
13
13
  from pycrdt_websocket.ystore import BaseYStore, YDocNotFound
14
14
 
15
15
  from .loaders import FileLoader
@@ -103,7 +103,7 @@ class DocumentRoom(YRoom):
103
103
  It is important to set the ready property in the parent class (`self.ready = True`),
104
104
  this setter will subscribe for updates on the shared document.
105
105
  """
106
- if self.ready: # type: ignore[has-type]
106
+ if self.ready:
107
107
  return
108
108
 
109
109
  self.log.info("Initializing room %s", self._room_id)
@@ -247,6 +247,33 @@ class DocumentRoom(YRoom):
247
247
  document. This tasks are debounced (60 seconds by default) so we
248
248
  need to cancel previous tasks before creating a new one.
249
249
  """
250
+ # Collect autosave values from all clients
251
+ autosave_states = [
252
+ state.get("autosave", True)
253
+ for state in self.awareness.states.values()
254
+ if state # skip empty states
255
+ ]
256
+
257
+ # If no states exist (e.g., during tests), force autosave to be True
258
+ if not autosave_states:
259
+ autosave_states = [True]
260
+
261
+ # Enable autosave if at least one client has it turned on
262
+ autosave = any(autosave_states)
263
+
264
+ if not autosave:
265
+ return
266
+ if self._update_lock.locked():
267
+ return
268
+
269
+ self._saving_document = asyncio.create_task(
270
+ self._maybe_save_document(self._saving_document)
271
+ )
272
+
273
+ def _save_to_disc(self):
274
+ """
275
+ Called when manual save is triggered. Helpful when autosave is turned off.
276
+ """
250
277
  if self._update_lock.locked():
251
278
  return
252
279
 
@@ -265,7 +292,6 @@ class DocumentRoom(YRoom):
265
292
  """
266
293
  if self._save_delay is None:
267
294
  return
268
-
269
295
  if saving_document is not None and not saving_document.done():
270
296
  # the document is being saved, cancel that
271
297
  saving_document.cancel()
@@ -285,9 +311,9 @@ class DocumentRoom(YRoom):
285
311
  "content": self._document.source,
286
312
  }
287
313
  )
288
- async with self._update_lock:
289
- self._document.dirty = False
290
- if saved_model:
314
+ if saved_model:
315
+ async with self._update_lock:
316
+ self._document.dirty = False
291
317
  self._document.hash = saved_model["hash"]
292
318
 
293
319
  self._emit(LogLevel.INFO, "save", "Content saved.")
@@ -19,6 +19,7 @@ FORK_EVENTS_SCHEMA_PATH = EVENTS_FOLDER_PATH / "fork.yaml"
19
19
  class MessageType(IntEnum):
20
20
  SYNC = 0
21
21
  AWARENESS = 1
22
+ RAW = 2
22
23
  CHAT = 125
23
24
 
24
25
 
@@ -7,9 +7,10 @@ import asyncio
7
7
  from logging import Logger
8
8
  from typing import Any, Callable
9
9
 
10
- from pycrdt_websocket.websocket_server import WebsocketServer, YRoom
10
+ from pycrdt_websocket.websocket import Websocket
11
+ from pycrdt_websocket.websocket_server import WebsocketServer
12
+ from pycrdt_websocket.yroom import YRoom
11
13
  from pycrdt_websocket.ystore import BaseYStore
12
- from tornado.websocket import WebSocketHandler
13
14
 
14
15
 
15
16
  class RoomNotFound(LookupError):
@@ -38,7 +39,7 @@ class JupyterWebsocketServer(WebsocketServer):
38
39
 
39
40
  def __init__(
40
41
  self,
41
- ystore_class: BaseYStore,
42
+ ystore_class: type[BaseYStore],
42
43
  rooms_ready: bool = True,
43
44
  auto_clean_rooms: bool = True,
44
45
  exception_handler: Callable[[Exception, Logger], bool] | None = None,
@@ -132,7 +133,7 @@ class JupyterWebsocketServer(WebsocketServer):
132
133
  await self.start_room(room)
133
134
  return room
134
135
 
135
- async def serve(self, websocket: WebSocketHandler) -> None:
136
+ async def serve(self, websocket: Websocket) -> None:
136
137
  # start monitoring here as the event loop is not yet available when initializing the object
137
138
  if self.monitor_task is None:
138
139
  self.monitor_task = asyncio.create_task(self._monitor())
@@ -51,9 +51,7 @@ async def test_defined_save_delay_should_save_content_after_document_change(
51
51
  rtc_create_mock_document_room,
52
52
  ):
53
53
  content = "test"
54
- cm, _, room = rtc_create_mock_document_room(
55
- "test-id", "test.txt", content, save_delay=0.01, writable=True
56
- )
54
+ cm, _, room = rtc_create_mock_document_room("test-id", "test.txt", content, save_delay=0.01)
57
55
 
58
56
  await room.initialize()
59
57
  room._document.source = "Test 2"
@@ -79,6 +77,50 @@ async def test_undefined_save_delay_should_not_save_content_after_document_chang
79
77
  assert "save" not in cm.actions
80
78
 
81
79
 
80
+ async def test_should_not_save_content_when_all_clients_have_autosave_disabled(
81
+ rtc_create_mock_document_room,
82
+ ):
83
+ content = "test"
84
+ cm, _, room = rtc_create_mock_document_room("test-id", "test.txt", content, save_delay=0.01)
85
+
86
+ # Disable autosave for all existing clients
87
+ for state in room.awareness._states.values():
88
+ if state is not None:
89
+ state["autosave"] = False
90
+
91
+ # Inject a dummy client with autosave disabled
92
+ room.awareness._states[9999] = {"autosave": False}
93
+
94
+ await room.initialize()
95
+ room._document.source = "Test 2"
96
+
97
+ await asyncio.sleep(0.15)
98
+
99
+ assert "save" not in cm.actions
100
+
101
+
102
+ async def test_should_save_content_when_at_least_one_client_has_autosave_enabled(
103
+ rtc_create_mock_document_room,
104
+ ):
105
+ content = "test"
106
+ cm, _, room = rtc_create_mock_document_room("test-id", "test.txt", content, save_delay=0.01)
107
+
108
+ # Disable autosave for all existing clients
109
+ for state in room.awareness._states.values():
110
+ if state is not None:
111
+ state["autosave"] = False
112
+
113
+ # Inject a dummy client with autosave enabled
114
+ room.awareness._states[10000] = {"autosave": True}
115
+
116
+ await room.initialize()
117
+ room._document.source = "Test 2"
118
+
119
+ await asyncio.sleep(0.15)
120
+
121
+ assert "save" in cm.actions
122
+
123
+
82
124
  # The following test should be restored when package versions are fixed.
83
125
 
84
126
  # async def test_document_path(rtc_create_mock_document_room):
@@ -1 +0,0 @@
1
- __version__ = "2.0.1"