From 0df64d2a157a9605fd23984482179e346ced603b Mon Sep 17 00:00:00 2001 From: Jason Grout Date: Wed, 2 Mar 2022 02:17:47 -0800 Subject: [PATCH] Backport echo widget state to 7.x This backports the combination of #3195 and #3394. --- ipywidgets/_version.py | 2 +- ipywidgets/widgets/tests/test_set_state.py | 167 ++++++++++++++++++--- ipywidgets/widgets/widget.py | 34 +++++ ipywidgets/widgets/widget_upload.py | 2 +- packages/base/src/widget.ts | 72 ++++++++- packages/schema/messages.md | 26 ++++ 6 files changed, 277 insertions(+), 26 deletions(-) diff --git a/ipywidgets/_version.py b/ipywidgets/_version.py index b56c867ff3b..60f5fb4e686 100644 --- a/ipywidgets/_version.py +++ b/ipywidgets/_version.py @@ -8,7 +8,7 @@ __version__ = '%s.%s.%s%s'%(version_info[0], version_info[1], version_info[2], '' if version_info[3]=='final' else _specifier_[version_info[3]]+str(version_info[4])) -__protocol_version__ = '2.0.0' +__protocol_version__ = '2.1.0' __control_protocol_version__ = '1.0.0' # These are *protocol* versions for each package, *not* npm versions. To check, look at each package's src/version.ts file for the protocol version the package implements. diff --git a/ipywidgets/widgets/tests/test_set_state.py b/ipywidgets/widgets/tests/test_set_state.py index 85f6e9e7529..250f2888a59 100644 --- a/ipywidgets/widgets/tests/test_set_state.py +++ b/ipywidgets/widgets/tests/test_set_state.py @@ -86,7 +86,7 @@ def test_set_state_simple(): c=[False, True, False], )) - assert w.comm.messages == [] + assert len(w.comm.messages) == 1 def test_set_state_transformer(): @@ -96,11 +96,18 @@ def test_set_state_transformer(): )) # Since the deserialize step changes the state, this should send an update assert w.comm.messages == [((), dict( + buffers=[], + data=dict( + buffer_paths=[], + method='echo_update', + state=dict(d=[True, False, True]), + ))), + ((), dict( buffers=[], data=dict( buffer_paths=[], method='update', - state=dict(d=[False, True, False]) + state=dict(d=[False, True, False]), )))] @@ -111,7 +118,7 @@ def test_set_state_data(): a=True, d={'data': data}, )) - assert w.comm.messages == [] + assert len(w.comm.messages) == 1 def test_set_state_data_truncate(): @@ -122,15 +129,15 @@ def test_set_state_data_truncate(): d={'data': data}, )) # Get message for checking - assert len(w.comm.messages) == 1 # ensure we didn't get more than expected - msg = w.comm.messages[0] + assert len(w.comm.messages) == 2 # ensure we didn't get more than expected + msg = w.comm.messages[1] # Assert that the data update (truncation) sends an update buffers = msg[1].pop('buffers') assert msg == ((), dict( data=dict( - buffer_paths=[['d', 'data']], method='update', - state=dict(d={}) + state=dict(d={}), + buffer_paths=[['d', 'data']] ))) # Sanity: @@ -150,8 +157,8 @@ def test_set_state_numbers_int(): i = 3, ci = 4, )) - # Ensure no update message gets produced - assert len(w.comm.messages) == 0 + # Ensure one update message gets produced + assert len(w.comm.messages) == 1 def test_set_state_numbers_float(): @@ -162,8 +169,8 @@ def test_set_state_numbers_float(): cf = 2.0, ci = 4.0 )) - # Ensure no update message gets produced - assert len(w.comm.messages) == 0 + # Ensure one update message gets produced + assert len(w.comm.messages) == 1 def test_set_state_float_to_float(): @@ -173,8 +180,8 @@ def test_set_state_float_to_float(): f = 1.2, cf = 2.6, )) - # Ensure no update message gets produced - assert len(w.comm.messages) == 0 + # Ensure one message gets produced + assert len(w.comm.messages) == 1 def test_set_state_cint_to_float(): @@ -185,8 +192,8 @@ def test_set_state_cint_to_float(): ci = 5.6 )) # Ensure an update message gets produced - assert len(w.comm.messages) == 1 - msg = w.comm.messages[0] + assert len(w.comm.messages) == 2 + msg = w.comm.messages[1] data = msg[1]['data'] assert data['method'] == 'update' assert data['state'] == {'ci': 5} @@ -241,13 +248,135 @@ def _propagate_value(self, change): # this mimics a value coming from the front end widget.set_state({'value': 42}) assert widget.value == 42 + assert widget.stop is True + + # we expect no new state to be sent + calls = [] + widget._send.assert_has_calls(calls) + + +def test_hold_sync(): + # when this widget's value is set to 42, it sets the value to 2, and also sets a different trait value + class AnnoyingWidget(Widget): + value = Float().tag(sync=True) + other = Float().tag(sync=True) + + @observe('value') + def _propagate_value(self, change): + print('_propagate_value', change.new) + if change.new == 42: + self.value = 2 + self.other = 11 - # we expect first the {'value': 2.0} state to be send, followed by the {'value': 42.0} state - msg = {'method': 'update', 'state': {'value': 2.0}, 'buffer_paths': []} + widget = AnnoyingWidget(value=1) + assert widget.value == 1 + + widget._send = mock.MagicMock() + # this mimics a value coming from the front end + widget.set_state({'value': 42}) + assert widget.value == 2 + assert widget.other == 11 + + msg = {'method': 'echo_update', 'state': {'value': 42.0}, 'buffer_paths': []} + call42 = mock.call(msg, buffers=[]) + + msg = {'method': 'update', 'state': {'value': 2.0, 'other': 11.0}, 'buffer_paths': []} call2 = mock.call(msg, buffers=[]) - msg = {'method': 'update', 'state': {'value': 42.0}, 'buffer_paths': []} + calls = [call42, call2] + widget._send.assert_has_calls(calls) + + +def test_echo(): + # we always echo values back to the frontend + class ValueWidget(Widget): + value = Float().tag(sync=True) + + widget = ValueWidget(value=1) + assert widget.value == 1 + + widget._send = mock.MagicMock() + # this mimics a value coming from the front end + widget.set_state({'value': 42}) + assert widget.value == 42 + + # we expect this to be echoed + msg = {'method': 'echo_update', 'state': {'value': 42.0}, 'buffer_paths': []} call42 = mock.call(msg, buffers=[]) - calls = [call2, call42] + calls = [call42] widget._send.assert_has_calls(calls) + + + +def test_echo_single(): + # we always echo multiple changes back in 1 update + class ValueWidget(Widget): + value = Float().tag(sync=True) + square = Float().tag(sync=True) + @observe('value') + def _square(self, change): + self.square = self.value**2 + + widget = ValueWidget(value=1) + assert widget.value == 1 + + widget._send = mock.MagicMock() + # this mimics a value coming from the front end + widget._handle_msg({ + 'content': { + 'data': { + 'method': 'update', + 'state': { + 'value': 8, + } + } + } + }) + assert widget.value == 8 + assert widget.square == 64 + + # we expect this to be echoed + # note that only value is echoed, not square + msg = {'method': 'echo_update', 'state': {'value': 8.0}, 'buffer_paths': []} + call = mock.call(msg, buffers=[]) + + msg = {'method': 'update', 'state': {'square': 64}, 'buffer_paths': []} + call2 = mock.call(msg, buffers=[]) + + + calls = [call, call2] + widget._send.assert_has_calls(calls) + + +def test_no_echo(): + # in cases where values coming from the frontend are 'heavy', we might want to opt out + class ValueWidget(Widget): + value = Float().tag(sync=True, echo_update=False) + + widget = ValueWidget(value=1) + assert widget.value == 1 + + widget._send = mock.MagicMock() + # this mimics a value coming from the front end + widget._handle_msg({ + 'content': { + 'data': { + 'method': 'update', + 'state': { + 'value': 42, + } + } + } + }) + assert widget.value == 42 + + # widget._send.assert_not_called(calls) + widget._send.assert_not_called() + + # a regular set should sync to the frontend + widget.value = 43 + widget._send.assert_has_calls([mock.call({'method': 'update', 'state': {'value': 43.0}, 'buffer_paths': []}, buffers=[])]) + + + diff --git a/ipywidgets/widgets/widget.py b/ipywidgets/widgets/widget.py index b08725b9f7c..8b399dd9c6b 100644 --- a/ipywidgets/widgets/widget.py +++ b/ipywidgets/widgets/widget.py @@ -7,6 +7,7 @@ in the IPython notebook front-end. """ +import os from contextlib import contextmanager try: from collections.abc import Iterable @@ -26,8 +27,26 @@ from base64 import standard_b64encode from .._version import __protocol_version__, __control_protocol_version__, __jupyter_widgets_base_version__ + + +# Based on jupyter_core.paths.envset +def envset(name, default): + """Return True if the given environment variable is turned on, otherwise False + If the environment variable is set, True will be returned if it is assigned to a value + other than 'no', 'n', 'false', 'off', '0', or '0.0' (case insensitive). + If the environment variable is not set, the default value is returned. + """ + if name in os.environ: + return os.environ[name].lower() not in ['no', 'n', 'false', 'off', '0', '0.0'] + else: + return bool(default) + + + + PROTOCOL_VERSION_MAJOR = __protocol_version__.split('.')[0] CONTROL_PROTOCOL_VERSION_MAJOR = __control_protocol_version__.split('.')[0] +JUPYTER_WIDGETS_ECHO = envset('JUPYTER_WIDGETS_ECHO', default=True) def _widget_to_json(x, obj): if isinstance(x, dict): @@ -580,6 +599,21 @@ def _compare(self, a, b): def set_state(self, sync_data): """Called when a state is received from the front-end.""" + # Send an echo update message immediately + if JUPYTER_WIDGETS_ECHO: + echo_state = {} + for attr,value in sync_data.items(): + if self.trait_metadata(attr, 'echo_update', default=True): + echo_state[attr] = value + if echo_state: + echo_state, echo_buffer_paths, echo_buffers = _remove_buffers(echo_state) + msg = { + 'method': 'echo_update', + 'state': echo_state, + 'buffer_paths': echo_buffer_paths, + } + self._send(msg, buffers=echo_buffers) + # The order of these context managers is important. Properties must # be locked when the hold_trait_notification context manager is # released and notifications are fired. diff --git a/ipywidgets/widgets/widget_upload.py b/ipywidgets/widgets/widget_upload.py index 67b0f84ed00..e7a189766fe 100644 --- a/ipywidgets/widgets/widget_upload.py +++ b/ipywidgets/widgets/widget_upload.py @@ -45,7 +45,7 @@ class FileUpload(DescriptionWidget, ValueWidget, CoreWidget): style = InstanceDict(ButtonStyle).tag(sync=True, **widget_serialization) metadata = List(Dict(), help='List of file metadata').tag(sync=True) data = List(Bytes(), help='List of file content (bytes)').tag( - sync=True, from_json=content_from_json + sync=True, echo_update=False, from_json=content_from_json ) error = Unicode(help='Error message').tag(sync=True) value = Dict(read_only=True) diff --git a/packages/base/src/widget.ts b/packages/base/src/widget.ts index e97da28380b..bada30fe5e5 100644 --- a/packages/base/src/widget.ts +++ b/packages/base/src/widget.ts @@ -112,7 +112,9 @@ class WidgetModel extends Backbone.Model { * comm : Comm instance (optional) */ initialize(attributes: any, options: {model_id: string, comm?: any, widget_manager: any}) { - this.expectedEchoMsgIds = new Map(); + this._expectedEchoMsgIds = {}; + this._attrsToUpdate = {}; + super.initialize(attributes, options); // Attributes should be initialized here, since user initialization may depend on it @@ -227,6 +229,38 @@ class WidgetModel extends Backbone.Model { }); utils.put_buffers(state, buffer_paths, buffers); + + if (msg.parent_header && method === 'echo_update') { + const msgId = (msg.parent_header as any).msg_id; + // we may have echos coming from other clients, we only care about + // dropping echos for which we expected a reply + const expectedEcho = Object.keys(state).filter((attrName) => + this._expectedEchoMsgIds.hasOwnProperty(attrName) + ); + expectedEcho.forEach((attrName: string) => { + // Skip echo messages until we get the reply we are expecting. + const isOldMessage = + this._expectedEchoMsgIds[attrName] !== msgId; + if (isOldMessage) { + // Ignore an echo update that comes before our echo. + delete state[attrName]; + } else { + // we got our echo confirmation, so stop looking for it + delete this._expectedEchoMsgIds[attrName]; + // Start accepting echo updates unless we plan to send out a new state soon + if ( + this._msg_buffer !== null && + Object.prototype.hasOwnProperty.call( + this._msg_buffer, + attrName + ) + ) { + delete state[attrName]; + } + } + }); + } + return (this.constructor as typeof WidgetModel)._deserialize_state(state, this.widget_manager); }).then((state) => { this.set_state(state); @@ -290,7 +324,11 @@ class WidgetModel extends Backbone.Model { // Send buffer if one is waiting and we are below the throttle. if (this._msg_buffer !== null && this._pending_msgs < 1 ) { - this.send_sync_message(this._msg_buffer, this._msg_buffer_callbacks); + const msgId = this.send_sync_message( + this._msg_buffer, + this._msg_buffer_callbacks + ); + this.rememberLastUpdateFor(msgId); this._msg_buffer = null; this._msg_buffer_callbacks = null; } @@ -385,6 +423,10 @@ class WidgetModel extends Backbone.Model { } } + Object.keys(attrs).forEach((attrName: string) => { + this._attrsToUpdate[attrName] = true; + }); + let msgState = this.serialize(attrs); if (Object.keys(msgState).length > 0) { @@ -415,7 +457,8 @@ class WidgetModel extends Backbone.Model { } else { // We haven't exceeded the throttle, send the message like // normal. - this.send_sync_message(attrs, callbacks); + const msgId = this.send_sync_message(attrs, callbacks); + this.rememberLastUpdateFor(msgId); // Since the comm is a one-way communication, assume the message // arrived and was processed successfully. // Don't call options.success since we don't have a model back from @@ -425,6 +468,13 @@ class WidgetModel extends Backbone.Model { } } + rememberLastUpdateFor(msgId: string) { + Object.keys(this._attrsToUpdate).forEach((attrName) => { + this._expectedEchoMsgIds[attrName] = msgId; + }); + this._attrsToUpdate = {}; + } + /** * Serialize widget state. * @@ -458,7 +508,11 @@ class WidgetModel extends Backbone.Model { /** * Send a sync message to the kernel. */ - send_sync_message(state: {}, callbacks: any = {}) { + send_sync_message(state: {}, callbacks: any = {}): string { + if (!this.comm) { + return ''; + } + try { callbacks.iopub = callbacks.iopub || {}; let statuscb = callbacks.iopub.status; @@ -471,15 +525,17 @@ class WidgetModel extends Backbone.Model { // split out the binary buffers let split = utils.remove_buffers(state); - this.comm.send({ + const msgId = this.comm.send({ method: 'update', state: split.state, buffer_paths: split.buffer_paths }, callbacks, {}, split.buffers); this._pending_msgs++; + return msgId; } catch (e) { console.error('Could not send widget sync message', e); } + return ''; } /** @@ -567,6 +623,12 @@ class WidgetModel extends Backbone.Model { private _msg_buffer: any; private _msg_buffer_callbacks: any; private _pending_msgs: number; + // keep track of the msg id for each attr for updates we send out so + // that we can ignore old messages that we send in order to avoid + // 'drunken' sliders going back and forward + private _expectedEchoMsgIds: {[key: string]: string}; + // because we don't know the attrs in _handle_status, we keep track of what we will send + private _attrsToUpdate: {[key: string]: boolean }; } export diff --git a/packages/schema/messages.md b/packages/schema/messages.md index 7e572c89c7b..e2643f6a741 100644 --- a/packages/schema/messages.md +++ b/packages/schema/messages.md @@ -292,6 +292,32 @@ The `data.state` and `data.buffer_paths` values are the same as in the `comm_ope See the [Model state](jupyterwidgetmodels.latest.md) documentation for the attributes of core Jupyter widgets. +#### Synchronizing multiple frontends: `echo_update` + +Starting with protocol version `2.1.0`, `echo_update` messages from the kernel to the frontend are optional update messages for echoing state in messages from a frontend to the kernel back out to all the frontends. + +``` +{ + 'comm_id' : 'u-u-i-d', + 'data' : { + 'method': 'echo_update', + 'state': { }, + 'buffer_paths': [ ] + } +} +``` + +The Jupyter comm protocol is asymmetric in how messages flow: messages flow from a single frontend to a single kernel, but messages are broadcast from the kernel to *all* frontends. In the widget protocol, if a frontend updates the value of a widget, the frontend does not have a way to directly notify other frontends about the state update. The `echo_update` optional messages enable a kernel to broadcast out frontend updates to all frontends. This can also help resolve the race condition where the kernel and a frontend simultaneously send updates to each other since the frontend now knows the order of kernel updates. + +The `echo_update` messages enable a frontend to optimistically update its widget views to reflect its own changes that it knows the kernel will yet process. These messages are intended to be used as follows: +1. A frontend model attribute is updated, and the frontend views are optimistically updated to reflect the attribute. +2. The frontend queues an update message to the kernel and records the message id for the attribute. +3. The frontend ignores updates to the attribute from the kernel contained in `echo_update` messages until it gets an `echo_update` message corresponding to its own update of the attribute (i.e., the [parent_header](https://jupyter-client.readthedocs.io/en/latest/messaging.html#parent-header) id matches the stored message id for the attribute). It also ignores `echo_update` updates if it has a pending attribute update to send to the kernel. Once the frontend receives its own `echo_update` and does not have any more pending attribute updates to send to the kernel, it starts applying attribute updates from `echo_update` messages. + +Since the `echo_update` update messages are optional, and not all attribute updates may be echoed, it is important that only `echo_update` updates are ignored in the last step above, and `update` message updates are always applied. + +Implementation note: For attributes where sending back an `echo_update` is considered too expensive or unnecessary, we have implemented an opt-out mechanism in the ipywidgets package. A model trait can have the `echo_update` metadata attribute set to `False` to flag that the kernel should never send an `echo_update` update for that attribute to the frontends. Additionally, we have a system-wide flag to disable echoing for all attributes via the environment variable `JUPYTER_WIDGETS_ECHO`. For ipywdgets 7.7, we default `JUPYTER_WIDGETS_ECHO` to off (disabling all echo messages) and in ipywidgets 8.0 we default `JUPYTER_WIDGETS_ECHO` to on (enabling echo messages). + #### State requests: `request_state` When a frontend wants to request the full state of a widget, the frontend sends a `request_state` message: