Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

XML and Text formatting support #184

Merged
merged 10 commits into from
Oct 19, 2024
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,6 @@ __pycache__/
*.so
Cargo.lock
.coverage
/site
/dist
_pycrdt.*.pyd
5 changes: 5 additions & 0 deletions docs/api_reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@
- Transaction
- TransactionEvent
- UndoManager
- XmlElement
- XmlFragment
- XmlText
- XmlChildrenView
- XmlAttributesView
- YMessageType
- YSyncMessageType
- create_awareness_message
Expand Down
4 changes: 4 additions & 0 deletions python/pycrdt/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
8 changes: 6 additions & 2 deletions python/pycrdt/_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
7 changes: 5 additions & 2 deletions python/pycrdt/_doc.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
"""
Expand Down
156 changes: 153 additions & 3 deletions python/pycrdt/_pycrdt.pyi
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import Any, Callable
from typing import Any, Callable, Iterator

class Doc:
"""Shared document."""
Expand Down Expand Up @@ -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]:
Expand Down Expand Up @@ -94,22 +97,49 @@ 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`."""

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."""
Expand Down Expand Up @@ -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."""

Expand Down
44 changes: 41 additions & 3 deletions python/pycrdt/_text.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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.
davidbrochart marked this conversation as resolved.
Show resolved Hide resolved
```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
davidbrochart marked this conversation as resolved.
Show resolved Hide resolved
"""
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.
davidbrochart marked this conversation as resolved.
Show resolved Hide resolved
"""
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
davidbrochart marked this conversation as resolved.
Show resolved Hide resolved
"""
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.
davidbrochart marked this conversation as resolved.
Show resolved Hide resolved
"""
with self.doc.transaction() as txn:
return self.integrated.diff(txn._txn)

def observe(self, callback: Callable[[TextEvent], None]) -> Subscription:
"""
Expand Down
Loading
Loading