From 450d0c3928eee8a933cb1f29aa04246c97df0ccb Mon Sep 17 00:00:00 2001 From: David Brochart Date: Fri, 27 Sep 2024 16:49:43 +0200 Subject: [PATCH] Add API reference --- docs/api_reference.md | 34 ++++++ docs/assets/logo.png | Bin 0 -> 6396 bytes mkdocs.yml | 15 +++ pyproject.toml | 6 +- python/pycrdt/__init__.py | 3 + python/pycrdt/_array.py | 204 ++++++++++++++++++++++++++++++++-- python/pycrdt/_base.py | 22 +++- python/pycrdt/_doc.py | 175 ++++++++++++++++++++++++++++- python/pycrdt/_map.py | 169 +++++++++++++++++++++++++++- python/pycrdt/_pycrdt.pyi | 22 ++-- python/pycrdt/_sync.py | 146 ++++++++++++++++++++++-- python/pycrdt/_text.py | 152 ++++++++++++++++++++++++- python/pycrdt/_transaction.py | 32 +++++- python/pycrdt/_undo.py | 63 +++++++++++ python/pycrdt/_update.py | 30 ++++- tests/test_array.py | 2 +- 16 files changed, 1028 insertions(+), 47 deletions(-) create mode 100644 docs/api_reference.md create mode 100644 docs/assets/logo.png diff --git a/docs/api_reference.md b/docs/api_reference.md new file mode 100644 index 0000000..b65c05b --- /dev/null +++ b/docs/api_reference.md @@ -0,0 +1,34 @@ +# API reference + +::: pycrdt + options: + inherited_members: true + unwrap_annotated: true + members: + - BaseType + - Array + - ArrayEvent + - Decoder + - Doc + - Map + - MapEvent + - NewTransaction + - ReadTransaction + - StackItem + - Subscription + - SubdocsEvent + - Text + - TextEvent + - Transaction + - TransactionEvent + - UndoManager + - YMessageType + - YSyncMessageType + - create_sync_message + - create_update_message + - handle_sync_message + - get_state + - get_update + - merge_updates + - read_message + - write_var_uint diff --git a/docs/assets/logo.png b/docs/assets/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..21ece957792db1f7a0c1106cab971b099fa92bae GIT binary patch literal 6396 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 KMNUMnLSTY6dIF;W literal 0 HcmV?d00001 diff --git a/mkdocs.yml b/mkdocs.yml index 5313255..ae6b0fc 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -25,13 +25,28 @@ theme: - search.highlight - content.code.annotate - content.code.copy + logo: assets/logo.png nav: - Overview: index.md - install.md - usage.md +- api_reference.md markdown_extensions: - admonition - pymdownx.details - pymdownx.superfences + +plugins: +- search +- mkdocstrings: + default_handler: python + handlers: + python: + options: + show_source: false + docstring_style: google + find_stubs_package: true + docstring_options: + ignore_init_summary: false diff --git a/pyproject.toml b/pyproject.toml index 5377431..bf26c74 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,7 +43,11 @@ test = [ "coverage[toml] >=7", "exceptiongroup; python_version<'3.11'", ] -docs = [ "mkdocs", "mkdocs-material" ] +docs = [ + "mkdocs", + "mkdocs-material", + "mkdocstrings[python]", +] [project.urls] Homepage = "https://github.com/jupyter-server/pycrdt" diff --git a/python/pycrdt/__init__.py b/python/pycrdt/__init__.py index 94f2bf8..f543b1b 100644 --- a/python/pycrdt/__init__.py +++ b/python/pycrdt/__init__.py @@ -3,6 +3,8 @@ from ._doc import Doc as Doc from ._map import Map as Map from ._map import MapEvent as MapEvent +from ._pycrdt import StackItem as StackItem +from ._pycrdt import SubdocsEvent as SubdocsEvent from ._pycrdt import Subscription as Subscription from ._pycrdt import TransactionEvent as TransactionEvent from ._sync import Decoder as Decoder @@ -15,6 +17,7 @@ from ._sync import write_var_uint as write_var_uint from ._text import Text as Text from ._text import TextEvent as TextEvent +from ._transaction import NewTransaction as NewTransaction from ._transaction import ReadTransaction as ReadTransaction from ._transaction import Transaction as Transaction from ._undo import UndoManager as UndoManager diff --git a/python/pycrdt/_array.py b/python/pycrdt/_array.py index ce4ca94..b746905 100644 --- a/python/pycrdt/_array.py +++ b/python/pycrdt/_array.py @@ -1,16 +1,21 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Callable, cast from ._base import BaseDoc, BaseEvent, BaseType, base_types, event_types from ._pycrdt import Array as _Array from ._pycrdt import ArrayEvent as _ArrayEvent +from ._pycrdt import Subscription if TYPE_CHECKING: # pragma: no cover from ._doc import Doc class Array(BaseType): + """ + A collection used to store data in an indexed sequence structure, similar to a Python `list`. + """ + _prelim: list | None _integrated: _Array | None @@ -21,6 +26,16 @@ def __init__( _doc: Doc | None = None, _integrated: _Array | None = None, ) -> None: + """ + Creates an array with an optional initial value: + ```py + array0 = Array() + array1 = Array(["foo", 3, array0]) + ``` + + Args: + init: The list from which to initialize the array. + """ super().__init__( init=init, _doc=_doc, @@ -52,24 +67,65 @@ def _get_or_insert(self, name: str, doc: Doc) -> _Array: return doc._doc.get_or_insert_array(name) def __len__(self) -> int: + """ + ```py + Doc()["array"] = array = Array([2, 3, 0]) + assert len(array) == 3 + ``` + + Returns: + The length of the array. + """ with self.doc.transaction() as txn: return self.integrated.len(txn._txn) def append(self, value: Any) -> None: + """ + Appends an item to the array. + + Args: + value: The item to append to the array. + """ with self.doc.transaction(): self += [value] def extend(self, value: list[Any]) -> None: + """ + Extends the array with a list of items. + + Args: + value: The items that will extend the array. + """ with self.doc.transaction(): self += value def clear(self) -> None: + """ + Removes all items from the array. + """ del self[:] - def insert(self, index, object) -> None: + def insert(self, index: int, object: Any) -> None: + """ + Inserts an item at a given index in the array. + + Args: + index: The index where to insert the item. + object: The item to insert in the array. + """ self[index:index] = [object] def pop(self, index: int = -1) -> Any: + """ + Removes the item at the given index from the array, and returns it. + If no index is passed, removes and returns the last item. + + Args: + index: The optional index of the item to pop. + + Returns: + The item at the given index, or the last item. + """ with self.doc.transaction(): index = self._check_index(index) res = self[index] @@ -79,6 +135,13 @@ def pop(self, index: int = -1) -> Any: return res def move(self, source_index: int, destination_index: int) -> None: + """ + Moves an item in the array from a source index to a destination index. + + Args: + source_index: The index of the item to move. + destination_index: The index where the item will be inserted. + """ with self.doc.transaction() as txn: self._forbid_read_transaction(txn) source_index = self._check_index(source_index) @@ -86,17 +149,60 @@ def move(self, source_index: int, destination_index: int) -> None: self.integrated.move_to(txn._txn, source_index, destination_index) def __add__(self, value: list[Any]) -> Array: + """ + Extends the array with a list of items: + ```py + Doc()["array"] = array = Array(["foo"]) + array += ["bar", "baz"] + assert array.to_py() == ["foo", "bar", "baz"] + ``` + + Args: + value: The items that will extend the array. + + Returns: + The extended array. + """ with self.doc.transaction(): length = len(self) self[length:length] = value return self def __radd__(self, value: list[Any]) -> Array: + """ + Prepends a list of items to the array: + ```py + Doc()["array"] = array = Array(["bar", "baz"]) + array = ["foo"] + array + assert array.to_py() == ["foo", "bar", "baz"] + ``` + + Args: + value: The list of items to prepend. + + Returns: + The prepended array. + """ with self.doc.transaction(): self[0:0] = value return self def __setitem__(self, key: int | slice, value: Any | list[Any]) -> None: + """ + Replaces the item at the given index with a new item: + ```py + Doc()["array"] = array = Array(["foo", "bar"]) + array[1] = "baz" + assert array.to_py() == ["foo", "baz"] + ``` + + Args: + key: The index of the item to replace. + value: The new item to set. + + Raises: + RuntimeError: Index must be of type integer. + """ with self.doc.transaction(): if isinstance(key, int): key = self._check_index(key) @@ -125,6 +231,20 @@ def _check_index(self, idx: int) -> int: return idx def __delitem__(self, key: int | slice) -> None: + """ + Removes the item at the given index from the array: + ```py + Doc()["array"] = array = Array(["foo", "bar", "baz"]) + del array[2] + assert array.to_py() == ["foo", "bar"] + ``` + + Args: + key: The index of the item to remove. + + Raises: + RuntimeError: Array indices must be integers or slices. + """ with self.doc.transaction() as txn: self._forbid_read_transaction(txn) if isinstance(key, int): @@ -148,10 +268,20 @@ def __delitem__(self, key: int | slice) -> None: self.integrated.remove_range(txn._txn, i, n) else: raise TypeError( - f"array indices must be integers or slices, not {type(key).__name__}" + f"Array indices must be integers or slices, not {type(key).__name__}" ) def __getitem__(self, key: int) -> BaseType: + """ + Gets the item at the given index: + ```py + Doc()["array"] = array = Array(["foo", "bar", "baz"]) + assert array[1] == "bar" + ``` + + Returns: + The item at the given index. + """ with self.doc.transaction() as txn: if isinstance(key, int): key = self._check_index(key) @@ -162,30 +292,87 @@ def __getitem__(self, key: int) -> BaseType: step = 1 if key.step is None else key.step return [self[i] for i in range(i0, i1, step)] - def __iter__(self): + def __iter__(self) -> ArrayIterator: + """ + ```py + Doc()["array"] = array = Array(["foo", "foo"]) + for value in array: + assert value == "foo" + ``` + Returns: + An iterable over the items of the array. + """ return ArrayIterator(self) def __contains__(self, item: Any) -> bool: - return item in list(self) + """ + Checks if the given item is in the array: + ```py + Doc()["array"] = array = Array(["foo", "bar"]) + assert "baz" not in array + ``` + + Args: + item: The item to look for in the array. + + Returns: + True if the item was found. + """ + return item in [value for value in self] def __str__(self) -> str: + """ + ```py + Doc()["array"] = array = Array([2, 3, 0]) + assert str(array) == "[2,3,0]" + ``` + + Returns: + The string representation of the array. + """ with self.doc.transaction() as txn: return self.integrated.to_json(txn._txn) def to_py(self) -> list | None: + """ + Recursively converts the array's items to Python objects, and + returns them in a list. If the array was not yet inserted in a document, + returns `None` if the array was not initialized. + + Returns: + The array recursively converted to Python objects, or `None`. + """ if self._integrated is None: py = self._prelim if py is None: return None else: - py = list(self) + py = [value for value in self] for idx, val in enumerate(py): if isinstance(val, BaseType): py[idx] = val.to_py() return py + def observe(self, callback: Callable[[ArrayEvent], None]) -> Subscription: + """ + Subscribes a callback to be called with the array event. + + Args: + callback: The callback to call with the [ArrayEvent][pycrdt.ArrayEvent]. + """ + return super().observe(cast(Callable[[BaseEvent], None], callback)) + class ArrayEvent(BaseEvent): + """ + An array change event. + + Attributes: + target (Array): The changed array. + delta (list[dict[str, Any]]): A list of items describing the changes. + path (list[int | str]): A list with the indices pointing to the array that was changed. + """ + __slots__ = "target", "delta", "path" @@ -195,7 +382,10 @@ def __init__(self, array: Array): self.length = len(array) self.idx = 0 - def __next__(self): + def __iter__(self) -> ArrayIterator: + return self + + def __next__(self) -> Any: if self.idx == self.length: raise StopIteration diff --git a/python/pycrdt/_base.py b/python/pycrdt/_base.py index e4b7f54..8b82c03 100644 --- a/python/pycrdt/_base.py +++ b/python/pycrdt/_base.py @@ -127,6 +127,12 @@ def integrated(self) -> Any: @property def doc(self) -> Doc: + """ + The document this shared type belongs to. + + Raises: + RuntimeError: Not integrated in a document yet. + """ if self._doc is None: raise RuntimeError("Not integrated in a document yet") return self._doc @@ -147,19 +153,31 @@ def prelim(self) -> Any: def type_name(self) -> str: return self._type_name - def observe(self, callback: Callable[[Any], None]) -> Subscription: + def observe(self, callback: Callable[[BaseEvent], None]) -> Subscription: _callback = partial(observe_callback, callback, self.doc) subscription = self.integrated.observe(_callback) self._subscriptions.append(subscription) return subscription - def observe_deep(self, callback: Callable[[Any], None]) -> Subscription: + def observe_deep(self, callback: Callable[[list[BaseEvent]], None]) -> Subscription: + """ + Subscribes a callback for all events emitted by this and nested collaborative types. + + Args: + callback: The callback to call with the list of events. + """ _callback = partial(observe_deep_callback, callback, self.doc) subscription = self.integrated.observe_deep(_callback) self._subscriptions.append(subscription) return subscription def unobserve(self, subscription: Subscription) -> None: + """ + Unsubscribes to changes using the given subscription. + + Args: + subscription: The subscription to unregister. + """ self._subscriptions.remove(subscription) subscription.drop() diff --git a/python/pycrdt/_doc.py b/python/pycrdt/_doc.py index d7dfa9f..eb0be01 100644 --- a/python/pycrdt/_doc.py +++ b/python/pycrdt/_doc.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Any, Callable, Type, TypeVar, cast +from typing import Any, Callable, Iterable, Type, TypeVar, cast from ._base import BaseDoc, BaseType, base_types from ._pycrdt import Doc as _Doc @@ -12,6 +12,15 @@ class Doc(BaseDoc): + """ + A shared document. + + All shared types live within the scope of their corresponding documents. + All updates are generated on a per-document basis. + All operations on shared types happen in a transaction, whose lifetime is also bound to a + document. + """ + def __init__( self, init: dict[str, BaseType] = {}, @@ -21,6 +30,12 @@ def __init__( Model=None, allow_multithreading: bool = False, ) -> None: + """ + Args: + init: The initial root types of the document. + client_id: An optional client ID for the document. + allow_multithreading: Whether to allow the document to be used in different threads. + """ super().__init__( client_id=client_id, doc=doc, Model=Model, allow_multithreading=allow_multithreading ) @@ -31,13 +46,36 @@ def __init__( @property def guid(self) -> int: + """The GUID of the document.""" return self._doc.guid() @property def client_id(self) -> int: + """The document client ID.""" return self._doc.client_id() def transaction(self, origin: Any = None) -> Transaction: + """ + Creates a new transaction or gets the current one, if any. + If an origin is passed and there is already an ongoing transaction, + the passed origin must be the same as the origin of the current transaction. + + This method must be used with a context manager: + + ```py + with doc.transaction(): + ... + ``` + + Args: + origin: An optional origin to set on this transaction. + + Raises: + RuntimeError: Nested transactions must have same origin as root transaction. + + Returns: + A new transaction or the current one. + """ if self._txn is not None: if origin is not None: if origin != self._txn.origin: @@ -48,20 +86,71 @@ def transaction(self, origin: Any = None) -> Transaction: return Transaction(self, origin=origin) def new_transaction(self, origin: Any = None, timeout: float | None = None) -> NewTransaction: + """ + Creates a new transaction. + Unlike [transaction()][pycrdt.Doc.transaction], this method will not reuse an ongoing + transaction. + If there is already an ongoing transaction, this method will wait (with an optional timeout) + until the current transaction has finished. + There are two ways to do so: + + - Use an async context manager: + ```py + async with doc.new_transaction(): + ... + ``` + In this case you most likely access the document in the same thread, which means that + the [Doc][pycrdt.Doc.__init__] can be created with `allow_multithreading=False`. + + - Use a (sync) context manager: + ```py + with doc.new_transaction(): + ... + ``` + In this case you want to use multithreading, as the ongoing transaction must + run in another thread (otherwise this will deadlock), which means that + the [Doc][pycrdt.Doc.__init__] must have been created with `allow_multithreading=True`. + + Args: + origin: An optional origin to set on this transaction. + timeout: An optional timeout (in seconds) to acquire a new transaction. + + Raises: + RuntimeError: Already in a transaction. + TimeoutError: Could not acquire transaction. + + Returns: + A new transaction. + """ return NewTransaction(self, origin=origin, timeout=timeout) def _read_transaction(self, _txn: _Transaction) -> ReadTransaction: return ReadTransaction(self, _txn) def get_state(self) -> bytes: + """ + Returns: + The current document state. + """ return self._doc.get_state() def get_update(self, state: bytes | None = None) -> bytes: + """ + Args: + state: The optional document state from which to get the update. + + Returns: + The update from the given document state (if any), or from the document creation. + """ if state is None: state = b"\x00" return self._doc.get_update(state) def apply_update(self, update: bytes) -> None: + """ + Args: + update: The update to apply to the document. + """ if self._Model is not None: twin_doc = cast(Doc, self._twin_doc) twin_doc.apply_update(update) @@ -74,6 +163,19 @@ def apply_update(self, update: bytes) -> None: self._doc.apply_update(update) def __setitem__(self, key: str, value: BaseType) -> None: + """ + Sets a document root type: + ```py + doc["text"] = Text("Hello") + ``` + + Args: + key: The name of the root type. + value: The root type. + + Raises: + RuntimeError: Key must be of type string. + """ if not isinstance(key, str): raise RuntimeError("Key must be of type string") integrated = value._get_or_insert(key, self) @@ -81,23 +183,62 @@ def __setitem__(self, key: str, value: BaseType) -> None: value._init(prelim) def __getitem__(self, key: str) -> BaseType: + """ + Gets the document root type corresponding to the given key: + ```py + text = doc["text"] + ``` + + Args: + key: The key of the root type to get. + + Returns: + The document root type. + """ return self._roots[key] - def __iter__(self): + def __iter__(self) -> Iterable[str]: + """ + Returns: + An iterable over the keys of the document root types. + """ return iter(self.keys()) def get(self, key: str, *, type: type[T_BaseType]) -> T_BaseType: + """ + Gets the document root type corresponding to the given key. + If it already exists, it will be cast to the given type (if different), + otherwise a new root type is created. + ```py + doc.get("text", type=Text) + ``` + + Returns: + The root type corresponding to the given key, cast to the given type. + """ value = type() self[key] = value return value - def keys(self): + def keys(self) -> Iterable[str]: + """ + Returns: + An iterable over the names of the document root types. + """ return self._roots.keys() - def values(self): + def values(self) -> Iterable[BaseType]: + """ + Returns: + An iterable over the document root types. + """ return self._roots.values() - def items(self): + def items(self) -> Iterable[tuple[str, BaseType]]: + """ + Returns: + An iterable over the key-value pairs of document root types. + """ return self._roots.items() @property @@ -114,16 +255,40 @@ def _roots(self) -> dict[str, BaseType]: } def observe(self, callback: Callable[[TransactionEvent], None]) -> Subscription: + """ + Subscribes a callback to be called with the document change event. + + Args: + callback: The callback to call with the [TransactionEvent][pycrdt.TransactionEvent]. + + Returns: + The subscription that can be used to [unobserve()][pycrdt.Doc.unobserve]. + """ subscription = self._doc.observe(callback) self._subscriptions.append(subscription) return subscription def observe_subdocs(self, callback: Callable[[SubdocsEvent], None]) -> Subscription: + """ + Subscribes a callback to be called with the document subdoc change event. + + Args: + callback: The callback to call with the [SubdocsEvent][pycrdt.SubdocsEvent]. + + Returns: + The subscription that can be used to [unobserve()][pycrdt.Doc.unobserve]. + """ subscription = self._doc.observe_subdocs(callback) self._subscriptions.append(subscription) return subscription def unobserve(self, subscription: Subscription) -> None: + """ + Unsubscribes to changes using the given subscription. + + Args: + subscription: The subscription to unregister. + """ self._subscriptions.remove(subscription) subscription.drop() diff --git a/python/pycrdt/_map.py b/python/pycrdt/_map.py index 6d640b3..d1fe560 100644 --- a/python/pycrdt/_map.py +++ b/python/pycrdt/_map.py @@ -1,16 +1,21 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Callable, Iterable, cast from ._base import BaseDoc, BaseEvent, BaseType, base_types, event_types from ._pycrdt import Map as _Map from ._pycrdt import MapEvent as _MapEvent +from ._pycrdt import Subscription if TYPE_CHECKING: # pragma: no cover from ._doc import Doc class Map(BaseType): + """ + A collection used to store key-value entries in an unordered manner, similar to a Python `dict`. + """ + _prelim: dict | None _integrated: _Map | None @@ -21,6 +26,16 @@ def __init__( _doc: Doc | None = None, _integrated: _Map | None = None, ) -> None: + """ + Creates a map with an optional initial value: + ```py + map0 = Map() + map1 = Map({"foo": 0, "bar": 3, "baz": map0}) + ``` + + Args: + init: The list from which to initialize the array. + """ super().__init__( init=init, _doc=_doc, @@ -52,14 +67,39 @@ def _get_or_insert(self, name: str, doc: Doc) -> _Map: return doc._doc.get_or_insert_map(name) def __len__(self) -> int: + """ + ```py + Doc()["map0"] = map0 = Map({"foo": 0, "bar": 1}) + assert len(map0) == 2 + ``` + Returns: + The length of the map. + """ with self.doc.transaction() as txn: return self.integrated.len(txn._txn) def __str__(self) -> str: + """ + ```py + Doc()["map0"] = map0 = Map({"foo": 0, "bar": 1}) + assert str(map0) == '{"foo":0,"bar":1}' + ``` + + Returns: + The string representation of the map. + """ with self.doc.transaction() as txn: return self.integrated.to_json(txn._txn) def to_py(self) -> dict | None: + """ + Recursively converts the map's items to Python objects, and + returns them in a `dict`. If the map was not yet inserted in a document, + returns `None` if the map was not initialized. + + Returns: + The map recursively converted to Python objects, or `None`. + """ if self._integrated is None: py = self._prelim if py is None: @@ -72,35 +112,113 @@ def to_py(self) -> dict | None: return py def __delitem__(self, key: str) -> None: + """ + Removes the item at the given key from the map: + ```py + Doc()["map0"] = map0 = Map({"foo": 0, "bar": 1}) + del map0["foo"] + assert map0.to_py() == {"bar": 1} + ``` + + Args: + key: The key of the item to remove. + """ with self.doc.transaction() as txn: self._forbid_read_transaction(txn) self._check_key(key) self.integrated.remove(txn._txn, key) def __getitem__(self, key: str) -> Any: + """ + Gets the value at the given key: + ```py + Doc()["map0"] = map0 = Map({"foo": 0, "bar": 1}) + assert map0["foo"] == 0 + ``` + + Returns: + The value at the given key. + """ with self.doc.transaction() as txn: self._check_key(key) return self._maybe_as_type_or_doc(self.integrated.get(txn._txn, key)) def __setitem__(self, key: str, value: Any) -> None: + """ + Sets a value at the given key: + ```py + Doc()["map0"] = map0 = Map({"foo": 0, "bar": 1}) + map0["foo"] = 2 + assert map0["foo"] == 2 + ``` + + Args: + key: The key to set. + value: The value to set. + + Raises: + RuntimeError: Key must be of type string. + """ if not isinstance(key, str): raise RuntimeError("Key must be of type string") with self.doc.transaction(): self._set(key, value) - def __iter__(self): + def __iter__(self) -> Iterable[str]: + """ + ```py + Doc()["map0"] = map0 = Map({"foo": 0, "bar": 0}) + for key in m0: + assert map[key] == 0 + ``` + Returns: + An iterable over the keys of the map. + """ return self.keys() def __contains__(self, item: str) -> bool: + """ + Checks if the given key is in the map: + ```py + Doc()["map0"] = map0 = Map({"foo": 0, "bar": 0}) + assert "baz" not in map0: + ``` + + Args: + item: The key to look for in the map. + + Returns: + True if the key was found. + """ return item in self.keys() def get(self, key: str, default_value: Any | None = None) -> Any | None: + """ + Returns the value corresponding to the given key if it exists, otherwise + returns the `default_value`. + + Args: + key: The key of the value to get. + default_value: The optional default value to return if the key is not found. + + Returns: + The value at the given key, or the default value. + """ with self.doc.transaction(): if key in self.keys(): return self[key] return default_value - def pop(self, *args) -> Any: + def pop(self, *args: Any) -> Any: + """ + Removes the entry at the given key from the map, and returns the corresponding value. + + Args: + args: The key of the value to pop, and an optional default value. + + Returns: + The value at the given key, or the default value if passed. + """ key, *default_value = args with self.doc.transaction(): if key not in self.keys(): @@ -119,30 +237,69 @@ def _check_key(self, key: str): if key not in self.keys(): raise KeyError(key) - def keys(self): + def keys(self) -> Iterable[str]: + """ + Returns: + An iterable over the keys of the map. + """ with self.doc.transaction() as txn: return iter(self.integrated.keys(txn._txn)) - def values(self): + def values(self) -> Iterable[Any]: + """ + Returns: + An iterable over the values of the map. + """ with self.doc.transaction() as txn: for k in self.integrated.keys(txn._txn): yield self[k] - def items(self): + def items(self) -> Iterable[tuple[str, Any]]: + """ + Returns: + An iterable over the key-value pairs of the map. + """ with self.doc.transaction() as txn: for k in self.integrated.keys(txn._txn): yield k, self[k] def clear(self) -> None: + """ + Removes all entries from the map. + """ with self.doc.transaction() as txn: for k in self.integrated.keys(txn._txn): del self[k] def update(self, value: dict[str, Any]) -> None: + """ + Sets entries in the map from all entries in the passed `dict`. + + Args: + value: The `dict` from which to get the entries to update. + """ self._init(value) + def observe(self, callback: Callable[[MapEvent], None]) -> Subscription: + """ + Subscribes a callback to be called with the map event. + + Args: + callback: The callback to call with the [MapEvent][pycrdt.MapEvent]. + """ + return super().observe(cast(Callable[[BaseEvent], None], callback)) + class MapEvent(BaseEvent): + """ + A map change event. + + Attributes: + target (Map): The changed map. + delta (list[dict[str, Any]]): A list of items describing the changes. + path (list[int | str]): A list with the indices pointing to the map that was changed. + """ + __slots__ = "target", "keys", "path" diff --git a/python/pycrdt/_pycrdt.pyi b/python/pycrdt/_pycrdt.pyi index 80ef5f4..2b8cd69 100644 --- a/python/pycrdt/_pycrdt.pyi +++ b/python/pycrdt/_pycrdt.pyi @@ -49,10 +49,10 @@ class Doc: Returns a subscription that can be used to unsubscribe.""" class Subscription: - """Observer subscription""" + """Observer subscription.""" def drop(self) -> None: - """Drop the subscription, effectively unobserving.""" + """Drops the subscription, effectively unobserving.""" class Transaction: """Document transaction""" @@ -68,16 +68,19 @@ class Transaction: """The origin of the transaction.""" class TransactionEvent: - """Event generated by `Doc.observe` method. Emitted during transaction commit - phase.""" + """ + Event generated by the [observe][pycrdt.Doc.observe] method, + emitted during the transaction commit phase. + """ @property def update(self) -> bytes: - """The emitted binary update""" + """The emitted binary update.""" class SubdocsEvent: - """Event generated by `Doc.observe_subdocs` method. Emitted during transaction commit - phase.""" + """ + Event generated by the [observe_subdocs][pycrdt.Doc.observe_subdocs] method, + emitted during the transaction commit phase.""" class TextEvent: """Event generated by `Text.observe` method. Emitted during transaction commit @@ -217,9 +220,8 @@ class UndoManager: """Returns the undo manager's redo stack.""" class StackItem: - """A unit of work for the undo manager, consisting of - compressed information about all updates and - deletions tracked by it. + """A unit of work for the [UndoManager][pycrdt.UndoManager], consisting of + compressed information about all updates and deletions tracked by it. """ def merge_updates(updates: tuple[bytes, ...]) -> bytes: ... diff --git a/python/pycrdt/_sync.py b/python/pycrdt/_sync.py index 1dd2672..f8d1d75 100644 --- a/python/pycrdt/_sync.py +++ b/python/pycrdt/_sync.py @@ -1,22 +1,51 @@ from __future__ import annotations from enum import IntEnum +from typing import Iterator from pycrdt import Doc class YMessageType(IntEnum): - SYNC = 0 - AWARENESS = 1 + """ + A generic Y message type. + + Attributes: + SYNC: A message type used for synchronizing documents. + AWARENESS: A message type used for the awareness protocol. + """ + + SYNC: int = 0 + AWARENESS: int = 1 class YSyncMessageType(IntEnum): - SYNC_STEP1 = 0 - SYNC_STEP2 = 1 - SYNC_UPDATE = 2 + """ + A message type used for synchronizing documents. + + Attributes: + SYNC_STEP1: A synchronization message type used to send a document state. + SYNC_STEP2: A synchronization message type used to reply to a + [SYNC_STEP1][pycrdt.YSyncMessageType], consisting of all missing updates and all + deletions. + SYNC_UPDATE: A synchronization message type used to send document updates. + """ + + SYNC_STEP1: int = 0 + SYNC_STEP2: int = 1 + SYNC_UPDATE: int = 2 def write_var_uint(num: int) -> bytes: + """ + Encodes a payload length. + + Args: + num: The payload length to encode. + + Returns: + The encoded payload length. + """ res = [] while num > 127: res.append(128 | (127 & num)) @@ -26,28 +55,82 @@ def write_var_uint(num: int) -> bytes: def create_message(data: bytes, msg_type: int) -> bytes: + """ + Creates a binary Y message. + + Args: + data: The data to send in the message. + msg_type: The [message type][pycrdt.YSyncMessageType]. + + Returns: + The binary Y message. + """ return bytes([YMessageType.SYNC, msg_type]) + write_var_uint(len(data)) + data def create_sync_step1_message(data: bytes) -> bytes: + """ + Creates a [SYNC_STEP1][pycrdt.YSyncMessageType.SYNC_STEP1] message consisting + of a document state. + + Args: + data: The document state. + + Returns: + A [SYNC_STEP1][pycrdt.YSyncMessageType.SYNC_STEP1] message. + """ return create_message(data, YSyncMessageType.SYNC_STEP1) def create_sync_step2_message(data: bytes) -> bytes: + """ + Creates a [SYNC_STEP2][pycrdt.YSyncMessageType.SYNC_STEP2] message in + reply to a [SYNC_STEP1][pycrdt.YSyncMessageType.SYNC_STEP1] message. + + Args: + data: All missing updates and deletetions. + + Returns: + A [SYNC_STEP2][pycrdt.YSyncMessageType.SYNC_STEP2] message. + """ return create_message(data, YSyncMessageType.SYNC_STEP2) def create_update_message(data: bytes) -> bytes: + """ + Creates a [SYNC_UPDATE][pycrdt.YSyncMessageType] message that + contains a document update. + + Args: + data: The document update. + + Returns: + A [SYNC_UPDATE][pycrdt.YSyncMessageType] message. + """ return create_message(data, YSyncMessageType.SYNC_UPDATE) class Decoder: + """ + A decoder capable of reading messages from a byte stream. + """ + def __init__(self, stream: bytes): + """ + Args: + stream: The byte stream from which to read messages. + """ self.stream = stream self.length = len(stream) self.i0 = 0 def read_var_uint(self) -> int: + """ + Decodes the current message length. + + Returns: + The decoded length of the message. + """ if self.length <= 0: raise RuntimeError("Y protocol error") uint = 0 @@ -63,6 +146,12 @@ def read_var_uint(self) -> int: return uint def read_message(self) -> bytes | None: + """ + Reads a message from the byte stream, ready to read the next message if any. + + Returns: + The current message, if any. + """ if self.length == 0: return None length = self.read_var_uint() @@ -74,14 +163,27 @@ def read_message(self) -> bytes | None: self.length -= length return message - def read_messages(self): + def read_messages(self) -> Iterator[bytes]: + """ + A generator that reads messages from the byte stream. + + Returns: + A generator that yields messages. + """ while True: message = self.read_message() if message is None: return yield message - def read_var_string(self): + def read_var_string(self) -> str: + """ + Reads a message as an UTF-8 string from the byte stream, ready to read the next message if + any. + + Returns: + The current message as a string. + """ message = self.read_message() if message is None: return "" @@ -89,12 +191,32 @@ def read_var_string(self): def read_message(stream: bytes) -> bytes: + """ + Reads a message from a byte stream. + + Args: + stream: The byte stream from which to read the message. + + Returns: + The message read from the byte stream. + """ message = Decoder(stream).read_message() assert message is not None return message def handle_sync_message(message: bytes, ydoc: Doc) -> bytes | None: + """ + Processes a [synchronization message][pycrdt.YSyncMessageType] on a document. + + Args: + message: A synchronization message. + ydoc: The [Doc][pycrdt.Doc] that this message targets. + + Returns: + The [SYNC_STEP2][pycrdt.YSyncMessageType] reply message, if the message + was a [SYNC_STEP1][pycrdt.YSyncMessageType]. + """ message_type = message[0] msg = message[1:] @@ -117,6 +239,16 @@ def handle_sync_message(message: bytes, ydoc: Doc) -> bytes | None: def create_sync_message(ydoc: Doc) -> bytes: + """ + Creates a [SYNC_STEP1][pycrdt.YSyncMessageType] message that + contains the state of a [Doc][pycrdt.Doc]. + + Args: + ydoc: The [Doc][pycrdt.Doc] for which to create the message. + + Returns: + A [SYNC_STEP1][pycrdt.YSyncMessageType] message. + """ state = ydoc.get_state() message = create_sync_step1_message(state) return message diff --git a/python/pycrdt/_text.py b/python/pycrdt/_text.py index 2cac85b..784ab51 100644 --- a/python/pycrdt/_text.py +++ b/python/pycrdt/_text.py @@ -1,8 +1,9 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Callable, cast from ._base import BaseEvent, BaseType, base_types, event_types +from ._pycrdt import Subscription from ._pycrdt import Text as _Text from ._pycrdt import TextEvent as _TextEvent @@ -11,6 +12,10 @@ class Text(BaseType): + """ + A shared data type used for collaborative text editing, similar to a Python `str`. + """ + _prelim: str | None _integrated: _Text | None @@ -21,6 +26,15 @@ def __init__( _doc: Doc | None = None, _integrated: _Text | None = None, ) -> None: + """ + Creates a text with an optional initial value: + ```py + text = Text("Hello, World!") + ``` + + Args: + init: The string from which to initialize the text. + """ super().__init__( init=init, _doc=_doc, @@ -36,26 +50,80 @@ def _init(self, value: str | None) -> None: def _get_or_insert(self, name: str, doc: Doc) -> _Text: return doc._doc.get_or_insert_text(name) - def __iter__(self): + def __iter__(self) -> TextIterator: + """ + ```py + Doc()["text"] = text = Text("***") + for character in text: + assert character == "*" + ``` + + Returns: + An iterable over the characters of the text. + """ return TextIterator(self) def __contains__(self, item: str) -> bool: + """ + Checks if the given string is in the text: + ```py + Doc()["text"] = text = Text("Hello, World!") + assert "World" in text + ``` + + Args: + item: The string to look for in the text. + + Returns: + True if the string was found. + """ return item in str(self) def __len__(self) -> int: + """ + ```py + Doc()["text"] = text = Text("Hello") + assert len(text) == 5 + ``` + + Returns: + The length of the text. + """ with self.doc.transaction() as txn: return self.integrated.len(txn._txn) def __str__(self) -> str: + """ + Returns: + The text as a Python `str`. + """ with self.doc.transaction() as txn: return self.integrated.get_string(txn._txn) def to_py(self) -> str | None: + """ + Returns: + The text as a Python `str`. + """ if self._integrated is None: return self._prelim return str(self) def __iadd__(self, value: str) -> Text: + """ + Concatenates a string to the text: + ```py + Doc()["text"] = text = Text("Hello") + text += ", World!" + assert str(text) == "Hello, World!" + ``` + + Args: + value: The string to concatenate. + + Returns: + The concatenated text. + """ with self.doc.transaction() as txn: self._forbid_read_transaction(txn) self.integrated.insert(txn._txn, len(self), value) @@ -79,6 +147,24 @@ def _check_slice(self, key: slice) -> tuple[int, int]: return start, stop def __delitem__(self, key: int | slice) -> None: + """ + Removes the characters at the given index or slice: + ```py + Doc()["text"] = text = Text("Hello, World!") + del text[5] + assert str(text) == "Hello World!" + del text[5:] + assert str(text) == "Hello" + ``` + + Args: + key: The index or the slice of the characters to remove. + + Raises: + RuntimeError: Step not supported. + RuntimeError: Negative start not supported. + RuntimeError: Negative stop not supported. + """ with self.doc.transaction() as txn: self._forbid_read_transaction(txn) if isinstance(key, int): @@ -92,10 +178,38 @@ def __delitem__(self, key: int | slice) -> None: raise RuntimeError(f"Index not supported: {key}") def __getitem__(self, key: int | slice) -> str: + """ + Gets the characters at the given index or slice: + ```py + Doc()["text"] = text = Text("Hello, World!") + assert text[:5] == "Hello" + ``` + + Returns: + The characters at the given index or slice. + """ value = str(self) return value[key] def __setitem__(self, key: int | slice, value: str) -> None: + """ + Replaces the characters at the given index or slice with new characters: + ```py + Doc()["text"] = text = Text("Hello, World!") + text[7:12] = "Brian" + assert text == "Hello, Brian!" + ``` + + Args: + key: The index or slice of the characters to replace. + value: The new characters to set. + + Raises: + RuntimeError: Step not supported. + RuntimeError: Negative start not supported. + RuntimeError: Negative stop not supported. + RuntimeError: Single item assigned value must have a length of 1. + """ with self.doc.transaction() as txn: self._forbid_read_transaction(txn) if isinstance(key, int): @@ -120,11 +234,38 @@ def clear(self) -> None: del self[:] def insert(self, index: int, value: str) -> None: - """Insert 'value' at character position 'index'.""" + """ + Inserts a string at a given index in the text. + Doc()["text"] = text = Text("Hello World!") + text.insert(5, ",") + assert text == "Hello, World!" + + Args: + index: The index where to insert the string. + value: The string to insert in the text. + """ self[index:index] = value + def observe(self, callback: Callable[[TextEvent], None]) -> Subscription: + """ + Subscribes a callback to be called with the text event. + + Args: + callback: The callback to call with the [TextEvent][pycrdt.TextEvent]. + """ + return super().observe(cast(Callable[[BaseEvent], None], callback)) + class TextEvent(BaseEvent): + """ + A text change event. + + Attributes: + target (Text): The changed text. + delta (list[dict[str, Any]]): A list of items describing the changes. + path (list[int | str]): A list with the indices pointing to the text that was changed. + """ + __slots__ = "target", "delta", "path" @@ -134,7 +275,10 @@ def __init__(self, text: Text): self.length = len(text) self.idx = 0 - def __next__(self): + def __iter__(self) -> TextIterator: + return self + + def __next__(self) -> str: if self.idx == self.length: raise StopIteration diff --git a/python/pycrdt/_transaction.py b/python/pycrdt/_transaction.py index 48beb69..a73648f 100644 --- a/python/pycrdt/_transaction.py +++ b/python/pycrdt/_transaction.py @@ -13,6 +13,15 @@ class Transaction: + """ + A read-write transaction that can be used to mutate a document. + It must be used with a context manager (see [Doc.transaction()][pycrdt.Doc.transaction]): + ```py + with doc.transaction(): + ... + ``` + """ + _doc: Doc _txn: _Transaction | None _leases: int @@ -77,6 +86,12 @@ def __exit__( @property def origin(self) -> Any: + """ + The origin of the transaction. + + Raises: + RuntimeError: No current transaction. + """ if self._txn is None: raise RuntimeError("No current transaction") @@ -88,6 +103,19 @@ def origin(self) -> Any: class NewTransaction(Transaction): + """ + A read-write transaction that can be used to mutate a document. + It can be used with a context manager or an async context manager + (see [Doc.new_transaction()][pycrdt.Doc.new_transaction]): + ```py + with doc.new_transaction(): + ... + + async with doc.new_transaction(): + ... + ``` + """ + async def __aenter__(self) -> Transaction: if self._doc._allow_multithreading: if not await to_thread.run_sync( @@ -110,7 +138,9 @@ async def __aexit__( class ReadTransaction(Transaction): - pass + """ + A read-only transaction that cannot be used to mutate a document. + """ def hash_origin(origin: Any) -> int: diff --git a/python/pycrdt/_undo.py b/python/pycrdt/_undo.py index 8d75137..0c59e38 100644 --- a/python/pycrdt/_undo.py +++ b/python/pycrdt/_undo.py @@ -16,6 +16,15 @@ class UndoManager: + """ + The undo manager allows to perform undo/redo operations on shared types. + It can be initialized either with a [Doc][pycrdt.Doc] or with scopes. + Scopes are a list of shared types integrated in a document. + If initialized with a `Doc`, scopes can later be expanded. + Changes can be undone/redone by batches using time intervals. + It is possible to include/exclude changes by transaction origin in undo/redo operations. + """ + def __init__( self, *, @@ -23,6 +32,16 @@ def __init__( scopes: list[BaseType] = [], capture_timeout_millis: int = 500, ) -> None: + """ + Args: + doc: The document the undo manager will work with. + scopes: A list of shared types the undo manager will work with. + capture_timeout_millis: A time interval for grouping changes that will be undone/redone. + + + Raises: + RuntimeError: UndoManager must be created with doc or scopes. + """ if doc is None: if not scopes: raise RuntimeError("UndoManager must be created with doc or scopes") @@ -34,34 +53,78 @@ def __init__( self.expand_scope(scope) def expand_scope(self, scope: BaseType) -> None: + """ + Expands the scope of shared types for this undo manager. + + Args: + scope: The shared type to include. + """ method = getattr(self._undo_manager, f"expand_scope_{scope.type_name}") method(scope._integrated) def include_origin(self, origin: Any) -> None: + """ + Extends the list of transactions origin tracked by this undo manager. + + Args: + origin: The origin to include. + """ self._undo_manager.include_origin(hash_origin(origin)) def exclude_origin(self, origin: Any) -> None: + """ + Removes a transaction origin from the list of origins tracked by this undo manager. + + Args: + origin: The origin to exclude. + """ self._undo_manager.exclude_origin(hash_origin(origin)) def can_undo(self) -> bool: + """ + Returns: + True if there are changes to undo. + """ return self._undo_manager.can_undo() def undo(self) -> bool: + """ + Perform an undo operation. + + Returns: + True if some changes were undone. + """ return self._undo_manager.undo() def can_redo(self) -> bool: + """ + Returns: + True if there are changes to redo. + """ return self._undo_manager.can_redo() def redo(self) -> bool: + """ + Perform a redo operation. + + Returns: + True if some changes were redone. + """ return self._undo_manager.redo() def clear(self) -> None: + """ + Clears all [StackItem][pycrdt.StackItem]s stored in this undo manager, + effectively resetting its state. + """ self._undo_manager.clear() @property def undo_stack(self) -> list[StackItem]: + """The list of undoable actions.""" return self._undo_manager.undo_stack() @property def redo_stack(self) -> list[StackItem]: + """The list of redoable actions.""" return self._undo_manager.redo_stack() diff --git a/python/pycrdt/_update.py b/python/pycrdt/_update.py index 91c861a..e5068ea 100644 --- a/python/pycrdt/_update.py +++ b/python/pycrdt/_update.py @@ -4,17 +4,41 @@ def get_state(update: bytes) -> bytes: - """Returns a state from an update.""" + """ + Returns a state from an update. + + Args: + update: The update from which to get the state. + + Returns: + The state corresponding to the update. + """ return _get_state(update) def get_update(update: bytes, state: bytes) -> bytes: - """Returns an update consisting of all changes from a given update which have not + """ + Returns an update consisting of all changes from a given update which have not been seen in the given state. + + Args: + update: The update from which to get all missing changes in the given state. + state: The state from which to get missing changes that are in the given update. + + Returns: + The changes from the given update not present in the given state. """ return _get_update(update, state) def merge_updates(*updates: bytes) -> bytes: - """Returns an update consisting of a combination of all given updates.""" + """ + Returns an update consisting of a combination of all given updates. + + Args: + updates: The updates to merge. + + Returns: + The merged updates. + """ return _merge_updates(updates) diff --git a/tests/test_array.py b/tests/test_array.py index 65b32df..90a5b2f 100644 --- a/tests/test_array.py +++ b/tests/test_array.py @@ -208,7 +208,7 @@ def test_api(): assert str(excinfo.value) == "Negative stop not supported" with pytest.raises(TypeError) as excinfo: del array["a"] - assert str(excinfo.value) == "array indices must be integers or slices, not str" + assert str(excinfo.value) == "Array indices must be integers or slices, not str" assert [value for value in array] == [value for value in range(10)] assert 1 in array