From 6cccc9cd81f04244fc3237e860d20d53cccdab3e Mon Sep 17 00:00:00 2001 From: Jason Grout Date: Thu, 3 Mar 2022 22:00:33 -0800 Subject: [PATCH 1/6] Add tests for setting JUPYTER_WIDGETS_ECHO to False This copies the test_set_state file from before #3195 to make sure that it still works unchanged when disabling echo_update messages. --- .../widgets/tests/test_set_state_noecho.py | 279 ++++++++++++++++++ 1 file changed, 279 insertions(+) create mode 100644 python/ipywidgets/ipywidgets/widgets/tests/test_set_state_noecho.py diff --git a/python/ipywidgets/ipywidgets/widgets/tests/test_set_state_noecho.py b/python/ipywidgets/ipywidgets/widgets/tests/test_set_state_noecho.py new file mode 100644 index 0000000000..bcbe64e97c --- /dev/null +++ b/python/ipywidgets/ipywidgets/widgets/tests/test_set_state_noecho.py @@ -0,0 +1,279 @@ +# Copyright (c) Jupyter Development Team. +# Distributed under the terms of the Modified BSD License. + +import pytest +from unittest 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 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): + 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: +# + +def test_set_state_simple(): + w = SimpleWidget() + w.set_state(dict( + a=True, + b=[True, False, True], + c=[False, True, False], + )) + + assert w.comm.messages == [] + + +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='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 w.comm.messages == [] + + +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) == 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={}) + ))) + + # 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 no update message gets produced + assert len(w.comm.messages) == 0 + + +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 no update message gets produced + assert len(w.comm.messages) == 0 + + +def test_set_state_float_to_float(): + 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 + + +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) == 1 + msg = w.comm.messages[0] + 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 + + # 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 + + 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 + + # we expect only single state to be sent, i.e. the {'value': 42.0} state + msg = {'method': 'update', 'state': {'value': 2.0, 'other': 11.0}, 'buffer_paths': []} + call42 = mock.call(msg, buffers=[]) + + calls = [call42] + widget._send.assert_has_calls(calls) From ca5e3e564fc566a088dd5f010a8cb74eb22f16f6 Mon Sep 17 00:00:00 2001 From: Jason Grout Date: Thu, 3 Mar 2022 22:23:37 -0800 Subject: [PATCH 2/6] Add doc message to send_sync_message --- packages/base/src/widget.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/base/src/widget.ts b/packages/base/src/widget.ts index 54ff15e17a..ded3dd14fe 100644 --- a/packages/base/src/widget.ts +++ b/packages/base/src/widget.ts @@ -538,6 +538,9 @@ export class WidgetModel extends Backbone.Model { /** * Send a sync message to the kernel. + * + * If a message is sent successfully, this returns the message ID of that + * message. Otherwise it returns an empty string */ send_sync_message(state: JSONObject, callbacks: any = {}): string { if (!this.comm) { From 53c98a32ae0776e30674f9ca77c389673fdbf947 Mon Sep 17 00:00:00 2001 From: Jason Grout Date: Fri, 4 Mar 2022 00:25:47 -0800 Subject: [PATCH 3/6] Add a simple test for the echo_update messages --- packages/base/test/src/dummy-manager.ts | 4 +- packages/base/test/src/widget_test.ts | 144 ++++++++++++------------ 2 files changed, 75 insertions(+), 73 deletions(-) diff --git a/packages/base/test/src/dummy-manager.ts b/packages/base/test/src/dummy-manager.ts index aafbc2624e..66b24701e2 100644 --- a/packages/base/test/src/dummy-manager.ts +++ b/packages/base/test/src/dummy-manager.ts @@ -45,13 +45,15 @@ export class MockComm implements widgets.IClassicComm { return ''; } send(): string { - return ''; + this._msgid += 1; + return this._msgid.toString(); } comm_id: string; target_name: string; _on_msg: Function | null = null; _on_open: Function | null = null; _on_close: Function | null = null; + _msgid: number = 0; } const typesToArray: { [key: string]: any } = { diff --git a/packages/base/test/src/widget_test.ts b/packages/base/test/src/widget_test.ts index b124458a19..e6f36856c2 100644 --- a/packages/base/test/src/widget_test.ts +++ b/packages/base/test/src/widget_test.ts @@ -353,6 +353,78 @@ describe('WidgetModel', function () { }); expect(customEventCallback).to.be.calledOnce; }); + + it('ignores echo_update messages when there is an expected echo_update', async function() { + const send = sinon.spy(this.widget, 'send_sync_message'); + // Set a value, generating an update message, get the message id from the comm? + this.widget.set('a', 'original value'); + this.widget.save_changes(); + + // Get the msg id + let msgId = send.returnValues[0]; + + // Inject a echo_update message from another client + await this.widget._handle_comm_msg({ + parent_header: { + msg_id: 'other-client' + }, + content: { + data: { + method: 'echo_update', + state: { a: 'other client update 1' }, + }, + }, + }); + + expect(this.widget.get('a')).to.equal('original value'); + + // Process a kernel update message, which should set the value + await this.widget._handle_comm_msg({ + parent_header: { + msg_id: 'from-kernel' + }, + content: { + data: { + method: 'update', + state: { a: 'kernel update' }, + }, + }, + }); + + expect(this.widget.get('a')).to.equal('kernel update'); + + + // Inject an echo_update message from us, resetting our value + await this.widget._handle_comm_msg({ + parent_header: { + msg_id: msgId + }, + content: { + data: { + method: 'echo_update', + state: { a: 'original value' }, + }, + }, + }); + + expect(this.widget.get('a')).to.equal('original value'); + + + // Inject another echo_update message from another client, which also updates us + await this.widget._handle_comm_msg({ + parent_header: { + msg_id: 'other-client-2' + }, + content: { + data: { + method: 'echo_update', + state: { a: 'other client update 2' }, + }, + }, + }); + + expect(this.widget.get('a')).to.equal('other client update 2'); + }) }); describe('_deserialize_state', function () { @@ -430,78 +502,6 @@ describe('WidgetModel', function () { }); }); - describe('_handle_comm_msg', function () { - beforeEach(async function () { - await this.setup(); - }); - - it('handles update messages', async function () { - const deserialize = this.widget.constructor._deserialize_state; - const setState = sinon.spy(this.widget, 'set_state'); - const state_change = this.widget._handle_comm_msg({ - content: { - data: { - method: 'update', - state: { a: 5 }, - }, - }, - }); - expect(this.widget.state_change).to.equal(state_change); - await state_change; - expect(deserialize).to.be.calledOnce; - expect(setState).to.be.calledOnce; - expect(deserialize).to.be.calledBefore(setState); - expect(this.widget.get('a')).to.equal(5); - }); - - it('updates handle various types of binary buffers', async function () { - const buffer1 = new Uint8Array([1, 2, 3]); - const buffer2 = new Float64Array([2.3, 6.4]); - const buffer3 = new Int16Array([10, 20, 30]); - await this.widget._handle_comm_msg({ - content: { - data: { - method: 'update', - state: { a: 5, c: ['start', null, {}] }, - buffer_paths: [['b'], ['c', 1], ['c', 2, 'd']], - }, - }, - buffers: [buffer1, buffer2.buffer, new DataView(buffer3.buffer)], - }); - expect(this.widget.get('a')).to.equal(5); - expect(this.widget.get('b')).to.deep.equal(new DataView(buffer1.buffer)); - expect(this.widget.get('c')).to.deep.equal([ - 'start', - new DataView(buffer2.buffer), - { d: new DataView(buffer3.buffer) }, - ]); - }); - - it('handles custom deserialization', async function () { - await this.widget._handle_comm_msg({ - content: { - data: { - method: 'update', - state: { halve: 10, times3: 4 }, - }, - }, - }); - expect(this.widget.get('halve')).to.equal(5); - expect(this.widget.get('times3')).to.equal(12); - }); - - it('handles custom messages', function () { - const customEventCallback = sinon.spy(); - this.widget.on('msg:custom', customEventCallback); - this.widget._handle_comm_msg({ - content: { - data: { method: 'custom' }, - }, - }); - expect(customEventCallback).to.be.calledOnce; - }); - }); - describe('set_state', function () { beforeEach(async function () { await this.setup(); From 686ec0437000289e32c8aafde7ea17422206fc02 Mon Sep 17 00:00:00 2001 From: Jason Grout Date: Fri, 4 Mar 2022 00:29:39 -0800 Subject: [PATCH 4/6] Rename echo message private attributes to follow the underscore convention --- packages/base/src/widget.ts | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/base/src/widget.ts b/packages/base/src/widget.ts index ded3dd14fe..b5d94d65cd 100644 --- a/packages/base/src/widget.ts +++ b/packages/base/src/widget.ts @@ -115,8 +115,8 @@ export class WidgetModel extends Backbone.Model { attributes: Backbone.ObjectHash, options: IBackboneModelOptions ): void { - this.expectedEchoMsgIds = new Map(); - this.attrsToUpdate = new Set(); + this._expectedEchoMsgIds = new Map(); + this._attrsToUpdate = new Set(); super.initialize(attributes, options); @@ -237,18 +237,18 @@ export class WidgetModel extends Backbone.Model { // 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.has(attrName) + this._expectedEchoMsgIds.has(attrName) ); expectedEcho.forEach((attrName: string) => { // Skip echo messages until we get the reply we are expecting. const isOldMessage = - this.expectedEchoMsgIds.get(attrName) !== msgId; + this._expectedEchoMsgIds.get(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 - this.expectedEchoMsgIds.delete(attrName); + this._expectedEchoMsgIds.delete(attrName); // Start accepting echo updates unless we plan to send out a new state soon if ( this._msg_buffer !== null && @@ -456,7 +456,7 @@ export class WidgetModel extends Backbone.Model { } Object.keys(attrs).forEach((attrName: string) => { - this.attrsToUpdate.add(attrName); + this._attrsToUpdate.add(attrName); }); const msgState = this.serialize(attrs); @@ -499,10 +499,10 @@ export class WidgetModel extends Backbone.Model { } } rememberLastUpdateFor(msgId: string) { - this.attrsToUpdate.forEach((attrName) => { - this.expectedEchoMsgIds.set(attrName, msgId); + this._attrsToUpdate.forEach((attrName) => { + this._expectedEchoMsgIds.set(attrName, msgId); }); - this.attrsToUpdate = new Set(); + this._attrsToUpdate = new Set(); } /** @@ -683,9 +683,9 @@ export class WidgetModel extends Backbone.Model { // 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: Map; + private _expectedEchoMsgIds: Map; // because we don't know the attrs in _handle_status, we keep track of what we will send - private attrsToUpdate: Set; + private _attrsToUpdate: Set; } export class DOMWidgetModel extends WidgetModel { From 133b78c198852c023ac30726738b317afe11df18 Mon Sep 17 00:00:00 2001 From: Jason Grout Date: Fri, 4 Mar 2022 00:33:14 -0800 Subject: [PATCH 5/6] Lint --- packages/base/test/src/dummy-manager.ts | 2 +- packages/base/test/src/widget_test.ts | 16 +++++++--------- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/packages/base/test/src/dummy-manager.ts b/packages/base/test/src/dummy-manager.ts index 66b24701e2..db429a64d6 100644 --- a/packages/base/test/src/dummy-manager.ts +++ b/packages/base/test/src/dummy-manager.ts @@ -53,7 +53,7 @@ export class MockComm implements widgets.IClassicComm { _on_msg: Function | null = null; _on_open: Function | null = null; _on_close: Function | null = null; - _msgid: number = 0; + _msgid = 0; } const typesToArray: { [key: string]: any } = { diff --git a/packages/base/test/src/widget_test.ts b/packages/base/test/src/widget_test.ts index e6f36856c2..9571666330 100644 --- a/packages/base/test/src/widget_test.ts +++ b/packages/base/test/src/widget_test.ts @@ -354,19 +354,19 @@ describe('WidgetModel', function () { expect(customEventCallback).to.be.calledOnce; }); - it('ignores echo_update messages when there is an expected echo_update', async function() { + it('ignores echo_update messages when there is an expected echo_update', async function () { const send = sinon.spy(this.widget, 'send_sync_message'); // Set a value, generating an update message, get the message id from the comm? this.widget.set('a', 'original value'); this.widget.save_changes(); // Get the msg id - let msgId = send.returnValues[0]; + const msgId = send.returnValues[0]; // Inject a echo_update message from another client await this.widget._handle_comm_msg({ parent_header: { - msg_id: 'other-client' + msg_id: 'other-client', }, content: { data: { @@ -381,7 +381,7 @@ describe('WidgetModel', function () { // Process a kernel update message, which should set the value await this.widget._handle_comm_msg({ parent_header: { - msg_id: 'from-kernel' + msg_id: 'from-kernel', }, content: { data: { @@ -393,11 +393,10 @@ describe('WidgetModel', function () { expect(this.widget.get('a')).to.equal('kernel update'); - // Inject an echo_update message from us, resetting our value await this.widget._handle_comm_msg({ parent_header: { - msg_id: msgId + msg_id: msgId, }, content: { data: { @@ -409,11 +408,10 @@ describe('WidgetModel', function () { expect(this.widget.get('a')).to.equal('original value'); - // Inject another echo_update message from another client, which also updates us await this.widget._handle_comm_msg({ parent_header: { - msg_id: 'other-client-2' + msg_id: 'other-client-2', }, content: { data: { @@ -424,7 +422,7 @@ describe('WidgetModel', function () { }); expect(this.widget.get('a')).to.equal('other client update 2'); - }) + }); }); describe('_deserialize_state', function () { From 94ffe26fa2d26e98f320578bc734a574b8e53392 Mon Sep 17 00:00:00 2001 From: Vidar Tonaas Fauske Date: Mon, 7 Mar 2022 17:27:12 +0000 Subject: [PATCH 6/6] 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. --- .../widgets/tests/test_set_state.py | 78 +++-- .../widgets/tests/test_set_state_noecho.py | 279 ------------------ 2 files changed, 46 insertions(+), 311 deletions(-) delete mode 100644 python/ipywidgets/ipywidgets/widgets/tests/test_set_state_noecho.py diff --git a/python/ipywidgets/ipywidgets/widgets/tests/test_set_state.py b/python/ipywidgets/ipywidgets/widgets/tests/test_set_state.py index 0564dc5b94..6d8a8abebb 100644 --- a/python/ipywidgets/ipywidgets/widgets/tests/test_set_state.py +++ b/python/ipywidgets/ipywidgets/widgets/tests/test_set_state.py @@ -8,7 +8,16 @@ from .utils import setup, teardown -from ..widget import Widget +import ipywidgets +from ipywidgets import Widget + + +@pytest.fixture(params=[True, False]) +def echo(request): + oldvalue = ipywidgets.widgets.widget.JUPYTER_WIDGETS_ECHO + ipywidgets.widgets.widget.JUPYTER_WIDGETS_ECHO = request.param + yield request.param + ipywidgets.widgets.widget.JUPYTER_WIDGETS_ECHO = oldvalue # # First some widgets to test on: @@ -72,7 +81,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, @@ -80,42 +89,47 @@ def test_set_state_simple(): c=[False, True, False], )) - assert len(w.comm.messages) == 1 + 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( - buffers=[], - data=dict( - buffer_paths=[], - method='echo_update', - state=dict(d=[True, False, True]), - ))), + 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]), - )))] + )))) + 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 len(w.comm.messages) == 1 + 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( @@ -123,8 +137,8 @@ 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) == 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( @@ -139,7 +153,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'). @@ -152,10 +166,10 @@ def test_set_state_numbers_int(): ci = 4, )) # Ensure one update message gets produced - assert len(w.comm.messages) == 1 + 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( @@ -164,10 +178,10 @@ def test_set_state_numbers_float(): ci = 4.0 )) # Ensure one update message gets produced - assert len(w.comm.messages) == 1 + 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( @@ -175,10 +189,10 @@ def test_set_state_float_to_float(): cf = 2.6, )) # Ensure one message gets produced - assert len(w.comm.messages) == 1 + 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 @@ -186,8 +200,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) == (2 if echo else 1) + msg = w.comm.messages[-1] data = msg[1]['data'] assert data['method'] == 'update' assert data['state'] == {'ci': 5} @@ -209,7 +223,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 @@ -218,7 +232,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) @@ -248,7 +262,7 @@ def _propagate_value(self, change): calls = [] widget._send.assert_has_calls(calls) -def test_hold_sync(): +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) @@ -276,7 +290,7 @@ def _propagate_value(self, change): msg = {'method': 'update', 'state': {'value': 2.0, 'other': 11.0}, 'buffer_paths': []} call2 = mock.call(msg, buffers=[]) - calls = [call42, call2] + calls = [call42, call2] if echo else [call2] widget._send.assert_has_calls(calls) @@ -333,7 +347,7 @@ def _square(self, change): # 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=[]) @@ -342,7 +356,7 @@ def _square(self, change): widget._send.assert_has_calls(calls) -def test_no_echo(): +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) diff --git a/python/ipywidgets/ipywidgets/widgets/tests/test_set_state_noecho.py b/python/ipywidgets/ipywidgets/widgets/tests/test_set_state_noecho.py deleted file mode 100644 index bcbe64e97c..0000000000 --- a/python/ipywidgets/ipywidgets/widgets/tests/test_set_state_noecho.py +++ /dev/null @@ -1,279 +0,0 @@ -# Copyright (c) Jupyter Development Team. -# Distributed under the terms of the Modified BSD License. - -import pytest -from unittest 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 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): - 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: -# - -def test_set_state_simple(): - w = SimpleWidget() - w.set_state(dict( - a=True, - b=[True, False, True], - c=[False, True, False], - )) - - assert w.comm.messages == [] - - -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='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 w.comm.messages == [] - - -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) == 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={}) - ))) - - # 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 no update message gets produced - assert len(w.comm.messages) == 0 - - -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 no update message gets produced - assert len(w.comm.messages) == 0 - - -def test_set_state_float_to_float(): - 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 - - -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) == 1 - msg = w.comm.messages[0] - 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 - - # 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 - - 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 - - # we expect only single state to be sent, i.e. the {'value': 42.0} state - msg = {'method': 'update', 'state': {'value': 2.0, 'other': 11.0}, 'buffer_paths': []} - call42 = mock.call(msg, buffers=[]) - - calls = [call42] - widget._send.assert_has_calls(calls)