From ba2271d2b7a35f8d6a9001ea6770a58336de5dce Mon Sep 17 00:00:00 2001 From: David Brochart Date: Tue, 18 Nov 2025 16:11:23 +0100 Subject: [PATCH 1/6] Add more tests --- .github/workflows/test.yml | 9 ++++++++- README.md | 2 -- jupyter_ydoc/__init__.py | 9 +-------- jupyter_ydoc/yblob.py | 3 +++ jupyter_ydoc/yunicode.py | 1 + pyproject.toml | 6 +++--- tests/conftest.py | 2 +- tests/test_pycrdt_yjs.py | 17 ++++++----------- tests/test_yblob.py | 29 +++++++++++++++++++++++++++++ tests/test_ynotebook.py | 5 ++++- tests/test_yunicode.py | 7 +++++-- tests/utils.py | 4 ++-- tests/yjs_client_1.js | 4 ++-- 13 files changed, 65 insertions(+), 33 deletions(-) create mode 100644 tests/test_yblob.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 169d1cd..a074439 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -35,6 +35,7 @@ jobs: strategy: matrix: os: [ubuntu-latest, windows-latest, macos-latest] + python-version: [ '3.10', '3.11', '3.12', '3.13', '3.14' ] defaults: run: shell: bash -l {0} @@ -48,7 +49,7 @@ jobs: - name: Install dependencies run: | micromamba install pip nodejs=18 - pip install ".[test]" + pip install . --group test - name: Build JavaScript assets working-directory: javascript run: | @@ -76,5 +77,11 @@ jobs: yarn build:test yarn test:cov - name: Run Python tests + if: ${{ !((matrix.python-version == '3.14') && (matrix.os == 'ubuntu-latest')) }} run: | python -m pytest -v + - name: Run Python code coverage + if: ${{ (matrix.python-version == '3.14') && (matrix.os == 'ubuntu-latest') }} + run: | + coverage run -m pytest -v + coverage report --show-missing diff --git a/README.md b/README.md index 720bcbf..73da42e 100644 --- a/README.md +++ b/README.md @@ -30,8 +30,6 @@ Which is just a shortcut to: ```py from importlib.metadata import entry_points -# for Python < 3.10, install importlib_metadata and do: -# from importlib_metadata import entry_points ydocs = {ep.name: ep.load() for ep in entry_points(group="jupyter_ydoc")} ``` diff --git a/jupyter_ydoc/__init__.py b/jupyter_ydoc/__init__.py index 9450058..c01f2f4 100644 --- a/jupyter_ydoc/__init__.py +++ b/jupyter_ydoc/__init__.py @@ -1,19 +1,12 @@ # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. -import sys - from ._version import __version__ as __version__ from .yblob import YBlob as YBlob from .yfile import YFile as YFile from .ynotebook import YNotebook as YNotebook from .yunicode import YUnicode as YUnicode -# See compatibility note on `group` keyword in -# https://docs.python.org/3/library/importlib.metadata.html#entry-points -if sys.version_info < (3, 10): - from importlib_metadata import entry_points -else: - from importlib.metadata import entry_points +from importlib.metadata import entry_points ydocs = {ep.name: ep.load() for ep in entry_points(group="jupyter_ydoc")} diff --git a/jupyter_ydoc/yblob.py b/jupyter_ydoc/yblob.py index 6061509..a917d12 100644 --- a/jupyter_ydoc/yblob.py +++ b/jupyter_ydoc/yblob.py @@ -64,6 +64,9 @@ def set(self, value: bytes) -> None: :param value: The content of the document. :type value: bytes """ + if self.get() == value: + return + self._ysource["bytes"] = value def observe(self, callback: Callable[[str, Any], None]) -> None: diff --git a/jupyter_ydoc/yunicode.py b/jupyter_ydoc/yunicode.py index d6f3914..418b49f 100644 --- a/jupyter_ydoc/yunicode.py +++ b/jupyter_ydoc/yunicode.py @@ -67,6 +67,7 @@ def set(self, value: str) -> None: # no-op if the values are already the same, # to avoid side-effects such as cursor jumping to the top return + with self._ydoc.transaction(): # clear document self._ysource.clear() diff --git a/pyproject.toml b/pyproject.toml index 04bab15..a1c0387 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,10 +9,9 @@ build-backend = "hatchling.build" name = "jupyter-ydoc" dynamic = ["version"] description = "Document structures for collaborative editing using Ypy" -requires-python = ">=3.8" +requires-python = ">=3.10" keywords = ["jupyter", "pycrdt", "yjs"] dependencies = [ - "importlib_metadata >=3.6; python_version<'3.10'", "pycrdt >=0.10.1,<0.13.0", ] @@ -20,7 +19,7 @@ dependencies = [ name = "Jupyter Development Team" email = "jupyter@googlegroups.com" -[project.optional-dependencies] +[dependency-groups] dev = [ "click", "jupyter_releaser", @@ -33,6 +32,7 @@ test = [ "httpx-ws >=0.5.2", "hypercorn >=0.16.0", "pycrdt-websocket >=0.16.0,<0.17.0", + "coverage >=7.12.0,<8", ] docs = [ "sphinx", diff --git a/tests/conftest.py b/tests/conftest.py index 6858bdc..ad584cd 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -63,5 +63,5 @@ def yjs_client(request): p.terminate() try: p.wait(timeout=10) - except Exception: + except Exception: # pragma: nocover p.kill() diff --git a/tests/test_pycrdt_yjs.py b/tests/test_pycrdt_yjs.py index 874bf6c..b33ae1c 100644 --- a/tests/test_pycrdt_yjs.py +++ b/tests/test_pycrdt_yjs.py @@ -5,7 +5,7 @@ from pathlib import Path import pytest -from anyio import Event, create_task_group, move_on_after +from anyio import Event, create_task_group, fail_after, move_on_after, sleep from httpx_ws import aconnect_ws from pycrdt import Doc, Map, Provider from utils import Websocket @@ -90,17 +90,12 @@ async def test_ypy_yjs_1(yws_server, yjs_client): ): output_text = ynotebook.ycells[0]["outputs"][0]["text"] assert output_text.to_py() == "Hello," - event = Event() - def callback(_event): - event.set() - - output_text.observe(callback) - - with move_on_after(10): - await event.wait() - - assert output_text.to_py() == "Hello,", " World!" + with fail_after(10): + while True: + await sleep(0.1) + if output_text.to_py() == "Hello, World!": + break def test_plotly_renderer(): diff --git a/tests/test_yblob.py b/tests/test_yblob.py new file mode 100644 index 0000000..225c68c --- /dev/null +++ b/tests/test_yblob.py @@ -0,0 +1,29 @@ +# Copyright (c) Jupyter Development Team. +# Distributed under the terms of the Modified BSD License. + +from jupyter_ydoc import YBlob + + +def test_set_no_op_if_unchanged(): + blob = YBlob() + + assert blob.version == "2.0.0" + + content0 = b"012" + blob.set(content0) + + changes = [] + + def record_changes(topic, event): + changes.append((topic, event)) # pragma: nocover + + blob.observe(record_changes) + + content1 = blob.get() + assert content0 == content1 + + # Call set with identical content + blob.set(content0) + + # No changes should be observed at all + assert changes == [] diff --git a/tests/test_ynotebook.py b/tests/test_ynotebook.py index 5ff293c..f7619a1 100644 --- a/tests/test_ynotebook.py +++ b/tests/test_ynotebook.py @@ -26,12 +26,15 @@ def __eq__(self, other): def test_set_preserves_cells_when_unchanged(): nb = YNotebook() + + assert nb.version == "2.0.0" + nb.set({"cells": [make_code_cell("print('a')\n"), make_code_cell("print('b')\n")]}) changes = [] def record_changes(topic, event): - changes.append((topic, event)) + changes.append((topic, event)) # pragma: nocover nb.observe(record_changes) diff --git a/tests/test_yunicode.py b/tests/test_yunicode.py index 096436e..ef19131 100644 --- a/tests/test_yunicode.py +++ b/tests/test_yunicode.py @@ -4,14 +4,17 @@ from jupyter_ydoc import YUnicode -def test_set_no_op_if_unchaged(): +def test_set_no_op_if_unchanged(): text = YUnicode() + + assert text.version == "1.0.0" + text.set("test content") changes = [] def record_changes(topic, event): - changes.append((topic, event)) + changes.append((topic, event)) # pragma: nocover text.observe(record_changes) diff --git a/tests/utils.py b/tests/utils.py index 6cac6c0..5797997 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -21,7 +21,7 @@ async def __anext__(self) -> bytes: try: message = await self.recv() except Exception: - raise StopAsyncIteration() + raise StopAsyncIteration() # pragma: nocover return message async def send(self, message: bytes): @@ -37,7 +37,7 @@ async def ensure_server_running(host: str, port: int) -> None: while True: try: await connect_tcp(host, port) - except OSError: + except OSError: # pragma: nocover pass else: break diff --git a/tests/yjs_client_1.js b/tests/yjs_client_1.js index d3ded09..93cc18d 100644 --- a/tests/yjs_client_1.js +++ b/tests/yjs_client_1.js @@ -25,8 +25,8 @@ notebook.changed.connect(() => { if (cell) { const youtput = cell.youtputs.get(0) const text = youtput.get('text') - if (text.length === 1) { - text.insert(1, [' World!']) + if (text.toString() === 'Hello,') { + text.insert(6, ' World!') } } }) From 2a096122259010761baf154c8e6d72eb106d7bc2 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 18 Nov 2025 16:02:06 +0000 Subject: [PATCH 2/6] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- jupyter_ydoc/__init__.py | 4 ++-- jupyter_ydoc/utils.py | 9 +++------ jupyter_ydoc/ybasedoc.py | 13 +++++++------ jupyter_ydoc/yblob.py | 5 +++-- jupyter_ydoc/ynotebook.py | 21 +++++++++++---------- jupyter_ydoc/yunicode.py | 5 +++-- tests/test_pycrdt_yjs.py | 10 ++++++---- 7 files changed, 35 insertions(+), 32 deletions(-) diff --git a/jupyter_ydoc/__init__.py b/jupyter_ydoc/__init__.py index c01f2f4..3dbb974 100644 --- a/jupyter_ydoc/__init__.py +++ b/jupyter_ydoc/__init__.py @@ -1,12 +1,12 @@ # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. +from importlib.metadata import entry_points + from ._version import __version__ as __version__ from .yblob import YBlob as YBlob from .yfile import YFile as YFile from .ynotebook import YNotebook as YNotebook from .yunicode import YUnicode as YUnicode -from importlib.metadata import entry_points - ydocs = {ep.name: ep.load() for ep in entry_points(group="jupyter_ydoc")} diff --git a/jupyter_ydoc/utils.py b/jupyter_ydoc/utils.py index b725b58..b9a42e7 100644 --- a/jupyter_ydoc/utils.py +++ b/jupyter_ydoc/utils.py @@ -1,15 +1,12 @@ # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. -from typing import Dict, List, Type, Union -INT = Type[int] -FLOAT = Type[float] +INT = type[int] +FLOAT = type[float] -def cast_all( - o: Union[List, Dict], from_type: Union[INT, FLOAT], to_type: Union[FLOAT, INT] -) -> Union[List, Dict]: +def cast_all(o: list | dict, from_type: INT | FLOAT, to_type: FLOAT | INT) -> list | dict: if isinstance(o, list): for i, v in enumerate(o): if type(v) is from_type: diff --git a/jupyter_ydoc/ybasedoc.py b/jupyter_ydoc/ybasedoc.py index a958898..3859c96 100644 --- a/jupyter_ydoc/ybasedoc.py +++ b/jupyter_ydoc/ybasedoc.py @@ -2,7 +2,8 @@ # Distributed under the terms of the Modified BSD License. from abc import ABC, abstractmethod -from typing import Any, Callable, Dict, Optional +from collections.abc import Callable +from typing import Any from pycrdt import Awareness, Doc, Map, Subscription, UndoManager @@ -17,10 +18,10 @@ class YBaseDoc(ABC): _ydoc: Doc _ystate: Map - _subscriptions: Dict[Any, Subscription] + _subscriptions: dict[Any, Subscription] _undo_manager: UndoManager - def __init__(self, ydoc: Optional[Doc] = None, awareness: Optional[Awareness] = None): + def __init__(self, ydoc: Doc | None = None, awareness: Awareness | None = None): """ Constructs a YBaseDoc. @@ -100,7 +101,7 @@ def source(self, value: Any): return self.set(value) @property - def dirty(self) -> Optional[bool]: + def dirty(self) -> bool | None: """ Returns whether the document is dirty. @@ -120,7 +121,7 @@ def dirty(self, value: bool) -> None: self._ystate["dirty"] = value @property - def hash(self) -> Optional[str]: + def hash(self) -> str | None: """ Returns the document hash as computed by contents manager. @@ -140,7 +141,7 @@ def hash(self, value: str) -> None: self._ystate["hash"] = value @property - def path(self) -> Optional[str]: + def path(self) -> str | None: """ Returns document's path. diff --git a/jupyter_ydoc/yblob.py b/jupyter_ydoc/yblob.py index a917d12..db6d38c 100644 --- a/jupyter_ydoc/yblob.py +++ b/jupyter_ydoc/yblob.py @@ -1,8 +1,9 @@ # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. +from collections.abc import Callable from functools import partial -from typing import Any, Callable, Optional +from typing import Any from pycrdt import Awareness, Doc, Map @@ -24,7 +25,7 @@ class YBlob(YBaseDoc): } """ - def __init__(self, ydoc: Optional[Doc] = None, awareness: Optional[Awareness] = None): + def __init__(self, ydoc: Doc | None = None, awareness: Awareness | None = None): """ Constructs a YBlob. diff --git a/jupyter_ydoc/ynotebook.py b/jupyter_ydoc/ynotebook.py index c117b44..9067740 100644 --- a/jupyter_ydoc/ynotebook.py +++ b/jupyter_ydoc/ynotebook.py @@ -2,8 +2,9 @@ # Distributed under the terms of the Modified BSD License. import copy +from collections.abc import Callable from functools import partial -from typing import Any, Callable, Dict, List, Optional +from typing import Any from uuid import uuid4 from pycrdt import Array, Awareness, Doc, Map, Text @@ -47,7 +48,7 @@ class YNotebook(YBaseDoc): } """ - def __init__(self, ydoc: Optional[Doc] = None, awareness: Optional[Awareness] = None): + def __init__(self, ydoc: Doc | None = None, awareness: Awareness | None = None): """ Constructs a YNotebook. @@ -92,7 +93,7 @@ def cell_number(self) -> int: """ return len(self._ycells) - def get_cell(self, index: int) -> Dict[str, Any]: + def get_cell(self, index: int) -> dict[str, Any]: """ Returns a cell. @@ -104,7 +105,7 @@ def get_cell(self, index: int) -> Dict[str, Any]: """ return self._cell_to_py(self._ycells[index]) - def _cell_to_py(self, ycell: Map) -> Dict[str, Any]: + def _cell_to_py(self, ycell: Map) -> dict[str, Any]: meta = self._ymeta.to_py() cell = ycell.to_py() cell.pop("execution_state", None) @@ -120,7 +121,7 @@ def _cell_to_py(self, ycell: Map) -> Dict[str, Any]: del cell["attachments"] return cell - def append_cell(self, value: Dict[str, Any]) -> None: + def append_cell(self, value: dict[str, Any]) -> None: """ Appends a cell. @@ -130,7 +131,7 @@ def append_cell(self, value: Dict[str, Any]) -> None: ycell = self.create_ycell(value) self._ycells.append(ycell) - def set_cell(self, index: int, value: Dict[str, Any]) -> None: + def set_cell(self, index: int, value: dict[str, Any]) -> None: """ Sets a cell into indicated position. @@ -143,7 +144,7 @@ def set_cell(self, index: int, value: Dict[str, Any]) -> None: ycell = self.create_ycell(value) self.set_ycell(index, ycell) - def create_ycell(self, value: Dict[str, Any]) -> Map: + def create_ycell(self, value: dict[str, Any]) -> Map: """ Creates YMap with the content of the cell. @@ -193,7 +194,7 @@ def set_ycell(self, index: int, ycell: Map) -> None: """ self._ycells[index] = ycell - def get(self) -> Dict: + def get(self) -> dict: """ Returns the content of the document. @@ -227,7 +228,7 @@ def get(self) -> Dict: nbformat_minor=int(meta.get("nbformat_minor", 0)), ) - def set(self, value: Dict) -> None: + def set(self, value: dict) -> None: """ Sets the content of the document. @@ -251,7 +252,7 @@ def set(self, value: Dict) -> None: old_ycells_by_id = {ycell["id"]: ycell for ycell in self._ycells} with self._ydoc.transaction(): - new_cell_list: List[dict] = [] + new_cell_list: list[dict] = [] retained_cells = set() # Determine cells to be retained diff --git a/jupyter_ydoc/yunicode.py b/jupyter_ydoc/yunicode.py index 418b49f..6a9dc6e 100644 --- a/jupyter_ydoc/yunicode.py +++ b/jupyter_ydoc/yunicode.py @@ -1,8 +1,9 @@ # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. +from collections.abc import Callable from functools import partial -from typing import Any, Callable, Optional +from typing import Any from pycrdt import Awareness, Doc, Text @@ -23,7 +24,7 @@ class YUnicode(YBaseDoc): } """ - def __init__(self, ydoc: Optional[Doc] = None, awareness: Optional[Awareness] = None): + def __init__(self, ydoc: Doc | None = None, awareness: Awareness | None = None): """ Constructs a YUnicode. diff --git a/tests/test_pycrdt_yjs.py b/tests/test_pycrdt_yjs.py index b33ae1c..26cceb9 100644 --- a/tests/test_pycrdt_yjs.py +++ b/tests/test_pycrdt_yjs.py @@ -65,8 +65,9 @@ async def test_ypy_yjs_0(yws_server, yjs_client): ydoc = Doc() ynotebook = YNotebook(ydoc) room_name = "my-roomname" - async with aconnect_ws(f"http://localhost:{port}/{room_name}") as websocket, Provider( - ydoc, Websocket(websocket, room_name) + async with ( + aconnect_ws(f"http://localhost:{port}/{room_name}") as websocket, + Provider(ydoc, Websocket(websocket, room_name)), ): nb = stringify_source(json.loads((files_dir / "nb0.ipynb").read_text())) ynotebook.source = nb @@ -85,8 +86,9 @@ async def test_ypy_yjs_1(yws_server, yjs_client): nb = stringify_source(json.loads((files_dir / "nb1.ipynb").read_text())) ynotebook.source = nb room_name = "my-roomname" - async with aconnect_ws(f"http://localhost:{port}/{room_name}") as websocket, Provider( - ydoc, Websocket(websocket, room_name) + async with ( + aconnect_ws(f"http://localhost:{port}/{room_name}") as websocket, + Provider(ydoc, Websocket(websocket, room_name)), ): output_text = ynotebook.ycells[0]["outputs"][0]["text"] assert output_text.to_py() == "Hello," From 5721231ac0f92f1619377db6e4fc2679f2cf62da Mon Sep 17 00:00:00 2001 From: David Brochart Date: Tue, 18 Nov 2025 17:10:08 +0100 Subject: [PATCH 3/6] Remove coverage pinning --- .github/workflows/test.yml | 10 +++++----- pyproject.toml | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a074439..1ccdcb1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -28,13 +28,13 @@ jobs: echo " pre-commit run --all-files --hook-stage=manual" test: - name: Run tests on ${{ matrix.os }} - runs-on: ${{ matrix.os }} + name: Run tests on ${{ matrix.os }} py${{ matrix.python-version }} + runs-on: ${{ matrix.os }}-latest timeout-minutes: 10 strategy: matrix: - os: [ubuntu-latest, windows-latest, macos-latest] + os: [ubuntu, windows, macos] python-version: [ '3.10', '3.11', '3.12', '3.13', '3.14' ] defaults: run: @@ -77,11 +77,11 @@ jobs: yarn build:test yarn test:cov - name: Run Python tests - if: ${{ !((matrix.python-version == '3.14') && (matrix.os == 'ubuntu-latest')) }} + if: ${{ !((matrix.python-version == '3.14') && (matrix.os == 'ubuntu')) }} run: | python -m pytest -v - name: Run Python code coverage - if: ${{ (matrix.python-version == '3.14') && (matrix.os == 'ubuntu-latest') }} + if: ${{ (matrix.python-version == '3.14') && (matrix.os == 'ubuntu') }} run: | coverage run -m pytest -v coverage report --show-missing diff --git a/pyproject.toml b/pyproject.toml index a1c0387..0e05736 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,7 @@ test = [ "httpx-ws >=0.5.2", "hypercorn >=0.16.0", "pycrdt-websocket >=0.16.0,<0.17.0", - "coverage >=7.12.0,<8", + "coverage", ] docs = [ "sphinx", From 23632a99e52a557c7d2442e50d41e9ac1feb8fa5 Mon Sep 17 00:00:00 2001 From: David Brochart Date: Tue, 18 Nov 2025 17:15:42 +0100 Subject: [PATCH 4/6] Use python 3.10 in check-release --- .github/workflows/check-release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/check-release.yml b/.github/workflows/check-release.yml index 2600b7d..b6dd998 100644 --- a/.github/workflows/check-release.yml +++ b/.github/workflows/check-release.yml @@ -15,7 +15,7 @@ jobs: fail-fast: false matrix: group: [check_release, link_check] - python-version: ["3.9"] + python-version: ["3.10"] node-version: ["14.x"] steps: - name: Checkout From a10f0d9d07c5de0beb276ef014d8ef3de24b6c5c Mon Sep 17 00:00:00 2001 From: David Brochart Date: Tue, 18 Nov 2025 17:21:35 +0100 Subject: [PATCH 5/6] In readthedocs.yml too --- readthedocs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/readthedocs.yml b/readthedocs.yml index ffb6560..89eb0e9 100644 --- a/readthedocs.yml +++ b/readthedocs.yml @@ -3,7 +3,7 @@ version: 2 build: os: ubuntu-20.04 tools: - python: "3.8" + python: "3.10" nodejs: "14" sphinx: From 759eec22fe001541fedf03493026d887070f0b6d Mon Sep 17 00:00:00 2001 From: David Brochart Date: Tue, 18 Nov 2025 17:29:48 +0100 Subject: [PATCH 6/6] Fix pip install in readthedocs --- readthedocs.yml | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/readthedocs.yml b/readthedocs.yml index 89eb0e9..9fd62e5 100644 --- a/readthedocs.yml +++ b/readthedocs.yml @@ -5,13 +5,10 @@ build: tools: python: "3.10" nodejs: "14" + jobs: + install: + - pip install --upgrade pip + - pip install --group 'docs' sphinx: configuration: docs/source/conf.py - -python: - install: - - method: pip - path: . - extra_requirements: - - docs