From 4ef0a23839df0a619206957a7acc538635aef3f6 Mon Sep 17 00:00:00 2001 From: Jason Grout Date: Tue, 17 Feb 2015 17:03:29 +0000 Subject: [PATCH 01/19] work-in-progress for custom js serializers --- IPython/html/static/widgets/js/widget.js | 208 ++++++++++++++--------- IPython/html/widgets/widget.py | 119 +++++++++---- IPython/html/widgets/widget_box.py | 6 +- IPython/html/widgets/widget_button.py | 2 +- 4 files changed, 220 insertions(+), 115 deletions(-) diff --git a/IPython/html/static/widgets/js/widget.js b/IPython/html/static/widgets/js/widget.js index e5f758dd3..b837f578c 100644 --- a/IPython/html/static/widgets/js/widget.js +++ b/IPython/html/static/widgets/js/widget.js @@ -32,6 +32,7 @@ define(["widgets/js/manager", this.state_lock = null; this.id = model_id; this.views = {}; + this.serializers = {}; this._resolve_received_state = {}; if (comm !== undefined) { @@ -62,13 +63,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 +137,37 @@ 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 || []; + var metadata = msg.content.data.metadata || {}; + var i,k; + for (var i=0; i 0) { // If this message was sent via backbone itself, it will not @@ -297,9 +346,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.pending_msgs++; + this.send_sync_message(attrs, callbacks); } } // Since the comm is a one-way communication, assume the message @@ -308,68 +355,71 @@ define(["widgets/js/manager", this._buffered_state_diff = {}; }, - save_changes: function(callbacks) { - /** - * Push this model's state to the back-end - * - * This invokes a Backbone.Sync. - */ - this.save(this._buffered_state_diff, {patch: true, callbacks: callbacks}); - }, - _pack_models: function(value) { - /** - * Replace models with model ids recursively. - */ + send_sync_message: function(attrs, callbacks) { + // prepare and send a comm message syncing attrs var that = this; - var packed; - if (value instanceof Backbone.Model) { - return "IPY_MODEL_" + value.id; - - } else if ($.isArray(value)) { - packed = []; - _.each(value, function(sub_value, key) { - packed.push(that._pack_models(sub_value)); - }); - return packed; - } else if (value instanceof Date || value instanceof String) { - return value; - } else if (value instanceof Object) { - packed = {}; - _.each(value, function(sub_value, key) { - packed[key] = that._pack_models(sub_value); - }); - return packed; - - } else { - return value; + // first, build a state dictionary with key=the attribute and the value + // being the value or the promise of the serialized value + var state_promise_dict = {}; + var keys = Object.keys(attrs); + for (var i=0; i Date: Mon, 16 Mar 2015 21:36:09 +0000 Subject: [PATCH 02/19] Delete the packing/unpacking tests, since we just use generic json now --- IPython/html/tests/widgets/widget.js | 51 ---------------------------- 1 file changed, 51 deletions(-) diff --git a/IPython/html/tests/widgets/widget.js b/IPython/html/tests/widgets/widget.js index d3c6b6d2a..d8e32bd03 100644 --- a/IPython/html/tests/widgets/widget.js +++ b/IPython/html/tests/widgets/widget.js @@ -46,57 +46,6 @@ casper.notebook_test(function () { this.execute_cell_then(index); this.then(function () { - - // Functions that can be used to test the packing and unpacking APIs - var that = this; - var test_pack = function (input) { - var output = that.evaluate(function(input) { - var model = new IPython.WidgetModel(IPython.notebook.kernel.widget_manager, undefined); - var results = model._pack_models(input); - return results; - }, {input: input}); - that.test.assert(recursive_compare(input, output), - JSON.stringify(input) + ' passed through Model._pack_model unchanged'); - }; - var test_unpack = function (input) { - that.thenEvaluate(function(input) { - window.results = undefined; - var model = new IPython.WidgetModel(IPython.notebook.kernel.widget_manager, undefined); - model._unpack_models(input).then(function(results) { - window.results = results; - }); - }, {input: input}); - - that.waitFor(function check() { - return that.evaluate(function() { - return window.results; - }); - }); - - that.then(function() { - var results = that.evaluate(function() { - return window.results; - }); - that.test.assert(recursive_compare(input, results), - JSON.stringify(input) + ' passed through Model._unpack_model unchanged'); - }); - }; - var test_packing = function(input) { - test_pack(input); - test_unpack(input); - }; - - test_packing({0: 'hi', 1: 'bye'}); - test_packing(['hi', 'bye']); - test_packing(['hi', 5]); - test_packing(['hi', '5']); - test_packing([1.0, 0]); - test_packing([1.0, false]); - test_packing([1, false]); - test_packing([1, false, {a: 'hi'}]); - test_packing([1, false, ['hi']]); - test_packing([String('hi'), Date("Thu Nov 13 2014 13:46:21 GMT-0500")]) - // Test multi-set, single touch code. First create a custom widget. this.thenEvaluate(function() { var MultiSetView = IPython.DOMWidgetView.extend({ From c9f3c8e073e895090d529967bc4da50409d1e67c Mon Sep 17 00:00:00 2001 From: Jason Grout Date: Tue, 24 Mar 2015 20:53:21 +0000 Subject: [PATCH 03/19] Make message throttling test easier to debug --- IPython/html/tests/widgets/widget.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/IPython/html/tests/widgets/widget.js b/IPython/html/tests/widgets/widget.js index d8e32bd03..bf9d83858 100644 --- a/IPython/html/tests/widgets/widget.js +++ b/IPython/html/tests/widgets/widget.js @@ -118,7 +118,7 @@ casper.notebook_test(function () { '.my-throttle-textbox'), 'Textbox exists.'); // Send 20 characters - this.sendKeys('.my-throttle-textbox input', '....................'); + this.sendKeys('.my-throttle-textbox input', '12345678901234567890'); }); this.wait_for_widget(textbox); From c70f687c0e538142ed795b253afca612585d2b24 Mon Sep 17 00:00:00 2001 From: Jason Grout Date: Tue, 24 Mar 2015 22:32:16 +0000 Subject: [PATCH 04/19] Increment the pending_msgs counter immediately to avoid race conditions that send more messages --- IPython/html/static/widgets/js/widget.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/IPython/html/static/widgets/js/widget.js b/IPython/html/static/widgets/js/widget.js index b837f578c..d6ae99daf 100644 --- a/IPython/html/static/widgets/js/widget.js +++ b/IPython/html/static/widgets/js/widget.js @@ -347,6 +347,7 @@ define(["widgets/js/manager", // We haven't exceeded the throttle, send the message like // normal. this.send_sync_message(attrs, callbacks); + this.pending_msgs++; } } // Since the comm is a one-way communication, assume the message @@ -395,8 +396,11 @@ define(["widgets/js/manager", } } that.comm.send({method: 'backbone', sync_data: state, buffer_keys: buffer_keys}, callbacks, {}, buffers); - that.pending_msgs++; - }) + }).catch(utils.reject("Couldn't send widget sync message"), true) + .catch(function(error) { + that.pending_msgs--; + return error; + }); }, serialize: function(model, attrs) { From 18ff9c5e1c18a9b589c6e1a43c138054a07ee332 Mon Sep 17 00:00:00 2001 From: Jason Grout Date: Tue, 24 Mar 2015 23:06:02 +0000 Subject: [PATCH 05/19] Fix two calls to utils.reject (misplaced parens) --- IPython/html/static/widgets/js/widget.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/IPython/html/static/widgets/js/widget.js b/IPython/html/static/widgets/js/widget.js index d6ae99daf..fdd99e495 100644 --- a/IPython/html/static/widgets/js/widget.js +++ b/IPython/html/static/widgets/js/widget.js @@ -396,7 +396,7 @@ define(["widgets/js/manager", } } that.comm.send({method: 'backbone', sync_data: state, buffer_keys: buffer_keys}, callbacks, {}, buffers); - }).catch(utils.reject("Couldn't send widget sync message"), true) + }).catch(utils.reject("Couldn't send widget sync message", true)) .catch(function(error) { that.pending_msgs--; return error; @@ -480,7 +480,7 @@ define(["widgets/js/manager", */ var that = this; options = $.extend({ parent: this }, options || {}); - return this.model.widget_manager.create_view(child_model, options).catch(utils.reject("Couldn't create child view"), true); + return this.model.widget_manager.create_view(child_model, options).catch(utils.reject("Couldn't create child view", true)); }, callbacks: function(){ From 5c797f96725e441b4b930ba401814c8d4fbb1e38 Mon Sep 17 00:00:00 2001 From: Jason Grout Date: Wed, 25 Mar 2015 15:18:25 +0000 Subject: [PATCH 06/19] Simplify error handling for errors in sending sync messages from js --- IPython/html/static/widgets/js/widget.js | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/IPython/html/static/widgets/js/widget.js b/IPython/html/static/widgets/js/widget.js index fdd99e495..8a689fe50 100644 --- a/IPython/html/static/widgets/js/widget.js +++ b/IPython/html/static/widgets/js/widget.js @@ -396,11 +396,10 @@ define(["widgets/js/manager", } } that.comm.send({method: 'backbone', sync_data: state, buffer_keys: buffer_keys}, callbacks, {}, buffers); - }).catch(utils.reject("Couldn't send widget sync message", true)) - .catch(function(error) { - that.pending_msgs--; - return error; - }); + }).catch(function(error) { + that.pending_msgs--; + return (utils.reject("Couldn't send widget sync message", true))(error); + }); }, serialize: function(model, attrs) { From f283d3d1e9b467059b9b45322877d3cdcaf957e9 Mon Sep 17 00:00:00 2001 From: Jason Grout Date: Wed, 25 Mar 2015 23:07:10 +0000 Subject: [PATCH 07/19] Add the outline of a few new tests for the custom serialization --- IPython/html/tests/widgets/widget.js | 210 +++++++++++++++++++++++---- 1 file changed, 183 insertions(+), 27 deletions(-) diff --git a/IPython/html/tests/widgets/widget.js b/IPython/html/tests/widgets/widget.js index bf9d83858..9dcde546a 100644 --- a/IPython/html/tests/widgets/widget.js +++ b/IPython/html/tests/widgets/widget.js @@ -40,9 +40,9 @@ casper.notebook_test(function () { var index; index = this.append_cell( - 'from IPython.html import widgets\n' + - 'from IPython.display import display, clear_output\n' + - 'print("Success")'); + ['from IPython.html import widgets', + 'from IPython.display import display, clear_output', + 'print("Success")'].join('\n')); this.execute_cell_then(index); this.then(function () { @@ -62,20 +62,20 @@ casper.notebook_test(function () { // Try creating the multiset widget, verify that sets the values correctly. var multiset = {}; - multiset.index = this.append_cell( - 'from IPython.utils.traitlets import Unicode, CInt\n' + - 'class MultiSetWidget(widgets.Widget):\n' + - ' _view_name = Unicode("MultiSetView", sync=True)\n' + - ' a = CInt(0, sync=True)\n' + - ' b = CInt(0, sync=True)\n' + - ' c = CInt(0, sync=True)\n' + - ' d = CInt(-1, sync=True)\n' + // See if it sends a full state. - ' def set_state(self, sync_data):\n' + - ' widgets.Widget.set_state(self, sync_data)\n'+ - ' self.d = len(sync_data)\n' + - 'multiset = MultiSetWidget()\n' + - 'display(multiset)\n' + - 'print(multiset.model_id)'); + multiset.index = this.append_cell([ + 'from IPython.utils.traitlets import Unicode, CInt', + 'class MultiSetWidget(widgets.Widget):', + ' _view_name = Unicode("MultiSetView", sync=True)', + ' a = CInt(0, sync=True)', + ' b = CInt(0, sync=True)', + ' c = CInt(0, sync=True)', + ' d = CInt(-1, sync=True)', // See if it sends a full state. + ' def set_state(self, sync_data):', + ' widgets.Widget.set_state(self, sync_data)'+ + ' self.d = len(sync_data)', + 'multiset = MultiSetWidget()', + 'display(multiset)', + 'print(multiset.model_id)'].join('\n')); this.execute_cell_then(multiset.index, function(index) { multiset.model_id = this.get_output_cell(index).text.trim(); }); @@ -97,16 +97,16 @@ casper.notebook_test(function () { }); var textbox = {}; - throttle_index = this.append_cell( - 'import time\n' + - 'textbox = widgets.Text()\n' + - 'display(textbox)\n' + - 'textbox._dom_classes = ["my-throttle-textbox"]\n' + - 'def handle_change(name, old, new):\n' + - ' display(len(new))\n' + - ' time.sleep(0.5)\n' + - 'textbox.on_trait_change(handle_change, "value")\n' + - 'print(textbox.model_id)'); + throttle_index = this.append_cell([ + 'import time', + 'textbox = widgets.Text()', + 'display(textbox)', + 'textbox._dom_classes = ["my-throttle-textbox"]', + 'def handle_change(name, old, new):', + ' display(len(new))', + ' time.sleep(0.5)', + 'textbox.on_trait_change(handle_change, "value")', + 'print(textbox.model_id)'].join('\n')); this.execute_cell_then(throttle_index, function(index){ textbox.model_id = this.get_output_cell(index).text.trim(); @@ -137,4 +137,160 @@ casper.notebook_test(function () { var last_state = outputs[outputs.length-1].data['text/plain']; this.test.assertEquals(last_state, "20", "Last state sent when throttling."); }); + + +/* New Test + + +%%javascript +define('TestWidget', ['widgets/js/widget', 'base/js/utils'], function(widget, utils) { + var TestWidget = widget.DOMWidgetView.extend({ + render: function () { + this.listenTo(this.model, 'msg:custom', this.handle_msg); + window.w = this; + console.log('data:', this.model.get('data')); + }, + handle_msg: function(content, buffers) { + this.msg = [content, buffers]; + } + }); + + var floatArray = { + deserialize: function (value, model) { + // DataView -> float64 typed array + return new Float64Array(value.buffer); + }, + // serialization automatically handled by message buffers + }; + + + 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);}) + } + }; + return {TestWidget: TestWidget, floatArray: floatArray, floatList: floatList}; +}); + + +-------------- + + +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 don't support the new buffer protocol + y = memoryview(buffer(x)) + return y, {'serialization': ('floatArray', 'TestWidget')} + +def _memoryview_to_array(x): + return array('d', x.tobytes()) + +arrays_binary = { + 'from_json': _memoryview_to_array, + 'to_json': _array_to_memoryview +} + +def _array_to_list(x): + if x is None: return None, {} + return list(x), {'serialization': ('floatList', 'TestWidget')} + +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): + _view_module = Unicode('TestWidget', sync=True) + _view_name = Unicode('TestWidget', sync=True) + array_binary = Instance(array, sync=True, **arrays_binary) + array_list = Instance(array, sync=True, **arrays_list) + 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) +x.array_binary=array('d', [1.5,2.5,5]) +print x.model_id + +----------------- + +%%javascript +console.log(w.model.get('array_binary')) +var z = w.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 +w.model.set('array_binary', null) +w.model.set('array_binary', z) +console.log(w.model.get('array_binary')) +w.touch() + +---------------- +x.array_binary.tolist() == [4.5, 7.5, 15.0] +---------------- + +x.array_list = array('d', [1.5, 2.0, 3.1]) +---------------- + +%%javascript +console.log(w.model.get('array_list')) +var z = w.model.get('array_list') +z[0]+="1234" +z[1]+="5678" +// we set to null so that we recognize the change +// when we set data back to z +w.model.set('array_list', null) +w.model.set('array_list', z) +w.touch() + +----------------- + +x.array_list.tolist() == [1.51234, 25678.0, 3.1] + +------------------- +x.send('some content', [memoryview(b'binarycontent'), memoryview('morecontent')]) + +------------------- + +%%javascript +console.log(w.msg[0] === 'some content') +var d=new TextDecoder('utf-8') +console.log(d.decode(w.msg[1][0])==='binarycontent') +console.log(d.decode(w.msg[1][1])==='morecontent') +w.send('content back', [new Uint8Array([1,2,3,4]), new Float64Array([2.1828, 3.14159])]) + +-------------------- + +print x.msg[0] == 'content back' +print x.msg[1][0].tolist() == [1,2,3,4] +print array('d', x.msg[1][1].tobytes()).tolist() == [2.1828, 3.14159] + + +*/ + + }); From 115536874de82a63e1eae23d457c90b1fa4a8f79 Mon Sep 17 00:00:00 2001 From: Jason Grout Date: Thu, 26 Mar 2015 22:14:45 +0000 Subject: [PATCH 08/19] Write tests for custom serialization --- IPython/html/tests/util.js | 13 ++ IPython/html/tests/widgets/widget.js | 301 ++++++++++++++------------- 2 files changed, 164 insertions(+), 150 deletions(-) diff --git a/IPython/html/tests/util.js b/IPython/html/tests/util.js index 0f89b00f6..d9fc73478 100644 --- a/IPython/html/tests/util.js +++ b/IPython/html/tests/util.js @@ -337,6 +337,19 @@ casper.execute_cell_then = function(index, then_callback, expect_failure) { return return_val; }; +casper.append_cell_execute_then = function(text, then_callback, expect_failure) { + // Append a code cell and execute it, optionally calling a then_callback + var c = this.append_cell(text); + return this.execute_cell_then(c, then_callback, expect_failure); +}; + +casper.assert_output_equals = function(text, output_text, message) { + // Append a code cell with the text, then assert the output is equal to output_text + this.append_cell_execute_then(text, function(index) { + this.test.assertEquals(this.get_output_cell(index).text.trim(), output_text, message); + }); +}; + casper.wait_for_element = function(index, selector){ // Utility function that allows us to easily wait for an element // within a cell. Uses JQuery selector to look for the element. diff --git a/IPython/html/tests/widgets/widget.js b/IPython/html/tests/widgets/widget.js index 9dcde546a..1cd3464ed 100644 --- a/IPython/html/tests/widgets/widget.js +++ b/IPython/html/tests/widgets/widget.js @@ -71,7 +71,7 @@ casper.notebook_test(function () { ' c = CInt(0, sync=True)', ' d = CInt(-1, sync=True)', // See if it sends a full state. ' def set_state(self, sync_data):', - ' widgets.Widget.set_state(self, sync_data)'+ + ' widgets.Widget.set_state(self, sync_data)', ' self.d = len(sync_data)', 'multiset = MultiSetWidget()', 'display(multiset)', @@ -139,158 +139,159 @@ casper.notebook_test(function () { }); -/* New Test - + this.thenEvaluate(function() { + define('TestWidget', ['widgets/js/widget', 'base/js/utils'], function(widget, utils) { + var TestWidget = widget.DOMWidgetView.extend({ + render: function () { + this.listenTo(this.model, 'msg:custom', this.handle_msg); + }, + handle_msg: function(content, buffers) { + this.msg = [content, buffers]; + } + }); -%%javascript -define('TestWidget', ['widgets/js/widget', 'base/js/utils'], function(widget, utils) { - var TestWidget = widget.DOMWidgetView.extend({ - render: function () { - this.listenTo(this.model, 'msg:custom', this.handle_msg); - window.w = this; - console.log('data:', this.model.get('data')); - }, - handle_msg: function(content, buffers) { - this.msg = [content, buffers]; - } + var floatArray = { + deserialize: function (value, model) { + // DataView -> float64 typed array + return new Float64Array(value.buffer); + }, + // serialization automatically handled by message buffers + }; + + 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);}) + } + }; + return {TestWidget: TestWidget, floatArray: floatArray, floatList: floatList}; + }); }); - var floatArray = { - deserialize: function (value, model) { - // DataView -> float64 typed array - return new Float64Array(value.buffer); - }, - // serialization automatically handled by message buffers - }; - + 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 don't support the new buffer protocol", + ' y = memoryview(buffer(x))', + " return y, {'serialization': ('floatArray', 'TestWidget')}", + 'def _memoryview_to_array(x):', + " return array('d', x.tobytes())", + 'arrays_binary = {', + " 'from_json': _memoryview_to_array,", + " 'to_json': _array_to_memoryview", + '}', + '', + 'def _array_to_list(x):', + ' if x is None: return None, {}', + " return list(x), {'serialization': ('floatList', 'TestWidget')}", + '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):', + " _view_module = Unicode('TestWidget', sync=True)", + " _view_name = Unicode('TestWidget', sync=True)", + ' array_binary = Instance(array, sync=True, **arrays_binary)', + ' array_list = Instance(array, 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"); + }); - 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);}) - } - }; - return {TestWidget: TestWidget, floatArray: floatArray, floatList: floatList}; -}); - - --------------- - - -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 don't support the new buffer protocol - y = memoryview(buffer(x)) - return y, {'serialization': ('floatArray', 'TestWidget')} - -def _memoryview_to_array(x): - return array('d', x.tobytes()) - -arrays_binary = { - 'from_json': _memoryview_to_array, - 'to_json': _array_to_memoryview -} - -def _array_to_list(x): - if x is None: return None, {} - return list(x), {'serialization': ('floatList', 'TestWidget')} - -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): - _view_module = Unicode('TestWidget', sync=True) - _view_name = Unicode('TestWidget', sync=True) - array_binary = Instance(array, sync=True, **arrays_binary) - array_list = Instance(array, sync=True, **arrays_list) - 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) -x.array_binary=array('d', [1.5,2.5,5]) -print x.model_id - ------------------ - -%%javascript -console.log(w.model.get('array_binary')) -var z = w.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 -w.model.set('array_binary', null) -w.model.set('array_binary', z) -console.log(w.model.get('array_binary')) -w.touch() - ----------------- -x.array_binary.tolist() == [4.5, 7.5, 15.0] ----------------- - -x.array_list = array('d', [1.5, 2.0, 3.1]) ----------------- - -%%javascript -console.log(w.model.get('array_list')) -var z = w.model.get('array_list') -z[0]+="1234" -z[1]+="5678" -// we set to null so that we recognize the change -// when we set data back to z -w.model.set('array_list', null) -w.model.set('array_list', z) -w.touch() - ------------------ - -x.array_list.tolist() == [1.51234, 25678.0, 3.1] - -------------------- -x.send('some content', [memoryview(b'binarycontent'), memoryview('morecontent')]) - -------------------- - -%%javascript -console.log(w.msg[0] === 'some content') -var d=new TextDecoder('utf-8') -console.log(d.decode(w.msg[1][0])==='binarycontent') -console.log(d.decode(w.msg[1][1])==='morecontent') -w.send('content back', [new Uint8Array([1,2,3,4]), new Float64Array([2.1828, 3.14159])]) - --------------------- - -print x.msg[0] == 'content back' -print x.msg[1][0].tolist() == [1,2,3,4] -print array('d', x.msg[1][1].tobytes()).tolist() == [2.1828, 3.14159] - - -*/ - + 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"); + } }); From 50a0cd57ef752c8379afa967884b47c2d8ce9059 Mon Sep 17 00:00:00 2001 From: Jason Grout Date: Thu, 26 Mar 2015 22:15:18 +0000 Subject: [PATCH 09/19] Handle case when pixel values are undefined or null --- IPython/html/static/widgets/js/widget.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/IPython/html/static/widgets/js/widget.js b/IPython/html/static/widgets/js/widget.js index 8a689fe50..18a7f7027 100644 --- a/IPython/html/static/widgets/js/widget.js +++ b/IPython/html/static/widgets/js/widget.js @@ -611,7 +611,7 @@ define(["widgets/js/manager", /** * Makes browser interpret a numerical string as a pixel value. */ - if (/^\d+\.?(\d+)?$/.test(value.trim())) { + if (value && /^\d+\.?(\d+)?$/.test(value.trim())) { return value.trim() + 'px'; } return value; From b7a0163810c1a0ed1c3ab56dbc7db0bef058345d Mon Sep 17 00:00:00 2001 From: Jason Grout Date: Fri, 27 Mar 2015 15:02:50 +0000 Subject: [PATCH 10/19] Make quoting consistent in the new tests --- IPython/html/tests/widgets/widget.js | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/IPython/html/tests/widgets/widget.js b/IPython/html/tests/widgets/widget.js index 1cd3464ed..b87136504 100644 --- a/IPython/html/tests/widgets/widget.js +++ b/IPython/html/tests/widgets/widget.js @@ -183,29 +183,29 @@ casper.notebook_test(function () { ' try:', ' y = memoryview(x)', ' except TypeError:', - " # in python 2, arrays don't support the new buffer protocol", + ' # in python 2, arrays do not support the new buffer protocol', ' y = memoryview(buffer(x))', - " return y, {'serialization': ('floatArray', 'TestWidget')}", + ' return y, {"serialization": ("floatArray", "TestWidget")}', 'def _memoryview_to_array(x):', - " return array('d', x.tobytes())", + ' return array("d", x.tobytes())', 'arrays_binary = {', - " 'from_json': _memoryview_to_array,", - " 'to_json': _array_to_memoryview", + ' "from_json": _memoryview_to_array,', + ' "to_json": _array_to_memoryview', '}', '', 'def _array_to_list(x):', ' if x is None: return None, {}', - " return list(x), {'serialization': ('floatList', 'TestWidget')}", + ' return list(x), {"serialization": ("floatList", "TestWidget")}', 'def _list_to_array(x):', - " return array('d',x)", + ' return array("d",x)', 'arrays_list = {', - " 'from_json': _list_to_array,", - " 'to_json': _array_to_list", + ' "from_json": _list_to_array,', + ' "to_json": _array_to_list', '}', '', 'class TestWidget(widgets.DOMWidget):', - " _view_module = Unicode('TestWidget', sync=True)", - " _view_name = Unicode('TestWidget', sync=True)", + ' _view_module = Unicode("TestWidget", sync=True)', + ' _view_name = Unicode("TestWidget", sync=True)', ' array_binary = Instance(array, sync=True, **arrays_binary)', ' array_list = Instance(array, sync=True, **arrays_list)', ' msg = {}', From b2f775549d3f826c9eb9e99800336f1c76e2e583 Mon Sep 17 00:00:00 2001 From: Jason Grout Date: Fri, 27 Mar 2015 15:12:28 +0000 Subject: [PATCH 11/19] Correct documentation --- IPython/html/widgets/widget.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/IPython/html/widgets/widget.py b/IPython/html/widgets/widget.py index 4dfa7d26e..c4aaff74d 100644 --- a/IPython/html/widgets/widget.py +++ b/IPython/html/widgets/widget.py @@ -455,18 +455,14 @@ class Widget(LoggingConfigurable): self._display_callbacks(self, **kwargs) def _trait_to_json(self, x): - """Convert a trait value to json + """Convert a trait value to json. - Traverse lists/tuples and dicts and serialize their values as well. - Replace any widgets with their model_id + Metadata (the second return value) is not sent """ return x, None def _trait_from_json(self, x): - """Convert json values to objects - - Replace any strings representing valid model id values to Widget references. - """ + """Convert json values to objects.""" return x def _ipython_display_(self, **kwargs): From 5e83876fc7555c28e135c133168179725418ee10 Mon Sep 17 00:00:00 2001 From: Jason Grout Date: Fri, 27 Mar 2015 15:14:14 +0000 Subject: [PATCH 12/19] Convert some test code to python2/3 (add parens for print) --- IPython/html/tests/widgets/widget.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/IPython/html/tests/widgets/widget.js b/IPython/html/tests/widgets/widget.js index b87136504..dfcc56263 100644 --- a/IPython/html/tests/widgets/widget.js +++ b/IPython/html/tests/widgets/widget.js @@ -216,7 +216,7 @@ casper.notebook_test(function () { ' self.msg = [content, buffers]', 'x=TestWidget()', 'display(x)', - 'print x.model_id'].join('\n'), function(index){ + 'print(x.model_id)'].join('\n'), function(index){ testwidget.index = index; testwidget.model_id = this.get_output_cell(index).text.trim(); }); @@ -239,7 +239,7 @@ casper.notebook_test(function () { 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]', + this.assert_output_equals('print(x.array_list.tolist() == [1.51234, 25678.0, 3.1])', 'True', 'JSON custom serializer js -> kernel'); if (this.slimerjs) { From a0ea58f7680677c250955e79959379f9e2fcc987 Mon Sep 17 00:00:00 2001 From: Jason Grout Date: Fri, 27 Mar 2015 15:37:38 +0000 Subject: [PATCH 13/19] Delete unused custom serializer --- IPython/html/widgets/widget.py | 18 +----------------- 1 file changed, 1 insertion(+), 17 deletions(-) diff --git a/IPython/html/widgets/widget.py b/IPython/html/widgets/widget.py index c4aaff74d..03148f6fa 100644 --- a/IPython/html/widgets/widget.py +++ b/IPython/html/widgets/widget.py @@ -111,25 +111,9 @@ def _json_to_widget(x): widget_serialization = { 'from_json': _json_to_widget, - 'to_json': lambda x: (_widget_to_json(x), {'serialization': ('widget_serialization', 'widgets/js/types')}) + 'to_json': lambda x: (_widget_to_json(x), {'serialization': ('models', 'widgets/js/types')}) } -def _to_binary_list(x): - import numpy - return memoryview(numpy.array(x, dtype=float)), {'serialization': ('list_of_numbers', 'widgets/js/types')} - -def _from_binary_list(x): - import numpy - a = numpy.frombuffer(x.tobytes(), dtype=float) - return list(a) - -list_of_numbers = { - 'from_json': _from_binary_list, - 'to_json': _to_binary_list -} - - - class Widget(LoggingConfigurable): #------------------------------------------------------------------------- # Class attributes From 2193a21fc6070a13a3617e6d027ffff9ef3a1109 Mon Sep 17 00:00:00 2001 From: Jason Grout Date: Fri, 27 Mar 2015 15:37:49 +0000 Subject: [PATCH 14/19] Delete unnecessary comment --- IPython/html/widgets/widget_box.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/IPython/html/widgets/widget_box.py b/IPython/html/widgets/widget_box.py index 4ad55fe18..3b6db6f9b 100644 --- a/IPython/html/widgets/widget_box.py +++ b/IPython/html/widgets/widget_box.py @@ -18,8 +18,6 @@ class Box(DOMWidget): # 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. - # TODO: make this tuple serialize models - # TODO: enforce that tuples here have a single datatype children = Tuple(sync=True, **widget_serialization) _overflow_values = ['visible', 'hidden', 'scroll', 'auto', 'initial', 'inherit', ''] From d6def13dbd5e14c0a9f79809d07b48c9063a8ed1 Mon Sep 17 00:00:00 2001 From: Jason Grout Date: Fri, 27 Mar 2015 15:52:02 +0000 Subject: [PATCH 15/19] Fix serialization of models from js -> kernel --- IPython/html/static/widgets/js/widget.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/IPython/html/static/widgets/js/widget.js b/IPython/html/static/widgets/js/widget.js index 18a7f7027..c59d25f5d 100644 --- a/IPython/html/static/widgets/js/widget.js +++ b/IPython/html/static/widgets/js/widget.js @@ -440,6 +440,14 @@ define(["widgets/js/manager", }, this); }, + + toJSON: function(options) { + /** + * Serialize the model. See the types.js deserialization function + * and the kernel-side serializer/deserializer + */ + return "IPY_MODEL_"+this.id; + } }); widgetmanager.WidgetManager.register_widget_model('WidgetModel', WidgetModel); From 15c29f685c39ece72bc5a93ee5fb9cf3ea0f2664 Mon Sep 17 00:00:00 2001 From: Jason Grout Date: Fri, 27 Mar 2015 21:03:12 +0000 Subject: [PATCH 16/19] Change custom serialization to use custom models, rather than transmitting the serializer name across the wire This separates the kernel and the js much more cleanly, and doesn't use as much space on the wire as well! --- IPython/html/static/widgets/js/init.js | 35 ++++++--- IPython/html/static/widgets/js/widget.js | 74 ++++---------------- IPython/html/static/widgets/js/widget_box.js | 34 ++++++++- IPython/html/widgets/widget.py | 43 ++---------- IPython/html/widgets/widget_box.py | 29 +++++++- 5 files changed, 106 insertions(+), 109 deletions(-) diff --git a/IPython/html/static/widgets/js/init.js b/IPython/html/static/widgets/js/init.js index 9dde6b091..0ea0a25c6 100644 --- a/IPython/html/static/widgets/js/init.js +++ b/IPython/html/static/widgets/js/init.js @@ -14,18 +14,33 @@ 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]); +], function(widgetmanager) { + + + /** + * From https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/endsWith + * Can be removed with the string endsWith function is implemented in major browsers + */ + var endsWith = function(target, searchString, position) { + var subjectString = target.toString(); + if (position === undefined || position > subjectString.length) { + position = subjectString.length; } - } + position -= searchString.length; + var lastIndex = subjectString.indexOf(searchString, position); + return lastIndex !== -1 && lastIndex === position; + }; - // Register all of the loaded 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]); + // Register all of the loaded models and views with the widget manager. + for (var i = 1; i < arguments.length; i++) { + var module = arguments[i]; + for (var target_name in module) { + if (module.hasOwnProperty(target_name)) { + if (endsWith(target_name, "View")) { + widgetmanager.WidgetManager.register_widget_view(target_name, module[target_name]); + } else if (endsWith(target_name, "Model")) { + widgetmanager.WidgetManager.register_widget_model(target_name, module[target_name]); + } } } } diff --git a/IPython/html/static/widgets/js/widget.js b/IPython/html/static/widgets/js/widget.js index c59d25f5d..a643ea03c 100644 --- a/IPython/html/static/widgets/js/widget.js +++ b/IPython/html/static/widgets/js/widget.js @@ -32,7 +32,6 @@ define(["widgets/js/manager", this.state_lock = null; this.id = model_id; this.views = {}; - this.serializers = {}; this._resolve_received_state = {}; if (comm !== undefined) { @@ -146,23 +145,17 @@ define(["widgets/js/manager", var state = msg.content.data.state || {}; var buffer_keys = msg.content.data.buffers || []; var buffers = msg.buffers || []; - var metadata = msg.content.data.metadata || {}; - var i,k; for (var i=0; i Date: Fri, 27 Mar 2015 22:24:03 +0000 Subject: [PATCH 17/19] Update widget test for new custom serialization changes --- IPython/html/tests/widgets/widget.js | 50 +++++++++++++++++----------- 1 file changed, 31 insertions(+), 19 deletions(-) diff --git a/IPython/html/tests/widgets/widget.js b/IPython/html/tests/widgets/widget.js index dfcc56263..372e29388 100644 --- a/IPython/html/tests/widgets/widget.js +++ b/IPython/html/tests/widgets/widget.js @@ -140,22 +140,15 @@ casper.notebook_test(function () { this.thenEvaluate(function() { - define('TestWidget', ['widgets/js/widget', 'base/js/utils'], function(widget, utils) { - var TestWidget = widget.DOMWidgetView.extend({ - render: function () { - this.listenTo(this.model, 'msg:custom', this.handle_msg); - }, - handle_msg: function(content, buffers) { - this.msg = [content, buffers]; - } - }); - + define('TestWidget', ['widgets/js/widget', 'base/js/utils', 'underscore'], function(widget, utils, _) { var floatArray = { deserialize: function (value, model) { + if (value===null) {return null;} // DataView -> float64 typed array return new Float64Array(value.buffer); }, - // serialization automatically handled by message buffers + // serialization automatically handled since the + // attribute is an ArrayBuffer view }; var floatList = { @@ -168,7 +161,24 @@ casper.notebook_test(function () { return value.map(function(x) {return parseFloat(x);}) } }; - return {TestWidget: TestWidget, floatArray: floatArray, floatList: floatList}; + + 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}; }); }); @@ -179,14 +189,15 @@ casper.notebook_test(function () { 'from IPython.display import display', 'from array import array', 'def _array_to_memoryview(x):', - ' if x is None: return None, {}', + ' 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, {"serialization": ("floatArray", "TestWidget")}', + ' 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,', @@ -194,8 +205,7 @@ casper.notebook_test(function () { '}', '', 'def _array_to_list(x):', - ' if x is None: return None, {}', - ' return list(x), {"serialization": ("floatList", "TestWidget")}', + ' return list(x)', 'def _list_to_array(x):', ' return array("d",x)', 'arrays_list = {', @@ -204,10 +214,12 @@ casper.notebook_test(function () { '}', '', '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("TestWidget", sync=True)', - ' array_binary = Instance(array, sync=True, **arrays_binary)', - ' array_list = Instance(array, sync=True, **arrays_list)', + ' _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)', From 58098aebc18305dd788c165dd871ac8004f84d36 Mon Sep 17 00:00:00 2001 From: Jason Grout Date: Mon, 30 Mar 2015 20:37:05 -0400 Subject: [PATCH 18/19] Register widget models and views that have the right inheritance, rather than the right name. Thanks to @jdfreder for this suggestion. --- IPython/html/static/widgets/js/init.js | 31 +++++++------------------- 1 file changed, 8 insertions(+), 23 deletions(-) diff --git a/IPython/html/static/widgets/js/init.js b/IPython/html/static/widgets/js/init.js index 0ea0a25c6..da09f54e8 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,36 +15,20 @@ define([ "widgets/js/widget_selection", "widgets/js/widget_selectioncontainer", "widgets/js/widget_string", -], function(widgetmanager) { - - - /** - * From https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/endsWith - * Can be removed with the string endsWith function is implemented in major browsers - */ - var endsWith = function(target, searchString, position) { - var subjectString = target.toString(); - if (position === undefined || position > subjectString.length) { - position = subjectString.length; - } - position -= searchString.length; - var lastIndex = subjectString.indexOf(searchString, position); - return lastIndex !== -1 && lastIndex === position; - }; - +], function(widgetmanager, widget) { // Register all of the loaded models and views with the widget manager. - for (var i = 1; i < arguments.length; i++) { + for (var i = 2; i < arguments.length; i++) { var module = arguments[i]; for (var target_name in module) { if (module.hasOwnProperty(target_name)) { - if (endsWith(target_name, "View")) { - widgetmanager.WidgetManager.register_widget_view(target_name, module[target_name]); - } else if (endsWith(target_name, "Model")) { - widgetmanager.WidgetManager.register_widget_model(target_name, module[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}; }); From 4d86d06940b2283b42619a9998d659576e71236c Mon Sep 17 00:00:00 2001 From: Jason Grout Date: Mon, 30 Mar 2015 20:37:40 -0400 Subject: [PATCH 19/19] Export the widget unpack_models function for other custom classes to use Thanks to @jdfreder for this suggestion. --- IPython/html/static/widgets/js/widget_box.js | 1 + 1 file changed, 1 insertion(+) diff --git a/IPython/html/static/widgets/js/widget_box.js b/IPython/html/static/widgets/js/widget_box.js index 1241409b5..2b1d4786a 100644 --- a/IPython/html/static/widgets/js/widget_box.js +++ b/IPython/html/static/widgets/js/widget_box.js @@ -179,6 +179,7 @@ define([ }); return { + 'unpack_models': unpack_models, 'BoxModel': BoxModel, 'BoxView': BoxView, 'FlexBoxView': FlexBoxView,