From 3fc52b9d522b556472ed5659a3d41292303a8d65 Mon Sep 17 00:00:00 2001 From: waidhoferj Date: Thu, 28 Apr 2022 16:40:10 -0700 Subject: [PATCH 1/3] deep observe for all YTypes --- src/type_conversions.rs | 20 +++++++++++ src/y_array.rs | 23 ++++++++++--- src/y_map.rs | 19 +++++++++-- src/y_text.rs | 31 +++++++++++------ src/y_xml.rs | 76 ++++++++++++++++++++++++++++------------- tests/test_y_array.py | 33 +++++++++++++++++- tests/test_y_map.py | 23 +++++++++++++ tests/test_y_xml.py | 19 +++++++++++ y_py.pyi | 27 ++++++++++++--- 9 files changed, 223 insertions(+), 48 deletions(-) diff --git a/src/type_conversions.rs b/src/type_conversions.rs index f471190..38c5b04 100644 --- a/src/type_conversions.rs +++ b/src/type_conversions.rs @@ -1,17 +1,24 @@ use lib0::any::Any; use pyo3::prelude::*; use pyo3::types as pytypes; +use pyo3::types::PyList; use std::collections::HashMap; use std::convert::TryFrom; use std::ops::Deref; use yrs::block::{ItemContent, Prelim}; +use yrs::types::Events; use yrs::types::{Attrs, Branch, BranchPtr, Change, Delta, EntryChange, Value}; use yrs::{Array, Map, Text, Transaction}; use crate::shared_types::{Shared, SharedType}; use crate::y_array::YArray; +use crate::y_array::YArrayEvent; use crate::y_map::YMap; +use crate::y_map::YMapEvent; use crate::y_text::YText; +use crate::y_text::YTextEvent; +use crate::y_xml::YXmlEvent; +use crate::y_xml::YXmlTextEvent; use crate::y_xml::{YXmlElement, YXmlText}; pub trait ToPython { @@ -324,6 +331,19 @@ impl ToPython for Value { } } +pub(crate) fn events_into_py(txn: &Transaction, events: &Events) -> PyObject { + Python::with_gil(|py| { + let py_events = events.iter().map(|event| match event { + yrs::types::Event::Text(e_txt) => YTextEvent::new(e_txt, txn).into_py(py), + yrs::types::Event::Array(e_arr) => YArrayEvent::new(e_arr, txn).into_py(py), + yrs::types::Event::Map(e_map) => YMapEvent::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), + }); + PyList::new(py, py_events).into() + }) +} + pub struct PyValueWrapper(pub PyObject); impl Prelim for PyValueWrapper { diff --git a/src/y_array.rs b/src/y_array.rs index 0436f63..d4b8d5f 100644 --- a/src/y_array.rs +++ b/src/y_array.rs @@ -2,7 +2,7 @@ use std::convert::TryInto; use std::mem::ManuallyDrop; use std::ops::DerefMut; -use crate::type_conversions::insert_at; +use crate::type_conversions::{events_into_py, insert_at}; use crate::y_transaction::YTransaction; use super::shared_types::SharedType; @@ -11,6 +11,7 @@ use pyo3::exceptions::{PyIndexError, PyTypeError}; use pyo3::prelude::*; use pyo3::types::{PyList, PySlice, PySliceIndices}; use yrs::types::array::{ArrayEvent, ArrayIter}; +use yrs::types::DeepObservable; use yrs::{Array, SubscriptionId, Transaction}; /// A collection used to store data in an indexed sequence structure. This type is internally @@ -186,10 +187,22 @@ impl YArray { /// Subscribes to all operations happening over this instance of `YArray`. All changes are /// batched and eventually triggered during transaction commit phase. /// Returns a `SubscriptionId` which can be used to cancel the callback with `unobserve`. - pub fn observe(&mut self, f: PyObject) -> PyResult { + pub fn observe(&mut self, f: PyObject, deep: Option) -> PyResult { + let deep = deep.unwrap_or(false); match &mut self.0 { + SharedType::Integrated(array) if deep => { + let sub = array.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) + } + }) + }); + Ok(sub.into()) + } SharedType::Integrated(array) => { - let subscription = array.observe(move |txn, e| { + let sub = array.observe(move |txn, e| { Python::with_gil(|py| { let event = YArrayEvent::new(e, txn); if let Err(err) = f.call1(py, (event,)) { @@ -197,7 +210,7 @@ impl YArray { } }) }); - Ok(subscription.into()) + Ok(sub.into()) } SharedType::Prelim(_) => Err(PyTypeError::new_err( "Cannot observe a preliminary type. Must be added to a YDoc first", @@ -356,7 +369,7 @@ pub struct YArrayEvent { } impl YArrayEvent { - fn new(event: &ArrayEvent, txn: &Transaction) -> Self { + pub fn new(event: &ArrayEvent, txn: &Transaction) -> Self { let inner = event as *const ArrayEvent; let txn = txn as *const Transaction; YArrayEvent { diff --git a/src/y_map.rs b/src/y_map.rs index 06260a3..d41b732 100644 --- a/src/y_map.rs +++ b/src/y_map.rs @@ -5,10 +5,11 @@ use std::collections::HashMap; use std::mem::ManuallyDrop; use std::ops::DerefMut; use yrs::types::map::{MapEvent, MapIter}; +use yrs::types::DeepObservable; use yrs::{Map, SubscriptionId, Transaction}; use crate::shared_types::SharedType; -use crate::type_conversions::{PyValueWrapper, ToPython}; +use crate::type_conversions::{events_into_py, PyValueWrapper, ToPython}; use crate::y_transaction::YTransaction; /// Collection used to store key-value entries in an unordered manner. Keys are always represented @@ -205,8 +206,20 @@ impl YMap { YMapKeyIterator(self.items()) } - pub fn observe(&mut self, f: PyObject) -> PyResult { + pub fn observe(&mut self, f: PyObject, deep: Option) -> PyResult { + let deep = deep.unwrap_or(false); match &mut self.0 { + SharedType::Integrated(map) if deep => { + let sub = map.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) + } + }) + }); + Ok(sub.into()) + } SharedType::Integrated(v) => Ok(v .observe(move |txn, e| { Python::with_gil(|py| { @@ -296,7 +309,7 @@ pub struct YMapEvent { } impl YMapEvent { - fn new(event: &MapEvent, txn: &Transaction) -> Self { + pub fn new(event: &MapEvent, txn: &Transaction) -> Self { let inner = event as *const MapEvent; let txn = txn as *const Transaction; YMapEvent { diff --git a/src/y_text.rs b/src/y_text.rs index b78bd1e..0a3be59 100644 --- a/src/y_text.rs +++ b/src/y_text.rs @@ -1,19 +1,18 @@ -use std::collections::HashMap; -use std::rc::Rc; - +use crate::shared_types::SharedType; +use crate::type_conversions::py_into_any; +use crate::type_conversions::{events_into_py, ToPython}; +use crate::y_transaction::YTransaction; use lib0::any::Any; use pyo3::exceptions::PyTypeError; use pyo3::prelude::*; use pyo3::types::PyList; +use std::collections::HashMap; +use std::rc::Rc; use yrs::types::text::TextEvent; use yrs::types::Attrs; +use yrs::types::DeepObservable; use yrs::{SubscriptionId, Text, Transaction}; -use crate::shared_types::SharedType; -use crate::type_conversions::py_into_any; -use crate::type_conversions::ToPython; -use crate::y_transaction::YTransaction; - /// A shared data type used for collaborative text editing. It enables multiple users to add and /// remove chunks of text in efficient manner. This type is internally represented as a mutable /// double-linked list of text chunks - an optimization occurs during `YTransaction.commit`, which @@ -193,8 +192,20 @@ impl YText { } } - pub fn observe(&mut self, f: PyObject) -> PyResult { + pub fn observe(&mut self, f: PyObject, deep: Option) -> PyResult { + let deep = deep.unwrap_or(false); match &mut self.0 { + SharedType::Integrated(text) if deep => { + let sub = text.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) + } + }) + }); + Ok(sub.into()) + } SharedType::Integrated(v) => Ok(v .observe(move |txn, e| { Python::with_gil(|py| { @@ -253,7 +264,7 @@ pub struct YTextEvent { } impl YTextEvent { - fn new(event: &TextEvent, txn: &Transaction) -> Self { + pub fn new(event: &TextEvent, txn: &Transaction) -> Self { let inner = event as *const TextEvent; let txn = txn as *const Transaction; YTextEvent { diff --git a/src/y_xml.rs b/src/y_xml.rs index f09f342..907b910 100644 --- a/src/y_xml.rs +++ b/src/y_xml.rs @@ -3,14 +3,14 @@ use pyo3::types::{PyDict, PyList}; use std::mem::ManuallyDrop; use std::ops::Deref; use yrs::types::xml::{Attributes, TreeWalker, XmlEvent, XmlTextEvent}; -use yrs::types::{EntryChange, Path, PathSegment}; +use yrs::types::{DeepObservable, EntryChange, Path, PathSegment}; use yrs::SubscriptionId; use yrs::Transaction; use yrs::Xml; use yrs::XmlElement; use yrs::XmlText; -use crate::type_conversions::ToPython; +use crate::type_conversions::{events_into_py, ToPython}; use crate::y_transaction::YTransaction; /// XML element data type. It represents an XML node, which can contain key-value attributes @@ -165,17 +165,31 @@ impl YXmlElement { /// Subscribes to all operations happening over this instance of `YXmlElement`. All changes are /// batched and eventually triggered during transaction commit phase. /// Returns an `SubscriptionId` which, can be used to unsubscribe the observer. - pub fn observe(&mut self, f: PyObject) -> SubscriptionId { - self.0 - .observe(move |txn, e| { - Python::with_gil(|py| { - let event = YXmlEvent::new(e, txn); - if let Err(err) = f.call1(py, (event,)) { - err.restore(py) - } + pub fn observe(&mut self, f: PyObject, deep: Option) -> SubscriptionId { + let deep = deep.unwrap_or(false); + if deep { + self.0 + .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() + .into() + } else { + self.0 + .observe(move |txn, e| { + Python::with_gil(|py| { + let event = YXmlEvent::new(e, txn); + if let Err(err) = f.call1(py, (event,)) { + err.restore(py) + } + }) + }) + .into() + } } /// Cancels the observer callback associated with the `subscripton_id`. pub fn unobserve(&mut self, subscription_id: SubscriptionId) { @@ -300,17 +314,31 @@ impl YXmlText { /// Subscribes to all operations happening over this instance of `YXmlText`. All changes are /// batched and eventually triggered during transaction commit phase. /// Returns an `SubscriptionId` which, which can be used to unsubscribe the callback function. - pub fn observe(&mut self, f: PyObject) -> SubscriptionId { - self.0 - .observe(move |txn, e| { - Python::with_gil(|py| { - let e = YXmlTextEvent::new(e, txn); - if let Err(err) = f.call1(py, (e,)) { - err.restore(py) - } + pub fn observe(&mut self, f: PyObject, deep: Option) -> SubscriptionId { + let deep = deep.unwrap_or(false); + if deep { + self.0 + .observe_deep(move |txn, events| { + Python::with_gil(|py| { + let e = events_into_py(txn, events); + if let Err(err) = f.call1(py, (e,)) { + err.restore(py) + } + }) }) - }) - .into() + .into() + } else { + self.0 + .observe(move |txn, e| { + Python::with_gil(|py| { + let e = YXmlTextEvent::new(e, txn); + if let Err(err) = f.call1(py, (e,)) { + err.restore(py) + } + }) + }) + .into() + } } /// Cancels the observer callback associated with the `subscripton_id`. @@ -371,7 +399,7 @@ pub struct YXmlEvent { keys: Option, } impl YXmlEvent { - fn new(event: &XmlEvent, txn: &Transaction) -> Self { + pub fn new(event: &XmlEvent, txn: &Transaction) -> Self { let inner = event as *const XmlEvent; let txn = txn as *const Transaction; YXmlEvent { @@ -470,7 +498,7 @@ pub struct YXmlTextEvent { } impl YXmlTextEvent { - fn new(event: &XmlTextEvent, txn: &Transaction) -> Self { + pub fn new(event: &XmlTextEvent, txn: &Transaction) -> Self { let inner = event as *const XmlTextEvent; let txn = txn as *const Transaction; YXmlTextEvent { diff --git a/tests/test_y_array.py b/tests/test_y_array.py index 6f73212..4d0e450 100644 --- a/tests/test_y_array.py +++ b/tests/test_y_array.py @@ -180,7 +180,7 @@ def callback(e): target = None delta = None - # insert item in the middle + # insert item in the middle with d1.begin_transaction() as txn: x.insert(txn, 1, [5]) assert target.to_json() == x.to_json() @@ -197,3 +197,34 @@ def callback(e): assert target == None assert delta == None + + +def test_deep_observe(): + """ + Ensure that changes to elements inside the array trigger a callback. + """ + ydoc = YDoc() + container = ydoc.get_array("container") + yarray = YArray([1, 2]) + with ydoc.begin_transaction() as txn: + container.push(txn, [yarray]) + + events = None + + def callback(e: list): + nonlocal events + events = e + + sub = container.observe(callback, deep=True) + with ydoc.begin_transaction() as txn: + container[0].push(txn, [3]) + + assert events != None + + # Ensure that observer unsubscribes + # events = None + # container.unobserve(sub) + # with ydoc.begin_transaction() as txn: + # container[0].push(txn, [4]) + + # assert events == None diff --git a/tests/test_y_map.py b/tests/test_y_map.py index a9d9788..c15038f 100644 --- a/tests/test_y_map.py +++ b/tests/test_y_map.py @@ -154,3 +154,26 @@ def callback(e): x.set(txn, "key1", [6]) assert target == None assert entries == None + + +def test_deep_observe(): + """ + Ensure that changes to elements inside the array trigger a callback. + """ + doc = Y.YDoc() + container = doc.get_map("container") + inner_map = Y.YMap({"key": "initial"}) + with doc.begin_transaction() as txn: + container.set(txn, "inner", inner_map) + + events = None + + def callback(e: list): + nonlocal events + events = e + + container.observe(callback, deep=True) + with doc.begin_transaction() as txn: + container["inner"].set(txn, "addition", 1) + + assert events != None diff --git a/tests/test_y_xml.py b/tests/test_y_xml.py index 3f3ce2d..2b35251 100644 --- a/tests/test_y_xml.py +++ b/tests/test_y_xml.py @@ -254,3 +254,22 @@ def callback(e): assert target == None assert nodes == None assert attributes == None + + +def test_deep_observe(): + ydoc = Y.YDoc() + container = ydoc.get_xml_element("container") + with ydoc.begin_transaction() as txn: + text = container.insert_xml_text(txn, 0) + + events = None + + def callback(e: list): + nonlocal events + events = e + + sub = container.observe(callback, deep=True) + with ydoc.begin_transaction() as txn: + container.first_child.push(txn, "nested") + + assert events != None diff --git a/y_py.pyi b/y_py.pyi index c62fa0d..993cc3b 100644 --- a/y_py.pyi +++ b/y_py.pyi @@ -14,6 +14,8 @@ from typing import ( SubscriptionId = int +Event = Union[YTextEvent, YArrayEvent, YMapEvent, YXmlTextEvent, YXmlElementEvent] + class YDoc: """ A Ypy document type. Documents are most important units of collaborative resources management. @@ -415,12 +417,15 @@ class YText: Deletes a specified range of of characters, starting at a given `index`. Both `index` and `length` are counted in terms of a number of UTF-8 character bytes. """ - def observe(self, f: Callable[[YTextEvent]]) -> SubscriptionId: + def observe( + self, f: Callable[[Union[YTextEvent, List[Event]]]], deep: bool = False + ) -> SubscriptionId: """ Assigns a callback function to listen to YText updates. Args: f: Callback function that runs when the text object receives an update. + deep: Determines whether the callback is triggered when embedded elements are changed. Returns: A reference to the callback subscription. """ @@ -518,12 +523,15 @@ class YArray: for item in array: print(item) """ - def observe(self, f: Callable[[YArrayEvent]]) -> SubscriptionId: + def observe( + self, f: Callable[[Union[YArrayEvent, List[Event]]]], deep: bool = False + ) -> SubscriptionId: """ Assigns a callback function to listen to YArray updates. Args: f: Callback function that runs when the array object receives an update. + deep: Determines whether observer is triggered by changes to elements in the YArray. Returns: An identifier associated with the callback subscription. """ @@ -634,12 +642,15 @@ class YMap: for (key, value) in map.items()): print(key, value) """ - def observe(self, f: Callable[[YMapEvent]]) -> SubscriptionId: + def observe( + self, f: Callable[[Union[YMapEvent, List[Event]]]], deep: bool = False + ) -> SubscriptionId: """ Assigns a callback function to listen to YMap updates. Args: f: Callback function that runs when the map object receives an update. + deep: Determines whether observer is triggered by changes to elements in the YMap. Returns: A reference to the callback subscription. Delete this observer in order to erase the associated callback function. """ @@ -767,13 +778,16 @@ class YXmlElement: Returns an iterator that enables a deep traversal of this XML node - starting from first child over this XML node successors using depth-first strategy. """ - def observe(self, f: Callable[[YXmlElementEvent]]) -> SubscriptionId: + def observe( + self, f: Callable[[Union[YXmlElementEvent, List[Event]]]], deep: bool = False + ) -> SubscriptionId: """ Subscribes to all operations happening over this instance of `YXmlElement`. All changes are batched and eventually triggered during transaction commit phase. Args: f: A callback function that receives update events. + deep: Determines whether observer is triggered by changes to elements in the YXmlElement. Returns: A `SubscriptionId` that can be used to cancel the observer callback. """ @@ -838,13 +852,16 @@ class YXmlText: An iterator that enables to traverse over all attributes of this XML node in unspecified order. """ - def observe(self, f: Callable[[YXmlTextEvent]]) -> SubscriptionId: + def observe( + self, f: Callable[[Union[YXmlTextEvent, List[Event]]]], deep: bool = False + ) -> SubscriptionId: """ Subscribes to all operations happening over this instance of `YXmlText`. All changes are batched and eventually triggered during transaction commit phase. Args: f: A callback function that receives update events. + deep: Determines whether observer is triggered by changes to elements in the YXmlText. Returns: A `SubscriptionId` that can be used to cancel the observer callback. """ From b6fe2963be9212ae79f2ac746175e60071437a73 Mon Sep 17 00:00:00 2001 From: waidhoferj Date: Thu, 5 May 2022 23:58:24 -0700 Subject: [PATCH 2/3] separated observers into a separate `observe` and `observe_deep` function --- src/shared_types.rs | 35 +++++++++++- src/y_array.rs | 70 +++++++++++++----------- src/y_map.rs | 73 ++++++++++++++----------- src/y_text.rs | 77 ++++++++++++++------------ src/y_xml.rs | 122 +++++++++++++++++++++++++----------------- tests/test_y_array.py | 12 ++--- tests/test_y_map.py | 9 +++- tests/test_y_text.py | 29 ++++++++++ tests/test_y_xml.py | 2 +- y_py.pyi | 78 ++++++++++++++++++++------- 10 files changed, 330 insertions(+), 177 deletions(-) diff --git a/src/shared_types.rs b/src/shared_types.rs index 92ab091..dff538f 100644 --- a/src/shared_types.rs +++ b/src/shared_types.rs @@ -4,11 +4,42 @@ use crate::{ y_text::YText, y_xml::{YXmlElement, YXmlText}, }; -use pyo3::prelude::*; +use pyo3::create_exception; +use pyo3::{exceptions::PyException, prelude::*}; use std::convert::TryFrom; -use yrs::types::TYPE_REFS_XML_ELEMENT; use yrs::types::TYPE_REFS_XML_TEXT; use yrs::types::{TypeRefs, TYPE_REFS_ARRAY, TYPE_REFS_MAP, TYPE_REFS_TEXT}; +use yrs::{types::TYPE_REFS_XML_ELEMENT, SubscriptionId}; + +// Common errors +create_exception!(y_py, PreliminaryObservationException, PyException, "Occurs when an observer is attached to a Y type that is not integrated into a YDoc. Y types can only be observed once they have been added to a YDoc."); + +/// Creates a default error with a common message string for throwing a `PyErr`. +pub(crate) trait DefaultPyErr { + /// Creates a new instance of the error with a default message. + fn default_message() -> PyErr; +} + +impl DefaultPyErr for PreliminaryObservationException { + fn default_message() -> PyErr { + PreliminaryObservationException::new_err( + "Cannot observe a preliminary type. Must be added to a YDoc first", + ) + } +} + +#[pyclass] +#[derive(Clone, Copy)] +pub struct ShallowSubscription(pub SubscriptionId); +#[pyclass] +#[derive(Clone, Copy)] +pub struct DeepSubscription(pub SubscriptionId); + +#[derive(FromPyObject)] +pub enum SubId { + Shallow(ShallowSubscription), + Deep(DeepSubscription), +} #[derive(Clone)] pub enum SharedType { diff --git a/src/y_array.rs b/src/y_array.rs index d4b8d5f..108d9a3 100644 --- a/src/y_array.rs +++ b/src/y_array.rs @@ -2,12 +2,15 @@ use std::convert::TryInto; use std::mem::ManuallyDrop; use std::ops::DerefMut; +use crate::shared_types::{ + DeepSubscription, DefaultPyErr, PreliminaryObservationException, ShallowSubscription, SubId, +}; use crate::type_conversions::{events_into_py, insert_at}; use crate::y_transaction::YTransaction; use super::shared_types::SharedType; use crate::type_conversions::ToPython; -use pyo3::exceptions::{PyIndexError, PyTypeError}; +use pyo3::exceptions::PyIndexError; use pyo3::prelude::*; use pyo3::types::{PyList, PySlice, PySliceIndices}; use yrs::types::array::{ArrayEvent, ArrayIter}; @@ -187,47 +190,52 @@ impl YArray { /// Subscribes to all operations happening over this instance of `YArray`. All changes are /// batched and eventually triggered during transaction commit phase. /// Returns a `SubscriptionId` which can be used to cancel the callback with `unobserve`. - pub fn observe(&mut self, f: PyObject, deep: Option) -> PyResult { - let deep = deep.unwrap_or(false); + pub fn observe(&mut self, f: PyObject) -> PyResult { match &mut self.0 { - SharedType::Integrated(array) if deep => { - let sub = array.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) - } + SharedType::Integrated(array) => { + let sub: SubscriptionId = array + .observe(move |txn, e| { + Python::with_gil(|py| { + let event = YArrayEvent::new(e, txn); + if let Err(err) = f.call1(py, (event,)) { + err.restore(py) + } + }) }) - }); - Ok(sub.into()) + .into(); + Ok(ShallowSubscription(sub)) } + SharedType::Prelim(_) => Err(PreliminaryObservationException::default_message()), + } + } + /// Observes YArray events and events of all child elements. + pub fn observe_deep(&mut self, f: PyObject) -> PyResult { + match &mut self.0 { SharedType::Integrated(array) => { - let sub = array.observe(move |txn, e| { - Python::with_gil(|py| { - let event = YArrayEvent::new(e, txn); - if let Err(err) = f.call1(py, (event,)) { - err.restore(py) - } + let sub: SubscriptionId = array + .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) + } + }) }) - }); - Ok(sub.into()) + .into(); + Ok(DeepSubscription(sub)) } - SharedType::Prelim(_) => Err(PyTypeError::new_err( - "Cannot observe a preliminary type. Must be added to a YDoc first", - )), + SharedType::Prelim(_) => Err(PreliminaryObservationException::default_message()), } } /// Cancels the callback of an observer using the Subscription ID returned from the `observe` method. - pub fn unobserve(&mut self, subscription_id: SubscriptionId) -> PyResult<()> { + pub fn unobserve(&mut self, subscription_id: SubId) -> PyResult<()> { match &mut self.0 { - SharedType::Integrated(v) => { - v.unobserve(subscription_id); - Ok(()) - } - SharedType::Prelim(_) => Err(PyTypeError::new_err( - "Cannot call unobserve on a preliminary type. Must be added to a YDoc first", - )), + SharedType::Integrated(arr) => Ok(match subscription_id { + SubId::Shallow(ShallowSubscription(id)) => arr.unobserve(id), + SubId::Deep(DeepSubscription(id)) => arr.unobserve_deep(id), + }), + SharedType::Prelim(_) => Err(PreliminaryObservationException::default_message()), } } } diff --git a/src/y_map.rs b/src/y_map.rs index d41b732..70ee596 100644 --- a/src/y_map.rs +++ b/src/y_map.rs @@ -8,7 +8,10 @@ use yrs::types::map::{MapEvent, MapIter}; use yrs::types::DeepObservable; use yrs::{Map, SubscriptionId, Transaction}; -use crate::shared_types::SharedType; +use crate::shared_types::{ + DeepSubscription, DefaultPyErr, PreliminaryObservationException, ShallowSubscription, + SharedType, SubId, +}; use crate::type_conversions::{events_into_py, PyValueWrapper, ToPython}; use crate::y_transaction::YTransaction; @@ -206,45 +209,51 @@ impl YMap { YMapKeyIterator(self.items()) } - pub fn observe(&mut self, f: PyObject, deep: Option) -> PyResult { - let deep = deep.unwrap_or(false); + pub fn observe(&mut self, f: PyObject) -> PyResult { match &mut self.0 { - SharedType::Integrated(map) if deep => { - let sub = map.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) - } + SharedType::Integrated(v) => { + let sub_id: SubscriptionId = v + .observe(move |txn, e| { + Python::with_gil(|py| { + let e = YMapEvent::new(e, txn); + if let Err(err) = f.call1(py, (e,)) { + err.restore(py) + } + }) }) - }); - Ok(sub.into()) + .into(); + Ok(ShallowSubscription(sub_id)) } - SharedType::Integrated(v) => Ok(v - .observe(move |txn, e| { - Python::with_gil(|py| { - let e = YMapEvent::new(e, txn); - if let Err(err) = f.call1(py, (e,)) { - err.restore(py) - } - }) - }) - .into()), - SharedType::Prelim(_) => Err(PyTypeError::new_err( - "Cannot observe a preliminary type. Must be added to a YDoc first", - )), + SharedType::Prelim(_) => Err(PreliminaryObservationException::default_message()), } } - /// Cancels the observer callback associated with the `subscripton_id`. - pub fn unobserve(&mut self, subscription_id: SubscriptionId) -> PyResult<()> { + + pub fn observe_deep(&mut self, f: PyObject) -> PyResult { match &mut self.0 { SharedType::Integrated(map) => { - map.unobserve(subscription_id); - Ok(()) + let sub: SubscriptionId = map + .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(); + Ok(DeepSubscription(sub)) } - SharedType::Prelim(_) => Err(PyTypeError::new_err( - "Cannot unobserve a preliminary type. Must be added to a YDoc first", - )), + SharedType::Prelim(_) => Err(PreliminaryObservationException::default_message()), + } + } + /// Cancels the observer callback associated with the `subscripton_id`. + pub fn unobserve(&mut self, subscription_id: SubId) -> PyResult<()> { + match &mut self.0 { + SharedType::Integrated(map) => Ok(match subscription_id { + SubId::Shallow(ShallowSubscription(id)) => map.unobserve(id), + SubId::Deep(DeepSubscription(id)) => map.unobserve_deep(id), + }), + SharedType::Prelim(_) => Err(PreliminaryObservationException::default_message()), } } } diff --git a/src/y_text.rs b/src/y_text.rs index 0a3be59..a5c3ac3 100644 --- a/src/y_text.rs +++ b/src/y_text.rs @@ -1,4 +1,7 @@ -use crate::shared_types::SharedType; +use crate::shared_types::{ + DeepSubscription, DefaultPyErr, PreliminaryObservationException, ShallowSubscription, + SharedType, SubId, +}; use crate::type_conversions::py_into_any; use crate::type_conversions::{events_into_py, ToPython}; use crate::y_transaction::YTransaction; @@ -11,7 +14,7 @@ use std::rc::Rc; use yrs::types::text::TextEvent; use yrs::types::Attrs; use yrs::types::DeepObservable; -use yrs::{SubscriptionId, Text, Transaction}; +use yrs::{Text, Transaction}; /// A shared data type used for collaborative text editing. It enables multiple users to add and /// remove chunks of text in efficient manner. This type is internally represented as a mutable @@ -192,45 +195,53 @@ impl YText { } } - pub fn observe(&mut self, f: PyObject, deep: Option) -> PyResult { - let deep = deep.unwrap_or(false); + /// Observes updates from the `YText` instance. + pub fn observe(&mut self, f: PyObject) -> PyResult { match &mut self.0 { - SharedType::Integrated(text) if deep => { - let sub = text.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) - } + SharedType::Integrated(text) => { + let sub_id = text + .observe(move |txn, e| { + Python::with_gil(|py| { + let e = YTextEvent::new(e, txn); + if let Err(err) = f.call1(py, (e,)) { + err.restore(py) + } + }); }) - }); - Ok(sub.into()) + .into(); + Ok(ShallowSubscription(sub_id)) } - SharedType::Integrated(v) => Ok(v - .observe(move |txn, e| { - Python::with_gil(|py| { - let e = YTextEvent::new(e, txn); - if let Err(err) = f.call1(py, (e,)) { - err.restore(py) - } - }); - }) - .into()), - SharedType::Prelim(_) => Err(PyTypeError::new_err( - "Cannot observe a preliminary type. Must be added to a YDoc first", - )), + SharedType::Prelim(_) => Err(PreliminaryObservationException::default_message()), } } - /// Cancels the observer callback associated with the `subscripton_id`. - pub fn unobserve(&mut self, subscription_id: SubscriptionId) -> PyResult<()> { + + /// Observes updates from the `YText` instance and all of its nested children. + pub fn observe_deep(&mut self, f: PyObject) -> PyResult { match &mut self.0 { SharedType::Integrated(text) => { - text.unobserve(subscription_id); - Ok(()) + let sub = text + .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(); + Ok(DeepSubscription(sub)) } - SharedType::Prelim(_) => Err(PyTypeError::new_err( - "Cannot unobserve a preliminary type. Must be added to a YDoc first", - )), + SharedType::Prelim(_) => Err(PreliminaryObservationException::default_message()), + } + } + /// Cancels the observer callback associated with the `subscripton_id`. + pub fn unobserve(&mut self, subscription_id: SubId) -> PyResult<()> { + match &mut self.0 { + SharedType::Integrated(text) => Ok(match subscription_id { + SubId::Shallow(ShallowSubscription(id)) => text.unobserve(id), + SubId::Deep(DeepSubscription(id)) => text.unobserve_deep(id), + }), + SharedType::Prelim(_) => Err(PreliminaryObservationException::default_message()), } } } diff --git a/src/y_xml.rs b/src/y_xml.rs index 907b910..1651358 100644 --- a/src/y_xml.rs +++ b/src/y_xml.rs @@ -1,3 +1,4 @@ +use crate::shared_types::SubId; use pyo3::prelude::*; use pyo3::types::{PyDict, PyList}; use std::mem::ManuallyDrop; @@ -10,6 +11,7 @@ use yrs::Xml; use yrs::XmlElement; use yrs::XmlText; +use crate::shared_types::{DeepSubscription, ShallowSubscription}; use crate::type_conversions::{events_into_py, ToPython}; use crate::y_transaction::YTransaction; @@ -165,35 +167,46 @@ impl YXmlElement { /// Subscribes to all operations happening over this instance of `YXmlElement`. All changes are /// batched and eventually triggered during transaction commit phase. /// Returns an `SubscriptionId` which, can be used to unsubscribe the observer. - pub fn observe(&mut self, f: PyObject, deep: Option) -> SubscriptionId { - let deep = deep.unwrap_or(false); - if deep { - self.0 - .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) - } - }) + pub fn observe(&mut self, f: PyObject) -> ShallowSubscription { + let sub_id = self + .0 + .observe(move |txn, e| { + Python::with_gil(|py| { + let event = YXmlEvent::new(e, txn); + if let Err(err) = f.call1(py, (event,)) { + err.restore(py) + } }) - .into() - } else { - self.0 - .observe(move |txn, e| { - Python::with_gil(|py| { - let event = YXmlEvent::new(e, txn); - if let Err(err) = f.call1(py, (event,)) { - err.restore(py) - } - }) + }) + .into(); + + ShallowSubscription(sub_id) + } + + /// Subscribes to all operations happening over this instance of `YXmlElement` and all of its children. + /// All changes are batched and eventually triggered during transaction commit phase. + /// Returns an `SubscriptionId` which, can be used to unsubscribe the observer. + pub fn observe_deep(&mut self, f: PyObject) -> DeepSubscription { + let sub_id = self + .0 + .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() - } + }) + .into(); + DeepSubscription(sub_id) } + /// Cancels the observer callback associated with the `subscripton_id`. - pub fn unobserve(&mut self, subscription_id: SubscriptionId) { - self.0.unobserve(subscription_id); + pub fn unobserve(&mut self, subscription_id: SubId) { + match subscription_id { + SubId::Shallow(ShallowSubscription(id)) => self.0.unobserve(id), + SubId::Deep(DeepSubscription(id)) => self.0.unobserve_deep(id), + } } } @@ -314,36 +327,45 @@ impl YXmlText { /// Subscribes to all operations happening over this instance of `YXmlText`. All changes are /// batched and eventually triggered during transaction commit phase. /// Returns an `SubscriptionId` which, which can be used to unsubscribe the callback function. - pub fn observe(&mut self, f: PyObject, deep: Option) -> SubscriptionId { - let deep = deep.unwrap_or(false); - if deep { - self.0 - .observe_deep(move |txn, events| { - Python::with_gil(|py| { - let e = events_into_py(txn, events); - if let Err(err) = f.call1(py, (e,)) { - err.restore(py) - } - }) + pub fn observe(&mut self, f: PyObject) -> ShallowSubscription { + let sub_id: SubscriptionId = self + .0 + .observe(move |txn, e| { + Python::with_gil(|py| { + let e = YXmlTextEvent::new(e, txn); + if let Err(err) = f.call1(py, (e,)) { + err.restore(py) + } }) - .into() - } else { - self.0 - .observe(move |txn, e| { - Python::with_gil(|py| { - let e = YXmlTextEvent::new(e, txn); - if let Err(err) = f.call1(py, (e,)) { - err.restore(py) - } - }) + }) + .into(); + ShallowSubscription(sub_id) + } + + /// Subscribes to all operations happening over this instance of `YXmlText` and its child elements. All changes are + /// batched and eventually triggered during transaction commit phase. + /// Returns an `SubscriptionId` which, which can be used to unsubscribe the callback function. + pub fn observe_deep(&mut self, f: PyObject) -> DeepSubscription { + let sub_id: SubscriptionId = self + .0 + .observe_deep(move |txn, events| { + Python::with_gil(|py| { + let e = events_into_py(txn, events); + if let Err(err) = f.call1(py, (e,)) { + err.restore(py) + } }) - .into() - } + }) + .into(); + DeepSubscription(sub_id) } /// Cancels the observer callback associated with the `subscripton_id`. - pub fn unobserve(&mut self, subscription_id: SubscriptionId) { - self.0.unobserve(subscription_id); + pub fn unobserve(&mut self, subscription_id: SubId) { + match subscription_id { + SubId::Shallow(ShallowSubscription(id)) => self.0.unobserve(id), + SubId::Deep(DeepSubscription(id)) => self.0.unobserve_deep(id), + } } } diff --git a/tests/test_y_array.py b/tests/test_y_array.py index 4d0e450..b6d46a2 100644 --- a/tests/test_y_array.py +++ b/tests/test_y_array.py @@ -215,16 +215,16 @@ def callback(e: list): nonlocal events events = e - sub = container.observe(callback, deep=True) + sub = container.observe_deep(callback) with ydoc.begin_transaction() as txn: container[0].push(txn, [3]) assert events != None # Ensure that observer unsubscribes - # events = None - # container.unobserve(sub) - # with ydoc.begin_transaction() as txn: - # container[0].push(txn, [4]) + events = None + container.unobserve(sub) + with ydoc.begin_transaction() as txn: + container[0].push(txn, [4]) - # assert events == None + assert events == None diff --git a/tests/test_y_map.py b/tests/test_y_map.py index c15038f..7ae0b14 100644 --- a/tests/test_y_map.py +++ b/tests/test_y_map.py @@ -172,8 +172,13 @@ def callback(e: list): nonlocal events events = e - container.observe(callback, deep=True) + sub = container.observe_deep(callback) with doc.begin_transaction() as txn: container["inner"].set(txn, "addition", 1) - assert events != None + events = None + container.unobserve(sub) + with doc.begin_transaction() as txn: + container["inner"].set(txn, "don't show up", 1) + + assert events is None diff --git a/tests/test_y_text.py b/tests/test_y_text.py index 774a23e..3b4cac7 100644 --- a/tests/test_y_text.py +++ b/tests/test_y_text.py @@ -195,3 +195,32 @@ def callback(e): assert delta == [{"retain": 4}, {"retain": 3, "attributes": {"bold": True}}] text.unobserve(sub) + + +def test_deep_observe(): + d = Y.YDoc() + text = d.get_text("text") + nested = Y.YMap({"bold": True}) + with d.begin_transaction() as txn: + text.push(txn, "Hello") + text.insert_embed(txn, 0, nested) # TODO how to observe? + events = None + + def callback(e): + nonlocal events + events = e + + sub = text.observe_deep(callback) + + with d.begin_transaction() as txn: + nested.set(txn, "new_attr", "value") + + assert events is not None and len(events) == 1 + + # verify that the subscription drops + events = None + text.unobserve(sub) + with d.begin_transaction() as txn: + nested.delete(txn, "new_attr") + + assert events is None diff --git a/tests/test_y_xml.py b/tests/test_y_xml.py index 2b35251..2d68ff7 100644 --- a/tests/test_y_xml.py +++ b/tests/test_y_xml.py @@ -268,7 +268,7 @@ def callback(e: list): nonlocal events events = e - sub = container.observe(callback, deep=True) + sub = container.observe_deep(callback) with ydoc.begin_transaction() as txn: container.first_child.push(txn, "nested") diff --git a/y_py.pyi b/y_py.pyi index 993cc3b..70e60ee 100644 --- a/y_py.pyi +++ b/y_py.pyi @@ -12,7 +12,11 @@ from typing import ( Dict, ) -SubscriptionId = int +class SubscriptionId: + """ + Tracks an observer callback. Pass this to the `unobserve` method to cancel + its associated callback. + """ Event = Union[YTextEvent, YArrayEvent, YMapEvent, YXmlTextEvent, YXmlElementEvent] @@ -417,15 +421,21 @@ class YText: Deletes a specified range of of characters, starting at a given `index`. Both `index` and `length` are counted in terms of a number of UTF-8 character bytes. """ - def observe( - self, f: Callable[[Union[YTextEvent, List[Event]]]], deep: bool = False - ) -> SubscriptionId: + def observe(self, f: Callable[[YTextEvent]]) -> SubscriptionId: """ Assigns a callback function to listen to YText updates. Args: f: Callback function that runs when the text object receives an update. - deep: Determines whether the callback is triggered when embedded elements are changed. + Returns: + A reference to the callback subscription. + """ + def observe_deep(self, f: Callable[[List[Event]]]) -> SubscriptionId: + """ + Assigns a callback function to listen to the updates of the YText instance and those of its nested attributes. + + Args: + f: Callback function that runs when the text object or its nested attributes receive an update. Returns: A reference to the callback subscription. """ @@ -523,15 +533,21 @@ class YArray: for item in array: print(item) """ - def observe( - self, f: Callable[[Union[YArrayEvent, List[Event]]]], deep: bool = False - ) -> SubscriptionId: + def observe(self, f: Callable[[YArrayEvent]]) -> SubscriptionId: """ Assigns a callback function to listen to YArray updates. Args: f: Callback function that runs when the array object receives an update. - deep: Determines whether observer is triggered by changes to elements in the YArray. + Returns: + An identifier associated with the callback subscription. + """ + def observe_deep(self, f: Callable[[List[Event]]]) -> SubscriptionId: + """ + Assigns a callback function to listen to the aggregated updates of the YArray and its child elements. + + Args: + f: Callback function that runs when the array object or components receive an update. Returns: An identifier associated with the callback subscription. """ @@ -642,15 +658,21 @@ class YMap: for (key, value) in map.items()): print(key, value) """ - def observe( - self, f: Callable[[Union[YMapEvent, List[Event]]]], deep: bool = False - ) -> SubscriptionId: + def observe(self, f: Callable[[YMapEvent]]) -> SubscriptionId: """ Assigns a callback function to listen to YMap updates. Args: f: Callback function that runs when the map object receives an update. - deep: Determines whether observer is triggered by changes to elements in the YMap. + Returns: + A reference to the callback subscription. Delete this observer in order to erase the associated callback function. + """ + def observe_deep(self, f: Callable[[List[Event]]]) -> SubscriptionId: + """ + Assigns a callback function to listen to YMap and child element updates. + + Args: + f: Callback function that runs when the map object or any of its tracked elements receive an update. Returns: A reference to the callback subscription. Delete this observer in order to erase the associated callback function. """ @@ -778,16 +800,23 @@ class YXmlElement: Returns an iterator that enables a deep traversal of this XML node - starting from first child over this XML node successors using depth-first strategy. """ - def observe( - self, f: Callable[[Union[YXmlElementEvent, List[Event]]]], deep: bool = False - ) -> SubscriptionId: + def observe(self, f: Callable[[YXmlElementEvent]]) -> SubscriptionId: """ Subscribes to all operations happening over this instance of `YXmlElement`. All changes are batched and eventually triggered during transaction commit phase. Args: f: A callback function that receives update events. - deep: Determines whether observer is triggered by changes to elements in the YXmlElement. + Returns: + A `SubscriptionId` that can be used to cancel the observer callback. + """ + def observe_deep(self, f: Callable[[List[Event]]]) -> SubscriptionId: + """ + Subscribes to all operations happening over this instance of `YXmlElement` and its children. All changes are + batched and eventually triggered during transaction commit phase. + + Args: + f: A callback function that receives update events from the Xml element and its children. Returns: A `SubscriptionId` that can be used to cancel the observer callback. """ @@ -852,9 +881,7 @@ class YXmlText: An iterator that enables to traverse over all attributes of this XML node in unspecified order. """ - def observe( - self, f: Callable[[Union[YXmlTextEvent, List[Event]]]], deep: bool = False - ) -> SubscriptionId: + def observe(self, f: Callable[[YXmlTextEvent]]) -> SubscriptionId: """ Subscribes to all operations happening over this instance of `YXmlText`. All changes are batched and eventually triggered during transaction commit phase. @@ -865,6 +892,17 @@ class YXmlText: Returns: A `SubscriptionId` that can be used to cancel the observer callback. """ + def observe_deep(self, f: Callable[[List[Event]]]) -> SubscriptionId: + """ + Subscribes to all operations happening over this instance of `YXmlText` and its children. All changes are + batched and eventually triggered during transaction commit phase. + + Args: + f: A callback function that receives update events of this element and its descendants. + deep: Determines whether observer is triggered by changes to elements in the YXmlText. + Returns: + A `SubscriptionId` that can be used to cancel the observer callback. + """ def unobserve(self, subscription_id: SubscriptionId): """ Cancels the observer callback associated with the `subscripton_id`. From 67a6593a84e68a38802b9d19bb3ba9b5034d5591 Mon Sep 17 00:00:00 2001 From: waidhoferj Date: Fri, 6 May 2022 08:40:01 -0700 Subject: [PATCH 3/3] explained support limitations of YText.observe_deep --- tests/test_y_text.py | 5 +++-- y_py.pyi | 2 ++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/test_y_text.py b/tests/test_y_text.py index 3b4cac7..0e0083d 100644 --- a/tests/test_y_text.py +++ b/tests/test_y_text.py @@ -203,7 +203,6 @@ def test_deep_observe(): nested = Y.YMap({"bold": True}) with d.begin_transaction() as txn: text.push(txn, "Hello") - text.insert_embed(txn, 0, nested) # TODO how to observe? events = None def callback(e): @@ -213,7 +212,9 @@ def callback(e): sub = text.observe_deep(callback) with d.begin_transaction() as txn: - nested.set(txn, "new_attr", "value") + # Currently, Yrs does not support deep observe on embedded values. + # Deep observe will pick up the same events as shallow observe. + text.push(txn, " World") assert events is not None and len(events) == 1 diff --git a/y_py.pyi b/y_py.pyi index 70e60ee..ac5ee51 100644 --- a/y_py.pyi +++ b/y_py.pyi @@ -433,6 +433,8 @@ class YText: def observe_deep(self, f: Callable[[List[Event]]]) -> SubscriptionId: """ Assigns a callback function to listen to the updates of the YText instance and those of its nested attributes. + Currently, this listens to the same events as YText.observe, but in the future this will also listen to + the events of embedded values. Args: f: Callback function that runs when the text object or its nested attributes receive an update.