From d1d1a478f8ad878e654bf6e76dc7a7c0be3901c0 Mon Sep 17 00:00:00 2001 From: Jean-Luc Stevens Date: Mon, 21 May 2018 23:22:20 +0100 Subject: [PATCH] Refactored comms to add JupyterLab support (#51) --- dodo.py | 2 +- examples/user_guide/View_Parameters.ipynb | 5 +- parambokeh/__init__.py | 96 +++++++-- parambokeh/comms.py | 249 ---------------------- parambokeh/view.py | 8 +- 5 files changed, 79 insertions(+), 281 deletions(-) delete mode 100644 parambokeh/comms.py diff --git a/dodo.py b/dodo.py index 128b961..385a1b9 100644 --- a/dodo.py +++ b/dodo.py @@ -7,7 +7,7 @@ # dependencies across projects. def task_install_required_dependencies(): - return {'actions': ['conda install -y -q -c conda-forge param "bokeh>=0.12.10"']} + return {'actions': ['conda install -y -q -c conda-forge -c pyviz param "bokeh>=0.12.10" pyviz_comms']} def task_install_test_dependencies(): return { diff --git a/examples/user_guide/View_Parameters.ipynb b/examples/user_guide/View_Parameters.ipynb index 8c2463a..05e21f0 100644 --- a/examples/user_guide/View_Parameters.ipynb +++ b/examples/user_guide/View_Parameters.ipynb @@ -80,13 +80,12 @@ " \n", " def event(self, **kwargs):\n", " if not self.output or any(k in kwargs for k in ['color', 'element']):\n", - " self.output = hv.DynamicMap(self.view, streams=[self])\n", + " self.output = hv.DynamicMap(self.view, streams=[self], cache_size=0)\n", " else:\n", " super(CurveExample, self).event(**kwargs)\n", "\n", "example = CurveExample(name='HoloViews Example')\n", - "parambokeh.Widgets(example, callback=example.event, push=False,\n", - " on_init=True, view_position='right')" + "parambokeh.Widgets(example, callback=example.event, on_init=True, view_position='right')" ] } ], diff --git a/parambokeh/__init__.py b/parambokeh/__init__.py index 73a9296..fc4905c 100644 --- a/parambokeh/__init__.py +++ b/parambokeh/__init__.py @@ -1,23 +1,29 @@ from __future__ import absolute_import import ast -import uuid import itertools import functools +import json import param from bokeh.document import Document -from bokeh.io import push_notebook, curdoc +from bokeh.io import curdoc from bokeh.layouts import row, column, widgetbox from bokeh.models.widgets import Div, Button, CheckboxGroup, TextInput from bokeh.models import CustomJS +from bokeh.protocol import Protocol try: - from .comms import JupyterCommJS, JS_CALLBACK, notebook_show + from IPython.display import publish_display_data + + import bokeh.embed.notebook + from bokeh.util.string import encode_utf8 + from pyviz_comms import JupyterCommManager, JS_CALLBACK, bokeh_msg_handler, PYVIZ_PROXY IPYTHON_AVAILABLE = True except: IPYTHON_AVAILABLE = False + from .widgets import wtype, literal_params from .util import named_objs, get_method_owner from .view import _View @@ -29,6 +35,33 @@ __version__ = '0.2.1-unknown' +def notebook_show(obj, doc, comm): + """ + Displays bokeh output inside a notebook. + """ + target = obj.ref['id'] + load_mime = 'application/vnd.holoviews_load.v0+json' + exec_mime = 'application/vnd.holoviews_exec.v0+json' + + # Publish plot HTML + bokeh_script, bokeh_div, _ = bokeh.embed.notebook.notebook_content(obj, comm.id) + publish_display_data(data={'text/html': encode_utf8(bokeh_div)}) + + # Publish comm manager + JS = '\n'.join([PYVIZ_PROXY, JupyterCommManager.js_manager]) + publish_display_data(data={load_mime: JS, 'application/javascript': JS}) + + # Publish bokeh plot JS + msg_handler = bokeh_msg_handler.format(plot_id=target) + comm_js = comm.js_template.format(plot_id=target, comm_id=comm.id, msg_handler=msg_handler) + bokeh_js = '\n'.join([comm_js, bokeh_script]) + + # Note: extension should be altered so text/html is not required + publish_display_data(data={exec_mime: '', 'text/html': '', + 'application/javascript': bokeh_js}, + metadata={exec_mime: {'id': target}}) + + class default_label_formatter(param.ParameterizedFunction): "Default formatter to turn parameter names into appropriate widget labels." @@ -44,7 +77,6 @@ class default_label_formatter(param.ParameterizedFunction): value is the desired label.""") def __call__(self, pname): - if pname in self.overrides: return self.overrides[pname] if self.replace_underscores: @@ -135,43 +167,49 @@ def __call__(self, parameterized, doc=None, plots=[], **params): self._widgets = {} self.parameterized = parameterized self.document = None - self.comm_target = None if self.p.mode == 'notebook': if not IPYTHON_AVAILABLE: raise ImportError('IPython is not available, cannot use ' 'Widgets in notebook mode.') - self.comm = JupyterCommJS(on_msg=self.on_msg) + self.comm = JupyterCommManager.get_client_comm(on_msg=self.on_msg) # HACK: Detects HoloViews plots and lets them handle the comms hv_plots = [plot for plot in plots if hasattr(plot, 'comm')] + self.server_comm = JupyterCommManager.get_server_comm() if hv_plots: - self.comm_target = [p.comm.id for p in hv_plots][0] self.document = [p.document for p in hv_plots][0] plots = [p.state for p in plots] self.p.push = False else: - self.comm_target = uuid.uuid4().hex self.document = doc or Document() else: self.document = doc or curdoc() self._queue = [] self._active = False - self._widget_options = {} self.shown = False + # Initialize root container + widget_box = widgetbox(width=self.p.width) + view_params = any(isinstance(p, _View) for p in parameterized.params().values()) + layout = self.p.view_position + container_type = column if layout in ['below', 'above'] else row + container = container_type() if plots or view_params else widget_box + self.plot_id = container.ref['id'] + + # Initialize widgets and populate container widgets, views = self.widgets() plots = views + plots - container = widgetbox(widgets, width=self.p.width) + widget_box.children = widgets if plots: view_box = column(plots) - layout = self.p.view_position if layout in ['below', 'right']: - children = [container, view_box] + children = [widget_box, view_box] else: - children = [view_box, container] - container_type = column if layout in ['below', 'above'] else row - container = container_type(children=children) + children = [view_box, widget_box] + container.children = children + + # Initialize view parameters for view in views: p_obj = self.parameterized.params(view.name) value = getattr(self.parameterized, view.name) @@ -190,8 +228,7 @@ def __call__(self, parameterized, doc=None, plots=[], **params): self.document.add_root(container) if self.p.mode == 'notebook': - self.notebook_handle = notebook_show(container, self.document, - self.comm_target) + notebook_show(container, self.document, self.server_comm) if self.document._hold is None: self.document.hold() self.shown = True @@ -262,18 +299,29 @@ def change_event(self): # document.hold() must have been done already? because this seems to work if self.p.mode == 'notebook' and self.p.push and self.document._held_events: - push_notebook(handle=self.notebook_handle, document=self.document) + self._send_notebook_diff() self._active = False + def _send_notebook_diff(self): + events = list(self.document._held_events) + msg = Protocol("1.0").create("PATCH-DOC", events, use_buffers=True) + self.document._held_events = [] + if msg is None: + return + self.server_comm.send(msg.header_json) + self.server_comm.send(msg.metadata_json) + self.server_comm.send(msg.content_json) + for header, payload in msg.buffers: + self.server_comm.send(json.dumps(header)) + self.server_comm.send(buffers=[payload]) + def _update_trait(self, p_name, p_value, widget=None): widget = self._widgets[p_name] if widget is None else widget if isinstance(p_value, tuple): p_value, size = p_value if isinstance(widget, Div): widget.text = p_value - elif self.p.mode == 'notebook' and self.shown: - return else: if widget.children: widget.children.remove(widget.children[0]) @@ -284,7 +332,7 @@ def _make_widget(self, p_name): p_obj = self.parameterized.params(p_name) if isinstance(p_obj, _View): - p_obj._comm_target = self.comm_target + p_obj._comm = self.server_comm p_obj._document = self.document p_obj._notebook = self.p.mode == 'notebook' @@ -362,8 +410,10 @@ def _get_customjs(self, change, p_name): fetch_data = data_template.format(change=change, p_name=p_name) self_callback = JS_CALLBACK.format(comm_id=self.comm.id, timeout=self.timeout, - debounce=self.debounce) - js_callback = CustomJS(code=fetch_data+self_callback) + debounce=self.debounce, + plot_id=self.plot_id) + js_callback = CustomJS(code='\n'.join([fetch_data, + self_callback])) return js_callback diff --git a/parambokeh/comms.py b/parambokeh/comms.py deleted file mode 100644 index e1017b1..0000000 --- a/parambokeh/comms.py +++ /dev/null @@ -1,249 +0,0 @@ -import json -import uuid -import sys -import traceback -try: - from StringIO import StringIO -except: - from io import StringIO - -import bokeh.embed.notebook -import bokeh.io.notebook -from bokeh.util.string import encode_utf8 - -from IPython.display import publish_display_data - - - - -JS_CALLBACK = """ - function unique_events(events) {{ - // Processes the event queue ignoring duplicate events - // of the same type - var unique = []; - var unique_events = []; - for (var i=0; i{bokeh_script} - """.format(bokeh_div=bokeh_div, bokeh_script=bokeh_script) - - publish_display_data({'text/html': encode_utf8(bokeh_output)}) - return bokeh.io.notebook.CommsHandle(bokeh.io.notebook.get_comms(target), doc) - - -class StandardOutput(list): - """ - Context manager to capture standard output for any code it - is wrapping and make it available as a list, e.g.: - - >>> with StandardOutput() as stdout: - ... print('This gets captured') - >>> print(stdout[0]) - This gets captured - """ - - def __enter__(self): - self._stdout = sys.stdout - sys.stdout = self._stringio = StringIO() - return self - - def __exit__(self, *args): - self.extend(self._stringio.getvalue().splitlines()) - sys.stdout = self._stdout - - -class JupyterCommJS(object): - """ - JupyterCommJS provides a comms channel for the Jupyter notebook, - which is initialized on the frontend. This allows sending events - initiated on the frontend to python. - """ - - template = """ - - -
- {init_frame} -
- """ - - def __init__(self, id=None, on_msg=None): - """ - Initializes a Comms object - """ - self.id = id if id else uuid.uuid4().hex - self._on_msg = on_msg - self._comm = None - - from IPython import get_ipython - self.manager = get_ipython().kernel.comm_manager - self.manager.register_target(self.id, self._handle_open) - - - def _handle_open(self, comm, msg): - self._comm = comm - self._comm.on_msg(self._handle_msg) - - - def send(self, data): - """ - Pushes data across comm socket. - """ - self.comm.send(data) - - - @classmethod - def decode(cls, msg): - """ - Decodes messages following Jupyter messaging protocol. - If JSON decoding fails data is assumed to be a regular string. - """ - return msg['content']['data'] - - - @property - def comm(self): - if not self._comm: - raise ValueError('Comm has not been initialized') - return self._comm - - - def _handle_msg(self, msg): - """ - Decode received message before passing it to on_msg callback - if it has been defined. - """ - comm_id = None - try: - stdout = [] - msg = self.decode(msg) - comm_id = msg.pop('comm_id', None) - if self._on_msg: - # Comm swallows standard output so we need to capture - # it and then send it to the frontend - with StandardOutput() as stdout: - self._on_msg(msg) - except Exception as e: - # TODO: isn't this cutting out info needed to understand what's gone wrong? - # Since it's only going to the js console, maybe we could just show everything - # (error = traceback.format_exc() or something like that)? Separately we do need a mechanism - # to report reasonable messages to users, though. - frame =traceback.extract_tb(sys.exc_info()[2])[-2] - fname,lineno,fn,text = frame - error_kwargs = dict(type=type(e).__name__, fn=fn, fname=fname, - line=lineno, error=str(e)) - error = '{fname} {fn} L{line}\n\t{type}: {error}'.format(**error_kwargs) - if stdout: - stdout = '\n\t'+'\n\t'.join(stdout) - error = '\n'.join([stdout, error]) - reply = {'msg_type': "Error", 'traceback': error} - else: - stdout = '\n\t'+'\n\t'.join(stdout) if stdout else '' - reply = {'msg_type': "Ready", 'content': stdout} - - # Returning the comm_id in an ACK message ensures that - # the correct comms handle is unblocked - if comm_id: - reply['comm_id'] = comm_id - self.send(json.dumps(reply)) diff --git a/parambokeh/view.py b/parambokeh/view.py index ecdc8a0..ff3699c 100644 --- a/parambokeh/view.py +++ b/parambokeh/view.py @@ -15,9 +15,7 @@ def render_function(obj, view): renderer = renderer.instance(mode='server') plot = renderer.get_plot(obj, doc=view._document) if view._notebook: - from holoviews.plotting.comms import JupyterComm - comm = JupyterComm(plot, view._comm_target) - plot.comm = comm + plot.comm = view._comm plot.document = view._document return plot.state return obj @@ -33,13 +31,13 @@ class _View(param.Parameter): and may optionally supply the desired size of the viewport. """ - __slots__ = ['callbacks', 'renderer', '_comm_target', '_document', '_notebook'] + __slots__ = ['callbacks', 'renderer', '_comm', '_document', '_notebook'] def __init__(self, default=None, callback=None, renderer=None, **kwargs): self.callbacks = {} self.renderer = (render_function if renderer is None else renderer) super(_View, self).__init__(default, **kwargs) - self._comm_target = None + self._comm = None self._document = None self._notebook = False