From 97e2257a78dcaad51c9d1498f17d3a4c2188b779 Mon Sep 17 00:00:00 2001 From: Jason Grout Date: Tue, 1 Mar 2022 22:41:08 -0800 Subject: [PATCH 1/8] 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/package.json | 1 + packages/base/src/version.ts | 2 +- packages/base/src/widget.ts | 76 +++++++++- packages/base/test/src/manager_test.ts | 4 +- packages/schema/messages.md | 26 ++++ 9 files changed, 284 insertions(+), 30 deletions(-) diff --git a/ipywidgets/_version.py b/ipywidgets/_version.py index b56c867ff3..60f5fb4e68 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 85f6e9e752..250f2888a5 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 b08725b9f7..8b399dd9c6 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 67b0f84ed0..e7a189766f 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/package.json b/packages/base/package.json index cd848f8fe6..8a26017cb1 100644 --- a/packages/base/package.json +++ b/packages/base/package.json @@ -27,6 +27,7 @@ "test:coverage": "npm run build:test && webpack --config test/webpack-cov.conf.js && karma start test/karma-cov.conf.js", "test:unit": "npm run test:unit:firefox && npm run test:unit:chrome", "test:unit:chrome": "npm run test:unit:default -- --browsers=Chrome", + "test:unit:chrome:debug": "npm run test:unit:default -- --browsers=Chrome --single-run=false", "test:unit:default": "npm run build:test && karma start test/karma.conf.js --log-level debug", "test:unit:firefox": "npm run test:unit:default -- --browsers=Firefox", "test:unit:firefox:headless": "npm run test:unit:default -- --browsers=FirefoxHeadless", diff --git a/packages/base/src/version.ts b/packages/base/src/version.ts index 0cb2fcfb27..8fbd0e7e5b 100644 --- a/packages/base/src/version.ts +++ b/packages/base/src/version.ts @@ -5,4 +5,4 @@ export const JUPYTER_WIDGETS_VERSION = '1.2.0'; export -const PROTOCOL_VERSION = '2.0.0'; +const PROTOCOL_VERSION = '2.1.0'; diff --git a/packages/base/src/widget.ts b/packages/base/src/widget.ts index ef2cc49214..bada30fe5e 100644 --- a/packages/base/src/widget.ts +++ b/packages/base/src/widget.ts @@ -112,6 +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 = {}; + this._attrsToUpdate = {}; + super.initialize(attributes, options); // Attributes should be initialized here, since user initialization may depend on it @@ -211,12 +214,13 @@ class WidgetModel extends Backbone.Model { // tslint:disable-next-line:switch-default switch (method) { case 'update': + case 'echo_update': this.state_change = this.state_change .then(() => { let state = data.state; - let buffer_paths = data.buffer_paths || []; + let buffer_paths = data.buffer_paths ?? []; // Make sure the buffers are DataViews - let buffers = (msg.buffers || []).map(b => { + let buffers = (msg.buffers?.slice(0, buffer_paths.length) ?? []).map(b => { if (b instanceof DataView) { return b; } else { @@ -225,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); @@ -288,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; } @@ -383,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) { @@ -413,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 @@ -423,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. * @@ -456,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; @@ -469,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 ''; } /** @@ -565,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/base/test/src/manager_test.ts b/packages/base/test/src/manager_test.ts index 5fe7545098..2989d3f1fd 100644 --- a/packages/base/test/src/manager_test.ts +++ b/packages/base/test/src/manager_test.ts @@ -159,7 +159,7 @@ describe('ManagerBase', function() { } }, metadata: { - version: '2.0.0' + version: '2.1.0' } }); expect(model.comm).to.equal(comm); @@ -222,7 +222,7 @@ describe('ManagerBase', function() { }, buffers: [new DataView((new Uint8Array([1, 2, 3])).buffer)], metadata: { - version: '2.0.0' + version: '2.1.0' } }); expect(model.comm).to.equal(comm); diff --git a/packages/schema/messages.md b/packages/schema/messages.md index 7e572c89c7..e2643f6a74 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: From c1b79935087f7f82379026c1bf41d4abfa5afcff Mon Sep 17 00:00:00 2001 From: Jason Grout Date: Wed, 2 Mar 2022 03:13:32 -0800 Subject: [PATCH 2/8] Fix hold_sync test We have to explicitly use hold_sync in the test, since https://github.com/jupyter-widgets/ipywidgets/pull/3271 was targeted to 8.0 and not backported to 7.x. --- ipywidgets/widgets/tests/test_set_state.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/ipywidgets/widgets/tests/test_set_state.py b/ipywidgets/widgets/tests/test_set_state.py index 250f2888a5..fb4e8087d3 100644 --- a/ipywidgets/widgets/tests/test_set_state.py +++ b/ipywidgets/widgets/tests/test_set_state.py @@ -265,8 +265,9 @@ class AnnoyingWidget(Widget): def _propagate_value(self, change): print('_propagate_value', change.new) if change.new == 42: - self.value = 2 - self.other = 11 + with self.hold_sync(): + self.value = 2 + self.other = 11 widget = AnnoyingWidget(value=1) assert widget.value == 1 From a6f4502e51ce8fd84816605d07d759b984f3cd25 Mon Sep 17 00:00:00 2001 From: Jason Grout Date: Wed, 2 Mar 2022 03:13:47 -0800 Subject: [PATCH 3/8] Fix import in tests for Python 3.10. --- ipywidgets/widgets/tests/test_interaction.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ipywidgets/widgets/tests/test_interaction.py b/ipywidgets/widgets/tests/test_interaction.py index 65bb7b473a..19a82bb236 100644 --- a/ipywidgets/widgets/tests/test_interaction.py +++ b/ipywidgets/widgets/tests/test_interaction.py @@ -254,7 +254,7 @@ def test_iterable_tuple(): check_widgets(c, lis=d) def test_mapping(): - from collections import Mapping, OrderedDict + from collections.abc import Mapping class TestMapping(Mapping): def __init__(self, values): self.values = values From 6f135eb354299e226f3cb8fed72277d22519d161 Mon Sep 17 00:00:00 2001 From: Jason Grout Date: Wed, 2 Mar 2022 03:34:44 -0800 Subject: [PATCH 4/8] Turn off echoing state by default in 7.x We do this for backwards compatibility, so there are no surprises. During 7.x, you can opt in to widget update echoing by setting the environment variable or by setting the module-level variable. --- ipywidgets/widgets/widget.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ipywidgets/widgets/widget.py b/ipywidgets/widgets/widget.py index 8b399dd9c6..ba6e18bce1 100644 --- a/ipywidgets/widgets/widget.py +++ b/ipywidgets/widgets/widget.py @@ -46,7 +46,7 @@ def envset(name, 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) +JUPYTER_WIDGETS_ECHO = envset('JUPYTER_WIDGETS_ECHO', default=False) def _widget_to_json(x, obj): if isinstance(x, dict): From 6969d533cd662bbd226485f8d8cf9e4fe8db512e Mon Sep 17 00:00:00 2001 From: Jason Grout Date: Wed, 2 Mar 2022 07:52:03 -0800 Subject: [PATCH 5/8] Test setting state with both echo and not echo --- ipywidgets/widgets/tests/test_set_state.py | 182 ++------ .../widgets/tests/test_set_state_echo.py | 395 ++++++++++++++++++ 2 files changed, 424 insertions(+), 153 deletions(-) create mode 100644 ipywidgets/widgets/tests/test_set_state_echo.py diff --git a/ipywidgets/widgets/tests/test_set_state.py b/ipywidgets/widgets/tests/test_set_state.py index fb4e8087d3..b2aeab915f 100644 --- a/ipywidgets/widgets/tests/test_set_state.py +++ b/ipywidgets/widgets/tests/test_set_state.py @@ -14,11 +14,17 @@ from .utils import setup, teardown -from ..widget import Widget +import ipywidgets +from ipywidgets import Widget + +# Everything in this file assumes echo is false +@pytest.fixture(autouse=True) +def echo(): + oldvalue = ipywidgets.widgets.widget.JUPYTER_WIDGETS_ECHO + ipywidgets.widgets.widget.JUPYTER_WIDGETS_ECHO = False + yield + ipywidgets.widgets.widget.JUPYTER_WIDGETS_ECHO = oldvalue -# -# First some widgets to test on: -# # A widget with simple traits (list + tuple to ensure both are handled) class SimpleWidget(Widget): @@ -86,7 +92,7 @@ def test_set_state_simple(): c=[False, True, False], )) - assert len(w.comm.messages) == 1 + assert w.comm.messages == [] def test_set_state_transformer(): @@ -96,18 +102,11 @@ 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]) )))] @@ -118,7 +117,7 @@ def test_set_state_data(): a=True, d={'data': data}, )) - assert len(w.comm.messages) == 1 + assert w.comm.messages == [] def test_set_state_data_truncate(): @@ -129,15 +128,15 @@ def test_set_state_data_truncate(): d={'data': data}, )) # Get message for checking - assert len(w.comm.messages) == 2 # ensure we didn't get more than expected - msg = w.comm.messages[1] + assert len(w.comm.messages) == 1 # ensure we didn't get more than expected + msg = w.comm.messages[0] # 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={}), - buffer_paths=[['d', 'data']] + state=dict(d={}) ))) # Sanity: @@ -157,8 +156,8 @@ def test_set_state_numbers_int(): i = 3, ci = 4, )) - # Ensure one update message gets produced - assert len(w.comm.messages) == 1 + # Ensure no update message gets produced + assert len(w.comm.messages) == 0 def test_set_state_numbers_float(): @@ -169,8 +168,8 @@ def test_set_state_numbers_float(): cf = 2.0, ci = 4.0 )) - # Ensure one update message gets produced - assert len(w.comm.messages) == 1 + # Ensure no update message gets produced + assert len(w.comm.messages) == 0 def test_set_state_float_to_float(): @@ -180,8 +179,8 @@ def test_set_state_float_to_float(): f = 1.2, cf = 2.6, )) - # Ensure one message gets produced - assert len(w.comm.messages) == 1 + # Ensure no update message gets produced + assert len(w.comm.messages) == 0 def test_set_state_cint_to_float(): @@ -192,8 +191,8 @@ def test_set_state_cint_to_float(): ci = 5.6 )) # Ensure an update message gets produced - assert len(w.comm.messages) == 2 - msg = w.comm.messages[1] + assert len(w.comm.messages) == 1 + msg = w.comm.messages[0] data = msg[1]['data'] assert data['method'] == 'update' assert data['state'] == {'ci': 5} @@ -248,136 +247,13 @@ 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: - with self.hold_sync(): - self.value = 2 - self.other = 11 - - 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': []} + # 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': []} call2 = mock.call(msg, buffers=[]) - 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': []} + msg = {'method': 'update', 'state': {'value': 42.0}, 'buffer_paths': []} call42 = mock.call(msg, buffers=[]) - 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] + calls = [call2, call42] 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/tests/test_set_state_echo.py b/ipywidgets/widgets/tests/test_set_state_echo.py new file mode 100644 index 0000000000..e2dc56e5b6 --- /dev/null +++ b/ipywidgets/widgets/tests/test_set_state_echo.py @@ -0,0 +1,395 @@ +# Copyright (c) Jupyter Development Team. +# Distributed under the terms of the Modified BSD License. + +import pytest +try: + from unittest import mock +except ImportError: + import mock + +from traitlets import Bool, Tuple, List, Instance, CFloat, CInt, Float, Int, TraitError, observe + +from .utils import setup, teardown + +import ipywidgets +from ipywidgets import Widget + +# Everything in this file assumes echo is true +@pytest.fixture(autouse=True) +def echo(): + oldvalue = ipywidgets.widgets.widget.JUPYTER_WIDGETS_ECHO + ipywidgets.widgets.widget.JUPYTER_WIDGETS_ECHO = True + yield + ipywidgets.widgets.widget.JUPYTER_WIDGETS_ECHO = oldvalue + + +# +# First some widgets to test on: +# + +# A widget with simple traits (list + tuple to ensure both are handled) +class SimpleWidget(Widget): + a = Bool().tag(sync=True) + b = Tuple(Bool(), Bool(), Bool(), default_value=(False, False, False)).tag(sync=True) + c = List(Bool()).tag(sync=True) + + +# A widget with various kinds of number traits +class NumberWidget(Widget): + f = Float().tag(sync=True) + cf = CFloat().tag(sync=True) + i = Int().tag(sync=True) + ci = CInt().tag(sync=True) + + + +# A widget where the data might be changed on reception: +def transform_fromjson(data, widget): + # Switch the two last elements when setting from json, if the first element is True + # and always set first element to False + if not data[0]: + return data + return [False] + data[1:-2] + [data[-1], data[-2]] + +class TransformerWidget(Widget): + d = List(Bool()).tag(sync=True, from_json=transform_fromjson) + + + +# A widget that has a buffer: +class DataInstance(): + def __init__(self, data=None): + self.data = data + +def mview_serializer(instance, widget): + return { 'data': memoryview(instance.data) if instance.data else None } + +def bytes_serializer(instance, widget): + return { 'data': bytearray(memoryview(instance.data).tobytes()) if instance.data else None } + +def deserializer(json_data, widget): + return DataInstance( memoryview(json_data['data']).tobytes() if json_data else None ) + +class DataWidget(SimpleWidget): + d = Instance(DataInstance).tag(sync=True, to_json=mview_serializer, from_json=deserializer) + +# A widget that has a buffer that might be changed on reception: +def truncate_deserializer(json_data, widget): + return DataInstance( json_data['data'][:20].tobytes() if json_data else None ) + +class TruncateDataWidget(SimpleWidget): + d = Instance(DataInstance).tag(sync=True, to_json=bytes_serializer, from_json=truncate_deserializer) + + +# +# Actual tests: +# + +import ipywidgets + + +# Set up widget echoing + +def test_set_state_simple(): + w = SimpleWidget() + w.set_state(dict( + a=True, + b=[True, False, True], + c=[False, True, False], + )) + + assert len(w.comm.messages) == 1 + + +def test_set_state_transformer(): + w = TransformerWidget() + w.set_state(dict( + d=[True, False, True] + )) + # 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]), + )))] + + +def test_set_state_data(): + w = DataWidget() + data = memoryview(b'x'*30) + w.set_state(dict( + a=True, + d={'data': data}, + )) + assert len(w.comm.messages) == 1 + + +def test_set_state_data_truncate(): + w = TruncateDataWidget() + data = memoryview(b'x'*30) + w.set_state(dict( + a=True, + d={'data': data}, + )) + # Get message for checking + 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( + method='update', + state=dict(d={}), + buffer_paths=[['d', 'data']] + ))) + + # Sanity: + assert len(buffers) == 1 + assert buffers[0] == data[:20].tobytes() + + +def test_set_state_numbers_int(): + # JS does not differentiate between float/int. + # Instead, it formats exact floats as ints in JSON (1.0 -> '1'). + + w = NumberWidget() + # Set everything with ints + w.set_state(dict( + f = 1, + cf = 2, + i = 3, + ci = 4, + )) + # Ensure one update message gets produced + assert len(w.comm.messages) == 1 + + +def test_set_state_numbers_float(): + w = NumberWidget() + # Set floats to int-like floats + w.set_state(dict( + f = 1.0, + cf = 2.0, + ci = 4.0 + )) + # Ensure one update message gets produced + assert len(w.comm.messages) == 1 + + +def test_set_state_float_to_float(): + w = NumberWidget() + # Set floats to float + w.set_state(dict( + f = 1.2, + cf = 2.6, + )) + # Ensure one message gets produced + assert len(w.comm.messages) == 1 + + +def test_set_state_cint_to_float(): + w = NumberWidget() + + # Set CInt to float + w.set_state(dict( + ci = 5.6 + )) + # Ensure an update message gets produced + assert len(w.comm.messages) == 2 + msg = w.comm.messages[1] + data = msg[1]['data'] + assert data['method'] == 'update' + assert data['state'] == {'ci': 5} + + +# This test is disabled, meaning ipywidgets REQUIRES +# any JSON received to format int-like numbers as ints +def _x_test_set_state_int_to_int_like(): + # Note: Setting i to an int-like float will produce an + # error, so if JSON producer were to always create + # float formatted numbers, this would fail! + + w = NumberWidget() + # Set floats to int-like floats + w.set_state(dict( + i = 3.0 + )) + # Ensure no update message gets produced + assert len(w.comm.messages) == 0 + + +def test_set_state_int_to_float(): + w = NumberWidget() + + # Set Int to float + with pytest.raises(TraitError): + w.set_state(dict( + i = 3.5 + )) + +def test_property_lock(): + # when this widget's value is set to 42, it sets itself to 2, and then back to 42 again (and then stops) + class AnnoyingWidget(Widget): + value = Float().tag(sync=True) + stop = Bool(False) + + @observe('value') + def _propagate_value(self, change): + print('_propagate_value', change.new) + if self.stop: + return + if change.new == 42: + self.value = 2 + if change.new == 2: + self.stop = True + self.value = 42 + + 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 == 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: + with self.hold_sync(): + self.value = 2 + self.other = 11 + + 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=[]) + + 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 = [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=[])]) + + + From d5207d0b5c82829c61b564e6d0e14f4dc75fafea Mon Sep 17 00:00:00 2001 From: Vidar Tonaas Fauske Date: Mon, 7 Mar 2022 17:27:12 +0000 Subject: [PATCH 6/8] Proposal to better keep tests in sync As the code in test_set_state_noecho was mostly a copy, I tried to make it more DRY. --- ipywidgets/widgets/tests/test_set_state.py | 213 ++++++++-- .../widgets/tests/test_set_state_echo.py | 395 ------------------ 2 files changed, 174 insertions(+), 434 deletions(-) delete mode 100644 ipywidgets/widgets/tests/test_set_state_echo.py diff --git a/ipywidgets/widgets/tests/test_set_state.py b/ipywidgets/widgets/tests/test_set_state.py index b2aeab915f..ab072fc435 100644 --- a/ipywidgets/widgets/tests/test_set_state.py +++ b/ipywidgets/widgets/tests/test_set_state.py @@ -1,15 +1,12 @@ # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. -from ipython_genutils.py3compat import PY3 - import pytest try: from unittest import mock except ImportError: import mock - from traitlets import Bool, Tuple, List, Instance, CFloat, CInt, Float, Int, TraitError, observe from .utils import setup, teardown @@ -17,14 +14,17 @@ import ipywidgets from ipywidgets import Widget -# Everything in this file assumes echo is false -@pytest.fixture(autouse=True) -def echo(): + +@pytest.fixture(params=[True, False]) +def echo(request): oldvalue = ipywidgets.widgets.widget.JUPYTER_WIDGETS_ECHO - ipywidgets.widgets.widget.JUPYTER_WIDGETS_ECHO = False - yield + ipywidgets.widgets.widget.JUPYTER_WIDGETS_ECHO = request.param + yield request.param ipywidgets.widgets.widget.JUPYTER_WIDGETS_ECHO = oldvalue +# +# First some widgets to test on: +# # A widget with simple traits (list + tuple to ensure both are handled) class SimpleWidget(Widget): @@ -84,7 +84,7 @@ class TruncateDataWidget(SimpleWidget): # Actual tests: # -def test_set_state_simple(): +def test_set_state_simple(echo): w = SimpleWidget() w.set_state(dict( a=True, @@ -92,35 +92,47 @@ def test_set_state_simple(): c=[False, True, False], )) - assert w.comm.messages == [] + assert len(w.comm.messages) == (1 if echo else 0) -def test_set_state_transformer(): +def test_set_state_transformer(echo): w = TransformerWidget() w.set_state(dict( d=[True, False, True] )) # Since the deserialize step changes the state, this should send an update - assert w.comm.messages == [((), dict( + expected = [] + if echo: + expected.append( + ((), dict( + buffers=[], + data=dict( + buffer_paths=[], + method='echo_update', + state=dict(d=[True, False, True]), + )))) + expected.append( + ((), dict( buffers=[], data=dict( buffer_paths=[], method='update', - state=dict(d=[False, True, False]) - )))] + state=dict(d=[False, True, False]), + )))) + assert w.comm.messages == expected -def test_set_state_data(): +def test_set_state_data(echo): w = DataWidget() data = memoryview(b'x'*30) w.set_state(dict( a=True, d={'data': data}, )) - assert w.comm.messages == [] + assert len(w.comm.messages) == (1 if echo else 0) -def test_set_state_data_truncate(): +def test_set_state_data_truncate(echo): w = TruncateDataWidget() data = memoryview(b'x'*30) w.set_state(dict( @@ -128,15 +140,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 if echo else 1 # 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: @@ -144,7 +156,7 @@ def test_set_state_data_truncate(): assert buffers[0] == data[:20].tobytes() -def test_set_state_numbers_int(): +def test_set_state_numbers_int(echo): # JS does not differentiate between float/int. # Instead, it formats exact floats as ints in JSON (1.0 -> '1'). @@ -156,11 +168,11 @@ 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 if echo else 0) -def test_set_state_numbers_float(): +def test_set_state_numbers_float(echo): w = NumberWidget() # Set floats to int-like floats w.set_state(dict( @@ -168,22 +180,22 @@ 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 if echo else 0) -def test_set_state_float_to_float(): +def test_set_state_float_to_float(echo): w = NumberWidget() # Set floats to float w.set_state(dict( 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 if echo else 0) -def test_set_state_cint_to_float(): +def test_set_state_cint_to_float(echo): w = NumberWidget() # Set CInt to float @@ -191,8 +203,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 if echo else 1) + msg = w.comm.messages[-1] data = msg[1]['data'] assert data['method'] == 'update' assert data['state'] == {'ci': 5} @@ -214,7 +226,7 @@ def _x_test_set_state_int_to_int_like(): assert len(w.comm.messages) == 0 -def test_set_state_int_to_float(): +def test_set_state_int_to_float(echo): w = NumberWidget() # Set Int to float @@ -223,7 +235,7 @@ def test_set_state_int_to_float(): i = 3.5 )) -def test_property_lock(): +def test_property_lock(echo): # when this widget's value is set to 42, it sets itself to 2, and then back to 42 again (and then stops) class AnnoyingWidget(Widget): value = Float().tag(sync=True) @@ -247,13 +259,136 @@ 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) + - # 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': []} +def test_hold_sync(echo): + # 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: + with self.hold_sync(): + self.value = 2 + self.other = 11 + + 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] if echo else [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(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/tests/test_set_state_echo.py b/ipywidgets/widgets/tests/test_set_state_echo.py deleted file mode 100644 index e2dc56e5b6..0000000000 --- a/ipywidgets/widgets/tests/test_set_state_echo.py +++ /dev/null @@ -1,395 +0,0 @@ -# Copyright (c) Jupyter Development Team. -# Distributed under the terms of the Modified BSD License. - -import pytest -try: - from unittest import mock -except ImportError: - import mock - -from traitlets import Bool, Tuple, List, Instance, CFloat, CInt, Float, Int, TraitError, observe - -from .utils import setup, teardown - -import ipywidgets -from ipywidgets import Widget - -# Everything in this file assumes echo is true -@pytest.fixture(autouse=True) -def echo(): - oldvalue = ipywidgets.widgets.widget.JUPYTER_WIDGETS_ECHO - ipywidgets.widgets.widget.JUPYTER_WIDGETS_ECHO = True - yield - ipywidgets.widgets.widget.JUPYTER_WIDGETS_ECHO = oldvalue - - -# -# First some widgets to test on: -# - -# A widget with simple traits (list + tuple to ensure both are handled) -class SimpleWidget(Widget): - a = Bool().tag(sync=True) - b = Tuple(Bool(), Bool(), Bool(), default_value=(False, False, False)).tag(sync=True) - c = List(Bool()).tag(sync=True) - - -# A widget with various kinds of number traits -class NumberWidget(Widget): - f = Float().tag(sync=True) - cf = CFloat().tag(sync=True) - i = Int().tag(sync=True) - ci = CInt().tag(sync=True) - - - -# A widget where the data might be changed on reception: -def transform_fromjson(data, widget): - # Switch the two last elements when setting from json, if the first element is True - # and always set first element to False - if not data[0]: - return data - return [False] + data[1:-2] + [data[-1], data[-2]] - -class TransformerWidget(Widget): - d = List(Bool()).tag(sync=True, from_json=transform_fromjson) - - - -# A widget that has a buffer: -class DataInstance(): - def __init__(self, data=None): - self.data = data - -def mview_serializer(instance, widget): - return { 'data': memoryview(instance.data) if instance.data else None } - -def bytes_serializer(instance, widget): - return { 'data': bytearray(memoryview(instance.data).tobytes()) if instance.data else None } - -def deserializer(json_data, widget): - return DataInstance( memoryview(json_data['data']).tobytes() if json_data else None ) - -class DataWidget(SimpleWidget): - d = Instance(DataInstance).tag(sync=True, to_json=mview_serializer, from_json=deserializer) - -# A widget that has a buffer that might be changed on reception: -def truncate_deserializer(json_data, widget): - return DataInstance( json_data['data'][:20].tobytes() if json_data else None ) - -class TruncateDataWidget(SimpleWidget): - d = Instance(DataInstance).tag(sync=True, to_json=bytes_serializer, from_json=truncate_deserializer) - - -# -# Actual tests: -# - -import ipywidgets - - -# Set up widget echoing - -def test_set_state_simple(): - w = SimpleWidget() - w.set_state(dict( - a=True, - b=[True, False, True], - c=[False, True, False], - )) - - assert len(w.comm.messages) == 1 - - -def test_set_state_transformer(): - w = TransformerWidget() - w.set_state(dict( - d=[True, False, True] - )) - # 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]), - )))] - - -def test_set_state_data(): - w = DataWidget() - data = memoryview(b'x'*30) - w.set_state(dict( - a=True, - d={'data': data}, - )) - assert len(w.comm.messages) == 1 - - -def test_set_state_data_truncate(): - w = TruncateDataWidget() - data = memoryview(b'x'*30) - w.set_state(dict( - a=True, - d={'data': data}, - )) - # Get message for checking - 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( - method='update', - state=dict(d={}), - buffer_paths=[['d', 'data']] - ))) - - # Sanity: - assert len(buffers) == 1 - assert buffers[0] == data[:20].tobytes() - - -def test_set_state_numbers_int(): - # JS does not differentiate between float/int. - # Instead, it formats exact floats as ints in JSON (1.0 -> '1'). - - w = NumberWidget() - # Set everything with ints - w.set_state(dict( - f = 1, - cf = 2, - i = 3, - ci = 4, - )) - # Ensure one update message gets produced - assert len(w.comm.messages) == 1 - - -def test_set_state_numbers_float(): - w = NumberWidget() - # Set floats to int-like floats - w.set_state(dict( - f = 1.0, - cf = 2.0, - ci = 4.0 - )) - # Ensure one update message gets produced - assert len(w.comm.messages) == 1 - - -def test_set_state_float_to_float(): - w = NumberWidget() - # Set floats to float - w.set_state(dict( - f = 1.2, - cf = 2.6, - )) - # Ensure one message gets produced - assert len(w.comm.messages) == 1 - - -def test_set_state_cint_to_float(): - w = NumberWidget() - - # Set CInt to float - w.set_state(dict( - ci = 5.6 - )) - # Ensure an update message gets produced - assert len(w.comm.messages) == 2 - msg = w.comm.messages[1] - data = msg[1]['data'] - assert data['method'] == 'update' - assert data['state'] == {'ci': 5} - - -# This test is disabled, meaning ipywidgets REQUIRES -# any JSON received to format int-like numbers as ints -def _x_test_set_state_int_to_int_like(): - # Note: Setting i to an int-like float will produce an - # error, so if JSON producer were to always create - # float formatted numbers, this would fail! - - w = NumberWidget() - # Set floats to int-like floats - w.set_state(dict( - i = 3.0 - )) - # Ensure no update message gets produced - assert len(w.comm.messages) == 0 - - -def test_set_state_int_to_float(): - w = NumberWidget() - - # Set Int to float - with pytest.raises(TraitError): - w.set_state(dict( - i = 3.5 - )) - -def test_property_lock(): - # when this widget's value is set to 42, it sets itself to 2, and then back to 42 again (and then stops) - class AnnoyingWidget(Widget): - value = Float().tag(sync=True) - stop = Bool(False) - - @observe('value') - def _propagate_value(self, change): - print('_propagate_value', change.new) - if self.stop: - return - if change.new == 42: - self.value = 2 - if change.new == 2: - self.stop = True - self.value = 42 - - 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 == 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: - with self.hold_sync(): - self.value = 2 - self.other = 11 - - 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=[]) - - 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 = [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=[])]) - - - From c6c90873b476b9dd56de57625a6f2eb14e669771 Mon Sep 17 00:00:00 2001 From: Vidar Tonaas Fauske Date: Wed, 9 Mar 2022 12:39:51 +0000 Subject: [PATCH 7/8] tweak previous commit for backport --- ipywidgets/widgets/tests/test_set_state.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/ipywidgets/widgets/tests/test_set_state.py b/ipywidgets/widgets/tests/test_set_state.py index ab072fc435..19683753e0 100644 --- a/ipywidgets/widgets/tests/test_set_state.py +++ b/ipywidgets/widgets/tests/test_set_state.py @@ -299,8 +299,8 @@ def _propagate_value(self, change): widget._send.assert_has_calls(calls) -def test_echo(): - # we always echo values back to the frontend +def test_echo(echo): + # we always echo values back to the frontend if configured class ValueWidget(Widget): value = Float().tag(sync=True) @@ -316,12 +316,12 @@ class ValueWidget(Widget): msg = {'method': 'echo_update', 'state': {'value': 42.0}, 'buffer_paths': []} call42 = mock.call(msg, buffers=[]) - calls = [call42] + calls = [call42] if echo else [] widget._send.assert_has_calls(calls) -def test_echo_single(): +def test_echo_single(echo): # we always echo multiple changes back in 1 update class ValueWidget(Widget): value = Float().tag(sync=True) @@ -357,7 +357,7 @@ def _square(self, change): call2 = mock.call(msg, buffers=[]) - calls = [call, call2] + calls = [call, call2] if echo else [call2] widget._send.assert_has_calls(calls) From ff4855b28cb7b227176ab5749dd1db09fe8b5d0c Mon Sep 17 00:00:00 2001 From: Jason Grout Date: Wed, 9 Mar 2022 16:46:13 -0800 Subject: [PATCH 8/8] Python 2/3 shim in tests so they pass on python 2 for ipywidgets 7.x --- ipywidgets/widgets/tests/test_interaction.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/ipywidgets/widgets/tests/test_interaction.py b/ipywidgets/widgets/tests/test_interaction.py index 19a82bb236..7d44098694 100644 --- a/ipywidgets/widgets/tests/test_interaction.py +++ b/ipywidgets/widgets/tests/test_interaction.py @@ -254,7 +254,12 @@ def test_iterable_tuple(): check_widgets(c, lis=d) def test_mapping(): - from collections.abc import Mapping + try: + # Python 3 + from collections.abc import Mapping + except ImportError: + # Python 2 + from collections import Mapping class TestMapping(Mapping): def __init__(self, values): self.values = values