Script 'mail_helper' called by obssrc
Hello community,
here is the log from the commit of package python-jupyter-server-ydoc for
openSUSE:Factory checked in at 2026-05-28 17:27:38
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/python-jupyter-server-ydoc (Old)
and /work/SRC/openSUSE:Factory/.python-jupyter-server-ydoc.new.1937 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "python-jupyter-server-ydoc"
Thu May 28 17:27:38 2026 rev:6 rq:1355498 version:2.4.0
Changes:
--------
---
/work/SRC/openSUSE:Factory/python-jupyter-server-ydoc/python-jupyter-server-ydoc.changes
2026-03-23 17:13:16.633447919 +0100
+++
/work/SRC/openSUSE:Factory/.python-jupyter-server-ydoc.new.1937/python-jupyter-server-ydoc.changes
2026-05-28 17:28:59.580819910 +0200
@@ -1,0 +2,6 @@
+Thu May 28 01:49:07 UTC 2026 - Steve Kowalik <[email protected]>
+
+- Update to 2.4.0
+ * New subpackage version for jupyter-collaboration 4.4.0
+
+-------------------------------------------------------------------
Old:
----
jupyter_server_ydoc-2.2.1.tar.gz
New:
----
jupyter_server_ydoc-2.4.0.tar.gz
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Other differences:
------------------
++++++ python-jupyter-server-ydoc.spec ++++++
--- /var/tmp/diff_new_pack.UNBrQm/_old 2026-05-28 17:29:00.212846071 +0200
+++ /var/tmp/diff_new_pack.UNBrQm/_new 2026-05-28 17:29:00.212846071 +0200
@@ -25,14 +25,15 @@
%bcond_with test
%endif
-%define distversion 2.2.1
+%define distversion 2.4
Name: python-jupyter-server-ydoc%{psuffix}
-Version: 2.2.1
+Version: 2.4.0
Release: 0
Summary: Jupyter server extension integrating collaborative shared
models
License: BSD-3-Clause
URL: https://github.com/jupyterlab/jupyter-collaboration
Source:
https://files.pythonhosted.org/packages/source/j/jupyter_server_ydoc/jupyter_server_ydoc-%{version}.tar.gz
+BuildRequires: %{python_module base >= 3.10}
BuildRequires: %{python_module hatchling >= 1.4.0}
BuildRequires: %{python_module pip}
BuildRequires: fdupes
++++++ jupyter_server_ydoc-2.2.1.tar.gz -> jupyter_server_ydoc-2.4.0.tar.gz
++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/jupyter_server_ydoc-2.2.1/PKG-INFO
new/jupyter_server_ydoc-2.4.0/PKG-INFO
--- old/jupyter_server_ydoc-2.2.1/PKG-INFO 2020-02-02 01:00:00.000000000
+0100
+++ new/jupyter_server_ydoc-2.4.0/PKG-INFO 2020-02-02 01:00:00.000000000
+0100
@@ -1,6 +1,6 @@
Metadata-Version: 2.4
Name: jupyter-server-ydoc
-Version: 2.2.1
+Version: 2.4.0
Summary: jupyter-server extension integrating collaborative shared models.
Project-URL: Documentation,
https://jupyterlab-realtime-collaboration.readthedocs.io/
Project-URL: Repository, https://github.com/jupyterlab/jupyter-collaboration
@@ -74,25 +74,24 @@
Classifier: Intended Audience :: System Administrators
Classifier: License :: OSI Approved :: BSD License
Classifier: Programming Language :: Python
-Classifier: Programming Language :: Python :: 3.8
-Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
-Requires-Python: >=3.8
+Classifier: Programming Language :: Python :: 3.13
+Classifier: Programming Language :: Python :: 3.14
+Requires-Python: >=3.10
Requires-Dist: jsonschema>=4.18.0
Requires-Dist: jupyter-events>=0.11.0
Requires-Dist: jupyter-server-fileid<1,>=0.7.0
Requires-Dist: jupyter-server<3.0.0,>=2.15.0
-Requires-Dist: jupyter-ydoc<4.0.0,>=3.0.3
+Requires-Dist: jupyter-ydoc<4.0.0,>=3.4.0
Requires-Dist: pycrdt
Requires-Dist: pycrdt-websocket<0.17.0,>=0.16.0
Provides-Extra: test
Requires-Dist: anyio; extra == 'test'
Requires-Dist: coverage; extra == 'test'
Requires-Dist: dirty-equals; extra == 'test'
-Requires-Dist: httpx-ws>=0.5.2; extra == 'test'
-Requires-Dist: importlib-metadata>=4.8.3; (python_version < '3.10') and extra
== 'test'
+Requires-Dist: httpx-ws>=0.9.0; extra == 'test'
Requires-Dist: jupyter-server-fileid[test]; extra == 'test'
Requires-Dist: jupyter-server[test]>=2.15.0; extra == 'test'
Requires-Dist: pytest-cov; extra == 'test'
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/jupyter_server_ydoc-2.2.1/jupyter_server_ydoc/__init__.py
new/jupyter_server_ydoc-2.4.0/jupyter_server_ydoc/__init__.py
--- old/jupyter_server_ydoc-2.2.1/jupyter_server_ydoc/__init__.py
2020-02-02 01:00:00.000000000 +0100
+++ new/jupyter_server_ydoc-2.4.0/jupyter_server_ydoc/__init__.py
2020-02-02 01:00:00.000000000 +0100
@@ -1,11 +1,11 @@
# Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License.
-from typing import Any, Dict, List
+from typing import Any
from ._version import __version__ # noqa
from .app import YDocExtension
-def _jupyter_server_extension_points() -> List[Dict[str, Any]]:
+def _jupyter_server_extension_points() -> list[dict[str, Any]]:
return [{"module": "jupyter_server_ydoc", "app": YDocExtension}]
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/jupyter_server_ydoc-2.2.1/jupyter_server_ydoc/_version.py
new/jupyter_server_ydoc-2.4.0/jupyter_server_ydoc/_version.py
--- old/jupyter_server_ydoc-2.2.1/jupyter_server_ydoc/_version.py
2020-02-02 01:00:00.000000000 +0100
+++ new/jupyter_server_ydoc-2.4.0/jupyter_server_ydoc/_version.py
2020-02-02 01:00:00.000000000 +0100
@@ -1 +1 @@
-__version__ = "2.2.1"
+__version__ = "2.4.0"
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/jupyter_server_ydoc-2.2.1/jupyter_server_ydoc/app.py
new/jupyter_server_ydoc-2.4.0/jupyter_server_ydoc/app.py
--- old/jupyter_server_ydoc-2.2.1/jupyter_server_ydoc/app.py 2020-02-02
01:00:00.000000000 +0100
+++ new/jupyter_server_ydoc-2.4.0/jupyter_server_ydoc/app.py 2020-02-02
01:00:00.000000000 +0100
@@ -3,8 +3,9 @@
from __future__ import annotations
import asyncio
+from collections import defaultdict
from functools import partial
-from typing import Literal
+from typing import Literal, cast
from jupyter_server.extension.application import ExtensionApp
from jupyter_ydoc import ydocs as YDOCS
@@ -27,6 +28,7 @@
AWARENESS_EVENTS_SCHEMA_PATH,
EVENTS_SCHEMA_PATH,
FORK_EVENTS_SCHEMA_PATH,
+ decode_file_path,
encode_file_path,
room_id_from_encoded_path,
)
@@ -91,6 +93,8 @@
model.""",
)
+ _room_locks: dict[str, asyncio.Lock] = defaultdict(asyncio.Lock)
+
def initialize(self):
super().initialize()
self.serverapp.event_logger.register_event_schema(EVENTS_SCHEMA_PATH)
@@ -153,6 +157,7 @@
"file_loaders": self.file_loaders,
"ystore_class": ystore_class,
"ywebsocket_server": self.ywebsocket_server,
+ "room_locks": self._room_locks,
},
),
(r"/api/collaboration/session/(.*)", DocSessionHandler),
@@ -182,6 +187,7 @@
file_format: Literal["json", "text"] | None = None,
room_id: str | None = None,
copy: bool = True,
+ create: bool = False,
) -> YBaseDoc | None:
"""Get a view of the shared model for the matching document.
@@ -190,8 +196,13 @@
If `copy=True`, the returned shared model is a fork, meaning that any
changes
made to it will not be propagated to the shared model used by the
application.
+
+ If `create=True`, the room will be created if it doesn't exist.
"""
- error_msg = "You need to provide either a ``room_id`` or the ``path``,
the ``content_type`` and the ``file_format``."
+ error_msg = (
+ "You need to provide either a ``room_id`` or the ``path``, "
+ "the ``content_type`` and the ``file_format``."
+ )
if room_id is None:
if path is None or content_type is None or file_format is None:
raise ValueError(error_msg)
@@ -204,13 +215,55 @@
elif path is not None or content_type is not None or file_format is
not None:
raise ValueError(error_msg)
- else:
- room_id = room_id
- try:
- room = await self.ywebsocket_server.get_room(room_id)
- except RoomNotFound:
- return None
+ async with self._room_locks[room_id]:
+ try:
+ room = await self.ywebsocket_server.get_room(room_id)
+ except RoomNotFound:
+ if not create:
+ return None
+
+ if not self.ywebsocket_server.started.is_set():
+ asyncio.create_task(self.ywebsocket_server.start())
+ await self.ywebsocket_server.started.wait()
+
+ file_format_str, file_type, file_id = decode_file_path(room_id)
+ # cast down so mypy won’t complain when we pass this into
DocumentRoom
+ file_format = cast(Literal["json", "text"], file_format_str)
+ updates_file_path = f".{file_type}:{file_id}.y"
+ ystore = self.ystore_class(
+ path=updates_file_path,
+ log=self.log,
+ )
+ # Create a new room
+ room = DocumentRoom(
+ room_id,
+ file_format,
+ file_type,
+ self.file_loaders[file_id],
+ self.serverapp.event_logger,
+ ystore,
+ self.log,
+ exception_handler=exception_logger,
+ save_delay=self.document_save_delay,
+ )
+ await room.initialize()
+ try:
+ await self.ywebsocket_server.start_room(room)
+ self.ywebsocket_server.add_room(room_id, room)
+ self.log.info(f"Created and started room: {room_id}")
+ except Exception as e:
+ self.log.error("Room %s failed to start on websocket
server", room_id)
+ # Clean room
+ await room.stop()
+ self.log.info("Room %s deleted", room_id)
+ file = self.file_loaders[file_id]
+ if file.number_of_subscriptions == 0 or (
+ file.number_of_subscriptions == 1 and room_id in
file._subscriptions
+ ):
+ self.log.info("Deleting file %s", file.path)
+ await self.file_loaders.remove(file_id)
+ raise e
if isinstance(room, DocumentRoom):
if copy:
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/jupyter_server_ydoc-2.2.1/jupyter_server_ydoc/handlers.py
new/jupyter_server_ydoc-2.4.0/jupyter_server_ydoc/handlers.py
--- old/jupyter_server_ydoc-2.2.1/jupyter_server_ydoc/handlers.py
2020-02-02 01:00:00.000000000 +0100
+++ new/jupyter_server_ydoc-2.4.0/jupyter_server_ydoc/handlers.py
2020-02-02 01:00:00.000000000 +0100
@@ -5,17 +5,16 @@
import asyncio
import json
-import uuid
+import os
from logging import Logger
-from typing import Any
+from typing import Any, cast
from uuid import uuid4
-from typing import cast
from jupyter_server.auth import authorized
from jupyter_server.base.handlers import APIHandler, JupyterHandler
from jupyter_server.utils import ensure_async
from jupyter_ydoc import ydocs as YDOCS
-from pycrdt import Doc, Encoder, UndoManager
+from pycrdt import Decoder, Doc, Encoder, UndoManager
from pycrdt.store import BaseYStore
from pycrdt.websocket import YRoom
from tornado import web
@@ -27,19 +26,21 @@
JUPYTER_COLLABORATION_AWARENESS_EVENTS_URI,
JUPYTER_COLLABORATION_EVENTS_URI,
JUPYTER_COLLABORATION_FORK_EVENTS_URI,
+ SERVER_SESSION,
+ YDOC_SERVER_VERSION,
LogLevel,
+ MessageType,
+ check_session_compatibility,
decode_file_path,
encode_file_path,
room_id_from_encoded_path,
+ save_current_session,
)
from .websocketserver import JupyterWebsocketServer, RoomNotFound
-from .utils import MessageType
-from pycrdt import Decoder
YFILE = YDOCS["file"]
-SERVER_SESSION = str(uuid.uuid4())
FORK_DOCUMENTS = {}
FORK_ROOMS: dict[str, dict[str, str]] = {}
@@ -66,7 +67,7 @@
_message_queue: asyncio.Queue[Any]
_background_tasks: set[asyncio.Task]
- _room_locks: dict[str, asyncio.Lock] = {}
+ _session_file_lock = asyncio.Lock()
def _room_lock(self, room_id: str) -> asyncio.Lock:
if room_id not in self._room_locks:
@@ -113,7 +114,11 @@
self._emit(
LogLevel.WARNING,
None,
- "There is another collaborative session accessing
the same file.\nThe synchronization between rooms is not supported and you
might lose some of your changes.",
+ (
+ "There is another collaborative session
accessing the same "
+ "file.\nThe synchronization between rooms is
not supported "
+ "and you might lose some of your changes."
+ ),
)
file = self._file_loaders[file_id]
@@ -173,6 +178,7 @@
ywebsocket_server: JupyterWebsocketServer,
file_loaders: FileLoaderMapping,
ystore_class: type[BaseYStore],
+ room_locks: dict[str, asyncio.Lock] | None = None,
document_cleanup_delay: float | None = 60.0,
document_save_delay: float | None = 1.0,
) -> None:
@@ -187,6 +193,7 @@
self._message_queue = asyncio.Queue()
self._room_id = ""
self.room = None # type:ignore
+ self._room_locks = room_locks if room_locks is not None else {}
@property
def path(self):
@@ -232,11 +239,35 @@
if isinstance(self.room, DocumentRoom):
# Close the connection if the document session expired
session_id = self.get_query_argument("sessionId", "")
+ root_dir = self.settings.get("server_root_dir", os.getcwd())
+ document_version = getattr(self.room._document, "version", None)
+
+ # Persist the current session so future reconnects can validate it
+ await save_current_session(
+ root_dir,
+ SERVER_SESSION,
+ YDOC_SERVER_VERSION,
+ self._session_file_lock,
+ document_version=document_version,
+ )
if SERVER_SESSION != session_id:
- self.close(
- 1003,
- f"Document session {session_id} expired. You need to
reload this browser tab.",
+ cannot_reconnect, reason = check_session_compatibility(
+ root_dir,
+ session_id,
+ YDOC_SERVER_VERSION,
+ current_document_version=document_version,
)
+ if cannot_reconnect:
+ # Must ask the user to reload
+ close_payload = json.dumps(
+ {
+ "reason": reason,
+ "sessionId": session_id,
+ "reloadable": True,
+ }
+ )
+ self.close(1003, close_payload)
+ # Else accept the old session, no reload needed.
# cancel the deletion of the room if it was scheduled
if self.room.cleaner is not None:
@@ -253,13 +284,36 @@
# Close websocket and propagate error.
if isinstance(e, web.HTTPError):
- self.log.error(f"File {file.path} not found.\n{e!r}",
exc_info=e)
- self.close(1004, f"File {file.path} not found.")
+ if e.status_code == 404:
+ error_code = 4404 # custom code for "file not found"
+ self.log.error(f"File {file.path} not found.\n{e!r}",
exc_info=e)
+ elif e.status_code == 400:
+ error_code = 4400 # custom code for "bad request"
+ self.log.error(f"Bad request for file
{file.path}.\n{e!r}", exc_info=e)
+ elif e.status_code == 500:
+ error_code = 4500 # custom code for "internal server
error"
+ self.log.error(
+ f"Internal server error for file
{file.path}.\n{e!r}", exc_info=e
+ )
+ else:
+ error_code = 4500 # generic error code for other HTTP
errors
+ self.log.error(
+ f"Error initializing room for file
{file.path}.\n{e!r}", exc_info=e
+ )
+ self.close(
+ error_code,
+ f"Error initializing: {file.path}.",
+ )
else:
self.log.error(f"Error initializing: {file.path}\n{e!r}",
exc_info=e)
self.close(
1003,
- f"Error initializing: {file.path}. You need to close
the document.",
+ json.dumps(
+ {
+ "reason": "initialization_error",
+ "reloadable": False,
+ }
+ ),
)
# Clean up the room and delete the file loader
@@ -407,7 +461,8 @@
Update the users when the global awareness changes.
Parameters:
- topic (str): `"update"` or `"change"` (`"change"` is triggered
only if the states are modified).
+ topic (str): `"update"` or `"change"` (`"change"` is triggered
+ only if the states are modified).
changes (tuple[dict[str, Any], Any]): The changes and the
origin of the changes.
"""
if topic != "change":
@@ -440,8 +495,8 @@
auth_resource = "contents"
@web.authenticated
- @authorized
- async def put(self, path):
+ @authorized # type: ignore[misc]
+ async def put(self, path: str) -> asyncio.Future[Any]:
"""
Creates a new session for a given document or returns an existing one.
"""
@@ -453,7 +508,11 @@
idx = file_id_manager.get_id(path)
if idx is not None:
# index already exists
- self.log.info("Request for Y document '%s' with room ID: %s",
path, idx)
+ self.log.info(
+ "Request for Y document (previously indexed) '%s' with room
ID: %s",
+ path,
+ idx,
+ )
data = json.dumps(
{
"format": format,
@@ -472,7 +531,7 @@
raise web.HTTPError(404, f"File {path!r} does not exist")
# index successfully created
- self.log.info("Request for Y document '%s' with room ID: %s", path,
idx)
+ self.log.info("Request for Y document (now indexed) '%s' with room ID:
%s", path, idx)
data = json.dumps(
{
"format": format,
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/jupyter_server_ydoc-2.2.1/jupyter_server_ydoc/loaders.py
new/jupyter_server_ydoc-2.4.0/jupyter_server_ydoc/loaders.py
--- old/jupyter_server_ydoc-2.2.1/jupyter_server_ydoc/loaders.py
2020-02-02 01:00:00.000000000 +0100
+++ new/jupyter_server_ydoc-2.4.0/jupyter_server_ydoc/loaders.py
2020-02-02 01:00:00.000000000 +0100
@@ -4,11 +4,11 @@
from __future__ import annotations
import asyncio
+from collections.abc import Callable, Coroutine
+from http import HTTPStatus
from logging import Logger, getLogger
from time import time
-from typing import Any, Callable, Coroutine
-from tornado.web import HTTPError
-from http import HTTPStatus
+from typing import Any
from jupyter_server.services.contents.manager import (
AsyncContentsManager,
@@ -16,6 +16,7 @@
)
from jupyter_server.utils import ensure_async
from jupyter_server_fileid.manager import BaseFileIdManager
+from tornado.web import HTTPError
from .utils import OutOfBandChanges
@@ -148,7 +149,8 @@
Save the content of the file.
Parameters:
- model (dict): A dictionary with format, type, last_modified,
and content of the file.
+ model (dict): A dictionary with format, type, last_modified,
+ and content of the file.
Raises:
OutOfBandChanges: if the file was modified at a latter time
than the model
@@ -246,7 +248,10 @@
errors_duration = time() -
consecutive_errors_started
if errors_duration >
self._stop_poll_on_errors_after:
self._log.warning(
- "Stopping watching file due to consecutive
errors over %s seconds: %s",
+ (
+ "Stopping watching file due to
consecutive "
+ "errors over %s seconds: %s"
+ ),
self._stop_poll_on_errors_after,
self.path,
)
@@ -313,7 +318,8 @@
Args:
settings: Server settings
log: [optional] Server log; default to local logger
- file_poll_interval: [optional] Interval between room notification;
default the loader won't poll
+ file_poll_interval: [optional] Interval between room
+ notification; default the loader won't poll
"""
self._settings = settings
self.__dict: dict[str, FileLoader] = {}
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/jupyter_server_ydoc-2.2.1/jupyter_server_ydoc/pytest_plugin.py
new/jupyter_server_ydoc-2.4.0/jupyter_server_ydoc/pytest_plugin.py
--- old/jupyter_server_ydoc-2.2.1/jupyter_server_ydoc/pytest_plugin.py
2020-02-02 01:00:00.000000000 +0100
+++ new/jupyter_server_ydoc-2.4.0/jupyter_server_ydoc/pytest_plugin.py
2020-02-02 01:00:00.000000000 +0100
@@ -10,13 +10,14 @@
import nbformat
import pytest
from httpx_ws import aconnect_ws
-from jupyter_server_ydoc.loaders import FileLoader
-from jupyter_server_ydoc.rooms import DocumentRoom
-from jupyter_server_ydoc.stores import SQLiteYStore
from jupyter_ydoc import YNotebook, YUnicode
from pycrdt import Provider
from pycrdt.websocket.websocket import HttpxWebsocket
+from jupyter_server_ydoc.loaders import FileLoader
+from jupyter_server_ydoc.rooms import DocumentRoom
+from jupyter_server_ydoc.stores import SQLiteYStore
+
from .test_utils import (
FakeContentsManager,
FakeEventLogger,
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/jupyter_server_ydoc-2.2.1/jupyter_server_ydoc/rooms.py
new/jupyter_server_ydoc-2.4.0/jupyter_server_ydoc/rooms.py
--- old/jupyter_server_ydoc-2.2.1/jupyter_server_ydoc/rooms.py 2020-02-02
01:00:00.000000000 +0100
+++ new/jupyter_server_ydoc-2.4.0/jupyter_server_ydoc/rooms.py 2020-02-02
01:00:00.000000000 +0100
@@ -4,13 +4,15 @@
from __future__ import annotations
import asyncio
+from collections.abc import Callable
from logging import Logger
-from typing import Any, Callable
+from typing import Any
from jupyter_events import EventLogger
from jupyter_ydoc import ydocs as YDOCS
-from pycrdt.websocket import YRoom
+from pycrdt import Doc
from pycrdt.store import BaseYStore, YDocNotFound
+from pycrdt.websocket import YRoom
from .loaders import FileLoader
from .utils import JUPYTER_COLLABORATION_EVENTS_URI, LogLevel, OutOfBandChanges
@@ -113,6 +115,7 @@
async with self._update_lock:
# try to apply Y updates from the YStore for this document
read_from_source = True
+ loaded_from_store = False
if self.ystore is not None:
async with self.ystore.start_lock:
if not self.ystore.started.is_set():
@@ -123,9 +126,7 @@
self._emit(
LogLevel.INFO,
"load",
- "Content loaded from the store {}".format(
- self.ystore.__class__.__qualname__
- ),
+ f"Content loaded from the store
{self.ystore.__class__.__qualname__}",
)
self.log.info(
"Content in room %s loaded from the ystore %s",
@@ -133,13 +134,15 @@
self.ystore.__class__.__name__,
)
read_from_source = False
+ loaded_from_store = True
except YDocNotFound:
- # YDoc not found in the YStore, create the document from
the source file (no change history)
+ # YDoc not found in the YStore, create the document from
+ # the source file (no change history)
pass
if not read_from_source:
# if YStore updates and source file are out-of-sync, resync
updates with source
- if self._document.source != model["content"]:
+ if await self._document.aget() != model["content"]:
# TODO: Delete document from the store.
self._emit(
LogLevel.INFO,
@@ -160,7 +163,10 @@
self._room_id,
self._file.path,
)
- self._document.source = model["content"]
+ if not loaded_from_store:
+ await
self._apply_deterministic_source_content(model["content"])
+ else:
+ await self._document.aset(model["content"])
if self.ystore:
await self.ystore.encode_state_as_update(self.ydoc)
@@ -169,6 +175,22 @@
self.ready = True
self._emit(LogLevel.INFO, "initialize", "Room initialized")
+ async def _apply_deterministic_source_content(self, content: Any) -> None:
+ """Load source content using a deterministic update.
+
+ Rooms rebuilt from disk must recreate the same Yjs history for
identical
+ content, otherwise reconnecting clients can merge duplicate content
from a
+ divergent history after server restart or room eviction.
+
+ The client ID needs to be fixed to a deterministic value, see:
+
https://discuss.yjs.dev/t/initial-offline-value-of-a-shared-document/465
+ """
+
+ source_ydoc: Doc = Doc(client_id=0)
+ source_document = YDOCS.get(self._file_type, YFILE)(source_ydoc)
+ await source_document.aset(content)
+ self.ydoc.apply_update(source_ydoc.get_update())
+
def _emit(self, level: LogLevel, action: str | None = None, msg: str |
None = None) -> None:
data = {"level": level.value, "room": self._room_id, "path":
self._file.path}
if action:
@@ -223,7 +245,8 @@
return
async with self._update_lock:
- self._document.source = model["content"]
+ if await self._document.aget() != model["content"]:
+ await self._document.aset(model["content"])
self._document.dirty = False
def _on_filepath_change(self) -> None:
@@ -319,7 +342,7 @@
{
"format": self._file_format,
"type": self._file_type,
- "content": self._document.source,
+ "content": await self._document.aget(),
}
)
if saved_model:
@@ -343,7 +366,8 @@
return None
async with self._update_lock:
- self._document.source = model["content"]
+ if await self._document.aget() != model["content"]:
+ await self._document.aset(model["content"])
self._document.dirty = False
self._emit(LogLevel.INFO, "overwrite", "Out-of-band changes while
saving.")
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/jupyter_server_ydoc-2.2.1/jupyter_server_ydoc/stores.py
new/jupyter_server_ydoc-2.4.0/jupyter_server_ydoc/stores.py
--- old/jupyter_server_ydoc-2.2.1/jupyter_server_ydoc/stores.py 2020-02-02
01:00:00.000000000 +0100
+++ new/jupyter_server_ydoc-2.4.0/jupyter_server_ydoc/stores.py 2020-02-02
01:00:00.000000000 +0100
@@ -38,6 +38,7 @@
None,
allow_none=True,
config=True,
- help="""The document time-to-live in seconds. Deprecated in favor of
'squash_after_inactivity_of'.
+ help="""The document time-to-live in seconds.
+ Deprecated in favor of 'squash_after_inactivity_of'.
Defaults to None (document history is never cleared).""",
)
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/jupyter_server_ydoc-2.2.1/jupyter_server_ydoc/test_utils.py
new/jupyter_server_ydoc-2.4.0/jupyter_server_ydoc/test_utils.py
--- old/jupyter_server_ydoc-2.2.1/jupyter_server_ydoc/test_utils.py
2020-02-02 01:00:00.000000000 +0100
+++ new/jupyter_server_ydoc-2.4.0/jupyter_server_ydoc/test_utils.py
2020-02-02 01:00:00.000000000 +0100
@@ -5,9 +5,9 @@
from datetime import datetime
from typing import Any
-from tornado.web import HTTPError
from jupyter_server import _tz as tz
+from tornado.web import HTTPError
class FakeFileIDManager:
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/jupyter_server_ydoc-2.2.1/jupyter_server_ydoc/utils.py
new/jupyter_server_ydoc-2.4.0/jupyter_server_ydoc/utils.py
--- old/jupyter_server_ydoc-2.2.1/jupyter_server_ydoc/utils.py 2020-02-02
01:00:00.000000000 +0100
+++ new/jupyter_server_ydoc-2.4.0/jupyter_server_ydoc/utils.py 2020-02-02
01:00:00.000000000 +0100
@@ -1,9 +1,14 @@
# Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License.
-
+import asyncio
+import json
+import os
+import uuid
+from datetime import datetime, timezone
from enum import Enum, IntEnum
from pathlib import Path
-from typing import Tuple
+
+from ._version import __version__ # noqa
EVENTS_FOLDER_PATH = Path(__file__).parent / "events"
JUPYTER_COLLABORATION_EVENTS_URI =
"https://schema.jupyter.org/jupyter_collaboration/session/v1"
@@ -14,6 +19,8 @@
JUPYTER_COLLABORATION_FORK_EVENTS_URI =
"https://schema.jupyter.org/jupyter_collaboration/fork/v1"
AWARENESS_EVENTS_SCHEMA_PATH = EVENTS_FOLDER_PATH / "awareness.yaml"
FORK_EVENTS_SCHEMA_PATH = EVENTS_FOLDER_PATH / "fork.yaml"
+SERVER_SESSION = str(uuid.uuid4())
+YDOC_SERVER_VERSION = __version__
class MessageType(IntEnum):
@@ -43,7 +50,7 @@
pass
-def decode_file_path(path: str) -> Tuple[str, str, str]:
+def decode_file_path(path: str) -> tuple[str, str, str]:
"""
Decodes a file path. The file path is composed by the format,
content type, and path or file id separated by ':'.
@@ -52,7 +59,7 @@
path (str): File path.
Returns:
- components (Tuple[str, str, str]): A tuple with the format,
+ components (tuple[str, str, str]): A tuple with the format,
content type, and path or file id.
"""
format, file_type, file_id = path.split(":", 2)
@@ -78,3 +85,106 @@
def room_id_from_encoded_path(encoded_path: str) -> str:
"""Transforms the encoded path into a stable room identifier."""
return encoded_path.split("/")[-1]
+
+
+def _get_jupyter_session_store(root_dir: str) -> Path:
+ """Return path to the session store file in .jupyter folder."""
+ try:
+ expanded = Path(root_dir).expanduser()
+ resolved = expanded.resolve()
+ jupyter_dir = resolved / ".jupyter"
+ jupyter_dir.mkdir(parents=True, exist_ok=True)
+ return jupyter_dir / "collaboration_sessions.json"
+ except OSError:
+ # In case if the server root dir is read-only
+ return Path(os.devnull)
+
+
+def _load_previous_sessions(root_dir: str) -> dict:
+ """Load previous session records from .jupyter folder."""
+ store_path = _get_jupyter_session_store(root_dir)
+ if store_path.exists():
+ try:
+ sessions = json.loads(store_path.read_text())
+ # Ensure the loaded JSON is a dict mapping session IDs to dicts.
+ if not isinstance(sessions, dict):
+ return {}
+ clean_sessions = {}
+ for key, value in sessions.items():
+ if isinstance(value, dict):
+ clean_sessions[str(key)] = value
+ return clean_sessions
+ except (OSError, json.JSONDecodeError, UnicodeDecodeError):
+ return {}
+ return {}
+
+
+async def save_current_session(
+ root_dir: str,
+ session_id: str,
+ version: str,
+ lock: asyncio.Lock,
+ document_version: str | None = None,
+) -> None:
+ """Persist the current session ID, server version, and optionally
+ document version to .jupyter folder."""
+ async with lock:
+ store_path = _get_jupyter_session_store(root_dir)
+ sessions = _load_previous_sessions(root_dir)
+
+ sessions[session_id] = {
+ "version": version,
+ "created_at": datetime.now(timezone.utc).isoformat(),
+ }
+ if document_version is not None:
+ sessions[session_id]["document_version"] = document_version
+
+ # Keep only the last 10 sessions to avoid unbounded growth
+ if len(sessions) > 10:
+ oldest_key = sorted(sessions, key=lambda k:
sessions[k].get("created_at", ""))[0]
+ del sessions[oldest_key]
+ try:
+ store_path.write_text(json.dumps(sessions, indent=2))
+ except OSError:
+ pass
+
+
+def check_session_compatibility(
+ root_dir: str,
+ client_session_id: str,
+ current_version: str,
+ current_document_version: str | None = None,
+) -> tuple[bool, str]:
+ """
+ Determine whether a client carrying an old session ID can reconnect or not.
+
+ Returns:
+ (cannot_reconnect: bool, reason: str)
+ """
+ if client_session_id == SERVER_SESSION:
+ return False, ""
+
+ previous_sessions = _load_previous_sessions(root_dir)
+
+ # Session ID not in our records at all → unknown origin, reject
+ if client_session_id not in previous_sessions:
+ return True, "unknown_session"
+
+ previous = previous_sessions[client_session_id]
+ previous_version = previous.get("version", "")
+
+ # Collaboration package version changed → reject
+ if previous_version != current_version:
+ return True, "version_mismatch"
+
+ # Check document version if both old and new sessions have it
+ if current_document_version is not None:
+ previous_document_version = previous.get("document_version")
+ if (
+ previous_document_version is not None
+ and previous_document_version != current_document_version
+ ):
+ return True, "version_mismatch"
+
+ # Same directory + same versions → safe to reconnect
+ return False, ""
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/jupyter_server_ydoc-2.2.1/jupyter_server_ydoc/websocketserver.py
new/jupyter_server_ydoc-2.4.0/jupyter_server_ydoc/websocketserver.py
--- old/jupyter_server_ydoc-2.2.1/jupyter_server_ydoc/websocketserver.py
2020-02-02 01:00:00.000000000 +0100
+++ new/jupyter_server_ydoc-2.4.0/jupyter_server_ydoc/websocketserver.py
2020-02-02 01:00:00.000000000 +0100
@@ -4,8 +4,9 @@
from __future__ import annotations
import asyncio
+from collections.abc import Callable
from logging import Logger
-from typing import Any, Callable
+from typing import Any
from pycrdt import Channel
from pycrdt.store import BaseYStore
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/jupyter_server_ydoc-2.2.1/pyproject.toml
new/jupyter_server_ydoc-2.4.0/pyproject.toml
--- old/jupyter_server_ydoc-2.2.1/pyproject.toml 2020-02-02
01:00:00.000000000 +0100
+++ new/jupyter_server_ydoc-2.4.0/pyproject.toml 2020-02-02
01:00:00.000000000 +0100
@@ -9,7 +9,7 @@
name = "jupyter-server-ydoc"
readme = "README.md"
license = { file = "LICENSE" }
-requires-python = ">=3.8"
+requires-python = ">=3.10"
description = "jupyter-server extension integrating collaborative shared
models."
classifiers = [
"Intended Audience :: Developers",
@@ -17,11 +17,11 @@
"Intended Audience :: Science/Research",
"License :: OSI Approved :: BSD License",
"Programming Language :: Python",
- "Programming Language :: Python :: 3.8",
- "Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
+ "Programming Language :: Python :: 3.13",
+ "Programming Language :: Python :: 3.14",
"Framework :: Jupyter"
]
authors = [
@@ -29,7 +29,7 @@
]
dependencies = [
"jupyter_server>=2.15.0,<3.0.0",
- "jupyter_ydoc>=3.0.3,<4.0.0",
+ "jupyter_ydoc>=3.4.0,<4.0.0",
"pycrdt",
"pycrdt-websocket>=0.16.0,<0.17.0",
"jupyter_events>=0.11.0",
@@ -54,8 +54,7 @@
"pytest>=7.0",
"pytest-cov",
"anyio",
- "httpx-ws >=0.5.2",
- "importlib_metadata >=4.8.3; python_version<'3.10'",
+ "httpx-ws >=0.9.0",
]
[tool.hatch.version]
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/jupyter_server_ydoc-2.2.1/tests/test_app.py
new/jupyter_server_ydoc-2.4.0/tests/test_app.py
--- old/jupyter_server_ydoc-2.2.1/tests/test_app.py 2020-02-02
01:00:00.000000000 +0100
+++ new/jupyter_server_ydoc-2.4.0/tests/test_app.py 2020-02-02
01:00:00.000000000 +0100
@@ -104,6 +104,24 @@
await collaboration.stop_extension()
+async def test_get_document_create_room(rtc_create_file, jp_serverapp):
+ path, content = await rtc_create_file("test.txt", "test", index=True)
+ collaboration = jp_serverapp.web_app.settings["jupyter_server_ydoc"]
+ # Document doesn't exist initially
+ document_before = await collaboration.get_document(
+ path=path, content_type="file", file_format="text", create=False
+ )
+ assert document_before is None
+
+ document_after = await collaboration.get_document(
+ path=path, content_type="file", file_format="text", create=True
+ )
+ # Verify document was created and has correct content
+ assert document_after is not None
+ assert document_after.get() == content == "test"
+ await collaboration.stop_extension()
+
+
@pytest.mark.parametrize("copy", [True, False])
async def test_get_document_notebook(rtc_create_notebook, jp_serverapp, copy):
nb = nbformat.v4.new_notebook(
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/jupyter_server_ydoc-2.2.1/tests/test_documents.py
new/jupyter_server_ydoc-2.4.0/tests/test_documents.py
--- old/jupyter_server_ydoc-2.2.1/tests/test_documents.py 2020-02-02
01:00:00.000000000 +0100
+++ new/jupyter_server_ydoc-2.4.0/tests/test_documents.py 2020-02-02
01:00:00.000000000 +0100
@@ -1,16 +1,16 @@
# Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License.
-import sys
+from copy import deepcopy
+from importlib.metadata import entry_points
from time import time
-if sys.version_info < (3, 10):
- from importlib_metadata import entry_points
-else:
- from importlib.metadata import entry_points
-
import pytest
from anyio import create_task_group, sleep
+from jupyter_server_ydoc.loaders import FileLoader
+from jupyter_server_ydoc.rooms import DocumentRoom
+from jupyter_server_ydoc.test_utils import FakeContentsManager,
FakeEventLogger, FakeFileIDManager
+from jupyter_ydoc import YNotebook
from pycrdt import Provider
from pycrdt.websocket.websocket import HttpxWebsocket
@@ -99,3 +99,73 @@
assert dt < 1
await cleanup(jp_serverapp)
+
+
+def _notebook_model() -> dict:
+ return {
+ "nbformat": 4,
+ "nbformat_minor": 5,
+ "metadata": {},
+ "cells": [
+ {
+ "cell_type": "code",
+ "id": "cell-1",
+ "metadata": {},
+ "source": "",
+ "outputs": [],
+ "execution_count": None,
+ }
+ ],
+ }
+
+
+async def _create_notebook_room(notebook: dict, room_id: str) ->
tuple[DocumentRoom, FileLoader]:
+ file_id = f"file-{room_id}"
+ loader = FileLoader(
+ file_id,
+ FakeFileIDManager({file_id: "test.ipynb"}),
+ FakeContentsManager({"content": deepcopy(notebook), "writable": True}),
+ )
+ room = DocumentRoom(
+ room_id,
+ "json",
+ "notebook",
+ loader,
+ FakeEventLogger(),
+ None,
+ None,
+ None,
+ )
+ await room.initialize()
+ return room, loader
+
+
+def _sync_documents(client_doc: YNotebook, room: DocumentRoom) -> dict:
+ room.ydoc.apply_update(client_doc.ydoc.get_update())
+ client_doc.ydoc.apply_update(room.ydoc.get_update())
+ return client_doc.get(deduplicate=False)
+
+
+async def
test_notebook_reconnect_with_divergent_history_does_not_duplicate_initial_cell():
+ notebook = _notebook_model()
+ room, loader = await _create_notebook_room(notebook,
"divergent-history-before")
+ client_doc = YNotebook()
+
+ try:
+ # Initial connection: client receives the original server-side history.
+ client_doc.ydoc.apply_update(room.ydoc.get_update())
+ finally:
+ # Simulate the server losing in-memory state while the client keeps
its local YDoc.
+ await room.stop()
+ await loader.clean()
+
+ recreated_room, recreated_loader = await _create_notebook_room(
+ notebook, "divergent-history-after"
+ )
+ try:
+ merged = _sync_documents(client_doc, recreated_room)
+ finally:
+ await recreated_room.stop()
+ await recreated_loader.clean()
+
+ assert len(merged["cells"]) == 1
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/jupyter_server_ydoc-2.2.1/tests/test_handlers.py
new/jupyter_server_ydoc-2.4.0/tests/test_handlers.py
--- old/jupyter_server_ydoc-2.2.1/tests/test_handlers.py 2020-02-02
01:00:00.000000000 +0100
+++ new/jupyter_server_ydoc-2.4.0/tests/test_handlers.py 2020-02-02
01:00:00.000000000 +0100
@@ -4,14 +4,14 @@
from __future__ import annotations
import json
-import pytest
from asyncio import Event, sleep
from typing import Any
+import pytest
from dirty_equals import IsStr
from jupyter_events.logger import EventLogger
from jupyter_ydoc import YUnicode
-from pycrdt import Text, Provider
+from pycrdt import Provider, Text
from pycrdt.websocket.websocket import HttpxWebsocket
@@ -119,8 +119,8 @@
websocket, room_name = await rtc_connect_doc_client("text", "file", path)
async with websocket as ws, Provider(doc.ydoc, HttpxWebsocket(ws,
room_name)):
await event.wait()
- await sleep(0.1)
+ await sleep(0.1)
fim = jp_serverapp.web_app.settings["file_id_manager"]
assert doc.source == content
@@ -258,7 +258,18 @@
assert listener_was_called is True
assert len(collected_data) == 4
# no two collaboration events are emitted.
- # [{'level': 'WARNING', 'msg': 'There is another collaborative session
accessing the same file.\nThe synchronization bet...ou might lose some of your
changes.', 'path': 'test2.txt', 'room':
'text2:file2:51b7e24f-f534-47fb-882f-5cc45ba867fe'}]
+ # [
+ # {
+ # "level": "WARNING",
+ # "msg": (
+ # "There is another collaborative session accessing the same
file.\n"
+ # "The synchronization between sessions may fail and you might
lose "
+ # "some of your changes."
+ # ),
+ # "path": "test2.txt",
+ # "room": "text2:file2:51b7e24f-f534-47fb-882f-5cc45ba867fe",
+ # }
+ # ]
assert collected_data[0]["path"] == "test2.txt"
assert collected_data[0]["room"] == "text2:file2:" +
fim.get_id("test2.txt")
assert collected_data[0]["action"] == "clean"
@@ -381,8 +392,9 @@
fork_ydoc.observe(_on_fork_change)
fork_text = fork_ydoc.ydoc.get("source", type=Text)
- async with await rtc_connect_fork_client(fork_roomid1) as ws, Provider(
- fork_ydoc.ydoc, HttpxWebsocket(ws, fork_roomid1)
+ async with (
+ await rtc_connect_fork_client(fork_roomid1) as ws,
+ Provider(fork_ydoc.ydoc, HttpxWebsocket(ws, fork_roomid1)),
):
await fork_connect_event.wait()
root_text = root_ydoc.ydoc.get("source", type=Text)
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/jupyter_server_ydoc-2.2.1/tests/test_rooms.py
new/jupyter_server_ydoc-2.4.0/tests/test_rooms.py
--- old/jupyter_server_ydoc-2.2.1/tests/test_rooms.py 2020-02-02
01:00:00.000000000 +0100
+++ new/jupyter_server_ydoc-2.4.0/tests/test_rooms.py 2020-02-02
01:00:00.000000000 +0100
@@ -4,7 +4,9 @@
from __future__ import annotations
import asyncio
+from unittest.mock import AsyncMock, patch
+from jupyter_server_ydoc.utils import OutOfBandChanges
from jupyter_ydoc import YUnicode
@@ -243,3 +245,83 @@
# await asyncio.sleep(0.15)
# assert room._document.path == new_path
+
+
+async def test_on_outofband_change_skips_aset_when_content_unchanged(
+ rtc_create_mock_document_room,
+):
+ """aset should not be called when out-of-band content matches the
document."""
+ content = "test"
+ _, _, room = rtc_create_mock_document_room("test-id", "test.txt", content)
+ await room.initialize()
+
+ with patch.object(room._document, "aset", new_callable=AsyncMock) as
mock_aset:
+ await room._on_outofband_change()
+ mock_aset.assert_not_called()
+
+ assert not room._document.dirty
+
+
+async def test_on_outofband_change_calls_aset_when_content_changed(
+ rtc_create_mock_document_room,
+):
+ """aset should be called when out-of-band content differs from the
document."""
+ content = "test"
+ cm, _, room = rtc_create_mock_document_room("test-id", "test.txt", content)
+ await room.initialize()
+
+ # Simulate the file changing on disk
+ cm.model["content"] = "new content from disk"
+
+ with patch.object(room._document, "aset", new_callable=AsyncMock) as
mock_aset:
+ await room._on_outofband_change()
+ mock_aset.assert_called_once_with("new content from disk")
+
+ assert not room._document.dirty
+
+
+async def test_save_oob_skips_aset_when_content_unchanged(
+ rtc_create_mock_document_room,
+):
+ """During save with OutOfBandChanges, aset should be skipped if content
matches."""
+ content = "test"
+ cm, loader, room = rtc_create_mock_document_room(
+ "test-id", "test.txt", content, save_delay=0.01
+ )
+ await room.initialize()
+
+ with (
+ patch.object(
+ loader, "maybe_save_content", new_callable=AsyncMock,
side_effect=OutOfBandChanges
+ ),
+ patch.object(room._document, "aset", new_callable=AsyncMock) as
mock_aset,
+ ):
+ await room._maybe_save_document(None, save_now=True)
+ mock_aset.assert_not_called()
+
+ assert not room._document.dirty
+
+
+async def test_save_oob_calls_aset_when_content_changed(
+ rtc_create_mock_document_room,
+):
+ """During save with OutOfBandChanges, aset should be called if content
differs."""
+ content = "test"
+ cm, loader, room = rtc_create_mock_document_room(
+ "test-id", "test.txt", content, save_delay=0.01
+ )
+ await room.initialize()
+
+ # Simulate file changing on disk after save attempt
+ cm.model["content"] = "changed on disk"
+
+ with (
+ patch.object(
+ loader, "maybe_save_content", new_callable=AsyncMock,
side_effect=OutOfBandChanges
+ ),
+ patch.object(room._document, "aset", new_callable=AsyncMock) as
mock_aset,
+ ):
+ await room._maybe_save_document(None, save_now=True)
+ mock_aset.assert_called_once_with("changed on disk")
+
+ assert not room._document.dirty
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/jupyter_server_ydoc-2.2.1/tests/test_session_store.py
new/jupyter_server_ydoc-2.4.0/tests/test_session_store.py
--- old/jupyter_server_ydoc-2.2.1/tests/test_session_store.py 1970-01-01
01:00:00.000000000 +0100
+++ new/jupyter_server_ydoc-2.4.0/tests/test_session_store.py 2020-02-02
01:00:00.000000000 +0100
@@ -0,0 +1,104 @@
+# Copyright (c) Jupyter Development Team.
+# Distributed under the terms of the Modified BSD License.
+
+import asyncio
+
+from jupyter_server_ydoc.utils import (
+ YDOC_SERVER_VERSION,
+ check_session_compatibility,
+ save_current_session,
+)
+
+
+async def test_allows_reconnect_same_dir_same_version(tmp_path):
+ await save_current_session(str(tmp_path), "old-session",
YDOC_SERVER_VERSION, asyncio.Lock())
+ cannot_reconnect, reason = check_session_compatibility(
+ str(tmp_path), "old-session", YDOC_SERVER_VERSION
+ )
+ assert cannot_reconnect is False
+ assert reason == ""
+
+
+async def test_rejects_reconnect_version_mismatch(tmp_path):
+ await save_current_session(str(tmp_path), "old-session", "0.0.1",
asyncio.Lock())
+ cannot_reconnect, reason = check_session_compatibility(
+ str(tmp_path), "old-session", YDOC_SERVER_VERSION
+ )
+ assert cannot_reconnect is True
+ assert reason == "version_mismatch"
+
+
+async def test_rejects_reconnect_different_directory(tmp_path):
+ other_dir = tmp_path / "other"
+ other_dir.mkdir()
+ await save_current_session(str(other_dir), "old-session",
YDOC_SERVER_VERSION, asyncio.Lock())
+ cannot_reconnect, reason = check_session_compatibility(
+ str(tmp_path), "old-session", YDOC_SERVER_VERSION
+ )
+ assert cannot_reconnect is True
+ # Since it cannot find .jupyter folder or the session ID if folder is
present
+ assert reason == "unknown_session"
+
+
+def test_rejects_unknown_session(tmp_path):
+ cannot_reconnect, reason = check_session_compatibility(
+ str(tmp_path), "never-seen-session", YDOC_SERVER_VERSION
+ )
+ assert cannot_reconnect is True
+ assert reason == "unknown_session"
+
+
+async def test_allows_reconnect_with_document_version(tmp_path):
+ """Test that document version is saved and validated correctly."""
+ doc_version = "2.1.0"
+ await save_current_session(
+ str(tmp_path),
+ "doc-session",
+ YDOC_SERVER_VERSION,
+ asyncio.Lock(),
+ document_version=doc_version,
+ )
+ cannot_reconnect, reason = check_session_compatibility(
+ str(tmp_path),
+ "doc-session",
+ YDOC_SERVER_VERSION,
+ current_document_version=doc_version,
+ )
+ assert cannot_reconnect is False
+ assert reason == ""
+
+
+async def test_rejects_reconnect_document_version_mismatch(tmp_path):
+ """Test that document version mismatch is detected."""
+ old_doc_version = "2.0.0"
+ new_doc_version = "2.1.0"
+ await save_current_session(
+ str(tmp_path),
+ "doc-session",
+ YDOC_SERVER_VERSION,
+ asyncio.Lock(),
+ document_version=old_doc_version,
+ )
+ cannot_reconnect, reason = check_session_compatibility(
+ str(tmp_path),
+ "doc-session",
+ YDOC_SERVER_VERSION,
+ current_document_version=new_doc_version,
+ )
+ assert cannot_reconnect is True
+ assert reason == "version_mismatch"
+
+
+async def
test_allows_reconnect_without_document_version_in_old_session(tmp_path):
+ """Test backward compatibility: old sessions without document version are
still allowed."""
+ # Old session saved without document_version
+ await save_current_session(str(tmp_path), "old-session",
YDOC_SERVER_VERSION, asyncio.Lock())
+ # New client has document version but old session doesn't → should allow
+ cannot_reconnect, reason = check_session_compatibility(
+ str(tmp_path),
+ "old-session",
+ YDOC_SERVER_VERSION,
+ current_document_version="2.1.0",
+ )
+ assert cannot_reconnect is False
+ assert reason == ""