From a0d91ddc64662daf467176770798967a6b4728a3 Mon Sep 17 00:00:00 2001 From: Alex Parrill Date: Sat, 19 Oct 2024 10:58:10 -0400 Subject: [PATCH] XML and Text formatting support (#184) * Add support for XML. Add bindings to XMLFragment, XMLElement, XMLText, and XMLEvent. Allows reading and writing of XML fragments from the document, which rich text editors use heavily. Closes #151 * Add support for formatting and embeds in Text Rich text editors often store their markup in attributes and embeds. * Allow apply_update in a transaction Currently trying to use apply_update in a transaction causes a deadlock, as apply_update tries to make its own new transaction but is locked out. This reuses the existing transaction, which also allows applying multiple updates in a single transaction. * Add __eq__ and __hash__ to XML types Allows comparing whether objects refer to the same element in a doc. Important since every access generates a new, unique Python wrapper instance. __eq__ is based directly from the underlying rust objects' `Eq` implementation. __hash__ is based off of the `Hash` implementation of the `BranchID` of the rust objects. * Add XmlText.diff Like Text, XmlText can contain embedded formatting, so make that available. * Mypy and ruff fixes and formatting * Document XML types * More lint fixes * Add XML related tests and fixes for found bugs --- .gitignore | 3 + docs/api_reference.md | 5 + python/pycrdt/__init__.py | 4 + python/pycrdt/_base.py | 8 +- python/pycrdt/_doc.py | 7 +- python/pycrdt/_pycrdt.pyi | 156 +++++++++++- python/pycrdt/_text.py | 44 +++- python/pycrdt/_xml.py | 485 ++++++++++++++++++++++++++++++++++++++ src/doc.rs | 29 +-- src/lib.rs | 9 + src/text.rs | 69 +++++- src/type_conversions.rs | 70 ++++-- src/xml.rs | 380 +++++++++++++++++++++++++++++ tests/test_text.py | 22 ++ tests/test_update.py | 21 +- tests/test_xml.py | 277 ++++++++++++++++++++++ 16 files changed, 1540 insertions(+), 49 deletions(-) create mode 100644 python/pycrdt/_xml.py create mode 100644 src/xml.rs create mode 100644 tests/test_xml.py diff --git a/.gitignore b/.gitignore index eee06c9..1edda7e 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,6 @@ __pycache__/ *.so Cargo.lock .coverage +/site +/dist +_pycrdt.*.pyd diff --git a/docs/api_reference.md b/docs/api_reference.md index 2050c17..95dc8ef 100644 --- a/docs/api_reference.md +++ b/docs/api_reference.md @@ -24,6 +24,11 @@ - Transaction - TransactionEvent - UndoManager + - XmlElement + - XmlFragment + - XmlText + - XmlChildrenView + - XmlAttributesView - YMessageType - YSyncMessageType - create_awareness_message diff --git a/python/pycrdt/__init__.py b/python/pycrdt/__init__.py index e3139e3..94f1dd1 100644 --- a/python/pycrdt/__init__.py +++ b/python/pycrdt/__init__.py @@ -28,3 +28,7 @@ from ._update import get_state as get_state from ._update import get_update as get_update from ._update import merge_updates as merge_updates +from ._xml import XmlElement as XmlElement +from ._xml import XmlEvent as XmlEvent +from ._xml import XmlFragment as XmlFragment +from ._xml import XmlText as XmlText diff --git a/python/pycrdt/_base.py b/python/pycrdt/_base.py index 8b82c03..08de2cd 100644 --- a/python/pycrdt/_base.py +++ b/python/pycrdt/_base.py @@ -21,6 +21,11 @@ event_types: dict[Any, type[BaseEvent]] = {} +def forbid_read_transaction(txn: Transaction): + if isinstance(txn, ReadTransaction): + raise RuntimeError("Read-only transaction cannot be used to modify document structure") + + class BaseDoc: _doc: _Doc _twin_doc: BaseDoc | None @@ -91,8 +96,7 @@ def _get_or_insert(self, name: str, doc: Doc) -> Any: ... def _init(self, value: Any | None) -> None: ... def _forbid_read_transaction(self, txn: Transaction): - if isinstance(txn, ReadTransaction): - raise RuntimeError("Read-only transaction cannot be used to modify document structure") + forbid_read_transaction(txn) def _integrate(self, doc: Doc, integrated: Any) -> Any: prelim = self._prelim diff --git a/python/pycrdt/_doc.py b/python/pycrdt/_doc.py index eb0be01..8273af6 100644 --- a/python/pycrdt/_doc.py +++ b/python/pycrdt/_doc.py @@ -2,7 +2,7 @@ from typing import Any, Callable, Iterable, Type, TypeVar, cast -from ._base import BaseDoc, BaseType, base_types +from ._base import BaseDoc, BaseType, base_types, forbid_read_transaction from ._pycrdt import Doc as _Doc from ._pycrdt import SubdocsEvent, Subscription, TransactionEvent from ._pycrdt import Transaction as _Transaction @@ -160,7 +160,10 @@ def apply_update(self, update: bytes) -> None: except Exception as e: self._twin_doc = Doc(dict(self)) raise e - self._doc.apply_update(update) + with self.transaction() as txn: + forbid_read_transaction(txn) + assert txn._txn is not None + self._doc.apply_update(txn._txn, update) def __setitem__(self, key: str, value: BaseType) -> None: """ diff --git a/python/pycrdt/_pycrdt.pyi b/python/pycrdt/_pycrdt.pyi index 2b8cd69..766407d 100644 --- a/python/pycrdt/_pycrdt.pyi +++ b/python/pycrdt/_pycrdt.pyi @@ -1,4 +1,4 @@ -from typing import Any, Callable +from typing import Any, Callable, Iterator class Doc: """Shared document.""" @@ -28,13 +28,16 @@ class Doc: def get_or_insert_map(self, name: str) -> Map: """Create a map root type on this document, or get an existing one.""" + def get_or_insert_xml_fragment(self, name: str) -> XmlFragment: + """Create an XML fragment root type on this document, or get an existing one.""" + def get_state(self) -> bytes: """Get the current document state.""" def get_update(self, state: bytes) -> bytes: """Get the update from the given state to the current state.""" - def apply_update(self, update: bytes) -> None: + def apply_update(self, txn: Transaction, update: bytes) -> None: """Apply the update to the document.""" def roots(self, txn: Transaction) -> dict[str, Text | Array | Map]: @@ -94,15 +97,39 @@ class MapEvent: """Event generated by `Map.observe` method. Emitted during transaction commit phase.""" +class XmlEvent: + """Event generated by `Xml*.observe` methods. Emitted during transaction commit + phase.""" + class Text: """Shared text.""" def len(self, txn: Transaction) -> int: """Returns the number of characters visible in the current shared text.""" - def insert(self, txn: Transaction, index: int, chunk: str) -> None: + def insert( + self, + txn: Transaction, + index: int, + chunk: str, + attrs: Iterator[tuple[str, Any]] | None = None, + ) -> None: """Inserts a `chunk` of text at a given `index`.""" + def insert_embed( + self, + txn: Transaction, + index: int, + embed: Any, + attrs: Iterator[tuple[str, Any]] | None = None, + ) -> None: + """Inserts an embed at a given `index`.""" + + def format( + self, txn: Transaction, index: int, len: int, attrs: Iterator[tuple[str, Any]] + ) -> None: + """Formats a range of elements""" + def remove_range(self, txn: Transaction, index: int, len: int) -> None: """Removes up to `len` characters from th current shared text, starting at given`index`.""" @@ -110,6 +137,9 @@ class Text: def get_string(self, txn: Transaction) -> str: """Returns a text representation of the current shared text.""" + def diff(self, txn: Transaction) -> list[tuple[Any, dict[str, Any] | None]]: + """Returns a sequence of formatted chunks""" + def observe(self, callback: Callable[[TextEvent], None]) -> Subscription: """Subscribes a callback to be called with the shared text change event. Returns a subscription that can be used to unsubscribe.""" @@ -183,6 +213,126 @@ class Map: """Unsubscribes previously subscribed event callback identified by given `subscription`.""" +class XmlFragment: + def parent(self) -> XmlFragment | XmlElement | XmlText | None: ... + def get_string(self, txn: Transaction) -> str: + """Returns a text representation of the current shared xml.""" + + def len(self, txn: Transaction) -> int: + """Returns the numer of children of the current shared xml.""" + + def get(self, txn: Transaction, index: int) -> XmlFragment | XmlElement | XmlText | None: + """Gets a child item by index, or None if the index is out of bounds""" + + def remove_range(self, txn: Transaction, index: int, len: int) -> None: + """Removes a range of children""" + + def insert_str(self, txn: Transaction, index: int, text: str) -> XmlText: + """Inserts a text node""" + + def insert_element_prelim(self, txn: Transaction, index: int, tag: str) -> XmlElement: + """Inserts an empty element node""" + + def observe(self, callback: Callable[[XmlEvent], None]) -> Subscription: + """Subscribes a callback to be called with the xml change event. + Returns a subscription that can be used to unsubscribe.""" + + def observe_deep(self, callback: Callable[[XmlEvent], None]) -> Subscription: + """Subscribes a callback to be called with the xml change event + and its nested elements. + Returns a subscription that can be used to unsubscribe.""" + +class XmlElement: + def parent(self) -> XmlFragment | XmlElement | XmlText | None: ... + def get_string(self, txn: Transaction) -> str: + """Returns a text representation of the current shared xml.""" + + def len(self, txn: Transaction) -> int: + """Returns the numer of children of the current shared xml.""" + + def get(self, txn: Transaction, index: int) -> XmlFragment | XmlElement | XmlText | None: + """Gets a child item by index, or None if the index is out of bounds""" + + def remove_range(self, txn: Transaction, index: int, len: int) -> None: + """Removes a range of children""" + + def insert_str(self, txn: Transaction, index: int, text: str) -> XmlText: + """Inserts a text node""" + + def insert_element_prelim(self, txn: Transaction, index: int, tag: str) -> XmlElement: + """Inserts an empty element node""" + + def attributes(self, txn: Transaction) -> list[tuple[str, str]]: + """Gets all attributes, as a list of `(key, value)` tuples""" + + def attribute(self, txn: Transaction, name: str) -> str | None: + """Gets an attribute, or None if the attribute does not exist""" + + def insert_attribute(self, txn: Transaction, name: str, value: str) -> None: + """Inserts or overwrites an attribute.""" + + def remove_attribute(self, txn: Transaction, name: str) -> None: + """Removes an attribute""" + + def siblings(self, txn) -> list[XmlFragment | XmlElement | XmlText]: + """Gets the siblings of this node""" + + def observe(self, callback: Callable[[XmlEvent], None]) -> Subscription: + """Subscribes a callback to be called with the xml change event. + Returns a subscription that can be used to unsubscribe.""" + + def observe_deep(self, callback: Callable[[XmlEvent], None]) -> Subscription: + """Subscribes a callback to be called with the xml change event + and its nested elements. + Returns a subscription that can be used to unsubscribe.""" + +class XmlText: + def parent(self) -> XmlFragment | XmlElement | XmlText | None: ... + def get_string(self, txn: Transaction) -> str: + """Returns a text representation of the current shared xml.""" + + def attributes(self, txn: Transaction) -> list[tuple[str, str]]: + """Gets all attributes, as a list of `(key, value)` tuples""" + + def attribute(self, txn: Transaction, name: str) -> str | None: + """Gets an attribute, or None if the attribute does not exist""" + + def insert_attribute(self, txn: Transaction, name: str, value: str) -> None: + """Inserts or overwrites an attribute.""" + + def remove_attribute(self, txn: Transaction, name: str) -> None: + """Removes an attribute""" + + def siblings(self, txn: Transaction) -> list[XmlFragment | XmlElement | XmlText]: + """Gets the siblings of this node""" + + def insert( + self, + txn: Transaction, + index: int, + text: str, + attrs: Iterator[tuple[str, Any]] | None = None, + ): + """Inserts text, optionally with attributes""" + + def remove_range(self, txn: Transaction, index: int, len: int): + """Removes text""" + + def format(self, txn: Transaction, index: int, len: int, attrs: Iterator[tuple[str, Any]]): + """Adds attributes to a section of text""" + + def diff(self, txn: Transaction) -> list[tuple[Any, dict[str, Any] | None]]: + """Returns a sequence of formatted chunks""" + + def observe(self, callback: Callable[[XmlEvent], None]) -> Subscription: + """Subscribes a callback to be called with the xml change event. + Returns a subscription that can be used to unsubscribe.""" + + def observe_deep(self, callback: Callable[[XmlEvent], None]) -> Subscription: + """Subscribes a callback to be called with the xml change event + and its nested elements. + Returns a subscription that can be used to unsubscribe.""" + class UndoManager: """Undo manager.""" diff --git a/python/pycrdt/_text.py b/python/pycrdt/_text.py index 21570a3..67a6515 100644 --- a/python/pycrdt/_text.py +++ b/python/pycrdt/_text.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Callable, cast +from typing import TYPE_CHECKING, Any, Callable, cast from ._base import BaseEvent, BaseType, base_types, event_types from ._pycrdt import Subscription @@ -233,18 +233,56 @@ def clear(self) -> None: """Remove the entire range of characters.""" del self[:] - def insert(self, index: int, value: str) -> None: + def insert(self, index: int, value: str, attrs: dict[str, Any] | None = None) -> None: """ Inserts a string at a given index in the text. + ```py 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. + attrs: Optional dictionary of attributes to apply + """ + with self.doc.transaction() as txn: + self._forbid_read_transaction(txn) + self.integrated.insert( + txn._txn, index, value, iter(attrs.items()) if attrs is not None else None + ) + + def insert_embed(self, index: int, value: Any, attrs: dict[str, Any] | None = None) -> None: + """ + Insert 'value' as an embed at a given index in the text. """ - self[index:index] = value + with self.doc.transaction() as txn: + self._forbid_read_transaction(txn) + self.integrated.insert_embed( + txn._txn, index, value, iter(attrs.items()) if attrs is not None else None + ) + + def format(self, start: int, stop: int, attrs: dict[str, Any]) -> None: + """ + Adds attribute to a section of text + """ + with self.doc.transaction() as txn: + self._forbid_read_transaction(txn) + start, stop = self._check_slice(slice(start, stop)) + length = stop - start + if length > 0: + self.integrated.format(txn._txn, start, length, iter(attrs.items())) + + def diff(self) -> list[tuple[Any, dict[str, Any] | None]]: + """ + Returns list of formatted chunks that the current text corresponds to. + + Each list item is a tuple containing the chunk's contents and formatting attributes. The + contents is usually the text as a string, but may be other data for embedded objects. + """ + with self.doc.transaction() as txn: + return self.integrated.diff(txn._txn) def observe(self, callback: Callable[[TextEvent], None]) -> Subscription: """ diff --git a/python/pycrdt/_xml.py b/python/pycrdt/_xml.py new file mode 100644 index 0000000..9823496 --- /dev/null +++ b/python/pycrdt/_xml.py @@ -0,0 +1,485 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Iterator, overload + +from ._base import BaseEvent, BaseType, base_types, event_types +from ._pycrdt import XmlElement as _XmlElement +from ._pycrdt import XmlEvent as _XmlEvent +from ._pycrdt import XmlFragment as _XmlFragment +from ._pycrdt import XmlText as _XmlText + +if TYPE_CHECKING: # pragma: no cover + from typing import Any, Iterable, Mapping, Sized, TypeVar + + from ._doc import Doc + + T = TypeVar("T") + + +def _integrated_to_wrapper( + doc: Doc, inner: _XmlText | _XmlElement | _XmlFragment +) -> XmlText | XmlElement | XmlFragment: + if isinstance(inner, _XmlElement): + return XmlElement(_doc=doc, _integrated=inner) + if isinstance(inner, _XmlFragment): + return XmlFragment(_doc=doc, _integrated=inner) + return XmlText(_doc=doc, _integrated=inner) + + +def _check_slice(value: Sized, key: slice) -> tuple[int, int]: + if key.step is not None: + raise RuntimeError("Step not supported") + if key.start is None: + start = 0 + elif key.start < 0: + raise RuntimeError("Negative start not supported") + else: + start = key.start + if key.stop is None: + stop = len(value) + elif key.stop < 0: + raise RuntimeError("Negative stop not supported") + else: + stop = key.stop + return start, stop + + +class _XmlBaseMixin(BaseType): + _integrated: _XmlElement | _XmlText | _XmlFragment | None + + @property + def parent(self) -> XmlFragment | XmlElement | XmlText | None: + """ + The parent of this node, if any. + """ + inner = self.integrated.parent() + if inner is None: + return None + return _integrated_to_wrapper(self.doc, inner) + + def __str__(self): + with self.doc.transaction() as txn: + return self.integrated.get_string(txn._txn) + + def __eq__(self, other: object): + if not isinstance(other, _XmlBaseMixin): + return False + return self.integrated == other.integrated + + def __hash__(self) -> int: + return hash(self.integrated) + + +class _XmlFragmentTraitMixin(_XmlBaseMixin): + _integrated: _XmlElement | _XmlFragment | None + + @property + def children(self) -> XmlChildrenView: + """ + A list-like view into this object's child nodes. + """ + return XmlChildrenView(self) + + +class _XmlTraitMixin(_XmlBaseMixin): + _integrated: _XmlElement | _XmlText | None + + @property + def attributes(self) -> XmlAttributesView: + """ + A dict-like view into this object's attributes. + """ + return XmlAttributesView(self) + + +class XmlFragment(_XmlFragmentTraitMixin): + _prelim: list[XmlFragment | XmlElement | XmlText] | None + _integrated: _XmlFragment | None + + def __init__( + self, + init: Iterable[XmlFragment | XmlElement | XmlText] | None = None, + *, + _doc: Doc | None = None, + _integrated: _XmlFragment | None = None, + ) -> None: + super().__init__( + init=list(init) if init is not None else None, + _doc=_doc, + _integrated=_integrated, + ) + + def to_py(self) -> None: + raise ValueError("XmlFragment has no Python equivalent") + + def _get_or_insert(self, name: str, doc: Doc) -> Any: + return doc._doc.get_or_insert_xml_fragment(name) + + def _init(self, value: list[XmlElement | str] | None) -> None: + if value is None: + return + for obj in value: + self.children.append(obj) + + +class XmlElement(_XmlFragmentTraitMixin, _XmlTraitMixin): + _prelim: tuple[str, list[tuple[str, str]], list[str | XmlElement | XmlText]] | None + _integrated: _XmlElement | None + + def __init__( + self, + tag: str | None = None, + attributes: dict[str, str] | Iterable[tuple[str, str]] | None = None, + contents: Iterable[XmlFragment | XmlElement | XmlText] | None = None, + *, + _doc: Doc | None = None, + _integrated: _XmlElement | None = None, + ) -> None: + """ + Creates a new preliminary element. + + `tag` is required. + """ + if _integrated is not None: + super().__init__(init=None, _doc=_doc, _integrated=_integrated) + return + + if tag is None: + raise ValueError("XmlElement: tag is required") + + if isinstance(attributes, dict): + init_attrs = list(attributes.items()) + elif attributes is not None: + init_attrs = list(attributes) + else: + init_attrs = [] + + super().__init__( + init=( + tag, + init_attrs, + list(contents) if contents is not None else [], + ) + ) + + def to_py(self) -> None: + raise ValueError("XmlElement has no Python equivalent") + + def _get_or_insert(self, name: str, doc: Doc) -> Any: + raise ValueError("Cannot get an XmlElement from a doc - get an XmlFragment instead.") + + def _init( + self, value: tuple[str, list[tuple[str, str]], list[str | XmlElement | XmlText]] | None + ): + assert value is not None + _, attrs, contents = value + with self.doc.transaction(): + for k, v in attrs: + self.attributes[k] = v + for child in contents: + self.children.append(child) + + @property + def tag(self) -> str | None: + """ + Gets the element's tag. + """ + return self.integrated.tag() + + +class XmlText(_XmlTraitMixin): + """ + A piece of text in an XML tree. + + This is similar to the `Text` object, but instead of existing in a doc on its own, it is a child + of an `XmlElement` or `XmlFragment`. + """ + + _prelim: str + _integrated: _XmlText | None + + def __init__( + self, + init: str = "", + *, + _doc: Doc | None = None, + _integrated: _XmlText | None = None, + ) -> None: + super().__init__( + init=init, + _doc=_doc, + _integrated=_integrated, + ) + + def _get_or_insert(self, _name: str, _doc: Doc) -> Any: + raise ValueError("Cannot get an XmlText from a doc - get an XmlFragment instead.") + + def to_py(self) -> str: + if self._integrated is None: + return self._prelim + return str(self) + + def _init(self, value: str | None) -> None: # pragma: no cover + assert value is not None + with self.doc.transaction() as txn: + self.integrated.insert(txn._txn, 0, value) + + def __len__(self) -> int: + with self.doc.transaction() as txn: + return self.integrated.len(txn._txn) + + def __iadd__(self, value: str) -> XmlText: + with self.doc.transaction(): + self.insert(len(self), value) + return self + + def insert(self, index: int, value: str, attrs: Mapping[str, Any] | None = None) -> None: + """ + Inserts text at the specified index, optionally with attributes + """ + with self.doc.transaction() as txn: + self._forbid_read_transaction(txn) + self.integrated.insert( + txn._txn, index, value, iter(attrs.items()) if attrs is not None else iter([]) + ) + + def insert_embed(self, index: int, value: Any, attrs: dict[str, Any] | None = None) -> None: + """ + Insert 'value' as an embed at a given index in the text. + """ + with self.doc.transaction() as txn: + self._forbid_read_transaction(txn) + self.integrated.insert_embed( + txn._txn, index, value, iter(attrs.items()) if attrs is not None else None + ) + + def format(self, start: int, stop: int, attrs: dict[str, Any]) -> None: + """ + Formats existing text with attributes. + + Affects the text from the 'start' index (inclusive) to the 'stop' index (exclusive). + """ + with self.doc.transaction() as txn: + self._forbid_read_transaction(txn) + start, stop = _check_slice(self, slice(start, stop)) + length = stop - start + if length > 0: + self.integrated.format(txn._txn, start, length, iter(attrs.items())) + + def diff(self) -> list[tuple[Any, dict[str, Any] | None]]: + """ + Returns list of formatted chunks that the current text corresponds to. + + Each list item is a tuple containing the chunk's contents and formatting attributes. The + contents is usually the text as a string, but may be other data for embedded objects. + """ + with self.doc.transaction() as txn: + return self.integrated.diff(txn._txn) + + def __delitem__(self, key: int | slice) -> None: + with self.doc.transaction() as txn: + self._forbid_read_transaction(txn) + if isinstance(key, int): + self.integrated.remove_range(txn._txn, key, 1) + elif isinstance(key, slice): + start, stop = _check_slice(self, key) + length = stop - start + if length > 0: + self.integrated.remove_range(txn._txn, start, length) + else: + raise TypeError(f"Index not supported: {key}") + + def clear(self) -> None: + """Remove the entire range of characters.""" + del self[:] + + +class XmlEvent(BaseEvent): + __slots__ = ["children_changed", "target", "path", "delta", "keys"] + + +class XmlAttributesView: + """ + A list-like view into an `XmlFragment` or `XmlElement`'s child nodes. + + Supports `len`, `in`, and getting, setting, and deleting by index. Iteration will iterate over + key/value tuples. + """ + + inner: _XmlTraitMixin + + def __init__(self, inner: _XmlTraitMixin) -> None: + self.inner = inner + + def get(self, key: str) -> Any | None: + """ + Gets the value of an attribute, or `None` if there is no attribute with the passed in name. + """ + with self.inner.doc.transaction() as txn: + v = self.inner.integrated.attribute(txn._txn, key) + if v is None: + return None + return v + + def __getitem__(self, key: str) -> Any: + """ + Gets an attribute by name. + """ + v = self.get(key) + if v is None: + raise KeyError(key) + return v + + def __setitem__(self, key: str, value: Any) -> None: + """ + Sets an attribute. + """ + with self.inner.doc.transaction() as txn: + self.inner._forbid_read_transaction(txn) + self.inner.integrated.insert_attribute(txn._txn, key, value) + + def __delitem__(self, key: str) -> None: + """ + Deletes an attribute + """ + with self.inner.doc.transaction() as txn: + self.inner._forbid_read_transaction(txn) + self.inner.integrated.remove_attribute(txn._txn, key) + + def __contains__(self, key: str) -> bool: + """ + Checks if an attribute exists + """ + return self.get(key) is not None + + def __len__(self) -> int: + """ + Gets the number of attributes + """ + with self.inner.doc.transaction() as txn: + return len(self.inner.integrated.attributes(txn._txn)) + + def __iter__(self) -> Iterable[tuple[str, Any]]: + """ + Iterates over each attribute, as key/value tuples. + """ + with self.inner.doc.transaction() as txn: + return iter(self.inner.integrated.attributes(txn._txn)) + + +class XmlChildrenView: + """ + A list-like view into an `XmlFragment` or `XmlElement`'s child nodes. + + Supports `iter`, `len`, and getting, setting, and deleting by index. + """ + + inner: _XmlFragmentTraitMixin + + def __init__(self, inner: _XmlFragmentTraitMixin) -> None: + self.inner = inner + + def __len__(self) -> int: + """ + Gets the number of child nodes + """ + with self.inner.doc.transaction() as txn: + return self.inner.integrated.len(txn._txn) + + def __getitem__(self, index: int) -> XmlElement | XmlFragment | XmlText: + """ + Gets a child by its index. + """ + with self.inner.doc.transaction() as txn: + if index >= len(self): + raise IndexError(index) + return _integrated_to_wrapper( + self.inner.doc, self.inner.integrated.get(txn._txn, index) + ) + + def __delitem__(self, key: int | slice) -> None: + """ + Removes a child (integer index) or a range of children (slice index). + """ + with self.inner.doc.transaction() as txn: + self.inner._forbid_read_transaction(txn) + if isinstance(key, int): + self.inner.integrated.remove_range(txn._txn, key, 1) + elif isinstance(key, slice): + start, stop = _check_slice(self, key) + length = stop - start + if length > 0: + self.inner.integrated.remove_range(txn._txn, start, length) + else: + raise TypeError(f"Index not supported: {key}") + + def __setitem__(self, key: int, value: str | XmlText | XmlElement): + """ + Replaces a child. Equivalent to deleting the index and then inserting the new value. + """ + with self.inner.doc.transaction(): + del self[key] + self.insert(key, value) + + def __iter__(self) -> Iterator[XmlText | XmlElement | XmlFragment]: + """ + Iterates over child nodes. + """ + with self.inner.doc.transaction(): + children = [self[i] for i in range(len(self))] + return iter(children) + + @overload + def insert(self, index: int, element: str | XmlText) -> XmlText: ... + @overload + def insert(self, index: int, element: XmlElement) -> XmlElement: ... + + def insert(self, index: int, element: str | XmlText | XmlElement) -> XmlText | XmlElement: + """ + Inserts a new node into the element's or fragment's children at the specified index. + + Passing in a `str` will convert it to an `XmlText`. Returns the passed in element, which + will now be integrated into the tree. + """ + with self.inner.doc.transaction() as txn: + self.inner._forbid_read_transaction(txn) + if index > len(self): + raise IndexError(index) + if isinstance(element, str): + integrated = self.inner.integrated.insert_str(txn._txn, index, element) + return XmlText(_doc=self.inner.doc, _integrated=integrated) + elif isinstance(element, XmlText): + if element._integrated is not None: + raise ValueError("Cannot insert an integrated XmlText") + integrated = self.inner.integrated.insert_str(txn._txn, index, element.prelim) + element._integrate(self.inner.doc, integrated) + return element + elif isinstance(element, XmlElement): + if element._integrated is not None: + raise ValueError("Cannot insert an integrated XmlElement") + prelim = element.prelim + integrated = self.inner.integrated.insert_element_prelim(txn._txn, index, prelim[0]) + element._integrate(self.inner.doc, integrated) + element._init(prelim) + return element + else: + raise TypeError("Cannot add value to XML: " + repr(element)) + + @overload + def append(self, element: str | XmlText) -> XmlText: ... + @overload + def append(self, element: XmlElement) -> XmlElement: ... + + def append(self, element: str | XmlText | XmlElement) -> XmlText | XmlElement: + """ + Appends a new node to the end of the element's or fragment's children. + + Equivalent to `insert` at index `len(self)`. + """ + return self.insert(len(self), element) + + +base_types[_XmlFragment] = XmlFragment +base_types[_XmlElement] = XmlElement +base_types[_XmlText] = XmlText +event_types[_XmlEvent] = XmlEvent diff --git a/src/doc.rs b/src/doc.rs index 1e3b919..fb5ae19 100644 --- a/src/doc.rs +++ b/src/doc.rs @@ -2,14 +2,7 @@ use pyo3::prelude::*; use pyo3::exceptions::{PyRuntimeError, PyValueError}; use pyo3::types::{PyBytes, PyDict, PyLong, PyList}; use yrs::{ - Doc as _Doc, - ReadTxn, - Transact, - TransactionMut, - TransactionCleanupEvent, - SubdocsEvent as _SubdocsEvent, - StateVector, - Update, + Doc as _Doc, ReadTxn, StateVector, SubdocsEvent as _SubdocsEvent, Transact, TransactionCleanupEvent, TransactionMut, Update }; use yrs::updates::encoder::Encode; use yrs::updates::decoder::Decode; @@ -19,6 +12,7 @@ use crate::map::Map; use crate::transaction::Transaction; use crate::subscription::Subscription; use crate::type_conversions::ToPython; +use crate::xml::XmlFragment; #[pyclass] @@ -72,6 +66,10 @@ impl Doc { Ok(pyshared) } + fn get_or_insert_xml_fragment(&mut self, name: &str) -> XmlFragment { + self.doc.get_or_insert_xml_fragment(name).into() + } + fn create_transaction(&self, py: Python<'_>) -> PyResult> { if let Ok(txn) = self.doc.try_transact_mut() { let t: Py = Py::new(py, Transaction::from(txn))?; @@ -105,15 +103,12 @@ impl Doc { Ok(bytes) } - fn apply_update(&mut self, update: &Bound<'_, PyBytes>) -> PyResult<()> { - let mut txn = self.doc.transact_mut(); - let bytes: &[u8] = update.extract()?; - let u = Update::decode_v1(&bytes).unwrap(); - if let Ok(_) = txn.apply_update(u) { - return Ok(()); - } else { - return Err(PyRuntimeError::new_err("Cannot apply update")); - } + fn apply_update(&mut self, txn: &mut Transaction, update: &Bound<'_, PyBytes>) -> PyResult<()> { + let u = Update::decode_v1(update.as_bytes()).unwrap(); + let mut _t = txn.transaction(); + let t = _t.as_mut().unwrap().as_mut(); + t.apply_update(u) + .map_err(|e| PyRuntimeError::new_err(format!("Cannot apply update: {}", e))) } fn roots(&self, py: Python<'_>, txn: &mut Transaction) -> PyObject { diff --git a/src/lib.rs b/src/lib.rs index 2cb2fc4..d3187c8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,8 @@ use pyo3::prelude::*; +use xml::XmlElement; +use xml::XmlEvent; +use xml::XmlFragment; +use xml::XmlText; mod doc; mod text; mod array; @@ -8,6 +12,7 @@ mod subscription; mod type_conversions; mod undo; mod update; +mod xml; use crate::doc::Doc; use crate::doc::TransactionEvent; use crate::doc::SubdocsEvent; @@ -34,6 +39,10 @@ fn _pycrdt(_py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; m.add_function(wrap_pyfunction!(get_state, m)?)?; m.add_function(wrap_pyfunction!(get_update, m)?)?; m.add_function(wrap_pyfunction!(merge_updates, m)?)?; diff --git a/src/text.rs b/src/text.rs index 7970ca0..5bc3f1b 100644 --- a/src/text.rs +++ b/src/text.rs @@ -1,5 +1,5 @@ use pyo3::prelude::*; -use pyo3::types::{PyList, PyString}; +use pyo3::types::{PyDict, PyIterator, PyList, PyString, PyTuple}; use yrs::{ GetString, Observable, @@ -7,10 +7,10 @@ use yrs::{ Text as _Text, TransactionMut, }; -use yrs::types::text::TextEvent as _TextEvent; +use yrs::types::text::{TextEvent as _TextEvent, YChange}; use crate::transaction::Transaction; use crate::subscription::Subscription; -use crate::type_conversions::ToPython; +use crate::type_conversions::{py_to_any, py_to_attrs, ToPython}; #[pyclass] @@ -36,10 +36,38 @@ impl Text { Ok(len) } - fn insert(&self, txn: &mut Transaction, index: u32, chunk: &str) -> PyResult<()> { + #[pyo3(signature = (txn, index, chunk, attrs=None))] + fn insert(&self, txn: &mut Transaction, index: u32, chunk: &str, attrs: Option>) -> PyResult<()> { let mut _t = txn.transaction(); let mut t = _t.as_mut().unwrap().as_mut(); - self.text.insert(&mut t, index, chunk); + if let Some(attrs) = attrs { + let attrs = py_to_attrs(attrs)?; + self.text.insert_with_attributes(&mut t, index, chunk, attrs); + } else { + self.text.insert(&mut t, index, chunk); + } + Ok(()) + } + + #[pyo3(signature = (txn, index, embed, attrs=None))] + fn insert_embed(&self, txn: &mut Transaction, index: u32, embed: Bound<'_, PyAny>, attrs: Option>) -> PyResult<()> { + let embed = py_to_any(&embed); + let mut _t = txn.transaction(); + let mut t = _t.as_mut().unwrap().as_mut(); + if let Some(attrs) = attrs { + let attrs = py_to_attrs(attrs)?; + self.text.insert_embed_with_attributes(&mut t, index, embed, attrs); + } else { + self.text.insert_embed(&mut t, index, embed); + } + Ok(()) + } + + fn format(&self, txn: &mut Transaction, index: u32, len: u32, attrs: Bound<'_, PyIterator>) -> PyResult<()> { + let mut _t = txn.transaction(); + let mut t = _t.as_mut().unwrap().as_mut(); + let attrs = py_to_attrs(attrs)?; + self.text.format(&mut t, index, len, attrs); Ok(()) } @@ -58,6 +86,37 @@ impl Text { Python::with_gil(|py| PyString::new_bound(py, &s).into()) } + fn diff<'py>(&self, py: Python<'py>, txn: &mut Transaction) -> Bound<'py, PyList> { + let mut t0 = txn.transaction(); + let t1 = t0.as_mut().unwrap(); + let t = t1.as_ref(); + + let iter = self.text.diff(t, YChange::identity) + .into_iter() + .map(|diff| { + let attrs = diff.attributes.map(|attrs| { + let pyattrs = PyDict::new_bound(py); + for (name, value) in attrs.into_iter() { + pyattrs.set_item( + PyString::intern_bound(py, &*name), + value.into_py(py), + ).unwrap(); + } + pyattrs.into_any().unbind() + }).unwrap_or_else(|| py.None()); + + PyTuple::new_bound(py, [ + diff.insert.into_py(py), + attrs, + ]) + }); + + PyList::new_bound( + py, + iter + ) + } + fn observe(&mut self, py: Python<'_>, f: PyObject) -> PyResult> { let sub = self.text.observe(move |txn, e| { Python::with_gil(|py| { diff --git a/src/type_conversions.rs b/src/type_conversions.rs index 12ee4f9..bd0a9b9 100644 --- a/src/type_conversions.rs +++ b/src/type_conversions.rs @@ -1,14 +1,15 @@ use pyo3::prelude::*; -use pyo3::types::{IntoPyDict, PyAny, PyBool, PyByteArray, PyDict, PyFloat, PyList, PyLong, PyString, PyBytes}; +use pyo3::types::{IntoPyDict, PyAny, PyBool, PyByteArray, PyBytes, PyDict, PyFloat, PyIterator, PyList, PyLong, PyString}; use yrs::types::{Attrs, Change, EntryChange, Delta, Events, Path, PathSegment}; -use yrs::{Any, Out, TransactionMut}; -use std::ops::Deref; +use yrs::{Any, Out, TransactionMut, XmlOut}; use std::collections::{VecDeque, HashMap}; +use std::sync::Arc; use crate::text::{Text, TextEvent}; use crate::array::{Array, ArrayEvent}; use crate::map::{Map, MapEvent}; use crate::doc::Doc; use crate::undo::StackItem; +use crate::xml::{XmlElement, XmlEvent, XmlFragment, XmlText}; pub trait ToPython { fn into_py(self, py: Python) -> PyObject; @@ -77,7 +78,7 @@ impl ToPython for Delta { result.set_item("insert", value).unwrap(); if let Some(attrs) = attrs { - let attrs = attrs_into_py(attrs.deref()); + let attrs = (&*attrs).into_py(py); result.set_item("attributes", attrs).unwrap(); } } @@ -85,7 +86,7 @@ impl ToPython for Delta { result.set_item("retain", len).unwrap(); if let Some(attrs) = attrs { - let attrs = attrs_into_py(attrs.deref()); + let attrs = (&*attrs).into_py(py); result.set_item("attributes", attrs).unwrap(); } } @@ -97,7 +98,7 @@ impl ToPython for Delta { } } -impl ToPython for Out{ +impl ToPython for Out { fn into_py(self, py: Python) -> pyo3::PyObject { match self { Out::Any(v) => v.into_py(py), @@ -105,23 +106,49 @@ impl ToPython for Out{ Out::YArray(v) => Array::from(v).into_py(py), Out::YMap(v) => Map::from(v).into_py(py), Out::YDoc(v) => Doc::from(v).into_py(py), - _ => pyo3::IntoPy::into_py(py.None(), py), - //Out::YXmlElement(v) => YXmlElement::from(v).into_py(py), - //Out::YXmlText(v) => YXmlText::from(v).into_py(py), + Out::YXmlElement(v) => XmlElement::from(v).into_py(py), + Out::YXmlText(v) => XmlText::from(v).into_py(py), + Out::YXmlFragment(v) => XmlFragment::from(v).into_py(py), + Out::UndefinedRef(_) => pyo3::IntoPy::into_py(py.None(), py), } } } -fn attrs_into_py(attrs: &Attrs) -> PyObject { - Python::with_gil(|py| { +impl ToPython for XmlOut { + fn into_py(self, py: Python) -> PyObject { + match self { + XmlOut::Element(xml_element_ref) => Py::new(py, XmlElement::from(xml_element_ref)) + .unwrap() + .into_any(), + XmlOut::Fragment(xml_fragment_ref) => Py::new(py, XmlFragment::from(xml_fragment_ref)) + .unwrap() + .into_any(), + XmlOut::Text(xml_text_ref) => { + Py::new(py, XmlText::from(xml_text_ref)).unwrap().into_any() + } + } + } +} + +impl ToPython for Option +where + T: ToPython, +{ + fn into_py(self, py: Python) -> PyObject { + self.map(|v| v.into_py(py)).unwrap_or_else(|| py.None()) + } +} + +impl ToPython for &'_ Attrs { + fn into_py(self, py: Python) -> PyObject { let o = PyDict::new_bound(py); - for (key, value) in attrs.iter() { + for (key, value) in self.iter() { let key = key.as_ref(); let value = Out::Any(value.clone()).into_py(py); o.set_item(key, value).unwrap(); } o.into() - }) + } } impl ToPython for &Change { @@ -255,10 +282,21 @@ pub(crate) fn events_into_py(txn: &TransactionMut, events: &Events) -> PyObject yrs::types::Event::Text(e_txt) => TextEvent::new(e_txt, txn).into_py(py), yrs::types::Event::Array(e_arr) => ArrayEvent::new(e_arr, txn).into_py(py), yrs::types::Event::Map(e_map) => MapEvent::new(e_map, txn).into_py(py), - //yrs::types::Event::XmlElement(e_xml) => YXmlEvent::new(e_xml, txn).into_py(py), - //yrs::types::Event::XmlText(e_xml) => YXmlTextEvent::new(e_xml, txn).into_py(py), - _ => py.None(), + yrs::types::Event::XmlFragment(e_xml) => unsafe { XmlEvent::from_xml_event(e_xml, txn, py) }.into_py(py), + yrs::types::Event::XmlText(e_xml) => unsafe { XmlEvent::from_xml_text_event(e_xml, txn, py) }.into_py(py), }); PyList::new_bound(py, py_events).into() }) } + +/// Converts an iterator of k,v tuples to an [`Attrs`] map +pub(crate) fn py_to_attrs<'py>( + pyobj: Bound<'py, PyIterator>, +) -> PyResult { + pyobj.map(|res| res.and_then(|item| { + let key = item.get_item(0)?.extract::>()?; + let value = item.get_item(1).map(|v| py_to_any(&v))?; + Ok((Arc::from(key.to_str()?), value)) + })).collect::>() +} + diff --git a/src/xml.rs b/src/xml.rs new file mode 100644 index 0000000..b01753e --- /dev/null +++ b/src/xml.rs @@ -0,0 +1,380 @@ + +use pyo3::types::{PyAnyMethods, PyDict, PyIterator, PyList, PyString, PyTuple}; +use pyo3::{pyclass, pymethods, Bound, IntoPy as _, PyAny, PyObject, PyResult, Python}; +use yrs::types::text::YChange; +use yrs::types::xml::{XmlEvent as _XmlEvent, XmlTextEvent as _XmlTextEvent}; +use yrs::{ + DeepObservable, GetString as _, Observable as _, Text as _, TransactionMut, Xml as _, XmlElementPrelim, XmlElementRef, XmlFragment as _, XmlFragmentRef, XmlOut, XmlTextPrelim, XmlTextRef +}; + +use crate::subscription::Subscription; +use crate::type_conversions::{events_into_py, py_to_any, py_to_attrs, EntryChangeWrapper}; +use crate::{transaction::Transaction, type_conversions::ToPython}; + +/// Implements methods common to `XmlFragment`, `XmlElement`, and `XmlText`. +macro_rules! impl_xml_methods { + ( + $typ:ident[ + $inner:ident + // For `XmlFragment` and `XmlElement`, implements methods from `yrs::types::xml::XmlFragment` + $(, fragment: $finner:ident)? + // For `XmlElement` and `XmlText`, implements methods from `yrs::types::xml::Xml` + $(, xml: $xinner:ident)? + ] { + // Methods specific to the type + $($extra:tt)* + } + ) => { + #[pymethods] + impl $typ { + fn parent(&self, py: Python<'_>) -> PyObject { + self.$inner.parent().into_py(py) + } + + fn get_string(&self, txn: &mut Transaction) -> String { + let mut t0 = txn.transaction(); + let t1 = t0.as_mut().unwrap(); + let t = t1.as_ref(); + self.$inner.get_string(t) + } + + fn len(&self, txn: &mut Transaction) -> u32 { + let mut t0 = txn.transaction(); + let t1 = t0.as_mut().unwrap(); + let t = t1.as_ref(); + self.$inner.len(t) + } + + $( + fn get(&self, py: Python<'_>, txn: &mut Transaction, index: u32) -> PyObject { + let mut t0 = txn.transaction(); + let t1 = t0.as_mut().unwrap(); + let t = t1.as_ref(); + self.$finner.get(t, index).into_py(py) + } + + fn remove_range(&self, txn: &mut Transaction, index: u32, len: u32) { + let mut _t = txn.transaction(); + let mut t = _t.as_mut().unwrap().as_mut(); + self.$finner.remove_range(&mut t, index, len); + } + + fn insert_str(&self, txn: &mut Transaction, index: u32, text: &str) -> XmlText { + let mut _t = txn.transaction(); + let mut t = _t.as_mut().unwrap().as_mut(); + self.$finner.insert(&mut t, index, XmlTextPrelim::new(text)).into() + } + + fn insert_element_prelim(&self, txn: &mut Transaction, index: u32, tag: &str) -> XmlElement { + let mut _t = txn.transaction(); + let mut t = _t.as_mut().unwrap().as_mut(); + self.$finner.insert(&mut t, index, XmlElementPrelim::empty(tag)).into() + } + )? + + $( + fn attributes(&self, txn: &mut Transaction) -> Vec<(String, String)> { + let mut t0 = txn.transaction(); + let t1 = t0.as_mut().unwrap(); + let t = t1.as_ref(); + self.$xinner.attributes(t).map(|(k,v)| (String::from(k), v)).collect() + } + + fn attribute(&self, txn: &mut Transaction, name: &str) -> Option { + let mut t0 = txn.transaction(); + let t1 = t0.as_mut().unwrap(); + let t = t1.as_ref(); + self.$xinner.get_attribute(t, name) + } + + fn insert_attribute(&self, txn: &mut Transaction, name: &str, value: &str) { + let mut _t = txn.transaction(); + let mut t = _t.as_mut().unwrap().as_mut(); + self.$xinner.insert_attribute(&mut t, name, value); + } + + fn remove_attribute(&self, txn: &mut Transaction, name: &str) { + let mut _t = txn.transaction(); + let mut t = _t.as_mut().unwrap().as_mut(); + self.$xinner.remove_attribute(&mut t, &name); + } + + fn siblings(&self, py: Python<'_>, txn: &mut Transaction) -> Vec { + let mut t0 = txn.transaction(); + let t1 = t0.as_mut().unwrap(); + let t = t1.as_ref(); + self.$xinner.siblings(t).map(|node| node.into_py(py)).collect() + } + )? + + $($extra)* + } + + impl std::hash::Hash for $typ { + fn hash(&self, state: &mut H) { + let branch: &yrs::branch::Branch = self.$inner.as_ref(); + branch.id().hash(state) + } + } + }; +} + +#[pyclass(eq, frozen, hash)] +#[derive(PartialEq, Eq)] +pub struct XmlFragment { + pub fragment: XmlFragmentRef, +} + +impl From for XmlFragment { + fn from(value: XmlFragmentRef) -> Self { + XmlFragment { fragment: value } + } +} + +impl_xml_methods!(XmlFragment[fragment, fragment: fragment] { + fn observe(&self, f: PyObject) -> Subscription { + self.fragment.observe(move |txn, e| { + Python::with_gil(|py| { + let e = unsafe { XmlEvent::from_xml_event(e, txn, py) }; + if let Err(err) = f.call1(py, (e,)) { + err.restore(py) + } + }); + }).into() + } + + fn observe_deep(&self, f: PyObject) -> Subscription { + self.fragment.observe_deep(move |txn, events| { + Python::with_gil(|py| { + let events = events_into_py(txn, events); + if let Err(err) = f.call1(py, (events,)) { + err.restore(py); + } + }) + }).into() + } +}); + +#[pyclass(eq, frozen, hash)] +#[derive(PartialEq, Eq)] +pub struct XmlElement { + pub element: XmlElementRef, +} + +impl From for XmlElement { + fn from(value: XmlElementRef) -> Self { + XmlElement { element: value } + } +} + +impl_xml_methods!(XmlElement[element, fragment: element, xml: element] { + fn tag(&self) -> Option { + self.element.try_tag().map(|s| String::from(&**s)) + } + + fn observe(&self, f: PyObject) -> Subscription { + self.element.observe(move |txn, e| { + Python::with_gil(|py| { + let e = unsafe { XmlEvent::from_xml_event(e, txn, py) }; + if let Err(err) = f.call1(py, (e,)) { + err.restore(py) + } + }); + }).into() + } + + fn observe_deep(&self, f: PyObject) -> Subscription { + self.element.observe_deep(move |txn, events| { + Python::with_gil(|py| { + let events = events_into_py(txn, events); + if let Err(err) = f.call1(py, (events,)) { + err.restore(py); + } + }) + }).into() + } +}); + +#[pyclass(eq, frozen, hash)] +#[derive(PartialEq, Eq)] +pub struct XmlText { + pub text: XmlTextRef, +} + +impl From for XmlText { + fn from(value: XmlTextRef) -> Self { + XmlText { text: value } + } +} + +impl_xml_methods!(XmlText[text, xml: text] { + #[pyo3(signature = (txn, index, text, attrs=None))] + fn insert(&self, txn: &mut Transaction, index: u32, text: &str, attrs: Option>) -> PyResult<()> { + let mut _t = txn.transaction(); + let mut t = _t.as_mut().unwrap().as_mut(); + if let Some(attrs) = attrs { + let attrs = py_to_attrs(attrs)?; + self.text.insert_with_attributes(&mut t, index, text, attrs); + } else { + self.text.insert(&mut t, index, text); + } + Ok(()) + } + + #[pyo3(signature = (txn, index, embed, attrs=None))] + fn insert_embed<'py>(&self, txn: &mut Transaction, index: u32, embed: Bound<'py, PyAny>, attrs: Option>) -> PyResult<()> { + let embed = py_to_any(&embed); + let mut _t = txn.transaction(); + let mut t = _t.as_mut().unwrap().as_mut(); + if let Some(attrs) = attrs { + let attrs = py_to_attrs(attrs)?; + self.text.insert_embed_with_attributes(&mut t, index, embed, attrs); + } else { + self.text.insert_embed(&mut t, index, embed); + } + Ok(()) + } + + fn remove_range(&self, txn: &mut Transaction, index: u32, len: u32) { + let mut _t = txn.transaction(); + let mut t = _t.as_mut().unwrap().as_mut(); + self.text.remove_range(&mut t, index, len); + } + + fn format(&self, txn: &mut Transaction, index: u32, len: u32, attrs: Bound<'_, PyIterator>) -> PyResult<()> { + let attrs = py_to_attrs(attrs)?; + let mut _t = txn.transaction(); + let mut t = _t.as_mut().unwrap().as_mut(); + self.text.format(&mut t, index, len, attrs); + Ok(()) + } + + fn diff<'py>(&self, py: Python<'py>, txn: &mut Transaction) -> Bound<'py, PyList> { + let mut t0 = txn.transaction(); + let t1 = t0.as_mut().unwrap(); + let t = t1.as_ref(); + + let iter = self.text.diff(t, YChange::identity) + .into_iter() + .map(|diff| { + let attrs = diff.attributes.map(|attrs| { + let pyattrs = PyDict::new_bound(py); + for (name, value) in attrs.into_iter() { + pyattrs.set_item( + PyString::intern_bound(py, &*name), + value.into_py(py), + ).unwrap(); + } + pyattrs.into_any().unbind() + }).unwrap_or_else(|| py.None()); + + PyTuple::new_bound(py, [ + diff.insert.into_py(py), + attrs, + ]) + }); + + PyList::new_bound( + py, + iter + ) + } + + fn observe(&self, f: PyObject) -> Subscription { + self.text.observe(move |txn, e| { + Python::with_gil(|py| { + let e = unsafe { XmlEvent::from_xml_text_event(e, txn, py) }; + if let Err(err) = f.call1(py, (e,)) { + err.restore(py) + } + }); + }).into() + } + + fn observe_deep(&self, f: PyObject) -> Subscription { + self.observe(f) + } +}); + + + +#[pyclass(unsendable)] +pub struct XmlEvent { + txn: *const TransactionMut<'static>, + transaction: Option, + #[pyo3(get)] + children_changed: PyObject, + #[pyo3(get)] + target: PyObject, + #[pyo3(get)] + path: PyObject, + #[pyo3(get)] + delta: PyObject, + #[pyo3(get)] + keys: PyObject, +} + +impl XmlEvent { + pub unsafe fn from_xml_event(event: &_XmlEvent, txn: &TransactionMut, py: Python<'_>) -> Self { + Self { + txn: unsafe { std::mem::transmute::<&TransactionMut, &TransactionMut<'static>>(txn) }, + transaction: None, + children_changed: event.children_changed().into_py(py), + target: event.target().clone().into_py(py), + path: event.path().clone().into_py(py), + delta: PyList::new_bound( + py, + event.delta(txn).into_iter().map(|d| d.into_py(py)), + ) + .into(), + keys: { + let dict = PyDict::new_bound(py); + for (key, value) in event.keys(txn).iter() { + dict.set_item(&**key, EntryChangeWrapper(value).into_py(py)) + .unwrap(); + } + dict.into() + }, + } + } + + pub unsafe fn from_xml_text_event(event: &_XmlTextEvent, txn: &TransactionMut, py: Python<'_>) -> Self { + Self { + txn: unsafe { std::mem::transmute::<&TransactionMut, &TransactionMut<'static>>(txn) }, + transaction: None, + target: XmlOut::Text(event.target().clone()).into_py(py), + path: event.path().clone().into_py(py), + delta: PyList::new_bound( + py, + event.delta(txn).into_iter().map(|d| d.clone().into_py(py)), + ) + .into(), + keys: { + let dict = PyDict::new_bound(py); + for (key, value) in event.keys(txn).iter() { + dict.set_item(&**key, EntryChangeWrapper(value).into_py(py)) + .unwrap(); + } + dict.into() + }, + children_changed: py.None(), + } + } +} + +#[pymethods] +impl XmlEvent { + #[getter] + fn transaction(&mut self, py: Python<'_>) -> PyObject { + self.transaction + .get_or_insert_with(|| Transaction::from(unsafe { &*self.txn }).into_py(py)) + .clone_ref(py) + } + + fn __repr__(&mut self) -> String { + format!( + "XmlEvent(children_changed={}, target={}, path={}, delta={}, keys={})", + self.children_changed, self.target, self.path, self.delta, self.keys, + ) + } +} diff --git a/tests/test_text.py b/tests/test_text.py index 7d866ac..c55a9c8 100644 --- a/tests/test_text.py +++ b/tests/test_text.py @@ -114,6 +114,28 @@ def test_slice(): assert str(excinfo.value) == "Negative stop not supported" +def test_formatting(): + doc = Doc() + doc["text"] = text = Text("") + + text.insert(0, "hello ") + assert len(text) == len("hello "), str(text) + text.insert(len(text), "world", {"bold": True}) + text.insert(len(text), "! I have formatting!", {}) + text.format(len("hello world! "), len("hello world! I have formatting!") + 1, {"font-size": 32}) + text.insert_embed(len(text), b"png blob", {"type": "image"}) + + diff = text.diff() + + assert diff == [ + ("hello ", None), + ("world", {"bold": True}), + ("! ", None), + ("I have formatting!", {"font-size": 32}), + (bytearray(b"png blob"), {"type": "image"}), + ] + + def test_observe(): doc = Doc() doc["text"] = text = Text() diff --git a/tests/test_update.py b/tests/test_update.py index a892b2e..31dc22d 100644 --- a/tests/test_update.py +++ b/tests/test_update.py @@ -1,4 +1,4 @@ -from pycrdt import Doc, Map, get_state, get_update, merge_updates +from pycrdt import Doc, Map, Text, get_state, get_update, merge_updates def test_update(): @@ -34,3 +34,22 @@ def test_update(): doc1.apply_update(update1) assert data0.to_py() == data1.to_py() == {"key0": "val0", "key1": "val1"} + + +def test_update_transaction(): + doc0 = Doc() + text0 = doc0.get("test", type=Text) + text0 += "Hello" + + update0 = doc0.get_update() + + text0 += " World!" + update1 = doc0.get_update() + del doc0 + + doc1 = Doc() + with doc1.transaction(): + doc1.apply_update(update0) + doc1.apply_update(update1) + + assert str(doc1.get("test", type=Text)) == "Hello World!" diff --git a/tests/test_xml.py b/tests/test_xml.py new file mode 100644 index 0000000..77392c5 --- /dev/null +++ b/tests/test_xml.py @@ -0,0 +1,277 @@ +import pytest +from pycrdt import Doc, XmlElement, XmlFragment, XmlText + + +def test_plain_text(): + doc1 = Doc() + frag = XmlFragment() + doc1["test"] = frag + with doc1.transaction(): + frag.children.append("Hello") + with doc1.transaction(): + frag.children.append(", World") + frag.children.append("!") + + assert str(frag) == "Hello, World!" + + +def test_api(): + doc = Doc() + frag = XmlFragment( + [ + XmlText("Hello "), + XmlElement("em", {"class": "bold"}, [XmlText("World")]), + XmlText("!"), + ] + ) + + with pytest.raises(RuntimeError) as excinfo: + frag.integrated + assert str(excinfo.value) == "Not integrated in a document yet" + + with pytest.raises(RuntimeError) as excinfo: + frag.doc + assert str(excinfo.value) == "Not integrated in a document yet" + + with pytest.raises(ValueError): + frag.to_py() + + doc["test"] = frag + assert frag.parent is None + assert str(frag) == 'Hello World!' + assert len(frag.children) == 3 + assert str(frag.children[0]) == "Hello " + assert str(frag.children[1]) == 'World' + assert str(frag.children[2]) == "!" + assert list(frag.children) == [frag.children[0], frag.children[1], frag.children[2]] + assert frag.children[0].parent == frag + assert hash(frag.children[0].parent) == hash(frag) + assert frag != object() + + frag.children.insert(1, XmlElement("strong", None, ["wonderful"])) + frag.children.insert(2, " ") + assert str(frag) == 'Hello wonderful World!' + assert len(frag.children) == 5 + + el = frag.children[3] + assert el.tag == "em" + assert len(el.attributes) == 1 + assert el.attributes.get("class") == "bold" + assert el.attributes["class"] == "bold" + assert "class" in el.attributes + assert el.attributes.get("non-existent") is None + assert "non-existent" not in el.attributes + with pytest.raises(KeyError): + el.attributes["non-existent"] + assert list(el.attributes) == [("class", "bold")] + + del frag.children[2] + del frag.children[1] + assert str(frag) == 'Hello World!' + + +def test_text(): + text = XmlText("Hello") + assert text.to_py() == "Hello" + + doc = Doc() + + with pytest.raises(ValueError): + doc["test"] = XmlText("test") + + doc["test"] = XmlFragment([text]) + + assert str(text) == "Hello" + assert text.to_py() == "Hello" + assert len(text) == len("Hello") + + text.clear() + assert str(text) == "" + + text += "Goodbye" + assert str(text) == "Goodbye" + + text.insert(1, " ") + assert str(text) == "G oodbye" + del text[1] + assert str(text) == "Goodbye" + + text.insert(1, " ") + del text[1:3] + assert str(text) == "Goodbye" + + assert text.diff() == [("Goodbye", None)] + text.format(1, 3, {"bold": True}) + assert text.diff() == [ + ("G", None), + ("oo", {"bold": True}), + ("dbye", None), + ] + + text.insert_embed(0, b"PNG!", {"type": "image"}) + assert text.diff() == [ + (b"PNG!", {"type": "image"}), + ("G", None), + ("oo", {"bold": True}), + ("dbye", None), + ] + + text.insert(len(text), " World!", {"href": "some-url"}) + assert text.diff() == [ + (b"PNG!", {"type": "image"}), + ("G", None), + ("oo", {"bold": True}), + ("dbye", None), + (" World!", {"href": "some-url"}), + ] + + del text[0] + assert text.diff() == [ + ("G", None), + ("oo", {"bold": True}), + ("dbye", None), + (" World!", {"href": "some-url"}), + ] + + del text[0:3] + assert text.diff() == [ + ("dbye", None), + (" World!", {"href": "some-url"}), + ] + + with pytest.raises(RuntimeError): + del text[0:5:2] + with pytest.raises(RuntimeError): + del text[-1:5] + with pytest.raises(RuntimeError): + del text[1:-1] + with pytest.raises(TypeError): + del text["invalid"] + + doc["test2"] = XmlFragment([XmlText()]) + + +def test_element(): + doc = Doc() + + with pytest.raises(ValueError): + doc["test"] = XmlElement("test") + + with pytest.raises(ValueError): + XmlElement() + + doc["test"] = frag = XmlFragment() + + el = XmlElement("div", {"class": "test"}) + frag.children.append(el) + assert str(el) == '
' + + el = XmlElement("div", [("class", "test")]) + frag.children.append(el) + assert str(el) == '
' + + el = XmlElement("div", None, [XmlText("Test")]) + frag.children.append(el) + assert str(el) == "
Test
" + + el = XmlElement("div") + frag.children.append(el) + assert str(el) == "
" + + with pytest.raises(ValueError): + el.to_py() + + el.attributes["class"] = "test" + assert str(el) == '
' + assert "class" in el.attributes + assert el.attributes["class"] == "test" + assert el.attributes.get("class") == "test" + assert len(el.attributes) == 1 + assert list(el.attributes) == [("class", "test")] + + del el.attributes["class"] + assert str(el) == "
" + assert "class" not in el.attributes + assert el.attributes.get("class") is None + assert len(el.attributes) == 0 + assert list(el.attributes) == [] + + node = XmlText("Hello") + el.children.append(node) + assert str(el) == "
Hello
" + assert len(el.children) == 1 + assert str(el.children[0]) == "Hello" + assert list(el.children) == [node] + + el.children[0] = XmlText("Goodbye") + assert str(el) == "
Goodbye
" + + del el.children[0] + assert str(el) == "
" + + el.children.append(XmlElement("foo")) + el.children.append(XmlElement("bar")) + el.children.append(XmlElement("baz")) + assert str(el) == "
" + + del el.children[0:2] + assert str(el) == "
" + + with pytest.raises(TypeError): + del el.children["invalid"] + with pytest.raises(IndexError): + el.children[1] + + text = XmlText("foo") + el.children.insert(0, text) + assert str(el) == "
foo
" + + el2 = XmlElement("bar") + el.children.insert(1, el2) + assert str(el) == "
foo
" + + with pytest.raises(IndexError): + el.children.insert(10, "test") + with pytest.raises(ValueError): + el.children.append(text) + with pytest.raises(ValueError): + el.children.append(el2) + with pytest.raises(TypeError): + el.children.append(object()) + + +def test_observe(): + doc = Doc() + doc["test"] = fragment = XmlFragment(["Hello world!"]) + events = [] + + def callback(event): + nonlocal fragment + with pytest.raises(RuntimeError) as excinfo: + fragment.children.append("text") + assert ( + str(excinfo.value) + == "Read-only transaction cannot be used to modify document structure" + ) + events.append(event) + + sub = fragment.observe_deep(callback) # noqa: F841 + + fragment.children.append(XmlElement("em", None, ["This is a test"])) + assert len(events) == 1 + assert len(events[0]) == 1 + assert events[0][0].children_changed is True + assert str(events[0][0].target) == "Hello world!This is a test" + assert events[0][0].path == [] + assert len(events[0][0].delta) == 2 + assert events[0][0].delta[0]["retain"] == 1 + assert str(events[0][0].delta[1]["insert"][0]) == "This is a test" + + events.clear() + fragment.children[0].format(1, 3, {"bold": True}) + + assert len(events) == 1 + assert len(events[0]) == 1 + assert str(events[0][0].target) == "Hello world!" + assert events[0][0].delta[0] == {"retain": 1} + assert events[0][0].delta[1] == {"retain": 2, "attributes": {"bold": True}}