diff --git a/python/pycrdt/__init__.py b/python/pycrdt/__init__.py index 94f2bf8..858cddf 100644 --- a/python/pycrdt/__init__.py +++ b/python/pycrdt/__init__.py @@ -15,6 +15,10 @@ from ._sync import write_var_uint as write_var_uint from ._text import Text as Text from ._text import TextEvent as TextEvent +from ._xml import XmlElement as XmlElement +from ._xml import XmlFragment as XmlFragment +from ._xml import XmlText as XmlText +from ._xml import XmlEvent as XmlEvent from ._transaction import ReadTransaction as ReadTransaction from ._transaction import Transaction as Transaction from ._undo import UndoManager as UndoManager diff --git a/python/pycrdt/_pycrdt.pyi b/python/pycrdt/_pycrdt.pyi index 80ef5f4..7d28a64 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,6 +28,9 @@ 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.""" @@ -91,6 +94,10 @@ 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.""" @@ -180,6 +187,123 @@ 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 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/_xml.py b/python/pycrdt/_xml.py new file mode 100644 index 0000000..13cbfaf --- /dev/null +++ b/python/pycrdt/_xml.py @@ -0,0 +1,354 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Iterator, overload + +from ._base import BaseEvent, BaseType, base_types, event_types +from ._pycrdt import XmlFragment as _XmlFragment +from ._pycrdt import XmlElement as _XmlElement +from ._pycrdt import XmlText as _XmlText +from ._pycrdt import XmlEvent as _XmlEvent + +if TYPE_CHECKING: # pragma: no cover + from ._doc import Doc + from typing import Mapping, Any, Iterable, Sized, TypeVar + 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: + 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) + +class _XmlFragmentTraitMixin(_XmlBaseMixin): + _integrated: _XmlElement | _XmlFragment | None + + @property + def children(self) -> XmlChildrenView: + return XmlChildrenView(self) + +class _XmlTraitMixin(_XmlBaseMixin): + _integrated: _XmlElement | _XmlText | None + + @property + def attributes(self) -> XmlAttributesView: + 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: + if tag is None and attributes is None and contents is None: + init = None + elif (attributes is not None or contents is not None) and tag is None: + raise ValueError("Tag is required if specifying attributes or contents") + else: + if isinstance(attributes, dict): + init_attrs = list(attributes.items()) + elif attributes is not None: + init_attrs = list(attributes) + else: + init_attrs = [] + + init = ( + tag, + init_attrs, + list(contents) if contents is not None else [], + ) + + super().__init__( + init=init, + _doc=_doc, + _integrated=_integrated, + ) + + 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): + if value is None: + return + _, 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: + return self.integrated.tag() + + + +class XmlText(_XmlTraitMixin): + _prelim: str | None + _integrated: _XmlText | None + + def __init__( + self, + init: str | None = None, + *, + _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 | None: + if self._integrated is None: + return self._prelim + return str(self) + + def _init(self, value: str | None) -> None: + if value is None: + return + 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, attrs.items() if attrs is not None else iter([])) + + def format(self, section: slice, attrs: Mapping[str, Any]): + """ + Formats a slice of text, setting attributes + """ + start, stop = _check_slice(self, section) + length = stop - start + if length > 0: + with self.doc.transaction() as txn: + self.integrated.format(txn._txn, start, length, iter(attrs)) + + 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 RuntimeError(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: + inner: _XmlTraitMixin + + def __init__(self, inner: _XmlTraitMixin) -> None: + self.inner = inner + + def get(self, key: str) -> str | None: + 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) -> str: + v = self.get(key) + if v is None: + raise KeyError(key) + return v + + def __setitem__(self, key: str, value: str) -> None: + 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: + with self.inner.doc.transaction() as txn: + self.inner._forbid_read_transaction(txn) + self.inner.integrated.remove_attribute(txn._txn, key) + + def __iter__(self) -> Iterable[tuple[str,str]]: + with self.inner.doc.transaction() as txn: + return iter(self.inner.integrated.attributes(txn)) + + + +class XmlChildrenView: + inner: _XmlFragmentTraitMixin + + def __init__(self, inner: _XmlFragmentTraitMixin) -> None: + self.inner = inner + + def __len__(self) -> int: + with self.inner.doc.transaction() as txn: + return self.inner.integrated.len(txn._txn) + + def __getitem__(self, index: int) -> XmlElement | XmlFragment | XmlText: + with self.inner.doc.transaction() as txn: + return _integrated_to_wrapper(self.inner.doc, self.inner.integrated.get(txn._txn, index)) + + def __delitem__(self, key: int | slice) -> None: + 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 RuntimeError(f"Index not supported: {key}") + + def __setitem__(self, key: int, value: str | XmlText | XmlElement): + with self.inner.doc.transaction(): + del self[key] + self.insert(key, value) + + def __iter__(self) -> Iterator[XmlText | XmlElement | XmlFragment]: + with self.inner.doc.transaction(): + children = [self[i] for i in 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: + with self.inner.doc.transaction() as txn: + self.inner._forbid_read_transaction(txn) + 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 ValueError("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: + 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..ae5c0ab 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))?; 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/type_conversions.rs b/src/type_conversions.rs index 12ee4f9..3510dc1 100644 --- a/src/type_conversions.rs +++ b/src/type_conversions.rs @@ -1,7 +1,7 @@ use pyo3::prelude::*; use pyo3::types::{IntoPyDict, PyAny, PyBool, PyByteArray, PyDict, PyFloat, PyList, PyLong, PyString, PyBytes}; use yrs::types::{Attrs, Change, EntryChange, Delta, Events, Path, PathSegment}; -use yrs::{Any, Out, TransactionMut}; +use yrs::{Any, Out, TransactionMut, XmlOut}; use std::ops::Deref; use std::collections::{VecDeque, HashMap}; use crate::text::{Text, TextEvent}; @@ -9,6 +9,7 @@ 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; @@ -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,13 +106,39 @@ 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), } } } +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()) + } +} + fn attrs_into_py(attrs: &Attrs) -> PyObject { Python::with_gil(|py| { let o = PyDict::new_bound(py); @@ -255,9 +282,8 @@ 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() }) diff --git a/src/xml.rs b/src/xml.rs new file mode 100644 index 0000000..1344b7b --- /dev/null +++ b/src/xml.rs @@ -0,0 +1,337 @@ + +use std::sync::Arc; + +use pyo3::types::{PyAnyMethods, PyDict, PyIterator, PyList, PyString, PyStringMethods}; +use pyo3::{pyclass, pymethods, Bound, IntoPy as _, PyObject, PyResult, Python}; +use yrs::types::xml::{XmlEvent as _XmlEvent, XmlTextEvent as _XmlTextEvent}; +use yrs::types::Attrs; +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, 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)? + ] { + $($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.$finner.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)* + } + }; +} + +#[pyclass] +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] +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] +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(()) + } + + 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 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.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() + }, + } + } + + 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, + ) + } +} + +/// Converts an iterator of k,v tuples to an [`Attrs`] map +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/tests/test_xml.py b/tests/test_xml.py new file mode 100644 index 0000000..8d49d10 --- /dev/null +++ b/tests/test_xml.py @@ -0,0 +1,70 @@ +import pytest +from pycrdt import Doc, XmlFragment, XmlText, XmlElement + +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" + + doc["test"] = frag + 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]) == "!" + + frag.children.insert(1, XmlElement("strong", None, ["wonderful"])) + frag.children.insert(2, " ") + assert str(frag) == "Hello wonderful World!" + assert len(frag.children) == 5 + +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 == 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" +