Skip to content

Commit 5a1d57b

Browse files
committed
Allow to set SQLiteYStore's database path and document time-to-live
1 parent a85713f commit 5a1d57b

File tree

3 files changed

+71
-25
lines changed

3 files changed

+71
-25
lines changed

jupyter_server_ydoc/app.py

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,10 @@
66
except ModuleNotFoundError:
77
raise ModuleNotFoundError("Jupyter Server must be installed to use this extension.")
88

9-
from traitlets import Float, Int, Type
10-
from ypy_websocket.ystore import BaseYStore # type: ignore
9+
from traitlets import Float, Int, Type, Unicode, observe
10+
from ypy_websocket.ystore import BaseYStore, SQLiteYStore # type: ignore
1111

12-
from .handlers import JupyterSQLiteYStore, YDocRoomIdHandler, YDocWebSocketHandler
12+
from .handlers import YDocRoomIdHandler, YDocWebSocketHandler, sqlite_ystore_factory
1313

1414

1515
class YDocExtension(ExtensionApp):
@@ -41,14 +41,47 @@ class YDocExtension(ExtensionApp):
4141
)
4242

4343
ystore_class = Type(
44-
default_value=JupyterSQLiteYStore,
44+
default_value=sqlite_ystore_factory(),
4545
klass=BaseYStore,
4646
config=True,
4747
help="""The YStore class to use for storing Y updates. Defaults to JupyterSQLiteYStore,
4848
which stores Y updates in a '.jupyter_ystore.db' SQLite database in the current
4949
directory, and clears history every 24 hours.""",
5050
)
5151

