# 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 @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: # # 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(echo): w = SimpleWidget() w.set_state(dict( a=True, b=[True, False, True], c=[False, True, False], )) assert len(w.comm.messages) == (1 if echo else 0) 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 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(echo): w = DataWidget() data = memoryview(b'x'*30) w.set_state(dict( a=True, d={'data': data}, )) assert len(w.comm.messages) == (1 if echo else 0) def test_set_state_data_truncate(echo): 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 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( 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(echo): # 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 if echo else 0) def test_set_state_numbers_float(echo): 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 if echo else 0) 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 one message gets produced assert len(w.comm.messages) == (1 if echo else 0) def test_set_state_cint_to_float(echo): 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 if echo else 1) 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(echo): w = NumberWidget() # Set Int to float with pytest.raises(TraitError): w.set_state(dict( i = 3.5 )) 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) 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(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=[]) calls = [call42, call2] if echo else [call2] widget._send.assert_has_calls(calls) def test_echo(echo): # we always echo values back to the frontend if configured 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] if echo else [] widget._send.assert_has_calls(calls) def test_echo_single(echo): # 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] if echo else [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=[])])