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

Any descendant of the state can be a binary_type #1194

Merged
merged 21 commits into from
Mar 21, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
8803a05
Any descendant of the state can be a binary_type now, usefull for ser…
maartenbreddels Mar 8, 2017
035e75e
serializing of nested buffer supported
maartenbreddels Mar 8, 2017
01e03ca
better docstring for _split_state_buffers
maartenbreddels Mar 9, 2017
7866d2c
fix: although state is a copy, it's ancestors may refer to original d…
maartenbreddels Mar 15, 2017
542940d
if a top level key enters twice, don't try to remove it twice
maartenbreddels Mar 15, 2017
761b62f
added unittest, make _split_state_buffer a function instead of method…
maartenbreddels Mar 15, 2017
1fa8e08
fix: toplevel buffers triggered a bug, since items were already remov…
maartenbreddels Mar 15, 2017
caadc22
test: using byte/string for memoryview for python27 compat
maartenbreddels Mar 15, 2017
1fccf08
fix unittest
maartenbreddels Mar 15, 2017
d5740b4
better docstring
maartenbreddels Mar 16, 2017
9d896f7
not using a seperate state with keeps the state which have buffer, mu…
maartenbreddels Mar 16, 2017
cca2110
_split_state_buffer renamed for ts/js consistency, _remove_buffers/_p…
maartenbreddels Mar 18, 2017
33e7569
Support object toJSON serialization in js
jasongrout Mar 20, 2017
926d40a
!= -> !==
jasongrout Mar 20, 2017
6ff791a
We can assume hasOwnProperty in our supported browsers.
jasongrout Mar 20, 2017
03323a6
whitespace changes
jasongrout Mar 20, 2017
91e8497
fix some unit tests
jasongrout Mar 20, 2017
abe3ee8
seperate -> separate
jasongrout Mar 20, 2017
c3e13ec
Add buffers to args dict on creation.
jasongrout Mar 20, 2017
0a1dffb
var -> let
jasongrout Mar 20, 2017
036487c
add newline
jasongrout Mar 20, 2017
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
52 changes: 52 additions & 0 deletions ipywidgets/widgets/tests/test_traits.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@
# Distributed under the terms of the Modified BSD License.

"""Test trait types of the widget packages."""
import array

from unittest import TestCase
from traitlets import HasTraits
from traitlets.tests.test_traitlets import TraitTestBase
from ipywidgets import Color
from ipywidgets.widgets.widget import _remove_buffers, _put_buffers


class ColorTrait(HasTraits):
Expand All @@ -19,3 +21,53 @@ class TestColor(TraitTestBase):
_good_values = ["blue", "#AA0", "#FFFFFF"]
_bad_values = ["vanilla", "blues"]


class TestBuffers(TestCase):
def test_remove_and_put_buffers(self):
mv1 = memoryview(b'test1')
mv2 = memoryview(b'test2')
state = {'plain': [0, 'text'], # should not get removed
'x': {'ar': mv1}, # should result in an empty dict
'y': {'shape': (10, 10), 'data': mv1},
'z': (mv1, mv2), # tests tuple assigment
'top': mv1, # test a top level removal
'deep': {'a': 1, 'b':[0,{'deeper':mv2}]}} # deeply nested
plain = state['plain']
x = state['x']
y = state['y']
y_shape = y['shape']
state_before = state
state, buffer_paths, buffers = _remove_buffers(state)

# check if buffers are removed
self.assertIn('plain', state)
self.assertIn('shape', state['y'])
self.assertNotIn('ar', state['x'])
self.assertEqual(state['x'], {})
self.assertNotIn('data', state['y'])
self.assertNotIn(mv1, state['z'])
self.assertNotIn(mv1, state['z'])
self.assertNotIn('top', state)
self.assertIn('deep', state)
self.assertIn('b', state['deep'])
self.assertNotIn('deeper', state['deep']['b'][1])

# check that items that didn't need change aren't touched
self.assertIsNot(state, state_before)
self.assertIs(state['plain'], plain)
self.assertIsNot(state['x'], x)
self.assertIsNot(state['y'], y)
self.assertIs(state['y']['shape'], y_shape)

# check that the buffer paths really point to the right buffer
for path, buffer in [(['x', 'ar'], mv1), (['y', 'data'], mv1), (['z', 0], mv1), (['z', 1], mv2),\
(['top'], mv1), (['deep', 'b', 1, 'deeper'], mv2)]:
self.assertIn(path, buffer_paths, "%r not in path" % path)
index = buffer_paths.index(path)
self.assertEqual(buffer, buffers[index])

