Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Version history

## Unreleased

- Add exact `Text`/`Array` owner validation with `StickyIndex.resolve()` and reject a mismatched
owner in sequence-backed `get_index()` calls.
- Return Python `ValueError`/`None` outcomes for malformed or unresolvable sticky indices instead
of propagating Rust panics.

## 0.14.4

- Bump `yrs` to v0.27.4.
Expand Down
27 changes: 27 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,33 @@ After receiving the other user's update, if no special care is taken, machine A
In other words, their document states will diverge, and thus users won't collaborate on the same document anymore.
CRDTs ensure that documents don't diverge, their shared documents will eventually have the same state. It will arbitrary be "ab" or "ba", but it will be the same on both machines.

## Sticky indices

A sticky index tracks a position in a `Text` or `Array` while edits move its current numeric
index. It can be serialized for another process and resolved later:

```py
from pycrdt import Assoc, Doc, StickyIndex, Text

doc = Doc()
text = doc.get("text", type=Text)
text += "abc"

encoded = text.sticky_index(1, Assoc.AFTER).encode()
position = StickyIndex.decode(encoded)
assert position.resolve(text) == 1
```

`resolve(sequence)` validates the exact shared type returned in the resolved Yrs offset. It returns
`None` when the position belongs to a sibling, its owner was deleted, or the position is not yet
available in the sequence's document. Resolution does not change the document.

Passing a sequence to `decode()` or `from_json()` associates that validation target with the sticky
index. In that form, `get_index()` remains convenient for existing callers but raises `ValueError`
if the position cannot be resolved against that sequence; it never returns an offset belonging to a
sibling. A sticky index deserialized without a sequence can still use the existing
`get_index(transaction)` form, which resolves without owner validation.

## Transactions

Every change to a shared data happens in a document transaction, and there can only be one transaction at a time. Pycrdt offers two methods for creating transactions:
Expand Down
9 changes: 8 additions & 1 deletion python/pycrdt/_pycrdt.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,9 @@ class Text:
def diff(self, txn: Transaction) -> list[tuple[Any, dict[str, Any] | None]]:
"""Returns a sequence of formatted chunks."""

def sticky_index(self, txn: Transaction, index: int, assoc: int) -> "StickyIndex":
"""Creates a sticky index at the given position."""

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 All @@ -184,6 +187,9 @@ class Array:
def to_json(self, txn: Transaction) -> str:
"""Returns a JSON representation of the current array."""

def sticky_index(self, txn: Transaction, index: int, assoc: int) -> "StickyIndex":
"""Creates a sticky index at the given position."""

def observe(self, callback: Callable[[TextEvent], None]) -> Subscription:
"""Subscribes a callback to be called with the array change event.
Returns a subscription that can be used to unsubscribe."""
Expand Down Expand Up @@ -600,7 +606,8 @@ class StackItem(Generic[MetaT]):
"""

class StickyIndex:
def get_offset(self, txn: Transaction) -> int: ...
def get_offset(self, txn: Transaction) -> int | None: ...
def resolve(self, txn: Transaction, sequence: Text | Array) -> int | None: ...
def encode(self) -> bytes: ...
def to_json_string(self) -> str: ...
def get_assoc(self) -> int: ...
Expand Down
64 changes: 51 additions & 13 deletions python/pycrdt/_sticky_index.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,19 +53,48 @@ def get_index(self, transaction: Transaction | None = None) -> int:
Raises:
RuntimeError: No transaction was provided and no shared type was associated
with the deserialized sticky index.
ValueError: The sticky index cannot be resolved, or its resolved owner is not
the associated shared type.
"""
if transaction is not None:
_txn = transaction._txn
assert _txn is not None
return self._sticky_index.get_offset(_txn)
if self._sequence is None:
index = self._sticky_index.get_offset(_txn)
elif self._sequence.is_integrated:
index = self._sticky_index.resolve(_txn, self._sequence.integrated)
else:
index = None
elif self._sequence is not None:
index = self.resolve(self._sequence)
else:
raise RuntimeError("No transaction available")

if index is None:
raise ValueError("Sticky index cannot be resolved")
return index

def resolve(self, sequence: Sequence) -> int | None:
"""
Resolve the current index only if it belongs to an exact shared type.

Resolution does not mutate the document. A position owned by a sibling shared type,
a deleted shared type, or data that is not yet available in the document is not resolved.

if self._sequence is not None:
with self._sequence.doc.transaction() as txn:
_txn = txn._txn
assert _txn is not None
return self._sticky_index.get_offset(_txn)
Args:
sequence: The [Array][pycrdt.Array] or [Text][pycrdt.Text] against which to validate
the resolved owner.

raise RuntimeError("No transaction available")
Returns:
The current index, or `None` if the position cannot be resolved against `sequence`.
"""
if not sequence.is_integrated:
return None

with sequence.doc.transaction() as txn:
_txn = txn._txn
assert _txn is not None
return self._sticky_index.resolve(_txn, sequence.integrated)

@property
def assoc(self) -> Assoc:
Expand Down Expand Up @@ -105,6 +134,9 @@ def new(cls, sequence: Sequence, index: int, assoc: Assoc) -> Self:

Returns:
The sticky index.

Raises:
ValueError: The index is outside the sequence.
"""
with sequence.doc.transaction() as txn:
self = cls(sequence.integrated.sticky_index(txn._txn, index, assoc), sequence)
Expand All @@ -117,12 +149,15 @@ def decode(cls, data: bytes, sequence: Sequence | None = None) -> Self:

Args:
data: The binary data to get the sticky index from.
sequence: The [Array][pycrdt.Array] or [Text][pycrdt.Text] the sticky index belongs to.
If not provided, a [Transaction][pycrdt.Transaction] will be needed when getting
the index.
sequence: The [Array][pycrdt.Array] or [Text][pycrdt.Text] against which the resolved
owner will be validated. If not provided, a [Transaction][pycrdt.Transaction]
will be needed when getting the index.

Returns:
The decoded sticky index.

Raises:
ValueError: The binary data is malformed.
"""
self = cls(decode_sticky_index(data), sequence)
return self
Expand All @@ -134,12 +169,15 @@ def from_json(cls, data: dict, sequence: Sequence | None = None) -> Self:

Args:
data: The JSON dictionary to get the sticky index from.
sequence: The [Array][pycrdt.Array] or [Text][pycrdt.Text] the sticky index belongs to.
If not provided, a [Transaction][pycrdt.Transaction] will be needed when getting
the index.
sequence: The [Array][pycrdt.Array] or [Text][pycrdt.Text] against which the resolved
owner will be validated. If not provided, a [Transaction][pycrdt.Transaction]
will be needed when getting the index.

Returns:
The deserialized sticky index.

Raises:
ValueError: The JSON data is malformed.
"""
self = cls(get_sticky_index_from_json_string(json.dumps(data)), sequence)
return self
7 changes: 4 additions & 3 deletions src/array.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ use yrs::{
Assoc,
DeepObservable,
Doc as _Doc,
IndexedSequence,
Observable,
TransactionMut,
XmlFragmentPrelim,
Expand Down Expand Up @@ -147,8 +146,10 @@ impl Array {
0 => _assoc = Assoc::After,
_ => _assoc = Assoc::Before,
}
let sticky_index = self.array.sticky_index(t, index, _assoc);
let s: Py<StickyIndex> = Py::new(py, StickyIndex::from(sticky_index))?;
let sticky_index =
StickyIndex::from_sequence(t, &self.array, index, self.array.len(t), _assoc)
.ok_or_else(|| PyValueError::new_err("Index out of range"))?;
let s: Py<StickyIndex> = Py::new(py, sticky_index)?;
Ok(s)
}

Expand Down
Loading
Loading