-
-
Notifications
You must be signed in to change notification settings - Fork 402
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Ensure multiple callbacks do not bleed wrong plot state #1034
Changes from 13 commits
16e9f92
9aa0a0f
ad6df39
4ee3623
cccacfc
7c602cb
6556637
0665086
80afcc8
0ce5e35
eab77cb
3348b11
9b01ca1
2c86401
ce4ad9c
6a3124d
4234142
89921f1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,4 +1,3 @@ | ||
import json | ||
from collections import defaultdict | ||
|
||
import param | ||
|
@@ -10,7 +9,7 @@ | |
from ..comms import JupyterCommJS | ||
|
||
|
||
def attributes_js(attributes): | ||
def attributes_js(attributes, handles): | ||
""" | ||
Generates JS code to look up attributes on JS objects from | ||
an attributes specification dictionary. | ||
|
@@ -28,7 +27,16 @@ def attributes_js(attributes): | |
obj_name = attrs[0] | ||
attr_getters = ''.join(["['{attr}']".format(attr=attr) | ||
for attr in attrs[1:]]) | ||
code += ''.join([data_assign, obj_name, attr_getters, ';\n']) | ||
if obj_name not in ['cb_obj', 'cb_data']: | ||
assign_str = '{assign}{{id: {obj_name}["id"], value: {obj_name}{attr_getters}}};\n'.format( | ||
assign=data_assign, obj_name=obj_name, attr_getters=attr_getters | ||
) | ||
code += 'if (({obj_name} != undefined) && ({obj_name}["id"] == "{id}")) {{ {assign} }}'.format( | ||
obj_name=obj_name, id=handles[obj_name].ref['id'], assign=assign_str | ||
) | ||
else: | ||
assign_str = ''.join([data_assign, obj_name, attr_getters, ';\n']) | ||
code += assign_str | ||
return code | ||
|
||
|
||
|
@@ -76,14 +84,16 @@ class Callback(object): | |
js_callback = """ | ||
function on_msg(msg){{ | ||
msg = JSON.parse(msg.content.data); | ||
var comm = HoloViewsWidget.comms["{comms_target}"]; | ||
var comm_state = HoloViewsWidget.comm_state["{comms_target}"]; | ||
var comms_target = msg["comms_target"] | ||
var comm = HoloViewsWidget.comms[comms_target]; | ||
var comm_state = HoloViewsWidget.comm_state[comms_target]; | ||
if (comm_state.event) {{ | ||
comm.send(comm_state.event); | ||
comm_state.blocked = true; | ||
comm_state.timeout = Date.now()+{debounce}; | ||
}} else {{ | ||
comm_state.blocked = false; | ||
}} | ||
comm_state.timeout = Date.now(); | ||
comm_state.event = undefined; | ||
if ((msg.msg_type == "Ready") && msg.content) {{ | ||
console.log("Python callback returned following output:", msg.content); | ||
|
@@ -92,7 +102,7 @@ class Callback(object): | |
}} | ||
}} | ||
|
||
var argstring = JSON.stringify(data); | ||
data['comms_target'] = "{comms_target}"; | ||
if ((window.Jupyter !== undefined) && (Jupyter.notebook.kernel !== undefined)) {{ | ||
var comm_manager = Jupyter.notebook.kernel.comm_manager; | ||
var comms = HoloViewsWidget.comms["{comms_target}"]; | ||
|
@@ -117,6 +127,8 @@ class Callback(object): | |
|
||
function trigger() {{ | ||
if (comm_state.event != undefined) {{ | ||
var comms_target = comm_state.event["comms_target"] | ||
var comm = HoloViewsWidget.comms[comms_target]; | ||
comm.send(comm_state.event); | ||
}} | ||
comm_state.event = undefined; | ||
|
@@ -125,9 +137,9 @@ class Callback(object): | |
timeout = comm_state.timeout + {timeout}; | ||
if ((window.Jupyter == undefined) | (Jupyter.notebook.kernel == undefined)) {{ | ||
}} else if ((comm_state.blocked && (Date.now() < timeout))) {{ | ||
comm_state.event = argstring; | ||
comm_state.event = data; | ||
}} else {{ | ||
comm_state.event = argstring; | ||
comm_state.event = data; | ||
setTimeout(trigger, {debounce}); | ||
comm_state.blocked = true; | ||
comm_state.timeout = Date.now()+{debounce}; | ||
|
@@ -152,39 +164,84 @@ def __init__(self, plot, streams, source, **params): | |
self.streams = streams | ||
self.comm = self._comm_type(plot, on_msg=self.on_msg) | ||
self.source = source | ||
self.handle_ids = defaultdict(list) | ||
|
||
|
||
def initialize(self): | ||
plots = [self.plot] | ||
if self.plot.subplots: | ||
plots += list(self.plot.subplots.values()) | ||
|
||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Looks like the code below could be made into a small
Which I think is clearer. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I am also assuming there is a good reason to use a dictionary update... i.e there may be other There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Originally had one, didn't seem worth it though.
That's true, the handles get merged if multiple streams end up being attached to the same callback, in that case the
I could just assign in There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
I agree it is a small bit of code and I was also wondering if it was worth it. On balance, I felt making the intent clearer for this block of code was more valuable. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm still a bit confused about
In none of these places do I see In that case, I would also want to look at where the merging of There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I posted where it's being changed above, in
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I see, I was looking for There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No point keeping both around imo. |
||
handles = {} | ||
for plot in plots: | ||
for k, v in plot.handles.items(): | ||
if k not in handles: | ||
handles[k] = v | ||
self.handle_ids.update(self._get_handle_ids(handles)) | ||
|
||
found = [] | ||
for plot in plots: | ||
for handle in self.handles: | ||
if handle not in plot.handles or handle in found: | ||
continue | ||
self.set_customjs(plot.handles[handle]) | ||
self.set_customjs(plot.handles[handle], handles) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If I understand right,
Now you might want to call |
||
found.append(handle) | ||
|
||
if len(found) != len(self.handles): | ||
self.warning('Plotting handle for JS callback not found') | ||
|
||
|
||
def _filter_msg(self, msg, ids): | ||
""" | ||
Filter event values that do not originate from the plotting | ||
handles associated with a particular stream using their | ||
ids to match them. | ||
""" | ||
filtered_msg = {} | ||
for k, v in msg.items(): | ||
if isinstance(v, dict) and 'id' in v: | ||
if v['id'] in ids: | ||
filtered_msg[k] = v['value'] | ||
else: | ||
filtered_msg[k] = v | ||
return filtered_msg | ||
|
||
|
||
def on_msg(self, msg): | ||
msg = json.loads(msg) | ||
msg = self._process_msg(msg) | ||
if any(v is None for v in msg.values()): | ||
return | ||
for stream in self.streams: | ||
stream.update(trigger=False, **msg) | ||
ids = self.handle_ids[stream] | ||
filtered_msg = self._filter_msg(msg, ids) | ||
processed_msg = self._process_msg(filtered_msg) | ||
if not processed_msg: | ||
continue | ||
stream.update(trigger=False, **processed_msg) | ||
Stream.trigger(self.streams) | ||
|
||
|
||
def _process_msg(self, msg): | ||
""" | ||
Subclassable method to preprocess JSON message in callback | ||
before passing to stream. | ||
""" | ||
return msg | ||
|
||
|
||
def set_customjs(self, handle): | ||
def _get_handle_ids(self, handles): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I would like a name that conveys the filtering operation that is also happening here. Maybe |
||
""" | ||
Gather the ids of the plotting handles attached to this callback | ||
This allows checking that a stream is not given the state | ||
of a plotting handle it wasn't attached to | ||
""" | ||
stream_handle_ids = defaultdict(list) | ||
for stream in self.streams: | ||
for h in self.handles: | ||
if h in handles: | ||
handle_id = handles[h].ref['id'] | ||
stream_handle_ids[stream].append(handle_id) | ||
return stream_handle_ids | ||
|
||
|
||
def set_customjs(self, handle, references): | ||
""" | ||
Generates a CustomJS callback by generating the required JS | ||
code and gathering all plotting handles and installs it on | ||
|
@@ -195,24 +252,21 @@ def set_customjs(self, handle): | |
self_callback = self.js_callback.format(comms_target=self.comm.target, | ||
timeout=self.timeout, | ||
debounce=self.debounce) | ||
attributes = attributes_js(self.attributes) | ||
|
||
attributes = attributes_js(self.attributes, references) | ||
code = 'var data = {};\n' + attributes + self.code + self_callback | ||
|
||
handles = {} | ||
subplots = list(self.plot.subplots.values())[::-1] if self.plot.subplots else [] | ||
plots = [self.plot] + subplots | ||
for plot in plots: | ||
handles.update({k: v for k, v in plot.handles.items() | ||
if k in self.handles}) | ||
# Set callback | ||
if id(handle.callback) in self._callbacks: | ||
cb = self._callbacks[id(handle.callback)] | ||
if isinstance(cb, type(self)): | ||
cb.streams += self.streams | ||
for k, v in self.handle_ids.items(): | ||
cb.handle_ids[k] += v | ||
else: | ||
handle.callback.code += code | ||
else: | ||
js_callback = CustomJS(args=handles, code=code) | ||
js_callback = CustomJS(args=references, code=code) | ||
self._callbacks[id(js_callback)] = self | ||
handle.callback = js_callback | ||
|
||
|
@@ -249,8 +303,12 @@ class RangeXYCallback(Callback): | |
handles = ['x_range', 'y_range'] | ||
|
||
def _process_msg(self, msg): | ||
return {'x_range': (msg['x0'], msg['x1']), | ||
'y_range': (msg['y0'], msg['y1'])} | ||
data = {} | ||
if 'x0' in msg and 'x1' in msg: | ||
data['x_range'] = (msg['x0'], msg['x1']) | ||
if 'y0' in msg and 'y1' in msg: | ||
data['y_range'] = (msg['y0'], msg['y1']) | ||
return data | ||
|
||
|
||
class RangeXCallback(Callback): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. All of the new bits of code below seems to have the form:
If you agree this is a general pattern, maybe we can just have an class RangeXCallback(Callback):
handles = ['x_range']
def applicable(msg):
return 'x0' in msg and 'x1' in msg
def _process_msg(self, msg):
return {'x_range': (msg['x0'], msg['x1'])} And of course There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What if there are multiple predicates, such as in There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If you agree with this suggestion, maybe it should be made into a new issue (feature request)? I don't think it would be hard to implement later. |
||
|
@@ -261,7 +319,10 @@ class RangeXCallback(Callback): | |
handles = ['x_range'] | ||
|
||
def _process_msg(self, msg): | ||
return {'x_range': (msg['x0'], msg['x1'])} | ||
if 'x0' in msg and 'x1' in msg: | ||
return {'x_range': (msg['x0'], msg['x1'])} | ||
else: | ||
return {} | ||
|
||
|
||
class RangeYCallback(Callback): | ||
|
@@ -272,7 +333,10 @@ class RangeYCallback(Callback): | |
handles = ['y_range'] | ||
|
||
def _process_msg(self, msg): | ||
return {'y_range': (msg['y0'], msg['y1'])} | ||
if 'y0' in msg and 'y1' in msg: | ||
return {'y_range': (msg['y0'], msg['y1'])} | ||
else: | ||
return {} | ||
|
||
|
||
class BoundsCallback(Callback): | ||
|
@@ -285,7 +349,10 @@ class BoundsCallback(Callback): | |
handles = ['box_select'] | ||
|
||
def _process_msg(self, msg): | ||
return {'bounds': (msg['x0'], msg['y0'], msg['x1'], msg['y1'])} | ||
if all(c in msg for c in ['x0', 'y0', 'x1', 'y1']): | ||
return {'bounds': (msg['x0'], msg['y0'], msg['x1'], msg['y1'])} | ||
else: | ||
return {} | ||
|
||
|
||
class Selection1DCallback(Callback): | ||
|
@@ -295,7 +362,10 @@ class Selection1DCallback(Callback): | |
handles = ['source'] | ||
|
||
def _process_msg(self, msg): | ||
return {'index': [int(v) for v in msg['index']]} | ||
if 'index' in msg: | ||
return {'index': [int(v) for v in msg['index']]} | ||
else: | ||
return {} | ||
|
||
|
||
callbacks = Stream._callbacks['bokeh'] | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Would be good to see the docstring updated with an example showing how handles is involved...
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Agreed.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This docstring still needs to be updated I think.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, still need to do that.