# and check that we can put it back together again
_put_buffers(state, buffer_paths, buffers)
# we know that tuples get converted to list, so help the comparison by changing the tuple to a list
state_before['z'] = list(state_before['z'])
self.assertEqual(state_before, state)
100 changes: 80 additions & 20 deletions ipywidgets/widgets/widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,78 @@ def _json_to_widget(x, obj):
else:
_binary_types = (memoryview, buffer)

def _put_buffers(state, buffer_paths, buffers):
"""The inverse of _remove_buffers, except here we modify the existing dict/lists.
Modifying should be fine, since this is used when state comes from the wire.
"""
for buffer_path, buffer in zip(buffer_paths, buffers):
# we'd like to set say sync_data['x'][0]['y'] = buffer
# where buffer_path in this example would be ['x', 0, 'y']
obj = state
for key in buffer_path[:-1]:
obj = obj[key]
obj[buffer_path[-1]] = buffer

def _separate_buffers(substate, path, buffer_paths, buffers):
"""For internal, see _remove_buffers"""
# remove binary types from dicts and lists, but keep track of their paths
# any part of the dict/list that needs modification will be cloned, so the original stays untouched
# e.g. {'x': {'ar': ar}, 'y': [ar2, ar3]}, where ar/ar2/ar3 are binary types
# will result in {'x': {}, 'y': [None, None]}, [ar, ar2, ar3], [['x', 'ar'], ['y', 0], ['y', 1]]
# instead of removing elements from the list, this will make replacing the buffers on the js side much easier
if isinstance(substate, (list, tuple)):
is_cloned = False
for i, v in enumerate(substate):
if isinstance(v, _binary_types):
if not is_cloned:
substate = list(substate) # shallow clone list/tuple
is_cloned = True
substate[i] = None
buffers.append(v)
buffer_paths.append(path + [i])
elif isinstance(v, (dict, list, tuple)):
vnew = _separate_buffers(v, path + [i], buffer_paths, buffers)
if v is not vnew: # only assign when value changed
if not is_cloned:
substate = list(substate) # clone list/tuple
is_cloned = True
substate[i] = vnew
elif isinstance(substate, dict):
is_cloned = False
for k, v in substate.items():
if isinstance(v, _binary_types):
if not is_cloned:
substate = dict(substate) # shallow clone dict
is_cloned = True
del substate[k]
buffers.append(v)
buffer_paths.append(path + [k])
elif isinstance(v, (dict, list, tuple)):
vnew = _separate_buffers(v, path + [k], buffer_paths, buffers)
if v is not vnew: # only assign when value changed
if not is_cloned:
substate = dict(substate) # clone list/tuple
is_cloned = True
substate[k] = vnew
else:
raise ValueError("expected state to be a list or dict, not %r" % substate)
return substate


def _remove_buffers(state):
"""Return (state_without_buffers, buffer_paths, buffers) for binary message parts

As an example:
>>> state = {'plain': [0, 'text'], 'x': {'ar': memoryview(ar1)}, 'y': {'shape': (10,10), 'data': memoryview(ar2)}}
>>> _remove_buffers(state)
({'plain': [0, 'text']}, {'x': {}, 'y': {'shape': (10, 10)}}, [['x', 'ar'], ['y', 'data']],
[<memory at 0x107ffec48>, <memory at 0x107ffed08>])
"""
buffer_paths, buffers = [], []
state = _separate_buffers(state, [], buffer_paths, buffers)
return state, buffer_paths, buffers


class CallbackDispatcher(LoggingConfigurable):
"""A structure for registering and running callbacks"""
callbacks = List()
Expand Down Expand Up @@ -213,17 +285,15 @@ def __del__(self):
def open(self):
"""Open a comm to the frontend if one isn't already open."""
if self.comm is None:
state, buffer_keys, buffers = self._split_state_buffers(self.get_state())
state, buffer_paths, buffers = _remove_buffers(self.get_state())

args = dict(target_name='jupyter.widget', data=state)
args = dict(target_name='jupyter.widget',
data={'state': state, 'buffer_paths': buffer_paths},
buffers=buffers)
if self._model_id is not None:
args['comm_id'] = self._model_id

self.comm = Comm(**args)
if buffers:
# FIXME: workaround ipykernel missing binary message support in open-on-init
# send state with binary elements as second message
self.send_state()

@observe('comm')
def _comm_changed(self, change):
Expand Down Expand Up @@ -258,16 +328,6 @@ def close(self):
self.comm = None
self._ipython_display_ = None

def _split_state_buffers(self, state):
"""Return (state_without_buffers, buffer_keys, buffers) for binary message parts"""
buffer_keys, buffers = [], []
for k, v in list(state.items()):
if isinstance(v, _binary_types):
state.pop(k)
buffers.append(v)
buffer_keys.append(k)
return state, buffer_keys, buffers

def send_state(self, key=None):
"""Sends the widget state, or a piece of it, to the front-end.

Expand All @@ -277,8 +337,8 @@ def send_state(self, key=None):
A single property's name or iterable of property names to sync with the front-end.
"""
state = self.get_state(key=key)
state, buffer_keys, buffers = self._split_state_buffers(state)
msg = {'method': 'update', 'state': state, 'buffers': buffer_keys}
state, buffer_paths, buffers = _remove_buffers(state)
msg = {'method': 'update', 'state': state, 'buffer_paths': buffer_paths}
self._send(msg, buffers=buffers)

def get_state(self, key=None, drop_defaults=False):
Expand Down Expand Up @@ -453,8 +513,8 @@ def _handle_msg(self, msg):
if 'sync_data' in data:
# get binary buffers too
sync_data = data['sync_data']
for i,k in enumerate(data.get('buffer_keys', [])):
sync_data[k] = msg['buffers'][i]
if 'buffer_paths' in data:
_put_buffers(sync_data, data['buffer_paths'], msg['buffers'])
self.set_state(sync_data) # handles all methods

# Handle a state request.
Expand Down
10 changes: 6 additions & 4 deletions jupyter-js-widgets/src/manager-base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,12 +185,14 @@ abstract class ManagerBase<T> {
* Handle when a comm is opened.
*/
handle_comm_open(comm: shims.services.Comm, msg: services.KernelMessage.ICommOpenMsg): Promise<Backbone.Model> {
var data = (msg.content.data as any);
utils.put_buffers(data.state, data.buffer_paths, msg.buffers)
return this.new_model({
model_name: msg.content.data['_model_name'],
model_module: msg.content.data['_model_module'],
model_module_version: msg.content.data['_model_module_version'],
model_name: data.state['_model_name'],
model_module: data.state['_model_module'],
model_module_version: data.state['_model_module_version'],
comm: comm
}, msg.content.data).catch(utils.reject('Could not create a model.', true));
}, data.state).catch(utils.reject('Could not create a model.', true));
};

/**
Expand Down
110 changes: 110 additions & 0 deletions jupyter-js-widgets/src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// Copyright (c) Jupyter Development Team.
// Distributed under the terms of the Modified BSD License.

import * as _ from 'underscore';

// TODO: ATTEMPT TO KILL THIS MODULE USING THIRD PARTY LIBRARIES WHEN IPYWIDGETS
// IS CONVERTED TO NODE COMMONJS.

Expand Down Expand Up @@ -169,3 +171,111 @@ function escape_html(text: string): string {
esc.textContent = text;
return esc.innerHTML;
};

/**
* Takes an object 'state' and fills in buffer[i] at 'path' buffer_paths[i]
* where buffer_paths[i] is a list indicating where in the object buffer[i] should
* be placed
* Example: state = {a: 1, b: {}, c: [0, null]}
* buffers = [array1, array2]
* buffer_paths = [['b', 'data'], ['c', 1]]
* Will lead to {a: 1, b: {data: array1}, c: [0, array2]}
*/
export
function put_buffers(state, buffer_paths, buffers) {
for (let i=0; i<buffer_paths.length; i++) {
let buffer_path = buffer_paths[i];
// say we want to set state[x][y][z] = buffers[i]
let obj = state;
// we first get obj = state[x][y]
for (let j = 0; j < buffer_path.length-1; j++)
obj = obj[buffer_path[j]];
// and then set: obj[z] = buffers[i]
obj[buffer_path[buffer_path.length-1]] = buffers[i];
}
}