52+
sqlite_ystore_db_path = Unicode(
53+
".jupyter_ystore.db",
54+
config=True,
55+
help="""The path to the YStore database. Defaults to '.jupyter_ystore.db' in the current
56+
directory. Only applicable if the YStore is an SQLiteYStore.""",
57+
)
58+
59+
@observe("sqlite_ystore_db_path")
60+
def _observe_sqlite_ystore_db_path(self, change):
61+
if issubclass(self.ystore_class, SQLiteYStore):
62+
self.ystore_class.db_path = change["new"]
63+
else:
64+
raise RuntimeError(
65+
f"ystore_class must be an SQLiteYStore to be able to set sqlite_ystore_db_path, not {self.ystore_class}"
66+
)
67+
68+
sqlite_ystore_document_ttl = Int(
69+
None,
70+
allow_none=True,
71+
config=True,
72+
help="""The document time-to-live in seconds. Defaults to None (document history is never
73+
cleared). Only applicable if the YStore is an SQLiteYStore.""",
74+
)
75+
76+
@observe("sqlite_ystore_document_ttl")
77+
def _observe_sqlite_ystore_document_ttl(self, change):
78+
if issubclass(self.ystore_class, SQLiteYStore):
79+
self.ystore_class.document_ttl = change["new"]
80+
else:
81+
raise RuntimeError(
82+
f"ystore_class must be an SQLiteYStore to be able to set sqlite_ystore_document_ttl, not {self.ystore_class}"
83+
)
84+
5285
def initialize_settings(self):
5386
self.settings.update(
5487
{

jupyter_server_ydoc/handlers.py

Lines changed: 33 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -5,21 +5,22 @@
55
import json
66
from logging import Logger
77
from pathlib import Path
8-
from typing import Any, Dict, Optional, Tuple
8+
from typing import Any, Dict, Optional, Tuple, Type
99

1010
from jupyter_server.auth import authorized
1111
from jupyter_server.base.handlers import APIHandler, JupyterHandler
1212
from jupyter_server.utils import ensure_async
1313
from jupyter_ydoc import ydocs as YDOCS # type: ignore
1414
from tornado import web
1515
from tornado.websocket import WebSocketHandler
16-
from ypy_websocket import WebsocketServer, YMessageType, YRoom # type: ignore
16+
from ypy_websocket.websocket_server import WebsocketServer, YRoom # type: ignore
1717
from ypy_websocket.ystore import ( # type: ignore
1818
BaseYStore,
1919
SQLiteYStore,
2020
TempFileYStore,
2121
YDocNotFound,
2222
)
23+
from ypy_websocket.yutils import YMessageType # type: ignore
2324

2425
YFILE = YDOCS["file"]
2526

@@ -28,16 +29,22 @@ class JupyterTempFileYStore(TempFileYStore):
2829
prefix_dir = "jupyter_ystore_"
2930

3031

31-
class JupyterSQLiteYStore(SQLiteYStore):
32-
db_path = ".jupyter_ystore.db"
33-
document_ttl = 24 * 60 * 60
32+
def sqlite_ystore_factory(
33+
db_path: str = ".jupyter_ystore.db", document_ttl: Optional[int] = None
34+
) -> Type[SQLiteYStore]:
35+
_db_path = db_path
36+
_document_ttl = document_ttl
37+
38+
class JupyterSQLiteYStore(SQLiteYStore):
39+
db_path = _db_path
40+
document_ttl = _document_ttl
41+
42+
return JupyterSQLiteYStore
3443

3544

3645
class DocumentRoom(YRoom):
3746
"""A Y room for a possibly stored document (e.g. a notebook)."""
3847

39-
is_transient = False
40-
4148
def __init__(self, type: str, ystore: BaseYStore, log: Optional[Logger]):
4249
super().__init__(ready=False, ystore=ystore, log=log)
4350
self.type = type
@@ -49,8 +56,6 @@ def __init__(self, type: str, ystore: BaseYStore, log: Optional[Logger]):
4956
class TransientRoom(YRoom):
5057
"""A Y room for sharing state (e.g. awareness)."""
5158

52-
is_transient = True
53-
5459
def __init__(self, log: Optional[Logger]):
5560
super().__init__(log=log)
5661

@@ -132,6 +137,7 @@ async def __anext__(self):
132137

133138
def get_file_info(self) -> Tuple[str, str, str]:
134139
assert self.websocket_server is not None
140+
assert isinstance(self.room, DocumentRoom)
135141
room_name = self.websocket_server.get_room_name(self.room)
136142
file_format: str
137143
file_type: str
@@ -175,10 +181,10 @@ async def open(self, path):
175181
asyncio.create_task(self.websocket_server.serve(self))
176182

177183
# cancel the deletion of the room if it was scheduled
178-
if not self.room.is_transient and self.room.cleaner is not None:
184+
if isinstance(self.room, DocumentRoom) and self.room.cleaner is not None:
179185
self.room.cleaner.cancel()
180186

181-
if not self.room.is_transient and not self.room.ready:
187+
if isinstance(self.room, DocumentRoom) and not self.room.ready:
182188
file_format, file_type, file_path = self.get_file_info()
183189
self.log.debug("Opening Y document from disk: %s", file_path)
184190
model = await ensure_async(
@@ -188,26 +194,30 @@ async def open(self, path):
188194
# check again if ready, because loading the file can be async
189195
if not self.room.ready:
190196
# try to apply Y updates from the YStore for this document
191-
try:
192-
await self.room.ystore.apply_updates(self.room.ydoc)
193-
read_from_source = False
194-
except YDocNotFound:
195-
# YDoc not found in the YStore, create the document from the source file (no change history)
196-
read_from_source = True
197+
read_from_source = True
198+
if self.room.ystore is not None:
199+
try:
200+
await self.room.ystore.apply_updates(self.room.ydoc)
201+
read_from_source = False
202+
except YDocNotFound:
203+
# YDoc not found in the YStore, create the document from the source file (no change history)
204+
pass
197205
if not read_from_source:
198206
# if YStore updates and source file are out-of-sync, resync updates with source
199207
if self.room.document.source != model["content"]:
200208
read_from_source = True
201209
if read_from_source:
202210
self.room.document.source = model["content"]
203-
await self.room.ystore.encode_state_as_update(self.room.ydoc)
211+
if self.room.ystore:
212+
await self.room.ystore.encode_state_as_update(self.room.ydoc)
204213
self.room.document.dirty = False
205214
self.room.ready = True
206215
self.room.watcher = asyncio.create_task(self.watch_file())
207216
# save the document when changed
208217
self.room.document.observe(self.on_document_change)
209218

210219
async def watch_file(self):
220+
assert isinstance(self.room, DocumentRoom)
211221
poll_interval = self.settings["collaborative_file_poll_interval"]
212222
if not poll_interval:
213223
self.room.watcher = None
@@ -217,6 +227,7 @@ async def watch_file(self):
217227
await self.maybe_load_document()
218228

219229
async def maybe_load_document(self):
230+
assert isinstance(self.room, DocumentRoom)
220231
file_format, file_type, file_path = self.get_file_info()
221232
async with self.lock:
222233
model = await ensure_async(
@@ -267,7 +278,7 @@ def on_message(self, message):
267278
# filter out message depending on changes
268279
if skip:
269280
self.log.debug(
270-
"Filtered out Y message of type: %s", YMessageType(message_type).raw_str()
281+
"Filtered out Y message of type: %s", YMessageType(message_type).name
271282
)
272283
return skip
273284
self._message_queue.put_nowait(message)
@@ -276,12 +287,13 @@ def on_message(self, message):
276287
def on_close(self) -> None:
277288
# stop serving this client
278289
self._message_queue.put_nowait(b"")
279-
if not self.room.is_transient and self.room.clients == [self]:
290+
if isinstance(self.room, DocumentRoom) and self.room.clients == [self]:
280291
# no client in this room after we disconnect
281292
# keep the document for a while in case someone reconnects
282293
self.room.cleaner = asyncio.create_task(self.clean_room())
283294

284295
async def clean_room(self) -> None:
296+
assert isinstance(self.room, DocumentRoom)
285297
seconds = self.settings["collaborative_document_cleanup_delay"]
286298
if seconds is None:
287299
return
@@ -309,6 +321,7 @@ def on_document_change(self, event):
309321
self.saving_document = asyncio.create_task(self.maybe_save_document())
310322

311323
async def maybe_save_document(self):
324+
assert isinstance(self.room, DocumentRoom)
312325
seconds = self.settings["collaborative_document_save_delay"]
313326
if seconds is None:
314327
return

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ classifiers = [
2222
requires-python = ">=3.7"
2323
dependencies = [
2424
"jupyter_ydoc>=0.2.0,<0.4.0",
25-
"ypy-websocket>=0.8.0,<0.9.0",
25+
"ypy-websocket>=0.8.1,<0.9.0",
2626
"jupyter_server_fileid >=0.6.0,<1"
2727
]
2828

0 commit comments

Comments
 (0)