From d6e249b0bdc1fa18cf2183c66a377626e58175e8 2015-03-31 18:37:34 From: Jonathan Frederic Date: 2015-03-31 18:37:34 Subject: [PATCH] Merge pull request #7757 from jasongrout/custom-serialization Custom serialization --- diff --git a/IPython/html/static/widgets/js/init.js b/IPython/html/static/widgets/js/init.js index 9dde6b0..da09f54 100644 --- a/IPython/html/static/widgets/js/init.js +++ b/IPython/html/static/widgets/js/init.js @@ -3,6 +3,7 @@ define([ "widgets/js/manager", + "widgets/js/widget", "widgets/js/widget_link", "widgets/js/widget_bool", "widgets/js/widget_button", @@ -14,21 +15,20 @@ define([ "widgets/js/widget_selection", "widgets/js/widget_selectioncontainer", "widgets/js/widget_string", -], function(widgetmanager, linkModels) { - for (var target_name in linkModels) { - if (linkModels.hasOwnProperty(target_name)) { - widgetmanager.WidgetManager.register_widget_model(target_name, linkModels[target_name]); - } - } - - // Register all of the loaded views with the widget manager. +], function(widgetmanager, widget) { + // Register all of the loaded models and views with the widget manager. for (var i = 2; i < arguments.length; i++) { - for (var target_name in arguments[i]) { - if (arguments[i].hasOwnProperty(target_name)) { - widgetmanager.WidgetManager.register_widget_view(target_name, arguments[i][target_name]); + var module = arguments[i]; + for (var target_name in module) { + if (module.hasOwnProperty(target_name)) { + var target = module[target_name]; + if (target.prototype instanceof widget.WidgetModel) { + widgetmanager.WidgetManager.register_widget_model(target_name, target); + } else if (target.prototype instanceof widget.WidgetView) { + widgetmanager.WidgetManager.register_widget_view(target_name, target); + } } } } - return {'WidgetManager': widgetmanager.WidgetManager}; }); diff --git a/IPython/html/static/widgets/js/widget.js b/IPython/html/static/widgets/js/widget.js index e5f758d..a643ea0 100644 --- a/IPython/html/static/widgets/js/widget.js +++ b/IPython/html/static/widgets/js/widget.js @@ -62,13 +62,13 @@ define(["widgets/js/manager", return Backbone.Model.apply(this); }, - send: function (content, callbacks) { + send: function (content, callbacks, buffers) { /** * Send a custom msg over the comm. */ if (this.comm !== undefined) { var data = {method: 'custom', content: content}; - this.comm.send(data, callbacks); + this.comm.send(data, callbacks, {}, buffers); this.pending_msgs++; } }, @@ -136,12 +136,31 @@ define(["widgets/js/manager", * Handle incoming comm msg. */ var method = msg.content.data.method; + var that = this; switch (method) { case 'update': this.state_change = this.state_change .then(function() { - return that.set_state(msg.content.data.state); + var state = msg.content.data.state || {}; + var buffer_keys = msg.content.data.buffers || []; + var buffers = msg.buffers || []; + for (var i=0; i 0) { // If this message was sent via backbone itself, it will not @@ -297,8 +326,7 @@ define(["widgets/js/manager", } else { // We haven't exceeded the throttle, send the message like // normal. - var data = {method: 'backbone', sync_data: attrs}; - this.comm.send(data, callbacks); + this.send_sync_message(attrs, callbacks); this.pending_msgs++; } } @@ -308,6 +336,42 @@ define(["widgets/js/manager", this._buffered_state_diff = {}; }, + + send_sync_message: function(attrs, callbacks) { + // prepare and send a comm message syncing attrs + var that = this; + // first, build a state dictionary with key=the attribute and the value + // being the value or the promise of the serialized value + var serializers = this.constructor.serializers; + if (serializers) { + for (k in attrs) { + if (serializers[k] && serializers[k].serialize) { + attrs[k] = (serializers[k].serialize)(attrs[k], this); + } + } + } + utils.resolve_promises_dict(attrs).then(function(state) { + // get binary values, then send + var keys = Object.keys(state); + var buffers = []; + var buffer_keys = []; + for (var i=0; i float64 typed array + return new Float64Array(value.buffer); + }, + // serialization automatically handled since the + // attribute is an ArrayBuffer view + }; + + var floatList = { + deserialize: function (value, model) { + // list of floats -> list of strings + return value.map(function(x) {return x.toString()}); + }, + serialize: function(value, model) { + // list of strings -> list of floats + return value.map(function(x) {return parseFloat(x);}) + } + }; + + var TestWidgetModel = widget.WidgetModel.extend({}, { + serializers: _.extend({ + array_list: floatList, + array_binary: floatArray + }, widget.WidgetModel.serializers) + }); + + var TestWidgetView = widget.DOMWidgetView.extend({ + render: function () { + this.listenTo(this.model, 'msg:custom', this.handle_msg); + }, + handle_msg: function(content, buffers) { + this.msg = [content, buffers]; + } + }); + + return {TestWidgetModel: TestWidgetModel, TestWidgetView: TestWidgetView}; + }); + }); + + var testwidget = {}; + this.append_cell_execute_then([ + 'from IPython.html import widgets', + 'from IPython.utils.traitlets import Unicode, Instance, List', + 'from IPython.display import display', + 'from array import array', + 'def _array_to_memoryview(x):', + ' if x is None: return None', + ' try:', + ' y = memoryview(x)', + ' except TypeError:', + ' # in python 2, arrays do not support the new buffer protocol', + ' y = memoryview(buffer(x))', + ' return y', + 'def _memoryview_to_array(x):', + ' if x is None: return None', + ' return array("d", x.tobytes())', + 'arrays_binary = {', + ' "from_json": _memoryview_to_array,', + ' "to_json": _array_to_memoryview', + '}', + '', + 'def _array_to_list(x):', + ' return list(x)', + 'def _list_to_array(x):', + ' return array("d",x)', + 'arrays_list = {', + ' "from_json": _list_to_array,', + ' "to_json": _array_to_list', + '}', + '', + 'class TestWidget(widgets.DOMWidget):', + ' _model_module = Unicode("TestWidget", sync=True)', + ' _model_name = Unicode("TestWidgetModel", sync=True)', + ' _view_module = Unicode("TestWidget", sync=True)', + ' _view_name = Unicode("TestWidgetView", sync=True)', + ' array_binary = Instance(array, allow_none=True, sync=True, **arrays_binary)', + ' array_list = Instance(array, args=("d", [3.0]), allow_none=False, sync=True, **arrays_list)', + ' msg = {}', + ' def __init__(self, **kwargs):', + ' super(widgets.DOMWidget, self).__init__(**kwargs)', + ' self.on_msg(self._msg)', + ' def _msg(self, _, content, buffers):', + ' self.msg = [content, buffers]', + 'x=TestWidget()', + 'display(x)', + 'print(x.model_id)'].join('\n'), function(index){ + testwidget.index = index; + testwidget.model_id = this.get_output_cell(index).text.trim(); + }); + this.wait_for_widget(testwidget); + + + this.append_cell_execute_then('x.array_list = array("d", [1.5, 2.0, 3.1])'); + this.wait_for_widget(testwidget); + this.then(function() { + var result = this.evaluate(function(index) { + var v = IPython.notebook.get_cell(index).widget_views[0]; + var result = v.model.get('array_list'); + var z = result.slice(); + z[0]+="1234"; + z[1]+="5678"; + v.model.set('array_list', z); + v.touch(); + return result; + }, testwidget.index); + this.test.assertEquals(result, ["1.5", "2", "3.1"], "JSON custom serializer kernel -> js"); + }); + + this.assert_output_equals('print(x.array_list.tolist() == [1.51234, 25678.0, 3.1])', + 'True', 'JSON custom serializer js -> kernel'); + + if (this.slimerjs) { + this.append_cell_execute_then("x.array_binary=array('d', [1.5,2.5,5])", function() { + this.evaluate(function(index) { + var v = IPython.notebook.get_cell(index).widget_views[0]; + var z = v.model.get('array_binary'); + z[0]*=3; + z[1]*=3; + z[2]*=3; + // we set to null so that we recognize the change + // when we set data back to z + v.model.set('array_binary', null); + v.model.set('array_binary', z); + v.touch(); + }, textwidget.index); + }); + this.wait_for_widget(testwidget); + this.assert_output_equals('x.array_binary.tolist() == [4.5, 7.5, 15.0]', + 'True\n', 'Binary custom serializer js -> kernel') + + this.append_cell_execute_then('x.send("some content", [memoryview(b"binarycontent"), memoryview("morecontent")])'); + this.wait_for_widget(testwidget); + + this.then(function() { + var result = this.evaluate(function(index) { + var v = IPython.notebook.get_cell(index).widget_views[0]; + var d = new TextDecoder('utf-8'); + return {text: v.msg[0], + binary0: d.decode(v.msg[1][0]), + binary1: d.decode(v.msg[1][1])}; + }, testwidget.index); + this.test.assertEquals(result, {text: 'some content', + binary0: 'binarycontent', + binary1: 'morecontent'}, + "Binary widget messages kernel -> js"); + }); + + this.then(function() { + this.evaluate(function(index) { + var v = IPython.notebook.get_cell(index).widget_views[0]; + v.send('content back', [new Uint8Array([1,2,3,4]), new Float64Array([2.1828, 3.14159])]) + }, testwidget.index); + }); + this.wait_for_widget(testwidget); + this.assert_output_equals([ + 'all([x.msg[0] == "content back",', + ' x.msg[1][0].tolist() == [1,2,3,4],', + ' array("d", x.msg[1][1].tobytes()).tolist() == [2.1828, 3.14159]])'].join('\n'), + 'True', 'Binary buffers message js -> kernel'); + } else { + console.log("skipping binary websocket tests on phantomjs"); + } + }); diff --git a/IPython/html/widgets/widget.py b/IPython/html/widgets/widget.py index 5c9ec61..28a24fb 100644 --- a/IPython/html/widgets/widget.py +++ b/IPython/html/widgets/widget.py @@ -216,10 +216,11 @@ class Widget(LoggingConfigurable): key : unicode, or iterable (optional) A single property's name or iterable of property names to sync with the front-end. """ - self._send({ - "method" : "update", - "state" : self.get_state(key=key) - }) + state, buffer_keys, buffers = self.get_state(key=key) + msg = {"method": "update", "state": state} + if buffer_keys: + msg['buffers'] = buffer_keys + self._send(msg, buffers=buffers) def get_state(self, key=None): """Gets the widget state, or a piece of it. @@ -228,6 +229,16 @@ class Widget(LoggingConfigurable): ---------- key : unicode or iterable (optional) A single property's name or iterable of property names to get. + + Returns + ------- + state : dict of states + buffer_keys : list of strings + the values that are stored in buffers + buffers : list of binary memoryviews + values to transmit in binary + metadata : dict + metadata for each field: {key: metadata} """ if key is None: keys = self.keys @@ -238,11 +249,18 @@ class Widget(LoggingConfigurable): else: raise ValueError("key must be a string, an iterable of keys, or None") state = {} + buffers = [] + buffer_keys = [] for k in keys: f = self.trait_metadata(k, 'to_json', self._trait_to_json) value = getattr(self, k) - state[k] = f(value) - return state + serialized = f(value) + if isinstance(serialized, memoryview): + buffers.append(serialized) + buffer_keys.append(k) + else: + state[k] = serialized + return state, buffer_keys, buffers def set_state(self, sync_data): """Called when a state is received from the front-end.""" @@ -253,15 +271,17 @@ class Widget(LoggingConfigurable): with self._lock_property(name, json_value): setattr(self, name, from_json(json_value)) - def send(self, content): + def send(self, content, buffers=None): """Sends a custom msg to the widget model in the front-end. Parameters ---------- content : dict Content of the message to send. + buffers : list of binary buffers + Binary buffers to send with message """ - self._send({"method": "custom", "content": content}) + self._send({"method": "custom", "content": content}, buffers=buffers) def on_msg(self, callback, remove=False): """(Un)Register a custom msg receive callback. @@ -269,9 +289,9 @@ class Widget(LoggingConfigurable): Parameters ---------- callback: callable - callback will be passed two arguments when a message arrives:: + callback will be passed three arguments when a message arrives:: - callback(widget, content) + callback(widget, content, buffers) remove: bool True if the callback should be unregistered.""" @@ -353,7 +373,10 @@ class Widget(LoggingConfigurable): # Handle backbone sync methods CREATE, PATCH, and UPDATE all in one. if method == 'backbone': 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] self.set_state(sync_data) # handles all methods # Handle a state request. @@ -363,15 +386,15 @@ class Widget(LoggingConfigurable): # Handle a custom msg from the front-end. elif method == 'custom': if 'content' in data: - self._handle_custom_msg(data['content']) + self._handle_custom_msg(data['content'], msg['buffers']) # Catch remainder. else: self.log.error('Unknown front-end to back-end widget msg with method "%s"' % method) - def _handle_custom_msg(self, content): + def _handle_custom_msg(self, content, buffers): """Called when a custom msg is received.""" - self._msg_callbacks(self, content) + self._msg_callbacks(self, content, buffers) def _notify_trait(self, name, old_value, new_value): """Called when a property has been changed.""" @@ -393,35 +416,12 @@ class Widget(LoggingConfigurable): self._display_callbacks(self, **kwargs) def _trait_to_json(self, x): - """Convert a trait value to json - - Traverse lists/tuples and dicts and serialize their values as well. - Replace any widgets with their model_id - """ - if isinstance(x, dict): - return {k: self._trait_to_json(v) for k, v in x.items()} - elif isinstance(x, (list, tuple)): - return [self._trait_to_json(v) for v in x] - elif isinstance(x, Widget): - return "IPY_MODEL_" + x.model_id - else: - return x # Value must be JSON-able + """Convert a trait value to json.""" + return x def _trait_from_json(self, x): - """Convert json values to objects - - Replace any strings representing valid model id values to Widget references. - """ - if isinstance(x, dict): - return {k: self._trait_from_json(v) for k, v in x.items()} - elif isinstance(x, (list, tuple)): - return [self._trait_from_json(v) for v in x] - elif isinstance(x, string_types) and x.startswith('IPY_MODEL_') and x[10:] in Widget.widgets: - # we want to support having child widgets at any level in a hierarchy - # trusting that a widget UUID will not appear out in the wild - return Widget.widgets[x[10:]] - else: - return x + """Convert json values to objects.""" + return x def _ipython_display_(self, **kwargs): """Called when `IPython.display.display` is called on the widget.""" @@ -430,9 +430,9 @@ class Widget(LoggingConfigurable): self._send({"method": "display"}) self._handle_displayed(**kwargs) - def _send(self, msg): + def _send(self, msg, buffers=None): """Sends a message to the model in the front-end.""" - self.comm.send(msg) + self.comm.send(data=msg, buffers=buffers) class DOMWidget(Widget): diff --git a/IPython/html/widgets/widget_box.py b/IPython/html/widgets/widget_box.py index ca73e0d..7ae342e 100644 --- a/IPython/html/widgets/widget_box.py +++ b/IPython/html/widgets/widget_box.py @@ -6,19 +6,46 @@ Represents a container that can be used to group other widgets. # Copyright (c) IPython Development Team. # Distributed under the terms of the Modified BSD License. -from .widget import DOMWidget, register +from .widget import DOMWidget, Widget, register from IPython.utils.traitlets import Unicode, Tuple, TraitError, Int, CaselessStrEnum from IPython.utils.warn import DeprecatedClass +def _widget_to_json(x): + if isinstance(x, dict): + return {k: _widget_to_json(v) for k, v in x.items()} + elif isinstance(x, (list, tuple)): + return [_widget_to_json(v) for v in x] + elif isinstance(x, Widget): + return "IPY_MODEL_" + x.model_id + else: + return x + +def _json_to_widget(x): + if isinstance(x, dict): + return {k: _json_to_widget(v) for k, v in x.items()} + elif isinstance(x, (list, tuple)): + return [_json_to_widget(v) for v in x] + elif isinstance(x, string_types) and x.startswith('IPY_MODEL_') and x[10:] in Widget.widgets: + return Widget.widgets[x[10:]] + else: + return x + +widget_serialization = { + 'from_json': _json_to_widget, + 'to_json': _widget_to_json +} + + @register('IPython.Box') class Box(DOMWidget): """Displays multiple widgets in a group.""" + _model_name = Unicode('BoxModel', sync=True) _view_name = Unicode('BoxView', sync=True) # Child widgets in the container. # Using a tuple here to force reassignment to update the list. # When a proper notifying-list trait exists, that is what should be used here. - children = Tuple(sync=True) + children = Tuple(sync=True, **widget_serialization) _overflow_values = ['visible', 'hidden', 'scroll', 'auto', 'initial', 'inherit', ''] overflow_x = CaselessStrEnum( diff --git a/IPython/html/widgets/widget_button.py b/IPython/html/widgets/widget_button.py index 4d5549c..3b3ad66 100644 --- a/IPython/html/widgets/widget_button.py +++ b/IPython/html/widgets/widget_button.py @@ -67,7 +67,7 @@ class Button(DOMWidget): Set to true to remove the callback from the list of callbacks.""" self._click_handlers.register_callback(callback, remove=remove) - def _handle_button_msg(self, _, content): + def _handle_button_msg(self, _, content, buffers): """Handle a msg from the front-end. Parameters