diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 84c155f..385af14 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -49,6 +49,7 @@ jobs: run: | micromamba install pip nodejs=18 pip install ".[test]" + pip install "pycrdt >=0.12.1" - name: Build JavaScript assets working-directory: javascript run: | diff --git a/jupyter_ydoc/__init__.py b/jupyter_ydoc/__init__.py index 9450058..66af9bf 100644 --- a/jupyter_ydoc/__init__.py +++ b/jupyter_ydoc/__init__.py @@ -5,9 +5,13 @@ from ._version import __version__ as __version__ from .yblob import YBlob as YBlob +from .yblob import YBlobDoc as YBlobDoc from .yfile import YFile as YFile +from .yfile import YFileDoc as YFileDoc from .ynotebook import YNotebook as YNotebook +from .ynotebook import YNotebookDoc as YNotebookDoc from .yunicode import YUnicode as YUnicode +from .yunicode import YUnicodeDoc as YUnicodeDoc # See compatibility note on `group` keyword in # https://docs.python.org/3/library/importlib.metadata.html#entry-points diff --git a/jupyter_ydoc/ybasedoc.py b/jupyter_ydoc/ybasedoc.py index a958898..18032ac 100644 --- a/jupyter_ydoc/ybasedoc.py +++ b/jupyter_ydoc/ybasedoc.py @@ -1,10 +1,22 @@ # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. +from __future__ import annotations + from abc import ABC, abstractmethod -from typing import Any, Callable, Dict, Optional +from typing import Any, Callable + +from pycrdt import Awareness, Doc, Subscription, TypedDoc, TypedMap, UndoManager + + +class YState(TypedMap): + dirty: bool + hash: str + path: str -from pycrdt import Awareness, Doc, Map, Subscription, UndoManager + +class YDoc(TypedDoc): + state: YState class YBaseDoc(ABC): @@ -15,12 +27,12 @@ class YBaseDoc(ABC): subscribe to changes in the document. """ - _ydoc: Doc - _ystate: Map - _subscriptions: Dict[Any, Subscription] + _ydoc: YDoc + _ystate: YState + _subscriptions: dict[Any, Subscription] _undo_manager: UndoManager - def __init__(self, ydoc: Optional[Doc] = None, awareness: Optional[Awareness] = None): + def __init__(self, ydoc: YDoc | Doc | None = None, awareness: Awareness | None = None): """ Constructs a YBaseDoc. @@ -30,15 +42,15 @@ def __init__(self, ydoc: Optional[Doc] = None, awareness: Optional[Awareness] = between clients. :type awareness: :class:`pycrdt.Awareness`, optional. """ - if ydoc is None: - self._ydoc = Doc() - else: + if isinstance(ydoc, YDoc): self._ydoc = ydoc + else: + self._ydoc = YDoc(ydoc) self.awareness = awareness - self._ystate = self._ydoc.get("state", type=Map) + self._ydoc.state = self._ystate = YState() self._subscriptions = {} - self._undo_manager = UndoManager(doc=self._ydoc, capture_timeout_millis=0) + self._undo_manager = UndoManager(doc=self._ydoc._, capture_timeout_millis=0) @property @abstractmethod @@ -60,22 +72,22 @@ def undo_manager(self) -> UndoManager: """ return self._undo_manager - def ystate(self) -> Map: + def ystate(self) -> YState: """ - A :class:`pycrdt.Map` containing the state of the document. + A :class:`YState` containing the state of the document. :return: The document's state. - :rtype: :class:`pycrdt.Map` + :rtype: :class:`YState` """ return self._ystate @property - def ydoc(self) -> Doc: + def ydoc(self) -> YDoc: """ - The underlying :class:`pycrdt.Doc` that contains the data. + The :class:`YDoc` that contains the data. :return: The document's ydoc. - :rtype: :class:`pycrdt.Doc` + :rtype: :class:`YDoc` """ return self._ydoc @@ -100,14 +112,17 @@ 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. :return: Whether the document is dirty. - :rtype: Optional[bool] + :rtype: bool | None """ - return self._ystate.get("dirty") + try: + return self._ystate.dirty + except KeyError: + return None @dirty.setter def dirty(self, value: bool) -> None: @@ -117,17 +132,20 @@ def dirty(self, value: bool) -> None: :param value: Whether the document is clean or dirty. :type value: bool """ - self._ystate["dirty"] = value + self._ystate.dirty = value @property - def hash(self) -> Optional[str]: + def hash(self) -> str | None: """ Returns the document hash as computed by contents manager. :return: The document hash. - :rtype: Optional[str] + :rtype: str | None """ - return self._ystate.get("hash") + try: + return self._ystate.hash + except KeyError: + return None @hash.setter def hash(self, value: str) -> None: @@ -137,17 +155,20 @@ def hash(self, value: str) -> None: :param value: The document hash. :type value: str """ - self._ystate["hash"] = value + self._ystate.hash = value @property - def path(self) -> Optional[str]: + def path(self) -> str | None: """ Returns document's path. :return: Document's path. - :rtype: Optional[str] + :rtype: str | None """ - return self._ystate.get("path") + try: + return self._ystate.path + except KeyError: + return None @path.setter def path(self, value: str) -> None: @@ -157,7 +178,7 @@ def path(self, value: str) -> None: :param value: Document's path. :type value: str """ - self._ystate["path"] = value + self._ystate.path = value @abstractmethod def get(self) -> Any: diff --git a/jupyter_ydoc/yblob.py b/jupyter_ydoc/yblob.py index 6061509..890a6bb 100644 --- a/jupyter_ydoc/yblob.py +++ b/jupyter_ydoc/yblob.py @@ -1,12 +1,22 @@ # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. +from __future__ import annotations + from functools import partial -from typing import Any, Callable, Optional +from typing import Any, Callable + +from pycrdt import Awareness, Doc, TypedMap + +from .ybasedoc import YBaseDoc, YDoc -from pycrdt import Awareness, Doc, Map -from .ybasedoc import YBaseDoc +class YBlobSource(TypedMap): + bytes: bytes + + +class YBlobDoc(YDoc): + source: YBlobSource class YBlob(YBaseDoc): @@ -24,7 +34,10 @@ class YBlob(YBaseDoc): } """ - def __init__(self, ydoc: Optional[Doc] = None, awareness: Optional[Awareness] = None): + _ydoc: YBlobDoc + _ysource: YBlobSource + + def __init__(self, ydoc: Doc | None = None, awareness: Awareness | None = None): """ Constructs a YBlob. @@ -34,9 +47,9 @@ def __init__(self, ydoc: Optional[Doc] = None, awareness: Optional[Awareness] = between clients. :type awareness: :class:`pycrdt.Awareness`, optional. """ - super().__init__(ydoc, awareness) - self._ysource = self._ydoc.get("source", type=Map) - self.undo_manager.expand_scope(self._ysource) + super().__init__(YBlobDoc(ydoc), awareness) + self._ydoc.source = self._ysource = YBlobSource() + self.undo_manager.expand_scope(self._ysource._) @property def version(self) -> str: @@ -55,7 +68,10 @@ def get(self) -> bytes: :return: Document's content. :rtype: bytes """ - return self._ysource.get("bytes", b"") + try: + return self._ysource.bytes + except KeyError: + return b"" def set(self, value: bytes) -> None: """ @@ -64,7 +80,7 @@ def set(self, value: bytes) -> None: :param value: The content of the document. :type value: bytes """ - self._ysource["bytes"] = value + self._ysource.bytes = value def observe(self, callback: Callable[[str, Any], None]) -> None: """ @@ -74,5 +90,5 @@ def observe(self, callback: Callable[[str, Any], None]) -> None: :type callback: Callable[[str, Any], None] """ self.unobserve() - self._subscriptions[self._ystate] = self._ystate.observe(partial(callback, "state")) - self._subscriptions[self._ysource] = self._ysource.observe(partial(callback, "source")) + self._subscriptions[self._ystate] = self._ystate._.observe(partial(callback, "state")) + self._subscriptions[self._ysource] = self._ysource._.observe(partial(callback, "source")) diff --git a/jupyter_ydoc/yfile.py b/jupyter_ydoc/yfile.py index ec2e82e..87a0b87 100644 --- a/jupyter_ydoc/yfile.py +++ b/jupyter_ydoc/yfile.py @@ -1,8 +1,14 @@ # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. -from .yunicode import YUnicode +from .yunicode import YUnicode, YUnicodeDoc +# For backwards-compatibility: -class YFile(YUnicode): # for backwards-compatibility + +class YFile(YUnicode): + pass + + +class YFileDoc(YUnicodeDoc): pass diff --git a/jupyter_ydoc/ynotebook.py b/jupyter_ydoc/ynotebook.py index 523b147..530e3f8 100644 --- a/jupyter_ydoc/ynotebook.py +++ b/jupyter_ydoc/ynotebook.py @@ -1,15 +1,17 @@ # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. +from __future__ import annotations + import copy from functools import partial -from typing import Any, Callable, Dict, Optional +from typing import Any, Callable from uuid import uuid4 -from pycrdt import Array, Awareness, Doc, Map, Text +from pycrdt import Array, Awareness, Doc, Map, Text, TypedArray, TypedMap from .utils import cast_all -from .ybasedoc import YBaseDoc +from .ybasedoc import YBaseDoc, YDoc # The default major version of the notebook format. NBFORMAT_MAJOR_VERSION = 4 @@ -17,6 +19,37 @@ NBFORMAT_MINOR_VERSION = 5 +class YMetadata(TypedMap): + language_info: dict + kernelspec: dict + + +class YMeta(TypedMap): + nbformat: int + nbformat_minor: int + metadata: YMetadata + + +class YCell(TypedMap): + id: str + cell_type: str + source: Text + metadata: Map + execution_state: str + execution_count: int | None + outputs: Array[Map] | None + attachments: dict | None + + +class YCells(TypedArray[YCell]): + type: YCell + + +class YNotebookDoc(YDoc): + meta: YMeta + cells: YCells + + class YNotebook(YBaseDoc): """ Extends :class:`YBaseDoc`, and represents a Notebook document. @@ -47,7 +80,11 @@ class YNotebook(YBaseDoc): } """ - def __init__(self, ydoc: Optional[Doc] = None, awareness: Optional[Awareness] = None): + _ydoc: YNotebookDoc + _ymeta: YMeta + _ycells: YCells + + def __init__(self, ydoc: Doc | None = None, awareness: Awareness | None = None): """ Constructs a YNotebook. @@ -57,10 +94,10 @@ def __init__(self, ydoc: Optional[Doc] = None, awareness: Optional[Awareness] = between clients. :type awareness: :class:`pycrdt.Awareness`, optional. """ - super().__init__(ydoc, awareness) - self._ymeta = self._ydoc.get("meta", type=Map) - self._ycells = self._ydoc.get("cells", type=Array) - self.undo_manager.expand_scope(self._ycells) + super().__init__(YNotebookDoc(ydoc), awareness) + self._ydoc.meta = self._ymeta = YMeta() + self._ydoc.cells = self._ycells = YCells() + self.undo_manager.expand_scope(self._ycells._) @property def version(self) -> str: @@ -92,7 +129,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. @@ -102,8 +139,8 @@ def get_cell(self, index: int) -> Dict[str, Any]: :return: A cell. :rtype: Dict[str, Any] """ - meta = self._ymeta.to_py() - cell = self._ycells[index].to_py() + meta = self._ymeta._.to_py() + cell = self._ycells[index]._.to_py() cell.pop("execution_state", None) cast_all(cell, float, int) # cells coming from Yjs have e.g. execution_count as float if "id" in cell and meta["nbformat"] == 4 and meta["nbformat_minor"] <= 4: @@ -117,7 +154,7 @@ def get_cell(self, index: int) -> 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. @@ -127,7 +164,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. @@ -140,9 +177,9 @@ 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]) -> YCell: """ - Creates YMap with the content of the cell. + Creates YCell with the content of the cell. :param value: A cell. :type value: Dict[str, Any] @@ -176,9 +213,9 @@ def create_ycell(self, value: Dict[str, Any]) -> Map: cell["outputs"] = Array(outputs) cell["execution_state"] = "idle" - return Map(cell) + return YCell(Map(cell)) - def set_ycell(self, index: int, ycell: Map) -> None: + def set_ycell(self, index: int, ycell: YCell) -> None: """ Sets a Y cell into the indicated position. @@ -190,14 +227,14 @@ 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. :return: Document's content. :rtype: Dict """ - meta = self._ymeta.to_py() + meta = self._ymeta._.to_py() cast_all(meta, float, int) # notebook coming from Yjs has e.g. nbformat as float cells = [] for i in range(len(self._ycells)): @@ -224,7 +261,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. @@ -246,23 +283,23 @@ def set(self, value: Dict) -> None: } ] - with self._ydoc.transaction(): + with self._ydoc._.transaction(): # clear document - self._ymeta.clear() - self._ycells.clear() - for key in [k for k in self._ystate.keys() if k not in ("dirty", "path")]: - del self._ystate[key] + self._ymeta._.clear() + self._ycells._.clear() + for key in [k for k in self._ystate._.keys() if k not in ("dirty", "path")]: + del self._ystate._[key] # initialize document self._ycells.extend([self.create_ycell(cell) for cell in cells]) - self._ymeta["nbformat"] = nb.get("nbformat", NBFORMAT_MAJOR_VERSION) - self._ymeta["nbformat_minor"] = nb.get("nbformat_minor", NBFORMAT_MINOR_VERSION) + self._ymeta.nbformat = int(nb.get("nbformat", NBFORMAT_MAJOR_VERSION)) + self._ymeta.nbformat_minor = int(nb.get("nbformat_minor", NBFORMAT_MINOR_VERSION)) + ymetadata = YMetadata() + self._ymeta.metadata = ymetadata metadata = nb.get("metadata", {}) - metadata.setdefault("language_info", {"name": ""}) - metadata.setdefault("kernelspec", {"name": "", "display_name": ""}) - - self._ymeta["metadata"] = Map(metadata) + ymetadata.language_info = metadata.get("language_info", {"name": ""}) + ymetadata.kernelspec = metadata.get("kernelspec", {"name": "", "display_name": ""}) def observe(self, callback: Callable[[str, Any], None]) -> None: """ @@ -272,6 +309,6 @@ def observe(self, callback: Callable[[str, Any], None]) -> None: :type callback: Callable[[str, Any], None] """ self.unobserve() - self._subscriptions[self._ystate] = self._ystate.observe(partial(callback, "state")) - self._subscriptions[self._ymeta] = self._ymeta.observe_deep(partial(callback, "meta")) - self._subscriptions[self._ycells] = self._ycells.observe_deep(partial(callback, "cells")) + self._subscriptions[self._ystate] = self._ystate._.observe(partial(callback, "state")) + self._subscriptions[self._ymeta] = self._ymeta._.observe_deep(partial(callback, "meta")) + self._subscriptions[self._ycells] = self._ycells._.observe_deep(partial(callback, "cells")) diff --git a/jupyter_ydoc/yunicode.py b/jupyter_ydoc/yunicode.py index 50d790c..e232dc9 100644 --- a/jupyter_ydoc/yunicode.py +++ b/jupyter_ydoc/yunicode.py @@ -1,12 +1,18 @@ # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. +from __future__ import annotations + from functools import partial -from typing import Any, Callable, Optional +from typing import Any, Callable from pycrdt import Awareness, Doc, Text -from .ybasedoc import YBaseDoc +from .ybasedoc import YBaseDoc, YDoc + + +class YUnicodeDoc(YDoc): + source: Text class YUnicode(YBaseDoc): @@ -23,7 +29,10 @@ class YUnicode(YBaseDoc): } """ - def __init__(self, ydoc: Optional[Doc] = None, awareness: Optional[Awareness] = None): + _ydoc: YUnicodeDoc + _ysource: Text + + def __init__(self, ydoc: Doc | None = None, awareness: Awareness | None = None): """ Constructs a YUnicode. @@ -33,8 +42,8 @@ def __init__(self, ydoc: Optional[Doc] = None, awareness: Optional[Awareness] = between clients. :type awareness: :class:`pycrdt.Awareness`, optional. """ - super().__init__(ydoc, awareness) - self._ysource = self._ydoc.get("source", type=Text) + super().__init__(YUnicodeDoc(ydoc), awareness) + self._ydoc.source = self._ysource = Text() self.undo_manager.expand_scope(self._ysource) @property @@ -63,7 +72,7 @@ def set(self, value: str) -> None: :param value: The content of the document. :type value: str """ - with self._ydoc.transaction(): + with self._ydoc._.transaction(): # clear document self._ysource.clear() # initialize document @@ -78,5 +87,5 @@ def observe(self, callback: Callable[[str, Any], None]) -> None: :type callback: Callable[[str, Any], None] """ self.unobserve() - self._subscriptions[self._ystate] = self._ystate.observe(partial(callback, "state")) + self._subscriptions[self._ystate] = self._ystate._.observe(partial(callback, "state")) self._subscriptions[self._ysource] = self._ysource.observe(partial(callback, "source")) diff --git a/pyproject.toml b/pyproject.toml index 9ec75d5..0d95e8f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,7 @@ requires-python = ">=3.8" keywords = ["jupyter", "pycrdt", "yjs"] dependencies = [ "importlib_metadata >=3.6; python_version<'3.10'", - "pycrdt >=0.10.1,<0.11.0", + "pycrdt >=0.12.1,<0.13.0", ] [[project.authors]] diff --git a/tests/test_pycrdt_yjs.py b/tests/test_pycrdt_yjs.py index bc23b37..f0cee25 100644 --- a/tests/test_pycrdt_yjs.py +++ b/tests/test_pycrdt_yjs.py @@ -89,7 +89,7 @@ async def test_ypy_yjs_1(yws_server, yjs_client): async with aconnect_ws(f"http://localhost:{port}/{room_name}") as websocket, WebsocketProvider( ydoc, Websocket(websocket, room_name) ): - output_text = ynotebook.ycells[0]["outputs"][0]["text"] + output_text = ynotebook.ycells[0].outputs[0]["text"] assert output_text.to_py() == "Hello," event = Event() diff --git a/tests/test_ydocs.py b/tests/test_ydocs.py index 6757f89..44a7804 100644 --- a/tests/test_ydocs.py +++ b/tests/test_ydocs.py @@ -32,7 +32,7 @@ def test_ynotebook_undo_manager(): "source": "Hello", } ynotebook.append_cell(cell0) - source = ynotebook.ycells[0]["source"] + source = ynotebook.ycells[0].source source += ", World!\n" cell1 = { "cell_type": "code", @@ -40,16 +40,16 @@ def test_ynotebook_undo_manager(): } ynotebook.append_cell(cell1) assert len(ynotebook.ycells) == 2 - assert str(ynotebook.ycells[0]["source"]) == "Hello, World!\n" - assert str(ynotebook.ycells[1]["source"]) == "print(1 + 1)\n" + assert str(ynotebook.ycells[0].source) == "Hello, World!\n" + assert str(ynotebook.ycells[1].source) == "print(1 + 1)\n" assert ynotebook.undo_manager.can_undo() ynotebook.undo_manager.undo() assert len(ynotebook.ycells) == 1 - assert str(ynotebook.ycells[0]["source"]) == "Hello, World!\n" + assert str(ynotebook.ycells[0].source) == "Hello, World!\n" assert ynotebook.undo_manager.can_undo() ynotebook.undo_manager.undo() assert len(ynotebook.ycells) == 1 - assert str(ynotebook.ycells[0]["source"]) == "Hello" + assert str(ynotebook.ycells[0].source) == "Hello" assert ynotebook.undo_manager.can_undo() ynotebook.undo_manager.undo() assert len(ynotebook.ycells) == 0