diff --git a/docs/source/changelog.md b/docs/source/changelog.md index 58c6c5277e..2dd5121761 100644 --- a/docs/source/changelog.md +++ b/docs/source/changelog.md @@ -37,15 +37,16 @@ Major user-visible changes in ipywidgets 7.0 include: Major changes developers should be aware of include: -- Custom serializers in either Python or Javascript can now return a structure which contains binary buffers. If a binary buffer is in the serialized data structure, the message will be synced in binary, which is much more efficient. ([#1194](https://github.com/jupyter-widgets/ipywidgets/pull/1194)) -- On the python/kernel side: - - The python `@register` decorator for widget classes no longer takes a string argument, but registers a widget class using the `_model_*` and `_view_*` traits in the class. Using the decorator as `@register('name')` is deprecated and should be changed to just `@register`. [#1228](https://github.com/jupyter-widgets/ipywidgets/pull/1228), [#1276](https://github.com/jupyter-widgets/ipywidgets/pull/1276) +- On the Python/kernel side: + - The Python `@register` decorator for widget classes no longer takes a string argument, but registers a widget class using the `_model_*` and `_view_*` traits in the class. Using the decorator as `@register('name')` is deprecated and should be changed to just `@register`. [#1228](https://github.com/jupyter-widgets/ipywidgets/pull/1228), [#1276](https://github.com/jupyter-widgets/ipywidgets/pull/1276) - Widgets will now need correct `_model_module` and `_view_module` Unicode traits defined. - Selection widgets now sync the index of the selected item, rather than the label. ([#1262](https://github.com/jupyter-widgets/ipywidgets/pull/1262)) - - The python `ipywidget.domwidget.LabeledWidget` is now `ipywidget.widget_description.DescriptionWidget`, and there is a new `ipywidget.widget_description.DescriptionStyle` that lets the user set the CSS width of the description. + - The Python `ipywidget.domwidget.LabeledWidget` is now `ipywidget.widget_description.DescriptionWidget`, and there is a new `ipywidget.widget_description.DescriptionStyle` that lets the user set the CSS width of the description. + - Custom serializers can now return a structure that contains binary objects (`memoryview`, `bytearray`, or Python 3 `bytes` object). In this case, the sync message will be a binary message, which is much more efficient for binary data than base64-encoding. ([#1194](https://github.com/jupyter-widgets/ipywidgets/pull/1194), [#1595](https://github.com/jupyter-widgets/ipywidgets/pull/1595)) - On the Javascript side: - The `jupyter-js-widgets` Javascript package has been split into `@jupyter-widgets/base` package (containing base widget classes, the DOM widget, and the associated layout and style classes), and the `@jupyter-widgets/controls` package (containing the rest of the Jupyter widgets controls). Authors of custom widgets will need to update to depend on `@jupyter-widgets/base` instead of `jupyter-js-widgets` (if you use a class from the controls package, you will also need to depend on `@jupyter-widgets/controls`). See the [cookie cutter](https://github.com/jupyter-widgets/widget-cookiecutter) to generate a simple example custom widget using the new packages. - Custom serializers in Javascript are now synchronous, and should return a snapshot of the widget state. The default serializer makes a copy of JSONable objects. ([#1270](https://github.com/jupyter-widgets/ipywidgets/pull/1270)) + - Custom serializers can now return a structure that contains binary objects (`ArrayBuffer`, `DataView`, or a typed array such as `Int8Array`, `Float64Array`, etc.). In this case, the sync message will be a binary message, which is much more efficient for binary data than base64-encoding. ([#1194](https://github.com/jupyter-widgets/ipywidgets/pull/1194)) - A custom serializer is given the widget instance as its second argument, and a custom deserializer is given the widget manager as its second argument. - The Javascript model `.id` attribute has been renamed to `.model_id` to avoid conflicting with the Backbone `.id` attribute. ([#1410](https://github.com/jupyter-widgets/ipywidgets/pull/1410)) - Regarding widget managers and the syncing message protocol: diff --git a/ipywidgets/widgets/tests/test_set_state.py b/ipywidgets/widgets/tests/test_set_state.py new file mode 100644 index 0000000000..3ee7c6cae4 --- /dev/null +++ b/ipywidgets/widgets/tests/test_set_state.py @@ -0,0 +1,125 @@ +# Copyright (c) Jupyter Development Team. +# Distributed under the terms of the Modified BSD License. + +from ipython_genutils.py3compat import PY3 + +import nose.tools as nt + +from traitlets import Bool, Tuple, List, Instance + +from .utils import setup, teardown + +from ..widget import Widget + +# +# First some widgets to test on: +# + +# A widget with simple traits (list + tuple to ensure both are handled) +class SimpleWidget(Widget): + a = Bool().tag(sync=True) + b = Tuple(Bool(), Bool(), Bool(), default_value=(False, False, False)).tag(sync=True) + c = List(Bool()).tag(sync=True) + + + +# A widget where the data might be changed on reception: +def transform_fromjson(data, widget): + # Switch the two last elements when setting from json, if the first element is True + # and always set first element to False + if not data[0]: + return data + return [False] + data[1:-2] + [data[-1], data[-2]] + +class TransformerWidget(Widget): + d = List(Bool()).tag(sync=True, from_json=transform_fromjson) + + + +# A widget that has a buffer: +class DataInstance(): + def __init__(self, data=None): + self.data = data + +def mview_serializer(instance, widget): + return { 'data': memoryview(instance.data) if instance.data else None } + +def bytes_serializer(instance, widget): + return { 'data': bytearray(memoryview(instance.data).tobytes()) if instance.data else None } + +def deserializer(json_data, widget): + return DataInstance( memoryview(json_data['data']).tobytes() if json_data else None ) + +class DataWidget(SimpleWidget): + d = Instance(DataInstance).tag(sync=True, to_json=mview_serializer, from_json=deserializer) + +# A widget that has a buffer that might be changed on reception: +def truncate_deserializer(json_data, widget): + return DataInstance( json_data['data'][:20].tobytes() if json_data else None ) + +class TruncateDataWidget(SimpleWidget): + d = Instance(DataInstance).tag(sync=True, to_json=bytes_serializer, from_json=truncate_deserializer) + + +# +# Actual tests: +# + +def test_set_state_simple(): + w = SimpleWidget() + w.set_state(dict( + a=True, + b=[True, False, True], + c=[False, True, False], + )) + + nt.assert_equal(w.comm.messages, []) + + +def test_set_state_transformer(): + w = TransformerWidget() + w.set_state(dict( + d=[True, False, True] + )) + # Since the deserialize step changes the state, this should send an update + nt.assert_equal(w.comm.messages, [((), dict( + buffers=[], + data=dict( + buffer_paths=[], + method='update', + state=dict(d=[False, True, False]) + )))]) + + +def test_set_state_data(): + w = DataWidget() + data = memoryview(b'x'*30) + w.set_state(dict( + a=True, + d={'data': data}, + )) + nt.assert_equal(w.comm.messages, []) + + +def test_set_state_data_truncate(): + w = TruncateDataWidget() + data = memoryview(b'x'*30) + w.set_state(dict( + a=True, + d={'data': data}, + )) + # Get message for checking + nt.assert_equal(len(w.comm.messages), 1) # ensure we didn't get more than expected + msg = w.comm.messages[0] + # Assert that the data update (truncation) sends an update + buffers = msg[1].pop('buffers') + nt.assert_equal(msg, ((), dict( + data=dict( + buffer_paths=[['d', 'data']], + method='update', + state=dict(d={}) + )))) + + # Sanity: + nt.assert_equal(len(buffers), 1) + nt.assert_equal(buffers[0], data[:20].tobytes()) diff --git a/ipywidgets/widgets/tests/utils.py b/ipywidgets/widgets/tests/utils.py index e7e03c2b7a..f1ac30f64d 100644 --- a/ipywidgets/widgets/tests/utils.py +++ b/ipywidgets/widgets/tests/utils.py @@ -6,12 +6,17 @@ class DummyComm(Comm): comm_id = 'a-b-c-d' + kernel = 'Truthy' + + def __init__(self, *args, **kwargs): + super(DummyComm, self).__init__(*args, **kwargs) + self.messages = [] def open(self, *args, **kwargs): pass def send(self, *args, **kwargs): - pass + self.messages.append((args, kwargs)) def close(self, *args, **kwargs): pass @@ -33,6 +38,7 @@ def teardown_test_comm(): delattr(Widget, attr) else: setattr(Widget, attr, value) + _widget_attrs.clear() def setup(): setup_test_comm() diff --git a/ipywidgets/widgets/widget.py b/ipywidgets/widgets/widget.py index 79f7a4ba1b..28cc1d5bb7 100644 --- a/ipywidgets/widgets/widget.py +++ b/ipywidgets/widgets/widget.py @@ -17,7 +17,7 @@ Undefined) from ipython_genutils.py3compat import string_types, PY3 from IPython.display import display -from json import loads as jsonloads, dumps as jsondumps +from json import dumps as jsondumps from base64 import standard_b64decode, standard_b64encode @@ -50,9 +50,9 @@ def _json_to_widget(x, obj): } if PY3: - _binary_types = (memoryview, bytes) + _binary_types = (memoryview, bytearray, bytes) else: - _binary_types = (memoryview, buffer) + _binary_types = (memoryview, bytearray) def _put_buffers(state, buffer_paths, buffers): """The inverse of _remove_buffers, except here we modify the existing dict/lists. @@ -115,6 +115,8 @@ def _separate_buffers(substate, path, buffer_paths, buffers): def _remove_buffers(state): """Return (state_without_buffers, buffer_paths, buffers) for binary message parts + A binary message part is a memoryview, bytearray, or python 3 bytes object. + As an example: >>> state = {'plain': [0, 'text'], 'x': {'ar': memoryview(ar1)}, 'y': {'shape': (10,10), 'data': memoryview(ar2)}} >>> _remove_buffers(state) @@ -125,6 +127,42 @@ def _remove_buffers(state): state = _separate_buffers(state, [], buffer_paths, buffers) return state, buffer_paths, buffers +def _buffer_list_equal(a, b): + """Compare two lists of buffers for equality. + + Used to decide whether two sequences of buffers (memoryviews, + bytearrays, or python 3 bytes) differ, such that a sync is needed. + + Returns True if equal, False if unequal + """ + if len(a) != len(b): + return False + if a == b: + return True + for ia, ib in zip(a, b): + # Check byte equality, since bytes are what is actually synced + # NOTE: Simple ia != ib does not always work as intended, as + # e.g. memoryview(np.frombuffer(ia, dtype='float32')) != + # memoryview(np.frombuffer(b)), since the format info differs. + if PY3: + # compare without copying + if memoryview(ia).cast('B') != memoryview(ib).cast('B'): + return False + else: + # python 2 doesn't have memoryview.cast, so we may have to copy + if isinstance(ia, memoryview) and ia.format != 'B': + ia = ia.tobytes() + if isinstance(ib, memoryview) and ib.format != 'B': + ib = ib.tobytes() + if ia != ib: + return False + return True + +def _json_equal(a, b): + """Compare the JSON strings generated by two objects.""" + return (jsondumps(a, separators=(',',':'), sort_keys=True, allow_nan=False) + == jsondumps(b, separators=(',',':'), sort_keys=True, allow_nan=False)) + class LoggingHasTraits(HasTraits): """A parent class for HasTraits that log. Subclasses have a log trait, and the default behavior @@ -597,10 +635,16 @@ def _should_send_property(self, key, value): # A roundtrip conversion through json in the comparison takes care of # idiosyncracies of how python data structures map to json, for example # tuples get converted to lists. - if (key in self._property_lock - and jsonloads(jsondumps(to_json(value, self))) == self._property_lock[key]): - return False - elif self._holding_sync: + if key in self._property_lock: + # model_state, buffer_paths, buffers + split_value = _remove_buffers({ key: to_json(value, self)}) + split_lock = _remove_buffers({ key: self._property_lock[key]}) + # Compare state and buffer_paths + if (_json_equal(split_value[0], split_lock[0]) + and split_value[1] == split_lock[1] + and _buffer_list_equal(split_value[2], split_lock[2])): + return False + if self._holding_sync: self._states_to_send.add(key) return False else: