Skip to content

Commit 17a3e45

Browse files
authored
Merge pull request #1194 from maartenbreddels/nested-buffers
Any descendant of the state can be a binary_type
2 parents 9c5c8d0 + 036487c commit 17a3e45

File tree

5 files changed

+259
-46
lines changed

5 files changed

+259
-46
lines changed

ipywidgets/widgets/tests/test_traits.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@
22
# Distributed under the terms of the Modified BSD License.
33

44
"""Test trait types of the widget packages."""
5+
import array
56

67
from unittest import TestCase
78
from traitlets import HasTraits
89
from traitlets.tests.test_traitlets import TraitTestBase
910
from ipywidgets import Color
11+
from ipywidgets.widgets.widget import _remove_buffers, _put_buffers
1012

1113

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

24+
25+
class TestBuffers(TestCase):
26+
def test_remove_and_put_buffers(self):
27+
mv1 = memoryview(b'test1')
28+
mv2 = memoryview(b'test2')
29+
state = {'plain': [0, 'text'], # should not get removed
30+
'x': {'ar': mv1}, # should result in an empty dict
31+
'y': {'shape': (10, 10), 'data': mv1},
32+
'z': (mv1, mv2), # tests tuple assigment
33+
'top': mv1, # test a top level removal
34+
'deep': {'a': 1, 'b':[0,{'deeper':mv2}]}} # deeply nested
35+
plain = state['plain']
36+
x = state['x']
37+
y = state['y']
38+
y_shape = y['shape']
39+
state_before = state
40+
state, buffer_paths, buffers = _remove_buffers(state)
41+
42+
# check if buffers are removed
43+
self.assertIn('plain', state)
44+
self.assertIn('shape', state['y'])
45+
self.assertNotIn('ar', state['x'])
46+
self.assertEqual(state['x'], {})
47+
self.assertNotIn('data', state['y'])
48+
self.assertNotIn(mv1, state['z'])
49+
self.assertNotIn(mv1, state['z'])
50+
self.assertNotIn('top', state)
51+
self.assertIn('deep', state)
52+
self.assertIn('b', state['deep'])
53+
self.assertNotIn('deeper', state['deep']['b'][1])
54+
55+
# check that items that didn't need change aren't touched
56+
self.assertIsNot(state, state_before)
57+
self.assertIs(state['plain'], plain)
58+
self.assertIsNot(state['x'], x)
59+
self.assertIsNot(state['y'], y)
60+
self.assertIs(state['y']['shape'], y_shape)
61+
62+
# check that the buffer paths really point to the right buffer
63+
for path, buffer in [(['x', 'ar'], mv1), (['y', 'data'], mv1), (['z', 0], mv1), (['z', 1], mv2),\
64+
(['top'], mv1), (['deep', 'b', 1, 'deeper'], mv2)]:
65+
self.assertIn(path, buffer_paths, "%r not in path" % path)
66+
index = buffer_paths.index(path)
67+
self.assertEqual(buffer, buffers[index])
68+
69+
# and check that we can put it back together again
70+
_put_buffers(state, buffer_paths, buffers)
71+
# we know that tuples get converted to list, so help the comparison by changing the tuple to a list
72+
state_before['z'] = list(state_before['z'])
73+
self.assertEqual(state_before, state)

ipywidgets/widgets/widget.py

Lines changed: 80 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,78 @@ def _json_to_widget(x, obj):
5050
else:
5151
_binary_types = (memoryview, buffer)
5252

53+
def _put_buffers(state, buffer_paths, buffers):
54+
"""The inverse of _remove_buffers, except here we modify the existing dict/lists.
55+
Modifying should be fine, since this is used when state comes from the wire.
56+
"""
57+
for buffer_path, buffer in zip(buffer_paths, buffers):
58+
# we'd like to set say sync_data['x'][0]['y'] = buffer
59+
# where buffer_path in this example would be ['x', 0, 'y']
60+
obj = state
61+
for key in buffer_path[:-1]:
62+
obj = obj[key]
63+
obj[buffer_path[-1]] = buffer
64+
65+
def _separate_buffers(substate, path, buffer_paths, buffers):
66+
"""For internal, see _remove_buffers"""
67+
# remove binary types from dicts and lists, but keep track of their paths
68+
# any part of the dict/list that needs modification will be cloned, so the original stays untouched
69+
# e.g. {'x': {'ar': ar}, 'y': [ar2, ar3]}, where ar/ar2/ar3 are binary types
70+
# will result in {'x': {}, 'y': [None, None]}, [ar, ar2, ar3], [['x', 'ar'], ['y', 0], ['y', 1]]
71+
# instead of removing elements from the list, this will make replacing the buffers on the js side much easier
72+
if isinstance(substate, (list, tuple)):
73+
is_cloned = False
74+
for i, v in enumerate(substate):
75+
if isinstance(v, _binary_types):
76+
if not is_cloned:
77+
substate = list(substate) # shallow clone list/tuple
78+
is_cloned = True
79+
substate[i] = None
80+
buffers.append(v)
81+
buffer_paths.append(path + [i])
82+
elif isinstance(v, (dict, list, tuple)):
83+
vnew = _separate_buffers(v, path + [i], buffer_paths, buffers)
84+
if v is not vnew: # only assign when value changed
85+
if not is_cloned:
86+
substate = list(substate) # clone list/tuple
87+
is_cloned = True
88+
substate[i] = vnew
89+
elif isinstance(substate, dict):
90+
is_cloned = False
91+
for k, v in substate.items():
92+
if isinstance(v, _binary_types):
93+
if not is_cloned:
94+
substate = dict(substate) # shallow clone dict
95+
is_cloned = True
96+
del substate[k]
97+
buffers.append(v)
98+
buffer_paths.append(path + [k])
99+
elif isinstance(v, (dict, list, tuple)):
100+
vnew = _separate_buffers(v, path + [k], buffer_paths, buffers)
101+
if v is not vnew: # only assign when value changed
102+
if not is_cloned:
103+
substate = dict(substate) # clone list/tuple
104+
is_cloned = True
105+
substate[k] = vnew
106+
else:
107+
raise ValueError("expected state to be a list or dict, not %r" % substate)
108+
return substate
109+
110+
111+
def _remove_buffers(state):
112+
"""Return (state_without_buffers, buffer_paths, buffers) for binary message parts
113+
114+
As an example:
115+
>>> state = {'plain': [0, 'text'], 'x': {'ar': memoryview(ar1)}, 'y': {'shape': (10,10), 'data': memoryview(ar2)}}
116+
>>> _remove_buffers(state)
117+
({'plain': [0, 'text']}, {'x': {}, 'y': {'shape': (10, 10)}}, [['x', 'ar'], ['y', 'data']],
118+
[<memory at 0x107ffec48>, <memory at 0x107ffed08>])
119+
"""
120+
buffer_paths, buffers = [], []
121+
state = _separate_buffers(state, [], buffer_paths, buffers)
122+
return state, buffer_paths, buffers
123+
124+
53125
class CallbackDispatcher(LoggingConfigurable):
54126
"""A structure for registering and running callbacks"""
55127
callbacks = List()
@@ -213,17 +285,15 @@ def __del__(self):
213285
def open(self):
214286
"""Open a comm to the frontend if one isn't already open."""
215287
if self.comm is None:
216-
state, buffer_keys, buffers = self._split_state_buffers(self.get_state())
288+
state, buffer_paths, buffers = _remove_buffers(self.get_state())
217289

218-
args = dict(target_name='jupyter.widget', data=state)
290+
args = dict(target_name='jupyter.widget',
291+
data={'state': state, 'buffer_paths': buffer_paths},
292+
buffers=buffers)
219293
if self._model_id is not None:
220294
args['comm_id'] = self._model_id
221295

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

228298
@observe('comm')
229299
def _comm_changed(self, change):
@@ -258,16 +328,6 @@ def close(self):
258328
self.comm = None
259329
self._ipython_display_ = None
260330

261-
def _split_state_buffers(self, state):
262-
"""Return (state_without_buffers, buffer_keys, buffers) for binary message parts"""
263-
buffer_keys, buffers = [], []
264-
for k, v in list(state.items()):
265-
if isinstance(v, _binary_types):
266-
state.pop(k)
267-
buffers.append(v)
268-
buffer_keys.append(k)
269-
return state, buffer_keys, buffers
270-
271331
def send_state(self, key=None):
272332
"""Sends the widget state, or a piece of it, to the front-end.
273333
@@ -277,8 +337,8 @@ def send_state(self, key=None):
277337
A single property's name or iterable of property names to sync with the front-end.
278338
"""
279339
state = self.get_state(key=key)
280-
state, buffer_keys, buffers = self._split_state_buffers(state)
281-
msg = {'method': 'update', 'state': state, 'buffers': buffer_keys}
340+
state, buffer_paths, buffers = _remove_buffers(state)
341+
msg = {'method': 'update', 'state': state, 'buffer_paths': buffer_paths}
282342
self._send(msg, buffers=buffers)
283343

284344
def get_state(self, key=None, drop_defaults=False):
@@ -453,8 +513,8 @@ def _handle_msg(self, msg):
453513
if 'sync_data' in data:
454514
# get binary buffers too
455515
sync_data = data['sync_data']
456-
for i,k in enumerate(data.get('buffer_keys', [])):
457-
sync_data[k] = msg['buffers'][i]
516+
if 'buffer_paths' in data:
517+
_put_buffers(sync_data, data['buffer_paths'], msg['buffers'])
458518
self.set_state(sync_data) # handles all methods
459519

460520
# Handle a state request.

jupyter-js-widgets/src/manager-base.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -185,12 +185,14 @@ abstract class ManagerBase<T> {
185185
* Handle when a comm is opened.
186186
*/
187187
handle_comm_open(comm: shims.services.Comm, msg: services.KernelMessage.ICommOpenMsg): Promise<Backbone.Model> {
188+
var data = (msg.content.data as any);
189+
utils.put_buffers(data.state, data.buffer_paths, msg.buffers)
188190
return this.new_model({
189-
model_name: msg.content.data['_model_name'],
190-
model_module: msg.content.data['_model_module'],
191-
model_module_version: msg.content.data['_model_module_version'],
191+
model_name: data.state['_model_name'],
192+
model_module: data.state['_model_module'],
193+
model_module_version: data.state['_model_module_version'],
192194
comm: comm
193-
}, msg.content.data).catch(utils.reject('Could not create a model.', true));
195+
}, data.state).catch(utils.reject('Could not create a model.', true));
194196
};
195197

196198
/**

jupyter-js-widgets/src/utils.ts

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
// Copyright (c) Jupyter Development Team.
22
// Distributed under the terms of the Modified BSD License.
33

4+
import * as _ from 'underscore';
5+
46
// TODO: ATTEMPT TO KILL THIS MODULE USING THIRD PARTY LIBRARIES WHEN IPYWIDGETS
57
// IS CONVERTED TO NODE COMMONJS.
68

@@ -169,3 +171,111 @@ function escape_html(text: string): string {
169171
esc.textContent = text;
170172
return esc.innerHTML;
171173
};
174+
175+
/**
176+
* Takes an object 'state' and fills in buffer[i] at 'path' buffer_paths[i]
177+
* where buffer_paths[i] is a list indicating where in the object buffer[i] should
178+
* be placed
179+
* Example: state = {a: 1, b: {}, c: [0, null]}
180+
* buffers = [array1, array2]
181+
* buffer_paths = [['b', 'data'], ['c', 1]]
182+
* Will lead to {a: 1, b: {data: array1}, c: [0, array2]}
183+
*/
184+
export
185+
function put_buffers(state, buffer_paths, buffers) {
186+
for (let i=0; i<buffer_paths.length; i++) {
187+
let buffer_path = buffer_paths[i];
188+
// say we want to set state[x][y][z] = buffers[i]
189+
let obj = state;
190+
// we first get obj = state[x][y]
191+
for (let j = 0; j < buffer_path.length-1; j++)
192+
obj = obj[buffer_path[j]];
193+
// and then set: obj[z] = buffers[i]
194+
obj[buffer_path[buffer_path.length-1]] = buffers[i];
195+
}
196+
}
197+
198+
/**
199+
* The inverse of put_buffers, return an objects with the new state where all buffers(ArrayBuffer)
200+
* are removed. If a buffer is a member of an object, that object is cloned, and the key removed. If a buffer
201+
* is an element of an array, that array is cloned, and the element is set to null.
202+
* See put_buffers for the meaning of buffer_paths
203+
* Returns an object with the new state (.state) an array with paths to the buffers (.buffer_paths),
204+
* and the buffers associated to those paths (.buffers).
205+
*/
206+
export
207+
function remove_buffers(state) {
208+
let buffers = [];
209+
let buffer_paths = [];
210+
// if we need to remove an object from a list, we need to clone that list, otherwise we may modify
211+
// the internal state of the widget model
212+
// however, we do not want to clone everything, for performance
213+
function remove(obj, path) {
214+
if (obj.toJSON) {
215+
// We need to get the JSON form of the object before recursing.
216+
// See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify#toJSON()_behavior
217+
obj = obj.toJSON();
218+
}
219+
if (Array.isArray(obj)) {
220+
let is_cloned = false;
221+
for (let i = 0; i < obj.length; i++) {
222+
let value = obj[i];
223+
if(value) {
224+
if (value.buffer instanceof ArrayBuffer || value instanceof ArrayBuffer) {
225+
if(!is_cloned) {
226+
obj = _.clone(obj);
227+
is_cloned = true;
228+
}
229+
buffers.push(value);
230+
buffer_paths.push(path.concat([i]));
231+
// easier to just keep the array, but clear the entry, otherwise we have to think
232+
// about array length, much easier this way
233+
obj[i] = null;
234+
} else {
235+
let new_value = remove(value, path.concat([i]));
236+
// only assigned when the value changes, we may serialize objects that don't support assignment
237+
if(new_value !== value) {
238+
if(!is_cloned) {
239+
obj = _.clone(obj);
240+
is_cloned = true;
241+
}
242+
obj[i] = new_value;
243+
}
244+
}
245+
}
246+
}
247+
} else if(_.isObject(obj)) {
248+
for (let key in obj) {
249+
let is_cloned = false;
250+
if (obj.hasOwnProperty(key)) {
251+
let value = obj[key];
252+
if(value) {
253+
if (value.buffer instanceof ArrayBuffer || value instanceof ArrayBuffer) {
254+
if(!is_cloned) {
255+
obj = _.clone(obj);
256+
is_cloned = true;
257+
}
258+
buffers.push(value);
259+
buffer_paths.push(path.concat([key]));
260+
delete obj[key]; // for objects/dicts we just delete them
261+
}
262+
else {
263+
let new_value = remove(value, path.concat([key]));
264+
// only assigned when the value changes, we may serialize objects that don't support assignment
265+
if(new_value !== value) {
266+
if(!is_cloned) {
267+
obj = _.clone(obj);
268+
is_cloned = true;
269+
}
270+
obj[key] = new_value;
271+
}
272+
}
273+
}
274+
}
275+
}
276+
}
277+
return obj;
278+
}
279+
let new_state = remove(state, []);
280+
return {state: new_state, buffers: buffers, buffer_paths: buffer_paths}
281+
}

