Skip to content

Commit

Permalink
Backport echo widget state to 7.x
Browse files Browse the repository at this point in the history
This backports the combination of jupyter-widgets#3195 and jupyter-widgets#3394.
  • Loading branch information
jasongrout committed Mar 2, 2022
1 parent 6694e22 commit 0df64d2
Show file tree
Hide file tree
Showing 6 changed files with 277 additions and 26 deletions.
2 changes: 1 addition & 1 deletion ipywidgets/_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
167 changes: 148 additions & 19 deletions ipywidgets/widgets/tests/test_set_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand All @@ -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]),
)))]


Expand All @@ -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():
Expand All @@ -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:
Expand All @@ -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():
Expand All @@ -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():
Expand All @@ -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():
Expand All @@ -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}
Expand Down Expand Up @@ -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=[])])



34 changes: 34 additions & 0 deletions ipywidgets/widgets/widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
in the IPython notebook front-end.
"""

import os
from contextlib import contextmanager
try:
from collections.abc import Iterable
Expand All @@ -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):
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion ipywidgets/widgets/widget_upload.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading

0 comments on commit 0df64d2

Please sign in to comment.