/**
* The inverse of put_buffers, return an objects with the new state where all buffers(ArrayBuffer)
* are removed. If a buffer is a member of an object, that object is cloned, and the key removed. If a buffer
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just like on the python side, I think we should assume that the objects and lists given to us at this stage are already copies, and we can mutate them however we wish to get them onto the wire.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Furthermore, I think it's okay to assume that we don't have cyclic references. This translation step should really essentially be the last step before using JSON.stringify. I think these two assumptions will greatly simplify the code here. Also, if we don't have to clone, I think we can delete the underscore import at the top.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both I disagree with. I've been bit by modifying the data. Say you serialize (from py side), and array like {'data': memoryview(ar), shape: ar.shape}. And on the JS side, you work with this as an object. Then you do not want this js code to delete the data attribute.
Cyclic references exists :( If you don't take this into account, the Controller will trigger this.

Copy link
Member

@jasongrout jasongrout Mar 18, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the serialization step should make a copy of the object being sent (otherwise it's not sending a snapshot of the current data). So I think what we're working with here should be our own copy. (We'd make a concession to binary data here, i.e., we're not copying it since it's typically large.)

The Controller widget has cyclic references in the JSON structure that is stringified? Won't that cause an error in the stringification? That sounds like a problem.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, i even saw the widgetmanager with the promises being serialized, didnt look into it further though. Only saw that with Controller.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was surprised to see Promises being stringified as well.

* is an element of an array, that array is cloned, and the element is set to null.
* See put_buffers for the meaning of buffer_paths
* Returns an object with the new state (.state) an array with paths to the buffers (.buffer_paths),
* and the buffers associated to those paths (.buffers).
*/
export
function remove_buffers(state) {
let buffers = [];
let buffer_paths = [];
// if we need to remove an object from a list, we need to clone that list, otherwise we may modify
// the internal state of the widget model
// however, we do not want to clone everything, for performance
function remove(obj, path) {
if (obj.toJSON) {
// We need to get the JSON form of the object before recursing.
// See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify#toJSON()_behavior
obj = obj.toJSON();
}
if (Array.isArray(obj)) {
let is_cloned = false;
for (let i = 0; i < obj.length; i++) {
let value = obj[i];
if(value) {
if (value.buffer instanceof ArrayBuffer || value instanceof ArrayBuffer) {
if(!is_cloned) {
obj = _.clone(obj);
is_cloned = true;
}
buffers.push(value);
buffer_paths.push(path.concat([i]));
// easier to just keep the array, but clear the entry, otherwise we have to think
// about array length, much easier this way
obj[i] = null;
} else {
let new_value = remove(value, path.concat([i]));
// only assigned when the value changes, we may serialize objects that don't support assignment
if(new_value !== value) {
if(!is_cloned) {
obj = _.clone(obj);
is_cloned = true;
}
obj[i] = new_value;
}
}
}
}
} else if(_.isObject(obj)) {
for (let key in obj) {
let is_cloned = false;
if (obj.hasOwnProperty(key)) {
let value = obj[key];
if(value) {
if (value.buffer instanceof ArrayBuffer || value instanceof ArrayBuffer) {
if(!is_cloned) {
obj = _.clone(obj);
is_cloned = true;
}
buffers.push(value);
buffer_paths.push(path.concat([key]));
delete obj[key]; // for objects/dicts we just delete them
}
else {
let new_value = remove(value, path.concat([key]));
// only assigned when the value changes, we may serialize objects that don't support assignment
if(new_value !== value) {
if(!is_cloned) {
obj = _.clone(obj);
is_cloned = true;
}
obj[key] = new_value;
}
}
}
}
}
}
return obj;
}
let new_state = remove(state, []);
return {state: new_state, buffers: buffers, buffer_paths: buffer_paths}
}
33 changes: 11 additions & 22 deletions jupyter-js-widgets/src/widget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,12 +172,11 @@ class WidgetModel extends Backbone.Model {
case 'update':
this.state_change = this.state_change
.then(() => {
var state = msg.content.data.state || {};
var buffer_keys = msg.content.data.buffers || [];
// see Widget.open/_split_state_buffers about why we need state_with_buffers
var state = msg.content.data.state;
var buffer_paths = msg.content.data.buffer_paths || [];
var buffers = msg.buffers || [];
for (var i=0; i<buffer_keys.length; i++) {
state[buffer_keys[i]] = buffers[i];
}
utils.put_buffers(state, buffer_paths, buffers);
return (this.constructor as typeof WidgetModel)._deserialize_state(state, this.widget_manager);
}).then((state) => {
this.set_state(state);
Expand Down Expand Up @@ -398,25 +397,15 @@ class WidgetModel extends Backbone.Model {
utils.resolvePromisesDict(attrs).then((state) => {
// get binary values, then send
var keys = Object.keys(state);
var buffers = [];
var buffer_keys = [];
for (var i=0; i<keys.length; i++) {
var key = keys[i];
var value = state[key];
if (value) {
if (value.buffer instanceof ArrayBuffer
|| value instanceof ArrayBuffer) {
buffers.push(value);
buffer_keys.push(key);
delete state[key];
}
}
}
// this function goes through lists and object and removes arraybuffers
// they will be transferred separately, since they don't json'ify
// on the python side the inverse happens
var split = utils.remove_buffers(state);
this.comm.send({
method: 'backbone',
sync_data: state,
buffer_keys: buffer_keys
}, callbacks, {}, buffers);
sync_data: split.state,
buffer_paths: split.buffer_paths
}, callbacks, {}, split.buffers);
}).catch((error) => {
this.pending_msgs--;
return (utils.reject('Could not send widget sync message', true))(error);
Expand Down