jupyter-js-widgets/src/widget.ts

Lines changed: 11 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -168,12 +168,11 @@ class WidgetModel extends Backbone.Model {
168168
case 'update':
169169
this.state_change = this.state_change
170170
.then(() => {
171-
var state = msg.content.data.state || {};
172-
var buffer_keys = msg.content.data.buffers || [];
171+
// see Widget.open/_split_state_buffers about why we need state_with_buffers
172+
var state = msg.content.data.state;
173+
var buffer_paths = msg.content.data.buffer_paths || [];
173174
var buffers = msg.buffers || [];
174-
for (var i=0; i<buffer_keys.length; i++) {
175-
state[buffer_keys[i]] = buffers[i];
176-
}
175+
utils.put_buffers(state, buffer_paths, buffers);
177176
return (this.constructor as typeof WidgetModel)._deserialize_state(state, this.widget_manager);
178177
}).then((state) => {
179178
this.set_state(state);
@@ -394,25 +393,15 @@ class WidgetModel extends Backbone.Model {
394393
utils.resolvePromisesDict(attrs).then((state) => {
395394
// get binary values, then send
396395
var keys = Object.keys(state);
397-
var buffers = [];
398-
var buffer_keys = [];
399-
for (var i=0; i<keys.length; i++) {
400-
var key = keys[i];
401-
var value = state[key];
402-
if (value) {
403-
if (value.buffer instanceof ArrayBuffer
404-
|| value instanceof ArrayBuffer) {
405-
buffers.push(value);
406-
buffer_keys.push(key);
407-
delete state[key];
408-
}
409-
}
410-
}
396+
// this function goes through lists and object and removes arraybuffers
397+
// they will be transferred separately, since they don't json'ify
398+
// on the python side the inverse happens
399+
var split = utils.remove_buffers(state);
411400
this.comm.send({
412401
method: 'backbone',
413-
sync_data: state,
414-
buffer_keys: buffer_keys
415-
}, callbacks, {}, buffers);
402+
sync_data: split.state,
403+
buffer_paths: split.buffer_paths
404+
}, callbacks, {}, split.buffers);
416405
}).catch((error) => {
417406
this.pending_msgs--;
418407
return (utils.reject('Could not send widget sync message', true))(error);

0 commit comments

Comments
 (0)